From d4fdc9890ad449c32af1420bfd6c82e3154860c9 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 14 Jun 2026 00:22:54 +0200 Subject: [PATCH] Test message 123! --- .gitignore | 1 + src/app.rs | 74 ++--- src/auth/mod.rs | 127 +++++++- src/cache.rs | 79 +++++ src/cli.rs | 79 ++++- src/config/mod.rs | 17 ++ src/config/ui.rs | 121 ++++++-- src/debug.rs | 28 ++ src/detail/git.rs | 96 ++++++ src/detail/mod.rs | 244 +++++++++++++++ src/detail/render.rs | 365 +++++++++++++++++++++++ src/hub/actions.rs | 217 ++++++++++++++ src/hub/keys.rs | 425 ++++++++++++++++++++++++++ src/hub/mod.rs | 226 ++++++++++++++ src/hub/modals.rs | 392 ++++++++++++++++++++++++ src/hub/render.rs | 201 +++++++++++++ src/hub/right_panel.rs | 434 +++++++++++++++++++++++++++ src/hub/state.rs | 493 +++++++++++++++++++++++++++++++ src/hub/tabs.rs | 280 ++++++++++++++++++ src/labels/mod.rs | 45 +++ src/{ => legacy}/adopt/mod.rs | 0 src/{ => legacy}/adopt/ui.rs | 2 +- src/{ => legacy}/init/mod.rs | 0 src/{ => legacy}/init/wizard.rs | 2 +- src/legacy/manage/mod.rs | 407 +++++++++++++++++++++++++ src/legacy/menu.rs | 138 +++++++++ src/legacy/mod.rs | 11 + src/{ => legacy}/refactor/mod.rs | 0 src/{ => legacy}/refactor/ui.rs | 8 +- src/{ => legacy}/release/mod.rs | 19 +- src/{ => legacy}/release/ui.rs | 2 +- src/{ => legacy}/stats/mod.rs | 0 src/{ => legacy}/stats/ui.rs | 2 +- src/{ => legacy}/summary/mod.rs | 2 +- src/{ => legacy}/sync/mod.rs | 10 +- src/{ => legacy}/sync/pr.rs | 0 src/{ => legacy}/sync/ui.rs | 8 +- src/legacy/util/mod.rs | 347 ++++++++++++++++++++++ src/main.rs | 16 +- src/projects/mod.rs | 71 +++++ src/providers/gitea.rs | 27 ++ src/providers/github.rs | 94 +++++- src/providers/gitlab.rs | 27 ++ src/providers/mod.rs | 56 ++++ src/status/detail.rs | 1 + src/status/mod.rs | 256 ++++++++++++++-- src/status/ui.rs | 160 ---------- src/todos/mod.rs | 154 ++++++++++ src/util/clipboard.rs | 26 ++ src/util/git.rs | 12 + src/util/mod.rs | 3 + src/util/url.rs | 9 + 52 files changed, 5537 insertions(+), 277 deletions(-) create mode 100644 src/cache.rs create mode 100644 src/debug.rs create mode 100644 src/detail/git.rs create mode 100644 src/detail/mod.rs create mode 100644 src/detail/render.rs create mode 100644 src/hub/actions.rs create mode 100644 src/hub/keys.rs create mode 100644 src/hub/mod.rs create mode 100644 src/hub/modals.rs create mode 100644 src/hub/render.rs create mode 100644 src/hub/right_panel.rs create mode 100644 src/hub/state.rs create mode 100644 src/hub/tabs.rs create mode 100644 src/labels/mod.rs rename src/{ => legacy}/adopt/mod.rs (100%) rename src/{ => legacy}/adopt/ui.rs (99%) rename src/{ => legacy}/init/mod.rs (100%) rename src/{ => legacy}/init/wizard.rs (99%) create mode 100644 src/legacy/manage/mod.rs create mode 100644 src/legacy/menu.rs create mode 100644 src/legacy/mod.rs rename src/{ => legacy}/refactor/mod.rs (100%) rename src/{ => legacy}/refactor/ui.rs (94%) rename src/{ => legacy}/release/mod.rs (92%) rename src/{ => legacy}/release/ui.rs (99%) rename src/{ => legacy}/stats/mod.rs (100%) rename src/{ => legacy}/stats/ui.rs (99%) rename src/{ => legacy}/summary/mod.rs (99%) rename src/{ => legacy}/sync/mod.rs (97%) rename src/{ => legacy}/sync/pr.rs (100%) rename src/{ => legacy}/sync/ui.rs (96%) create mode 100644 src/legacy/util/mod.rs create mode 100644 src/projects/mod.rs create mode 100644 src/status/detail.rs delete mode 100644 src/status/ui.rs create mode 100644 src/todos/mod.rs create mode 100644 src/util/clipboard.rs create mode 100644 src/util/git.rs create mode 100644 src/util/mod.rs create mode 100644 src/util/url.rs diff --git a/.gitignore b/.gitignore index 910f994..f1ac0fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/target .env *.env node_modules/ diff --git a/src/app.rs b/src/app.rs index 2f62fd4..04bd7dc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,51 +1,37 @@ use crate::cli::{Cli, Commands}; use anyhow::Result; +const NOT_YET: &str = "\ + This CLI command is not yet implemented. + Launch the TUI with: fxg +"; + pub async fn run(cli: Cli) -> Result<()> { + if cli.debug { crate::debug::enable(); } + let cfg = crate::config::load_config()?; + match cli.command { - Commands::Config => { - let cfg = crate::config::load_config()?; - crate::config::run(cfg).await - } - Commands::Auth(args) => { - let cfg = crate::config::load_config()?; - crate::auth::run(args, &cfg).await - } - Commands::Init(args) => { - let cfg = crate::config::load_config()?; - crate::init::run(args, &cfg).await - } - Commands::Status(args) => { - let cfg = crate::config::load_config()?; - crate::status::run(args, &cfg).await - } - Commands::Sync(args) => { - let cfg = crate::config::load_config()?; - crate::sync::run(args, &cfg).await - } - Commands::Release(args) => { - let cfg = crate::config::load_config()?; - crate::release::run(args, &cfg).await - } - Commands::Refactor(args) => { - let cfg = crate::config::load_config()?; - crate::refactor::run(args, &cfg).await - } - Commands::Stats(args) => { - let cfg = crate::config::load_config()?; - crate::stats::run(args, &cfg).await - } - Commands::Workspace(args) => { - let cfg = crate::config::load_config()?; - crate::workspace::run(args, &cfg).await - } - Commands::Summary(args) => { - let cfg = crate::config::load_config()?; - crate::summary::run(args, &cfg).await - } - Commands::Adopt(args) => { - let cfg = crate::config::load_config()?; - crate::adopt::run(args, &cfg).await - } + // ── no subcommand → TUI hub ── + None => crate::status::run( + crate::cli::StatusArgs { repo: None, workspace: None, filter: None }, + &cfg, + ).await, + + // ── always-on: config + auth ── + Some(Commands::Config) => crate::config::run(cfg).await, + Some(Commands::Auth(args)) => crate::auth::run(args, &cfg).await, + + // ── everything else: stub ── + Some(Commands::Init(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Status(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Sync(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Release(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Refactor(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Stats(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Workspace(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Summary(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Adopt(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Manage(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + Some(Commands::Util(_)) => { eprintln!("{}", NOT_YET); Ok(()) } } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 263bcf6..f2d93f9 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -54,21 +54,95 @@ pub fn save_accounts(accounts: &[Account], cfg: &AppConfig) -> Result<()> { Ok(()) } -// ── keyring ─────────────────────────────────────────────────────────────────── +// ── token storage (keyring with file fallback) ──────────────────────────────── const SERVICE: &str = "fxg"; +fn tokens_file() -> std::path::PathBuf { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from("~")) + .join(".fxg") + .join(".tokens") +} + +fn file_read_token(id: &str) -> Option { + let raw = std::fs::read_to_string(tokens_file()).ok()?; + for line in raw.lines() { + if let Some((k, v)) = line.split_once('=') { + if k.trim() == id { + return Some(v.trim().to_string()); + } + } + } + None +} + +fn file_write_token(id: &str, token: &str) -> Result<()> { + let path = tokens_file(); + if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } + + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let mut lines: Vec = existing + .lines() + .filter(|l| !l.starts_with(&format!("{}=", id))) + .map(|l| l.to_string()) + .collect(); + lines.push(format!("{}={}", id, token)); + + std::fs::write(&path, lines.join("\n") + "\n")?; + + // 600 permissions — owner read/write only + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +fn file_delete_token(id: &str) -> Result<()> { + let path = tokens_file(); + if !path.exists() { return Ok(()); } + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let lines: Vec<&str> = existing + .lines() + .filter(|l| !l.starts_with(&format!("{}=", id))) + .collect(); + std::fs::write(&path, lines.join("\n") + "\n")?; + Ok(()) +} + pub fn store_token(id: &str, token: &str) -> Result<()> { - keyring::Entry::new(SERVICE, id)?.set_password(token)?; + // File is always the source of truth — keyring is best-effort only + file_write_token(id, token)?; + if let Ok(entry) = keyring::Entry::new(SERVICE, id) { + let _ = entry.set_password(token); + } Ok(()) } pub fn get_token(id: &str) -> Result { - Ok(keyring::Entry::new(SERVICE, id)?.get_password()?) + // Read from file first (reliable); keyring as fallback for tokens stored before this fix + if let Some(t) = file_read_token(id) { + return Ok(t); + } + if let Ok(entry) = keyring::Entry::new(SERVICE, id) { + if let Ok(t) = entry.get_password() { + // Migrate to file so future reads don't need keyring + let _ = file_write_token(id, &t); + return Ok(t); + } + } + Err(anyhow::anyhow!( + "no token for '{}' — run: fxg auth import (or fxg auth add --token )", id + )) } pub fn delete_token(id: &str) -> Result<()> { - keyring::Entry::new(SERVICE, id)?.delete_credential()?; + file_delete_token(id)?; + if let Ok(entry) = keyring::Entry::new(SERVICE, id) { + let _ = entry.delete_credential(); + } Ok(()) } @@ -105,6 +179,48 @@ fn switch_default(accounts: &mut Vec, id: &str, cfg: &AppConfig) -> Res Ok(()) } +// ── gh CLI import ───────────────────────────────────────────────────────────── + +fn gh_output(args: &[&str]) -> Option { + std::process::Command::new("gh") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && s != "null") +} + +fn import_from_gh(accounts: &mut Vec, cfg: &AppConfig) -> Result<()> { + let token = gh_output(&["auth", "token"]) + .ok_or_else(|| anyhow::anyhow!( + "gh auth token failed — run: gh auth login" + ))?; + + let username = gh_output(&["api", "user", "--jq", ".login"]) + .unwrap_or_else(|| cfg.username.clone()); + + let email = gh_output(&["api", "user", "--jq", ".email"]) + .unwrap_or_else(|| cfg.email.clone()); + + let account = Account { + provider: "github".into(), + alias: "personal".into(), + username: username.clone(), + email, + base_url: None, + is_default: true, + }; + + // mark all others as non-default + for a in accounts.iter_mut() { a.is_default = false; } + + add_account(accounts, account, &token, cfg)?; + println!("✓ Imported GitHub account for '{}'", username); + Ok(()) +} + // ── entry point ─────────────────────────────────────────────────────────────── pub async fn run(args: AuthArgs, cfg: &AppConfig) -> Result<()> { @@ -126,6 +242,9 @@ pub async fn run(args: AuthArgs, cfg: &AppConfig) -> Result<()> { add_account(&mut accounts, account, &token, cfg)?; println!("✓ Account added"); } + Some(AuthAction::Import) => { + import_from_gh(&mut accounts, cfg)?; + } Some(AuthAction::Remove { account }) => { remove_account(&mut accounts, &account, cfg)?; println!("✓ Account removed"); diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..a4e3f0d --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,79 @@ +use crate::providers::{IssueDetail, PrDetail}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RepoMeta { + pub stars: Option, + pub open_issues: Option, + pub open_prs: Option, + pub visibility: Option, + pub ci_ok: Option, + pub fetched_at: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CacheEntry { + #[serde(default)] + pub meta: RepoMeta, + #[serde(default)] + pub prs: Vec, + #[serde(default)] + pub issues: Vec, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct MetaCache { + #[serde(default)] + pub repos: HashMap, +} + +fn file_path() -> PathBuf { + dirs::home_dir().unwrap_or_default().join(".fxg").join("meta_cache.toml") +} + +pub fn load() -> MetaCache { + let p = file_path(); + if !p.exists() { return MetaCache::default(); } + toml::from_str(&std::fs::read_to_string(p).unwrap_or_default()).unwrap_or_default() +} + +pub fn save(cache: &MetaCache) -> anyhow::Result<()> { + let p = file_path(); + if let Some(parent) = p.parent() { std::fs::create_dir_all(parent)?; } + std::fs::write(p, toml::to_string_pretty(cache)?)?; + Ok(()) +} + +impl MetaCache { + pub fn get(&self, repo_name: &str) -> Option<&CacheEntry> { + self.repos.get(repo_name) + } + + pub fn set_meta(&mut self, repo_name: &str, meta: RepoMeta) { + self.repos.entry(repo_name.to_string()).or_default().meta = meta; + } + + pub fn set_prs(&mut self, repo_name: &str, prs: Vec) { + self.repos.entry(repo_name.to_string()).or_default().prs = prs; + } + + pub fn set_issues(&mut self, repo_name: &str, issues: Vec) { + self.repos.entry(repo_name.to_string()).or_default().issues = issues; + } + + /// All PRs across all repos, sorted by updated_ts desc. + pub fn all_prs(&self) -> Vec<&PrDetail> { + let mut v: Vec<&PrDetail> = self.repos.values().flat_map(|e| &e.prs).collect(); + v.sort_by(|a, b| b.updated_ts.cmp(&a.updated_ts)); + v + } + + /// All issues across all repos, sorted by updated_ts desc. + pub fn all_issues(&self) -> Vec<&IssueDetail> { + let mut v: Vec<&IssueDetail> = self.repos.values().flat_map(|e| &e.issues).collect(); + v.sort_by(|a, b| b.updated_ts.cmp(&a.updated_ts)); + v + } +} diff --git a/src/cli.rs b/src/cli.rs index fe1cb30..56280ca 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,7 +4,10 @@ use clap::{Args, Parser, Subcommand}; #[command(name = "fxg", about = "Multi-repo git workflow manager", version)] pub struct Cli { #[command(subcommand)] - pub command: Commands, + pub command: Option, + /// Show timing debug panel at bottom of TUI + #[arg(long, hide = true)] + pub debug: bool, } #[derive(Subcommand)] @@ -31,6 +34,10 @@ pub enum Commands { Summary(SummaryArgs), /// Import existing local folders into git + create remote repo Adopt(AdoptArgs), + /// Manage remote repo properties (visibility, topics, rename, delete, archive) + Manage(ManageArgs), + /// Utility commands: unified log, secret scanning, doc generation + Util(UtilArgs), } @@ -48,6 +55,8 @@ pub enum AuthAction { #[arg(long)] alias: Option, #[arg(long)] url: Option, }, + /// Auto-import credentials from the gh CLI (no token needed) + Import, Remove { account: String }, Switch { account: String }, Test { account: Option }, @@ -227,3 +236,71 @@ pub struct AdoptArgs { /// Initial commit message #[arg(long, short = 'm', default_value = "chore: init")] pub message: String, } + + +#[derive(Args)] +pub struct ManageArgs { + #[command(subcommand)] + pub action: Option, +} + +#[derive(Subcommand)] +pub enum ManageAction { + /// Delete remote (and optionally local) repository + Delete { + pub_repo: String, + /// Also delete local directory + #[arg(long)] local: bool, + }, + /// Rename repository on remote (and locally if found) + Rename { + pub_repo: String, + new_name: String, + }, + /// Change repository visibility (interactive picker) + Visibility { + /// Repository name (omit to pick from list) + pub_repo: Option, + }, + /// Set repository topics (interactive picker) + Topics { + /// Repository name (omit to pick from list) + pub_repo: Option, + /// Topics to set (comma-separated; omit to enter interactively) + #[arg(long, value_delimiter = ',')] set: Vec, + }, + /// Archive repository (makes it read-only on remote) + Archive { + pub_repo: String, + }, + /// Interactive wizard: assign local category folder + GitHub topics to all repos + Categorize, +} + + +#[derive(Args)] +pub struct UtilArgs { + #[command(subcommand)] + pub action: Option, +} + +#[derive(Subcommand)] +pub enum UtilAction { + /// Show unified chronological commit log across all repos + Log { + /// Max commits to show in total + #[arg(long, default_value = "30")] limit: usize, + }, + /// Scan git history for common secret patterns + FindSecrets { + /// Repo names to scan (comma-separated; omit for all) + #[arg(value_delimiter = ',')] repos: Vec, + }, + /// Create a new document (CHANGELOG, README, TODO) in a repo + NewDoc { + /// Repository name (omit to pick from list) + pub_repo: Option, + /// Document type (omit to pick interactively) + #[arg(long = "type")] doc_type: Option, + }, +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 6da8c26..90ef056 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -24,6 +24,21 @@ pub struct AppConfig { /// Example: ["clients", "internal", "opensource"] #[serde(default)] pub workspace_groups: Vec, + /// Shell command used to copy text to clipboard (e.g. "wl-copy" or "xclip -selection clipboard"). + #[serde(default = "default_clipboard_cmd")] + pub clipboard_cmd: String, + /// Count lines-of-code during workspace scan (slow for large repos; off by default). + #[serde(default)] + pub show_locs: bool, +} + +fn default_clipboard_cmd() -> String { + for cmd in &["wl-copy", "xclip", "pbcopy"] { + if std::process::Command::new("which").arg(cmd).output().map(|o| o.status.success()).unwrap_or(false) { + return if *cmd == "xclip" { "xclip -selection clipboard".into() } else { cmd.to_string() }; + } + } + String::new() } impl Default for AppConfig { @@ -44,6 +59,8 @@ impl Default for AppConfig { timezone: "Europe/Warsaw".into(), vsc_data_dir: home.join(".fxg/vsc-data"), workspace_groups: vec![], + clipboard_cmd: default_clipboard_cmd(), + show_locs: false, } } } diff --git a/src/config/ui.rs b/src/config/ui.rs index 7ef77a3..cdc2c28 100644 --- a/src/config/ui.rs +++ b/src/config/ui.rs @@ -2,6 +2,7 @@ use crate::config::AppConfig; use crate::tui; use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEvent}; +use inquire::Select; use ratatui::{ layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, @@ -10,12 +11,31 @@ use ratatui::{ Frame, }; +// Fields that must be chosen from a fixed list — no free text allowed. +const ENUM_FIELDS: &[(&str, &[&str])] = &[ + ("default_provider", &["github", "gitlab", "gitea", "forgejo"]), + ("default_visibility", &["private", "public"]), + ("share_stats", &["false", "true"]), + ("gitignore_template", &["node", "rust", "python", "blank"]), +]; + +fn enum_options(key: &str) -> Option<&'static [&'static str]> { + ENUM_FIELDS.iter().find(|(k, _)| *k == key).map(|(_, opts)| *opts) +} + +// ── TUI state ───────────────────────────────────────────────────────────────── + +enum Action { + Quit, + OpenPicker { field_idx: usize, options: Vec<&'static str> }, +} + struct State { - fields: Vec<(&'static str, String)>, - selected: usize, - editing: bool, - edit_buf: String, - quit: bool, + fields: Vec<(&'static str, String)>, + selected: usize, + editing: bool, + edit_buf: String, + action: Option, } impl State { @@ -25,7 +45,7 @@ impl State { selected: 0, editing: false, edit_buf: String::new(), - quit: false, + action: None, } } @@ -39,6 +59,8 @@ impl State { } } +// ── render ──────────────────────────────────────────────────────────────────── + fn render(f: &mut Frame, state: &State) { let area = f.area(); let outer = Block::default() @@ -54,17 +76,33 @@ fn render(f: &mut Frame, state: &State) { let items: Vec = state.fields.iter().enumerate().map(|(i, (key, val))| { let active = i == state.selected; + let is_enum = enum_options(key).is_some(); + let value_str = if active && state.editing { format!("{}_", state.edit_buf) } else { val.clone() }; + + let value_style = if is_enum { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::White) + }; + + let key_label = if is_enum { + format!("{:<22} [pick]", key) + } else { + format!("{:<22} ", key) + }; + let line = Line::from(vec![ - Span::raw(format!(" [{:2}] ", i + 1)), - Span::styled(format!("{:<22}", key), Style::default().fg(Color::Cyan)), Span::raw(" "), - Span::raw(value_str), + Span::styled(key_label, Style::default().fg(Color::Cyan)), + Span::raw(" "), + Span::styled(value_str, value_style), ]); + let style = if active { Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) } else { @@ -78,7 +116,7 @@ fn render(f: &mut Frame, state: &State) { let hint = if state.editing { " [Enter] confirm [Esc] cancel [Backspace] delete" } else { - " [↑/↓] navigate [Enter] edit [r] reset [q] save & quit" + " [↑/↓] navigate [Enter] edit / pick [r] reset defaults [q] save & quit" }; f.render_widget( Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)), @@ -86,6 +124,8 @@ fn render(f: &mut Frame, state: &State) { ); } +// ── key handling ────────────────────────────────────────────────────────────── + fn handle_key(state: &mut State, key: KeyEvent) { if state.editing { match key.code { @@ -104,8 +144,11 @@ fn handle_key(state: &mut State, key: KeyEvent) { } return; } + match key.code { - KeyCode::Char('q') | KeyCode::Esc => state.quit = true, + KeyCode::Char('q') | KeyCode::Esc => { + state.action = Some(Action::Quit); + } KeyCode::Char('r') => { let defaults = AppConfig::default().fields(); state.fields = defaults; @@ -117,21 +160,61 @@ fn handle_key(state: &mut State, key: KeyEvent) { if state.selected + 1 < state.fields.len() { state.selected += 1; } } KeyCode::Enter => { - state.edit_buf = state.fields[state.selected].1.clone(); - state.editing = true; + let key_name = state.fields[state.selected].0; + if let Some(opts) = enum_options(key_name) { + // Signal the outer loop to open a picker + state.action = Some(Action::OpenPicker { + field_idx: state.selected, + options: opts.to_vec(), + }); + } else { + state.edit_buf = state.fields[state.selected].1.clone(); + state.editing = true; + } } _ => {} } } +// ── ratatui inner loop ──────────────────────────────────────────────────────── + +fn run_ratatui_loop(state: &mut State) -> Result { + tui::run_loop(|terminal| { + terminal.draw(|f| render(f, state))?; + if let Some(Event::Key(key)) = tui::next_event(250)? { + handle_key(state, key); + } + Ok(state.action.is_some()) + })?; + Ok(state.action.take().unwrap_or(Action::Quit)) +} + +// ── public entry point ──────────────────────────────────────────────────────── + pub fn run_config_tui(cfg: AppConfig) -> Result { let mut state = State::new(&cfg); - tui::run_loop(|terminal| { - terminal.draw(|f| render(f, &state))?; - if let Some(Event::Key(key)) = tui::next_event(250)? { - handle_key(&mut state, key); + + loop { + match run_ratatui_loop(&mut state)? { + Action::Quit => break, + + Action::OpenPicker { field_idx, options } => { + // We are now back in normal terminal mode — inquire can draw. + let current = &state.fields[field_idx].1; + let start = options.iter().position(|o| *o == current.as_str()).unwrap_or(0); + let key_name = state.fields[field_idx].0; + match Select::new(&format!("{}:", key_name), options) + .with_starting_cursor(start) + .with_help_message("Use ↑/↓ to select, Enter to confirm, Esc to cancel") + .prompt_skippable()? + { + Some(chosen) => { state.fields[field_idx].1 = chosen.to_string(); } + None => {} // Esc — keep old value + } + // Loop continues — re-enter ratatui + } } - Ok(state.quit) - })?; + } + Ok(state.to_config(cfg)) } diff --git a/src/debug.rs b/src/debug.rs new file mode 100644 index 0000000..0bec9bd --- /dev/null +++ b/src/debug.rs @@ -0,0 +1,28 @@ +use std::cell::RefCell; +use std::time::Instant; + +thread_local! { + static ENABLED: RefCell = RefCell::new(false); + static LOG: RefCell> = RefCell::new(Vec::new()); + static T0: RefCell> = RefCell::new(None); +} + +pub fn enable() { + ENABLED.with(|e| *e.borrow_mut() = true); + T0.with(|t| *t.borrow_mut() = Some(Instant::now())); +} + +pub fn is_enabled() -> bool { + ENABLED.with(|e| *e.borrow()) +} + +pub fn log(msg: impl Into) { + if !is_enabled() { return; } + let ms = T0.with(|t| t.borrow().map(|i| i.elapsed().as_millis()).unwrap_or(0)); + LOG.with(|l| l.borrow_mut().push(format!("{ms:>6}ms {}", msg.into()))); +} + +/// Drain and return all collected entries (empties the internal buffer). +pub fn take() -> Vec { + LOG.with(|l| std::mem::take(&mut *l.borrow_mut())) +} diff --git a/src/detail/git.rs b/src/detail/git.rs new file mode 100644 index 0000000..a364208 --- /dev/null +++ b/src/detail/git.rs @@ -0,0 +1,96 @@ +use super::HistEntry; +use crate::status::format_age_pub; +use git2::Repository; + +pub use crate::util::git::{git_run, git_run_ok}; +pub use crate::util::url::to_web_url; +pub use crate::util::clipboard::copy_auto as copy_to_clipboard; + +pub fn build_history(path: &std::path::Path, limit: usize) -> Vec { + let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] }; + + let mut commits: Vec = { + let mut rw = match repo.revwalk() { Ok(r) => r, Err(_) => return vec![] }; + let _ = rw.push_head(); + rw.take(limit).filter_map(|id| { + let id = id.ok()?; + let c = repo.find_commit(id).ok()?; + let sha = id.to_string()[..7].to_string(); + let ts = c.time().seconds(); + let age = format_age_pub(ts); + let author = c.author().name().unwrap_or("").to_string(); + let msg = c.summary().unwrap_or("").chars().take(72).collect(); + Some(HistEntry::Commit { sha, age, ts, author, msg }) + }).collect() + }; + + let mut tags: Vec = vec![]; + if let Ok(tag_names) = repo.tag_names(None) { + for name in tag_names.iter().flatten() { + let refname = format!("refs/tags/{}", name); + if let Ok(obj) = repo.revparse_single(&refname) { + let (ts, tag_msg) = if let Ok(tag) = obj.clone().into_tag() { + let ts = tag.tagger().map(|s| s.when().seconds()).unwrap_or(0); + let msg = tag.message().unwrap_or("").chars().take(72).collect(); + (ts, msg) + } else if let Ok(c) = obj.peel_to_commit() { + (c.time().seconds(), String::new()) + } else { continue }; + tags.push(HistEntry::Tag { + name: name.to_string(), + age: format_age_pub(ts), + ts, + msg: tag_msg, + }); + } + } + } + + commits.extend(tags); + commits.sort_by(|a, b| b.ts().cmp(&a.ts())); + commits.truncate(limit + 20); + commits +} + +pub fn count_contributors(path: &std::path::Path) -> usize { + std::process::Command::new("git") + .arg("-C").arg(path).args(["shortlog", "-sn", "--all"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).lines().count()) + .unwrap_or(0) +} + +pub fn count_tags(path: &std::path::Path) -> usize { + std::process::Command::new("git") + .arg("-C").arg(path).args(["tag", "-l"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).lines().filter(|l| !l.is_empty()).count()) + .unwrap_or(0) +} + +pub fn count_stashes(path: &std::path::Path) -> usize { + std::process::Command::new("git") + .arg("-C").arg(path).args(["stash", "list"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).lines().count()) + .unwrap_or(0) +} + +pub fn count_local_branches(path: &std::path::Path) -> usize { + std::process::Command::new("git") + .arg("-C").arg(path).args(["branch"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).lines().count()) + .unwrap_or(1) +} + +pub fn list_all_files(path: &std::path::Path) -> Vec { + let out = std::process::Command::new("git") + .arg("-C").arg(path).args(["ls-files"]) + .output() + .unwrap_or_else(|_| std::process::Output { + status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![] + }); + String::from_utf8_lossy(&out.stdout) + .lines().map(|l| l.to_string()).collect() +} diff --git a/src/detail/mod.rs b/src/detail/mod.rs new file mode 100644 index 0000000..933b955 --- /dev/null +++ b/src/detail/mod.rs @@ -0,0 +1,244 @@ +mod git; +mod render; + +use crate::config::AppConfig; +use crate::status::{count_tracked_files, repo_detail, repo_recent_commits, RepoSummary}; +use anyhow::Result; +use crossterm::event::{Event, KeyCode}; +use git2::Repository; +use git::{ + build_history, copy_to_clipboard, count_contributors, count_local_branches, + count_stashes, count_tags, git_run, git_run_ok, list_all_files, to_web_url, +}; +use render::draw; + +// ── history entry (commits + release tags interleaved) ──────────────────────── + +#[derive(Clone)] +pub enum HistEntry { + Commit { sha: String, age: String, ts: i64, author: String, msg: String }, + Tag { name: String, age: String, ts: i64, msg: String }, +} + +impl HistEntry { + pub fn ts(&self) -> i64 { match self { Self::Commit { ts, .. } | Self::Tag { ts, .. } => *ts } } +} + +// ── tabs ────────────────────────────────────────────────────────────────────── + +#[derive(PartialEq, Clone, Copy)] +pub enum Tab { Overview, History, Files } + +// ── state ───────────────────────────────────────────────────────────────────── + +pub struct St { + pub summary: RepoSummary, + pub tab: Tab, + pub files: Vec<(char, String)>, + pub file_count: usize, + pub contributors: usize, + pub total_tags: usize, + pub recent_commits: Vec<(String, String)>, + pub stash_count: usize, + pub branch_count: usize, + pub history: Vec, + pub hist_off: usize, + pub file_off: usize, + pub show_all_files: bool, + pub all_files: Vec, + pub ov_scroll: usize, + pub quit: bool, + pub run_action: Option, + pub status_msg: Option, +} + +// ── entry point ─────────────────────────────────────────────────────────────── + +pub async fn run(summary: RepoSummary, cfg: &AppConfig) -> Result<()> { + let detail = repo_detail(&summary.path)?; + let path = summary.path.clone(); + + let mut st = St { + files: detail.modified_files, + file_count: count_tracked_files(&path), + contributors: count_contributors(&path), + total_tags: count_tags(&path), + recent_commits: repo_recent_commits(&path, 8), + stash_count: count_stashes(&path), + branch_count: count_local_branches(&path), + history: build_history(&path, 60), + hist_off: 0, + file_off: 0, + show_all_files: false, + all_files: list_all_files(&path), + ov_scroll: 0, + quit: false, + run_action: None, + status_msg: None, + tab: Tab::Overview, + summary, + }; + + loop { + let action = tui_phase(&mut st)?; + if st.quit { break; } + if let Some(a) = action { + exec_action(&mut st, a).await?; + } + } + let _ = cfg; + Ok(()) +} + +fn tui_phase(st: &mut St) -> Result> { + let mut terminal = crate::tui::setup()?; + let result = (|| -> Result> { + loop { + terminal.draw(|f| draw(f, st))?; + let event = crate::tui::next_event(250)?; + let key = match event { Some(Event::Key(k)) => k, _ => continue }; + handle_key(key.code, st); + if st.quit { return Ok(None); } + if let Some(a) = st.run_action.take() { return Ok(Some(a)); } + } + })(); + crate::tui::teardown(terminal)?; + result +} + +fn handle_key(code: KeyCode, st: &mut St) { + st.status_msg = None; + match code { + KeyCode::Char('q') | KeyCode::Esc => st.quit = true, + + KeyCode::Tab => { + st.tab = match st.tab { Tab::Overview => Tab::History, Tab::History => Tab::Files, Tab::Files => Tab::Overview }; + } + KeyCode::Char('1') => st.tab = Tab::Overview, + KeyCode::Char('2') => st.tab = Tab::History, + KeyCode::Char('3') => st.tab = Tab::Files, + + KeyCode::Down | KeyCode::Char('j') => match st.tab { + Tab::History => st.hist_off += 1, + Tab::Files => st.file_off += 1, + Tab::Overview => st.ov_scroll += 1, + }, + KeyCode::Up | KeyCode::Char('k') => match st.tab { + Tab::History => st.hist_off = st.hist_off.saturating_sub(1), + Tab::Files => st.file_off = st.file_off.saturating_sub(1), + Tab::Overview => st.ov_scroll = st.ov_scroll.saturating_sub(1), + }, + KeyCode::PageDown => match st.tab { + Tab::History => st.hist_off = (st.hist_off + 10).min(st.history.len().saturating_sub(1)), + Tab::Files => st.file_off += 10, + Tab::Overview => st.ov_scroll += 5, + }, + KeyCode::PageUp => match st.tab { + Tab::History => st.hist_off = st.hist_off.saturating_sub(10), + Tab::Files => st.file_off = st.file_off.saturating_sub(10), + Tab::Overview => st.ov_scroll = st.ov_scroll.saturating_sub(5), + }, + + KeyCode::Char('s') => { st.run_action = Some(0); } + KeyCode::Char('r') => { st.run_action = Some(1); } + KeyCode::Char('W') => { + if let Ok(repo) = Repository::open(&st.summary.path) { + if let Ok(remote) = repo.find_remote("origin") { + if let Some(url) = remote.url() { + let web = to_web_url(url); + let _ = std::process::Command::new("xdg-open").arg(&web).spawn(); + st.status_msg = Some("Opening in browser...".into()); + } + } + } + } + KeyCode::Char('o') => { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); + let _ = std::process::Command::new(&editor).arg(&st.summary.path).spawn(); + st.status_msg = Some(format!("Opened in {}", editor)); + } + KeyCode::Char('y') => { + let path = st.summary.path.to_string_lossy().to_string(); + if copy_to_clipboard(&path) { st.status_msg = Some("Path copied!".into()); } + } + KeyCode::Char('u') => { + if let Ok(repo) = Repository::open(&st.summary.path) { + if let Ok(remote) = repo.find_remote("origin") { + if let Some(url) = remote.url() { + if copy_to_clipboard(url) { st.status_msg = Some("URL copied!".into()); } + } + } + } + } + KeyCode::Char('g') => { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); + let gi = st.summary.path.join(".gitignore"); + let _ = std::process::Command::new(&editor).arg(&gi).spawn(); + st.status_msg = Some("Opening .gitignore...".into()); + } + KeyCode::Char('c') => { st.run_action = Some(10); } + KeyCode::Char('S') => { + let ok = git_run_ok(&st.summary.path, &["stash"]); + st.status_msg = Some(if ok { "Stashed.".into() } else { "Stash failed.".into() }); + if ok { st.stash_count += 1; } + } + KeyCode::Char('A') => { + let ok = git_run_ok(&st.summary.path, &["stash", "pop"]); + st.status_msg = Some(if ok { "Stash applied.".into() } else { "No stash / error.".into() }); + if ok { st.stash_count = st.stash_count.saturating_sub(1); } + } + KeyCode::Char('a') if st.tab == Tab::Files => { + st.show_all_files = !st.show_all_files; + st.file_off = 0; + } + + _ => {} + } +} + +async fn exec_action(st: &mut St, idx: usize) -> Result<()> { + let path = &st.summary.path; + match idx { + 0 => { + println!("\n── Sync: {} ──\n", st.summary.name); + let mode = inquire::Select::new("Action:", vec!["pull", "commit + push", "push only"]) + .with_page_size(3).prompt_skippable()?; + if let Some(m) = mode { + match m { + "pull" => git_run(path, &["pull"]), + "commit + push" => { + let msg = inquire::Text::new("Commit message:").with_default("chore: update").prompt()?; + git_run(path, &["add", "-A"]); + git_run(path, &["commit", "-m", &msg]); + git_run(path, &["push"]); + } + _ => git_run(path, &["push"]), + } + } + } + 1 => { + println!("\n── Release: {} ──\n", st.summary.name); + let tag = inquire::Text::new("Tag name (e.g. v1.0.0):").prompt()?; + let msg = inquire::Text::new("Tag message:").with_default(&tag).prompt()?; + git_run(path, &["tag", "-a", &tag, "-m", &msg]); + if inquire::Confirm::new("Push tag?").with_default(true).prompt()? { + git_run(path, &["push", "origin", &tag]); + } + st.history = build_history(path, 60); + st.total_tags += 1; + } + 10 => { + println!("\n── New branch in: {} ──\n", st.summary.name); + let name = inquire::Text::new("Branch name:").prompt()?; + if !name.trim().is_empty() { + git_run(path, &["checkout", "-b", &name]); + st.branch_count += 1; + st.summary.branch = name; + } + } + _ => {} + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + Ok(()) +} diff --git a/src/detail/render.rs b/src/detail/render.rs new file mode 100644 index 0000000..10f5baa --- /dev/null +++ b/src/detail/render.rs @@ -0,0 +1,365 @@ +use super::{HistEntry, St, Tab}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, + Frame, +}; + +pub fn draw(f: &mut Frame, st: &St) { + let area = f.area(); + + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Fill(1), Constraint::Length(1)]) + .split(area); + + render_header(f, st, vert[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Fill(1), Constraint::Length(24)]) + .split(vert[1]); + + match st.tab { + Tab::Overview => render_overview(f, st, body[0]), + Tab::History => render_history(f, st, body[0]), + Tab::Files => render_files(f, st, body[0]), + } + render_sidebar(f, st, body[1]); + + let hint = if let Some(ref msg) = st.status_msg { + format!(" {} — press any key to continue", msg) + } else { + match st.tab { + Tab::Overview => " [↑↓/jk] scroll [Tab/1-3] tab [q] back to hub".into(), + Tab::History => " [↑↓/jk] scroll [PgUp/Dn] fast [Tab/1-3] tab [q] back".into(), + Tab::Files => " [a] toggle all/changed [↑↓] scroll [g] edit .gitignore [q] back".into(), + } + }; + f.render_widget( + Paragraph::new(hint.as_str()).style(Style::default().fg(Color::DarkGray)), + vert[2], + ); +} + +fn render_header(f: &mut Frame, st: &St, area: Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(area); + + let r = &st.summary; + let owner_s = r.owner.as_deref().map(|o| format!(" @{}", o)).unwrap_or_default(); + let stars_s = r.stars.map(|s| format!(" ★ {}", s)).unwrap_or_default(); + let issues_s = r.open_issues.map(|i| if i > 0 { format!(" ⚠ {} issues", i) } else { String::new() }).unwrap_or_default(); + let prs_s = r.open_prs.map(|p| if p > 0 { format!(" ↔ {} PRs", p) } else { String::new() }).unwrap_or_default(); + + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {}", r.name), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled(owner_s, Style::default().fg(Color::DarkGray)), + Span::styled(format!(" ⎇ {}", r.branch), Style::default().fg(Color::Cyan)), + if r.is_dirty { Span::styled(" ⚠ dirty", Style::default().fg(Color::Yellow)) } + else { Span::styled(" ✓ clean", Style::default().fg(Color::DarkGray)) }, + Span::styled(stars_s, Style::default().fg(Color::Yellow)), + Span::styled(issues_s, Style::default().fg(Color::Red)), + Span::styled(prs_s, Style::default().fg(Color::Cyan)), + ])), + rows[0], + ); + + let tab_s = |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)) } + }; + f.render_widget( + Paragraph::new(Line::from(vec![ + tab_s("[1] Overview", st.tab == Tab::Overview), + Span::raw(" "), + tab_s("[2] History", st.tab == Tab::History), + Span::raw(" "), + tab_s("[3] Files", st.tab == Tab::Files), + ])), + rows[1], + ); +} + +fn render_sidebar(f: &mut Frame, st: &St, area: Rect) { + let blk = Block::default().title(" Actions ").borders(Borders::LEFT) + .border_style(Style::default().fg(Color::DarkGray)); + let inner = blk.inner(area); + f.render_widget(blk, area); + + let act = |key: &'static str, label: &'static str, col: Color| -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" [{}]", key), Style::default().fg(col).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}", label), Style::default().fg(Color::White)), + ]) + }; + let sep = || Line::from(Span::styled(" ─────────────────", Style::default().fg(Color::DarkGray))); + let stat_line = |label: &str, val: String, col: Color| -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<10}", label), Style::default().fg(Color::DarkGray)), + Span::styled(val, Style::default().fg(col)), + ]) + }; + + let mut lines: Vec = vec![ + stat_line("Branch", st.summary.branch.chars().take(11).collect(), Color::Cyan), + stat_line("Tags", st.total_tags.to_string(), Color::Yellow), + stat_line("Stashes", st.stash_count.to_string(), Color::DarkGray), + stat_line("Branches", st.branch_count.to_string(), Color::DarkGray), + sep(), + act("s", "Sync", Color::Green), + act("r", "Release", Color::Green), + sep(), + act("o", "Editor", Color::Cyan), + act("W", "Browser", Color::Cyan), + act("y", "Copy path", Color::Cyan), + act("u", "Copy URL", Color::Cyan), + act("g", ".gitignore", Color::Cyan), + sep(), + act("c", "New branch", Color::White), + act("S", "Stash", Color::White), + act("A", "Pop stash", Color::White), + sep(), + act("q", "← Back", Color::DarkGray), + ]; + + if let Some(ref msg) = st.status_msg { + lines.push(sep()); + let trunc: String = msg.chars().take(20).collect(); + lines.push(Line::from(Span::styled(format!(" {}", trunc), Style::default().fg(Color::Green)))); + } + + f.render_widget(Paragraph::new(lines), inner); +} + +fn render_overview(f: &mut Frame, st: &St, area: Rect) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) + .split(area); + + let r = &st.summary; + + let mut all_lines: Vec = vec![section("Identity"), Line::from("")]; + all_lines.push(kv("Path", &r.path.to_string_lossy())); + all_lines.push(kv("Owner", r.owner.as_deref().unwrap_or("—"))); + all_lines.push(kv("Visibility", r.visibility.as_deref().unwrap_or("unknown"))); + all_lines.push(kv("Language", r.lang.as_deref().unwrap_or("—"))); + all_lines.push(kv("License", if r.license { "✓ present" } else { "⚠ not found" })); + if let Some(s) = r.stars { all_lines.push(kv("Stars", &format!("★ {}", s))); } + if let Some(i) = r.open_issues { all_lines.push(kv("Issues", &format!("{} open", i))); } + if let Some(p) = r.open_prs { all_lines.push(kv("Open PRs", &format!("{}", p))); } + all_lines.push(Line::from("")); + + all_lines.push(section("Status")); + all_lines.push(Line::from("")); + let branch_detail = match (r.ahead, r.behind) { + (0, 0) => " (in sync)".to_string(), + (a, 0) => format!(" ↑{}", a), + (0, b) => format!(" ↓{}", b), + (a, b) => format!(" ↑{} ↓{}", a, b), + }; + all_lines.push(Line::from(vec![ + Span::styled(" Branch ", Style::default().fg(Color::DarkGray)), + Span::styled(r.branch.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled(branch_detail, Style::default().fg(Color::DarkGray)), + ])); + let (dirty_txt, dirty_col) = if r.is_dirty { + (format!("⚠ {} file(s)", st.files.len()), Color::Yellow) + } else { ("✓ clean".into(), Color::DarkGray) }; + all_lines.push(Line::from(vec![ + Span::styled(" Changes ", Style::default().fg(Color::DarkGray)), + Span::styled(dirty_txt, Style::default().fg(dirty_col)), + ])); + all_lines.push(Line::from("")); + + all_lines.push(section("Repository")); + all_lines.push(Line::from("")); + all_lines.push(kv("Files", &st.file_count.to_string())); + all_lines.push(kv("Branches", &st.branch_count.to_string())); + all_lines.push(kv("Contributors", &st.contributors.to_string())); + all_lines.push(kv("Stashes", &st.stash_count.to_string())); + all_lines.push(kv("Tags", &st.total_tags.to_string())); + all_lines.push(Line::from("")); + + all_lines.push(section("Last commit")); + all_lines.push(Line::from("")); + all_lines.push(kv("When", &r.last_commit_age)); + let msg_trunc: String = r.last_commit_msg.chars().take(46).collect(); + all_lines.push(Line::from(vec![ + Span::styled(" Message ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("\"{}\"", msg_trunc), Style::default().fg(Color::White)), + ])); + + let skip = st.ov_scroll.min(all_lines.len().saturating_sub(1)); + let visible: Vec = all_lines.into_iter().skip(skip).collect(); + + f.render_widget( + Paragraph::new(visible).block(Block::default().borders(Borders::RIGHT)), + cols[0], + ); + + let mut right: Vec = vec![section("Recent commits"), Line::from("")]; + for (age, msg) in &st.recent_commits { + right.push(Line::from(vec![ + Span::styled(format!(" {:>8} ", age), Style::default().fg(Color::DarkGray)), + Span::raw(msg.chars().take(cols[1].width.saturating_sub(14) as usize).collect::()), + ])); + } + if !st.files.is_empty() { + right.push(Line::from("")); + right.push(section("Changed files")); + right.push(Line::from("")); + if st.file_count > 500 { + right.push(Line::from(Span::styled( + format!(" ⚠ {} tracked — check .gitignore!", st.file_count), + Style::default().fg(Color::Yellow), + ))); + } + for (ch, path) in st.files.iter().take(10) { + let col = match ch { 'M' => Color::Yellow, 'D' => Color::Red, _ => Color::Green }; + right.push(Line::from(vec![ + Span::styled(format!(" {} ", ch), Style::default().fg(col)), + Span::styled( + path.chars().take(cols[1].width.saturating_sub(6) as usize).collect::(), + Style::default().fg(Color::DarkGray), + ), + ])); + } + if st.files.len() > 10 { + right.push(Line::from(Span::styled( + format!(" … {} more", st.files.len() - 10), + Style::default().fg(Color::DarkGray), + ))); + } + } + f.render_widget(Paragraph::new(right), cols[1]); +} + +fn render_history(f: &mut Frame, st: &St, area: Rect) { + let blk = Block::default() + .title(format!(" History ({} entries) ", st.history.len())) + .borders(Borders::ALL); + let inner = blk.inner(area); + f.render_widget(blk, area); + + let avail = inner.height.saturating_sub(1) as usize; + let header = Row::new(vec!["TYPE", "REF", "AGE", "AUTHOR / TAG MSG", "MESSAGE"]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let rows: Vec = st.history.iter() + .skip(st.hist_off).take(avail) + .map(|entry| match entry { + HistEntry::Tag { name, age, msg, .. } => { + Row::new(vec![ + Cell::from("🔖 tag").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Cell::from(name.clone()).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(""), + Cell::from(if msg.is_empty() { "(lightweight tag)".to_string() } else { msg.clone() }) + .style(Style::default().fg(Color::DarkGray)), + ]).style(Style::default().bg(Color::Reset)) + } + HistEntry::Commit { sha, age, author, msg, .. } => { + Row::new(vec![ + Cell::from("●").style(Style::default().fg(Color::DarkGray)), + Cell::from(sha.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(format!("@{}", author)).style(Style::default().fg(Color::Cyan)), + Cell::from(msg.clone()), + ]) + } + }) + .collect(); + + f.render_widget( + Table::new(rows, [ + Constraint::Length(7), + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(18), + Constraint::Fill(1), + ]).header(header), + inner, + ); +} + +fn render_files(f: &mut Frame, st: &St, area: Rect) { + let title: &str = if st.show_all_files { + " All tracked files — [a] show changed " + } else { + " Changed files — [a] show all tracked " + }; + + let blk = Block::default().title(title).borders(Borders::ALL); + let inner = blk.inner(area); + f.render_widget(blk, area); + + let mut warn_rows = 0usize; + if st.file_count > 500 && !st.show_all_files { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" ⚠ {} tracked files — large repo, consider .gitignore [g] edit it", st.file_count), + Style::default().fg(Color::Yellow), + ))), + Rect::new(inner.x, inner.y, inner.width, 1), + ); + warn_rows = 1; + } + + let usable = Rect::new(inner.x, inner.y + warn_rows as u16, inner.width, inner.height.saturating_sub(warn_rows as u16)); + + if st.show_all_files { + let avail = usable.height as usize; + let items: Vec = st.all_files.iter() + .skip(st.file_off).take(avail) + .map(|p| ListItem::new(Line::from(Span::styled(format!(" {}", p), Style::default().fg(Color::DarkGray))))) + .collect(); + let count = Line::from(Span::styled( + format!(" {} files total (skip: {})", st.all_files.len(), st.file_off), + Style::default().fg(Color::DarkGray), + )); + let mut all_items = vec![ListItem::new(count)]; + all_items.extend(items); + f.render_widget(List::new(all_items), usable); + } else { + if st.files.is_empty() { + f.render_widget( + Paragraph::new(" (working tree clean)").style(Style::default().fg(Color::DarkGray)), + usable, + ); + return; + } + let avail = usable.height.saturating_sub(1) as usize; + let rows: Vec = st.files.iter() + .skip(st.file_off).take(avail) + .map(|(ch, p)| { + let col = match ch { 'M' => Color::Yellow, 'D' => Color::Red, _ => Color::Green }; + Row::new(vec![ + Cell::from(ch.to_string()).style(Style::default().fg(col)), + Cell::from(p.clone()), + ]) + }) + .collect(); + f.render_widget(Table::new(rows, [Constraint::Length(3), Constraint::Fill(1)]), usable); + } +} + +fn section(title: &str) -> Line<'static> { + Line::from(Span::styled( + format!(" {}", title), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )) +} + +fn kv(key: &str, val: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<15}", key), Style::default().fg(Color::DarkGray)), + Span::raw(val.to_string()), + ]) +} diff --git a/src/hub/actions.rs b/src/hub/actions.rs new file mode 100644 index 0000000..95e6f39 --- /dev/null +++ b/src/hub/actions.rs @@ -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 = 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 = + 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::()); + 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 { + 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 { + let p = bookmarks_path(); + if !p.exists() { return HashSet::new(); } + #[derive(serde::Deserialize, Default)] + struct Bk { #[serde(default)] paths: Vec } + toml::from_str::(&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) { + #[derive(serde::Serialize)] + struct Bk { paths: Vec } + 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 { + let p = pins_path(); + if !p.exists() { return HashSet::new(); } + #[derive(serde::Deserialize, Default)] + struct Pins { #[serde(default)] paths: Vec } + toml::from_str::(&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) { + #[derive(serde::Serialize)] + struct Pins { paths: Vec } + 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); } +} diff --git a/src/hub/keys.rs b/src/hub/keys.rs new file mode 100644 index 0000000..10baa3b --- /dev/null +++ b/src/hub/keys.rs @@ -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 { + let mut terminal = crate::tui::setup()?; + let result = (|| -> Result { + 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 = 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 +} diff --git a/src/hub/mod.rs b/src/hub/mod.rs new file mode 100644 index 0000000..be294b1 --- /dev/null +++ b/src/hub/mod.rs @@ -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, + scan_rx: Option>, + 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 = meta_cache.all_prs().into_iter().cloned().collect(); + let cached_issues: Vec = 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 = + 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, _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 = 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::()), + 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::()).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) + }) +} diff --git a/src/hub/modals.rs b/src/hub/modals.rs new file mode 100644 index 0000000..178f741 --- /dev/null +++ b/src/hub/modals.rs @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = state.repos.iter() + .map(|r| projects::rel_key(&r.path, &state.workspace_root)) + .collect(); + let options: Vec = state.repos.iter().zip(&rel_paths) + .map(|(r, rel)| format!("{:<18} {}", r.name, rel)) + .collect(); + let checks: Vec = 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 = 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 = 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 = (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}")); + } + + _ => {} + } +} diff --git a/src/hub/render.rs b/src/hub/render.rs new file mode 100644 index 0000000..619ab0b --- /dev/null +++ b/src/hub/render.rs @@ -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 = 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 = 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); + } +} diff --git a/src/hub/right_panel.rs b/src/hub/right_panel.rs new file mode 100644 index 0000000..ec2049c --- /dev/null +++ b/src/hub/right_panel.rs @@ -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 = 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::(); + 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 = 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 = 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::>().chunks(max_w).map(|c| c.iter().collect::()) { + 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 = 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::()), 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::()), 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 = 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::()), 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::()), 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); +} diff --git a/src/hub/state.rs b/src/hub/state.rs new file mode 100644 index 0000000..156ce23 --- /dev/null +++ b/src/hub/state.rs @@ -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, cursor: usize, tag: SelectTag }, + Multi { title: String, options: Vec, checks: Vec, cursor: usize, tag: MultiTag }, + Confirm { message: String, tag: ConfirmTag }, + Info { title: String, lines: Vec, 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), +} + +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, + pub projects: Vec, + pub project_map: HashMap, + pub todos: Vec, + pub prs: Vec, + pub issues: Vec, + pub tab: Tab, + pub display: Vec, + pub repo_pos: Vec, + pub cursor: usize, + pub offset: usize, + pub visible_rows: usize, + pub selected: HashSet, + 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, + pub pins: HashSet, + pub status_msg: Option, + pub pending_clone: Option, + pub modal: Modal, + pub confirming: Option, + 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, + pub editor: String, + pub clipboard_cmd: String, + pub show_code_todos:bool, + pub debug_mode: bool, + pub debug_log: Vec, + /// Live receiver from the background scan thread; None when scan is complete. + pub scan_rx: Option>, + pub meta_cache: crate::cache::MetaCache, + /// Background diff computation in progress: (repo_idx, receiver). + pub diff_rx: Option<(usize, std::sync::mpsc::Receiver>)>, +} + +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 = (0..self.repos.len()) + .filter(|&ri| self.passes_filter(ri)) + .collect(); + let mut pinned: Vec = all_idx.iter().copied().filter(|&ri| self.pins.contains(&self.repos[ri].path)).collect(); + let mut regular: Vec = 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 { + self.repo_pos.get(self.cursor).copied() + } + pub fn cur_repo_idx(&self) -> Option { + match self.display.get(self.cur_di()?)? { + DisplayRow::Repo { repo_idx } => Some(*repo_idx), + _ => None, + } + } + pub fn cur_proj_idx(&self) -> Option { + match self.display.get(self.cur_di()?)? { + DisplayRow::Project { proj_idx } => Some(*proj_idx), + _ => None, + } + } + pub fn cur_todo_idx(&self) -> Option { + match self.display.get(self.cur_di()?)? { + DisplayRow::Todo { todo_idx } => Some(*todo_idx), + _ => None, + } + } + + pub fn project_repos(&self, pi: usize) -> Vec { + 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 { + 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 { + if self.selected.len() > 1 { + let mut v: Vec = 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 { + 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 +} diff --git a/src/hub/tabs.rs b/src/hub/tabs.rs new file mode 100644 index 0000000..6724b34 --- /dev/null +++ b/src/hub/tabs.rs @@ -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 = 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::()), + Cell::from(proj_name.chars().take(10).collect::()).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::()).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 = 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 = 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 = 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::())) + .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::())) + .style(Style::default().fg(Color::Green)), + Cell::from(pr.title.chars().take(60).collect::()), + 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 = 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::())) + .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::())) + .style(Style::default().fg(Color::Yellow)), + Cell::from(issue.title.chars().take(60).collect::()), + 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), + } +} diff --git a/src/labels/mod.rs b/src/labels/mod.rs new file mode 100644 index 0000000..da48f1b --- /dev/null +++ b/src/labels/mod.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Serialize, Deserialize, Default)] +struct LabelsFile { + #[serde(default)] + labels: HashMap, // absolute path → display label +} + +fn labels_path(workspace_root: &Path) -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".fxg") + .join("labels.toml") +} + +pub fn load_labels(workspace_root: &Path) -> HashMap { + let path = labels_path(workspace_root); + if !path.exists() { return HashMap::new(); } + let raw = std::fs::read_to_string(&path).unwrap_or_default(); + let file: LabelsFile = toml::from_str(&raw).unwrap_or_default(); + file.labels.into_iter().map(|(k, v)| (PathBuf::from(k), v)).collect() +} + +pub fn save_label(repo_path: &Path, label: &str, workspace_root: &Path) -> anyhow::Result<()> { + let path = labels_path(workspace_root); + let raw = if path.exists() { std::fs::read_to_string(&path)? } else { String::new() }; + let mut file: LabelsFile = toml::from_str(&raw).unwrap_or_default(); + + let key = repo_path.to_string_lossy().to_string(); + if label.is_empty() { + file.labels.remove(&key); + } else { + file.labels.insert(key, label.to_string()); + } + + if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } + std::fs::write(path, toml::to_string_pretty(&file)?)?; + Ok(()) +} + +pub fn remove_label(repo_path: &Path, workspace_root: &Path) -> anyhow::Result<()> { + save_label(repo_path, "", workspace_root) +} diff --git a/src/adopt/mod.rs b/src/legacy/adopt/mod.rs similarity index 100% rename from src/adopt/mod.rs rename to src/legacy/adopt/mod.rs diff --git a/src/adopt/ui.rs b/src/legacy/adopt/ui.rs similarity index 99% rename from src/adopt/ui.rs rename to src/legacy/adopt/ui.rs index 86d735b..fa9a365 100644 --- a/src/adopt/ui.rs +++ b/src/legacy/adopt/ui.rs @@ -1,4 +1,4 @@ -use crate::adopt::{adopt_folder, AdoptResult}; +use super::{adopt_folder, AdoptResult}; use crate::config::AppConfig; use crate::tui; use anyhow::Result; diff --git a/src/init/mod.rs b/src/legacy/init/mod.rs similarity index 100% rename from src/init/mod.rs rename to src/legacy/init/mod.rs diff --git a/src/init/wizard.rs b/src/legacy/init/wizard.rs similarity index 99% rename from src/init/wizard.rs rename to src/legacy/init/wizard.rs index ffdee00..e56d295 100644 --- a/src/init/wizard.rs +++ b/src/legacy/init/wizard.rs @@ -1,5 +1,5 @@ use crate::auth::Account; -use crate::init::InitConfig; +use super::InitConfig; use anyhow::Result; use inquire::{Confirm, MultiSelect, Select, Text}; diff --git a/src/legacy/manage/mod.rs b/src/legacy/manage/mod.rs new file mode 100644 index 0000000..d2c0e01 --- /dev/null +++ b/src/legacy/manage/mod.rs @@ -0,0 +1,407 @@ +use crate::auth::load_accounts; +use crate::cli::{ManageAction, ManageArgs}; +use crate::config::AppConfig; +use crate::providers::{default_account, make_provider}; +use crate::status::scan_workspace; +use anyhow::Result; +use inquire::{Confirm, Select, Text}; +use std::path::PathBuf; + +pub async fn run(args: ManageArgs, cfg: &AppConfig) -> Result<()> { + let accounts = load_accounts(cfg)?; + let account = default_account(&accounts, None) + .ok_or_else(|| anyhow::anyhow!( + "No accounts configured. Run: fxg auth add --provider github --token " + ))?; + let provider = make_provider(account)?; + + match args.action { + None => { + println!("Usage: fxg manage "); + println!(); + println!(" delete [--local] Delete remote (+ optionally local) repo"); + println!(" rename Rename repo on remote + locally"); + println!(" visibility [] Change visibility (picker)"); + println!(" topics [] Set topics (picker)"); + println!(" archive Archive repo (read-only)"); + } + + Some(ManageAction::Delete { pub_repo, local }) => { + let confirmed = Confirm::new(&format!( + "Delete remote repo '{}'? This cannot be undone.", pub_repo + )) + .with_default(false) + .prompt()?; + if !confirmed { + println!("Cancelled."); + return Ok(()); + } + + provider.delete_repo(&pub_repo).await?; + println!("✓ Remote repo '{}' deleted", pub_repo); + + if local { + let path = cfg.workspace_root.join(&pub_repo); + if path.exists() { + let confirmed2 = Confirm::new(&format!( + "Also delete local directory '{}'?", path.display() + )) + .with_default(false) + .prompt()?; + if confirmed2 { + std::fs::remove_dir_all(&path)?; + println!("✓ Local directory removed"); + } + } else { + println!(" (local directory not found, skipping)"); + } + } + } + + Some(ManageAction::Rename { pub_repo, new_name }) => { + let confirmed = Confirm::new(&format!( + "Rename '{}' → '{}'?", pub_repo, new_name + )) + .with_default(true) + .prompt()?; + if !confirmed { + println!("Cancelled."); + return Ok(()); + } + + provider.rename_repo(&pub_repo, &new_name).await?; + println!("✓ Remote repo renamed to '{}'", new_name); + + // Rename local dir if it exists under workspace_root or any group + let candidates: Vec = { + let mut v = vec![cfg.workspace_root.join(&pub_repo)]; + for g in &cfg.workspace_groups { + v.push(cfg.workspace_root.join(g).join(&pub_repo)); + } + v + }; + for old_path in candidates { + if old_path.exists() { + let new_path = old_path.parent().unwrap().join(&new_name); + std::fs::rename(&old_path, &new_path)?; + println!("✓ Local directory renamed: {} → {}", old_path.display(), new_path.display()); + break; + } + } + } + + Some(ManageAction::Visibility { pub_repo }) => { + let repo = resolve_repo(pub_repo, cfg)?; + let choice = Select::new( + &format!("New visibility for '{}':", repo), + vec!["private", "public"], + ) + .with_help_message("Use ↑/↓ to select, Enter to confirm") + .prompt()?; + let private = choice == "private"; + provider.update_visibility(&repo, private).await?; + println!("✓ Repo '{}' is now {}", repo, choice); + } + + Some(ManageAction::Topics { pub_repo, set }) => { + let repo = resolve_repo(pub_repo, cfg)?; + let topics: Vec = if set.is_empty() { + let raw = Text::new(&format!("Topics for '{}' (comma-separated):", repo)) + .with_placeholder("rust, cli, tools") + .with_default("") + .prompt()?; + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } else { + set + }; + provider.set_topics(&repo, &topics).await?; + if topics.is_empty() { + println!("✓ Topics cleared for '{}'", repo); + } else { + println!("✓ Topics set for '{}': {}", repo, topics.join(", ")); + } + } + + Some(ManageAction::Archive { pub_repo }) => { + let confirmed = Confirm::new(&format!( + "Archive '{}' on remote? It will become read-only.", pub_repo + )) + .with_default(false) + .prompt()?; + if !confirmed { + println!("Cancelled."); + return Ok(()); + } + provider.archive_repo(&pub_repo).await?; + println!("✓ Repo '{}' archived (read-only)", pub_repo); + } + + Some(ManageAction::Categorize) => { + // provider is optional for categorize — topics need auth, moves don't + drop(provider); + return run_categorize(cfg).await; + } + } + + Ok(()) +} + +fn resolve_repo(name: Option, cfg: &AppConfig) -> Result { + if let Some(n) = name { + return Ok(n); + } + let repos = scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)?; + if repos.is_empty() { + anyhow::bail!("No repos found in workspace. Check workspace_root in config."); + } + let names: Vec = repos.iter().map(|r| r.name.clone()).collect(); + Ok(Select::new("Select repository:", names) + .with_help_message("Use ↑/↓ to select, Enter to confirm") + .prompt()?) +} + +// ── categorize ──────────────────────────────────────────────────────────────── + +const CATEGORIES: &[&str] = &[ + "webdev", + "software", + "gamedev", + "minecraft", + "scpsl", + "pterodactyl", + "tools", + "libs", + "bots", + "experiments", +]; + +const PROJECT_TYPES: &[&str] = &[ + "fullstack", + "frontend", + "backend", + "plugin", + "mod", + "cli", + "library", + "bot", + "game", + "api", + "script", + "template", +]; + +const TECHNOLOGIES: &[&str] = &[ + "nodejs", + "typescript", + "rust", + "java", + "csharp", + "go", + "python", + "lua", + "php", + "mixed", + "other", +]; + +struct RepoPlan { + folder_name: String, + current_path: PathBuf, + target_path: Option, + topics: Vec, +} + +/// Collect all existing project-group subfolder names under workspace_root/category. +fn existing_groups(workspace_root: &std::path::Path, category: &str) -> Vec { + let dir = workspace_root.join(category); + std::fs::read_dir(&dir).ok() + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().is_dir() && !e.path().join(".git").exists()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect() +} + +async fn run_categorize(cfg: &AppConfig) -> Result<()> { + let repos = scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)?; + if repos.is_empty() { + println!("No repos found. Check workspace_root in fxg config."); + return Ok(()); + } + + let total = repos.len(); + println!("\n══════════════════════════════════════════════════════"); + println!(" fxg categorize — {} repos", total); + println!(" Structure: workspace/category/project/repo"); + println!(" Skip any repo with Esc on the first prompt"); + println!("══════════════════════════════════════════════════════\n"); + + let mut plans: Vec = Vec::new(); + + for (idx, repo) in repos.iter().enumerate() { + let folder_name = repo.path + .file_name().unwrap_or_default() + .to_string_lossy().to_string(); + + println!("─────────────────────────────────────────────────────"); + println!("[{}/{}] {}", + idx + 1, total, + repo.path.strip_prefix(&cfg.workspace_root) + .unwrap_or(&repo.path) + .display()); + println!(" branch: {} dirty: {} last commit: {}", + repo.branch, + if repo.is_dirty { "yes" } else { "no" }, + repo.last_commit_age); + + // Step 1 — category (Esc = skip this repo) + let category = match Select::new(" Category:", CATEGORIES.to_vec()) + .with_page_size(CATEGORIES.len()) + .with_help_message("Esc to skip this repo") + .prompt_skippable()? + { + Some(c) => c, + None => { println!(" skipped\n"); continue; } + }; + + // Step 2 — project group (blank = standalone, no subfolder) + let mut existing = existing_groups(&cfg.workspace_root, category); + existing.sort(); + existing.dedup(); + + let project_group: String = if existing.is_empty() { + Text::new(" Project group (blank = standalone):") + .with_help_message("e.g. 'vulcode', 'platformaHostingowa' — groups repos under one project") + .with_default("") + .prompt()? + .trim() + .to_string() + } else { + // Show existing groups + option to type a new one + let mut choices: Vec = existing.clone(); + choices.push("[ new group... ]".into()); + choices.push("[ standalone — no group ]".into()); + + let picked = Select::new(" Project group:", choices.iter().map(|s| s.as_str()).collect()) + .with_page_size(choices.len().min(15)) + .with_help_message("Pick existing group, create new, or leave standalone") + .prompt()?; + + if picked == "[ new group... ]" { + Text::new(" New group name:").prompt()?.trim().to_string() + } else if picked == "[ standalone — no group ]" { + String::new() + } else { + picked.to_string() + } + }; + + // Step 3 — project type + tech (GitHub topics) + let proj_type = Select::new(" Project type (GitHub topic):", PROJECT_TYPES.to_vec()) + .with_page_size(PROJECT_TYPES.len()) + .prompt()?; + + let tech = Select::new(" Primary tech (GitHub topic):", TECHNOLOGIES.to_vec()) + .with_page_size(TECHNOLOGIES.len()) + .prompt()?; + + let topics = vec![proj_type.to_string(), tech.to_string()]; + + // Build target path: workspace/category[/group]/repo + let target_dir = if project_group.is_empty() { + cfg.workspace_root.join(category) + } else { + cfg.workspace_root.join(category).join(&project_group) + }; + let target_path_full = target_dir.join(&folder_name); + let already_there = repo.path == target_path_full; + + let do_move = if already_there { + println!(" (already in correct location)"); + false + } else { + let dest_display = target_path_full + .strip_prefix(&cfg.workspace_root) + .map(|r| r.display().to_string()) + .unwrap_or_else(|_| target_path_full.display().to_string()); + Confirm::new(&format!(" Move to workspace/{}?", dest_display)) + .with_default(true) + .prompt()? + }; + + plans.push(RepoPlan { + folder_name, + current_path: repo.path.clone(), + target_path: if do_move { Some(target_path_full) } else { None }, + topics, + }); + println!(); + } + + if plans.is_empty() { + println!("Nothing to do."); + return Ok(()); + } + + // ── Summary ─────────────────────────────────────────────────────────────── + println!("\n══════════════════════════════════════════════════════"); + println!(" Summary — {} repos", plans.len()); + println!("══════════════════════════════════════════════════════"); + for p in &plans { + let dest = match &p.target_path { + Some(t) => t.strip_prefix(&cfg.workspace_root) + .map(|r| format!("workspace/{}", r.display())) + .unwrap_or_else(|_| t.display().to_string()), + None => "(no move)".to_string(), + }; + println!(" {:<30} → {:<40} [{}]", + p.folder_name, dest, p.topics.join(", ")); + } + + let proceed = Confirm::new("\nApply all changes?") + .with_default(true) + .prompt()?; + if !proceed { println!("Cancelled."); return Ok(()); } + + // ── Execute ─────────────────────────────────────────────────────────────── + let accounts = load_accounts(cfg)?; + let provider_opt = default_account(&accounts, None) + .and_then(|a| make_provider(a).ok()); + + if provider_opt.is_none() { + println!(" (no fxg account — GitHub topics skipped; run: fxg auth import)"); + } + + for p in &plans { + if let Some(ref provider) = provider_opt { + match provider.set_topics(&p.folder_name, &p.topics).await { + Ok(_) => println!(" ✓ [{}] topics: {}", p.folder_name, p.topics.join(", ")), + Err(e) => println!(" ✗ [{}] topics: {}", p.folder_name, e), + } + } + if let Some(ref dest) = p.target_path { + if let Some(parent) = dest.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + println!(" ✗ [{}] mkdir: {}", p.folder_name, e); + continue; + } + } + match std::fs::rename(&p.current_path, dest) { + Ok(_) => { + let rel = dest.strip_prefix(&cfg.workspace_root) + .map(|r| r.display().to_string()) + .unwrap_or_else(|_| dest.display().to_string()); + println!(" ✓ [{}] moved → workspace/{}", p.folder_name, rel); + } + Err(e) => println!(" ✗ [{}] move: {}", p.folder_name, e), + } + } + } + + println!("\n✓ Done."); + Ok(()) +} diff --git a/src/legacy/menu.rs b/src/legacy/menu.rs new file mode 100644 index 0000000..f921431 --- /dev/null +++ b/src/legacy/menu.rs @@ -0,0 +1,138 @@ +use crate::config::AppConfig; +use anyhow::Result; +use inquire::Select; + +// Top-level menu entries +const MAIN: &[(&str, &str)] = &[ + ("status", "View all repositories"), + ("sync", "Pull / commit / push / branch / PR"), + ("manage", "Rename · visibility · topics · delete · archive · categorize"), + ("util", "Unified log · secret scan · new doc"), + ("release", "Create tags & GitHub releases"), + ("init", "Initialize new repository"), + ("adopt", "Import local folder → git + remote"), + ("auth", "Manage provider accounts"), + ("config", "Settings"), +]; + +const MANAGE: &[(&str, &str)] = &[ + ("categorize", "Organize repos into categories (local + GitHub topics)"), + ("visibility", "Change visibility (private / public)"), + ("topics", "Set GitHub topics"), + ("rename", "Rename repo on remote + locally"), + ("delete", "Delete repo (remote + optional local)"), + ("archive", "Archive repo (read-only on remote)"), +]; + +const UTIL: &[(&str, &str)] = &[ + ("log", "Unified chronological log across all repos"), + ("find-secrets", "Scan git history for leaked credentials"), + ("new-doc", "Create CHANGELOG / README / TODO in a repo"), +]; + +fn make_choices(items: &[(&str, &str)]) -> Vec { + let mut v: Vec = items.iter().enumerate() + .map(|(i, (cmd, desc))| format!(" {} {:<14} {}", i + 1, cmd, desc)) + .collect(); + v.push(" 0 back / exit".to_string()); + v +} + +fn pick(title: &str, items: &[(&str, &str)]) -> Result> { + let choices = make_choices(items); + let page = choices.len(); + match Select::new(title, choices.iter().map(|s| s.as_str()).collect()) + .with_page_size(page) + .prompt_skippable()? + { + None => Ok(None), + Some(s) => { + // Parse the leading number + let num: usize = s.trim_start().chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .unwrap_or(0); + Ok(Some(num)) + } + } +} + +pub async fn run(cfg: &AppConfig) -> Result<()> { + loop { + println!(); // breathing room after alternate-screen exit + let choice = pick("fxg — Main Menu", MAIN)?; + + match choice { + None | Some(0) => break, + + Some(1) => { + crate::status::run( + crate::cli::StatusArgs { repo: None, workspace: None, filter: None }, + cfg, + ).await?; + } + + Some(2) => { + crate::legacy::sync::run( + crate::cli::SyncArgs { action: None }, + cfg, + ).await?; + } + + Some(3) => { + // Manage sub-menu + loop { + let sub = pick("fxg manage", MANAGE)?; + match sub { + None | Some(0) => break, + Some(1) => { crate::legacy::manage::run(crate::cli::ManageArgs { action: Some(crate::cli::ManageAction::Categorize) }, cfg).await?; } + Some(2) => { crate::legacy::manage::run(crate::cli::ManageArgs { action: Some(crate::cli::ManageAction::Visibility { pub_repo: None }) }, cfg).await?; } + Some(3) => { crate::legacy::manage::run(crate::cli::ManageArgs { action: Some(crate::cli::ManageAction::Topics { pub_repo: None, set: vec![] }) }, cfg).await?; } + Some(4) => { + if let Ok(name) = inquire::Text::new("Repo name to rename:").prompt() { + if let Ok(new) = inquire::Text::new("New name:").prompt() { + crate::legacy::manage::run(crate::cli::ManageArgs { action: Some(crate::cli::ManageAction::Rename { pub_repo: name, new_name: new }) }, cfg).await?; + } + } + } + Some(5) => { + if let Ok(name) = inquire::Text::new("Repo name to delete:").prompt() { + crate::legacy::manage::run(crate::cli::ManageArgs { action: Some(crate::cli::ManageAction::Delete { pub_repo: name, local: false }) }, cfg).await?; + } + } + Some(6) => { + if let Ok(name) = inquire::Text::new("Repo name to archive:").prompt() { + crate::legacy::manage::run(crate::cli::ManageArgs { action: Some(crate::cli::ManageAction::Archive { pub_repo: name }) }, cfg).await?; + } + } + _ => {} + } + } + } + + Some(4) => { + // Util sub-menu + loop { + let sub = pick("fxg util", UTIL)?; + match sub { + None | Some(0) => break, + Some(1) => { crate::legacy::util::run(crate::cli::UtilArgs { action: Some(crate::cli::UtilAction::Log { limit: 30 }) }, cfg).await?; } + Some(2) => { crate::legacy::util::run(crate::cli::UtilArgs { action: Some(crate::cli::UtilAction::FindSecrets { repos: vec![] }) }, cfg).await?; } + Some(3) => { crate::legacy::util::run(crate::cli::UtilArgs { action: Some(crate::cli::UtilAction::NewDoc { pub_repo: None, doc_type: None }) }, cfg).await?; } + _ => {} + } + } + } + + Some(5) => { crate::legacy::release::run(crate::cli::ReleaseArgs { action: None }, cfg).await?; } + Some(6) => { crate::legacy::init::run(crate::cli::InitArgs { name: None, provider: None, tags: vec![], status: "private".into(), branch: "main".into(), batch: None }, cfg).await?; } + Some(7) => { crate::legacy::adopt::run(crate::cli::AdoptArgs { name: None, all: false, provider: None, visibility: "private".into(), message: "chore: init".into() }, cfg).await?; } + Some(8) => { crate::auth::run(crate::cli::AuthArgs { action: None }, cfg).await?; } + Some(9) => { crate::config::run(cfg.clone()).await?; } + _ => {} + } + } + + Ok(()) +} diff --git a/src/legacy/mod.rs b/src/legacy/mod.rs new file mode 100644 index 0000000..8bc530b --- /dev/null +++ b/src/legacy/mod.rs @@ -0,0 +1,11 @@ +#![allow(dead_code, unused_imports, unused_variables, unused_mut, unused_must_use)] +pub mod adopt; +pub mod init; +pub mod manage; +pub mod menu; +pub mod refactor; +pub mod release; +pub mod stats; +pub mod summary; +pub mod sync; +pub mod util; diff --git a/src/refactor/mod.rs b/src/legacy/refactor/mod.rs similarity index 100% rename from src/refactor/mod.rs rename to src/legacy/refactor/mod.rs diff --git a/src/refactor/ui.rs b/src/legacy/refactor/ui.rs similarity index 94% rename from src/refactor/ui.rs rename to src/legacy/refactor/ui.rs index a2502b0..e4aeb14 100644 --- a/src/refactor/ui.rs +++ b/src/legacy/refactor/ui.rs @@ -1,5 +1,5 @@ use crate::config::AppConfig; -use crate::refactor::{CleanResult, FindResult}; +use super::{CleanResult, FindResult}; use crate::tui; use anyhow::Result; use crossterm::event::{Event, KeyCode}; @@ -121,7 +121,7 @@ fn handle_key(state: &mut State, key: crossterm::event::KeyEvent, cfg: &AppConfi if state.input_active { match key.code { KeyCode::Enter => { - let results = crate::refactor::find_in_dir(&state.input_buf, &cfg.workspace_root, "workspace"); + let results = super::find_in_dir(&state.input_buf, &cfg.workspace_root, "workspace"); state.find_results = results; state.cursor = 0; state.scroll = 0; @@ -147,12 +147,12 @@ fn handle_key(state: &mut State, key: crossterm::event::KeyEvent, cfg: &AppConfi state.status = Some("Type pattern and press Enter".into()); } KeyCode::Char('3') => { - state.clean_results = crate::refactor::find_unused("dead-code", &cfg.workspace_root); + state.clean_results = super::find_unused("dead-code", &cfg.workspace_root); state.view = View::CleanResults; state.cursor = 0; } KeyCode::Char('4') => { - state.find_results = crate::refactor::generate_todo_list(&cfg.workspace_root); + state.find_results = super::generate_todo_list(&cfg.workspace_root); state.view = View::FindResults; state.cursor = 0; } diff --git a/src/release/mod.rs b/src/legacy/release/mod.rs similarity index 92% rename from src/release/mod.rs rename to src/legacy/release/mod.rs index e06523a..78c2841 100644 --- a/src/release/mod.rs +++ b/src/legacy/release/mod.rs @@ -97,9 +97,24 @@ fn tag_one_repo(path: &Path, version: &str, token: &str, cfg: &AppConfig) -> Res Ok(()) } +fn find_repo_upward() -> Result<(Repository, std::path::PathBuf)> { + let mut current = std::env::current_dir()?; + loop { + if current.join(".git").exists() { + let repo = Repository::open(¤t)?; + return Ok((repo, current)); + } + match current.parent() { + Some(p) => current = p.to_path_buf(), + None => anyhow::bail!( + "not inside a git repository — run from within a repo, or cd into one first" + ), + } + } +} + pub async fn run(args: ReleaseArgs, cfg: &AppConfig) -> Result<()> { - let cwd = std::env::current_dir()?; - let repo = Repository::open(&cwd)?; + let (repo, cwd) = find_repo_upward()?; let accounts = load_accounts(cfg)?; let account = crate::providers::default_account(&accounts, None) .ok_or_else(|| anyhow::anyhow!("no account configured — run: fxg auth add"))?; diff --git a/src/release/ui.rs b/src/legacy/release/ui.rs similarity index 99% rename from src/release/ui.rs rename to src/legacy/release/ui.rs index 80de309..0e93520 100644 --- a/src/release/ui.rs +++ b/src/legacy/release/ui.rs @@ -1,4 +1,4 @@ -use crate::release::{format_changelog_md, ChangelogEntry}; +use super::{format_changelog_md, ChangelogEntry}; use crate::tui; use anyhow::Result; use crossterm::event::{Event, KeyCode}; diff --git a/src/stats/mod.rs b/src/legacy/stats/mod.rs similarity index 100% rename from src/stats/mod.rs rename to src/legacy/stats/mod.rs diff --git a/src/stats/ui.rs b/src/legacy/stats/ui.rs similarity index 99% rename from src/stats/ui.rs rename to src/legacy/stats/ui.rs index 8af57f0..99d6a14 100644 --- a/src/stats/ui.rs +++ b/src/legacy/stats/ui.rs @@ -1,4 +1,4 @@ -use crate::stats::{CodeStats, SecWarning, TodoItem}; +use super::{CodeStats, SecWarning, TodoItem}; use crate::tui; use anyhow::Result; use crossterm::event::{Event, KeyCode}; diff --git a/src/summary/mod.rs b/src/legacy/summary/mod.rs similarity index 99% rename from src/summary/mod.rs rename to src/legacy/summary/mod.rs index 1885fa2..18e8b77 100644 --- a/src/summary/mod.rs +++ b/src/legacy/summary/mod.rs @@ -1,6 +1,6 @@ use crate::config::AppConfig; use crate::cli::SummaryArgs; -use crate::stats::collect_stats; +use crate::legacy::stats::collect_stats; use anyhow::Result; use git2::Repository; use std::collections::HashMap; diff --git a/src/sync/mod.rs b/src/legacy/sync/mod.rs similarity index 97% rename from src/sync/mod.rs rename to src/legacy/sync/mod.rs index a84cd71..cbdb808 100644 --- a/src/sync/mod.rs +++ b/src/legacy/sync/mod.rs @@ -131,14 +131,14 @@ pub async fn run(args: SyncArgs, cfg: &AppConfig) -> Result<()> { match args.action { None => { - let repos = crate::status::scan_workspace(&cfg.workspace_root)?; + let repos = crate::status::scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)?; ui::run_sync_tui(repos, cfg) } Some(SyncAction::Pull { all, workspace }) => { let repos = if let Some(ws) = workspace { repos_for_workspace(&ws, cfg)? } else if all { - crate::status::scan_workspace(&cfg.workspace_root)? + crate::status::scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)? } else { let cwd = std::env::current_dir()?; vec![crate::status::repo_summary(&cwd)?] @@ -150,7 +150,7 @@ pub async fn run(args: SyncArgs, cfg: &AppConfig) -> Result<()> { } Some(SyncAction::Commit { message, all }) => { let msg = message.unwrap_or_else(|| "chore: sync".into()); - let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? } + let repos = if all { crate::status::scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)? } else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] }; for r in repos.iter().filter(|r| r.is_dirty) { print_result(&commit_repo(&r.path, &msg, &cfg.username, &cfg.email) @@ -159,7 +159,7 @@ pub async fn run(args: SyncArgs, cfg: &AppConfig) -> Result<()> { Ok(()) } Some(SyncAction::Push { all, force_with_lease, message }) => { - let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? } + let repos = if all { crate::status::scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)? } else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] }; // If -m given, commit dirty repos first if let Some(msg) = &message { @@ -191,7 +191,7 @@ pub async fn run(args: SyncArgs, cfg: &AppConfig) -> Result<()> { } BranchAction::Delete { name } => delete_branch(&std::env::current_dir()?, &name)?, BranchAction::Switch { name, all } => { - let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? } + let repos = if all { crate::status::scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)? } else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] }; for r in &repos { let _ = switch_branch(&r.path, &name); } } diff --git a/src/sync/pr.rs b/src/legacy/sync/pr.rs similarity index 100% rename from src/sync/pr.rs rename to src/legacy/sync/pr.rs diff --git a/src/sync/ui.rs b/src/legacy/sync/ui.rs similarity index 96% rename from src/sync/ui.rs rename to src/legacy/sync/ui.rs index a80a314..b822744 100644 --- a/src/sync/ui.rs +++ b/src/legacy/sync/ui.rs @@ -1,6 +1,6 @@ use crate::config::AppConfig; use crate::status::RepoSummary; -use crate::sync::SyncResult; +use super::SyncResult; use crate::tui; use anyhow::Result; use crossterm::event::{Event, KeyCode}; @@ -176,11 +176,11 @@ fn execute_action(state: &mut State, cfg: &AppConfig) { for repo in &selected { let result = match state.action_cursor { - 0 => crate::sync::pull_repo(&repo.path, &token) + 0 => super::pull_repo(&repo.path, &token) .unwrap_or_else(|e| SyncResult::err(&repo.name, "pull", e.to_string())), - 1 => crate::sync::commit_repo(&repo.path, &state.commit_msg, &cfg.username, &cfg.email) + 1 => super::commit_repo(&repo.path, &state.commit_msg, &cfg.username, &cfg.email) .unwrap_or_else(|e| SyncResult::err(&repo.name, "commit", e.to_string())), - 2 => crate::sync::push_repo(&repo.path, &token, false) + 2 => super::push_repo(&repo.path, &token, false) .unwrap_or_else(|e| SyncResult::err(&repo.name, "push", e.to_string())), _ => SyncResult::err(&repo.name, ACTIONS[state.action_cursor], "niedostepne w TUI — uzyj: fxg sync branch create / fxg sync pr create"), diff --git a/src/legacy/util/mod.rs b/src/legacy/util/mod.rs new file mode 100644 index 0000000..70954ff --- /dev/null +++ b/src/legacy/util/mod.rs @@ -0,0 +1,347 @@ +use crate::cli::{UtilAction, UtilArgs}; +use crate::config::AppConfig; +use crate::status::scan_workspace; +use anyhow::Result; +use chrono::Utc; +use git2::Repository; +use inquire::Select; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +pub async fn run(args: UtilArgs, cfg: &AppConfig) -> Result<()> { + match args.action { + None => { + println!("Usage: fxg util "); + println!(); + println!(" log [--limit N] Unified chronological log across all repos"); + println!(" find-secrets [] Scan git history for leaked credentials"); + println!(" new-doc [] Create CHANGELOG / README / TODO in a repo"); + } + Some(UtilAction::Log { limit }) => run_log(limit, cfg)?, + Some(UtilAction::FindSecrets { repos }) => run_find_secrets(repos, cfg)?, + Some(UtilAction::NewDoc { pub_repo, doc_type }) => run_new_doc(pub_repo, doc_type, cfg)?, + } + Ok(()) +} + +// ── log ─────────────────────────────────────────────────────────────────────── + +struct LogEntry { + ts: i64, + repo: String, + sha: String, + author: String, + msg: String, +} + +fn run_log(limit: usize, cfg: &AppConfig) -> Result<()> { + let repos = scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)?; + + let mut entries: Vec = Vec::new(); + + for rs in &repos { + let Ok(repo) = Repository::open(&rs.path) else { continue }; + let mut revwalk = match repo.revwalk() { + Ok(r) => r, + Err(_) => continue, + }; + let _ = revwalk.push_head(); + for id in revwalk.take(limit) { + let Ok(id) = id else { continue }; + let Ok(commit) = repo.find_commit(id) else { continue }; + entries.push(LogEntry { + ts: commit.time().seconds(), + repo: rs.name.clone(), + sha: id.to_string()[..7].to_string(), + author: commit.author().name().unwrap_or("").chars().take(16).collect(), + msg: commit.summary().unwrap_or("").chars().take(60).collect(), + }); + } + } + + entries.sort_by(|a, b| b.ts.cmp(&a.ts)); + entries.truncate(limit); + + if entries.is_empty() { + println!("No commits found."); + return Ok(()); + } + + println!("{:<16} {:<7} {:<16} {:<16} {}", "REPO", "SHA", "AUTHOR", "AGE", "MESSAGE"); + println!("{}", "─".repeat(90)); + for e in &entries { + println!( + "{:<16} {:<7} {:<16} {:<16} {}", + truncate(&e.repo, 15), e.sha, + truncate(&e.author, 15), + format_age(e.ts), + e.msg + ); + } + Ok(()) +} + +// ── find-secrets ────────────────────────────────────────────────────────────── + +const PATTERNS: &[(&str, &str)] = &[ + ("Stripe live key", "sk_live_"), + ("Stripe test key", "sk_test_"), + ("AWS access key", "AKIA"), + ("RSA private key", "BEGIN RSA PRIVATE KEY"), + ("OpenSSH private key","BEGIN OPENSSH PRIVATE KEY"), + ("GitHub PAT", "ghp_"), + ("Generic API key", "api_key"), + ("Generic secret", "client_secret"), +]; + +fn run_find_secrets(repos: Vec, cfg: &AppConfig) -> Result<()> { + let all_repos = scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)?; + + let paths: Vec = if repos.is_empty() { + all_repos.iter().map(|r| r.path.clone()).collect() + } else { + all_repos.iter() + .filter(|r| repos.contains(&r.name)) + .map(|r| r.path.clone()) + .collect() + }; + + if paths.is_empty() { + println!("No repos found to scan."); + return Ok(()); + } + + let mut total_hits = 0usize; + + for path in &paths { + let repo_name = path.file_name().unwrap_or_default().to_string_lossy(); + println!("\nScanning {} ...", repo_name); + + let mut child = match Command::new("git") + .args(["-C", path.to_str().unwrap_or("."), "log", "-p", + "--all", "--max-count=500", "--format=COMMIT:%H"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(e) => { eprintln!(" Could not run git: {}", e); continue; } + }; + + let stdout = child.stdout.take().unwrap(); + let reader = BufReader::new(stdout); + let mut cur_commit = String::new(); + let mut hits_this = 0usize; + + for line in reader.lines().flatten() { + if let Some(sha) = line.strip_prefix("COMMIT:") { + cur_commit = sha[..sha.len().min(7)].to_string(); + continue; + } + if !line.starts_with('+') || line.starts_with("+++") { + continue; + } + let lower = line.to_ascii_lowercase(); + for (label, pattern) in PATTERNS { + if lower.contains(&pattern.to_ascii_lowercase()) { + println!(" [{}] commit {} — {}", label, cur_commit, + line.chars().take(80).collect::().trim()); + hits_this += 1; + total_hits += 1; + break; + } + } + } + let _ = child.wait(); + + if hits_this == 0 { + println!(" No secrets found."); + } + } + + println!("\n─────────────────────────────────────────────"); + if total_hits == 0 { + println!("All clear — no secret patterns detected."); + } else { + println!("Found {} potential secret(s). Review the above lines.", total_hits); + } + Ok(()) +} + +// ── new-doc ─────────────────────────────────────────────────────────────────── + +const DOC_TYPES: &[&str] = &["CHANGELOG.md", "README.md", "TODO.md", "CONTRIBUTING.md", ".env.example"]; + +fn run_new_doc(pub_repo: Option, doc_type: Option, cfg: &AppConfig) -> Result<()> { + // Resolve repo path + let repo_path = resolve_repo_path(pub_repo, cfg)?; + let repo_name = repo_path.file_name().unwrap_or_default().to_string_lossy().to_string(); + + // Resolve doc type via picker if not given + let doc = if let Some(t) = doc_type { + if !DOC_TYPES.contains(&t.as_str()) { + anyhow::bail!("Unknown doc type '{}'. Choose from: {}", t, DOC_TYPES.join(", ")); + } + t + } else { + Select::new("Document to create:", DOC_TYPES.to_vec()) + .with_help_message("Use ↑/↓ to select, Enter to confirm") + .prompt()? + .to_string() + }; + + let dest = repo_path.join(&doc); + if dest.exists() { + anyhow::bail!("'{}' already exists in '{}'", doc, repo_name); + } + + let content = template_content(&doc, &repo_name); + std::fs::write(&dest, content)?; + println!("✓ Created {}", dest.display()); + Ok(()) +} + +fn template_content(doc: &str, repo_name: &str) -> String { + match doc { + "CHANGELOG.md" => format!( +"# Changelog + +All notable changes to **{}** are documented here. +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +### Added + +### Changed + +### Fixed +", repo_name), + + "README.md" => format!( +"# {} + +## Description + +## Installation + +```bash +# add install steps here +``` + +## Usage + +```bash +# add usage examples here +``` + +## License + +MIT +", repo_name), + + "TODO.md" => format!( +"# TODO — {} + +## High Priority + +- [ ] + +## Medium Priority + +- [ ] + +## Low Priority + +- [ ] +", repo_name), + + "CONTRIBUTING.md" => format!( +"# Contributing to {} + +## Getting Started + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Commit your changes: `git commit -m 'feat: add my feature'` +4. Push to the branch: `git push origin feature/my-feature` +5. Open a Pull Request + +## Code Style + +Follow the existing conventions in the codebase. + +## Reporting Issues + +Open an issue with a clear description and steps to reproduce. +", repo_name), + + ".env.example" => String::from( +"# Copy this file to .env and fill in your values +# Never commit .env to version control + +# Application +APP_ENV=development +APP_PORT=3000 + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME= +DB_USER= +DB_PASS= + +# Auth / Secrets +SECRET_KEY= +"), + _ => String::new(), + } +} + +fn resolve_repo_path(name: Option, cfg: &AppConfig) -> Result { + if let Some(n) = name { + // Search workspace_root and groups + let candidates: Vec = { + let mut v = vec![cfg.workspace_root.join(&n)]; + for g in &cfg.workspace_groups { + v.push(cfg.workspace_root.join(g).join(&n)); + } + v + }; + for p in candidates { + if p.join(".git").exists() { return Ok(p); } + } + anyhow::bail!("Repo '{}' not found in workspace", n); + } + let repos = scan_workspace(&cfg.workspace_root, &cfg.workspace_groups)?; + if repos.is_empty() { + anyhow::bail!("No repos found in workspace. Check workspace_root in config."); + } + let names: Vec = repos.iter().map(|r| r.name.clone()).collect(); + let chosen = Select::new("Select repository:", names) + .with_help_message("Use ↑/↓ to select, Enter to confirm") + .prompt()?; + repos.iter() + .find(|r| r.name == chosen) + .map(|r| r.path.clone()) + .ok_or_else(|| anyhow::anyhow!("repo not found")) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn format_age(secs: i64) -> String { + let diff = Utc::now().timestamp() - secs; + match diff { + d if d < 60 => "just now".into(), + d if d < 3600 => format!("{}m ago", d / 60), + d if d < 86400 => format!("{}h ago", d / 3600), + d if d < 86400 * 2 => "yesterday".into(), + d => format!("{}d ago", d / 86400), + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { s.to_string() } + else { format!("{}…", &s[..max - 1]) } +} diff --git a/src/main.rs b/src/main.rs index 579e840..36a5081 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,19 @@ -mod adopt; mod app; mod auth; +mod cache; mod cli; mod config; -mod init; +mod debug; +mod detail; +mod hub; +mod labels; +mod legacy; +mod projects; mod providers; -mod refactor; -mod release; -mod stats; -mod summary; mod status; -mod sync; +mod todos; mod tui; +mod util; mod workspace; use anyhow::Result; diff --git a/src/projects/mod.rs b/src/projects/mod.rs new file mode 100644 index 0000000..7863ed9 --- /dev/null +++ b/src/projects/mod.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub name: String, + pub color: String, // hex e.g. "#e74c3c" + #[serde(default)] + pub repos: Vec, // relative paths from workspace_root +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ProjectsFile { + #[serde(default)] + project: Vec, +} + +fn file_path() -> PathBuf { + dirs::home_dir().unwrap_or_default().join(".fxg").join("projects.toml") +} + +pub fn load() -> Vec { + let p = file_path(); + if !p.exists() { return Vec::new(); } + toml::from_str::(&std::fs::read_to_string(p).unwrap_or_default()) + .map(|f| f.project) + .unwrap_or_default() +} + +pub fn save(projects: &[Project]) -> anyhow::Result<()> { + let p = file_path(); + if let Some(parent) = p.parent() { std::fs::create_dir_all(parent)?; } + let file = ProjectsFile { project: projects.to_vec() }; + std::fs::write(p, toml::to_string_pretty(&file)?)?; + Ok(()) +} + +/// Returns map of relative_repo_path → project index for O(1) lookup. +pub fn index_by_repo(projects: &[Project]) -> HashMap { + let mut m = HashMap::new(); + for (i, p) in projects.iter().enumerate() { + for r in &p.repos { + m.insert(r.clone(), i); + } + } + m +} + +/// Relative path of repo_path from workspace_root, as the key used in projects. +pub fn rel_key(repo_path: &Path, workspace_root: &Path) -> String { + repo_path.strip_prefix(workspace_root) + .unwrap_or(repo_path) + .to_string_lossy() + .to_string() +} + +/// Parse "#rrggbb" → (u8, u8, u8). Falls back to white. +pub fn hex_to_rgb(color: &str) -> (u8, u8, u8) { + let s = color.trim_start_matches('#'); + if s.len() == 6 { + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&s[0..2], 16), + u8::from_str_radix(&s[2..4], 16), + u8::from_str_radix(&s[4..6], 16), + ) { + return (r, g, b); + } + } + (180, 180, 180) +} diff --git a/src/providers/gitea.rs b/src/providers/gitea.rs index fd67413..1696cdd 100644 --- a/src/providers/gitea.rs +++ b/src/providers/gitea.rs @@ -188,4 +188,31 @@ impl Provider for GiteaProvider { let v = Self::check(resp).await.context("whoami")?; Ok(v["login"].as_str().unwrap_or("unknown").to_string()) } + + async fn update_visibility(&self, repo: &str, private: bool) -> Result<()> { + let body = json!({ "private": private }); + let resp = self.client + .patch(self.url(&format!("repos/{}/{}", self.owner, repo))) + .json(&body).send().await?; + Self::check(resp).await.context("update_visibility")?; + Ok(()) + } + + async fn rename_repo(&self, repo: &str, new_name: &str) -> Result<()> { + let body = json!({ "name": new_name }); + let resp = self.client + .patch(self.url(&format!("repos/{}/{}", self.owner, repo))) + .json(&body).send().await?; + Self::check(resp).await.context("rename_repo")?; + Ok(()) + } + + async fn archive_repo(&self, repo: &str) -> Result<()> { + let body = json!({ "archived": true }); + let resp = self.client + .patch(self.url(&format!("repos/{}/{}", self.owner, repo))) + .json(&body).send().await?; + Self::check(resp).await.context("archive_repo")?; + Ok(()) + } } diff --git a/src/providers/github.rs b/src/providers/github.rs index 526d08a..39e9742 100644 --- a/src/providers/github.rs +++ b/src/providers/github.rs @@ -1,4 +1,4 @@ -use super::{PrInfo, Provider, RepoInfo, RepoMeta}; +use super::{IssueDetail, PrDetail, PrInfo, Provider, RepoInfo, RepoMeta}; use anyhow::{Context, Result}; use async_trait::async_trait; use reqwest::{header, Client, Response}; @@ -179,4 +179,96 @@ impl Provider for GitHubProvider { let v = Self::check(resp).await.context("whoami")?; Ok(v["login"].as_str().unwrap_or("unknown").to_string()) } + + async fn update_visibility(&self, repo: &str, private: bool) -> Result<()> { + let body = serde_json::json!({ "private": private }); + let resp = self.client + .patch(self.url(&format!("repos/{}/{}", self.owner, repo))) + .json(&body).send().await?; + Self::check(resp).await.context("update_visibility")?; + Ok(()) + } + + async fn rename_repo(&self, repo: &str, new_name: &str) -> Result<()> { + let body = serde_json::json!({ "name": new_name }); + let resp = self.client + .patch(self.url(&format!("repos/{}/{}", self.owner, repo))) + .json(&body).send().await?; + Self::check(resp).await.context("rename_repo")?; + Ok(()) + } + + async fn archive_repo(&self, repo: &str) -> Result<()> { + let body = serde_json::json!({ "archived": true }); + let resp = self.client + .patch(self.url(&format!("repos/{}/{}", self.owner, repo))) + .json(&body).send().await?; + Self::check(resp).await.context("archive_repo")?; + Ok(()) + } + + async fn list_pull_requests(&self, repo: &str) -> Result> { + let resp = self.client + .get(self.url(&format!("repos/{}/{}/pulls?state=open&per_page=100", self.owner, repo))) + .send().await?; + if !resp.status().is_success() { return Ok(vec![]); } + let list: Value = resp.json().await.unwrap_or(Value::Array(vec![])); + let prs = list.as_array().map(|arr| arr.iter().map(|pr| { + let labels: Vec = pr["labels"].as_array() + .map(|l| l.iter().filter_map(|x| x["name"].as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + PrDetail { + repo: repo.to_string(), + number: pr["number"].as_u64().unwrap_or(0), + title: pr["title"].as_str().unwrap_or("").to_string(), + author: pr["user"]["login"].as_str().unwrap_or("").to_string(), + head_branch: pr["head"]["ref"].as_str().unwrap_or("").to_string(), + base_branch: pr["base"]["ref"].as_str().unwrap_or("").to_string(), + created_ts: parse_gh_ts(pr["created_at"].as_str().unwrap_or("")), + updated_ts: parse_gh_ts(pr["updated_at"].as_str().unwrap_or("")), + url: pr["html_url"].as_str().unwrap_or("").to_string(), + draft: pr["draft"].as_bool().unwrap_or(false), + labels, + } + }).collect()).unwrap_or_default(); + Ok(prs) + } + + async fn list_issues(&self, repo: &str) -> Result> { + let resp = self.client + .get(self.url(&format!( + "repos/{}/{}/issues?state=open&per_page=100&pulls=false", + self.owner, repo + ))) + .send().await?; + if !resp.status().is_success() { return Ok(vec![]); } + let list: Value = resp.json().await.unwrap_or(Value::Array(vec![])); + let issues = list.as_array().map(|arr| arr.iter() + // GitHub /issues returns PRs too — filter them out + .filter(|i| i["pull_request"].is_null()) + .map(|i| { + let labels: Vec = i["labels"].as_array() + .map(|l| l.iter().filter_map(|x| x["name"].as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + IssueDetail { + repo: repo.to_string(), + number: i["number"].as_u64().unwrap_or(0), + title: i["title"].as_str().unwrap_or("").to_string(), + author: i["user"]["login"].as_str().unwrap_or("").to_string(), + labels, + created_ts: parse_gh_ts(i["created_at"].as_str().unwrap_or("")), + updated_ts: parse_gh_ts(i["updated_at"].as_str().unwrap_or("")), + url: i["html_url"].as_str().unwrap_or("").to_string(), + comments: i["comments"].as_u64().unwrap_or(0) as u32, + } + }).collect() + ).unwrap_or_default(); + Ok(issues) + } +} + +fn parse_gh_ts(s: &str) -> i64 { + chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.timestamp()) + .unwrap_or(0) } diff --git a/src/providers/gitlab.rs b/src/providers/gitlab.rs index d8c7d5b..f0ea641 100644 --- a/src/providers/gitlab.rs +++ b/src/providers/gitlab.rs @@ -190,4 +190,31 @@ impl Provider for GitLabProvider { let v = Self::check(resp).await.context("whoami")?; Ok(v["username"].as_str().unwrap_or("unknown").to_string()) } + + async fn update_visibility(&self, repo: &str, private: bool) -> Result<()> { + let visibility = if private { "private" } else { "public" }; + let body = json!({ "visibility": visibility }); + let resp = self.client + .put(self.url(&format!("projects/{}", self.repo_id(repo)))) + .json(&body).send().await?; + Self::check(resp).await.context("update_visibility")?; + Ok(()) + } + + async fn rename_repo(&self, repo: &str, new_name: &str) -> Result<()> { + let body = json!({ "name": new_name, "path": new_name }); + let resp = self.client + .put(self.url(&format!("projects/{}", self.repo_id(repo)))) + .json(&body).send().await?; + Self::check(resp).await.context("rename_repo")?; + Ok(()) + } + + async fn archive_repo(&self, repo: &str) -> Result<()> { + let resp = self.client + .post(self.url(&format!("projects/{}/archive", self.repo_id(repo)))) + .send().await?; + Self::check(resp).await.context("archive_repo")?; + Ok(()) + } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 3371657..72a69e6 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -32,6 +32,34 @@ pub struct RepoMeta { pub stars: u32, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PrDetail { + pub repo: String, + pub number: u64, + pub title: String, + pub author: String, + pub head_branch: String, + pub base_branch: String, + pub created_ts: i64, + pub updated_ts: i64, + pub url: String, + pub draft: bool, + pub labels: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IssueDetail { + pub repo: String, + pub number: u64, + pub title: String, + pub author: String, + pub labels: Vec, + pub created_ts: i64, + pub updated_ts: i64, + pub url: String, + pub comments: u32, +} + #[async_trait] #[allow(dead_code)] pub trait Provider: Send + Sync { @@ -79,8 +107,36 @@ pub trait Provider: Send + Sync { /// Fetch basic metadata (issues, PRs, stars). async fn repo_meta(&self, repo: &str) -> Result; + /// List open pull requests for a repository. + async fn list_pull_requests(&self, _repo: &str) -> Result> { + Ok(vec![]) + } + + /// List open issues for a repository (excludes PRs). + async fn list_issues(&self, _repo: &str) -> Result> { + Ok(vec![]) + } + /// Verify the token works, return the authenticated username. async fn whoami(&self) -> Result; + + /// Change a repository's visibility. + async fn update_visibility(&self, repo: &str, private: bool) -> Result<()> { + let _ = (repo, private); + anyhow::bail!("update_visibility not supported by this provider") + } + + /// Rename a repository on the remote. + async fn rename_repo(&self, repo: &str, new_name: &str) -> Result<()> { + let _ = (repo, new_name); + anyhow::bail!("rename_repo not supported by this provider") + } + + /// Archive a repository (make it read-only). + async fn archive_repo(&self, repo: &str) -> Result<()> { + let _ = repo; + anyhow::bail!("archive_repo not supported by this provider") + } } /// Build a boxed `Provider` from an `Account`. diff --git a/src/status/detail.rs b/src/status/detail.rs new file mode 100644 index 0000000..b90d67e --- /dev/null +++ b/src/status/detail.rs @@ -0,0 +1 @@ +pub use crate::detail::run; diff --git a/src/status/mod.rs b/src/status/mod.rs index 9e06257..3bb288e 100644 --- a/src/status/mod.rs +++ b/src/status/mod.rs @@ -1,4 +1,4 @@ -pub mod ui; +pub mod detail; use crate::cli::StatusArgs; use crate::config::AppConfig; @@ -6,7 +6,9 @@ use crate::workspace::load_workspaces; use anyhow::Result; use chrono::Utc; use git2::{Repository, StatusOptions}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::mpsc; #[derive(Debug, Clone)] pub struct RepoSummary { @@ -16,9 +18,18 @@ pub struct RepoSummary { pub ahead: usize, pub behind: usize, pub is_dirty: bool, - #[allow(dead_code)] pub last_commit_msg: String, pub last_commit_age: String, + pub last_commit_ts: i64, + pub last_tag: Option, + pub visibility: Option, + pub owner: Option, // parsed from git remote URL + pub license: bool, + pub lang: Option, + pub bookmarked: bool, + pub stars: Option, + pub open_issues: Option, + pub open_prs: Option, } #[derive(Debug, Clone)] @@ -39,8 +50,90 @@ pub struct RepoDetail { #[allow(dead_code)] pub meta_stars: Option, } +// ── owner parsing ───────────────────────────────────────────────────────────── + +fn parse_owner_from_url(url: &str) -> Option { + // https://github.com/owner/repo[.git] + if url.starts_with("http") { + let parts: Vec<&str> = url.trim_end_matches('/').trim_end_matches(".git").split('/').collect(); + let n = parts.len(); + if n >= 2 { return Some(parts[n - 2].to_string()); } + } + // git@github.com:owner/repo.git + if let Some(colon) = url.rfind(':') { + let after = url[colon + 1..].trim_start_matches('/'); + if let Some(slash) = after.find('/') { + return Some(after[..slash].to_string()); + } + } + None +} + +fn extract_owner(repo: &Repository) -> Option { + let remote = repo.find_remote("origin").ok()?; + parse_owner_from_url(remote.url()?) +} + +// ── license + language detection ────────────────────────────────────────────── + +pub fn detect_license(path: &Path) -> bool { + ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENSE-MIT", "LICENSE-APACHE", "COPYING", "COPYING.md"] + .iter().any(|f| path.join(f).exists()) +} + +pub fn detect_language(path: &Path) -> Option { + // Fast path: check indicator files instead of scanning the full git index. + const INDICATORS: &[(&str, &str)] = &[ + ("Cargo.toml", "Rust"), + ("go.mod", "Go"), + ("pyproject.toml", "Python"), + ("requirements.txt", "Python"), + ("setup.py", "Python"), + ("pom.xml", "Java"), + ("build.gradle", "Java"), + ("build.gradle.kts", "Kotlin"), + ("Gemfile", "Ruby"), + ("composer.json", "PHP"), + ("Package.swift", "Swift"), + ("mix.exs", "Elixir"), + ("*.cabal", "Haskell"), + ]; + for (file, lang) in INDICATORS { + if path.join(file).exists() { return Some(lang.to_string()); } + } + if path.join("package.json").exists() { + let lang = if path.join("tsconfig.json").exists() { "TypeScript" } else { "JavaScript" }; + return Some(lang.to_string()); + } + None +} + +// ── lazy per-repo extras ─────────────────────────────────────────────────────── + +pub fn count_tracked_files(path: &Path) -> usize { + let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return 0 }; + repo.index().map(|i| i.len()).unwrap_or(0) +} + +pub fn repo_recent_commits(path: &Path, n: usize) -> Vec<(String, String)> { + let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] }; + let mut revwalk = match repo.revwalk() { Ok(r) => r, Err(_) => return vec![] }; + let _ = revwalk.push_head(); + revwalk.take(n) + .filter_map(|id| { + let id = id.ok()?; + let commit = repo.find_commit(id).ok()?; + let msg = commit.summary().unwrap_or("").chars().take(45).collect::(); + let age = format_age(commit.time().seconds()); + Some((age, msg)) + }) + .collect() +} + // ── git helpers ─────────────────────────────────────────────────────────────── +pub fn format_age_pub(secs: i64) -> String { format_age(secs) } + fn format_age(secs: i64) -> String { let diff = Utc::now().timestamp() - secs; match diff { @@ -63,23 +156,67 @@ fn ahead_behind(repo: &Repository, branch: &str) -> (usize, usize) { } } +fn latest_tag(repo: &Repository) -> Option { + let mut best: Option<(i64, String)> = None; + if let Ok(names) = repo.tag_names(None) { + for name in names.iter().flatten() { + let refname = format!("refs/tags/{}", name); + let ts = repo.revparse_single(&refname).ok() + .and_then(|obj| obj.peel_to_commit().ok()) + .map(|c| c.time().seconds()); + if let Some(ts) = ts { + if best.as_ref().map_or(true, |(t, _)| ts > *t) { + best = Some((ts, name.to_string())); + } + } + } + } + best.map(|(_, n)| n) +} + pub fn repo_summary(path: &Path) -> Result { - let repo = Repository::open(path)?; - let head = repo.head()?; + let t_total = std::time::Instant::now(); + + let repo = Repository::open(path)?; + let head = repo.head()?; let branch = head.shorthand().unwrap_or("HEAD").to_string(); let commit = head.peel_to_commit()?; - let msg = commit.summary().unwrap_or("").chars().take(60).collect::(); - let age = format_age(commit.time().seconds()); + let msg = commit.summary().unwrap_or("").chars().take(60).collect::(); + let age = format_age(commit.time().seconds()); + let ts = commit.time().seconds(); + let t_tag = std::time::Instant::now(); + let last_tag = latest_tag(&repo); + let tag_ms = t_tag.elapsed().as_millis(); + + let owner = extract_owner(&repo); + let license = detect_license(path); + + let t_lang = std::time::Instant::now(); + let lang = detect_language(path); + let lang_ms = t_lang.elapsed().as_millis(); + + let t_status = std::time::Instant::now(); let mut opts = StatusOptions::new(); opts.include_untracked(true); let is_dirty = repo.statuses(Some(&mut opts)).map(|s| !s.is_empty()).unwrap_or(false); + let status_ms = t_status.elapsed().as_millis(); let (ahead, behind) = ahead_behind(&repo, &branch); let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); - Ok(RepoSummary { name, path: path.to_path_buf(), branch, ahead, behind, is_dirty, last_commit_msg: msg, last_commit_age: age }) + crate::debug::log(format!( + "repo[{name}] total={}ms tag={tag_ms}ms lang={lang_ms}ms status={status_ms}ms", + t_total.elapsed().as_millis() + )); + + Ok(RepoSummary { + name, path: path.to_path_buf(), branch, ahead, behind, is_dirty, + last_commit_msg: msg, last_commit_age: age, last_commit_ts: ts, + last_tag, visibility: None, owner, license, lang, bookmarked: false, + stars: None, open_issues: None, open_prs: None, + }) } pub fn repo_detail(path: &Path) -> Result { @@ -114,24 +251,102 @@ pub fn repo_detail(path: &Path) -> Result { Ok(RepoDetail { summary, modified_files, recent_commits, meta_issues: None, meta_prs: None, meta_stars: None }) } -pub fn scan_workspace(root: &Path) -> Result> { - let entries = match std::fs::read_dir(root) { - Ok(e) => e, - Err(_) => return Ok(vec![]), - }; - let mut repos = Vec::new(); +/// Background streaming scan — sends repos to the TUI as they are found. +/// The channel closes when the scan finishes; the receiver side triggers a final sort. +pub fn scan_workspace_stream(root: PathBuf) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let labels = crate::labels::load_labels(&root); + scan_recursive_tx(&root, &root, &tx, 5, &labels); + }); + rx +} + +fn scan_recursive_tx( + dir: &Path, root: &Path, + tx: &mpsc::Sender, + depth: u8, + labels: &HashMap, +) { + if depth == 0 { return; } + let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); + if !path.is_dir() { continue; } + let fname = path.file_name().unwrap_or_default().to_string_lossy(); + if SKIP_DIRS.iter().any(|s| *s == fname.as_ref()) { continue; } + if path.join(".git").exists() { - if let Ok(s) = repo_summary(&path) { - repos.push(s); + if let Ok(mut s) = repo_summary(&path) { + if let Ok(rel) = path.strip_prefix(root) { + s.name = rel.to_string_lossy().to_string(); + } + if let Some(label) = labels.get(&path) { + s.name = label.clone(); + } + let _ = tx.send(s); } + } else { + scan_recursive_tx(&path, root, tx, depth - 1, labels); } } - repos.sort_by(|a, b| a.name.cmp(&b.name)); +} + +pub fn scan_workspace(root: &Path, _groups: &[String]) -> Result> { + crate::debug::log(format!("scan_workspace: {}", root.display())); + let mut repos = Vec::new(); + let labels = crate::labels::load_labels(root); + scan_recursive(root, root, &mut repos, 5); + crate::debug::log(format!("scan done: {} repos", repos.len())); + + // Apply labels: if a repo has an assigned display name, use it + for r in &mut repos { + if let Some(label) = labels.get(&r.path) { + r.name = label.clone(); + } + } + + // Sort: active repos (dirty or behind) first, then by last commit timestamp desc + 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)) + }); + repos.dedup_by(|a, b| a.path == b.path); Ok(repos) } +const SKIP_DIRS: &[&str] = &[ + ".git", "node_modules", "target", ".cache", "vendor", + ".npm", ".cargo", "__pycache__", ".venv", "venv", "dist", "build", +]; + +fn scan_recursive(dir: &Path, root: &Path, repos: &mut Vec, depth: u8) { + if depth == 0 { return; } + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { continue; } + let fname = path.file_name().unwrap_or_default().to_string_lossy(); + if SKIP_DIRS.iter().any(|s| *s == fname.as_ref()) { continue; } + + if path.join(".git").exists() { + if let Ok(mut s) = repo_summary(&path) { + // Show relative path from workspace_root so user knows context + if let Ok(rel) = path.strip_prefix(root) { + s.name = rel.to_string_lossy().to_string(); + } + repos.push(s); + } + } else { + scan_recursive(&path, root, repos, depth - 1); + } + } +} + pub fn apply_filter(mut repos: Vec, filter: &str) -> Vec { repos.retain(|r| match filter { "dirty" => r.is_dirty, @@ -147,7 +362,7 @@ pub async fn run(args: StatusArgs, cfg: &AppConfig) -> Result<()> { if let Some(repo_name) = &args.repo { let path = cfg.workspace_root.join(repo_name); let detail = repo_detail(&path)?; - return ui::run_detail_tui(detail); + return crate::hub::run_detail_tui(detail); } if let Some(ws_name) = &args.workspace { @@ -159,10 +374,9 @@ pub async fn run(args: StatusArgs, cfg: &AppConfig) -> Result<()> { if let Ok(s) = repo_summary(&path) { repos.push(s); } } if let Some(f) = &args.filter { repos = apply_filter(repos, f); } - return ui::run_overview_tui(repos); + return crate::hub::run_overview_tui(repos, None, cfg).await; } - let mut repos = scan_workspace(&cfg.workspace_root)?; - if let Some(f) = &args.filter { repos = apply_filter(repos, f); } - ui::run_overview_tui(repos) + let rx = scan_workspace_stream(cfg.workspace_root.clone()); + crate::hub::run_overview_tui(vec![], Some(rx), cfg).await } diff --git a/src/status/ui.rs b/src/status/ui.rs deleted file mode 100644 index 4f75b0a..0000000 --- a/src/status/ui.rs +++ /dev/null @@ -1,160 +0,0 @@ -use crate::status::{RepoDetail, RepoSummary}; -use crate::tui; -use anyhow::Result; -use crossterm::event::{Event, KeyCode}; -use ratatui::{ - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, - Frame, -}; - -// ── overview TUI ────────────────────────────────────────────────────────────── - -struct OverviewState { - repos: Vec, - selected: usize, - quit: bool, -} - -fn render_overview(f: &mut Frame, state: &OverviewState) { - let area = f.area(); - let outer = Block::default() - .title(format!(" fxg — Workspace Status · {} repos ", state.repos.len())) - .borders(Borders::ALL); - let inner = outer.inner(area); - f.render_widget(outer, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Fill(1), Constraint::Length(1)]) - .split(inner); - - let header = Row::new(vec!["#", "NAME", "BRANCH", "AHEAD", "BEHIND", "DIRTY", "LAST COMMIT"]) - .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); - - let rows: Vec = state.repos.iter().enumerate().map(|(i, r)| { - let dirty = if r.is_dirty { "✓" } else { "✗" }; - let row_style = if i == state.selected { - Style::default().bg(Color::DarkGray) - } else if r.is_dirty { - Style::default().fg(Color::Yellow) - } else if r.behind > 0 { - Style::default().fg(Color::Red) - } else { - Style::default() - }; - Row::new(vec![ - Cell::from(format!("{}", i + 1)), - Cell::from(r.name.clone()), - Cell::from(r.branch.clone()), - Cell::from(r.ahead.to_string()), - Cell::from(r.behind.to_string()), - Cell::from(dirty), - Cell::from(r.last_commit_age.clone()), - ]).style(row_style) - }).collect(); - - let widths = [ - Constraint::Length(4), Constraint::Fill(1), Constraint::Length(14), - Constraint::Length(6), Constraint::Length(7), Constraint::Length(6), - Constraint::Length(12), - ]; - f.render_widget(Table::new(rows, widths).header(header), chunks[0]); - f.render_widget( - Paragraph::new(" [↑/↓] navigate [Enter] drill-in [f] filter [q] quit") - .style(Style::default().fg(Color::DarkGray)), - chunks[1], - ); -} - -pub fn run_overview_tui(repos: Vec) -> Result<()> { - let mut state = OverviewState { repos, selected: 0, quit: false }; - tui::run_loop(|terminal| { - terminal.draw(|f| render_overview(f, &state))?; - if let Some(Event::Key(key)) = tui::next_event(250)? { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => state.quit = true, - KeyCode::Up | KeyCode::Char('k') => { - if state.selected > 0 { state.selected -= 1; } - } - KeyCode::Down | KeyCode::Char('j') => { - if state.selected + 1 < state.repos.len() { state.selected += 1; } - } - KeyCode::Enter => { - if let Some(r) = state.repos.get(state.selected) { - if let Ok(detail) = crate::status::repo_detail(&r.path) { - // temporarily exit and re-enter for the detail view - state.quit = true; - // Note: after this loop ends we open detail TUI - let _ = run_detail_tui(detail); - } - } - } - _ => {} - } - } - Ok(state.quit) - }) -} - -// ── detail TUI ──────────────────────────────────────────────────────────────── - -fn render_detail(f: &mut Frame, detail: &RepoDetail) { - let area = f.area(); - let outer = Block::default() - .title(format!(" {} · {} ", detail.summary.name, detail.summary.branch)) - .borders(Borders::ALL); - let inner = outer.inner(area); - f.render_widget(outer, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(4), Constraint::Fill(1), Constraint::Fill(1), Constraint::Length(1)]) - .split(inner); - - // Header info - let info = vec![ - Line::from(vec![Span::raw(" Branch: "), Span::styled(&detail.summary.branch, Style::default().fg(Color::Cyan))]), - Line::from(vec![ - Span::raw(" Status: "), - Span::raw(format!("+{} ahead -{} behind dirty: {}", detail.summary.ahead, detail.summary.behind, - if detail.summary.is_dirty { "yes" } else { "no" })), - ]), - ]; - f.render_widget(Paragraph::new(info), chunks[0]); - - // Modified files - let file_block = Block::default().title(" Modified files ").borders(Borders::ALL); - let files: Vec = detail.modified_files.iter() - .map(|(ch, path)| ListItem::new(format!(" {} {}", ch, path))) - .collect(); - f.render_widget(List::new(files).block(file_block), chunks[1]); - - // Recent commits - let commit_block = Block::default().title(" Recent commits ").borders(Borders::ALL); - let commits: Vec = detail.recent_commits.iter() - .map(|c| ListItem::new(format!(" {} {:<50} {} {}", c.sha, c.msg, c.age, c.author))) - .collect(); - f.render_widget(List::new(commits).block(commit_block), chunks[2]); - - f.render_widget( - Paragraph::new(" [p] pull [P] push [c] commit [b] branches [q] back") - .style(Style::default().fg(Color::DarkGray)), - chunks[3], - ); -} - -pub fn run_detail_tui(detail: RepoDetail) -> Result<()> { - let mut quit = false; - tui::run_loop(|terminal| { - terminal.draw(|f| render_detail(f, &detail))?; - if let Some(Event::Key(key)) = tui::next_event(250)? { - if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { - quit = true; - } - } - Ok(quit) - }) -} diff --git a/src/todos/mod.rs b/src/todos/mod.rs new file mode 100644 index 0000000..6b60ea3 --- /dev/null +++ b/src/todos/mod.rs @@ -0,0 +1,154 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TodoStatus { + Open, + InProgress, + Done, + Cancelled, +} + +impl TodoStatus { + pub fn label(&self) -> &'static str { + match self { + Self::Open => "Open", + Self::InProgress => "In Progress", + Self::Done => "Done", + Self::Cancelled => "Cancelled", + } + } + pub fn color(&self) -> ratatui::style::Color { + use ratatui::style::Color; + match self { + Self::Open => Color::Cyan, + Self::InProgress => Color::Yellow, + Self::Done => Color::Green, + Self::Cancelled => Color::DarkGray, + } + } + pub fn cycle(&self) -> Self { + match self { + Self::Open => Self::InProgress, + Self::InProgress => Self::Done, + Self::Done => Self::Cancelled, + Self::Cancelled => Self::Open, + } + } + pub fn idx(&self) -> usize { + match self { + Self::Open => 0, Self::InProgress => 1, + Self::Done => 2, Self::Cancelled => 3, + } + } + pub fn from_idx(i: usize) -> Self { + match i { 1 => Self::InProgress, 2 => Self::Done, 3 => Self::Cancelled, _ => Self::Open } + } + pub fn icon(&self) -> &'static str { + match self { Self::Open => "○", Self::InProgress => "◎", Self::Done => "●", Self::Cancelled => "×" } + } + pub fn all_labels() -> Vec<&'static str> { + vec!["Open", "In Progress", "Done", "Cancelled"] + } +} + +/// Where a todo came from — user-created or auto-scanned from code. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub enum TodoSource { + #[default] + Manual, + CodeScan, +} + +impl TodoSource { + pub fn icon(&self) -> &'static str { + match self { Self::Manual => "✎", Self::CodeScan => "⌂" } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Todo { + pub id: u64, + pub title: String, + pub description: String, + pub status: TodoStatus, + pub project: Option, + /// Which repo this todo belongs to (repo folder name). + #[serde(default)] + pub repo: Option, + /// Manual (user-created) or CodeScan (found in source). + #[serde(default)] + pub source: TodoSource, + pub created_at: i64, + pub updated_at: i64, +} + +impl Todo { + pub fn new( + id: u64, title: String, description: String, + status: TodoStatus, project: Option, + ) -> Self { + let now = chrono::Utc::now().timestamp(); + Self { id, title, description, status, project, + repo: None, source: TodoSource::Manual, + created_at: now, updated_at: now } + } + + pub fn new_scan( + id: u64, title: String, repo: String, + ) -> Self { + let now = chrono::Utc::now().timestamp(); + Self { id, title, description: String::new(), + status: TodoStatus::Open, project: None, + repo: Some(repo), source: TodoSource::CodeScan, + created_at: now, updated_at: now } + } + + pub fn touch(&mut self) { + self.updated_at = chrono::Utc::now().timestamp(); + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct TodosFile { + #[serde(default)] + todo: Vec, +} + +fn file_path() -> PathBuf { + dirs::home_dir().unwrap_or_default().join(".fxg").join("todos.toml") +} + +pub fn load() -> Vec { + let p = file_path(); + if !p.exists() { return Vec::new(); } + toml::from_str::(&std::fs::read_to_string(p).unwrap_or_default()) + .map(|f| f.todo) + .unwrap_or_default() +} + +/// Load only manual todos (default view). +pub fn load_manual() -> Vec { + load().into_iter().filter(|t| t.source == TodoSource::Manual).collect() +} + +pub fn save(todos: &[Todo]) -> anyhow::Result<()> { + let p = file_path(); + if let Some(parent) = p.parent() { std::fs::create_dir_all(parent)?; } + std::fs::write(p, toml::to_string_pretty(&TodosFile { todo: todos.to_vec() })?)?; + Ok(()) +} + +/// Purge all CodeScan todos from the saved file. +pub fn purge_code_scan() -> anyhow::Result { + let all = load(); + let before = all.len(); + let manual: Vec = all.into_iter().filter(|t| t.source == TodoSource::Manual).collect(); + let removed = before - manual.len(); + save(&manual)?; + Ok(removed) +} + +pub fn next_id(todos: &[Todo]) -> u64 { + todos.iter().map(|t| t.id).max().unwrap_or(0) + 1 +} diff --git a/src/util/clipboard.rs b/src/util/clipboard.rs new file mode 100644 index 0000000..da8f341 --- /dev/null +++ b/src/util/clipboard.rs @@ -0,0 +1,26 @@ +use std::io::Write as _; + +/// Copy text using a configured command string (e.g. "wl-copy" or "xclip -selection clipboard"). +pub fn copy_to_clipboard(text: &str, cmd: &str) -> bool { + if cmd.is_empty() { return false; } + let parts: Vec<&str> = cmd.split_whitespace().collect(); + let (prog, args) = parts.split_first().unwrap_or((&"", &[])); + let Ok(mut child) = std::process::Command::new(prog) + .args(args) + .stdin(std::process::Stdio::piped()) + .spawn() else { return false; }; + if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(text.as_bytes()); } + child.wait().map(|s| s.success()).unwrap_or(false) +} + +/// Copy text by auto-detecting the available clipboard tool (wl-copy or xclip). +pub fn copy_auto(text: &str) -> bool { + for (cmd, args) in [("wl-copy", &[][..]), ("xclip", &["-selection", "clipboard"][..])] { + if let Ok(mut child) = std::process::Command::new(cmd) + .args(args).stdin(std::process::Stdio::piped()).spawn() { + if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(text.as_bytes()); } + if child.wait().map(|s| s.success()).unwrap_or(false) { return true; } + } + } + false +} diff --git a/src/util/git.rs b/src/util/git.rs new file mode 100644 index 0000000..45a6c49 --- /dev/null +++ b/src/util/git.rs @@ -0,0 +1,12 @@ +pub fn git_run(path: &std::path::Path, args: &[&str]) { + match std::process::Command::new("git").arg("-C").arg(path).args(args).status() { + Ok(s) if s.success() => {} + Ok(s) => eprintln!(" git {} → exit {}", args.join(" "), s), + Err(e) => eprintln!(" git {}: {}", args.join(" "), e), + } +} + +pub fn git_run_ok(path: &std::path::Path, args: &[&str]) -> bool { + std::process::Command::new("git").arg("-C").arg(path).args(args) + .status().map(|s| s.success()).unwrap_or(false) +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..71b63b6 --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,3 @@ +pub mod clipboard; +pub mod git; +pub mod url; diff --git a/src/util/url.rs b/src/util/url.rs new file mode 100644 index 0000000..fb0f0c9 --- /dev/null +++ b/src/util/url.rs @@ -0,0 +1,9 @@ +pub fn to_web_url(git_url: &str) -> String { + if git_url.starts_with("http") { return git_url.trim_end_matches(".git").to_string(); } + if let Some(colon) = git_url.rfind(':') { + let host = git_url[..colon].split('@').last().unwrap_or(""); + let path = git_url[colon + 1..].trim_end_matches(".git"); + return format!("https://{}/{}", host, path); + } + git_url.to_string() +}