Test message 123!
This commit is contained in:
1 parent
4cf726709f
commit
d4fdc9890a
52 files changed
+5537
-277
No files matched your search
@@ -0,0 +1,217 @@
|
||||
use anyhow::Result;
|
||||
use crate::config::AppConfig;
|
||||
use crate::status::RepoSummary;
|
||||
use crate::todos::{self, TodoSource};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use super::state::HubState;
|
||||
|
||||
// ── sync flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn run_sync_flow(repos: &[RepoSummary], targets: &[usize]) -> Result<()> {
|
||||
let sel: Vec<&RepoSummary> = targets.iter().filter_map(|&i| repos.get(i)).collect();
|
||||
if sel.is_empty() { return Ok(()); }
|
||||
println!("\n Syncing {} repo(s):", sel.len());
|
||||
for r in &sel { println!(" · {}", r.name); }
|
||||
println!();
|
||||
|
||||
let mode = match inquire::Select::new("Action:", vec!["pull", "commit + push", "push only"])
|
||||
.with_page_size(3).prompt_skippable()? { Some(m) => m, None => return Ok(()) };
|
||||
|
||||
let global_msg: Option<String> = if mode == "commit + push" {
|
||||
let m = inquire::Select::new("Commit message:", vec!["same for all", "per repo"]).with_page_size(2).prompt()?;
|
||||
if m == "same for all" { Some(inquire::Text::new("Message:").with_default("chore: update").prompt()?) } else { None }
|
||||
} else { None };
|
||||
|
||||
for r in &sel {
|
||||
println!("\n ── {} ──", r.name);
|
||||
match mode {
|
||||
"pull" => git_run(&r.path, &["pull"]),
|
||||
"commit + push" => {
|
||||
let msg = match &global_msg { Some(m) => m.clone(), None => inquire::Text::new(&format!("Message for '{}':", r.name)).with_default("chore: update").prompt()? };
|
||||
git_run(&r.path, &["add", "-A"]);
|
||||
git_run(&r.path, &["commit", "-m", &msg]);
|
||||
git_run(&r.path, &["push"]);
|
||||
}
|
||||
_ => git_run(&r.path, &["push"]),
|
||||
}
|
||||
}
|
||||
println!("\n ✓ Done. Press Enter...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub use crate::util::git::git_run;
|
||||
|
||||
// ── API refresh ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn exec_api_refresh(state: &mut HubState, cfg: &AppConfig) {
|
||||
use crate::auth;
|
||||
use crate::providers;
|
||||
|
||||
let accounts = match auth::load_accounts(cfg) {
|
||||
Ok(a) => a,
|
||||
Err(_) => { state.status_msg = Some("No accounts configured — fxg auth add".into()); return; }
|
||||
};
|
||||
if accounts.is_empty() {
|
||||
state.status_msg = Some("No accounts configured — fxg auth add".into());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cache = crate::cache::load();
|
||||
let mut fetched_meta = 0usize;
|
||||
let mut fetched_prs = 0usize;
|
||||
let mut fetched_iss = 0usize;
|
||||
|
||||
for account in &accounts {
|
||||
let provider = match providers::make_provider(account) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
state.status_msg = Some(format!("Auth error ({}): {e}", account.provider));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for r in &mut state.repos {
|
||||
let is_mine = r.owner.as_deref() == Some(account.username.as_str());
|
||||
if !is_mine { continue; }
|
||||
|
||||
if let Ok(meta) = provider.repo_meta(&r.name).await {
|
||||
let cache_meta = crate::cache::RepoMeta {
|
||||
stars: Some(meta.stars),
|
||||
open_issues: Some(meta.open_issues),
|
||||
open_prs: Some(meta.open_prs),
|
||||
visibility: r.visibility.clone(),
|
||||
ci_ok: None,
|
||||
fetched_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
r.stars = Some(meta.stars);
|
||||
r.open_issues = Some(meta.open_issues);
|
||||
r.open_prs = Some(meta.open_prs);
|
||||
cache.set_meta(&r.name, cache_meta);
|
||||
fetched_meta += 1;
|
||||
}
|
||||
|
||||
if let Ok(prs) = provider.list_pull_requests(&r.name).await {
|
||||
fetched_prs += prs.len();
|
||||
cache.set_prs(&r.name, prs);
|
||||
}
|
||||
|
||||
if let Ok(issues) = provider.list_issues(&r.name).await {
|
||||
fetched_iss += issues.len();
|
||||
cache.set_issues(&r.name, issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = crate::cache::save(&cache);
|
||||
state.prs = cache.all_prs().into_iter().cloned().collect();
|
||||
state.issues = cache.all_issues().into_iter().cloned().collect();
|
||||
|
||||
state.status_msg = Some(format!(
|
||||
"Refreshed {} repos: {} PRs, {} issues",
|
||||
fetched_meta, fetched_prs, fetched_iss
|
||||
));
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
pub fn refresh_repos(state: &mut HubState) {
|
||||
if let Ok(fresh) = crate::status::scan_workspace(&state.workspace_root, &[]) {
|
||||
state.repos = fresh;
|
||||
for r in &mut state.repos {
|
||||
r.bookmarked = state.bookmarks.contains(&r.path);
|
||||
}
|
||||
state.focused_cache.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
}
|
||||
|
||||
// ── TODO scanner ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn scan_todos_from_repos(state: &mut HubState) -> usize {
|
||||
let mut added = 0usize;
|
||||
let existing_titles: HashSet<String> =
|
||||
state.todos.iter().map(|t| t.title.clone()).collect();
|
||||
|
||||
for r in &state.repos {
|
||||
for pattern in &["TODO", "FIXME", "HACK"] {
|
||||
let results = crate::legacy::refactor::find_in_dir(pattern, &r.path, &r.name);
|
||||
for fr in results.iter().take(50) {
|
||||
let short_file = std::path::Path::new(&fr.file)
|
||||
.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| fr.file.clone());
|
||||
let title = format!("{}:{} {}", short_file, fr.line,
|
||||
fr.text.trim().chars().take(60).collect::<String>());
|
||||
if existing_titles.contains(&title) { continue; }
|
||||
let id = todos::next_id(&state.todos);
|
||||
let mut t = todos::Todo::new_scan(id, title, r.name.clone());
|
||||
t.source = TodoSource::CodeScan;
|
||||
state.todos.push(t);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added > 0 { let _ = todos::save(&state.todos); }
|
||||
added
|
||||
}
|
||||
|
||||
// ── clipboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub use crate::util::clipboard::copy_to_clipboard;
|
||||
|
||||
pub fn get_remote_url(path: &PathBuf) -> Option<String> {
|
||||
let repo = git2::Repository::open(path).ok()?;
|
||||
let remote = repo.find_remote("origin").ok()?;
|
||||
Some(remote.url()?.to_string())
|
||||
}
|
||||
|
||||
pub use crate::util::url::to_web_url;
|
||||
|
||||
// ── bookmarks persistence ─────────────────────────────────────────────────────
|
||||
|
||||
fn bookmarks_path() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_default().join(".fxg").join("bookmarks.toml")
|
||||
}
|
||||
|
||||
pub fn load_bookmarks() -> HashSet<PathBuf> {
|
||||
let p = bookmarks_path();
|
||||
if !p.exists() { return HashSet::new(); }
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Bk { #[serde(default)] paths: Vec<String> }
|
||||
toml::from_str::<Bk>(&std::fs::read_to_string(p).unwrap_or_default())
|
||||
.map(|b| b.paths.into_iter().map(PathBuf::from).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save_bookmarks(bk: &HashSet<PathBuf>) {
|
||||
#[derive(serde::Serialize)]
|
||||
struct Bk { paths: Vec<String> }
|
||||
let p = bookmarks_path();
|
||||
if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }
|
||||
let data = Bk { paths: bk.iter().map(|p| p.to_string_lossy().to_string()).collect() };
|
||||
if let Ok(s) = toml::to_string_pretty(&data) { let _ = std::fs::write(p, s); }
|
||||
}
|
||||
|
||||
// ── pins persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
fn pins_path() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_default().join(".fxg").join("pins.toml")
|
||||
}
|
||||
|
||||
pub fn load_pins() -> HashSet<PathBuf> {
|
||||
let p = pins_path();
|
||||
if !p.exists() { return HashSet::new(); }
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Pins { #[serde(default)] paths: Vec<String> }
|
||||
toml::from_str::<Pins>(&std::fs::read_to_string(p).unwrap_or_default())
|
||||
.map(|b| b.paths.into_iter().map(PathBuf::from).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save_pins(pins: &HashSet<PathBuf>) {
|
||||
#[derive(serde::Serialize)]
|
||||
struct Pins { paths: Vec<String> }
|
||||
let p = pins_path();
|
||||
if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }
|
||||
let data = Pins { paths: pins.iter().map(|p| p.to_string_lossy().to_string()).collect() };
|
||||
if let Ok(s) = toml::to_string_pretty(&data) { let _ = std::fs::write(p, s); }
|
||||
}
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers, MouseEventKind};
|
||||
use super::actions::{copy_to_clipboard, get_remote_url, scan_todos_from_repos, save_bookmarks, save_pins, to_web_url};
|
||||
use super::modals::{handle_modal_key, process_modal_confirm};
|
||||
use super::render::{render_hub, HELP_LINES};
|
||||
use super::state::{
|
||||
ConfirmTag, DisplayRow, HubState, Modal, ModalAction, PendingAction,
|
||||
PhaseResult, SelectTag, Tab, TextTag,
|
||||
};
|
||||
use crate::todos;
|
||||
|
||||
pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
|
||||
let mut terminal = crate::tui::setup()?;
|
||||
let result = (|| -> Result<PhaseResult> {
|
||||
loop {
|
||||
state.poll_incoming();
|
||||
state.poll_diff();
|
||||
terminal.draw(|f| render_hub(f, state))?;
|
||||
state.update_focused_extra();
|
||||
|
||||
let timeout_ms = if state.scan_rx.is_some() { 50 } else { 250 };
|
||||
let event = crate::tui::next_event(timeout_ms)?;
|
||||
|
||||
if let Some(Event::Resize(_, _)) = &event {
|
||||
let _ = terminal.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(Event::Mouse(m)) = &event {
|
||||
match m.kind {
|
||||
MouseEventKind::ScrollDown => { state.scroll_down(); }
|
||||
MouseEventKind::ScrollUp => { state.scroll_up(); }
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let key = match event { Some(Event::Key(k)) => k, _ => continue };
|
||||
{
|
||||
if state.modal.is_some() {
|
||||
match handle_modal_key(&mut state.modal, key.code) {
|
||||
ModalAction::Confirm => process_modal_confirm(state),
|
||||
ModalAction::Cancel => state.modal = Modal::None,
|
||||
ModalAction::Pending => {}
|
||||
}
|
||||
if let Some(url) = state.pending_clone.take() {
|
||||
return Ok(PhaseResult::ExecClone(url));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if state.confirming.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Char('y') => { state.exec_action = true; }
|
||||
KeyCode::Esc => { state.confirming = None; }
|
||||
_ => {}
|
||||
}
|
||||
if state.exec_action {
|
||||
let r = match state.confirming.as_ref().unwrap() {
|
||||
PendingAction::Sync => PhaseResult::ExecSync,
|
||||
PendingAction::Release => PhaseResult::ExecRelease,
|
||||
};
|
||||
return Ok(r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if state.filtering {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
state.filtering = false;
|
||||
state.filter_text.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Enter => { state.filtering = false; }
|
||||
KeyCode::Backspace => {
|
||||
state.filter_text.pop();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
state.filter_text.push(c);
|
||||
state.rebuild_display();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
state.status_msg = None;
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
if !state.filter_text.is_empty() {
|
||||
state.filter_text.clear(); state.rebuild_display();
|
||||
} else {
|
||||
state.quit = true; return Ok(PhaseResult::None);
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => state.scroll_up(),
|
||||
KeyCode::Down | KeyCode::Char('j') => state.scroll_down(),
|
||||
KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let visible: Vec<usize> = state.display.iter().filter_map(|row| {
|
||||
if let DisplayRow::Repo { repo_idx } = row { Some(*repo_idx) } else { None }
|
||||
}).collect();
|
||||
let all_sel = visible.iter().all(|ri| state.selected.contains(ri));
|
||||
if all_sel {
|
||||
state.selected.clear();
|
||||
state.status_msg = Some("All deselected".into());
|
||||
} else {
|
||||
for ri in visible { state.selected.insert(ri); }
|
||||
state.status_msg = Some(format!("Selected {} repos", state.selected.len()));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('*') => {
|
||||
state.modal = Modal::Select {
|
||||
title: "Select by condition".into(),
|
||||
options: vec![
|
||||
"All visible".into(),
|
||||
"Dirty (uncommitted changes)".into(),
|
||||
"Behind remote".into(),
|
||||
"Ahead of remote".into(),
|
||||
"No license".into(),
|
||||
"Language: Rust".into(),
|
||||
"Language: TypeScript".into(),
|
||||
"Language: Python".into(),
|
||||
"Language: Go".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::SelectByCondition,
|
||||
};
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
match state.tab {
|
||||
Tab::Todos => {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
state.todos[ti].status = state.todos[ti].status.cycle();
|
||||
state.todos[ti].touch();
|
||||
let _ = todos::save(&state.todos);
|
||||
}
|
||||
}
|
||||
_ => state.toggle_select(),
|
||||
}
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right => {
|
||||
state.tab = match state.tab {
|
||||
Tab::Repos => Tab::Projects,
|
||||
Tab::Projects => Tab::Todos,
|
||||
Tab::Todos => Tab::PRs,
|
||||
Tab::PRs => Tab::Issues,
|
||||
Tab::Issues => Tab::Repos,
|
||||
};
|
||||
state.cursor = 0; state.offset = 0; state.selected.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Left => {
|
||||
state.tab = match state.tab {
|
||||
Tab::Repos => Tab::Issues,
|
||||
Tab::Projects => Tab::Repos,
|
||||
Tab::Todos => Tab::Projects,
|
||||
Tab::PRs => Tab::Todos,
|
||||
Tab::Issues => Tab::PRs,
|
||||
};
|
||||
state.cursor = 0; state.offset = 0; state.selected.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
match state.tab {
|
||||
Tab::Repos | Tab::Projects => {
|
||||
if state.cur_repo_idx().is_some() || state.cur_proj_idx().is_some() {
|
||||
state.open_detail = true;
|
||||
return Ok(PhaseResult::OpenDetail);
|
||||
}
|
||||
}
|
||||
Tab::PRs => {
|
||||
let di = state.cur_di();
|
||||
if let Some(di) = di {
|
||||
if let DisplayRow::Pr { idx } = &state.display[di] {
|
||||
let url = state.prs[*idx].url.clone();
|
||||
if !url.is_empty() {
|
||||
let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
|
||||
state.status_msg = Some("Opening PR in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Issues => {
|
||||
let di = state.cur_di();
|
||||
if let Some(di) = di {
|
||||
if let DisplayRow::Issue { idx } = &state.display[di] {
|
||||
let url = state.issues[*idx].url.clone();
|
||||
if !url.is_empty() {
|
||||
let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
|
||||
state.status_msg = Some("Opening issue in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Todos => {}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Sync); }
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Release); }
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let cur = state.repos[ri].name.clone();
|
||||
state.modal = Modal::Text { title: "Set label".into(), prompt: "Display name (blank = reset):".into(), value: cur, tag: TextTag::Label(ri) };
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
let mut opts = vec!["Create project".to_string()];
|
||||
if !state.projects.is_empty() {
|
||||
opts.push("Assign repos".into());
|
||||
opts.push("Rename / recolor".into());
|
||||
opts.push("Delete project".into());
|
||||
}
|
||||
state.modal = Modal::Select { title: "Projects".into(), options: opts, cursor: 0, tag: SelectTag::ProjMenu };
|
||||
}
|
||||
KeyCode::Char('+') => {
|
||||
state.modal = Modal::Text { title: "New todo — Title".into(), prompt: "Title:".into(), value: String::new(), tag: TextTag::TodoTitle };
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let cur = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Text { title: "Edit todo — Title".into(), prompt: "Title:".into(), value: cur, tag: TextTag::TodoEditTitle(ti) };
|
||||
}
|
||||
}
|
||||
KeyCode::Delete | KeyCode::Char('D') if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||
if state.tab == Tab::Todos {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let title = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete '{}'?", title), tag: ConfirmTag::DeleteTodo(ti) };
|
||||
}
|
||||
} else if state.tab == Tab::Projects {
|
||||
if let Some(pi) = state.cur_proj_idx() {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if state.tab == Tab::Todos {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let title = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete '{}'?", title), tag: ConfirmTag::DeleteTodo(ti) };
|
||||
}
|
||||
} else if state.tab == Tab::Projects {
|
||||
if let Some(pi) = state.cur_proj_idx() {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => { state.filter_dirty = !state.filter_dirty; state.rebuild_display(); }
|
||||
KeyCode::Char('b') => { state.filter_behind = !state.filter_behind; state.rebuild_display(); }
|
||||
KeyCode::Char('f') => { state.filter_ahead = !state.filter_ahead; state.rebuild_display(); }
|
||||
KeyCode::Char('/') => {
|
||||
state.filtering = true;
|
||||
state.filter_text.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
state.modal = Modal::Info {
|
||||
title: "Keybindings".into(),
|
||||
lines: HELP_LINES.iter().map(|s| s.to_string()).collect(),
|
||||
scroll: 0,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
state.sort_mode = state.sort_mode.next();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('o') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
let editor = state.editor.clone();
|
||||
let _ = std::process::Command::new(&editor).arg(&path).spawn();
|
||||
state.status_msg = Some(format!("Opened in {}", editor));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('y') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.to_string_lossy().to_string();
|
||||
let cmd = state.clipboard_cmd.clone();
|
||||
if copy_to_clipboard(&path, &cmd) {
|
||||
state.status_msg = Some("Path copied!".into());
|
||||
} else {
|
||||
state.status_msg = Some("No clipboard cmd".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
let cmd = state.clipboard_cmd.clone();
|
||||
if let Some(url) = get_remote_url(&path) {
|
||||
if copy_to_clipboard(&url, &cmd) {
|
||||
state.status_msg = Some("URL copied!".into());
|
||||
} else {
|
||||
state.status_msg = Some(format!(" {}", url));
|
||||
}
|
||||
} else {
|
||||
state.status_msg = Some("No remote origin".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('B') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if state.bookmarks.contains(&path) {
|
||||
state.bookmarks.remove(&path);
|
||||
state.repos[ri].bookmarked = false;
|
||||
state.status_msg = Some("Bookmark removed".into());
|
||||
} else {
|
||||
state.bookmarks.insert(path.clone());
|
||||
state.repos[ri].bookmarked = true;
|
||||
state.status_msg = Some("Bookmarked!".into());
|
||||
}
|
||||
save_bookmarks(&state.bookmarks);
|
||||
state.rebuild_display();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('T') => {
|
||||
let count = scan_todos_from_repos(state);
|
||||
state.status_msg = Some(format!("Scanned: {} new code todos", count));
|
||||
state.tab = Tab::Todos;
|
||||
state.show_code_todos = true;
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('X') => {
|
||||
if state.tab == Tab::Todos {
|
||||
state.show_code_todos = !state.show_code_todos;
|
||||
state.rebuild_display();
|
||||
state.status_msg = Some(if state.show_code_todos {
|
||||
"Showing code scan todos".into()
|
||||
} else {
|
||||
"Showing manual todos only".into()
|
||||
});
|
||||
}
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let gitignore = state.repos[ri].path.join(".gitignore");
|
||||
let editor = state.editor.clone();
|
||||
let _ = std::process::Command::new(&editor).arg(&gitignore).spawn();
|
||||
state.status_msg = Some("Opening .gitignore...".into());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('O') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
let _ = std::process::Command::new("xdg-open").arg(&path).spawn();
|
||||
state.status_msg = Some("Opening in file manager...".into());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('W') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if let Some(url) = get_remote_url(&path) {
|
||||
let web = to_web_url(&url);
|
||||
let _ = std::process::Command::new("xdg-open").arg(&web).spawn();
|
||||
state.status_msg = Some("Opening in browser...".into());
|
||||
} else {
|
||||
state.status_msg = Some("No remote origin".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('P') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if state.pins.contains(&path) {
|
||||
state.pins.remove(&path);
|
||||
state.status_msg = Some("Unpinned".into());
|
||||
} else {
|
||||
state.pins.insert(path.clone());
|
||||
state.status_msg = Some("Pinned to top! ◆".into());
|
||||
}
|
||||
save_pins(&state.pins);
|
||||
state.rebuild_display();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
state.filter_archived = !state.filter_archived;
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('C') => {
|
||||
let prompt = format!("Git URL (clones into {}):", state.workspace_root.display());
|
||||
state.modal = Modal::Text {
|
||||
title: "Clone repository".into(),
|
||||
prompt,
|
||||
value: String::new(),
|
||||
tag: TextTag::CloneUrl,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('I') => {
|
||||
let prompt = format!("Folder name (created in {}):", state.workspace_root.display());
|
||||
state.modal = Modal::Text {
|
||||
title: "Init new repository".into(),
|
||||
prompt,
|
||||
value: String::new(),
|
||||
tag: TextTag::InitName,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('L') => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Link existing folder".into(),
|
||||
prompt: "Absolute path to folder:".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::LinkPath,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('R') => {
|
||||
state.status_msg = Some("Fetching from GitHub API...".into());
|
||||
return Ok(PhaseResult::ExecRefresh);
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
crate::tui::teardown(terminal)?;
|
||||
result
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
mod actions;
|
||||
mod keys;
|
||||
mod modals;
|
||||
mod render;
|
||||
mod right_panel;
|
||||
mod state;
|
||||
mod tabs;
|
||||
|
||||
use actions::{exec_api_refresh, load_bookmarks, load_pins, refresh_repos, run_sync_flow};
|
||||
use keys::tui_phase;
|
||||
use state::{HubState, Modal, PhaseResult, SortMode, Tab};
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::config::AppConfig;
|
||||
use crate::projects;
|
||||
use crate::providers::{IssueDetail, PrDetail};
|
||||
use crate::status::RepoSummary;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Cell, Paragraph, Row, Table},
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub async fn run_overview_tui(
|
||||
mut repos: Vec<RepoSummary>,
|
||||
scan_rx: Option<std::sync::mpsc::Receiver<RepoSummary>>,
|
||||
cfg: &AppConfig,
|
||||
) -> Result<()> {
|
||||
let workspace_root = cfg.workspace_root.clone();
|
||||
let projects = projects::load();
|
||||
let project_map = projects::index_by_repo(&projects);
|
||||
let todos = crate::todos::load_manual();
|
||||
let bookmarks = load_bookmarks();
|
||||
let pins = load_pins();
|
||||
|
||||
let meta_cache = crate::cache::load();
|
||||
for r in &mut repos {
|
||||
if let Some(entry) = meta_cache.get(&r.name) {
|
||||
let m = &entry.meta;
|
||||
r.stars = m.stars;
|
||||
r.open_issues = m.open_issues;
|
||||
r.open_prs = m.open_prs;
|
||||
if r.visibility.is_none() { r.visibility = m.visibility.clone(); }
|
||||
}
|
||||
}
|
||||
let cached_prs: Vec<PrDetail> = meta_cache.all_prs().into_iter().cloned().collect();
|
||||
let cached_issues: Vec<IssueDetail> = meta_cache.all_issues().into_iter().cloned().collect();
|
||||
|
||||
let debug_mode = crate::debug::is_enabled();
|
||||
let debug_log = crate::debug::take();
|
||||
|
||||
let mut state = HubState {
|
||||
repos, projects, project_map, todos,
|
||||
prs: cached_prs,
|
||||
issues: cached_issues,
|
||||
tab: Tab::Repos,
|
||||
display: Vec::new(), repo_pos: Vec::new(),
|
||||
cursor: 0, offset: 0, visible_rows: 0,
|
||||
selected: HashSet::new(),
|
||||
filter_dirty: false, filter_behind: false, filter_ahead: false,
|
||||
filter_archived: false,
|
||||
filter_text: String::new(), filtering: false,
|
||||
sort_mode: SortMode::Default,
|
||||
bookmarks,
|
||||
pins,
|
||||
status_msg: None,
|
||||
pending_clone: None,
|
||||
modal: Modal::None, confirming: None, exec_action: false,
|
||||
open_detail: false, quit: false,
|
||||
workspace_root,
|
||||
focused_cache: HashMap::new(),
|
||||
editor: cfg.editor.clone(),
|
||||
clipboard_cmd: cfg.clipboard_cmd.clone(),
|
||||
show_code_todos: false,
|
||||
debug_mode,
|
||||
debug_log,
|
||||
scan_rx,
|
||||
meta_cache,
|
||||
diff_rx: None,
|
||||
};
|
||||
for r in &mut state.repos {
|
||||
r.bookmarked = state.bookmarks.contains(&r.path);
|
||||
}
|
||||
state.rebuild_display();
|
||||
|
||||
loop {
|
||||
let phase_result = tui_phase(&mut state)?;
|
||||
|
||||
if state.quit { break; }
|
||||
|
||||
match phase_result {
|
||||
PhaseResult::OpenDetail => {
|
||||
state.open_detail = false;
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let _ = crate::status::detail::run(state.repos[ri].clone(), cfg).await;
|
||||
} else if let Some(pi) = state.cur_proj_idx() {
|
||||
let repo_indices = state.project_repos(pi);
|
||||
let proj_repos: Vec<RepoSummary> =
|
||||
repo_indices.iter().filter_map(|&ri| state.repos.get(ri)).cloned().collect();
|
||||
run_project_detail(&state.projects[pi], proj_repos, cfg).await?;
|
||||
}
|
||||
}
|
||||
PhaseResult::ExecSync => {
|
||||
state.exec_action = false;
|
||||
state.confirming = None;
|
||||
let targets = state.action_targets();
|
||||
run_sync_flow(&state.repos, &targets).await?;
|
||||
state.selected.clear();
|
||||
refresh_repos(&mut state);
|
||||
}
|
||||
PhaseResult::ExecRelease => {
|
||||
state.exec_action = false;
|
||||
state.confirming = None;
|
||||
println!("\n Use [Enter] → repo detail → Actions → Release");
|
||||
println!(" Press Enter...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
}
|
||||
PhaseResult::ExecClone(url) => {
|
||||
println!("\n Cloning: {}", url);
|
||||
println!(" Into: {}\n", state.workspace_root.display());
|
||||
let _ = std::process::Command::new("git")
|
||||
.arg("clone").arg(&url)
|
||||
.current_dir(&state.workspace_root)
|
||||
.status();
|
||||
println!("\n Done. Press Enter...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
refresh_repos(&mut state);
|
||||
}
|
||||
PhaseResult::ExecRefresh => {
|
||||
exec_api_refresh(&mut state, cfg).await;
|
||||
}
|
||||
PhaseResult::None => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_project_detail(proj: &crate::projects::Project, repos: Vec<RepoSummary>, _cfg: &AppConfig) -> Result<()> {
|
||||
use tabs::age_color;
|
||||
|
||||
let (pr, pg, pb) = crate::projects::hex_to_rgb(&proj.color);
|
||||
let proj_color = ratatui::style::Color::Rgb(pr, pg, pb);
|
||||
|
||||
let mut terminal = crate::tui::setup()?;
|
||||
let result = (|| -> Result<()> {
|
||||
loop {
|
||||
let proj_name = proj.name.clone();
|
||||
let proj_hex = proj.color.clone();
|
||||
let repos_snap = repos.clone();
|
||||
terminal.draw(|f| {
|
||||
let area = f.area();
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1)])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {} ", proj_name), Style::default().fg(proj_color).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!("({})", proj_hex), Style::default().fg(proj_color)),
|
||||
Span::styled(format!(" {} repos", repos_snap.len()), Style::default().fg(Color::DarkGray)),
|
||||
])), layout[0]);
|
||||
|
||||
let header = Row::new(vec!["NAME", "BRANCH", "STATUS", "AGE", "LAST COMMIT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
let rows: Vec<Row> = repos_snap.iter().map(|r| {
|
||||
let status: String = if r.is_dirty { "⚠ dirty".into() } else if r.behind > 0 { format!("↓{}", r.behind) } else { "✓".into() };
|
||||
let name_col = if r.is_dirty || r.ahead > 0 || r.behind > 0 { Color::Yellow } else { Color::White };
|
||||
Row::new(vec![
|
||||
Cell::from(r.name.clone()).style(Style::default().fg(name_col)),
|
||||
Cell::from(r.branch.chars().take(12).collect::<String>()),
|
||||
Cell::from(status),
|
||||
Cell::from(r.last_commit_age.clone()).style(Style::default().fg(age_color(r.last_commit_ts))),
|
||||
Cell::from(r.last_commit_msg.chars().take(40).collect::<String>()).style(Style::default().fg(Color::DarkGray)),
|
||||
])
|
||||
}).collect();
|
||||
f.render_widget(
|
||||
Table::new(rows, [Constraint::Fill(1), Constraint::Length(13), Constraint::Length(10), Constraint::Length(10), Constraint::Fill(2)])
|
||||
.header(header),
|
||||
layout[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(" [q/Esc] back").style(Style::default().fg(Color::DarkGray)),
|
||||
layout[2],
|
||||
);
|
||||
})?;
|
||||
|
||||
if let Some(Event::Key(k)) = crate::tui::next_event(250)? {
|
||||
match k.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
crate::tui::teardown(terminal)?;
|
||||
result
|
||||
}
|
||||
|
||||
pub fn run_detail_tui(detail: crate::status::RepoDetail) -> Result<()> {
|
||||
use crossterm::event::Event;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
let mut quit = false;
|
||||
crate::tui::run_loop(|terminal| {
|
||||
terminal.draw(|f| {
|
||||
let area = f.area();
|
||||
let blk = Block::default().title(format!(" {} · {} ", detail.summary.name, detail.summary.branch)).borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
let lines = vec![
|
||||
Line::from(format!(" Branch: {} +{} ahead -{} behind dirty: {}", detail.summary.branch, detail.summary.ahead, detail.summary.behind, detail.summary.is_dirty)),
|
||||
Line::from(format!(" Path: {}", detail.summary.path.display())),
|
||||
Line::from(""),
|
||||
Line::from(" Modified files:"),
|
||||
];
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
})?;
|
||||
if let Some(Event::Key(key)) = crate::tui::next_event(250)? {
|
||||
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { quit = true; }
|
||||
}
|
||||
Ok(quit)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
use crate::projects;
|
||||
use crate::todos::{self, Todo, TodoStatus};
|
||||
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::{
|
||||
ConfirmTag, HubState, Modal, ModalAction, MultiTag,
|
||||
ProjAction, SelectTag, TextTag,
|
||||
};
|
||||
|
||||
pub fn render_modal(f: &mut Frame, modal: &Modal, area: Rect) {
|
||||
match modal {
|
||||
Modal::Text { title, prompt, value, .. } => {
|
||||
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 lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(format!(" {}", prompt), Style::default().fg(Color::White))),
|
||||
Line::from(""),
|
||||
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::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn handle_modal_key(modal: &mut Modal, code: KeyCode) -> ModalAction {
|
||||
match modal {
|
||||
Modal::Text { value, .. } => match code {
|
||||
KeyCode::Char(c) => { 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::None => ModalAction::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_modal_confirm(state: &mut HubState) {
|
||||
let modal = std::mem::replace(&mut state.modal, Modal::None);
|
||||
match modal {
|
||||
Modal::Text { value, tag: TextTag::Label(ri), .. } => {
|
||||
let display = if value.trim().is_empty() {
|
||||
state.repos[ri].path.file_name().unwrap_or_default().to_string_lossy().to_string()
|
||||
} else { value.clone() };
|
||||
state.repos[ri].name = display.clone();
|
||||
let _ = crate::labels::save_label(&state.repos[ri].path.clone(), &value, &state.workspace_root);
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::ProjName, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.modal = Modal::Text {
|
||||
title: "Create project — Color".into(),
|
||||
prompt: "Hex color (#rrggbb):".into(),
|
||||
value: "#3498db".into(),
|
||||
tag: TextTag::ProjColor(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
Modal::Text { value: color, tag: TextTag::ProjColor(name), .. } => {
|
||||
let color = if color.starts_with('#') && color.len() == 7 { color } else { "#3498db".into() };
|
||||
state.projects.push(crate::projects::Project { name, color, repos: vec![] });
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::ProjRenameName(pi), .. } => {
|
||||
let cur_color = state.projects[pi].color.clone();
|
||||
state.modal = Modal::Text {
|
||||
title: format!("Rename '{}' — Color", state.projects[pi].name),
|
||||
prompt: "New color (#rrggbb):".into(),
|
||||
value: cur_color,
|
||||
tag: TextTag::ProjRenameColor(pi, value),
|
||||
};
|
||||
}
|
||||
Modal::Text { value: color, tag: TextTag::ProjRenameColor(pi, name), .. } => {
|
||||
let color = if color.starts_with('#') && color.len() == 7 { color } else { state.projects[pi].color.clone() };
|
||||
state.projects[pi].name = name;
|
||||
state.projects[pi].color = color;
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::ProjMenu, .. } => {
|
||||
if state.projects.is_empty() && cursor == 0 {
|
||||
state.modal = Modal::Text { title: "Create project — Name".into(), prompt: "Project name:".into(), value: String::new(), tag: TextTag::ProjName };
|
||||
return;
|
||||
}
|
||||
match cursor {
|
||||
0 => {
|
||||
state.modal = Modal::Text { title: "Create project — Name".into(), prompt: "Project name:".into(), value: String::new(), tag: TextTag::ProjName };
|
||||
}
|
||||
1 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Assign repos to…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Assign) };
|
||||
}
|
||||
2 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Rename…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Rename) };
|
||||
}
|
||||
3 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Delete…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Delete) };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::ProjForAction(action), .. } => {
|
||||
let pi = cursor;
|
||||
match action {
|
||||
ProjAction::Assign => {
|
||||
let rel_paths: Vec<String> = state.repos.iter()
|
||||
.map(|r| projects::rel_key(&r.path, &state.workspace_root))
|
||||
.collect();
|
||||
let options: Vec<String> = state.repos.iter().zip(&rel_paths)
|
||||
.map(|(r, rel)| format!("{:<18} {}", r.name, rel))
|
||||
.collect();
|
||||
let checks: Vec<bool> = rel_paths.iter()
|
||||
.map(|rel| state.projects[pi].repos.contains(rel))
|
||||
.collect();
|
||||
state.modal = Modal::Multi { title: format!("Assign repos to '{}'", state.projects[pi].name), options, checks, cursor: 0, tag: MultiTag::ProjAssign(pi) };
|
||||
}
|
||||
ProjAction::Rename => {
|
||||
let cur_name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Text { title: "Rename project".into(), prompt: "New name:".into(), value: cur_name, tag: TextTag::ProjRenameName(pi) };
|
||||
}
|
||||
ProjAction::Delete => {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
}
|
||||
Modal::Multi { checks, tag: MultiTag::ProjAssign(pi), .. } => {
|
||||
let rel_paths: Vec<String> = state.repos.iter()
|
||||
.map(|r| projects::rel_key(&r.path, &state.workspace_root))
|
||||
.collect();
|
||||
state.projects[pi].repos = rel_paths.into_iter().zip(checks.iter()).filter(|(_, &c)| c).map(|(p, _)| p).collect();
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
}
|
||||
Modal::Confirm { tag: ConfirmTag::DeleteProject(pi), .. } => {
|
||||
state.projects.remove(pi);
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::TodoTitle, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.modal = Modal::Text { title: "New todo — Description".into(), prompt: "Description (optional):".into(), value: String::new(), tag: TextTag::TodoDesc(value) };
|
||||
}
|
||||
}
|
||||
Modal::Text { value: desc, tag: TextTag::TodoDesc(title), .. } => {
|
||||
let options = TodoStatus::all_labels().iter().map(|s| s.to_string()).collect();
|
||||
state.modal = Modal::Select { title: "New todo — Status".into(), options, cursor: 0, tag: SelectTag::TodoStatus { title, desc } };
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::TodoStatus { title, desc }, .. } => {
|
||||
let mut opts: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
opts.insert(0, "— no project —".into());
|
||||
state.modal = Modal::Select { title: "New todo — Project".into(), options: opts, cursor: 0, tag: SelectTag::TodoProject { title, desc, status_idx: cursor } };
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::TodoProject { title, desc, status_idx }, .. } => {
|
||||
let project = if cursor == 0 { None } else { Some(state.projects[cursor - 1].name.clone()) };
|
||||
let id = todos::next_id(&state.todos);
|
||||
let todo = Todo::new(id, title, desc, TodoStatus::from_idx(status_idx), project);
|
||||
state.todos.push(todo);
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::TodoEditTitle(ti), .. } => {
|
||||
state.todos[ti].title = value.clone();
|
||||
let cur_desc = state.todos[ti].description.clone();
|
||||
state.todos[ti].touch();
|
||||
state.modal = Modal::Text { title: "Edit todo — Description".into(), prompt: "Description:".into(), value: cur_desc, tag: TextTag::TodoEditDesc(ti, value) };
|
||||
}
|
||||
Modal::Text { value: desc, tag: TextTag::TodoEditDesc(ti, _), .. } => {
|
||||
state.todos[ti].description = desc;
|
||||
state.todos[ti].touch();
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::TodoChangeStatus(ti), .. } => {
|
||||
state.todos[ti].status = TodoStatus::from_idx(cursor);
|
||||
state.todos[ti].touch();
|
||||
let _ = todos::save(&state.todos);
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::SelectByCondition, .. } => {
|
||||
let repos = &state.repos;
|
||||
let candidates: Vec<usize> = (0..repos.len()).filter(|&ri| state.passes_filter(ri)).collect();
|
||||
state.selected.clear();
|
||||
match cursor {
|
||||
0 => { for ri in candidates { state.selected.insert(ri); } }
|
||||
1 => { for ri in candidates { if repos[ri].is_dirty { state.selected.insert(ri); } } }
|
||||
2 => { for ri in candidates { if repos[ri].behind > 0 { state.selected.insert(ri); } } }
|
||||
3 => { for ri in candidates { if repos[ri].ahead > 0 { state.selected.insert(ri); } } }
|
||||
4 => { for ri in candidates { if !repos[ri].license { state.selected.insert(ri); } } }
|
||||
5 => { for ri in candidates { if repos[ri].lang.as_deref() == Some("Rust") { state.selected.insert(ri); } } }
|
||||
6 => { for ri in candidates { if repos[ri].lang.as_deref() == Some("TypeScript") { state.selected.insert(ri); } } }
|
||||
7 => { for ri in candidates { if repos[ri].lang.as_deref() == Some("Python") { state.selected.insert(ri); } } }
|
||||
8 => { for ri in candidates { if repos[ri].lang.as_deref() == Some("Go") { state.selected.insert(ri); } } }
|
||||
_ => {}
|
||||
}
|
||||
state.status_msg = Some(format!("Selected {} repos", state.selected.len()));
|
||||
}
|
||||
|
||||
Modal::Confirm { tag: ConfirmTag::DeleteTodo(ti), .. } => {
|
||||
state.todos.remove(ti);
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::CloneUrl, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.pending_clone = Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::InitName, .. } => {
|
||||
let name = value.trim().to_string();
|
||||
if name.is_empty() { return; }
|
||||
let target = state.workspace_root.join(&name);
|
||||
if let Err(e) = std::fs::create_dir_all(&target) {
|
||||
state.status_msg = Some(format!("mkdir failed: {e}"));
|
||||
return;
|
||||
}
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("init").current_dir(&target).output();
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
super::actions::refresh_repos(state);
|
||||
state.status_msg = Some(format!("Initialized {name}"));
|
||||
}
|
||||
Ok(o) => {
|
||||
state.status_msg = Some(format!("git init failed: {}", String::from_utf8_lossy(&o.stderr).trim()));
|
||||
}
|
||||
Err(e) => { state.status_msg = Some(format!("git init error: {e}")); }
|
||||
}
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::LinkPath, .. } => {
|
||||
let src = std::path::PathBuf::from(value.trim());
|
||||
if !src.exists() {
|
||||
state.status_msg = Some("Path does not exist".into());
|
||||
return;
|
||||
}
|
||||
let name = src.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if name.is_empty() { state.status_msg = Some("Invalid path".into()); return; }
|
||||
let link = state.workspace_root.join(&name);
|
||||
if link.exists() {
|
||||
state.status_msg = Some(format!("{name} already exists in workspace"));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::os::unix::fs::symlink(&src, &link) {
|
||||
state.status_msg = Some(format!("symlink failed: {e}"));
|
||||
return;
|
||||
}
|
||||
// Ensure it's a git repo
|
||||
if !src.join(".git").exists() {
|
||||
let _ = std::process::Command::new("git").arg("init").current_dir(&src).output();
|
||||
}
|
||||
super::actions::refresh_repos(state);
|
||||
state.status_msg = Some(format!("Linked {name}"));
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use super::modals::render_modal;
|
||||
use super::right_panel::render_right;
|
||||
use super::state::{HubState, Modal, SortMode, Tab};
|
||||
use super::tabs::render_list;
|
||||
|
||||
pub static HELP_LINES: &[&str] = &[
|
||||
"── Navigation ───────────────────────────────",
|
||||
" ↑↓ / jk Move cursor up/down",
|
||||
" Tab Switch tab (Repos → Projects → Todos → PRs → Issues)",
|
||||
" Enter Open repo/project detail view",
|
||||
" q / Esc Quit (or clear active filter)",
|
||||
"",
|
||||
"── Filtering & Sorting ───────────────────────",
|
||||
" / Fuzzy filter repos (name, branch, owner, lang)",
|
||||
" S Cycle sort: Default → Name → Recent → Dirty↑ → Behind↑",
|
||||
" d Toggle: show only dirty repos",
|
||||
" b Toggle: show only behind repos",
|
||||
" f Toggle: show only ahead repos",
|
||||
" a Toggle: show only archived repos (no commits >90d)",
|
||||
"",
|
||||
"── Open / Navigate ───────────────────────────",
|
||||
" o Open repo in $EDITOR",
|
||||
" O Open repo in file manager (xdg-open)",
|
||||
" W Open repo in browser (GitHub/GitLab)",
|
||||
" y Copy repo path to clipboard",
|
||||
" u Copy remote URL to clipboard",
|
||||
" g Open .gitignore in $EDITOR",
|
||||
" C Clone a repository into workspace",
|
||||
"",
|
||||
"── Repo Actions ──────────────────────────────",
|
||||
" s Sync (pull / commit+push) selected repos",
|
||||
" r Create release tag",
|
||||
" B Toggle bookmark (★) — visible in table",
|
||||
" P Toggle pin (◆) — pinned repos shown at top",
|
||||
" n Rename/label repo display name",
|
||||
" Space Select repo for batch action",
|
||||
"",
|
||||
"── Projects & Todos ──────────────────────────",
|
||||
" p Project menu (create / assign / rename / delete)",
|
||||
" + New todo",
|
||||
" e Edit todo (Todos tab)",
|
||||
" Space (Todos tab) Cycle todo status",
|
||||
" D / Del Delete todo or project",
|
||||
" T Scan repos for TODO/FIXME/HACK → import to Todos",
|
||||
"",
|
||||
"── Help ──────────────────────────────────────",
|
||||
" ? Show this help",
|
||||
" Esc / q Close modal or quit",
|
||||
];
|
||||
|
||||
pub fn render_hub(f: &mut Frame, state: &mut HubState) {
|
||||
let area = f.area();
|
||||
let bottom_h: u16 = if state.debug_mode { 11 } else { 3 };
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(bottom_h)])
|
||||
.split(area);
|
||||
|
||||
let tab_span = |label: &str, on: bool| {
|
||||
if on {
|
||||
Span::styled(format!(" {} ", label), Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD))
|
||||
} else {
|
||||
Span::styled(format!(" {} ", label), Style::default().fg(Color::DarkGray))
|
||||
}
|
||||
};
|
||||
let n = state.repo_pos.len();
|
||||
let pos_str = format!(" {}/{}", if n == 0 { 0 } else { state.cursor + 1 }, n);
|
||||
|
||||
let behind_c = state.repos.iter().filter(|r| r.behind > 0).count();
|
||||
let dirty_c = state.repos.iter().filter(|r| r.is_dirty).count();
|
||||
let health_str = if behind_c > 0 || dirty_c > 0 {
|
||||
format!(" ↓{} ⚠{} ", behind_c, dirty_c)
|
||||
} else {
|
||||
" ✓ ".into()
|
||||
};
|
||||
let health_col = if behind_c > 0 { Color::Red } else if dirty_c > 0 { Color::Yellow } else { Color::Green };
|
||||
|
||||
let pr_badge = if !state.prs.is_empty() { format!("PRs({})", state.prs.len()) } else { "PRs".into() };
|
||||
let issue_badge = if !state.issues.is_empty() { format!("Issues({})", state.issues.len()) } else { "Issues".into() };
|
||||
|
||||
let mut top_spans = vec![
|
||||
Span::styled(" fxg ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
tab_span("Repos", state.tab == Tab::Repos),
|
||||
Span::raw(" "),
|
||||
tab_span("Projects", state.tab == Tab::Projects),
|
||||
Span::raw(" "),
|
||||
tab_span("Todos", state.tab == Tab::Todos),
|
||||
Span::raw(" "),
|
||||
tab_span(&pr_badge, state.tab == Tab::PRs),
|
||||
Span::raw(" "),
|
||||
tab_span(&issue_badge, state.tab == Tab::Issues),
|
||||
Span::raw(" "),
|
||||
];
|
||||
|
||||
// Active filter indicators — only shown when on, so they don't crowd the bar.
|
||||
let active_filter_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
if state.filter_dirty { top_spans.push(Span::styled("[d:dirty] ", active_filter_style)); }
|
||||
if state.filter_behind { top_spans.push(Span::styled("[b:behind] ", active_filter_style)); }
|
||||
if state.filter_ahead { top_spans.push(Span::styled("[f:ahead] ", active_filter_style)); }
|
||||
if state.filter_archived { top_spans.push(Span::styled("[a:archived] ", active_filter_style)); }
|
||||
|
||||
if state.sort_mode != SortMode::Default {
|
||||
top_spans.push(Span::styled(
|
||||
format!("[S:{}] ", state.sort_mode.label()),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
}
|
||||
if !state.filter_text.is_empty() {
|
||||
top_spans.push(Span::styled(
|
||||
format!("[/:{}_] ", state.filter_text),
|
||||
active_filter_style,
|
||||
));
|
||||
} else if state.filtering {
|
||||
top_spans.push(Span::styled("[/: _] ", active_filter_style));
|
||||
}
|
||||
|
||||
top_spans.push(Span::styled(health_str, Style::default().fg(health_col)));
|
||||
top_spans.push(Span::styled(pos_str, Style::default().fg(Color::DarkGray)));
|
||||
|
||||
if let Some(ref msg) = state.status_msg {
|
||||
top_spans.push(Span::styled(format!(" {}", msg), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(Line::from(top_spans)), outer[0]);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(73), Constraint::Percentage(27)])
|
||||
.split(outer[1]);
|
||||
|
||||
let left_blk = Block::default().borders(Borders::ALL);
|
||||
let left_inner = left_blk.inner(cols[0]);
|
||||
f.render_widget(left_blk, cols[0]);
|
||||
state.visible_rows = left_inner.height.saturating_sub(1) as usize;
|
||||
render_list(f, state, left_inner);
|
||||
render_right(f, state, cols[1]);
|
||||
|
||||
if state.debug_mode {
|
||||
let blk = Block::default().borders(Borders::TOP).title(" debug ")
|
||||
.border_style(Style::default().fg(Color::DarkGray));
|
||||
let inner = blk.inner(outer[2]);
|
||||
f.render_widget(blk, outer[2]);
|
||||
let h = inner.height as usize;
|
||||
let n = state.debug_log.len();
|
||||
let start = n.saturating_sub(h);
|
||||
let lines: Vec<Line> = state.debug_log[start..]
|
||||
.iter()
|
||||
.map(|s| Line::from(Span::styled(s.clone(), Style::default().fg(Color::DarkGray))))
|
||||
.collect();
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
} else {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let key = |k: &str| Span::styled(format!("[{}] ", k), dim);
|
||||
let sep = || Span::styled(" ", dim);
|
||||
|
||||
let hint_lines: Vec<Line> = if state.modal.is_some() {
|
||||
vec![
|
||||
Line::from(vec![key("↑↓/jk"), Span::styled("navigate ", dim), key("Enter"), Span::styled("confirm ", dim), key("Esc"), Span::styled("cancel", dim)]),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
]
|
||||
} else if state.filtering {
|
||||
vec![
|
||||
Line::from(vec![Span::styled(" type to filter", dim), sep(), key("Enter"), Span::styled("lock ", dim), key("Esc"), Span::styled("clear", dim)]),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
]
|
||||
} else if state.confirming.is_some() {
|
||||
vec![
|
||||
Line::from(vec![key("y"), Span::styled("confirm ", dim), key("Esc"), Span::styled("cancel", dim)]),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
]
|
||||
} else {
|
||||
match state.tab {
|
||||
Tab::PRs | Tab::Issues => vec![
|
||||
Line::from(vec![key("↑↓/jk"), Span::styled("navigate ", dim), key("Tab/←→"), Span::styled("switch tab ", dim), key("↵"), Span::styled("open in browser ", dim), key("R"), Span::styled("refresh API ", dim), key("?"), Span::styled("help ", dim), key("q"), Span::styled("quit", dim)]),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
],
|
||||
_ => vec![
|
||||
Line::from(vec![key("↑↓/jk"), Span::styled("navigate ", dim), key("Tab/←→"), Span::styled("tabs ", dim), key("/"), Span::styled("filter ", dim), key("d/b/f/a"), Span::styled("toggle ", dim), key("S"), Span::styled("sort ", dim), key("?"), Span::styled("help ", dim), key("q"), Span::styled("quit", dim)]),
|
||||
Line::from(vec![key("Space"), Span::styled("select ", dim), key("*"), Span::styled("select by ", dim), key("^A"), Span::styled("all/none ", dim), key("R"), Span::styled("API refresh ", dim), key("T"), Span::styled("scan TODOs ", dim), key("X"), Span::styled("code TODOs", dim)]),
|
||||
Line::from(vec![key("C"), Span::styled("clone ", dim), key("I"), Span::styled("init repo ", dim), key("L"), Span::styled("link folder", dim)]),
|
||||
],
|
||||
}
|
||||
};
|
||||
f.render_widget(Paragraph::new(hint_lines), outer[2]);
|
||||
}
|
||||
|
||||
if state.modal.is_some() {
|
||||
render_modal(f, &state.modal, area);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
use crate::status::format_age_pub;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use super::state::{DiffState, DisplayRow, HubState, PendingAction, Tab};
|
||||
use super::tabs::age_color;
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn r_kv<'a>(key: &'a str, val: &str, val_col: Color) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<11}", key), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(val.to_string(), Style::default().fg(val_col)),
|
||||
])
|
||||
}
|
||||
pub fn r_kv_color<'a>(key: &'a str, val: &str, val_col: Color) -> Line<'a> {
|
||||
r_kv(key, val, val_col)
|
||||
}
|
||||
pub fn r_section(title: &str) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", title),
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
}
|
||||
pub fn r_sep(width: u16) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
" ".to_string() + &"─".repeat(width.saturating_sub(2) as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))
|
||||
}
|
||||
pub fn r_dim(s: String) -> Line<'static> {
|
||||
Line::from(Span::styled(s, Style::default().fg(Color::DarkGray)))
|
||||
}
|
||||
pub fn r_action_line<'a>(key: char, desc: &'a str, col: Color) -> Line<'a> {
|
||||
if key == '\n' {
|
||||
return Line::from(Span::styled(desc, Style::default().fg(col)));
|
||||
}
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" [{}]", key), Style::default().fg(col).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!(" {}", desc), Style::default().fg(Color::White)),
|
||||
])
|
||||
}
|
||||
pub fn r_action_key<'a>(key: &'a str, desc: &'a str, col: Color) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" [{}]", key), Style::default().fg(col).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!(" {}", desc), Style::default().fg(Color::White)),
|
||||
])
|
||||
}
|
||||
|
||||
// ── render right ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn render_right(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
if let Some(ref action) = state.confirming {
|
||||
render_confirm_overlay(f, state, area, action);
|
||||
return;
|
||||
}
|
||||
|
||||
let blk = Block::default().borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
match state.tab {
|
||||
Tab::Repos => render_right_repo(f, state, inner),
|
||||
Tab::Projects => render_right_project(f, state, inner),
|
||||
Tab::Todos => render_right_todo(f, state, inner),
|
||||
Tab::PRs => render_right_pr(f, state, inner),
|
||||
Tab::Issues => render_right_issue(f, state, inner),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_confirm_overlay(f: &mut Frame, state: &HubState, area: Rect, action: &PendingAction) {
|
||||
let targets = state.action_targets();
|
||||
let (title, verb) = match action {
|
||||
PendingAction::Sync => (" Sync ", "Sync"),
|
||||
PendingAction::Release => (" Release ", "Release"),
|
||||
};
|
||||
let blk = Block::default().borders(Borders::ALL).title(title)
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(""),
|
||||
r_section(verb),
|
||||
Line::from(""),
|
||||
];
|
||||
for &ri in targets.iter().take(12) {
|
||||
if let Some(r) = state.repos.get(ri) {
|
||||
lines.push(r_kv(" ·", &r.name, Color::Cyan));
|
||||
}
|
||||
}
|
||||
if targets.len() > 12 {
|
||||
lines.push(r_dim(format!(" … and {} more", targets.len() - 12)));
|
||||
}
|
||||
lines.extend([
|
||||
Line::from(""),
|
||||
r_kv("Targets", &format!("{} repo(s)", targets.len()), Color::White),
|
||||
Line::from(""),
|
||||
r_action_line('y', "Confirm", Color::Green),
|
||||
r_action_line('n', "Cancel", Color::Red),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn render_right_repo(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let actions_h = 20u16;
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(actions_h)])
|
||||
.split(area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let r = &state.repos[ri];
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", r.name),
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(r_sep(area.width));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(r_kv("Branch", &r.branch, Color::Cyan));
|
||||
lines.push(r_kv("Owner", r.owner.as_deref().unwrap_or("—"), Color::White));
|
||||
let proj_name = state.repo_project(ri).map(|p| p.name.as_str()).unwrap_or("—");
|
||||
let proj_color = state.repo_proj_color(ri).unwrap_or(Color::DarkGray);
|
||||
lines.push(r_kv_color("Project", proj_name, proj_color));
|
||||
lines.push(r_kv("Vis.", r.visibility.as_deref().unwrap_or("unknown"), match r.visibility.as_deref() { Some("public") => Color::Green, Some("private") => Color::Yellow, _ => Color::DarkGray }));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let (sync_txt, sync_col) = match (r.ahead, r.behind) {
|
||||
(0, 0) => ("✓ in sync with remote", Color::Green),
|
||||
(a, 0) => (Box::leak(format!("↑ {} commit(s) ahead", a).into_boxed_str()) as &str, Color::Cyan),
|
||||
(0, b) => (Box::leak(format!("↓ {} commit(s) behind", b).into_boxed_str()) as &str, Color::Red),
|
||||
(a, b) => (Box::leak(format!("↑{} ahead ↓{} behind", a, b).into_boxed_str()) as &str, Color::Yellow),
|
||||
};
|
||||
let (dirty_txt, dirty_col) = if r.is_dirty {
|
||||
("⚠ uncommitted changes", Color::Yellow)
|
||||
} else {
|
||||
("✓ working tree clean", Color::DarkGray)
|
||||
};
|
||||
lines.push(r_kv("Changes", dirty_txt, dirty_col));
|
||||
lines.push(r_kv("Remote", sync_txt, sync_col));
|
||||
|
||||
if let Some(ex) = state.focused_cache.get(&ri) {
|
||||
lines.push(r_kv("Files", &format!("{} tracked", ex.file_count), Color::DarkGray));
|
||||
}
|
||||
if let Some(tag) = &r.last_tag { lines.push(r_kv("Release", tag, Color::Cyan)); }
|
||||
if let Some(lang) = &r.lang { lines.push(r_kv("Lang", lang, Color::DarkGray)); }
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(r_section("Last commit"));
|
||||
lines.push(r_kv("When", &r.last_commit_age, age_color(r.last_commit_ts)));
|
||||
if !r.last_commit_msg.is_empty() {
|
||||
let msg = r.last_commit_msg.chars().take(area.width.saturating_sub(14) as usize).collect::<String>();
|
||||
lines.push(r_kv("Message", &msg, Color::DarkGray));
|
||||
}
|
||||
|
||||
if r.is_dirty {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(r_section("Diff preview"));
|
||||
if let Some(ex) = state.focused_cache.get(&ri) {
|
||||
match &ex.diff_preview {
|
||||
DiffState::Loading => {
|
||||
lines.push(r_dim(" loading...".into()));
|
||||
}
|
||||
DiffState::Ready(diff_lines) => {
|
||||
if diff_lines.is_empty() {
|
||||
lines.push(r_dim(" (no tracked changes)".into()));
|
||||
} else {
|
||||
for dl in diff_lines.iter().take(8) {
|
||||
let col = if dl.starts_with('+') { Color::Green }
|
||||
else if dl.starts_with('-') { Color::Red }
|
||||
else { Color::DarkGray };
|
||||
let max_w = area.width.saturating_sub(2) as usize;
|
||||
let truncated: String = dl.chars().take(max_w).collect();
|
||||
lines.push(Line::from(Span::styled(truncated, Style::default().fg(col))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(r_dim(" loading...".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if !state.selected.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {} repos selected (actions apply to all)", state.selected.len()),
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
lines.push(r_dim(" ← select a repository".into()));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), layout[0]);
|
||||
|
||||
let action_block = Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Style::default().fg(Color::DarkGray))
|
||||
.title(" Actions ");
|
||||
let action_inner = action_block.inner(layout[1]);
|
||||
f.render_widget(action_block, layout[1]);
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
r_action_key("↵ ", "Open full detail", Color::White),
|
||||
r_action_line('\n', "─────────────────", Color::DarkGray),
|
||||
r_action_line('o', "Open in editor", Color::Cyan),
|
||||
r_action_line('W', "Open in browser", Color::Cyan),
|
||||
r_action_line('O', "Open file manager", Color::Cyan),
|
||||
r_action_line('y', "Copy path", Color::Cyan),
|
||||
r_action_line('u', "Copy remote URL", Color::Cyan),
|
||||
r_action_line('g', "Edit .gitignore", Color::Cyan),
|
||||
r_action_line('\n', "─────────────────", Color::DarkGray),
|
||||
r_action_line('s', "Sync with remote", Color::Green),
|
||||
r_action_line('r', "Create release tag", Color::Green),
|
||||
r_action_line('\n', "─────────────────", Color::DarkGray),
|
||||
r_action_key("Spc", "Toggle select", Color::Yellow),
|
||||
r_action_line('*', "Select by condition",Color::Yellow),
|
||||
r_action_key("^A", "Select all / none", Color::Yellow),
|
||||
r_action_line('\n', "─────────────────", Color::DarkGray),
|
||||
r_action_line('B', "Bookmark toggle", Color::White),
|
||||
r_action_line('P', "Pin to top", Color::White),
|
||||
r_action_line('n', "Rename label", Color::White),
|
||||
r_action_line('p', "Manage projects", Color::White),
|
||||
r_action_line('+', "New todo", Color::White),
|
||||
r_action_line('\n', "─────────────────", Color::DarkGray),
|
||||
r_action_line('C', "Clone repo", Color::DarkGray),
|
||||
r_action_line('I', "Init new repo", Color::DarkGray),
|
||||
r_action_line('L', "Link folder", Color::DarkGray),
|
||||
]), action_inner);
|
||||
}
|
||||
|
||||
fn render_right_project(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let actions_h = 7u16;
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(actions_h)])
|
||||
.split(area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
if let Some(pi) = state.cur_proj_idx() {
|
||||
let p = &state.projects[pi];
|
||||
let col = state.proj_color(pi);
|
||||
let all_ri = state.project_repos(pi);
|
||||
let dirty_n = all_ri.iter().filter(|&&ri| state.repos[ri].is_dirty).count();
|
||||
let behind_n = all_ri.iter().filter(|&&ri| state.repos[ri].behind > 0).count();
|
||||
let open_todos = state.todos.iter()
|
||||
.filter(|t| t.project.as_deref() == Some(&p.name)
|
||||
&& t.status != crate::todos::TodoStatus::Done
|
||||
&& t.status != crate::todos::TodoStatus::Cancelled)
|
||||
.count();
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ◼ {}", p.name),
|
||||
Style::default().fg(col).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(r_sep(area.width));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(r_kv("Color", &p.color, col));
|
||||
lines.push(r_kv("Repos", &format!("{} repositories", all_ri.len()), Color::White));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(r_section("Health"));
|
||||
let health = match (dirty_n, behind_n) {
|
||||
(0, 0) => ("✓ all clean", Color::Green),
|
||||
(d, 0) => (Box::leak(format!("⚠ {} with changes", d).into_boxed_str()) as &str, Color::Yellow),
|
||||
(0, b) => (Box::leak(format!("↓ {} behind remote", b).into_boxed_str()) as &str, Color::Red),
|
||||
(d, b) => (Box::leak(format!("⚠ {} changed ↓ {} behind", d, b).into_boxed_str()) as &str, Color::Yellow),
|
||||
};
|
||||
lines.push(r_kv("Status", health.0, health.1));
|
||||
lines.push(r_kv("Todos",
|
||||
if open_todos == 0 { "no open todos" }
|
||||
else { Box::leak(format!("{} open", open_todos).into_boxed_str()) },
|
||||
if open_todos == 0 { Color::DarkGray } else { Color::Yellow },
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(r_section("Repositories"));
|
||||
for &ri in all_ri.iter().take(8) {
|
||||
let r = &state.repos[ri];
|
||||
let suf = if r.is_dirty { " ⚠" } else if r.behind > 0 { " ↓" } else { "" };
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" · {}{}", r.name, suf),
|
||||
Style::default().fg(if r.behind > 0 { Color::Red } else if r.is_dirty { Color::Yellow } else { Color::DarkGray }),
|
||||
)));
|
||||
}
|
||||
if all_ri.len() > 8 {
|
||||
lines.push(r_dim(format!(" … and {} more", all_ri.len() - 8)));
|
||||
}
|
||||
} else {
|
||||
lines.push(r_dim(" ← select a project".into()));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), layout[0]);
|
||||
|
||||
let action_block = Block::default().borders(Borders::TOP).border_style(Style::default().fg(Color::DarkGray)).title(" Actions ");
|
||||
let action_inner = action_block.inner(layout[1]);
|
||||
f.render_widget(action_block, layout[1]);
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
r_action_line('s', "Sync all repos", Color::Green),
|
||||
r_action_line('p', "Edit this project", Color::White),
|
||||
r_action_key("↵ ", "Open detail view", Color::White),
|
||||
r_action_line('+', "New todo", Color::White),
|
||||
r_action_line('D', "Delete project", Color::DarkGray),
|
||||
]), action_inner);
|
||||
}
|
||||
|
||||
fn render_right_todo(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let actions_h = 6u16;
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(actions_h)])
|
||||
.split(area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let t = &state.todos[ti];
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", t.title),
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(r_sep(area.width));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(r_kv_color("Status", &format!("{} {}", t.status.icon(), t.status.label()), t.status.color()));
|
||||
lines.push(r_kv("Project", t.project.as_deref().unwrap_or("— not assigned"), Color::DarkGray));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
if !t.description.is_empty() {
|
||||
lines.push(r_section("Description"));
|
||||
let max_w = area.width.saturating_sub(3) as usize;
|
||||
for raw_line in t.description.lines().take(6) {
|
||||
for chunk in raw_line.chars().collect::<Vec<_>>().chunks(max_w).map(|c| c.iter().collect::<String>()) {
|
||||
lines.push(Line::from(Span::styled(format!(" {}", chunk), Style::default().fg(Color::DarkGray))));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
lines.push(r_section("Timeline"));
|
||||
lines.push(r_kv("Created", &format_age_pub(t.created_at), Color::DarkGray));
|
||||
lines.push(r_kv("Updated", &format_age_pub(t.updated_at), Color::DarkGray));
|
||||
} else {
|
||||
lines.push(r_dim(" ← select a todo".into()));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), layout[0]);
|
||||
|
||||
let action_block = Block::default().borders(Borders::TOP).border_style(Style::default().fg(Color::DarkGray)).title(" Actions ");
|
||||
let action_inner = action_block.inner(layout[1]);
|
||||
f.render_widget(action_block, layout[1]);
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
r_action_key("Space", "Cycle status", Color::Green),
|
||||
r_action_line('e', "Edit title & description", Color::White),
|
||||
r_action_line('+', "New todo", Color::White),
|
||||
r_action_line('D', "Delete", Color::Red),
|
||||
]), action_inner);
|
||||
}
|
||||
|
||||
fn render_right_pr(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let di = state.cur_di();
|
||||
let pr = di.and_then(|d| {
|
||||
if let DisplayRow::Pr { idx } = &state.display[d] { state.prs.get(*idx) } else { None }
|
||||
});
|
||||
|
||||
let mut lines: Vec<Line> = vec![r_section("Pull Request"), Line::from("")];
|
||||
if let Some(pr) = pr {
|
||||
let age = format_age_pub(pr.updated_ts);
|
||||
lines.push(r_kv("Repo", &pr.repo, Color::Cyan));
|
||||
lines.push(r_kv("Number", &format!("#{}", pr.number), Color::DarkGray));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(format!(" \"{}\"", pr.title.chars().take(30).collect::<String>()), Style::default().fg(Color::White).add_modifier(Modifier::BOLD))));
|
||||
if pr.title.len() > 30 {
|
||||
lines.push(Line::from(Span::styled(format!(" {}", pr.title.chars().skip(30).take(30).collect::<String>()), Style::default().fg(Color::White))));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(r_kv("Author", &format!("@{}", pr.author), Color::Green));
|
||||
lines.push(r_kv("Updated", &age, Color::DarkGray));
|
||||
lines.push(r_kv("Branch", &format!("{} → {}", pr.head_branch, pr.base_branch), Color::Cyan));
|
||||
if pr.draft { lines.push(r_dim(" ⚠ DRAFT".into())); }
|
||||
if !pr.labels.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(r_dim(format!(" Labels: {}", pr.labels.join(", "))));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(r_dim(" [↵] open in browser".into()));
|
||||
} else {
|
||||
lines.push(r_dim(" ← select a PR".into()));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn render_right_issue(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let di = state.cur_di();
|
||||
let issue = di.and_then(|d| {
|
||||
if let DisplayRow::Issue { idx } = &state.display[d] { state.issues.get(*idx) } else { None }
|
||||
});
|
||||
|
||||
let mut lines: Vec<Line> = vec![r_section("Issue"), Line::from("")];
|
||||
if let Some(issue) = issue {
|
||||
let age = format_age_pub(issue.updated_ts);
|
||||
lines.push(r_kv("Repo", &issue.repo, Color::Cyan));
|
||||
lines.push(r_kv("Number", &format!("#{}", issue.number), Color::DarkGray));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(format!(" \"{}\"", issue.title.chars().take(30).collect::<String>()), Style::default().fg(Color::White).add_modifier(Modifier::BOLD))));
|
||||
if issue.title.len() > 30 {
|
||||
lines.push(Line::from(Span::styled(format!(" {}", issue.title.chars().skip(30).take(30).collect::<String>()), Style::default().fg(Color::White))));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(r_kv("Author", &format!("@{}", issue.author), Color::Yellow));
|
||||
lines.push(r_kv("Updated", &age, Color::DarkGray));
|
||||
lines.push(r_kv("Comments", &format!("{}", issue.comments), Color::DarkGray));
|
||||
if !issue.labels.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(r_dim(format!(" Labels: {}", issue.labels.join(", "))));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(r_dim(" [↵] open in browser".into()));
|
||||
} else {
|
||||
lines.push(r_dim(" ← select an issue".into()));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
use crate::projects::{self, Project};
|
||||
use crate::providers::{IssueDetail, PrDetail};
|
||||
use crate::status::{count_tracked_files, repo_recent_commits, RepoSummary};
|
||||
use crate::todos::{Todo, TodoSource};
|
||||
use ratatui::style::Color;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ── tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub enum Tab { Repos, Projects, Todos, PRs, Issues }
|
||||
|
||||
// ── sort mode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SortMode { Default, Name, Age, DirtyFirst, BehindFirst }
|
||||
|
||||
impl SortMode {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Default => Self::Name,
|
||||
Self::Name => Self::Age,
|
||||
Self::Age => Self::DirtyFirst,
|
||||
Self::DirtyFirst => Self::BehindFirst,
|
||||
Self::BehindFirst=> Self::Default,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "Default",
|
||||
Self::Name => "Name",
|
||||
Self::Age => "Recent",
|
||||
Self::DirtyFirst => "Dirty↑",
|
||||
Self::BehindFirst=> "Behind↑",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── modal system ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Modal {
|
||||
None,
|
||||
Text { title: String, prompt: String, value: String, tag: TextTag },
|
||||
Select { title: String, options: Vec<String>, cursor: usize, tag: SelectTag },
|
||||
Multi { title: String, options: Vec<String>, checks: Vec<bool>, cursor: usize, tag: MultiTag },
|
||||
Confirm { message: String, tag: ConfirmTag },
|
||||
Info { title: String, lines: Vec<String>, scroll: usize },
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
pub fn is_some(&self) -> bool { !matches!(self, Modal::None) }
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum TextTag {
|
||||
Label(usize),
|
||||
ProjName,
|
||||
ProjColor(String),
|
||||
ProjRenameName(usize),
|
||||
ProjRenameColor(usize, String),
|
||||
TodoTitle,
|
||||
TodoDesc(String),
|
||||
TodoEditTitle(usize),
|
||||
TodoEditDesc(usize, String),
|
||||
CloneUrl,
|
||||
InitName,
|
||||
LinkPath,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SelectTag {
|
||||
ProjMenu,
|
||||
ProjForAction(ProjAction),
|
||||
TodoStatus { title: String, desc: String },
|
||||
TodoProject { title: String, desc: String, status_idx: usize },
|
||||
TodoChangeStatus(usize),
|
||||
SelectByCondition,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ProjAction { Assign, Rename, Delete }
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum MultiTag {
|
||||
ProjAssign(usize),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ConfirmTag {
|
||||
DeleteProject(usize),
|
||||
DeleteTodo(usize),
|
||||
}
|
||||
|
||||
// ── display rows ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum DisplayRow {
|
||||
Repo { repo_idx: usize },
|
||||
Project { proj_idx: usize },
|
||||
Todo { todo_idx: usize },
|
||||
Pr { idx: usize },
|
||||
Issue { idx: usize },
|
||||
}
|
||||
|
||||
// ── pending action ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum PendingAction { Sync, Release }
|
||||
|
||||
// ── focused extras (keyed by repo_idx, never cleared on scroll) ───────────────
|
||||
|
||||
pub enum DiffState {
|
||||
Loading,
|
||||
Ready(Vec<String>),
|
||||
}
|
||||
|
||||
pub struct FocusedExtra {
|
||||
pub commits: Vec<(String, String)>,
|
||||
pub file_count: usize,
|
||||
pub diff_preview: DiffState,
|
||||
}
|
||||
|
||||
// ── phase result ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum PhaseResult { None, OpenDetail, ExecSync, ExecRelease, ExecClone(String), ExecRefresh }
|
||||
|
||||
// ── modal action ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub enum ModalAction { Pending, Confirm, Cancel }
|
||||
|
||||
// ── hub state ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct HubState {
|
||||
pub repos: Vec<RepoSummary>,
|
||||
pub projects: Vec<Project>,
|
||||
pub project_map: HashMap<String, usize>,
|
||||
pub todos: Vec<Todo>,
|
||||
pub prs: Vec<PrDetail>,
|
||||
pub issues: Vec<IssueDetail>,
|
||||
pub tab: Tab,
|
||||
pub display: Vec<DisplayRow>,
|
||||
pub repo_pos: Vec<usize>,
|
||||
pub cursor: usize,
|
||||
pub offset: usize,
|
||||
pub visible_rows: usize,
|
||||
pub selected: HashSet<usize>,
|
||||
pub filter_dirty: bool,
|
||||
pub filter_behind: bool,
|
||||
pub filter_ahead: bool,
|
||||
pub filter_text: String,
|
||||
pub filtering: bool,
|
||||
pub filter_archived:bool,
|
||||
pub sort_mode: SortMode,
|
||||
pub bookmarks: HashSet<PathBuf>,
|
||||
pub pins: HashSet<PathBuf>,
|
||||
pub status_msg: Option<String>,
|
||||
pub pending_clone: Option<String>,
|
||||
pub modal: Modal,
|
||||
pub confirming: Option<PendingAction>,
|
||||
pub exec_action: bool,
|
||||
pub open_detail: bool,
|
||||
pub quit: bool,
|
||||
pub workspace_root: PathBuf,
|
||||
/// Per-repo cache of expensive extras; never cleared on scroll, only on refresh.
|
||||
pub focused_cache: HashMap<usize, FocusedExtra>,
|
||||
pub editor: String,
|
||||
pub clipboard_cmd: String,
|
||||
pub show_code_todos:bool,
|
||||
pub debug_mode: bool,
|
||||
pub debug_log: Vec<String>,
|
||||
/// Live receiver from the background scan thread; None when scan is complete.
|
||||
pub scan_rx: Option<std::sync::mpsc::Receiver<crate::status::RepoSummary>>,
|
||||
pub meta_cache: crate::cache::MetaCache,
|
||||
/// Background diff computation in progress: (repo_idx, receiver).
|
||||
pub diff_rx: Option<(usize, std::sync::mpsc::Receiver<Vec<String>>)>,
|
||||
}
|
||||
|
||||
impl HubState {
|
||||
pub fn passes_filter(&self, ri: usize) -> bool {
|
||||
let r = &self.repos[ri];
|
||||
if (self.filter_dirty && !r.is_dirty)
|
||||
|| (self.filter_behind && r.behind == 0)
|
||||
|| (self.filter_ahead && r.ahead == 0) { return false; }
|
||||
if self.filter_archived {
|
||||
let days = (chrono::Utc::now().timestamp() - r.last_commit_ts) / 86400;
|
||||
if days < 90 { return false; }
|
||||
}
|
||||
if !self.filter_text.is_empty() {
|
||||
let q = self.filter_text.to_lowercase();
|
||||
let in_name = r.name.to_lowercase().contains(&q);
|
||||
let in_owner = r.owner.as_deref().map(|o| o.to_lowercase().contains(&q)).unwrap_or(false);
|
||||
let in_branch = r.branch.to_lowercase().contains(&q);
|
||||
let in_lang = r.lang.as_deref().map(|l| l.to_lowercase().contains(&q)).unwrap_or(false);
|
||||
if !(in_name || in_owner || in_branch || in_lang) { return false; }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn rebuild_display(&mut self) {
|
||||
let mut d = Vec::new();
|
||||
match self.tab {
|
||||
Tab::Repos => {
|
||||
let all_idx: Vec<usize> = (0..self.repos.len())
|
||||
.filter(|&ri| self.passes_filter(ri))
|
||||
.collect();
|
||||
let mut pinned: Vec<usize> = all_idx.iter().copied().filter(|&ri| self.pins.contains(&self.repos[ri].path)).collect();
|
||||
let mut regular: Vec<usize> = all_idx.iter().copied().filter(|&ri| !self.pins.contains(&self.repos[ri].path)).collect();
|
||||
let repos = &self.repos;
|
||||
match self.sort_mode {
|
||||
SortMode::Default => {}
|
||||
SortMode::Name => regular.sort_by(|&a, &b| repos[a].name.cmp(&repos[b].name)),
|
||||
SortMode::Age => regular.sort_by(|&a, &b| repos[b].last_commit_ts.cmp(&repos[a].last_commit_ts)),
|
||||
SortMode::DirtyFirst => regular.sort_by(|&a, &b| repos[b].is_dirty.cmp(&repos[a].is_dirty)),
|
||||
SortMode::BehindFirst=> regular.sort_by(|&a, &b| repos[b].behind.cmp(&repos[a].behind)),
|
||||
}
|
||||
pinned.sort_by(|&a, &b| repos[a].name.cmp(&repos[b].name));
|
||||
for ri in pinned.into_iter().chain(regular) {
|
||||
d.push(DisplayRow::Repo { repo_idx: ri });
|
||||
}
|
||||
}
|
||||
Tab::Projects => {
|
||||
for pi in 0..self.projects.len() {
|
||||
d.push(DisplayRow::Project { proj_idx: pi });
|
||||
}
|
||||
}
|
||||
Tab::Todos => {
|
||||
for (ti, t) in self.todos.iter().enumerate() {
|
||||
if !self.show_code_todos && t.source == TodoSource::CodeScan { continue; }
|
||||
d.push(DisplayRow::Todo { todo_idx: ti });
|
||||
}
|
||||
}
|
||||
Tab::PRs => {
|
||||
for i in 0..self.prs.len() {
|
||||
d.push(DisplayRow::Pr { idx: i });
|
||||
}
|
||||
}
|
||||
Tab::Issues => {
|
||||
for i in 0..self.issues.len() {
|
||||
d.push(DisplayRow::Issue { idx: i });
|
||||
}
|
||||
}
|
||||
}
|
||||
self.repo_pos = (0..d.len()).collect();
|
||||
if self.cursor >= self.repo_pos.len().max(1) {
|
||||
self.cursor = self.repo_pos.len().saturating_sub(1);
|
||||
}
|
||||
self.offset = 0;
|
||||
self.display = d;
|
||||
}
|
||||
|
||||
pub fn rebuild_project_map(&mut self) {
|
||||
self.project_map = projects::index_by_repo(&self.projects);
|
||||
}
|
||||
|
||||
pub fn cur_di(&self) -> Option<usize> {
|
||||
self.repo_pos.get(self.cursor).copied()
|
||||
}
|
||||
pub fn cur_repo_idx(&self) -> Option<usize> {
|
||||
match self.display.get(self.cur_di()?)? {
|
||||
DisplayRow::Repo { repo_idx } => Some(*repo_idx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn cur_proj_idx(&self) -> Option<usize> {
|
||||
match self.display.get(self.cur_di()?)? {
|
||||
DisplayRow::Project { proj_idx } => Some(*proj_idx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn cur_todo_idx(&self) -> Option<usize> {
|
||||
match self.display.get(self.cur_di()?)? {
|
||||
DisplayRow::Todo { todo_idx } => Some(*todo_idx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_repos(&self, pi: usize) -> Vec<usize> {
|
||||
self.projects[pi].repos.iter()
|
||||
.filter_map(|rel| {
|
||||
let abs = self.workspace_root.join(rel);
|
||||
self.repos.iter().position(|r| r.path == abs)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn repo_project(&self, ri: usize) -> Option<&Project> {
|
||||
let rel = projects::rel_key(&self.repos[ri].path, &self.workspace_root);
|
||||
self.project_map.get(&rel).map(|&pi| &self.projects[pi])
|
||||
}
|
||||
pub fn repo_proj_color(&self, ri: usize) -> Option<Color> {
|
||||
let p = self.repo_project(ri)?;
|
||||
let (r, g, b) = projects::hex_to_rgb(&p.color);
|
||||
Some(Color::Rgb(r, g, b))
|
||||
}
|
||||
pub fn proj_color(&self, pi: usize) -> Color {
|
||||
let (r, g, b) = projects::hex_to_rgb(&self.projects[pi].color);
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
pub fn action_targets(&self) -> Vec<usize> {
|
||||
if self.selected.len() > 1 {
|
||||
let mut v: Vec<usize> = self.selected.iter().copied().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
} else if let Some(ri) = self.cur_repo_idx() {
|
||||
vec![ri]
|
||||
} else if let Some(pi) = self.cur_proj_idx() {
|
||||
let mut v = self.project_repos(pi);
|
||||
v.retain(|&ri| self.passes_filter(ri));
|
||||
v
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self) {
|
||||
let n = self.repo_pos.len();
|
||||
if n == 0 || self.cursor + 1 >= n { return; }
|
||||
self.cursor += 1;
|
||||
let di = self.repo_pos[self.cursor];
|
||||
if self.visible_rows > 0 && di >= self.offset + self.visible_rows {
|
||||
self.offset = di - self.visible_rows + 1;
|
||||
}
|
||||
}
|
||||
pub fn scroll_up(&mut self) {
|
||||
if self.cursor == 0 { return; }
|
||||
self.cursor -= 1;
|
||||
let di = self.repo_pos[self.cursor];
|
||||
if di < self.offset { self.offset = di; }
|
||||
}
|
||||
pub fn toggle_select(&mut self) {
|
||||
if let Some(ri) = self.cur_repo_idx() {
|
||||
if self.selected.contains(&ri) { self.selected.remove(&ri); } else { self.selected.insert(ri); }
|
||||
} else if let Some(pi) = self.cur_proj_idx() {
|
||||
let repos = self.project_repos(pi);
|
||||
let all = repos.iter().all(|ri| self.selected.contains(ri));
|
||||
if all { for ri in repos { self.selected.remove(&ri); } } else { for ri in repos { self.selected.insert(ri); } }
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain all pending repos from the background scan channel.
|
||||
/// Non-blocking: returns immediately if nothing is available.
|
||||
pub fn poll_incoming(&mut self) {
|
||||
if self.scan_rx.is_none() { return; }
|
||||
let mut any_new = false;
|
||||
let mut scan_done = false;
|
||||
loop {
|
||||
let result = self.scan_rx.as_ref().unwrap().try_recv();
|
||||
match result {
|
||||
Ok(mut repo) => {
|
||||
repo.bookmarked = self.bookmarks.contains(&repo.path);
|
||||
// Apply any cached API metadata (stars, PRs, issues)
|
||||
let cache_vals = self.meta_cache.get(&repo.name).map(|e| {
|
||||
(e.meta.stars, e.meta.open_issues, e.meta.open_prs, e.meta.visibility.clone())
|
||||
});
|
||||
if let Some((stars, issues, prs, vis)) = cache_vals {
|
||||
repo.stars = stars;
|
||||
repo.open_issues = issues;
|
||||
repo.open_prs = prs;
|
||||
if repo.visibility.is_none() { repo.visibility = vis; }
|
||||
}
|
||||
self.repos.push(repo);
|
||||
any_new = true;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
// Scan finished — apply final sort matching scan_workspace order
|
||||
self.repos.sort_by(|a, b| {
|
||||
let a_active = a.is_dirty || a.behind > 0;
|
||||
let b_active = b.is_dirty || b.behind > 0;
|
||||
b_active.cmp(&a_active).then(b.last_commit_ts.cmp(&a.last_commit_ts))
|
||||
});
|
||||
self.repos.dedup_by(|a, b| a.path == b.path);
|
||||
self.scan_rx = None;
|
||||
scan_done = true;
|
||||
any_new = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if any_new { self.rebuild_display(); }
|
||||
if scan_done {
|
||||
crate::debug::log("scan channel closed — final sort applied".to_string());
|
||||
if self.debug_mode {
|
||||
self.debug_log.push(format!("scan complete: {} repos", self.repos.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute and cache focused extras for the current repo.
|
||||
/// Commits + file count run synchronously; diff is spawned in the background.
|
||||
/// Safe to call every frame — skips if already cached or diff already in flight.
|
||||
pub fn update_focused_extra(&mut self) {
|
||||
let Some(ri) = self.cur_repo_idx() else { return; };
|
||||
if self.focused_cache.contains_key(&ri) { return; }
|
||||
// Don't spawn a second diff for the same repo while one is already in flight.
|
||||
if self.diff_rx.as_ref().map(|(idx, _)| *idx == ri).unwrap_or(false) { return; }
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let path = self.repos[ri].path.clone();
|
||||
|
||||
let t1 = std::time::Instant::now();
|
||||
let commits = repo_recent_commits(&path, 4);
|
||||
let commits_ms = t1.elapsed().as_millis();
|
||||
|
||||
let t2 = std::time::Instant::now();
|
||||
let file_count = count_tracked_files(&path);
|
||||
let files_ms = t2.elapsed().as_millis();
|
||||
|
||||
let diff_preview = if self.repos[ri].is_dirty {
|
||||
let path2 = path.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || { let _ = tx.send(diff_inner(&path2)); });
|
||||
self.diff_rx = Some((ri, rx));
|
||||
DiffState::Loading
|
||||
} else {
|
||||
DiffState::Ready(vec![])
|
||||
};
|
||||
|
||||
if self.debug_mode {
|
||||
let total_ms = t0.elapsed().as_millis();
|
||||
self.debug_log.push(format!(
|
||||
"{total_ms:>5}ms hover[{ri}] {} commits={commits_ms}ms files={files_ms}ms diff=bg",
|
||||
self.repos[ri].name,
|
||||
));
|
||||
if self.debug_log.len() > 200 { self.debug_log.drain(..100); }
|
||||
}
|
||||
|
||||
self.focused_cache.insert(ri, FocusedExtra { commits, file_count, diff_preview });
|
||||
}
|
||||
|
||||
/// Check if the background diff thread finished; if so, update the cache entry.
|
||||
pub fn poll_diff(&mut self) {
|
||||
let rx_ready = if let Some((ri, rx)) = &self.diff_rx {
|
||||
match rx.try_recv() {
|
||||
Ok(lines) => Some((*ri, lines)),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some((*ri, vec![])),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((ri, lines)) = rx_ready {
|
||||
self.diff_rx = None;
|
||||
if let Some(ex) = self.focused_cache.get_mut(&ri) {
|
||||
ex.diff_preview = DiffState::Ready(lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_inner(path: &PathBuf) -> Vec<String> {
|
||||
let Ok(repo) = git2::Repository::open(path) else { return vec![]; };
|
||||
let Ok(head) = repo.head() else { return vec![]; };
|
||||
let Ok(tree) = head.peel_to_commit().and_then(|c| c.tree()) else { return vec![]; };
|
||||
|
||||
let mut opts = git2::DiffOptions::new();
|
||||
opts.include_untracked(false)
|
||||
.recurse_untracked_dirs(false)
|
||||
.include_ignored(false)
|
||||
.recurse_ignored_dirs(false)
|
||||
.ignore_submodules(true)
|
||||
.skip_binary_check(true);
|
||||
|
||||
let Ok(diff) = repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts)) else { return vec![]; };
|
||||
let Ok(stats) = diff.stats() else { return vec![]; };
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let _ = diff.foreach(
|
||||
&mut |delta, _| {
|
||||
if lines.len() >= 20 { return false; }
|
||||
let marker = match delta.status() {
|
||||
git2::Delta::Added => '+',
|
||||
git2::Delta::Deleted => '-',
|
||||
_ => ' ',
|
||||
};
|
||||
let p = delta.new_file().path()
|
||||
.or_else(|| delta.old_file().path())
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
lines.push(format!("{} {}", marker, p));
|
||||
true
|
||||
},
|
||||
None, None, None,
|
||||
);
|
||||
|
||||
let (ins, del) = (stats.insertions(), stats.deletions());
|
||||
if ins > 0 || del > 0 { lines.push(format!("+{ins} -{del}")); }
|
||||
lines
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
use crate::status::format_age_pub;
|
||||
use crate::todos::TodoSource;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Cell, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
use super::state::{DisplayRow, HubState, Tab};
|
||||
|
||||
pub fn age_color(ts: i64) -> Color {
|
||||
let days = (chrono::Utc::now().timestamp() - ts) / 86400;
|
||||
if days < 7 { Color::Green } else if days < 30 { Color::Yellow } else { Color::Red }
|
||||
}
|
||||
|
||||
pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![
|
||||
" ", "NAME", "BRANCH", "PROJECT", "VIS", "AGE", "STATUS", "REL", "LIC", "★",
|
||||
]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Repo { repo_idx: ri } = row else { return None; };
|
||||
let r = &state.repos[*ri];
|
||||
let is_cur = di == cur_di;
|
||||
let is_sel = state.selected.contains(ri);
|
||||
|
||||
let check_sty = if is_sel { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
|
||||
let proj_name = state.repo_project(*ri).map(|p| p.name.as_str()).unwrap_or("—");
|
||||
let proj_color = state.repo_proj_color(*ri).unwrap_or(Color::DarkGray);
|
||||
|
||||
let vis = r.visibility.as_deref().unwrap_or("?");
|
||||
let rel = r.last_tag.as_deref().unwrap_or("—");
|
||||
let d_sym = if r.is_dirty { "⚠" } else { "·" };
|
||||
let a_str = if r.ahead > 0 { format!("+{}", r.ahead) } else { String::new() };
|
||||
let b_str = if r.behind > 0 { format!("-{}", r.behind) } else { String::new() };
|
||||
let status_str = format!("{} {}{}", d_sym, a_str, b_str);
|
||||
let name_col = if is_cur || (!r.is_dirty && r.ahead == 0 && r.behind == 0) {
|
||||
Color::White
|
||||
} else {
|
||||
Color::Yellow
|
||||
};
|
||||
let lic_str = if r.license { "✓" } else { "—" };
|
||||
let stars_str = r.stars.map(|s| format!("{}", s)).unwrap_or_else(|| "—".into());
|
||||
let stars_col = if r.stars.is_some() { Color::Yellow } else { Color::DarkGray };
|
||||
let is_pinned = state.pins.contains(&r.path);
|
||||
let prefix = match (is_pinned, r.bookmarked) {
|
||||
(true, true) => "◆★",
|
||||
(true, false) => "◆ ",
|
||||
(false, true) => "★ ",
|
||||
(false, false)=> " ",
|
||||
};
|
||||
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if is_sel { "[✓]" } else { "[ ]" })).style(check_sty),
|
||||
Cell::from(format!("{}{}", prefix, r.name)).style(Style::default().fg(name_col)),
|
||||
Cell::from(r.branch.chars().take(9).collect::<String>()),
|
||||
Cell::from(proj_name.chars().take(10).collect::<String>()).style(Style::default().fg(proj_color)),
|
||||
Cell::from(vis).style(match vis { "public" => Style::default().fg(Color::Green), "private" => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(r.last_commit_age.clone()).style(Style::default().fg(age_color(r.last_commit_ts))),
|
||||
Cell::from(status_str).style(if r.is_dirty || r.behind > 0 { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(rel.chars().take(7).collect::<String>()).style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(lic_str).style(Style::default().fg(if r.license { Color::Green } else { Color::DarkGray })),
|
||||
Cell::from(stars_str).style(Style::default().fg(stars_col)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(6),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(11),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(6),
|
||||
];
|
||||
f.render_widget(Table::new(rows, widths).header(header), area);
|
||||
}
|
||||
|
||||
pub fn render_projects_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" ", "PROJECT", "COLOR", "REPOS", "DIRTY", "BEHIND"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Project { proj_idx: pi } = row else { return None; };
|
||||
let p = &state.projects[*pi];
|
||||
let all_ri = state.project_repos(*pi);
|
||||
let dirty_n = all_ri.iter().filter(|&&ri| state.repos[ri].is_dirty).count();
|
||||
let behind_n = all_ri.iter().filter(|&&ri| state.repos[ri].behind > 0).count();
|
||||
let all_sel = !all_ri.is_empty() && all_ri.iter().all(|ri| state.selected.contains(ri));
|
||||
let any_sel = all_ri.iter().any(|ri| state.selected.contains(ri));
|
||||
let is_cur = di == cur_di;
|
||||
let color = state.proj_color(*pi);
|
||||
|
||||
let check_sty = if all_sel || any_sel { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else if behind_n > 0 { Style::default().fg(Color::Red) } else if dirty_n > 0 { Style::default().fg(Color::Yellow) } else { Style::default() };
|
||||
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if all_sel { "[✓]" } else if any_sel { "[◈]" } else { "[ ]" })).style(check_sty),
|
||||
Cell::from(p.name.clone()).style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(Line::from(vec![
|
||||
Span::styled("██ ", Style::default().fg(color)),
|
||||
Span::styled(p.color.clone(), Style::default().fg(color)),
|
||||
])),
|
||||
Cell::from(all_ri.len().to_string()),
|
||||
Cell::from(if dirty_n > 0 { dirty_n.to_string() } else { "·".into() }).style(if dirty_n > 0 { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(if behind_n > 0 { behind_n.to_string() } else { "·".into() }).style(if behind_n > 0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::DarkGray) }),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(Table::new(rows, [Constraint::Length(6), Constraint::Fill(1), Constraint::Length(9), Constraint::Length(7), Constraint::Length(6), Constraint::Length(7)]).header(header), area);
|
||||
}
|
||||
|
||||
pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" SRC", " STATUS", "TITLE", "REPO", "PROJECT", "CREATED", "UPDATED"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Todo { todo_idx: ti } = row else { return None; };
|
||||
let t = &state.todos[*ti];
|
||||
let is_cur = di == cur_di;
|
||||
let status_color = t.status.color();
|
||||
let proj_name = t.project.as_deref().unwrap_or("—");
|
||||
let repo_name = t.repo.as_deref().unwrap_or("—");
|
||||
let created = format_age_pub(t.created_at);
|
||||
let updated = format_age_pub(t.updated_at);
|
||||
let title_short: String = if t.source == TodoSource::CodeScan {
|
||||
let raw = &t.title;
|
||||
let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() };
|
||||
trimmed.chars().take(55).collect()
|
||||
} else {
|
||||
t.title.chars().take(55).collect()
|
||||
};
|
||||
let src_icon = t.source.icon();
|
||||
let src_col = match t.source { TodoSource::Manual => Color::Cyan, TodoSource::CodeScan => Color::DarkGray };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", src_icon)).style(Style::default().fg(src_col)),
|
||||
Cell::from(format!(" {} {}", t.status.icon(), t.status.label()))
|
||||
.style(Style::default().fg(status_color)),
|
||||
Cell::from(title_short),
|
||||
Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(proj_name).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(created).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(updated).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(14),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_prs_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" REPO", "#", "AGE", "AUTHOR", "TITLE", "DRAFT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Pr { idx } = row else { return None; };
|
||||
let pr = &state.prs[*idx];
|
||||
let is_cur = di == cur_di;
|
||||
let age = format_age_pub(pr.updated_ts);
|
||||
let draft_s = if pr.draft { "draft" } else { "" };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", pr.repo.chars().take(14).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(format!("#{}", pr.number)).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(age).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(format!("@{}", pr.author.chars().take(12).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Green)),
|
||||
Cell::from(pr.title.chars().take(60).collect::<String>()),
|
||||
Cell::from(draft_s).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(16), Constraint::Length(7), Constraint::Length(10),
|
||||
Constraint::Length(14), Constraint::Fill(1), Constraint::Length(6),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_issues_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" REPO", "#", "AGE", "AUTHOR", "TITLE", "CMT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Issue { idx } = row else { return None; };
|
||||
let issue = &state.issues[*idx];
|
||||
let is_cur = di == cur_di;
|
||||
let age = format_age_pub(issue.updated_ts);
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", issue.repo.chars().take(14).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(format!("#{}", issue.number)).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(age).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(format!("@{}", issue.author.chars().take(12).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Yellow)),
|
||||
Cell::from(issue.title.chars().take(60).collect::<String>()),
|
||||
Cell::from(issue.comments.to_string()).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(16), Constraint::Length(7), Constraint::Length(10),
|
||||
Constraint::Length(14), Constraint::Fill(1), Constraint::Length(5),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_list(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
if state.display.is_empty() {
|
||||
let msg = match state.tab {
|
||||
Tab::Repos => " (no repos match current filters)",
|
||||
Tab::Projects => " No projects — press [p] to create one",
|
||||
Tab::Todos => " No todos — press [+] to create one",
|
||||
Tab::PRs => " No PRs cached — press [R] to fetch from GitHub",
|
||||
Tab::Issues => " No issues cached — press [R] to fetch from GitHub",
|
||||
};
|
||||
f.render_widget(ratatui::widgets::Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)), area);
|
||||
return;
|
||||
}
|
||||
match state.tab {
|
||||
Tab::Repos => render_repos_table(f, state, area),
|
||||
Tab::Projects => render_projects_table(f, state, area),
|
||||
Tab::Todos => render_todos_table(f, state, area),
|
||||
Tab::PRs => render_prs_table(f, state, area),
|
||||
Tab::Issues => render_issues_table(f, state, area),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user