Phases 0-1 + Sprint A: module split, CLI layer, org-refresh fix, hygiene

- LICENSE (MIT) + README truth-pass; docs/cli.md CLI contract
- CLI subcommands (src/cli_cmds/) per "TUI for users, CLI for users+AI"
- Hub/detail refactor: keymap dispatch, modal split, action enum, BgMsg bus
- Fix: org repos were never refreshed (owner==username match); refresh now
  parallel — 49 repos in ~11s, surfaced 70 previously invisible PRs
- Fix: 3 date bugs in detail/git.rs (format-local, Local::now, 1y month math)
- Stash indicator + [m] actions, session persistence, gh CLI token fallback
- Legacy UI modules removed; plan/ docs (roadmap, audit, debate, handoff)
This commit is contained in:
dualfroz committed 2026-07-06 14:42:52 +02:00
1 parent 5787b14880
commit 0468c032c6
100 files changed
+11614 -9840

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
//! Local activity log (`~/.vulp/activity.log`, one JSON line per event) for
//! actions vulp itself performed — feeds the hub's Activity tab alongside
//! (but separately from) the git-log-sourced commit feed, which sees real
//! commit history regardless of which tool made it.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// The kind of action recorded in one activity log entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActivityKind { Commit, Push, Pull, Release, NewRepo, Todo, Pr, Issue }
impl ActivityKind {
/// Display label shown in the Activity tab's table.
pub fn label(&self) -> &'static str {
match self {
Self::Commit => "commit",
Self::Push => "push",
Self::Pull => "pull",
Self::Release => "release",
Self::NewRepo => "new repo",
Self::Todo => "todo",
Self::Pr => "pr",
Self::Issue => "issue",
}
}
/// Cycle order for the Activity tab's kind filter (`All` is handled
/// separately as `None`).
pub fn cycle() -> &'static [ActivityKind] {
&[
Self::Commit, Self::Push, Self::Pull, Self::Release,
Self::NewRepo, Self::Todo, Self::Pr, Self::Issue,
]
}
}
/// One recorded activity-log event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityEntry {
pub ts: i64,
pub kind: ActivityKind,
pub repo: String,
pub detail: String,
}
fn log_path() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")).join(".vulp").join("activity.log")
}
/// Appends one event to the local activity log. Only called from code paths
/// where vulp itself performs the action (push/pull/release/repo-create/
/// todo-scan) — it has no way to see git activity done outside vulp, which
/// the Activity tab's commit feed (sourced straight from git log) covers
/// separately and accurately regardless of how it happened.
pub fn log(entry: &ActivityEntry) {
let Ok(line) = serde_json::to_string(entry) else { return };
let path = log_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
use std::io::Write;
let _ = writeln!(f, "{line}");
}
}
/// Loads every logged entry, skipping any line that fails to parse (a
/// truncated last line from a crash, for instance) rather than discarding
/// the whole log.
pub fn load_logged() -> Vec<ActivityEntry> {
let Ok(content) = std::fs::read_to_string(log_path()) else { return vec![] };
content.lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
}
/// Keeps the append-only log from growing forever: when it exceeds
/// `max_entries`, rewrites the file with only the newest `max_entries`.
/// Called once at hub startup — cheap no-op below the threshold.
pub fn compact(max_entries: usize) {
let entries = load_logged();
if entries.len() <= max_entries { return; }
let keep = &entries[entries.len() - max_entries..];
let body: String = keep.iter()
.filter_map(|e| serde_json::to_string(e).ok())
.map(|l| l + "\n")
.collect();
let path = log_path();
let tmp = path.with_extension("log.tmp");
if std::fs::write(&tmp, body).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}