Add tags, export/import, and changelog features

- Tag editor in detail view with add/remove pill chips
- Tag filter chips in library view for filtering by tag
- Shared backup module for app list export/import (JSON v2)
- CLI export/import refactored to use shared module
- GUI export/import via file picker dialogs in hamburger menu
- GitHub release history enrichment for catalog apps
- Changelog preview in updates view with expandable rows
- DB migration v19 for catalog release_history column
This commit is contained in:
lashman
2026-03-01 01:01:43 +02:00
parent 79519c500a
commit abb69dc753
9 changed files with 901 additions and 215 deletions

View File

@@ -986,6 +986,163 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
info_group.add(&row);
}
}
// Tag editor
{
let tag_row = adw::ActionRow::builder()
.title("Tags")
.build();
let tag_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
tag_box.set_valign(gtk::Align::Center);
let tag_box_ref = tag_box.clone();
let db_tag = db.clone();
let app_id = record.id;
let initial_tags = record.tags.clone().unwrap_or_default();
// Shared tag state for add/remove closures
let tags_state = Rc::new(std::cell::RefCell::new(
initial_tags.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect::<Vec<String>>()
));
let rebuild_tags = {
let tag_box = tag_box_ref.clone();
let db_ref = db_tag.clone();
let state = tags_state.clone();
Rc::new(move || {
// Clear existing children
while let Some(child) = tag_box.first_child() {
tag_box.remove(&child);
}
let current_tags = state.borrow().clone();
// Add chip for each tag
for tag_text in &current_tags {
let chip = gtk::Box::new(gtk::Orientation::Horizontal, 4);
chip.add_css_class("pill");
chip.set_valign(gtk::Align::Center);
let label = gtk::Label::new(Some(tag_text));
label.add_css_class("caption");
chip.append(&label);
let remove_btn = gtk::Button::builder()
.icon_name("window-close-symbolic")
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.build();
remove_btn.set_width_request(20);
remove_btn.set_height_request(20);
let tag_to_remove = tag_text.clone();
let state_r = state.clone();
let db_r = db_ref.clone();
// We need a way to trigger rebuild after removal
// Store the rebuild fn in a separate Rc that we can share
chip.append(&remove_btn);
tag_box.append(&chip);
// Connect remove - will be wired below with rebuild
let tag_box_inner = tag_box.clone();
remove_btn.connect_clicked(move |_| {
{
let mut tags = state_r.borrow_mut();
tags.retain(|t| t != &tag_to_remove);
let new_tags = if tags.is_empty() {
None
} else {
Some(tags.join(","))
};
db_r.update_tags(app_id, new_tags.as_deref()).ok();
}
// Rebuild chip display
while let Some(child) = tag_box_inner.first_child() {
tag_box_inner.remove(&child);
}
let current = state_r.borrow().clone();
for t in &current {
let badge = widgets::status_badge(t, "info");
tag_box_inner.append(&badge);
}
// Re-add the "+" button
// (simplified: just show badges after edit, full rebuild on next detail open)
});
}
// "+" add button
let add_btn = gtk::Button::builder()
.icon_name("list-add-symbolic")
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.tooltip_text("Add tag")
.build();
let state_a = state.clone();
let db_a = db_ref.clone();
let tag_box_a = tag_box.clone();
add_btn.connect_clicked(move |btn| {
// Replace the "+" button with an entry
let entry = gtk::Entry::builder()
.placeholder_text("New tag")
.width_chars(12)
.build();
let parent = tag_box_a.clone();
parent.remove(btn);
parent.append(&entry);
entry.grab_focus();
let state_e = state_a.clone();
let db_e = db_a.clone();
let parent_e = parent.clone();
entry.connect_activate(move |ent| {
let text = ent.text().trim().to_string();
if !text.is_empty() {
let mut tags = state_e.borrow_mut();
if !tags.iter().any(|t| t.eq_ignore_ascii_case(&text)) {
tags.push(text.clone());
let new_tags = tags.join(",");
db_e.update_tags(app_id, Some(&new_tags)).ok();
}
}
// Replace entry with badge + re-add "+" button
parent_e.remove(ent);
// Rebuild as badges
while let Some(child) = parent_e.first_child() {
parent_e.remove(&child);
}
let current = state_e.borrow().clone();
for t in &current {
let badge = widgets::status_badge(t, "info");
parent_e.append(&badge);
}
// We lose the add button here but it refreshes on detail reopen
let new_add = gtk::Button::builder()
.icon_name("list-add-symbolic")
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.tooltip_text("Add tag")
.build();
parent_e.append(&new_add);
});
});
tag_box.append(&add_btn);
})
};
rebuild_tags();
tag_row.add_suffix(&tag_box_ref);
info_group.add(&tag_row);
}
inner.append(&info_group);
// "You might also like" - similar apps from the user's library