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

@@ -3,6 +3,7 @@ use glib::ExitCode;
use gtk::prelude::*;
use std::time::Instant;
use crate::core::backup;
use crate::core::database::Database;
use crate::core::discovery;
use crate::core::duplicates;
@@ -885,209 +886,39 @@ fn cmd_verify(path: &str, expected_sha256: Option<&str>) -> ExitCode {
// --- Export/Import library ---
fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode {
let records = match db.get_all_appimages() {
Ok(r) => r,
let path = output.unwrap_or("driftwood-apps.json");
let export_path = std::path::Path::new(path);
match backup::export_app_list(db, export_path) {
Ok(count) => {
eprintln!("Exported {} AppImages to {}", count, path);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::FAILURE;
ExitCode::FAILURE
}
};
let appimages: Vec<serde_json::Value> = records
.iter()
.map(|r| {
serde_json::json!({
"path": r.path,
"app_name": r.app_name,
"app_version": r.app_version,
"integrated": r.integrated,
"notes": r.notes,
"categories": r.categories,
})
})
.collect();
let export_data = serde_json::json!({
"version": 1,
"exported_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"appimages": appimages,
});
let json_str = match serde_json::to_string_pretty(&export_data) {
Ok(s) => s,
Err(e) => {
eprintln!("Error serializing export data: {}", e);
return ExitCode::FAILURE;
}
};
if let Some(path) = output {
if let Err(e) = std::fs::write(path, &json_str) {
eprintln!("Error writing to {}: {}", path, e);
return ExitCode::FAILURE;
}
} else {
println!("{}", json_str);
}
eprintln!("Exported {} AppImages", records.len());
ExitCode::SUCCESS
}
fn cmd_import(db: &Database, file: &str) -> ExitCode {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
let import_path = std::path::Path::new(file);
match backup::import_app_list(db, import_path) {
Ok(result) => {
eprintln!("Matched and merged metadata for {} apps", result.matched);
if !result.missing.is_empty() {
eprintln!(
"{} apps not found in library: {}",
result.missing.len(),
result.missing.join(", ")
);
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error reading {}: {}", file, e);
return ExitCode::FAILURE;
eprintln!("Error: {}", e);
ExitCode::FAILURE
}
};
let data: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!("Error parsing JSON: {}", e);
return ExitCode::FAILURE;
}
};
let entries = match data.get("appimages").and_then(|a| a.as_array()) {
Some(arr) => arr,
None => {
eprintln!("Error: JSON missing 'appimages' array");
return ExitCode::FAILURE;
}
};
let total = entries.len();
let mut imported = 0u32;
let mut skipped = 0u32;
for entry in entries {
let path_str = match entry.get("path").and_then(|p| p.as_str()) {
Some(p) => p,
None => {
skipped += 1;
continue;
}
};
let file_path = std::path::Path::new(path_str);
if !file_path.exists() {
skipped += 1;
continue;
}
// Validate that the file is actually an AppImage
let appimage_type = match discovery::detect_appimage(file_path) {
Some(t) => t,
None => {
eprintln!(" Skipping {} - not a valid AppImage", path_str);
skipped += 1;
continue;
}
};
let metadata = std::fs::metadata(file_path);
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
let is_executable = metadata
.as_ref()
.map(|m| {
use std::os::unix::fs::PermissionsExt;
m.permissions().mode() & 0o111 != 0
})
.unwrap_or(false);
let filename = file_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let file_modified = metadata
.as_ref()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|dur| {
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
});
let id = match db.upsert_appimage(
path_str,
&filename,
Some(appimage_type.as_i32()),
size_bytes,
is_executable,
file_modified.as_deref(),
) {
Ok(id) => id,
Err(e) => {
eprintln!(" Error registering {}: {}", path_str, e);
skipped += 1;
continue;
}
};
// Restore metadata fields from the export
let app_name = entry.get("app_name").and_then(|v| v.as_str());
let app_version = entry.get("app_version").and_then(|v| v.as_str());
let categories = entry.get("categories").and_then(|v| v.as_str());
if app_name.is_some() || app_version.is_some() {
db.update_metadata(
id,
app_name,
app_version,
None,
None,
categories,
None,
None,
None,
).ok();
}
// Restore notes if present
if let Some(notes_str) = entry.get("notes").and_then(|v| v.as_str()) {
db.update_notes(id, Some(notes_str)).ok();
}
// If it was integrated in the export, integrate it now
let was_integrated = entry
.get("integrated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if was_integrated {
// Need the full record to integrate
if let Ok(Some(record)) = db.get_appimage_by_id(id) {
if !record.integrated {
match integrator::integrate_tracked(&record, &db) {
Ok(result) => {
db.set_integrated(
id,
true,
Some(&result.desktop_file_path.to_string_lossy()),
).ok();
}
Err(e) => {
eprintln!(" Warning: could not integrate {}: {}", path_str, e);
}
}
}
}
}
imported += 1;
}
eprintln!(
"Imported {} of {} AppImages ({} skipped - file not found)",
imported,
total,
skipped,
);
ExitCode::SUCCESS
}

