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