Marketplace: explain, don't offer, updates that would be refused (PR re-review)
ItemUpdate gains invalid_at_head: compute_updates records why the item cannot be installed at head (catalog invalid, or gone) — the rule update_marketplace_item applies — read once per marketplace from the snapshot at head, else from the catalog parsed at head. The Installed row shows that reason instead of a Review button, and the update counts in the Marketplace tab and Settings count only applicable updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,8 @@ use tauri::Emitter;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::models::marketplace::{
|
||||
effective_installs, CatalogItem, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport,
|
||||
effective_installs, CatalogItem, ItemKind, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot,
|
||||
SyncReport,
|
||||
};
|
||||
use crate::models::{AppSettings, Project};
|
||||
use catalog::{item_fingerprint, parse_catalog};
|
||||
@@ -103,6 +104,18 @@ impl MarketplaceManager {
|
||||
.and_then(|s| s.head_commit.clone())
|
||||
}
|
||||
|
||||
/// Each item's `invalid` reason in the in-memory snapshot, if that
|
||||
/// snapshot is at `head` — without copying the items' previews.
|
||||
fn invalid_reasons_at(
|
||||
&self,
|
||||
marketplace_id: &str,
|
||||
head: &str,
|
||||
) -> Option<HashMap<(ItemKind, String), Option<String>>> {
|
||||
let snapshots = self.snapshots.lock().unwrap();
|
||||
let snap = snapshots.get(marketplace_id)?;
|
||||
(snap.head_commit.as_deref() == Some(head)).then(|| invalid_reasons(&snap.items))
|
||||
}
|
||||
|
||||
pub fn snapshot(&self, marketplace_id: &str) -> Option<MarketplaceSnapshot> {
|
||||
self.snapshots.lock().unwrap().get(marketplace_id).cloned()
|
||||
}
|
||||
@@ -350,12 +363,21 @@ pub async fn remove_marketplace_cache(mgr: &MarketplaceManager, marketplace_id:
|
||||
.await;
|
||||
}
|
||||
|
||||
fn invalid_reasons(items: &[CatalogItem]) -> HashMap<(ItemKind, String), Option<String>> {
|
||||
items
|
||||
.iter()
|
||||
.map(|i| ((i.kind, i.key.clone()), i.invalid.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One marketplace's side of an update check: its head, read once, and its
|
||||
/// cache, opened once, with trees shared across installs (PR review #10).
|
||||
struct UpdateCheck {
|
||||
head: String,
|
||||
repo: Option<gix::Repository>,
|
||||
trees: HashMap<String, Result<GitTree, String>>,
|
||||
/// Catalog `invalid` per item at head, read once when first needed.
|
||||
invalid_at_head: Option<HashMap<(ItemKind, String), Option<String>>>,
|
||||
}
|
||||
|
||||
impl UpdateCheck {
|
||||
@@ -365,9 +387,40 @@ impl UpdateCheck {
|
||||
head,
|
||||
repo: None,
|
||||
trees: HashMap::new(),
|
||||
invalid_at_head: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Why `inst`'s item cannot be installed at head — the rule
|
||||
/// `update_marketplace_item` applies (round 2) — from the snapshot when
|
||||
/// it is at head, else from the catalog parsed at head.
|
||||
fn invalid_reason(
|
||||
&mut self,
|
||||
mgr: &MarketplaceManager,
|
||||
m: &Marketplace,
|
||||
inst: &MarketplaceInstall,
|
||||
) -> Option<String> {
|
||||
if self.invalid_at_head.is_none() {
|
||||
let head = self.head.clone();
|
||||
let reasons = match mgr.invalid_reasons_at(&m.id, &head) {
|
||||
Some(r) => r,
|
||||
None => match self.tree(&git::cache_path(mgr.data_root(), &m.id), &head) {
|
||||
Ok(tree) => invalid_reasons(&parse_catalog(tree)),
|
||||
Err(e) => return Some(e),
|
||||
},
|
||||
};
|
||||
self.invalid_at_head = Some(reasons);
|
||||
}
|
||||
match self
|
||||
.invalid_at_head
|
||||
.as_ref()
|
||||
.and_then(|r| r.get(&(inst.kind, inst.key.clone())))
|
||||
{
|
||||
Some(reason) => reason.clone(),
|
||||
None => Some(format!("\"{}\" is no longer in \"{}\".", inst.key, m.name)),
|
||||
}
|
||||
}
|
||||
|
||||
fn tree(&mut self, repo_path: &Path, commit: &str) -> Result<&GitTree, String> {
|
||||
if !self.trees.contains_key(commit) {
|
||||
if self.repo.is_none() {
|
||||
@@ -428,6 +481,7 @@ pub fn compute_updates(
|
||||
item: inst.item_ref(),
|
||||
pinned: inst.commit.clone(),
|
||||
head: check.head.clone(),
|
||||
invalid_at_head: check.invalid_reason(mgr, m, inst),
|
||||
}),
|
||||
Ok(false) => {}
|
||||
Err(e) => log::debug!("Update check skipped for {}: {}", inst.key, e),
|
||||
@@ -876,6 +930,52 @@ mod tests {
|
||||
assert_eq!(updates[0].head, c2);
|
||||
}
|
||||
|
||||
/// Re-review round 2: an update to a head where the item is not
|
||||
/// installable is listed with the reason, since update_marketplace_item
|
||||
/// would always refuse it.
|
||||
#[tokio::test]
|
||||
async fn an_update_to_an_invalid_version_carries_the_reason() {
|
||||
let Some(fx) = GitFixture::new() else { return };
|
||||
let c1 = fx.with_all_kinds();
|
||||
fx.write(
|
||||
"hooks/notify-on-stop/hook.json",
|
||||
r#"{"hooks":{"PreFoo":[{"hooks":[{"type":"command","command":"x"}]}]}}"#,
|
||||
);
|
||||
fx.write(
|
||||
"agents/code-reviewer.md",
|
||||
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview harder.\n",
|
||||
);
|
||||
std::fs::remove_file(fx.dir.path().join("commands/example-command.md")).unwrap();
|
||||
let c2 = fx.commit("break the hook, tweak the agent, drop the command");
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mut settings = settings_with(&fx.url());
|
||||
settings.global_marketplace_installs = vec![
|
||||
install(ItemKind::Hook, "notify-on-stop", &c1),
|
||||
install(ItemKind::Agent, "code-reviewer", &c1),
|
||||
install(ItemKind::Command, "example-command", &c1),
|
||||
];
|
||||
{
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
|
||||
check_reasons(&compute_updates(&mgr, &settings, &[]), &c2);
|
||||
}
|
||||
// Same answer from the cache alone (no snapshot in memory).
|
||||
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||
check_reasons(&compute_updates(&mgr, &settings, &[]), &c2);
|
||||
}
|
||||
|
||||
fn check_reasons(updates: &[ItemUpdate], head: &str) {
|
||||
let reason = |key: &str| {
|
||||
let u = updates.iter().find(|u| u.item.key == key).unwrap();
|
||||
assert_eq!(u.head, head);
|
||||
u.invalid_at_head.clone()
|
||||
};
|
||||
assert_eq!(updates.len(), 3, "{updates:?}");
|
||||
assert!(reason("notify-on-stop").unwrap().contains("PreFoo"));
|
||||
assert_eq!(reason("code-reviewer"), None);
|
||||
assert!(reason("example-command").unwrap().contains("no longer in"));
|
||||
}
|
||||
|
||||
/// PR review #10: refreshing one marketplace sets only its pins, and so
|
||||
/// never waits on another marketplace's lock.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -202,6 +202,10 @@ pub struct ItemUpdate {
|
||||
pub item: MarketplaceItemRef,
|
||||
pub pinned: String,
|
||||
pub head: String,
|
||||
/// Why the item cannot be installed at `head` (invalid there, or gone),
|
||||
/// so the update would be refused; `None` when it can be applied.
|
||||
#[serde(default)]
|
||||
pub invalid_at_head: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user