Test message 123!

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

No files matched your search

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