'?' help never mentioned the M (Profiles & Sync) menu at all, and
documented 'd'/'p'/'c' only for their generic meaning while Stats/
Activity tabs silently overload the same keys for something else —
same class of bug the earlier B4 fix addressed, just new drift since.
Also: 'D' with an explicit SHIFT-modifier guard never matched, since
terminals deliver Shift+d as an already-uppercase char with no
modifier flag (same root cause as the earlier Ctrl+A fix) — merged
into the identical plain-Delete arm instead of leaving it dead code.
Also: PR/issue title continuation used title.len() (bytes) to decide
whether to render a second line, while the actual text used
.chars() — a non-ASCII title could get a spurious blank line.
file_write_token() wrote plaintext via fs::write (default-mode,
typically world-readable 0644) and only chmod'd to 0600 afterward —
every store/rotate had a real window where another local user could
read the token. Now opens with mode 0600 set atomically at creation
on Unix. Also fixed 'auth test' slicing a token's preview by byte
index instead of char boundary, which would panic on non-ASCII input.
A dead PAT returns GitHub's actual 401 body ('Bad credentials'), which
matched none of the auth_fail substrings — it fell into the 'API
error, but authenticated' branch and never prompted the user to
re-run vulp auth import. Added 401/'bad credentials' to the check.
Command::new(&cfg.editor) treated the whole configured string as one
binary name, so an editor set to e.g. 'code --wait' or 'vim -R' — a
common config style — failed to spawn at all every single time. Added
a shared launch_editor() that splits on whitespace (program + args)
first, used by every one of the 6 call sites across detail/ and hub/.
copy_auto only ever tried wl-copy/xclip, so on macOS or Windows it
always returned false with no clipboard tool attempted. Added
pbcopy/clip fallbacks per-OS. Also, the detail-pane 'y'/'u' handlers
showed a status message only on success — a failed copy (no tool
found) looked like nothing happened at all; both now report the
outcome either way.
The compiled binary's update-check tile hardcoded dualfroz/fxg with no
way to override it, unlike the install scripts which already honor
$VULP_REPO — a fork or a future repo rename needed a rebuild just to
fix the tile. Also add .github/workflows/ci.yml (build + clippy -D
warnings + test on push/PR): the only existing workflow was the
tag-triggered release build, so nothing gated PRs before merge.
Used format!("repos/{owner}/{repo}/issues", ...) instead of the
slug() helper list_pull_requests already uses, so an org-owned repo
(passed as 'Org/repo') produced repos/{personal_owner}/Org/repo/issues
— a 404 the code silently swallows into an empty list, making the
Issues tab look like 'no issues' instead of a broken request.
repo_id() always prefixed self.namespace onto the given repo name, so
any repo already passed as a full group/repo or group/subgroup/repo
path got mangled into namespace/group/repo and 404'd on every
settings-tab mutation. Now mirrors GitHubProvider::slug: an
already-qualified path (contains '/') is used verbatim.
Bulk-import, import-directory, remove-git-from-all, create-repo,
publish-existing, create-PR, create-issue, profile-push, and
profile-pull all set state.pull_all_rx/create_repo_rx/profile_pull_rx
unconditionally. Starting a second one of these before the first
finished silently dropped the first job's progress/result (its sends
now went to an orphaned receiver), and for the workspace-mutating jobs
(remove-git-all racing an in-flight clone) could corrupt repos on disk.
Each spawn site now checks the channel is free first and tells the
user to wait instead.
A background refresh (bulk-import/create-repo/profile-pull all replace
state.repos wholesale) could complete while the 'Set label' modal was
still open, so its captured index either panicked (out-of-bounds) or
silently relabeled the wrong repo. Now degrades gracefully via .get().
The Stats tab counted every commit on HEAD (mostly upstream history in a
workspace full of forks), so "your activity" was fictional. It now leads
with an author-filtered panel: current/longest day streak, a GitHub-style
52-week contribution heatmap, and an hour-of-day histogram — computed
from your identities via the same scan the weekly digest uses, in the
existing background stats thread. The all-commits period bar chart moves
below it; languages/top-repos/day-of-week stay on the right.
Activity rows are now a cursor list: ↑↓ (and the scroll wheel) move the
highlight, Enter jumps to that repo and opens its detail. Each row shows
a precise local timestamp (MM-DD HH:MM) instead of only a relative age,
colour-codes the kind, colours the repo by its project and widens that
column so long workspace paths are readable, and keeps the relative age
as a dim suffix on the detail. A › marks rows whose repo is still in the
workspace (Enter-able).
Overview and the PRs tab listed open PRs from every repo, including ones
cloned for reference (e.g. pterodactyl) whose PRs you can't merge and
didn't author — pure noise. A PR is now surfaced only when you authored
it but can't merge it (your contribution elsewhere) or someone else
authored it and you can (awaiting your review); your own PR in a repo
you control, and strangers' PRs you can't touch, are filtered out.
Providers report viewer merge permission (RepoMeta.viewer_can_merge,
from GitHub/Gitea repo permissions and GitLab access level), stamped
onto each PR at refresh. The cache keeps every PR; the filter is applied
where the hub builds state.prs, so the per-repo detail view is unchanged.
The Todos tab now carries a count badge (4 Todos(N), N = open + in
progress) like PRs/Issues do. The panel title drops the [s]/[g]/[c]
keybind soup that read as clutter; the current view/group/code-scan
state and the keys that change it move to the bottom hint bar, where
every other keybinding already lives.
A repo that keeps several branches (e.g. one theme per branch) only ever
showed its checked-out branch, hiding that the others exist. The Repos
table now renders the active branch plus `+N` for the other local
branches (cyan when >1), the BRANCH column widened to fit, and
`vulp status` prints "(+N more local)". The full list stays in the
detail Branches tab; RepoSummary.branch_count is in --json too.
Two Repos-tab quality-of-life changes:
- External/vendored clones (repos with zero of your own commits — e.g. an
upstream cloned for reference and never pushed to) are measured on
refresh, cached in stats_cache (RepoStatsEntry.authored), shown with a
dim ⧉ marker prefixed to the name, and hidden by the persistent
hide_external_clones config toggle (key `x`, the `S` menu, or
`vulp list --external|--no-external`). A fork you do commit to stays
yours, so only true reference clones are flagged.
- Inside the `/` filter box, ↑↓ now move through the live-filtered
results and Enter opens the highlighted repo/project directly, instead
of the filter only "locking" on Enter with no way to scroll or select.
OwnEmailMatcher::author_regex() emits an extended-regex alternation
(<email>|<noreply-address>), but scan_repo_own_commits ran `git log
--author=` without -E. git defaults --author to *basic* regex, where |
is a literal, so the pattern matched nothing for anyone with two or more
identities — silently zeroing streak, heatmap, digest and the hour/day
patterns (measured 0 -> 141 own commits across the workspace after the
fix). Add -E here and in the new has_own_commit helper.
Implements the approved mockup from the UX debate report:
- new DisplayRow::TodoHeader variant: collapsible group headers, toggled
by Enter/Space, tracked in HubState.todo_group_collapsed
- [g] cycles grouping: status (default) -> project -> repo -> none
- [s] cycles which statuses are included before grouping: active
(open+in-progress, default) -> open -> done -> all
- table: dropped SRC/EST (near-always empty), code-scan todos show a dim
'no status icon' marker instead of a separate source column, project
name colored by the project's own color (was DarkGray, wasting the most
useful visual identifier in the tool), age now tracks updated_at not
created_at ('what's moving' beats 'what's old')
- right panel: added Repo/Difficulty/Estimate, previously only visible in
the table despite the panel having more room for them
- block title shows live counts + current view/group/code-scan state
Simplified vs. the literal ASCII mockup: [s] filters the underlying set
before grouping, so e.g. the default 'active' view omits Done/Cancelled
groups entirely rather than always showing them collapsed - a simpler,
more internally consistent model. Flagged for foksiu to request the exact
mockup behavior if he wants it.
Verified via pyte: group/status-view cycling, collapse/expand (arrow flips
and child rows appear/disappear), right panel on header vs. todo row.
Every place building an owner/repo slug for the provider API (refresh,
release, add_collaborator) used path.file_name() as the repo segment - the
local checkout's directory name. That's usually the same as the GitHub repo
name, but not always: vulp's own folder is 'vulp', its GitHub repo is 'fxg',
so every refresh silently 404'd fetching its own metadata/PRs/issues/CI (the
cache carried an empty meta block with fetched_at: 0, never actually set).
RepoSummary now also carries remote_repo_name, parsed from the git remote
URL alongside owner (same source, same parsing approach) - the segment the
provider actually knows the repo by. All four slug-building call sites now
prefer it, falling back to the folder name only when there's no remote to
parse it from. Verified: 'vulp refresh' now correctly hits dualfroz/fxg and
the cache shows real stars/issues/PRs for vulp instead of the empty stub.
Sprint E, redesigned from the original per-repo-metadata plan: the deploy
host already mirrors workspace_root 1:1 (documented convention), so instead
of maintaining a repo->host/compose-path mapping by hand, vulp refresh runs
one batched remote command (via a configurable ssh wrapper) listing every
mirrored repo's HEAD sha and diffs it against local HEAD.
- src/deploy.rs: DeployState (InSync/Behind(n)/Diverged), scan_deploy_heads
(one remote round-trip, retried once for this VPS's known SSH flakiness),
drift_for (path relative to workspace_root -> host lookup -> local git
rev-list --count)
- config: deploy_ssh_cmd + deploy_root, both empty by default (feature is
fully inert, no SSH attempted, until configured)
- cache: RepoMeta.deploy_status, folded into the existing refresh loop
(TUI [R] and CLI) alongside CI/dependabot rather than a second pass
- table: DEPLOY column (checkmark/behind-count/diverged/dash); CLI
list --stale-deploy and deploy_status in --json
- verified against the real VPS: 37/49 repos matched to a mirror (30 in
sync, 7 behind 1-4 commits - a real signal), everything else correctly
shows unset (no mirror there, e.g. vulp itself, a pure OSS dev tool)
- set deploy_ssh_cmd/deploy_root in the live config so this is active now,
not just theoretically configurable
The hub hands its live terminal to detail/project_detail on open (already
flash-free going in), but both sub-views unconditionally tore the terminal
down on exit; the hub's next loop iteration then always called tui::setup()
again, producing a real LeaveAlternateScreen -> (plain terminal visible for
one frame) -> EnterAlternateScreen blink on every trip back.
- detail::run / project_detail::run now return the live terminal on a clean
exit instead of tearing it down (only torn down if the inner loop itself
errors); hub::tui_phase accepts an already-live terminal and skips its own
setup() when given one, threading it across loop iterations
- project_detail's own nested repo-detail call (Enter on its Repos tab) had
the same issue one level down, fixed the same way via Option<Tty> so the
terminal can be taken out for the nested call and put back
- verified via raw pty byte counts: two full hub<->detail round trips now
show zero EnterAlternateScreen/LeaveAlternateScreen pairs in between,
vs. one pair per trip before
- src/stats/: OwnEmailMatcher (config emails + GH no-reply per account) +
git-side --author filtering (cheap even on the pterodactyl fork's 4700+
commits); streak (current/longest, midnight-grace rule), 52-week heatmap,
day/hour histograms — one scan feeds all of them, data layer only for now
(visual Stats-tab redesign stays in Sprint D's mockup-first cycle)
- src/stats/cadence.rs: release cadence (median tag gap, local tags, Tier A)
- src/stats/size.rs + cache.rs: git-object vs working-tree size, persisted
in new ~/.vulp/stats_cache.toml (max 1 sample/day), recorded only during
[R]/vulp refresh — confirms vulp's own filter-repo cleanup (539MB->834KB)
- src/digest.rs: WeeklyDigest struct + 3 renderers (Discord embed/markdown
archive/plaintext); vulp digest [send discord|webhook <url>] CLI, webhook
stored via existing auth token store (~/.vulp/.tokens, 0600), never logged;
send respects --dry-run
- TUI: [d] on Stats previews the digest in a Modal::Info before anything is
ever sent
- fix: list_contributors used --all (double-counts after fetching remotes),
now --branches
- config: stats_author_emails field (TUI-editable)
- verified: streak/delta/top-repo/release math against a hand-built commit
history, webhook validation+permissions, dry-run, real refresh (51 repos,
vulp's own entry confirms the filter-repo win), TUI preview via pyte
- Provider::org_dependabot_counts: one org-level request per distinct owner
(cursor pagination via Link header — page= returns 400 on this endpoint);
403/404 → None (unknown) vs Some(0) = checked, none open
- Provider::latest_ci_status: latest Actions run on the repo's branch →
success/failure/running; CiStatus enum with stable string form
- cache RepoMeta: ci_status + dependabot_open replace unused ci_ok;
RepoSummary carries both, applied at startup/scan/refresh (TUI + CLI)
- Repos table: CI column (✓/✗/●) + ALERT column (count, red when >0,
'—' = unknown); CLI: list --alerts filter, scan_repos now cache-enriched
- verified against live API: 49 repos, 44 with alert counts, CI statuses
match reality; table rendering checked via pyte
- global --dry-run: sync/release/init/collab/license print 'DRY RUN: would...'
and exit 0 (no auth needed for previews where possible)
- config protected_repos (name/basename, case-insensitive): mutating commands
exit 1 on listed repos unless --force; checked before dry-run
- VULP_AGENT env: activity::log centrally appends '· via agent' to detail,
covering both CLI and TUI writers
- sync: unknown --action now rejected before touching any repo