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:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user