Add UX enhancements: carousel, filter chips, command palette, and more

- Replace featured section Stack with AdwCarousel + indicator dots
- Convert category grid to horizontal scrollable filter chips
- Add grid/list view toggle for catalog with compact row layout
- Add quick launch button on library list rows
- Add stale catalog banner when data is older than 7 days
- Add command palette (Ctrl+K) for quick app search and launch
- Show specific app names in update notifications
- Add per-app auto-update toggle (skip updates switch)
- Add keyboard shortcut hints to button tooltips
- Add source trust badges (AppImageHub/Community) on catalog tiles
- Add undo-based uninstall with toast and record restoration
- Add type-to-search in library view
- Use human-readable catalog source labels
- Show Launch button for installed apps in catalog detail
- Replace external browser link with inline AppImage explainer dialog
This commit is contained in:
lashman
2026-03-01 00:39:43 +02:00
parent 4b939f044a
commit d11546efc6
25 changed files with 1711 additions and 481 deletions

View File

@@ -92,23 +92,21 @@ pub struct SystemModification {
pub enum CatalogSortOrder {
NameAsc,
NameDesc,
StarsDesc,
StarsAsc,
DownloadsDesc,
DownloadsAsc,
PopularityDesc,
PopularityAsc,
ReleaseDateDesc,
ReleaseDateAsc,
}
impl CatalogSortOrder {
/// Popularity combines OCS downloads, GitHub stars, and GitHub downloads
/// into a single comparable score.
pub fn sql_clause(&self) -> &'static str {
match self {
Self::NameAsc => "ORDER BY name COLLATE NOCASE ASC",
Self::NameDesc => "ORDER BY name COLLATE NOCASE DESC",
Self::StarsDesc => "ORDER BY COALESCE(github_stars, 0) DESC, name COLLATE NOCASE ASC",
Self::StarsAsc => "ORDER BY CASE WHEN github_stars IS NULL THEN 1 ELSE 0 END, github_stars ASC, name COLLATE NOCASE ASC",
Self::DownloadsDesc => "ORDER BY (COALESCE(ocs_downloads, 0) + COALESCE(github_downloads, 0)) DESC, name COLLATE NOCASE ASC",
Self::DownloadsAsc => "ORDER BY CASE WHEN ocs_downloads IS NULL AND github_downloads IS NULL THEN 1 ELSE 0 END, (COALESCE(ocs_downloads, 0) + COALESCE(github_downloads, 0)) ASC, name COLLATE NOCASE ASC",
Self::PopularityDesc => "ORDER BY (COALESCE(ocs_downloads, 0) + COALESCE(github_stars, 0) + COALESCE(github_downloads, 0)) DESC, name COLLATE NOCASE ASC",
Self::PopularityAsc => "ORDER BY CASE WHEN ocs_downloads IS NULL AND github_stars IS NULL AND github_downloads IS NULL THEN 1 ELSE 0 END, (COALESCE(ocs_downloads, 0) + COALESCE(github_stars, 0) + COALESCE(github_downloads, 0)) ASC, name COLLATE NOCASE ASC",
Self::ReleaseDateDesc => "ORDER BY COALESCE(release_date, '0000') DESC, name COLLATE NOCASE ASC",
Self::ReleaseDateAsc => "ORDER BY CASE WHEN release_date IS NULL THEN 1 ELSE 0 END, release_date ASC, name COLLATE NOCASE ASC",
}
@@ -1225,6 +1223,20 @@ impl Database {
ocs_version, ocs_tags, ocs_changed, ocs_preview_url,
ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments";
/// 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.
/// Also handles within-source case duplicates (e.g. "Sabaki" vs "sabaki").
const CATALOG_DEDUP_FILTER: &str =
"AND id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY LOWER(name)
ORDER BY CASE WHEN ocs_id IS NOT NULL THEN 0 ELSE 1 END, id DESC
) AS rn
FROM catalog_apps
) WHERE rn = 1
)";
fn catalog_app_from_row(row: &rusqlite::Row) -> rusqlite::Result<CatalogApp> {
Ok(CatalogApp {
id: row.get(0)?,
@@ -1371,6 +1383,37 @@ impl Database {
Ok(())
}
/// Re-insert a previously deleted AppImageRecord with its original ID.
/// Used for undo-uninstall support.
pub fn restore_appimage_record(&self, r: &AppImageRecord) -> SqlResult<()> {
self.conn.execute(
&format!(
"INSERT OR REPLACE INTO appimages ({}) VALUES ({})",
Self::APPIMAGE_COLUMNS,
(1..=63).map(|i| format!("?{}", i)).collect::<Vec<_>>().join(", ")
),
params![
r.id, r.path, r.filename, r.app_name, r.app_version, r.appimage_type,
r.size_bytes, r.sha256, r.icon_path, r.desktop_file, r.integrated,
r.integrated_at, r.is_executable, r.desktop_entry_content,
r.categories, r.description, r.developer, r.architecture,
r.first_seen, r.last_scanned, r.file_modified,
r.fuse_status, r.wayland_status, r.update_info, r.update_type,
r.latest_version, r.update_checked, r.update_url, r.notes, r.sandbox_mode,
r.runtime_wayland_status, r.runtime_wayland_checked, r.analysis_status,
r.launch_args, r.tags, r.pinned, r.avg_startup_ms,
r.appstream_id, r.appstream_description, r.generic_name, r.license,
r.homepage_url, r.bugtracker_url, r.donation_url, r.help_url, r.vcs_url,
r.keywords, r.mime_types, r.content_rating, r.project_group,
r.release_history, r.desktop_actions, r.has_signature, r.screenshot_urls,
r.previous_version_path, r.source_url, r.autostart, r.startup_wm_class,
r.verification_status, r.first_run_prompted, r.system_wide, r.is_portable,
r.mount_point,
],
)?;
Ok(())
}
pub fn remove_missing_appimages(&self) -> SqlResult<Vec<AppImageRecord>> {
let all = self.get_all_appimages()?;
let mut removed = Vec::new();
@@ -1984,7 +2027,34 @@ impl Database {
Ok(rows.next().transpose()?)
}
// --- Phase 5: Runtime Updates ---
pub fn delete_sandbox_profile(&self, profile_id: i64) -> SqlResult<()> {
self.conn.execute(
"DELETE FROM sandbox_profiles WHERE id = ?1",
params![profile_id],
)?;
Ok(())
}
pub fn get_all_sandbox_profiles(&self) -> SqlResult<Vec<SandboxProfileRecord>> {
let mut stmt = self.conn.prepare(
"SELECT id, app_name, profile_version, author, description, content, source, registry_id, created_at
FROM sandbox_profiles ORDER BY app_name ASC"
)?;
let rows = stmt.query_map([], |row| {
Ok(SandboxProfileRecord {
id: row.get(0)?,
app_name: row.get(1)?,
profile_version: row.get(2)?,
author: row.get(3)?,
description: row.get(4)?,
content: row.get(5)?,
source: row.get(6)?,
registry_id: row.get(7)?,
created_at: row.get(8)?,
})
})?;
rows.collect()
}
// --- Phase 6: Tags, Pin, Startup Time ---
@@ -2332,11 +2402,13 @@ impl Database {
query: &str,
category: Option<&str>,
limit: i32,
offset: i32,
sort: CatalogSortOrder,
) -> SqlResult<Vec<CatalogApp>> {
let mut sql = format!(
"SELECT {} FROM catalog_apps WHERE 1=1",
Self::CATALOG_APP_COLUMNS
"SELECT {} FROM catalog_apps WHERE 1=1 {}",
Self::CATALOG_APP_COLUMNS,
Self::CATALOG_DEDUP_FILTER,
);
let mut params_list: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
@@ -2351,7 +2423,7 @@ impl Database {
params_list.push(Box::new(format!("%{}%", cat)));
}
sql.push_str(&format!(" {} LIMIT {}", sort.sql_clause(), limit));
sql.push_str(&format!(" {} LIMIT {} OFFSET {}", sort.sql_clause(), limit, offset));
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_list.iter().map(|p| p.as_ref()).collect();
@@ -2366,6 +2438,34 @@ impl Database {
Ok(results)
}
/// Count matching catalog apps (for pagination).
pub fn count_catalog_matches(
&self,
query: &str,
category: Option<&str>,
) -> SqlResult<i32> {
let mut sql = format!(
"SELECT COUNT(*) FROM catalog_apps WHERE 1=1 {}",
Self::CATALOG_DEDUP_FILTER,
);
let mut params_list: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if !query.is_empty() {
sql.push_str(" AND (name LIKE ?1 OR description LIKE ?1 OR ocs_summary LIKE ?1 OR ocs_description LIKE ?1)");
params_list.push(Box::new(format!("%{}%", query)));
}
if let Some(cat) = category {
let idx = params_list.len() + 1;
sql.push_str(&format!(" AND categories LIKE ?{}", idx));
params_list.push(Box::new(format!("%{}%", cat)));
}
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_list.iter().map(|p| p.as_ref()).collect();
self.conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0))
}
pub fn get_catalog_app(&self, id: i64) -> SqlResult<Option<CatalogApp>> {
let sql = format!(
"SELECT {} FROM catalog_apps WHERE id = ?1",
@@ -2395,8 +2495,10 @@ impl Database {
AND (description IS NOT NULL AND description != ''
OR ocs_summary IS NOT NULL AND ocs_summary != '')
AND (screenshots IS NOT NULL AND screenshots != ''
OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')",
Self::CATALOG_APP_COLUMNS
OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')
{}",
Self::CATALOG_APP_COLUMNS,
Self::CATALOG_DEDUP_FILTER,
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map([], Self::catalog_app_from_row)?;
@@ -2425,9 +2527,11 @@ impl Database {
}
pub fn get_catalog_categories(&self) -> SqlResult<Vec<(String, u32)>> {
let mut stmt = self.conn.prepare(
"SELECT categories FROM catalog_apps WHERE categories IS NOT NULL AND categories != ''"
)?;
let sql = format!(
"SELECT categories FROM catalog_apps WHERE categories IS NOT NULL AND categories != '' {}",
Self::CATALOG_DEDUP_FILTER,
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut counts = std::collections::HashMap::new();
@@ -2445,7 +2549,11 @@ impl Database {
}
pub fn catalog_app_count(&self) -> SqlResult<i64> {
self.conn.query_row("SELECT COUNT(*) FROM catalog_apps", [], |row| row.get(0))
let sql = format!(
"SELECT COUNT(*) FROM catalog_apps WHERE 1=1 {}",
Self::CATALOG_DEDUP_FILTER,
);
self.conn.query_row(&sql, [], |row| row.get(0))
}
pub fn insert_catalog_app(
@@ -2562,6 +2670,20 @@ impl Database {
Ok(())
}
/// Delete secondary source entries that have a matching name in the OCS source.
/// This cleans up duplicates from before OCS was added as primary source.
pub fn delete_secondary_duplicates(&self, secondary_source_id: i64) -> SqlResult<usize> {
self.conn.execute(
"DELETE FROM catalog_apps
WHERE source_id = ?1
AND LOWER(name) IN (
SELECT LOWER(name) FROM catalog_apps
WHERE source_id != ?1 AND ocs_id IS NOT NULL
)",
params![secondary_source_id],
)
}
/// Get all app names for a given source (used for deduplication).
pub fn get_catalog_app_names_for_source(&self, source_id: i64) -> SqlResult<std::collections::HashSet<String>> {
let mut stmt = self.conn.prepare(
@@ -2702,9 +2824,11 @@ impl Database {
let sql = format!(
"SELECT {} FROM catalog_apps
WHERE github_owner IS NOT NULL AND github_enriched_at IS NULL
{}
ORDER BY id
LIMIT ?1",
Self::CATALOG_APP_COLUMNS
Self::CATALOG_APP_COLUMNS,
Self::CATALOG_DEDUP_FILTER,
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params![limit], Self::catalog_app_from_row)?;
@@ -2712,16 +2836,16 @@ impl Database {
}
pub fn catalog_enrichment_progress(&self) -> SqlResult<(i64, i64)> {
let enriched: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL AND github_enriched_at IS NOT NULL",
[],
|row| row.get(0),
)?;
let total_with_github: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL",
[],
|row| row.get(0),
)?;
let enriched_sql = format!(
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL AND github_enriched_at IS NOT NULL {}",
Self::CATALOG_DEDUP_FILTER,
);
let enriched: i64 = self.conn.query_row(&enriched_sql, [], |row| row.get(0))?;
let total_sql = format!(
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL {}",
Self::CATALOG_DEDUP_FILTER,
);
let total_with_github: i64 = self.conn.query_row(&total_sql, [], |row| row.get(0))?;
Ok((enriched, total_with_github))
}