View File

@@ -407,6 +407,197 @@ fn read_manifest(archive_path: &Path) -> Result<BackupManifest, BackupError> {
.map_err(|e| BackupError::Io(format!("Invalid manifest: {}", e)))
}
// ===== App list export/import (JSON v2) =====
/// Result of importing an app list.
#[derive(Debug)]
pub struct ImportResult {
pub matched: usize,
pub missing: Vec<String>,
}
/// Export the app list to a JSON file (v2 format with extended fields).
pub fn export_app_list(db: &Database, path: &Path) -> Result<usize, BackupError> {
let records = db.get_all_appimages()
.map_err(|e| BackupError::Database(e.to_string()))?;
let appimages: Vec<serde_json::Value> = records
.iter()
.map(|r| {
serde_json::json!({
"path": r.path,
"app_name": r.app_name,
"app_version": r.app_version,
"integrated": r.integrated,
"notes": r.notes,
"categories": r.categories,
"tags": r.tags,
"pinned": r.pinned,
"launch_args": r.launch_args,
"sandbox_mode": r.sandbox_mode,
"autostart": r.autostart,
})
})
.collect();
let count = appimages.len();
let export_data = serde_json::json!({
"version": 2,
"exported_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"appimages": appimages,
});
let json_str = serde_json::to_string_pretty(&export_data)
.map_err(|e| BackupError::Io(e.to_string()))?;
fs::write(path, &json_str)
.map_err(|e| BackupError::Io(e.to_string()))?;
Ok(count)
}
/// Import an app list from a JSON file (supports both v1 and v2 formats).
/// Matches apps by path. Merges metadata for matched apps (only fills empty fields).
pub fn import_app_list(db: &Database, path: &Path) -> Result<ImportResult, BackupError> {
let content = fs::read_to_string(path)
.map_err(|e| BackupError::Io(e.to_string()))?;
let data: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| BackupError::Io(format!("Invalid JSON: {}", e)))?;
let entries = data.get("appimages")
.and_then(|a| a.as_array())
.ok_or_else(|| BackupError::Io("Missing 'appimages' array".to_string()))?;
let mut matched = 0usize;
let mut missing = Vec::new();
for entry in entries {
let path_str = match entry.get("path").and_then(|p| p.as_str()) {
Some(p) => p,
None => continue,
};
// Try to find this app in the database by path
let existing = db.get_appimage_by_path(path_str)
.ok()
.flatten();
let record = match existing {
Some(r) => r,
None => {
// App not in DB - check if file exists on disk
if !Path::new(path_str).exists() {
missing.push(path_str.to_string());
continue;
}
missing.push(path_str.to_string());
continue;
}
};
let id = record.id;
// Merge metadata: only overwrite if existing is empty/None and import has a value
let app_name = merge_str_field(record.app_name.as_deref(), entry, "app_name");
let app_version = merge_str_field(record.app_version.as_deref(), entry, "app_version");
let categories = merge_str_field(record.categories.as_deref(), entry, "categories");
if app_name.is_some() || app_version.is_some() || categories.is_some() {
db.update_metadata(
id,
app_name.as_deref().or(record.app_name.as_deref()),
app_version.as_deref().or(record.app_version.as_deref()),
None,
None,
categories.as_deref().or(record.categories.as_deref()),
None,
None,
None,
).ok();
}
// Merge notes
if record.notes.as_ref().map_or(true, |n| n.is_empty()) {
if let Some(notes) = entry.get("notes").and_then(|v| v.as_str()) {
if !notes.is_empty() {
db.update_notes(id, Some(notes)).ok();
}
}
}
// Merge tags (union of existing + imported, no duplicates)
if let Some(import_tags) = entry.get("tags").and_then(|v| v.as_str()) {
if !import_tags.is_empty() {
let mut tag_set: std::collections::BTreeSet<String> = record
.tags
.as_deref()
.unwrap_or("")
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
for tag in import_tags.split(',') {
let trimmed = tag.trim();
if !trimmed.is_empty() {
tag_set.insert(trimmed.to_string());
}
}
let merged = tag_set.into_iter().collect::<Vec<_>>().join(",");
db.update_tags(id, Some(&merged)).ok();
}
}
// Merge pinned (only set if imported says true and current is false)
if !record.pinned {
if let Some(true) = entry.get("pinned").and_then(|v| v.as_bool()) {
db.set_pinned(id, true).ok();
}
}
// Merge launch_args
if record.launch_args.as_ref().map_or(true, |a| a.is_empty()) {
if let Some(args) = entry.get("launch_args").and_then(|v| v.as_str()) {
if !args.is_empty() {
db.update_launch_args(id, Some(args)).ok();
}
}
}
// Merge sandbox_mode
if record.sandbox_mode.is_none() {
if let Some(mode) = entry.get("sandbox_mode").and_then(|v| v.as_str()) {
if !mode.is_empty() {
db.update_sandbox_mode(id, Some(mode)).ok();
}
}
}
// Merge autostart (only set if imported says true and current is false)
if !record.autostart {
if let Some(true) = entry.get("autostart").and_then(|v| v.as_bool()) {
db.set_autostart(id, true).ok();
}
}
matched += 1;
}
Ok(ImportResult { matched, missing })
}
/// Helper: returns Some(imported_value) only if existing is empty and import has a non-empty value.
fn merge_str_field(existing: Option<&str>, entry: &serde_json::Value, key: &str) -> Option<String> {
if existing.map_or(false, |s| !s.is_empty()) {
return None; // existing has data, don't overwrite
}
entry.get(key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -154,6 +154,7 @@ pub struct CatalogApp {
pub ocs_arch: Option<String>,
pub ocs_md5sum: Option<String>,
pub ocs_comments: Option<i64>,
pub release_history: Option<String>,
}
#[derive(Debug, Clone)]
@@ -484,6 +485,10 @@ impl Database {
self.migrate_to_v18()?;
}
if current_version < 19 {
self.migrate_to_v19()?;
}
// Ensure all expected columns exist (repairs DBs where a migration
// was updated after it had already run on this database)
self.ensure_columns()?;
@@ -1050,6 +1055,18 @@ impl Database {
Ok(())
}
fn migrate_to_v19(&self) -> SqlResult<()> {
self.conn.execute(
"ALTER TABLE catalog_apps ADD COLUMN release_history TEXT",
[],
).ok();
self.conn.execute(
"UPDATE schema_version SET version = ?1",
params![19],
)?;
Ok(())
}
pub fn upsert_appimage(
&self,
path: &str,
@@ -1221,7 +1238,8 @@ impl Database {
github_enriched_at, github_download_url, github_release_assets, github_description, github_readme,
ocs_id, ocs_downloads, ocs_score, ocs_typename, ocs_personid, ocs_description, ocs_summary,
ocs_version, ocs_tags, ocs_changed, ocs_preview_url,
ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments";
ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments,
release_history";
/// SQL filter that deduplicates catalog apps by lowercase name.
/// Keeps the OCS entry when both OCS and secondary source entries exist for the same name.
@@ -1277,6 +1295,7 @@ impl Database {
ocs_arch: row.get(35).unwrap_or(None),
ocs_md5sum: row.get(36).unwrap_or(None),
ocs_comments: row.get(37).unwrap_or(None),
release_history: row.get(38).unwrap_or(None),
})
}
@@ -2066,6 +2085,27 @@ impl Database {
Ok(())
}
/// Get all distinct tags used across all installed apps.
/// Returns a sorted, deduplicated list of tag strings.
pub fn get_all_tags(&self) -> SqlResult<Vec<String>> {
let mut stmt = self.conn.prepare(
"SELECT tags FROM appimages WHERE tags IS NOT NULL AND tags != ''"
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut tag_set = std::collections::BTreeSet::new();
for row in rows {
if let Ok(tags_str) = row {
for tag in tags_str.split(',') {
let trimmed = tag.trim();
if !trimmed.is_empty() {
tag_set.insert(trimmed.to_string());
}
}
}
}
Ok(tag_set.into_iter().collect())
}
pub fn set_pinned(&self, id: i64, pinned: bool) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET pinned = ?2 WHERE id = ?1",
@@ -2479,6 +2519,23 @@ impl Database {
}
}
/// Look up release history from catalog for an installed app (by name).
/// Returns the release_history JSON string if a matching catalog app has one.
pub fn get_catalog_release_history_by_name(&self, app_name: &str) -> SqlResult<Option<String>> {
let result = self.conn.query_row(
"SELECT release_history FROM catalog_apps
WHERE LOWER(name) = LOWER(?1) AND release_history IS NOT NULL
LIMIT 1",
params![app_name],
|row| row.get::<_, Option<String>>(0),
);
match result {
Ok(history) => Ok(history),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
/// Get featured catalog apps. Apps with enrichment data (stars or OCS downloads)
/// sort first by combined popularity, then unenriched apps get a deterministic
/// shuffle that rotates every 15 minutes.
@@ -2860,6 +2917,18 @@ impl Database {
)?;
Ok(())
}
pub fn update_catalog_app_release_history(
&self,
app_id: i64,
history_json: &str,
) -> SqlResult<()> {
self.conn.execute(
"UPDATE catalog_apps SET release_history = ?2 WHERE id = ?1",
params![app_id, history_json],
)?;
Ok(())
}
}
#[cfg(test)]

View File

@@ -265,6 +265,72 @@ pub fn enrich_app_release_info(
Ok(remaining)
}
/// A GitHub release with body text for changelog display.
#[derive(Debug, serde::Deserialize)]
struct GitHubRelease {
tag_name: String,
published_at: Option<String>,
body: Option<String>,
}
/// Fetch up to 10 recent releases for a repo.
fn fetch_recent_releases(owner: &str, repo: &str, token: &str) -> Result<(Vec<GitHubRelease>, u32), String> {
let url = format!("https://api.github.com/repos/{}/{}/releases?per_page=10", owner, repo);
let (body, remaining) = github_get(&url, token)?;
let releases: Vec<GitHubRelease> = serde_json::from_str(&body)
.map_err(|e| format!("Parse error: {}", e))?;
Ok((releases, remaining))
}
/// Enrich a catalog app with release history (version, date, description for last 10 releases).
/// Only populates if the existing release_history is empty.
pub fn enrich_app_release_history(
db: &Database,
app_id: i64,
owner: &str,
repo: &str,
token: &str,
) -> Result<u32, String> {
// Check if release_history is already populated (from AppStream or prior enrichment)
if let Ok(Some(app)) = db.get_catalog_app(app_id) {
if app.release_history.as_ref().is_some_and(|h| !h.is_empty()) {
return Ok(u32::MAX); // already has data, skip
}
}
let (releases, remaining) = fetch_recent_releases(owner, repo, token)?;
if releases.is_empty() {
return Ok(remaining);
}
// Convert to the same JSON format used by AppStream: [{version, date, description}]
let history: Vec<serde_json::Value> = releases.iter().map(|r| {
let version = r.tag_name.strip_prefix('v')
.unwrap_or(&r.tag_name)
.to_string();
let date = r.published_at.as_deref()
.and_then(|d| d.split('T').next())
.unwrap_or("");
let mut obj = serde_json::json!({
"version": version,
"date": date,
});
if let Some(ref body) = r.body {
if !body.is_empty() {
obj["description"] = serde_json::Value::String(body.clone());
}
}
obj
}).collect();
let json = serde_json::to_string(&history)
.map_err(|e| format!("JSON error: {}", e))?;
db.update_catalog_app_release_history(app_id, &json)
.map_err(|e| format!("DB error: {}", e))?;
Ok(remaining)
}
/// Fetch and store the README for a catalog app.
pub fn enrich_app_readme(
db: &Database,
@@ -310,6 +376,9 @@ pub fn background_enrich_batch(
Ok(remaining) => {
enriched += 1;
// Also fetch release history (changelog data)
let _ = enrich_app_release_history(db, app.id, owner, repo, token);
// Report progress
if let Ok((done, total)) = db.catalog_enrichment_progress() {
on_progress(done, total);

View File

@@ -402,6 +402,9 @@ pub fn build_catalog_detail_page(
let _ = github_enrichment::enrich_app_readme(
db, app_id, &owner_c, &repo_c, &token_c,
);
let _ = github_enrichment::enrich_app_release_history(
db, app_id, &owner_c, &repo_c, &token_c,
);
}
}).await;

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

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);
}
}
}

