diff --git a/data/app.driftwood.Driftwood.gschema.xml b/data/app.driftwood.Driftwood.gschema.xml
index 805cf82..08efe9a 100644
--- a/data/app.driftwood.Driftwood.gschema.xml
+++ b/data/app.driftwood.Driftwood.gschema.xml
@@ -36,7 +36,7 @@
- 'name'
+ 'recently-added'
Library sort mode
How to sort the library: name, recently-added, or size.
diff --git a/data/resources/style.css b/data/resources/style.css
index bace028..e8ac193 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -364,3 +364,28 @@ window.lightbox .lightbox-nav {
.stat-card image {
opacity: 0.55;
}
+
+/* ===== Catalog Row (compact list view) ===== */
+.catalog-row {
+ border: 1px solid alpha(@window_fg_color, 0.08);
+ border-radius: 8px;
+ padding: 0;
+}
+
+.catalog-row:hover {
+ border-color: alpha(@accent_bg_color, 0.4);
+}
+
+/* ===== Skeleton Loading Placeholder ===== */
+.skeleton-card {
+ background: alpha(@card_bg_color, 0.5);
+ border-radius: 12px;
+ min-height: 180px;
+ min-width: 140px;
+ animation: skeleton-pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes skeleton-pulse {
+ 0%, 100% { opacity: 0.4; }
+ 50% { opacity: 0.7; }
+}
diff --git a/src/config.rs b/src/config.rs
index 233caf2..d439726 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -13,7 +13,6 @@ pub fn data_dir_fallback() -> PathBuf {
}
/// Return the XDG config directory with a proper $HOME-based fallback.
-#[allow(dead_code)]
pub fn config_dir_fallback() -> PathBuf {
dirs::config_dir().unwrap_or_else(|| home_dir().join(".config"))
}
diff --git a/src/core/catalog.rs b/src/core/catalog.rs
index 588ec0b..e0fc49b 100644
--- a/src/core/catalog.rs
+++ b/src/core/catalog.rs
@@ -35,6 +35,16 @@ impl CatalogType {
}
}
+ /// Human-readable label for display in the UI.
+ pub fn label(&self) -> &str {
+ match self {
+ Self::AppImageHub => "Community Feed",
+ Self::OcsAppImageHub => "AppImageHub Catalog",
+ Self::GitHubSearch => "GitHub Search",
+ Self::Custom => "Custom Source",
+ }
+ }
+
pub fn from_str(s: &str) -> Self {
match s {
"appimage-hub" => Self::AppImageHub,
@@ -45,6 +55,12 @@ impl CatalogType {
}
}
+impl std::fmt::Display for CatalogType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
/// An app entry from a catalog source.
#[derive(Debug, Clone)]
pub struct CatalogApp {
@@ -212,6 +228,7 @@ pub enum SyncProgress {
AllDone,
}
+#[allow(dead_code)]
pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result {
sync_catalog_with_progress(db, source, &|_| {})
}
@@ -249,6 +266,12 @@ pub fn sync_catalog_with_progress(
let source_id = source.id.ok_or(CatalogError::NoSourceId)?;
+ // Clean up old duplicates: delete secondary source entries that now have OCS counterparts
+ match db.delete_secondary_duplicates(source_id) {
+ Ok(n) if n > 0 => log::info!("Removed {} secondary duplicates with OCS counterparts", n),
+ _ => {}
+ }
+
// Build set of OCS app names for dedup (skip apps already in OCS source)
let ocs_names = get_ocs_source_names(db);
@@ -305,6 +328,7 @@ pub fn sync_catalog_with_progress(
/// Download an AppImage from the catalog to a local directory.
/// If `ocs_id` is provided, resolves a fresh download URL from the OCS API
/// (since OCS download links use JWT tokens that expire).
+#[allow(dead_code)]
pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result {
install_from_catalog_with_ocs(app, install_dir, None, 1)
}
@@ -876,14 +900,14 @@ fn fetch_custom_catalog(url: &str) -> Result, CatalogError> {
pub fn ensure_default_sources(db: &Database) {
// Primary: OCS AppImageHub.com (insert first so it syncs first)
db.upsert_catalog_source(
- "AppImageHub.com",
+ "AppImageHub Catalog",
OCS_API_URL,
"ocs-appimagehub",
).ok();
// Secondary: appimage.github.io feed
db.upsert_catalog_source(
- "AppImageHub",
+ "Community Feed",
APPIMAGEHUB_API_URL,
"appimage-hub",
).ok();
@@ -1005,6 +1029,7 @@ pub fn sanitize_filename(name: &str) -> String {
/// Download icons for all catalog apps that have icon_url set.
/// Saves to ~/.cache/driftwood/icons/{sanitized_name}.png
+#[allow(dead_code)]
fn cache_catalog_icons(apps: &[CatalogApp]) -> u32 {
cache_catalog_icons_with_progress(apps, &|_| {})
}
diff --git a/src/core/database.rs b/src/core/database.rs
index 459419a..aa3c11f 100644
--- a/src/core/database.rs
+++ b/src/core/database.rs
@@ -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 {
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::>().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> {
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> {
+ 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> {
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> = 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 {
+ let mut sql = format!(
+ "SELECT COUNT(*) FROM catalog_apps WHERE 1=1 {}",
+ Self::CATALOG_DEDUP_FILTER,
+ );
+ let mut params_list: Vec> = 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