Test message 123!

This commit is contained in:
dualfroz committed 2026-06-14 00:22:54 +02:00
1 parent 4cf726709f
commit d4fdc9890a
52 files changed
+5537 -277

No files matched your search

+217
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
+392
View File
@@ -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}"));
}
_ => {}
}
}
+201
View File
@@ -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);
}
}
+434
View File
@@ -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);
}
+493
View File
@@ -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
View File
@@ -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),
}
}