View File

@@ -262,11 +262,6 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
for record in &updatable {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
let row = adw::ActionRow::builder()
.title(name)
.activatable(false)
.build();
// Show version info: current -> latest (with size if available)
let current = record.app_version.as_deref().unwrap_or("unknown");
let latest = record.latest_version.as_deref().unwrap_or("unknown");
@@ -275,12 +270,46 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
} else {
format!("{} -> {}", current, latest)
};
row.set_subtitle(&subtitle);
// Try to find changelog from the app's own release_history or from catalog
let changelog = find_changelog_for_version(
&state.db, record, latest,
);
// Use ExpanderRow if we have changelog, otherwise plain ActionRow
let row: adw::ExpanderRow = adw::ExpanderRow::builder()
.title(name)
.subtitle(&subtitle)
.show_enable_switch(false)
.expanded(false)
.build();
// App icon
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32);
row.add_prefix(&icon);
// "What's new" content inside the expander
let changelog_text = match &changelog {
Some(text) => text.clone(),
None => i18n("Release notes not available"),
};
let changelog_label = gtk::Label::builder()
.label(&changelog_text)
.wrap(true)
.xalign(0.0)
.css_classes(if changelog.is_some() { vec!["body"] } else { vec!["dim-label", "caption"] })
.margin_start(12)
.margin_end(12)
.margin_top(8)
.margin_bottom(8)
.selectable(true)
.build();
let changelog_row = adw::ActionRow::builder()
.activatable(false)
.child(&changelog_label)
.build();
row.add_row(&changelog_row);
// Individual update button
let update_btn = gtk::Button::builder()
.icon_name("software-update-available-symbolic")
@@ -341,3 +370,81 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
state.list_box.append(&row);
}
}
/// Try to find changelog/release notes for a specific version.
/// Checks the installed app's release_history first, then falls back
/// to the catalog app's release_history (populated by GitHub enrichment).
fn find_changelog_for_version(
db: &Database,
record: &crate::core::database::AppImageRecord,
target_version: &str,
) -> Option<String> {
// First check the installed app's own release_history
if let Some(ref history_json) = record.release_history {
if let Some(text) = extract_version_notes(history_json, target_version) {
return Some(text);
}
}
// Fall back to catalog app's release_history
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
if let Ok(Some(ref history_json)) = db.get_catalog_release_history_by_name(app_name) {
if let Some(text) = extract_version_notes(history_json, target_version) {
return Some(text);
}
}
None
}
/// Parse release_history JSON and extract the description for a given version.
/// Format: [{"version": "1.0.0", "date": "2026-01-01", "description": "..."}]
fn extract_version_notes(history_json: &str, target_version: &str) -> Option<String> {
let releases: Vec<serde_json::Value> = serde_json::from_str(history_json).ok()?;
// Try exact match first
for release in &releases {
if let Some(ver) = release.get("version").and_then(|v| v.as_str()) {
if ver == target_version {
return release.get("description")
.and_then(|d| d.as_str())
.map(|s| truncate_changelog(s));
}
}
}
// If no exact match, try matching without "v" prefix on both sides
let clean_target = target_version.strip_prefix('v').unwrap_or(target_version);
for release in &releases {
if let Some(ver) = release.get("version").and_then(|v| v.as_str()) {
let clean_ver = ver.strip_prefix('v').unwrap_or(ver);
if clean_ver == clean_target {
return release.get("description")
.and_then(|d| d.as_str())
.map(|s| truncate_changelog(s));
}
}
}
// No matching version found - show the latest entry's notes as a fallback
// (the first entry is typically the newest release)
if let Some(first) = releases.first() {
if let Some(desc) = first.get("description").and_then(|d| d.as_str()) {
let ver = first.get("version").and_then(|v| v.as_str()).unwrap_or("?");
return Some(format!("(v{}) {}", ver, truncate_changelog(desc)));
}
}
None
}
/// Truncate long changelog text to keep the UI compact.
fn truncate_changelog(text: &str) -> String {
let max_len = 500;
let trimmed = text.trim();
if trimmed.len() <= max_len {
trimmed.to_string()
} else {
format!("{}...", &trimmed[..max_len])
}
}

