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

@@ -46,6 +46,10 @@ pub struct LibraryView {
records: Rc<RefCell<Vec<AppImageRecord>>>,
search_empty_page: adw::StatusPage,
update_banner: adw::Banner,
// Tag filtering
tag_bar: gtk::Box,
tag_scroll: gtk::ScrolledWindow,
active_tag: Rc<RefCell<Option<String>>>,
// Batch selection
selection_mode: Rc<Cell<bool>>,
selected_ids: Rc<RefCell<HashSet<i64>>>,
@@ -377,12 +381,32 @@ impl LibraryView {
.build();
update_banner.set_action_name(Some("win.show-updates"));
// --- Tag filter bar ---
let tag_bar = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.margin_start(12)
.margin_end(12)
.margin_top(6)
.margin_bottom(2)
.visible(false)
.build();
let active_tag: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let tag_scroll = gtk::ScrolledWindow::builder()
.child(&tag_bar)
.hscrollbar_policy(gtk::PolicyType::Automatic)
.vscrollbar_policy(gtk::PolicyType::Never)
.visible(false)
.build();
// --- Assemble toolbar view ---
let content_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
content_box.append(&update_banner);
content_box.append(&search_bar);
content_box.append(&tag_scroll);
content_box.append(&stack);
content_box.append(&action_bar);
@@ -559,6 +583,9 @@ impl LibraryView {
records,
search_empty_page,
update_banner,
tag_bar,
tag_scroll,
active_tag,
selection_mode,
selected_ids,
_action_bar: action_bar,
@@ -600,24 +627,29 @@ impl LibraryView {
self.list_box.remove(&row);
}
// Reset active tag filter
*self.active_tag.borrow_mut() = None;
// Sort records based on current sort mode
let mut new_records = new_records;
match self.sort_mode.get() {
SortMode::NameAsc => {
new_records.sort_by(|a, b| {
let name_a = a.app_name.as_deref().unwrap_or(&a.filename).to_lowercase();
let name_b = b.app_name.as_deref().unwrap_or(&b.filename).to_lowercase();
name_a.cmp(&name_b)
});
}
SortMode::RecentlyAdded => {
new_records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen));
}
SortMode::Size => {
new_records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
self.sort_records(&mut new_records);
// Collect all unique tags for the tag filter bar
let mut all_tags = std::collections::BTreeSet::new();
for record in &new_records {
if let Some(ref tags) = record.tags {
for tag in tags.split(',') {
let trimmed = tag.trim();
if !trimmed.is_empty() {
all_tags.insert(trimmed.to_string());
}
}
}
}
// Build tag filter chip bar
self.build_tag_chips(&all_tags);
// Build cards and list rows
for record in &new_records {
// Grid card
@@ -652,6 +684,91 @@ impl LibraryView {
}
}
fn sort_records(&self, records: &mut Vec<AppImageRecord>) {
match self.sort_mode.get() {
SortMode::NameAsc => {
records.sort_by(|a, b| {
let name_a = a.app_name.as_deref().unwrap_or(&a.filename).to_lowercase();
let name_b = b.app_name.as_deref().unwrap_or(&b.filename).to_lowercase();
name_a.cmp(&name_b)
});
}
SortMode::RecentlyAdded => {
records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen));
}
SortMode::Size => {
records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
}
}
}
fn build_tag_chips(&self, all_tags: &std::collections::BTreeSet<String>) {
// Clear existing chips
while let Some(child) = self.tag_bar.first_child() {
self.tag_bar.remove(&child);
}
if all_tags.is_empty() {
self.tag_scroll.set_visible(false);
self.tag_bar.set_visible(false);
return;
}
self.tag_scroll.set_visible(true);
self.tag_bar.set_visible(true);
// "All" chip
let all_chip = gtk::ToggleButton::builder()
.label(&i18n("All"))
.active(true)
.css_classes(["pill"])
.build();
widgets::set_pointer_cursor(&all_chip);
self.tag_bar.append(&all_chip);
// Tag chips
let mut chips: Vec<gtk::ToggleButton> = vec![all_chip.clone()];
for tag in all_tags {
let chip = gtk::ToggleButton::builder()
.label(tag)
.css_classes(["pill"])
.group(&all_chip)
.build();
widgets::set_pointer_cursor(&chip);
self.tag_bar.append(&chip);
chips.push(chip);
}
// Connect "All" chip
{
let active_tag = self.active_tag.clone();
let flow_box = self.flow_box.clone();
let list_box = self.list_box.clone();
let records = self.records.clone();
all_chip.connect_toggled(move |btn| {
if btn.is_active() {
*active_tag.borrow_mut() = None;
apply_tag_filter(&flow_box, &list_box, &records.borrow(), None);
}
});
}
// Connect each tag chip
for chip in &chips[1..] {
let tag_name = chip.label().map(|l| l.to_string()).unwrap_or_default();
let active_tag = self.active_tag.clone();
let flow_box = self.flow_box.clone();
let list_box = self.list_box.clone();
let records = self.records.clone();
chip.connect_toggled(move |btn| {
if btn.is_active() {
*active_tag.borrow_mut() = Some(tag_name.clone());
apply_tag_filter(&flow_box, &list_box, &records.borrow(), Some(&tag_name));
}
});
}
}
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
@@ -918,3 +1035,40 @@ fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model:
});
widget.as_ref().add_controller(long_press);
}
/// Apply tag filtering to both flow_box (grid) and list_box (list).
fn apply_tag_filter(
flow_box: &gtk::FlowBox,
list_box: &gtk::ListBox,
records: &[AppImageRecord],
tag: Option<&str>,
) {
let match_flags: Vec<bool> = records
.iter()
.map(|rec| {
match tag {
None => true, // "All" - show everything
Some(filter_tag) => {
rec.tags.as_ref().map_or(false, |tags| {
tags.split(',')
.any(|t| t.trim().eq_ignore_ascii_case(filter_tag))
})
}
}
})
.collect();
// Filter grid view
let flags_grid = match_flags.clone();
flow_box.set_filter_func(move |child| {
let idx = child.index() as usize;
flags_grid.get(idx).copied().unwrap_or(false)
});
// Filter list view
for (i, visible) in match_flags.iter().enumerate() {
if let Some(row) = list_box.row_at_index(i as i32) {
row.set_visible(*visible);
}
}
}