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:
@@ -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: >k::FlowBox,
|
||||
list_box: >k::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user