View File

@@ -7,6 +7,7 @@ use std::time::Instant;
use crate::config::APP_ID;
use crate::core::analysis;
use crate::core::backup;
use crate::core::database::Database;
use crate::core::discovery;
use crate::core::footprint;
@@ -170,6 +171,8 @@ impl DriftwoodWindow {
section2.append(Some(&i18n("Find Duplicates")), Some("win.find-duplicates"));
section2.append(Some(&i18n("Security Report")), Some("win.security-report"));
section2.append(Some(&i18n("Disk Cleanup")), Some("win.cleanup"));
section2.append(Some(&i18n("Export App List")), Some("win.export-app-list"));
section2.append(Some(&i18n("Import App List")), Some("win.import-app-list"));
menu.append_section(None, &section2);
let section3 = gio::Menu::new();
@@ -655,6 +658,20 @@ impl DriftwoodWindow {
})
.build();
// Export app list action
let export_action = gio::ActionEntry::builder("export-app-list")
.activate(|window: &Self, _, _| {
window.show_export_dialog();
})
.build();
// Import app list action
let import_action = gio::ActionEntry::builder("import-app-list")
.activate(|window: &Self, _, _| {
window.show_import_dialog();
})
.build();
self.add_action_entries([
dashboard_action,
preferences_action,
@@ -668,6 +685,8 @@ impl DriftwoodWindow {
catalog_action,
shortcuts_action,
show_drop_hint_action,
export_action,
import_action,
]);
// Sort library action (parameterized with sort mode string)
@@ -2003,4 +2022,90 @@ impl DriftwoodWindow {
self.maximize();
}
}
fn show_export_dialog(&self) {
let db = self.database().clone();
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
let dialog = gtk::FileDialog::builder()
.title(i18n("Export App List"))
.initial_name("driftwood-apps.json")
.build();
let json_filter = gtk::FileFilter::new();
json_filter.set_name(Some("JSON files"));
json_filter.add_pattern("*.json");
let filters = gio::ListStore::new::<gtk::FileFilter>();
filters.append(&json_filter);
dialog.set_filters(Some(&filters));
let window = self.clone();
dialog.save(Some(&window), gio::Cancellable::NONE, move |result| {
if let Ok(file) = result {
if let Some(path) = file.path() {
match backup::export_app_list(&db, &path) {
Ok(count) => {
toast_overlay.add_toast(
adw::Toast::new(&format!("Exported {} apps", count)),
);
}
Err(e) => {
toast_overlay.add_toast(
adw::Toast::new(&format!("Export failed: {}", e)),
);
}
}
}
}
});
}
fn show_import_dialog(&self) {
let db = self.database().clone();
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
let dialog = gtk::FileDialog::builder()
.title(i18n("Import App List"))
.build();
let json_filter = gtk::FileFilter::new();
json_filter.set_name(Some("JSON files"));
json_filter.add_pattern("*.json");
let filters = gio::ListStore::new::<gtk::FileFilter>();
filters.append(&json_filter);
dialog.set_filters(Some(&filters));
let window = self.clone();
dialog.open(Some(&window), gio::Cancellable::NONE, move |result| {
if let Ok(file) = result {
if let Some(path) = file.path() {
match backup::import_app_list(&db, &path) {
Ok(result) => {
let msg = if result.missing.is_empty() {
format!("Imported metadata for {} apps", result.matched)
} else {
format!(
"Imported {} apps, {} not found",
result.matched,
result.missing.len()
)
};
toast_overlay.add_toast(adw::Toast::new(&msg));
// Show missing apps dialog if any
if !result.missing.is_empty() {
let missing_text = result.missing.join("\n");
log::info!("Import - missing apps:\n{}", missing_text);
}
}
Err(e) => {
toast_overlay.add_toast(
adw::Toast::new(&format!("Import failed: {}", e)),
);
}
}
}
}
});
}
}