Sprint B.5: add-LICENSE action (MIT/Apache-2.0) in [m] menu + vulp repo license
- src/license/: embedded templates, holder resolution (config > git user.name > account username), write path shared by TUI and CLI - TUI: bulk-aware [m] entry over action_targets(), kind select + confirm, point-updates has_license/is_dirty, logs to activity (new License kind) - CLI: vulp repo license <repo>...|--all [--kind] [--holder] [--json], skips already-licensed repos, exit 2/4 contract kept - config: license_holder + default_license fields (TUI-editable) - never overwrites an existing license, never auto-commits
This commit is contained in:
1 parent
0468c032c6
commit
f1432d1cd3
15 files changed
+506
-7
No files matched your search
@@ -6,7 +6,7 @@ use super::modals_spawn::{
|
||||
spawn_convention_preview, spawn_create_issue, spawn_create_pr, spawn_find_usages,
|
||||
spawn_list_functions, spawn_repo_stats, spawn_rename_preview,
|
||||
};
|
||||
use super::state::{HubState, MenuAction, Modal, PhaseResult, SelectTag, SortMode, TextTag};
|
||||
use super::state::{ConfirmTag, HubState, MenuAction, Modal, PhaseResult, SelectTag, SortMode, TextTag};
|
||||
|
||||
/// Handles one step of a sort/filter, repo-creation, sync, Quick Actions, or
|
||||
/// code-intel modal chain. Returns `None` once `modal` has been consumed by
|
||||
@@ -157,6 +157,25 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
|
||||
}
|
||||
}
|
||||
Some(MenuAction::CodeIntelPicker) => super::modals_spawn::open_codeintel_picker(state),
|
||||
Some(MenuAction::AddLicense) => {
|
||||
let targets: Vec<usize> = state.action_targets().into_iter()
|
||||
.filter(|&ri| !state.repos[ri].has_license)
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
state.status_msg = Some("All targeted repos already have a LICENSE".into());
|
||||
} else {
|
||||
let cursor = match crate::license::default_kind(&state.cfg) {
|
||||
crate::license::LicenseKind::Apache2 => 1,
|
||||
crate::license::LicenseKind::Mit => 0,
|
||||
};
|
||||
state.modal = Modal::Select {
|
||||
title: format!("Add LICENSE — {} repo{}", targets.len(), if targets.len() == 1 { "" } else { "s" }),
|
||||
options: vec!["MIT".into(), "Apache-2.0".into()],
|
||||
cursor,
|
||||
tag: SelectTag::LicenseKind { targets },
|
||||
};
|
||||
}
|
||||
}
|
||||
Some(MenuAction::StashSave(ri)) | Some(MenuAction::StashPop(ri)) => {
|
||||
let ri = *ri;
|
||||
let repo = state.repos.get(ri)?;
|
||||
@@ -187,6 +206,61 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── Add LICENSE ([m] action, bulk over action_targets) ────────────────
|
||||
Modal::Select { cursor, tag: SelectTag::LicenseKind { targets }, .. } => {
|
||||
let kind = if cursor == 1 { crate::license::LicenseKind::Apache2 } else { crate::license::LicenseKind::Mit };
|
||||
let names: Vec<&str> = targets.iter().take(6)
|
||||
.filter_map(|&ri| state.repos.get(ri).map(|r| r.name.as_str()))
|
||||
.collect();
|
||||
let more = targets.len().saturating_sub(names.len());
|
||||
let list = if more > 0 {
|
||||
format!("{} +{} more", names.join(", "), more)
|
||||
} else {
|
||||
names.join(", ")
|
||||
};
|
||||
state.modal = Modal::Confirm {
|
||||
message: format!(
|
||||
"Write {} LICENSE to {} repo{}? ({}) — not committed",
|
||||
kind.label(), targets.len(), if targets.len() == 1 { "" } else { "s" }, list,
|
||||
),
|
||||
tag: ConfirmTag::WriteLicense { kind, targets },
|
||||
};
|
||||
None
|
||||
}
|
||||
Modal::Confirm { tag: ConfirmTag::WriteLicense { kind, targets }, .. } => {
|
||||
let holder = crate::license::holder_for(&state.cfg);
|
||||
let year = chrono::Datelike::year(&chrono::Utc::now());
|
||||
let (mut written, mut skipped, mut failed) = (0usize, 0usize, 0usize);
|
||||
for ri in targets {
|
||||
let Some(repo) = state.repos.get(ri) else { continue };
|
||||
let (path, name) = (repo.path.clone(), repo.name.clone());
|
||||
match crate::license::write_license(&path, kind, &holder, year) {
|
||||
Ok(crate::license::WriteOutcome::Written) => {
|
||||
written += 1;
|
||||
state.repos[ri].has_license = true;
|
||||
// New untracked LICENSE file — the repo is dirty now.
|
||||
state.repos[ri].is_dirty = true;
|
||||
crate::activity::log(&crate::activity::ActivityEntry {
|
||||
ts: chrono::Utc::now().timestamp(),
|
||||
kind: crate::activity::ActivityKind::License,
|
||||
repo: name,
|
||||
detail: format!("{} ({})", kind.label(), holder),
|
||||
});
|
||||
}
|
||||
Ok(crate::license::WriteOutcome::AlreadyLicensed) => {
|
||||
skipped += 1;
|
||||
state.repos[ri].has_license = true;
|
||||
}
|
||||
Err(_) => failed += 1,
|
||||
}
|
||||
}
|
||||
let mut msg = format!("LICENSE ({}): {} written", kind.label(), written);
|
||||
if skipped > 0 { msg.push_str(&format!(", {} already licensed", skipped)); }
|
||||
if failed > 0 { msg.push_str(&format!(", {} FAILED", failed)); }
|
||||
state.status_msg = Some(msg);
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::PrRepo, .. } => {
|
||||
let repo = value.trim().to_string();
|
||||
if repo.is_empty() { return None; }
|
||||
|
||||
@@ -609,6 +609,12 @@ pub fn open_action_menu(state: &mut HubState) {
|
||||
entries.push(entry("Delete project", "Del", kc(KeyCode::Delete)));
|
||||
}
|
||||
|
||||
// Bulk-aware (multi-select > cursor repo > project): only shown when at
|
||||
// least one targeted repo is missing a license file.
|
||||
if state.action_targets().iter().any(|&ri| !state.repos[ri].has_license) {
|
||||
entries.push(entry("Add LICENSE (MIT/Apache)", "", MenuAction::AddLicense));
|
||||
}
|
||||
|
||||
match state.tab {
|
||||
Tab::Todos => {
|
||||
entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' '))));
|
||||
|
||||
@@ -81,6 +81,8 @@ pub enum MenuAction {
|
||||
/// `git stash` / `git stash pop` on the repo under the cursor.
|
||||
StashSave(usize),
|
||||
StashPop(usize),
|
||||
/// Write a LICENSE file into every `action_targets()` repo missing one.
|
||||
AddLicense,
|
||||
}
|
||||
|
||||
/// What a `Modal::Select` list is *for*, mirroring `TextTag`.
|
||||
@@ -121,6 +123,8 @@ pub enum SelectTag {
|
||||
ProfileScope(ProfileScopeAction),
|
||||
// Contextual `[m]` action menu — options paired 1:1 with the actions
|
||||
ActionMenu(Vec<MenuAction>),
|
||||
// add-LICENSE flow: MIT vs Apache-2.0, for the captured target repos
|
||||
LicenseKind { targets: Vec<usize> },
|
||||
// Code intelligence (Stats tab, [m])
|
||||
CodeIntelRepoPick,
|
||||
CodeIntelAction { repo: std::path::PathBuf, name: String },
|
||||
@@ -154,6 +158,8 @@ pub enum ConfirmTag {
|
||||
DeleteTodo(usize),
|
||||
RemoveGitAll,
|
||||
DeleteTemplate(usize),
|
||||
// write a LICENSE (of the chosen kind) into each target repo
|
||||
WriteLicense { kind: crate::license::LicenseKind, targets: Vec<usize> },
|
||||
}
|
||||
|
||||
/// Outcome of one key event while a modal is open, returned by
|
||||
|
||||
@@ -27,6 +27,7 @@ fn kind_color(kind: crate::activity::ActivityKind) -> Color {
|
||||
K::Todo => Color::Yellow,
|
||||
K::Pr => Color::Cyan,
|
||||
K::Issue => Color::Yellow,
|
||||
K::License => Color::Magenta,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ pub fn render_activity_global(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
crate::activity::ActivityKind::Todo => Style::default().fg(Color::Yellow),
|
||||
crate::activity::ActivityKind::Pr => Style::default().fg(Color::Cyan),
|
||||
crate::activity::ActivityKind::Issue => Style::default().fg(Color::Yellow),
|
||||
crate::activity::ActivityKind::License => Style::default().fg(Color::Magenta),
|
||||
};
|
||||
let detail = e.detail.chars().take(detail_w).collect::<String>();
|
||||
Row::new(vec![
|
||||
|
||||
Reference in new issue
Block a user