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

@@ -36,7 +36,7 @@
<choice value='recently-added'/>
<choice value='size'/>
</choices>
<default>'name'</default>
<default>'recently-added'</default>
<summary>Library sort mode</summary>
<description>How to sort the library: name, recently-added, or size.</description>
</key>

View File

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

View File

@@ -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"))
}

View File

@@ -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<u32, CatalogError> {
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<PathBuf, CatalogError> {
install_from_catalog_with_ocs(app, install_dir, None, 1)
}
@@ -876,14 +900,14 @@ fn fetch_custom_catalog(url: &str) -> Result<Vec<CatalogApp>, 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, &|_| {})
}

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

View File

@@ -19,7 +19,7 @@ impl std::fmt::Display for InspectorError {
match self {
Self::IoError(e) => write!(f, "I/O error: {}", e),
Self::NoOffset => write!(f, "Could not determine squashfs offset"),
Self::UnsquashfsNotFound => write!(f, "unsquashfs not found - install squashfs-tools"),
Self::UnsquashfsNotFound => write!(f, "A system tool needed to read app contents is missing. Install it by running: sudo apt install squashfs-tools"),
Self::UnsquashfsFailed(msg) => write!(f, "unsquashfs failed: {}", msg),
Self::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"),
}

View File

@@ -113,7 +113,26 @@ pub fn launch_appimage(
method
};
let result = execute_appimage(appimage_path, &method, extra_args, extra_env);
// When sandboxed, ensure a profile exists and resolve its path
let profile_path = if method == LaunchMethod::Sandboxed {
let app_name = appimage_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
// Auto-generate a default profile if none exists yet
if super::sandbox::profile_path_for_app(&app_name).is_none() {
let profile = super::sandbox::generate_default_profile(&app_name);
if let Err(e) = super::sandbox::save_profile(db, &profile) {
log::warn!("Failed to create default sandbox profile: {}", e);
}
}
super::sandbox::profile_path_for_app(&app_name)
} else {
None
};
let result = execute_appimage(appimage_path, &method, extra_args, extra_env, profile_path.as_deref());
// Record the launch event regardless of success
if let Err(e) = db.record_launch(record_id, source) {
@@ -141,7 +160,7 @@ pub fn launch_appimage_simple(
}
};
execute_appimage(appimage_path, &method, extra_args, &[])
execute_appimage(appimage_path, &method, extra_args, &[], None)
}
/// Execute the AppImage process with the given method.
@@ -150,6 +169,7 @@ fn execute_appimage(
method: &LaunchMethod,
args: &[String],
extra_env: &[(&str, &str)],
sandbox_profile: Option<&Path>,
) -> LaunchResult {
let mut cmd = match method {
LaunchMethod::Direct => {
@@ -165,6 +185,9 @@ fn execute_appimage(
}
LaunchMethod::Sandboxed => {
let mut c = Command::new("firejail");
if let Some(profile) = sandbox_profile {
c.arg(format!("--profile={}", profile.display()));
}
c.arg("--appimage");
c.arg(appimage_path);
c.args(args);

View File

@@ -15,6 +15,7 @@ pub mod notification;
pub mod orphan;
pub mod portable;
pub mod report;
pub mod sandbox;
pub mod security;
pub mod updater;
pub mod verification;

View File

@@ -116,18 +116,19 @@ pub fn list_profiles(db: &Database) -> Vec<SandboxProfile> {
/// Search the community registry for sandbox profiles matching an app name.
/// Uses the GitHub-based registry approach (fetches a JSON index).
pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<Vec<CommunityProfileEntry>, SanboxError> {
#[allow(dead_code)] // Community registry UI not yet wired
pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<Vec<CommunityProfileEntry>, SandboxError> {
let index_url = format!("{}/index.json", registry_url.trim_end_matches('/'));
let response = ureq::get(&index_url)
.call()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let body = response.into_body().read_to_string()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let index: CommunityIndex = serde_json::from_str(&body)
.map_err(|e| SanboxError::Parse(e.to_string()))?;
.map_err(|e| SandboxError::Parse(e.to_string()))?;
let query = app_name.to_lowercase();
let matches: Vec<CommunityProfileEntry> = index.profiles
@@ -139,16 +140,17 @@ pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<V
}
/// Download a community profile by its URL and save it locally.
#[allow(dead_code)] // Community registry UI not yet wired
pub fn download_community_profile(
db: &Database,
entry: &CommunityProfileEntry,
) -> Result<SandboxProfile, SanboxError> {
) -> Result<SandboxProfile, SandboxError> {
let response = ureq::get(&entry.url)
.call()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let content = response.into_body().read_to_string()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let profile = SandboxProfile {
id: None,
@@ -163,7 +165,7 @@ pub fn download_community_profile(
};
save_profile(db, &profile)
.map_err(|e| SanboxError::Io(e.to_string()))?;
.map_err(|e| SandboxError::Io(e.to_string()))?;
Ok(profile)
}
@@ -221,11 +223,13 @@ pub fn profile_path_for_app(app_name: &str) -> Option<PathBuf> {
// --- Community registry types ---
#[allow(dead_code)] // Used by community registry search
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CommunityIndex {
pub profiles: Vec<CommunityProfileEntry>,
}
#[allow(dead_code)] // Used by community registry search/download
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CommunityProfileEntry {
pub id: String,
@@ -240,16 +244,12 @@ pub struct CommunityProfileEntry {
// --- Error types ---
#[derive(Debug)]
#[allow(dead_code)] // Network + Parse variants used by community registry functions
pub enum SandboxError {
Io(String),
Database(String),
}
#[derive(Debug)]
pub enum SanboxError {
Network(String),
Parse(String),
Io(String),
}
impl std::fmt::Display for SandboxError {
@@ -257,16 +257,8 @@ impl std::fmt::Display for SandboxError {
match self {
Self::Io(e) => write!(f, "I/O error: {}", e),
Self::Database(e) => write!(f, "Database error: {}", e),
}
}
}
impl std::fmt::Display for SanboxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Network(e) => write!(f, "Network error: {}", e),
Self::Parse(e) => write!(f, "Parse error: {}", e),
Self::Io(e) => write!(f, "I/O error: {}", e),
}
}
}
@@ -379,6 +371,10 @@ mod tests {
assert!(format!("{}", err).contains("permission denied"));
let err = SandboxError::Database("db locked".to_string());
assert!(format!("{}", err).contains("db locked"));
let err = SandboxError::Network("connection refused".to_string());
assert!(format!("{}", err).contains("connection refused"));
let err = SandboxError::Parse("invalid json".to_string());
assert!(format!("{}", err).contains("invalid json"));
}
#[test]

View File

@@ -24,23 +24,30 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
);
icon_widget.add_css_class("icon-dropshadow");
if record.integrated {
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&icon_widget));
let icon_overlay = gtk::Overlay::new();
icon_overlay.set_child(Some(&icon_widget));
if record.integrated {
let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic");
emblem.set_pixel_size(16);
emblem.add_css_class("integration-emblem");
emblem.set_halign(gtk::Align::End);
emblem.set_valign(gtk::Align::End);
emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]);
overlay.add_overlay(&emblem);
card.append(&overlay);
} else {
card.append(&icon_widget);
icon_overlay.add_overlay(&emblem);
}
if record.sandbox_mode.as_deref() == Some("firejail") {
let shield = gtk::Image::from_icon_name("security-high-symbolic");
shield.set_pixel_size(16);
shield.set_halign(gtk::Align::Start);
shield.set_valign(gtk::Align::End);
shield.set_tooltip_text(Some("This app runs with security restrictions"));
icon_overlay.add_overlay(&shield);
}
card.append(&icon_overlay);
// App name
let name_label = gtk::Label::builder()
.label(name)
@@ -142,7 +149,12 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
return Some(widgets::status_badge("Portable", "info"));
}
None
// 4. Fallback: integration status
if record.integrated {
return Some(widgets::status_badge("Ready", "success"));
}
Some(widgets::status_badge("Not in menu", "neutral"))
}
/// Build a descriptive accessible label for screen readers.

View File

@@ -6,6 +6,7 @@ use gtk::gio;
use crate::core::catalog;
use crate::core::database::{CatalogApp, Database};
use crate::core::fuse;
use crate::core::github_enrichment;
use crate::core::github_enrichment::AppImageAsset;
use crate::i18n::i18n;
@@ -100,14 +101,15 @@ pub fn build_catalog_detail_page(
&& (app.latest_version.is_none() || is_enrichment_stale(app.github_enriched_at.as_deref()));
let awaiting_github = needs_enrichment && app.github_download_url.is_none();
// Check if already installed
let installed_names: std::collections::HashSet<String> = db
// Check if already installed (map name -> record id for launching)
let installed_map: std::collections::HashMap<String, i64> = db
.get_all_appimages()
.unwrap_or_default()
.iter()
.filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase()))
.filter_map(|r| r.app_name.as_ref().map(|n| (n.to_lowercase(), r.id)))
.collect();
let is_installed = installed_names.contains(&app.name.to_lowercase());
let installed_record_id = installed_map.get(&app.name.to_lowercase()).copied();
let is_installed = installed_record_id.is_some();
let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0);
@@ -120,6 +122,17 @@ pub fn build_catalog_detail_page(
let awaiting_ocs = has_ocs && !is_installed;
if is_installed {
// Show Launch button for installed apps
if let Some(record_id) = installed_record_id {
let launch_btn = gtk::Button::builder()
.label(&i18n("Launch"))
.css_classes(["suggested-action", "pill"])
.build();
launch_btn.set_action_name(Some("win.launch-appimage"));
launch_btn.set_action_target_value(Some(&record_id.to_variant()));
widgets::set_pointer_cursor(&launch_btn);
button_box.append(&launch_btn);
}
let installed_badge = widgets::status_badge(&i18n("Installed"), "success");
installed_badge.set_valign(gtk::Align::Center);
button_box.append(&installed_badge);
@@ -166,6 +179,37 @@ pub fn build_catalog_detail_page(
}
info_box.append(&button_box);
// "What you'll get" info and compatibility check for install
if !is_installed {
let size_hint = app.ocs_downloadsize.filter(|&s| s > 0)
.map(|s| format!(" ({})", widgets::format_size(s)))
.unwrap_or_default();
let install_info = gtk::Label::builder()
.label(&format!(
"Downloads to ~/Applications and adds to your app launcher{}",
size_hint
))
.css_classes(["caption", "dim-label"])
.wrap(true)
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
info_box.append(&install_info);
// System compatibility check
let fuse_info = fuse::detect_system_fuse();
let (compat_text, compat_class) = if fuse_info.status.is_functional() {
("Works with your system", "success")
} else {
("May need additional setup to run", "warning")
};
let compat_badge = widgets::status_badge(compat_text, compat_class);
compat_badge.set_halign(gtk::Align::Start);
compat_badge.set_margin_top(2);
info_box.append(&compat_badge);
}
header_box.append(&info_box);
content.append(&header_box);
@@ -1243,9 +1287,17 @@ fn format_ocs_file_label(file: &catalog::OcsDownloadFile) -> String {
if !file.version.is_empty() {
parts.push(format!("v{}", file.version));
}
if let Some(ref arch) = file.arch {
parts.push(arch.clone());
}
if !file.filename.is_empty() {
parts.push(file.filename.clone());
}
if let Some(ref pkg_type) = file.pkg_type {
if pkg_type != "appimage" {
parts.push(format!("[{}]", pkg_type));
}
}
if let Some(size_kb) = file.size_kb {
if size_kb > 0 {
parts.push(format!("({})", widgets::format_size(size_kb * 1024)));

View File

@@ -6,7 +6,8 @@ use super::widgets;
/// Build a catalog tile for the browse grid.
/// Left-aligned layout: icon (48px) at top, name, description, category badge.
/// Card fills its entire FlowBoxChild cell.
pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
/// If `installed` is true, an "Installed" badge is shown on the card.
pub fn build_catalog_tile(app: &CatalogApp, installed: bool) -> gtk::FlowBoxChild {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
@@ -75,10 +76,11 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
desc_label.set_height_request(desc_label.preferred_size().1.height().max(36));
inner.append(&desc_label);
// Stats row (stars + version) - only if data exists
// Stats row (downloads + stars + version) - only if data exists
let has_downloads = app.ocs_downloads.is_some_and(|d| d > 0);
let has_stars = app.github_stars.is_some_and(|s| s > 0);
let has_version = app.latest_version.is_some();
if has_stars || has_version {
if has_downloads || has_stars || has_version {
let stats_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
@@ -86,6 +88,22 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
.build();
stats_row.add_css_class("catalog-stats-row");
if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) {
let dl_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let dl_icon = gtk::Image::from_icon_name("folder-download-symbolic");
dl_icon.set_pixel_size(12);
dl_box.append(&dl_icon);
let dl_label = gtk::Label::new(Some(&widgets::format_count(downloads)));
dl_label.add_css_class("caption");
dl_label.add_css_class("dim-label");
dl_box.append(&dl_label);
dl_box.set_tooltip_text(Some(&format!("{} downloads", downloads)));
stats_row.append(&dl_box);
}
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
@@ -135,6 +153,21 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
}
}
// Installed badge
if installed {
let installed_badge = widgets::status_badge("Installed", "success");
installed_badge.set_halign(gtk::Align::Start);
installed_badge.set_margin_top(4);
inner.append(&installed_badge);
}
// Source badge - show which source this app came from
let source_label = if app.ocs_id.is_some() { "AppImageHub" } else { "Community" };
let source_badge = widgets::status_badge(source_label, "neutral");
source_badge.set_halign(gtk::Align::Start);
source_badge.set_margin_top(2);
inner.append(&source_badge);
card.append(&inner);
let child = gtk::FlowBoxChild::builder()
@@ -146,6 +179,92 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
child
}
/// Build a compact list-row tile for the browse grid in list mode.
/// Horizontal layout: icon (32px) | name | description snippet | stats.
pub fn build_catalog_row(app: &CatalogApp, installed: bool) -> gtk::FlowBoxChild {
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Fill)
.hexpand(true)
.build();
row.add_css_class("card");
row.add_css_class("catalog-row");
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.margin_top(8)
.margin_bottom(8)
.margin_start(12)
.margin_end(12)
.hexpand(true)
.build();
// Icon (32px)
let icon = widgets::app_icon(None, &app.name, 32);
icon.set_valign(gtk::Align::Center);
inner.append(&icon);
// Name
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(18)
.xalign(0.0)
.width_chars(14)
.build();
inner.append(&name_label);
// Description (single line)
let plain = app.ocs_summary.as_deref()
.filter(|d| !d.is_empty())
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
.or(app.description.as_deref().filter(|d| !d.is_empty()))
.map(|d| strip_html(d))
.unwrap_or_default();
let desc_label = gtk::Label::builder()
.label(&plain)
.css_classes(["dim-label"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.hexpand(true)
.xalign(0.0)
.build();
inner.append(&desc_label);
// Stats (compact)
if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) {
let dl_label = gtk::Label::builder()
.label(&format!("{} dl", widgets::format_count(downloads)))
.css_classes(["caption", "dim-label"])
.build();
inner.append(&dl_label);
} else if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_label = gtk::Label::builder()
.label(&format!("{} stars", widgets::format_count(stars)))
.css_classes(["caption", "dim-label"])
.build();
inner.append(&star_label);
}
// Installed badge
if installed {
let badge = widgets::status_badge("Installed", "success");
badge.set_valign(gtk::Align::Center);
inner.append(&badge);
}
row.append(&inner);
let child = gtk::FlowBoxChild::builder()
.child(&row)
.build();
child.add_css_class("activatable");
widgets::set_pointer_cursor(&child);
child
}
/// Build a featured banner card for the carousel.
/// Layout: screenshot preview on top, then icon + name + description + badge below.
/// Width is set dynamically by the carousel layout.
@@ -241,7 +360,7 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
text_box.append(&desc_label);
}
// Badge row: category + stars
// Badge row: category + downloads/stars
let badge_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
@@ -262,7 +381,15 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
}
}
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) {
let dl_badge = widgets::status_badge_with_icon(
"folder-download-symbolic",
&widgets::format_count(downloads),
"neutral",
);
dl_badge.set_halign(gtk::Align::Start);
badge_row.append(&dl_badge);
} else if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_badge = widgets::status_badge_with_icon(
"starred-symbolic",
&widgets::format_count(stars),

View File

@@ -1,5 +1,6 @@
use adw::prelude::*;
use std::cell::RefCell;
use std::collections::HashSet;
use std::rc::Rc;
use gtk::gio;
@@ -43,7 +44,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
.search_mode_enabled(true)
.build();
// --- Featured section (paged carousel) ---
// --- Featured section (AdwCarousel with swipe support) ---
let featured_label = gtk::Label::builder()
.label(&i18n("Featured"))
.css_classes(["title-2"])
@@ -52,92 +53,18 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
.margin_start(18)
.build();
// Stack for crossfade page transitions
let featured_stack = gtk::Stack::builder()
.transition_type(gtk::StackTransitionType::Crossfade)
.transition_duration(250)
let featured_carousel = adw::Carousel::builder()
.hexpand(true)
.allow_scroll_wheel(true)
.allow_long_swipes(false)
.build();
// Page state: all featured apps and current page index
let carousel_dots = adw::CarouselIndicatorDots::builder()
.carousel(&featured_carousel)
.build();
// State: all featured apps (for later population)
let featured_apps: Rc<RefCell<Vec<CatalogApp>>> = Rc::new(RefCell::new(Vec::new()));
let featured_page: Rc<std::cell::Cell<usize>> = Rc::new(std::cell::Cell::new(0));
// Tracks which stack child name is active ("a" or "b") for crossfade toggling
let featured_flip: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
// Navigation arrows
let left_arrow = gtk::Button::builder()
.icon_name("go-previous-symbolic")
.css_classes(["circular", "osd"])
.valign(gtk::Align::Center)
.sensitive(false)
.build();
let right_arrow = gtk::Button::builder()
.icon_name("go-next-symbolic")
.css_classes(["circular", "osd"])
.valign(gtk::Align::Center)
.build();
// Carousel row: [<] [stack] [>]
let carousel_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_start(18)
.margin_end(18)
.build();
carousel_row.append(&left_arrow);
carousel_row.append(&featured_stack);
carousel_row.append(&right_arrow);
// Wire arrow navigation (page through featured apps with crossfade)
{
let apps_ref = featured_apps.clone();
let page_ref = featured_page.clone();
let flip_ref = featured_flip.clone();
let stack_ref = featured_stack.clone();
let left_ref = left_arrow.clone();
let right_ref = right_arrow.clone();
let db_ref = db.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
left_arrow.connect_clicked(move |_| {
let page = page_ref.get();
if page > 0 {
page_ref.set(page - 1);
show_featured_page(
&apps_ref, page - 1, &stack_ref, &flip_ref,
&left_ref, &right_ref,
&db_ref, &nav_ref, &toast_ref,
);
}
});
}
{
let apps_ref = featured_apps.clone();
let page_ref = featured_page.clone();
let flip_ref = featured_flip.clone();
let stack_ref = featured_stack.clone();
let left_ref = left_arrow.clone();
let right_ref = right_arrow.clone();
let db_ref = db.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
right_arrow.connect_clicked(move |_| {
let apps = apps_ref.borrow();
let page = page_ref.get();
let max_page = apps.len().saturating_sub(1) / CARDS_PER_PAGE;
if page < max_page {
drop(apps);
page_ref.set(page + 1);
show_featured_page(
&apps_ref, page + 1, &stack_ref, &flip_ref,
&left_ref, &right_ref,
&db_ref, &nav_ref, &toast_ref,
);
}
});
}
// Wrapping container for featured section
let featured_section = gtk::Box::builder()
@@ -145,19 +72,21 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
.spacing(8)
.build();
featured_section.append(&featured_label);
featured_section.append(&carousel_row);
featured_section.append(&featured_carousel);
featured_section.append(&carousel_dots);
// --- Category filter tiles (wrapping grid) ---
let category_box = gtk::FlowBox::builder()
.homogeneous(false)
.min_children_per_line(3)
.max_children_per_line(6)
.selection_mode(gtk::SelectionMode::None)
// --- Category filter chips (horizontal scrollable row) ---
let category_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Automatic)
.vscrollbar_policy(gtk::PolicyType::Never)
.margin_start(18)
.margin_end(18)
.row_spacing(8)
.column_spacing(8)
.build();
let category_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.build();
category_scroll.set_child(Some(&category_box));
// --- "All Apps" section header with sort dropdown ---
let all_label = gtk::Label::builder()
@@ -171,10 +100,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let sort_options = [
("Name (A-Z)", CatalogSortOrder::NameAsc),
("Name (Z-A)", CatalogSortOrder::NameDesc),
("Stars (most first)", CatalogSortOrder::StarsDesc),
("Stars (fewest first)", CatalogSortOrder::StarsAsc),
("Downloads (most first)", CatalogSortOrder::DownloadsDesc),
("Downloads (fewest first)", CatalogSortOrder::DownloadsAsc),
("Popularity (most first)", CatalogSortOrder::PopularityDesc),
("Popularity (least first)", CatalogSortOrder::PopularityAsc),
("Release date (newest first)", CatalogSortOrder::ReleaseDateDesc),
("Release date (oldest first)", CatalogSortOrder::ReleaseDateAsc),
];
@@ -186,7 +113,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
.model(&sort_model)
.selected(0)
.valign(gtk::Align::Center)
.tooltip_text(&i18n("Sort apps"))
.tooltip_text(&i18n("Popularity is based on download count, GitHub stars, and community activity"))
.build();
sort_dropdown.add_css_class("flat");
@@ -203,6 +130,17 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
sort_row.append(&sort_icon);
sort_row.append(&sort_dropdown);
// View mode toggle (grid vs list)
let view_toggle = gtk::ToggleButton::builder()
.icon_name("view-list-symbolic")
.tooltip_text(&i18n("Compact list view"))
.valign(gtk::Align::Center)
.css_classes(["flat", "circular"])
.build();
widgets::set_pointer_cursor(&view_toggle);
let compact_mode: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
let all_header = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
@@ -211,11 +149,15 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
.build();
all_header.append(&all_label);
all_header.append(&sort_row);
all_header.append(&view_toggle);
// Sort state
let active_sort: Rc<std::cell::Cell<CatalogSortOrder>> =
Rc::new(std::cell::Cell::new(CatalogSortOrder::NameAsc));
// Pagination state
let current_page: Rc<std::cell::Cell<i32>> = Rc::new(std::cell::Cell::new(0));
// FlowBox grid
let flow_box = gtk::FlowBox::builder()
.homogeneous(true)
@@ -224,11 +166,38 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
.selection_mode(gtk::SelectionMode::None)
.margin_start(18)
.margin_end(18)
.margin_bottom(24)
.row_spacing(12)
.column_spacing(12)
.build();
// Pagination controls
let page_prev_btn = gtk::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text(&i18n("Previous page"))
.sensitive(false)
.css_classes(["flat", "circular"])
.build();
let page_next_btn = gtk::Button::builder()
.icon_name("go-next-symbolic")
.tooltip_text(&i18n("Next page"))
.sensitive(false)
.css_classes(["flat", "circular"])
.build();
let page_label = gtk::Label::builder()
.label("Page 1")
.css_classes(["dim-label"])
.build();
let page_bar = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.halign(gtk::Align::Center)
.spacing(12)
.margin_bottom(24)
.visible(false)
.build();
page_bar.append(&page_prev_btn);
page_bar.append(&page_label);
page_bar.append(&page_next_btn);
let clamp = adw::Clamp::builder()
.maximum_size(1200)
.tightening_threshold(900)
@@ -271,13 +240,34 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
enrich_label.set_widget_name("enrich-label");
enrichment_banner.append(&enrich_label);
// Layout order: search -> enrichment banner -> featured carousel -> categories -> all apps grid
// Stale catalog banner - shown when catalog hasn't been refreshed in 7+ days
let stale_banner = adw::Banner::builder()
.title(&i18n("Catalog data may be outdated - tap to refresh"))
.button_label(&i18n("Refresh"))
.revealed(false)
.build();
{
let settings = gio::Settings::new(crate::config::APP_ID);
let last_refreshed = settings.string("catalog-last-refreshed").to_string();
if !last_refreshed.is_empty() {
if let Ok(then) = chrono::DateTime::parse_from_rfc3339(&last_refreshed) {
let days = (chrono::Utc::now() - then.with_timezone(&chrono::Utc)).num_days();
if days >= 7 {
stale_banner.set_revealed(true);
}
}
}
}
// Layout order: search -> stale banner -> enrichment banner -> featured carousel -> categories -> all apps grid
content.append(&search_bar);
content.append(&stale_banner);
content.append(&enrichment_banner);
content.append(&featured_section);
content.append(&category_box);
content.append(&category_scroll);
content.append(&all_header);
content.append(&flow_box);
content.append(&page_bar);
clamp.set_child(Some(&content));
let scrolled = gtk::ScrolledWindow::builder()
@@ -306,7 +296,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let app_count = db.catalog_app_count().unwrap_or(0);
if app_count > 0 {
stack.set_visible_child_name("results");
update_catalog_subtitle(&title, app_count);
update_catalog_subtitle(&title, db);
} else {
stack.set_visible_child_name("empty");
}
@@ -345,16 +335,57 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
// Populate categories
populate_categories(
db, &category_box, &active_category, &active_sort, &flow_box, &search_entry,
&featured_section, &all_label, nav_view, &toast_overlay,
db, &category_box, &active_category, &active_sort, &current_page,
&flow_box, &search_entry, &featured_section, &all_label,
&page_bar, &page_label, &page_prev_btn, &page_next_btn, &scrolled,
&compact_mode,
);
// Initial population
populate_featured(
db, &featured_apps, &featured_page, &featured_stack, &featured_flip,
&left_arrow, &right_arrow, nav_view, &toast_overlay,
db, &featured_apps, &featured_carousel, nav_view, &toast_overlay,
);
populate_grid(db, "", None, active_sort.get(), &flow_box, &all_label, nav_view, &toast_overlay);
populate_grid(
db, "", None, active_sort.get(), 0,
&flow_box, &all_label, &page_bar, &page_label, &page_prev_btn, &page_next_btn, &scrolled,
compact_mode.get(),
);
// View toggle handler
{
let db_ref = db.clone();
let flow_ref = flow_box.clone();
let cat_ref = active_category.clone();
let sort_ref = active_sort.clone();
let page_ref = current_page.clone();
let search_ref = search_entry.clone();
let compact_ref = compact_mode.clone();
let all_label_ref = all_label.clone();
let page_bar_ref = page_bar.clone();
let page_label_ref = page_label.clone();
let page_prev_ref = page_prev_btn.clone();
let page_next_ref = page_next_btn.clone();
let scrolled_ref = scrolled.clone();
view_toggle.connect_toggled(move |btn| {
let is_compact = btn.is_active();
compact_ref.set(is_compact);
if is_compact {
btn.set_icon_name("view-grid-symbolic");
btn.set_tooltip_text(Some(&i18n("Grid view")));
} else {
btn.set_icon_name("view-list-symbolic");
btn.set_tooltip_text(Some(&i18n("Compact list view")));
}
let query = search_ref.text().to_string();
let cat = cat_ref.borrow().clone();
populate_grid(
&db_ref, &query, cat.as_deref(), sort_ref.get(), page_ref.get(),
&flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref,
&page_prev_ref, &page_next_ref, &scrolled_ref,
is_compact,
);
});
}
// Sort dropdown handler
{
@@ -362,29 +393,35 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let flow_ref = flow_box.clone();
let cat_ref = active_category.clone();
let sort_ref = active_sort.clone();
let page_ref = current_page.clone();
let search_ref = search_entry.clone();
let compact_ref = compact_mode.clone();
let all_label_ref = all_label.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
let page_bar_ref = page_bar.clone();
let page_label_ref = page_label.clone();
let page_prev_ref = page_prev_btn.clone();
let page_next_ref = page_next_btn.clone();
let scrolled_ref = scrolled.clone();
sort_dropdown.connect_selected_notify(move |dd| {
let idx = dd.selected() as usize;
let sort_options_local = [
CatalogSortOrder::NameAsc,
CatalogSortOrder::NameDesc,
CatalogSortOrder::StarsDesc,
CatalogSortOrder::StarsAsc,
CatalogSortOrder::DownloadsDesc,
CatalogSortOrder::DownloadsAsc,
CatalogSortOrder::PopularityDesc,
CatalogSortOrder::PopularityAsc,
CatalogSortOrder::ReleaseDateDesc,
CatalogSortOrder::ReleaseDateAsc,
];
let sort = sort_options_local.get(idx).copied().unwrap_or(CatalogSortOrder::NameAsc);
sort_ref.set(sort);
page_ref.set(0);
let query = search_ref.text().to_string();
let cat = cat_ref.borrow().clone();
populate_grid(
&db_ref, &query, cat.as_deref(), sort,
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
&db_ref, &query, cat.as_deref(), sort, 0,
&flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref,
&page_prev_ref, &page_next_ref, &scrolled_ref,
compact_ref.get(),
);
});
}
@@ -395,18 +432,84 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let flow_ref = flow_box.clone();
let cat_ref = active_category.clone();
let sort_ref = active_sort.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
let page_ref = current_page.clone();
let compact_ref = compact_mode.clone();
let all_label_ref = all_label.clone();
let featured_section_ref = featured_section.clone();
let page_bar_ref = page_bar.clone();
let page_label_ref = page_label.clone();
let page_prev_ref = page_prev_btn.clone();
let page_next_ref = page_next_btn.clone();
let scrolled_ref = scrolled.clone();
search_entry.connect_search_changed(move |entry| {
let query = entry.text().to_string();
let cat = cat_ref.borrow().clone();
let is_searching = !query.is_empty() || cat.is_some();
featured_section_ref.set_visible(!is_searching);
page_ref.set(0);
populate_grid(
&db_ref, &query, cat.as_deref(), sort_ref.get(),
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
&db_ref, &query, cat.as_deref(), sort_ref.get(), 0,
&flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref,
&page_prev_ref, &page_next_ref, &scrolled_ref,
compact_ref.get(),
);
});
}
// Pagination: Previous page
{
let db_ref = db.clone();
let flow_ref = flow_box.clone();
let cat_ref = active_category.clone();
let sort_ref = active_sort.clone();
let page_ref = current_page.clone();
let search_ref = search_entry.clone();
let compact_ref = compact_mode.clone();
let all_label_ref = all_label.clone();
let page_bar_ref = page_bar.clone();
let page_label_ref = page_label.clone();
let page_prev_ref = page_prev_btn.clone();
let page_next_ref = page_next_btn.clone();
let scrolled_ref = scrolled.clone();
page_prev_btn.connect_clicked(move |_| {
let new_page = (page_ref.get() - 1).max(0);
page_ref.set(new_page);
let query = search_ref.text().to_string();
let cat = cat_ref.borrow().clone();
populate_grid(
&db_ref, &query, cat.as_deref(), sort_ref.get(), new_page,
&flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref,
&page_prev_ref, &page_next_ref, &scrolled_ref,
compact_ref.get(),
);
});
}
// Pagination: Next page
{
let db_ref = db.clone();
let flow_ref = flow_box.clone();
let cat_ref = active_category.clone();
let sort_ref = active_sort.clone();
let page_ref = current_page.clone();
let search_ref = search_entry.clone();
let compact_ref = compact_mode.clone();
let all_label_ref = all_label.clone();
let page_bar_ref = page_bar.clone();
let page_label_ref = page_label.clone();
let page_prev_ref = page_prev_btn.clone();
let page_next_ref = page_next_btn.clone();
let scrolled_ref = scrolled.clone();
page_next_btn.connect_clicked(move |_| {
let new_page = page_ref.get() + 1;
page_ref.set(new_page);
let query = search_ref.text().to_string();
let cat = cat_ref.borrow().clone();
populate_grid(
&db_ref, &query, cat.as_deref(), sort_ref.get(), new_page,
&flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref,
&page_prev_ref, &page_next_ref, &scrolled_ref,
compact_ref.get(),
);
});
}
@@ -440,17 +543,20 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let cat_box_ref = category_box.clone();
let active_cat_ref = active_category.clone();
let active_sort_ref = active_sort.clone();
let page_ref = current_page.clone();
let search_ref = search_entry.clone();
let compact_ref = compact_mode.clone();
let featured_apps_ref = featured_apps.clone();
let featured_page_ref = featured_page.clone();
let featured_stack_ref = featured_stack.clone();
let featured_flip_ref = featured_flip.clone();
let left_arrow_ref = left_arrow.clone();
let right_arrow_ref = right_arrow.clone();
let featured_carousel_ref = featured_carousel.clone();
let all_label_ref = all_label.clone();
let featured_section_ref = featured_section.clone();
let nav_ref = nav_view.clone();
let progress_ref = progress_bar.clone();
let page_bar_ref = page_bar.clone();
let page_label_ref = page_label.clone();
let page_prev_ref = page_prev_btn.clone();
let page_next_ref = page_next_btn.clone();
let scrolled_ref = scrolled.clone();
btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
@@ -465,15 +571,18 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let active_sort_c = active_sort_ref.clone();
let search_c = search_ref.clone();
let featured_apps_c = featured_apps_ref.clone();
let featured_page_c = featured_page_ref.clone();
let featured_stack_c = featured_stack_ref.clone();
let featured_flip_c = featured_flip_ref.clone();
let left_arrow_c = left_arrow_ref.clone();
let right_arrow_c = right_arrow_ref.clone();
let featured_carousel_c = featured_carousel_ref.clone();
let all_label_c = all_label_ref.clone();
let featured_section_c = featured_section_ref.clone();
let nav_c = nav_ref.clone();
let progress_c = progress_ref.clone();
let page_bar_c = page_bar_ref.clone();
let page_label_c = page_label_ref.clone();
let page_prev_c = page_prev_ref.clone();
let page_next_c = page_next_ref.clone();
let scrolled_c = scrolled_ref.clone();
let page_c = page_ref.clone();
let compact_c = compact_ref.clone();
// Capture app count before refresh for delta calculation
let count_before = db_c.catalog_app_count().unwrap_or(0);
@@ -483,6 +592,11 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
progress_c.set_fraction(0.0);
progress_c.set_text(Some("Fetching catalog..."));
// Show skeleton placeholder cards while syncing
if count_before == 0 {
show_skeleton(&flow_c);
}
// Channel for progress updates from background thread
let (tx, rx) = std::sync::mpsc::channel::<catalog::SyncProgress>();
@@ -525,12 +639,12 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
&format!("{}: Found {} apps", &*name, total),
));
}
catalog::SyncProgress::CachingIcon { current, total, .. } => {
catalog::SyncProgress::CachingIcon { current, total, ref app_name } => {
let name = src_name.borrow();
let inner = 0.05 + 0.85 * (current as f64 / total.max(1) as f64);
progress_listen.set_fraction(src_base.get() + src_span.get() * inner);
progress_listen.set_text(Some(
&format!("{}: Caching icons ({}/{})", &*name, current, total),
&format!("{}: Caching icon for {} ({}/{})", &*name, app_name, current, total),
));
}
catalog::SyncProgress::SavingApps { current, total } => {
@@ -541,12 +655,12 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
&format!("{}: Saving ({}/{})", &*name, current, total),
));
}
catalog::SyncProgress::Done { .. } => {
catalog::SyncProgress::Done { total } => {
// Single source done - don't break, more sources may follow
let name = src_name.borrow();
progress_listen.set_fraction(src_base.get() + src_span.get());
progress_listen.set_text(Some(
&format!("{}: Complete", &*name),
&format!("{}: {} apps synced", &*name, total),
));
}
catalog::SyncProgress::AllDone => {
@@ -611,20 +725,24 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
"Catalog refreshed, no new apps".to_string()
};
toast_c.add_toast(adw::Toast::new(&toast_msg));
update_catalog_subtitle(&title_c, count_after);
update_catalog_subtitle(&title_c, &db_c);
stack_c.set_visible_child_name("results");
populate_categories(
&db_c, &cat_box_c, &active_cat_c, &active_sort_c, &flow_c, &search_c,
&featured_section_c, &all_label_c,
&nav_c, &toast_c,
&db_c, &cat_box_c, &active_cat_c, &active_sort_c, &page_c,
&flow_c, &search_c, &featured_section_c, &all_label_c,
&page_bar_c, &page_label_c, &page_prev_c, &page_next_c, &scrolled_c,
&compact_c,
);
populate_featured(
&db_c, &featured_apps_c, &featured_page_c,
&featured_stack_c, &featured_flip_c,
&left_arrow_c, &right_arrow_c, &nav_c, &toast_c,
&db_c, &featured_apps_c, &featured_carousel_c,
&nav_c, &toast_c,
);
page_c.set(0);
populate_grid(
&db_c, "", None, active_sort_c.get(), &flow_c, &all_label_c, &nav_c, &toast_c,
&db_c, "", None, active_sort_c.get(), 0,
&flow_c, &all_label_c, &page_bar_c, &page_label_c,
&page_prev_c, &page_next_c, &scrolled_c,
compact_c.get(),
);
let settings = gio::Settings::new(crate::config::APP_ID);
@@ -648,6 +766,16 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
wire_refresh(&refresh_btn);
wire_refresh(&refresh_header_btn);
// Stale banner button triggers a refresh
{
let refresh_btn_ref = refresh_header_btn.clone();
let stale_ref = stale_banner.clone();
stale_banner.connect_button_clicked(move |_| {
stale_ref.set_revealed(false);
refresh_btn_ref.emit_clicked();
});
}
// Auto-refresh on first visit when catalog is empty
if app_count == 0 {
refresh_btn.emit_clicked();
@@ -664,66 +792,62 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
(page, enrichment_banner)
}
fn update_catalog_subtitle(title: &adw::WindowTitle, app_count: i64) {
let settings = gtk::gio::Settings::new(crate::config::APP_ID);
let last_refreshed = settings.string("catalog-last-refreshed");
if last_refreshed.is_empty() {
title.set_subtitle(&format!("{} apps available", app_count));
fn update_catalog_subtitle(title: &adw::WindowTitle, db: &Database) {
let app_count = db.catalog_app_count().unwrap_or(0);
let sources = catalog::get_sources(db);
// Use the most recent last_synced across all sources
let last_synced = sources.iter()
.filter_map(|s| s.last_synced.as_deref())
.max();
// Show per-source app counts in tooltip-friendly format
let source_summary: String = sources.iter()
.filter(|s| s.app_count > 0)
.map(|s| format!("{}: {}", s.name, s.app_count))
.collect::<Vec<_>>()
.join(", ");
if let Some(synced) = last_synced {
let relative = widgets::relative_time(synced);
title.set_subtitle(&format!("{} apps ({}) - Refreshed {}", app_count, source_summary, relative));
} else {
let relative = widgets::relative_time(&last_refreshed);
title.set_subtitle(&format!("{} apps - Refreshed {}", app_count, relative));
title.set_subtitle(&format!("{} apps available", app_count));
}
}
const CARDS_PER_PAGE: usize = 3;
/// Populate featured apps data and show the first page.
/// Populate featured apps into the AdwCarousel, one page of 3 cards each.
fn populate_featured(
db: &Rc<Database>,
featured_apps: &Rc<RefCell<Vec<CatalogApp>>>,
featured_page: &Rc<std::cell::Cell<usize>>,
featured_stack: &gtk::Stack,
featured_flip: &Rc<std::cell::Cell<bool>>,
left_arrow: &gtk::Button,
right_arrow: &gtk::Button,
carousel: &adw::Carousel,
nav_view: &adw::NavigationView,
toast_overlay: &adw::ToastOverlay,
) {
let apps = db.get_featured_catalog_apps(30).unwrap_or_default();
*featured_apps.borrow_mut() = apps;
featured_page.set(0);
show_featured_page(
featured_apps, 0, featured_stack, featured_flip,
left_arrow, right_arrow, db, nav_view, toast_overlay,
);
// Remove old pages
while carousel.n_pages() > 0 {
let child = carousel.nth_page(0);
carousel.remove(&child);
}
/// Display a specific page of featured cards with crossfade transition.
fn show_featured_page(
featured_apps: &Rc<RefCell<Vec<CatalogApp>>>,
page: usize,
stack: &gtk::Stack,
flip: &Rc<std::cell::Cell<bool>>,
left_arrow: &gtk::Button,
right_arrow: &gtk::Button,
db: &Rc<Database>,
nav_view: &adw::NavigationView,
toast_overlay: &adw::ToastOverlay,
) {
let apps = featured_apps.borrow();
let start = page * CARDS_PER_PAGE;
let apps = db.get_featured_catalog_apps(30).unwrap_or_default();
if apps.is_empty() {
*featured_apps.borrow_mut() = apps;
return;
}
let total_pages = (apps.len() + CARDS_PER_PAGE - 1) / CARDS_PER_PAGE;
for page_idx in 0..total_pages {
let start = page_idx * CARDS_PER_PAGE;
let end = (start + CARDS_PER_PAGE).min(apps.len());
let max_page = apps.len().saturating_sub(1) / CARDS_PER_PAGE;
left_arrow.set_sensitive(page > 0);
right_arrow.set_sensitive(page < max_page);
// Build a new page container with equal-width cards
let page_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.homogeneous(true)
.hexpand(true)
.margin_start(18)
.margin_end(18)
.build();
for app in &apps[start..end] {
@@ -796,56 +920,113 @@ fn show_featured_page(
page_box.append(&tile);
}
// Crossfade: alternate between "page-a" and "page-b"
let current = flip.get();
let new_name = if current { "page-a" } else { "page-b" };
flip.set(!current);
// Remove stale child with this name (from 2 transitions ago)
if let Some(old) = stack.child_by_name(new_name) {
stack.remove(&old);
carousel.append(&page_box);
}
stack.add_named(&page_box, Some(new_name));
stack.set_visible_child_name(new_name);
*featured_apps.borrow_mut() = apps;
}
const PAGE_SIZE: i32 = 100;
/// Populate the main grid with catalog tiles.
fn populate_grid(
db: &Rc<Database>,
query: &str,
category: Option<&str>,
sort: CatalogSortOrder,
page: i32,
flow_box: &gtk::FlowBox,
all_label: &gtk::Label,
_nav_view: &adw::NavigationView,
_toast_overlay: &adw::ToastOverlay,
page_bar: &gtk::Box,
page_label: &gtk::Label,
page_prev: &gtk::Button,
page_next: &gtk::Button,
scrolled: &gtk::ScrolledWindow,
compact: bool,
) {
// Clear existing
while let Some(child) = flow_box.first_child() {
flow_box.remove(&child);
}
let results = db.search_catalog(query, category, 200, sort).unwrap_or_default();
let total = db.count_catalog_matches(query, category).unwrap_or(0);
let total_pages = ((total as f64) / (PAGE_SIZE as f64)).ceil() as i32;
let page = page.clamp(0, (total_pages - 1).max(0));
let offset = page * PAGE_SIZE;
let results = db.search_catalog(query, category, PAGE_SIZE, offset, sort).unwrap_or_default();
if results.is_empty() {
all_label.set_label(&i18n("No results"));
page_bar.set_visible(false);
return;
}
let start = offset + 1;
let end = offset + results.len() as i32;
let label_text = if query.is_empty() && category.is_none() {
format!("{} ({})", i18n("All Apps"), results.len())
format!("{} ({}-{} of {})", i18n("All Apps"), start, end, total)
} else {
format!("{} ({})", i18n("Results"), results.len())
format!("{} ({}-{} of {})", i18n("Results"), start, end, total)
};
all_label.set_label(&label_text);
// Build set of installed app names for badge display
let installed_names: HashSet<String> = db.get_all_appimages()
.unwrap_or_default()
.into_iter()
.filter_map(|r| r.app_name.map(|n| n.to_lowercase()))
.collect();
// Adjust grid layout for compact mode
if compact {
flow_box.set_min_children_per_line(1);
flow_box.set_max_children_per_line(1);
} else {
flow_box.set_min_children_per_line(2);
flow_box.set_max_children_per_line(5);
}
for app in &results {
let tile = catalog_tile::build_catalog_tile(app);
// Store the app ID in the widget name for retrieval on click
let is_installed = installed_names.contains(&app.name.to_lowercase());
let tile = if compact {
catalog_tile::build_catalog_row(app, is_installed)
} else {
catalog_tile::build_catalog_tile(app, is_installed)
};
tile.set_widget_name(&format!("catalog-app-{}", app.id));
flow_box.append(&tile);
}
// Update pagination controls
if total_pages > 1 {
page_bar.set_visible(true);
page_label.set_label(&format!("Page {} of {}", page + 1, total_pages));
page_prev.set_sensitive(page > 0);
page_next.set_sensitive(page < total_pages - 1);
} else {
page_bar.set_visible(false);
}
// Scroll to top when changing pages
scrolled.vadjustment().set_value(0.0);
}
/// Show skeleton placeholder cards while the catalog is syncing.
fn show_skeleton(flow_box: &gtk::FlowBox) {
while let Some(child) = flow_box.first_child() {
flow_box.remove(&child);
}
for _ in 0..12 {
let placeholder = gtk::Box::builder()
.css_classes(["skeleton-card"])
.build();
let child = gtk::FlowBoxChild::builder()
.child(&placeholder)
.focusable(false)
.build();
flow_box.append(&child);
}
}
/// Map a FreeDesktop category name to (icon_name, color_css_class).
@@ -866,18 +1047,16 @@ fn category_meta(name: &str) -> (&'static str, &'static str) {
}
}
/// Build a category tile toggle button with icon and label.
fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, active: bool) -> gtk::ToggleButton {
/// Build a category chip toggle button (pill-shaped, horizontal scrollable).
fn build_category_chip(label_text: &str, icon_name: &str, _color_class: &str, active: bool) -> gtk::ToggleButton {
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(24);
icon.set_pixel_size(16);
let label = gtk::Label::new(Some(label_text));
label.set_ellipsize(gtk::pango::EllipsizeMode::End);
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(10)
.halign(gtk::Align::Center)
.spacing(6)
.build();
inner.append(&icon);
inner.append(&label);
@@ -885,7 +1064,7 @@ fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, act
let btn = gtk::ToggleButton::builder()
.child(&inner)
.active(active)
.css_classes(["flat", "category-tile", color_class])
.css_classes(["pill"])
.build();
widgets::set_pointer_cursor(&btn);
btn
@@ -893,15 +1072,20 @@ fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, act
fn populate_categories(
db: &Rc<Database>,
category_box: &gtk::FlowBox,
category_box: &gtk::Box,
active_category: &Rc<RefCell<Option<String>>>,
active_sort: &Rc<std::cell::Cell<CatalogSortOrder>>,
current_page: &Rc<std::cell::Cell<i32>>,
flow_box: &gtk::FlowBox,
search_entry: &gtk::SearchEntry,
featured_section: &gtk::Box,
all_label: &gtk::Label,
nav_view: &adw::NavigationView,
toast_overlay: &adw::ToastOverlay,
page_bar: &gtk::Box,
page_label: &gtk::Label,
page_prev: &gtk::Button,
page_next: &gtk::Button,
scrolled: &gtk::ScrolledWindow,
compact_mode: &Rc<std::cell::Cell<bool>>,
) {
// Clear existing
while let Some(child) = category_box.first_child() {
@@ -913,7 +1097,7 @@ fn populate_categories(
return;
}
let all_btn = build_category_tile(
let all_btn = build_category_chip(
&i18n("All"), "view-grid-symbolic", "cat-accent", true,
);
category_box.append(&all_btn);
@@ -923,21 +1107,26 @@ fn populate_categories(
for (cat, _count) in categories.iter().take(12) {
let (icon_name, color_class) = category_meta(cat);
let btn = build_category_tile(cat, icon_name, color_class, false);
let btn = build_category_chip(cat, icon_name, color_class, false);
category_box.append(&btn);
buttons.borrow_mut().push(btn.clone());
let cat_str = cat.clone();
let active_ref = active_category.clone();
let sort_ref = active_sort.clone();
let page_ref = current_page.clone();
let flow_ref = flow_box.clone();
let search_ref = search_entry.clone();
let db_ref = db.clone();
let buttons_ref = buttons.clone();
let compact_ref = compact_mode.clone();
let featured_section_ref = featured_section.clone();
let all_label_ref = all_label.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
let page_bar_ref = page_bar.clone();
let page_label_ref = page_label.clone();
let page_prev_ref = page_prev.clone();
let page_next_ref = page_next.clone();
let scrolled_ref = scrolled.clone();
btn.connect_toggled(move |btn| {
if btn.is_active() {
for other in buttons_ref.borrow().iter() {
@@ -947,10 +1136,13 @@ fn populate_categories(
}
*active_ref.borrow_mut() = Some(cat_str.clone());
featured_section_ref.set_visible(false);
page_ref.set(0);
let query = search_ref.text().to_string();
populate_grid(
&db_ref, &query, Some(&cat_str), sort_ref.get(),
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
&db_ref, &query, Some(&cat_str), sort_ref.get(), 0,
&flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref,
&page_prev_ref, &page_next_ref, &scrolled_ref,
compact_ref.get(),
);
}
});
@@ -959,14 +1151,19 @@ fn populate_categories(
{
let active_ref = active_category.clone();
let sort_ref = active_sort.clone();
let page_ref = current_page.clone();
let flow_ref = flow_box.clone();
let search_ref = search_entry.clone();
let db_ref = db.clone();
let buttons_ref = buttons.clone();
let compact_ref = compact_mode.clone();
let featured_section_ref = featured_section.clone();
let all_label_ref = all_label.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
let page_bar_ref = page_bar.clone();
let page_label_ref = page_label.clone();
let page_prev_ref = page_prev.clone();
let page_next_ref = page_next.clone();
let scrolled_ref = scrolled.clone();
all_btn.connect_toggled(move |btn| {
if btn.is_active() {
for other in buttons_ref.borrow().iter() {
@@ -976,10 +1173,13 @@ fn populate_categories(
}
*active_ref.borrow_mut() = None;
featured_section_ref.set_visible(true);
page_ref.set(0);
let query = search_ref.text().to_string();
populate_grid(
&db_ref, &query, None, sort_ref.get(),
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
&db_ref, &query, None, sort_ref.get(), 0,
&flow_ref, &all_label_ref, &page_bar_ref, &page_label_ref,
&page_prev_ref, &page_next_ref, &scrolled_ref,
compact_ref.get(),
);
}
});

View File

@@ -38,6 +38,54 @@ pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
content.append(&banner);
}
// Getting Started checklist (shown for new users)
let records = db.get_all_appimages().unwrap_or_default();
let total_count = records.len();
let integrated_count = records.iter().filter(|r| r.integrated).count();
if total_count < 3 {
let started_group = adw::PreferencesGroup::builder()
.title("Getting Started")
.description("New to Driftwood? Here are three steps to get you up and running.")
.build();
let scan_row = adw::ActionRow::builder()
.title("Scan your system for apps")
.subtitle("Look for AppImage files in your configured folders")
.activatable(true)
.build();
scan_row.set_action_name(Some("win.scan"));
if total_count > 0 {
let check = widgets::status_badge_with_icon("emblem-ok-symbolic", "Done", "success");
check.set_valign(gtk::Align::Center);
scan_row.add_suffix(&check);
}
started_group.add(&scan_row);
let catalog_row = adw::ActionRow::builder()
.title("Browse the app catalog")
.subtitle("Discover and install apps from the AppImage ecosystem")
.activatable(true)
.build();
catalog_row.set_action_name(Some("win.catalog"));
let arrow1 = gtk::Image::from_icon_name("go-next-symbolic");
arrow1.set_valign(gtk::Align::Center);
catalog_row.add_suffix(&arrow1);
started_group.add(&catalog_row);
let menu_row = adw::ActionRow::builder()
.title("Add an app to your launcher")
.subtitle("Make an app findable in your application menu")
.build();
if integrated_count > 0 {
let check = widgets::status_badge_with_icon("emblem-ok-symbolic", "Done", "success");
check.set_valign(gtk::Align::Center);
menu_row.add_suffix(&check);
}
started_group.add(&menu_row);
content.append(&started_group);
}
// Section 1: System Status
content.append(&build_system_status_group());
@@ -89,6 +137,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let session_row = adw::ActionRow::builder()
.title("Display server")
.subtitle(session.label())
.tooltip_text("How your system draws windows on screen")
.build();
let session_badge = widgets::status_badge(
session.label(),
@@ -107,15 +156,16 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let de_row = adw::ActionRow::builder()
.title("Desktop environment")
.subtitle(&de)
.tooltip_text("Your desktop interface")
.build();
group.add(&de_row);
// FUSE status
let fuse_info = fuse::detect_system_fuse();
let fuse_row = adw::ActionRow::builder()
.title("FUSE")
.title("App compatibility")
.subtitle(&fuse_description(&fuse_info))
.tooltip_text("Filesystem in Userspace - required for mounting AppImages")
.tooltip_text("Most AppImages need a system component called FUSE to run. This shows whether it is set up correctly.")
.build();
let fuse_badge = widgets::status_badge(
fuse_info.status.label(),
@@ -128,7 +178,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
// Install hint if FUSE not functional
if let Some(ref hint) = fuse_info.install_hint {
let hint_row = adw::ActionRow::builder()
.title("Fix FUSE")
.title("Fix app compatibility")
.subtitle(hint)
.subtitle_selectable(true)
.build();
@@ -141,7 +191,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let xwayland_row = adw::ActionRow::builder()
.title("XWayland")
.subtitle(if has_xwayland { "Running" } else { "Not detected" })
.tooltip_text("X11 compatibility layer for Wayland desktops")
.tooltip_text("Compatibility layer that lets older apps run on modern displays")
.build();
let xwayland_badge = widgets::status_badge(
if has_xwayland { "Available" } else { "Unavailable" },

View File

@@ -12,6 +12,7 @@ use crate::core::fuse::{self, FuseStatus};
use crate::core::integrator;
use crate::core::launcher::{self, SandboxMode};
use crate::core::notification;
use crate::core::sandbox;
use crate::core::security;
use crate::core::updater;
use crate::core::wayland::{self, WaylandStatus};
@@ -296,11 +297,41 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
}
}
// Windows equivalent hint for novice users
if let Some(equiv) = windows_equivalent(name) {
let equiv_label = gtk::Label::builder()
.label(equiv)
.css_classes(["caption", "dim-label"])
.halign(gtk::Align::Start)
.build();
text_col.append(&equiv_label);
}
text_col.append(&badge_box);
banner.append(&text_col);
banner
}
fn windows_equivalent(app_name: &str) -> Option<&'static str> {
match app_name.to_lowercase().as_str() {
s if s.contains("vlc") => Some("Similar to Windows Media Player"),
s if s.contains("gimp") => Some("Similar to Photoshop"),
s if s.contains("libreoffice") => Some("Similar to Microsoft Office"),
s if s.contains("firefox") => Some("Similar to Edge / Chrome"),
s if s.contains("chromium") || s.contains("brave") => Some("Similar to Chrome"),
s if s.contains("kdenlive") || s.contains("shotcut") => Some("Similar to Windows Video Editor"),
s if s.contains("krita") => Some("Similar to Paint / Photoshop"),
s if s.contains("thunderbird") => Some("Similar to Outlook"),
s if s.contains("telegram") || s.contains("signal") => Some("Similar to WhatsApp Desktop"),
s if s.contains("obs") => Some("Similar to OBS Studio (same app!)"),
s if s.contains("blender") => Some("Similar to Blender (same app!)"),
s if s.contains("audacity") => Some("Similar to Audacity (same app!)"),
s if s.contains("inkscape") => Some("Similar to Illustrator"),
s if s.contains("handbrake") => Some("Similar to HandBrake (same app!)"),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Tab 1: Overview - about, description, links, updates, releases, usage,
// capabilities, file info
@@ -382,6 +413,35 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
about_group.add(&row);
}
// Categories display
if let Some(ref cats) = record.categories {
if !cats.is_empty() {
let cat_row = adw::ActionRow::builder()
.title("Categories")
.build();
let cat_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
cat_box.set_valign(gtk::Align::Center);
for cat in cats.split(';').filter(|c| !c.is_empty()) {
let friendly = match cat.trim() {
"AudioVideo" | "Audio" | "Video" => "Media",
"Development" => "Developer Tools",
"Education" | "Science" => "Science & Education",
"Game" => "Games",
"Graphics" => "Graphics",
"Network" => "Internet",
"Office" => "Office",
"System" => "System Tools",
"Utility" => "Utilities",
other => other,
};
let badge = widgets::status_badge(friendly, "neutral");
cat_box.append(&badge);
}
cat_row.add_suffix(&cat_box);
about_group.add(&cat_row);
}
}
inner.append(&about_group);
}
@@ -620,8 +680,9 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle(
"This app does not include update information. \
You will need to check for new versions manually."
"This app does not include automatic update information. \
To update manually, download a newer version from the \
developer's website and drag it into Driftwood."
)
.tooltip_text(
"AppImages can include built-in update information that tells Driftwood \
@@ -675,6 +736,22 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
updates_group.add(&row);
}
// Per-app auto-update toggle (pinned = skip updates)
let pin_row = adw::SwitchRow::builder()
.title("Skip auto-updates")
.subtitle("When enabled, this app will be excluded from batch update checks")
.active(record.pinned)
.build();
{
let db_ref = db.clone();
let record_id = record.id;
pin_row.connect_active_notify(move |row| {
let _ = db_ref.set_pinned(record_id, row.is_active());
});
}
updates_group.add(&pin_row);
inner.append(&updates_group);
// -----------------------------------------------------------------------
@@ -837,8 +914,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
let type_str = match record.appimage_type {
Some(1) => "Type 1 - older format, still widely supported",
Some(2) => "Type 2 - modern, compressed format",
Some(1) => "Legacy format - an older packaging style. Still works but less common.",
Some(2) => "Modern format - compressed and efficient. This is the standard format for most AppImages.",
_ => "Unknown type",
};
let type_row = adw::ActionRow::builder()
@@ -870,19 +947,19 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let sig_row = adw::ActionRow::builder()
.title("Verified by developer")
.subtitle(if record.has_signature {
"Signed by the developer"
"This app includes a developer signature, which helps verify it has not been tampered with."
} else {
"Not signed"
"This is normal for most AppImages and does not mean the app is unsafe."
})
.tooltip_text(
"This app was signed by its developer, which helps verify \
it hasn't been tampered with since it was published."
"Some developers sign their apps with a cryptographic key. \
This helps verify the file hasn't been modified since it was published."
)
.build();
let sig_badge = if record.has_signature {
widgets::status_badge("Signed", "success")
} else {
widgets::status_badge("Unsigned", "neutral")
widgets::status_badge("No signature", "neutral")
};
sig_badge.set_valign(gtk::Align::Center);
sig_row.add_suffix(&sig_badge);
@@ -1040,7 +1117,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Autostart toggle
let autostart_row = adw::SwitchRow::builder()
.title("Start at login")
.subtitle("Launch this app automatically when you log in")
.subtitle("Launch this app automatically when you log in, like a Windows Startup program")
.active(record.autostart)
.tooltip_text(
"Creates an autostart entry so this app launches \
@@ -1070,9 +1147,9 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
});
integration_group.add(&autostart_row);
// StartupWMClass row with editable override
// StartupWMClass row with editable override - hidden in Advanced expander
let wm_class_row = adw::EntryRow::builder()
.title("Window class (advanced)")
.title("Window class")
.text(record.startup_wm_class.as_deref().unwrap_or(""))
.show_apply_button(true)
.build();
@@ -1090,7 +1167,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
}
}
});
integration_group.add(&wm_class_row);
let advanced_expander = adw::ExpanderRow::builder()
.title("Advanced settings")
.show_enable_switch(false)
.build();
advanced_expander.add_row(&wm_class_row);
integration_group.add(&advanced_expander);
// System-wide install toggle
if record.integrated {
@@ -1409,11 +1491,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let fuse_status = fuse_system.status.clone();
let fuse_row = adw::ActionRow::builder()
.title("App mounting")
.title("App startup system")
.subtitle(fuse_user_explanation(&fuse_status))
.tooltip_text(
"FUSE lets apps like AppImages run directly without unpacking first. \
Without it, apps still work but take a little longer to start."
"Most AppImages need a system component called FUSE to mount and run. \
This shows whether it is available."
)
.build();
let fuse_badge = widgets::status_badge_with_icon(
@@ -1434,12 +1516,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Per-app launch method
let appimage_path = std::path::Path::new(&record.path);
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
let launch_method_subtitle = if app_fuse_status.as_str() == "extract_and_run" {
"This app unpacks itself each time it starts. This is slower than normal but works without extra system setup.".to_string()
} else {
format!("This app will launch using: {}", app_fuse_status.label())
};
let launch_method_row = adw::ActionRow::builder()
.title("Startup method")
.subtitle(&format!(
"This app will launch using: {}",
app_fuse_status.label()
))
.subtitle(&launch_method_subtitle)
.tooltip_text(
"AppImages can start two ways: mounting (fast, instant startup) or \
unpacking to a temporary folder first (slower, but works everywhere). \
@@ -1457,10 +1541,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Sandboxing group
let sandbox_group = adw::PreferencesGroup::builder()
.title("App Isolation")
.title("Security Restrictions")
.description(
"Restrict what this app can access on your system \
for extra security."
"Control what this app can access on your system. \
When enabled, the app can only reach your Documents and Downloads folders."
)
.build();
@@ -1490,8 +1574,51 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
)
.build();
// Profile status row - shows current sandbox profile info
let profile_row = adw::ActionRow::builder()
.title("Sandbox profile")
.build();
let app_name_for_sandbox = record
.app_name
.clone()
.unwrap_or_else(|| {
std::path::Path::new(&record.filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
});
// Show current profile status
let loaded_profile = sandbox::load_profile(db, &app_name_for_sandbox)
.ok()
.flatten();
let profile_badge = if loaded_profile.is_some() {
widgets::status_badge("Active", "success")
} else if current_mode == SandboxMode::Firejail {
widgets::status_badge("Default", "neutral")
} else {
widgets::status_badge("None", "dim")
};
profile_badge.set_valign(gtk::Align::Center);
profile_row.add_suffix(&profile_badge);
// Show total profile count across all apps in group description
let all_profiles = sandbox::list_profiles(db);
if !all_profiles.is_empty() {
sandbox_group.set_description(Some(&format!(
"Restrict what this app can access on your system \
for extra security. {} sandbox {} configured across all apps.",
all_profiles.len(),
if all_profiles.len() == 1 { "profile" } else { "profiles" },
)));
}
let record_id = record.id;
let db_ref = db.clone();
let sandbox_name = app_name_for_sandbox.clone();
let profile_row_ref = profile_row.clone();
firejail_row.connect_active_notify(move |row| {
let mode = if row.is_active() {
SandboxMode::Firejail
@@ -1501,9 +1628,72 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) {
log::warn!("Failed to update sandbox mode: {}", e);
}
// Auto-generate a default sandbox profile when enabling isolation
if mode == SandboxMode::Firejail {
let existing = sandbox::load_profile(&db_ref, &sandbox_name)
.ok()
.flatten();
if existing.is_none() {
let profile = sandbox::generate_default_profile(&sandbox_name);
match sandbox::save_profile(&db_ref, &profile) {
Ok(path) => {
log::info!("Created default sandbox profile at {}", path.display());
// Update badge to show profile is active
while let Some(child) = profile_row_ref.last_child() {
profile_row_ref.remove(&child);
}
let badge = widgets::status_badge("Active", "success");
badge.set_valign(gtk::Align::Center);
profile_row_ref.add_suffix(&badge);
}
Err(e) => log::warn!("Failed to create sandbox profile: {}", e),
}
}
}
});
sandbox_group.add(&firejail_row);
if firejail_available && current_mode == SandboxMode::Firejail {
// Show profile explanation when sandbox is active
if let Some(path) = sandbox::profile_path_for_app(&app_name_for_sandbox) {
profile_row.set_subtitle(&format!(
"Blocks access to most of your files, camera, microphone, USB devices, and other apps. Only Documents and Downloads folders are accessible.\n{}",
path.display()
));
} else {
profile_row.set_subtitle("Default restrictive profile will be created on next launch");
}
// Reset button to delete current profile and regenerate default
if let Some(ref profile) = loaded_profile {
if let Some(profile_id) = profile.id {
let reset_btn = gtk::Button::builder()
.icon_name("edit-clear-symbolic")
.tooltip_text("Reset to default profile")
.valign(gtk::Align::Center)
.css_classes(["flat"])
.build();
let db_reset = db.clone();
let reset_name = app_name_for_sandbox.clone();
reset_btn.connect_clicked(move |_btn| {
if let Err(e) = sandbox::delete_profile(&db_reset, profile_id) {
log::warn!("Failed to delete sandbox profile: {}", e);
return;
}
// Regenerate a fresh default
let profile = sandbox::generate_default_profile(&reset_name);
if let Err(e) = sandbox::save_profile(&db_reset, &profile) {
log::warn!("Failed to regenerate sandbox profile: {}", e);
} else {
log::info!("Reset sandbox profile for {}", reset_name);
}
});
profile_row.add_suffix(&reset_btn);
}
}
sandbox_group.add(&profile_row);
}
if !firejail_available {
let firejail_cmd = "sudo apt install firejail";
let info_row = adw::ActionRow::builder()
@@ -1892,18 +2082,19 @@ fn build_storage_tab(
}
if !fp.paths.is_empty() {
let categories = [
("Configuration", fp.config_size),
("Application data", fp.data_size),
("Cache", fp.cache_size),
("State", fp.state_size),
("Other", fp.other_size),
let categories: &[(&str, u64, &str)] = &[
("Configuration", fp.config_size, "Your preferences and settings for this app"),
("Application data", fp.data_size, "Files and data saved by this app"),
("Cache", fp.cache_size, "Temporary files that can be safely deleted"),
("State", fp.state_size, "Runtime state like window positions and recent files"),
("Other", fp.other_size, "Other files associated with this app"),
];
for (label, size) in &categories {
for (label, size, tooltip) in categories {
if *size > 0 {
let row = adw::ActionRow::builder()
.title(*label)
.subtitle(&widgets::format_size(*size as i64))
.tooltip_text(*tooltip)
.build();
size_group.add(&row);
}
@@ -2001,11 +2192,40 @@ fn build_storage_tab(
.valign(gtk::Align::Center)
.build();
row.add_suffix(&size_label);
// Open folder button
let open_btn = gtk::Button::builder()
.icon_name("folder-open-symbolic")
.tooltip_text("Open in file manager")
.valign(gtk::Align::Center)
.build();
open_btn.add_css_class("flat");
let path_str = dp.path.to_string_lossy().to_string();
open_btn.connect_clicked(move |_| {
let file = gio::File::for_path(&path_str);
let launcher = gtk::FileLauncher::new(Some(&file));
launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
});
row.add_suffix(&open_btn);
paths_group.add(&row);
}
}
inner.append(&paths_group);
// Backup estimate
let est_size = fp.config_size + fp.data_size + fp.state_size;
if est_size > 0 {
let estimate_row = adw::ActionRow::builder()
.title("Backup estimate")
.subtitle(&format!(
"Estimated backup size: {} (settings and data files)",
widgets::format_size(est_size as i64)
))
.build();
paths_group.add(&estimate_row);
}
// Backups group
inner.append(&build_backup_group(record.id, toast_overlay));
@@ -2357,19 +2577,19 @@ fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
match status {
FuseStatus::FullyFunctional =>
"Everything is set up - apps start instantly.",
"This app can start normally using your system's app loader.",
FuseStatus::Fuse3Only =>
"A small system component is missing. Most apps will still work, \
but some may need it. Copy the install command to fix this.",
"A system component is needed for this app to start normally. \
Without it, the app uses a slower startup method.",
FuseStatus::NoFusermount =>
"A system component is missing, so apps will take a little longer \
to start. They'll still work fine.",
"A system component is needed for this app to start normally. \
Without it, the app uses a slower startup method.",
FuseStatus::NoDevFuse =>
"Your system doesn't support instant app mounting. Apps will unpack \
before starting, which takes a bit longer.",
"A system component is needed for this app to start normally. \
Without it, the app uses a slower startup method.",
FuseStatus::MissingLibfuse2 =>
"A small system component is needed for fast startup. \
Copy the install command to fix this.",
"A system component is needed for this app to start normally. \
Without it, the app uses a slower startup method.",
}
}
@@ -2624,9 +2844,9 @@ pub fn show_uninstall_dialog_with_callback(
db: &Rc<Database>,
is_integrated: bool,
data_paths: &[(String, String, u64)],
on_complete: Option<Box<dyn FnOnce() + 'static>>,
on_complete: Option<Rc<dyn Fn() + 'static>>,
) {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
let name = record.app_name.as_deref().unwrap_or(&record.filename).to_string();
let dialog = adw::AlertDialog::builder()
.heading(&format!("Uninstall {}?", name))
.body("Select what to remove:")
@@ -2675,46 +2895,34 @@ pub fn show_uninstall_dialog_with_callback(
dialog.set_extra_child(Some(&extra));
let record_snapshot = record.clone();
let record_id = record.id;
let record_path = record.path.clone();
let db_ref = db.clone();
let toast_ref = toast_overlay.clone();
let on_complete = std::cell::Cell::new(on_complete);
dialog.connect_response(Some("uninstall"), move |_dlg, _response| {
// Remove integration if checked
if let Some(ref check) = integration_check {
if check.is_active() {
// Capture deletion choices from checkboxes before dialog closes
let delete_file = appimage_check.is_active();
let should_remove_integration = integration_check.as_ref().map_or(false, |c| c.is_active());
let paths_to_delete: Vec<String> = path_checks.iter()
.filter(|(check, _)| check.is_active())
.map(|(_, path)| path.clone())
.collect();
// Remove integration immediately (before DB delete, since CASCADE
// removes system_modifications entries we need for undo_all_modifications)
if should_remove_integration {
integrator::undo_all_modifications(&db_ref, record_id).ok();
if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) {
integrator::remove_integration(&rec).ok();
}
}
}
// Remove checked data paths
for (check, path) in &path_checks {
if check.is_active() {
let p = std::path::Path::new(path);
if p.is_dir() {
std::fs::remove_dir_all(p).ok();
} else if p.is_file() {
std::fs::remove_file(p).ok();
}
}
}
// Remove AppImage file if checked
if appimage_check.is_active() {
std::fs::remove_file(&record_path).ok();
}
// Remove from database
// Remove from database (so item vanishes from lists)
db_ref.remove_appimage(record_id).ok();
toast_ref.add_toast(adw::Toast::new("AppImage uninstalled"));
// Run the completion callback if provided
if let Some(cb) = on_complete.take() {
// Run the completion callback to refresh the library view
if let Some(ref cb) = on_complete {
cb();
}
@@ -2723,6 +2931,66 @@ pub fn show_uninstall_dialog_with_callback(
let nav: adw::NavigationView = nav.downcast().unwrap();
nav.pop();
}
// Show undo toast - file deletion is deferred until toast dismisses
let toast = adw::Toast::builder()
.title(&format!("{} uninstalled", name))
.button_label("Undo")
.timeout(7)
.build();
let undo_clicked = Rc::new(Cell::new(false));
// On Undo: restore record to DB, re-integrate if needed, refresh
{
let undo_flag = undo_clicked.clone();
let db_undo = db_ref.clone();
let snapshot = record_snapshot.clone();
let toast_undo = toast_ref.clone();
let on_complete_undo = on_complete.clone();
let name_undo = name.clone();
let was_integrated = should_remove_integration;
toast.connect_button_clicked(move |_| {
undo_flag.set(true);
db_undo.restore_appimage_record(&snapshot).ok();
// Re-integrate if it was previously integrated
if was_integrated {
integrator::integrate(&snapshot).ok();
}
if let Some(ref cb) = on_complete_undo {
cb();
}
toast_undo.add_toast(adw::Toast::new(&format!("{} restored", name_undo)));
});
}
// On dismiss (timeout or any reason): perform actual file deletions if not undone
{
let undo_flag = undo_clicked.clone();
let path = record_path.clone();
toast.connect_dismissed(move |_| {
if undo_flag.get() {
return; // Undo was clicked, nothing to delete
}
// Remove data paths
for data_path in &paths_to_delete {
let p = std::path::Path::new(data_path);
if p.is_dir() {
std::fs::remove_dir_all(p).ok();
} else if p.is_file() {
std::fs::remove_file(p).ok();
}
}
// Remove AppImage file
if delete_file {
std::fs::remove_file(&path).ok();
}
});
}
toast_ref.add_toast(toast);
});
dialog.present(Some(toast_overlay));

View File

@@ -50,7 +50,13 @@ pub fn show_drop_dialog(
};
let body = if count == 1 {
files[0].to_string_lossy().to_string()
let path_str = files[0].to_string_lossy().to_string();
let size = std::fs::metadata(&files[0]).map(|m| m.len()).unwrap_or(0);
if size > 0 {
format!("{}\n({})", path_str, super::widgets::format_size(size as i64))
} else {
path_str
}
} else {
files
.iter()
@@ -87,9 +93,9 @@ pub fn show_drop_dialog(
}
dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("keep-in-place", &i18n("Keep in place"));
dialog.add_response("copy-only", &i18n("Copy to Applications"));
dialog.add_response("copy-and-integrate", &i18n("Copy & add to menu"));
dialog.add_response("keep-in-place", &i18n("Run Portable"));
dialog.add_response("copy-only", &i18n("Copy to Apps"));
dialog.add_response("copy-and-integrate", &i18n("Copy & Add to Launcher"));
dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("copy-and-integrate"));

View File

@@ -52,7 +52,7 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.build();
let title = gtk::Label::builder()
.label(&i18n("FUSE is required"))
.label(&i18n("Additional setup needed"))
.xalign(0.0)
.build();
title.add_css_class("title-3");
@@ -60,8 +60,9 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
let explanation = gtk::Label::builder()
.label(&i18n(
"Most AppImages require libfuse2 to mount and run. \
Without it, apps will use a slower extract-and-run fallback or may not launch at all.",
"Most apps need a small system component called FUSE to start quickly. \
Without it, apps will still work but will take longer to start each time. \
You can install it now (requires your password) or skip and use the slower method.",
))
.wrap(true)
.xalign(0.0)
@@ -101,18 +102,34 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.margin_top(12)
.build();
let skip_btn = gtk::Button::builder()
.label(&i18n("Skip and use slower method"))
.tooltip_text(&i18n("Apps will still work, but they will take longer to start because they unpack themselves each time"))
.build();
skip_btn.add_css_class("flat");
skip_btn.add_css_class("pill");
let install_btn = gtk::Button::builder()
.label(&i18n("Install via pkexec"))
.build();
install_btn.add_css_class("suggested-action");
install_btn.add_css_class("pill");
button_box.append(&skip_btn);
button_box.append(&install_btn);
content.append(&button_box);
toolbar.set_content(Some(&content));
dialog.set_child(Some(&toolbar));
// Skip button just closes the dialog
let dialog_weak = dialog.downgrade();
skip_btn.connect_clicked(move |_| {
if let Some(dlg) = dialog_weak.upgrade() {
dlg.close();
}
});
let cmd = install_cmd.clone();
let status_ref = status_label.clone();
let btn_ref = install_btn.clone();

View File

@@ -89,7 +89,7 @@ impl LibraryView {
let search_button = gtk::ToggleButton::builder()
.icon_name("system-search-symbolic")
.tooltip_text(&i18n("Search"))
.tooltip_text(&i18n("Search (Ctrl+F)"))
.build();
search_button.add_css_class("flat");
search_button.update_property(&[AccessibleProperty::Label("Toggle search")]);
@@ -124,7 +124,7 @@ impl LibraryView {
// Scan button
let scan_button = gtk::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text(&i18n("Scan for AppImages"))
.tooltip_text(&i18n("Scan for AppImages (Ctrl+R)"))
.build();
scan_button.add_css_class("flat");
scan_button.set_action_name(Some("win.scan"));
@@ -244,14 +244,44 @@ impl LibraryView {
browse_catalog_btn.set_action_name(Some("win.catalog"));
browse_catalog_btn.update_property(&[AccessibleProperty::Label("Browse app catalog")]);
let learn_btn = gtk::Button::builder()
.label(&i18n("What is an AppImage?"))
.build();
learn_btn.add_css_class("flat");
learn_btn.add_css_class("pill");
learn_btn.connect_clicked(|btn| {
let dialog = adw::AlertDialog::builder()
.heading(&i18n("What is an AppImage?"))
.body(&i18n(
"AppImages are self-contained app files for Linux, similar to .exe files on Windows or .dmg files on Mac.\n\n\
Key differences from traditional Linux packages:\n\
- No installation needed - just download and run\n\
- One file per app - easy to back up and share\n\
- Works on most Linux distributions\n\
- Does not require admin/root access\n\n\
Driftwood helps you discover, organize, and keep your AppImages up to date."
))
.build();
dialog.add_response("learn-more", &i18n("Learn More Online"));
dialog.add_response("ok", &i18n("Got It"));
dialog.set_default_response(Some("ok"));
dialog.set_close_response("ok");
dialog.connect_response(Some("learn-more"), |_, _| {
gtk::UriLauncher::new("https://appimage.org")
.launch(gtk::Window::NONE, gtk::gio::Cancellable::NONE, |_| {});
});
dialog.present(Some(btn));
});
empty_button_box.append(&scan_now_btn);
empty_button_box.append(&browse_catalog_btn);
empty_button_box.append(&learn_btn);
let empty_page = adw::StatusPage::builder()
.icon_name("application-x-executable-symbolic")
.title(&i18n("No AppImages Yet"))
.description(&i18n(
"Drag and drop AppImage files here, or scan your system to find them.",
"AppImages are portable apps for Linux - like .exe files, but they run without installation. Drag one here, scan your system, or browse the catalog to get started.",
))
.child(&empty_button_box)
.build();
@@ -361,6 +391,9 @@ impl LibraryView {
toolbar_view.set_content(Some(&content_box));
widgets::apply_pointer_cursors(&toolbar_view);
// Enable type-to-search: any keypress in the view opens the search bar
search_bar.set_key_capture_widget(Some(&toolbar_view));
let page = adw::NavigationPage::builder()
.title("Driftwood")
.tag("library")
@@ -670,6 +703,18 @@ impl LibraryView {
icon.add_css_class("icon-rounded");
row.add_prefix(&icon);
// Quick launch button
let launch_btn = gtk::Button::builder()
.icon_name("media-playback-start-symbolic")
.tooltip_text(&i18n("Launch"))
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.build();
launch_btn.set_action_name(Some("win.launch-appimage"));
launch_btn.set_action_target_value(Some(&record.id.to_variant()));
widgets::set_pointer_cursor(&launch_btn);
row.add_suffix(&launch_btn);
// Single most important badge as suffix (same priority as cards)
if let Some(badge) = app_card::build_priority_badge(record) {
badge.set_valign(gtk::Align::Center);
@@ -792,7 +837,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 1: Launch
let section1 = gtk::gio::Menu::new();
section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id)));
section1.append(Some("Open"), Some(&format!("win.launch-appimage(int64 {})", record.id)));
menu.append_section(None, &section1);
// Section 2: Actions
@@ -803,7 +848,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 3: Integration + folder
let section3 = gtk::gio::Menu::new();
let integrate_label = if record.integrated { "Remove from app menu" } else { "Add to app menu" };
let integrate_label = if record.integrated { "Remove from launcher" } else { "Add to launcher" };
section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id)));
section3.append(Some("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id)));
menu.append_section(None, &section3);

View File

@@ -37,10 +37,10 @@ pub fn show_permission_dialog(
extra.append(&access_label);
let items = [
"Your home directory and files",
"Network and internet access",
"Display server (Wayland/X11)",
"System D-Bus and services",
"Your files and folders",
"Internet and network access",
"Your screen and windows",
"Background system services",
];
for item in &items {
let label = gtk::Label::builder()
@@ -50,16 +50,30 @@ pub fn show_permission_dialog(
extra.append(&label);
}
// Show firejail option if available
if launcher::has_firejail() {
let sandbox_note = gtk::Label::builder()
// Explanation paragraph
let explain = gtk::Label::builder()
.label(&i18n(
"Firejail is available on your system. You can configure sandboxing in the app's system tab.",
"AppImages run like regular programs on your computer. Unlike phone apps, \
desktop apps typically have full access to your files and system. This is normal.",
))
.wrap(true)
.xalign(0.0)
.margin_top(8)
.build();
explain.add_css_class("caption");
explain.add_css_class("dim-label");
extra.append(&explain);
// Show firejail option if available
if launcher::has_firejail() {
let sandbox_note = gtk::Label::builder()
.label(&i18n(
"You can restrict this app's access later in Details > Security Restrictions.",
))
.wrap(true)
.xalign(0.0)
.margin_top(4)
.build();
sandbox_note.add_css_class("dim-label");
extra.append(&sandbox_note);
}

View File

@@ -81,7 +81,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Scan Locations group
let scan_group = adw::PreferencesGroup::builder()
.title(&i18n("Scan Locations"))
.description(&i18n("Directories to scan for AppImage files"))
.description(&i18n("Folders where Driftwood looks for AppImage files. Add any folder where you save downloaded apps."))
.build();
let dirs = settings.strv("scan-directories");
@@ -157,6 +157,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Automation group
let automation_group = adw::PreferencesGroup::builder()
.title(&i18n("Automation"))
.description(&i18n("Control what happens automatically when Driftwood starts or finds new apps."))
.build();
let auto_scan_row = adw::SwitchRow::builder()
@@ -260,6 +261,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Update Checking group
let checking_group = adw::PreferencesGroup::builder()
.title(&i18n("Update Checking"))
.description(&i18n("Let Driftwood periodically check if newer versions of your apps are available."))
.build();
let auto_update_row = adw::SwitchRow::builder()
@@ -297,6 +299,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Update Behavior group
let behavior_group = adw::PreferencesGroup::builder()
.title(&i18n("Update Behavior"))
.description(&i18n("Control what happens when an app is updated to a newer version."))
.build();
let cleanup_row = adw::ComboRow::builder()
@@ -359,7 +362,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Security Scanning group
let security_group = adw::PreferencesGroup::builder()
.title(&i18n("Security Scanning"))
.description(&i18n("Check bundled libraries for known CVEs via OSV.dev"))
.description(&i18n("Automatically check the components bundled inside your apps for known security issues via the OSV.dev database."))
.build();
let auto_security_row = adw::SwitchRow::builder()
@@ -417,7 +420,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Catalog Enrichment group
let enrichment_group = adw::PreferencesGroup::builder()
.title(&i18n("Catalog Enrichment"))
.description(&i18n("Fetch GitHub metadata (stars, version, downloads) for catalog apps"))
.description(&i18n("Fetch additional app information like stars, downloads, and descriptions from GitHub to enrich the catalog."))
.build();
let auto_enrich_row = adw::SwitchRow::builder()
@@ -445,7 +448,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
enrichment_group.add(&token_row);
let token_hint = adw::ActionRow::builder()
.title(&i18n("Optional - increases rate limit from 60 to 5,000 requests per hour"))
.title(&i18n("Optional. Speeds up catalog data fetching. Get a free token at github.com/settings/tokens (no special permissions needed)."))
.css_classes(["dim-label"])
.build();
enrichment_group.add(&token_hint);

View File

@@ -277,7 +277,7 @@ fn build_report_content(db: &Rc<Database>) -> gtk::ScrolledWindow {
fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder()
.title("Vulnerability Summary")
.description("Overall security status across all your apps")
.description("Driftwood checks the software components bundled inside your apps against a database of known security issues (CVEs). Most issues are in underlying libraries, not the apps themselves.")
.build();
let total_row = adw::ActionRow::builder()
@@ -294,6 +294,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref
let row = adw::ActionRow::builder()
.title("Critical")
.subtitle(&summary.critical.to_string())
.tooltip_text("Could allow an attacker to take control of affected components")
.build();
let badge = widgets::status_badge("Critical", "error");
badge.set_valign(gtk::Align::Center);
@@ -304,6 +305,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref
let row = adw::ActionRow::builder()
.title("High")
.subtitle(&summary.high.to_string())
.tooltip_text("Could allow unauthorized access to data processed by the app")
.build();
let badge = widgets::status_badge("High", "error");
badge.set_valign(gtk::Align::Center);
@@ -314,6 +316,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref
let row = adw::ActionRow::builder()
.title("Medium")
.subtitle(&summary.medium.to_string())
.tooltip_text("Could cause the app to behave unexpectedly or crash")
.build();
let badge = widgets::status_badge("Medium", "warning");
badge.set_valign(gtk::Align::Center);
@@ -324,6 +327,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref
let row = adw::ActionRow::builder()
.title("Low")
.subtitle(&summary.low.to_string())
.tooltip_text("Minor issue with limited practical impact")
.build();
let badge = widgets::status_badge("Low", "neutral");
badge.set_valign(gtk::Align::Center);
@@ -340,7 +344,10 @@ fn build_app_findings_group(
summary: &crate::core::database::CveSummary,
cve_matches: &[crate::core::database::CveMatchRecord],
) -> adw::PreferencesGroup {
let description = format!("{} known security issues found", summary.total());
let description = format!(
"{} known security issues found. Check if a newer version is available in the catalog or from the developer's website. Most security issues are fixed in newer releases.",
summary.total()
);
let group = adw::PreferencesGroup::builder()
.title(app_name)
.description(&description)

View File

@@ -255,18 +255,23 @@ fn handle_old_version_cleanup(dialog: &adw::AlertDialog, old_path: PathBuf) {
}
/// Batch check all AppImages for updates. Returns count of updates found.
pub fn batch_check_updates(db: &Database) -> u32 {
/// Check all apps for updates, returns (count, list of app names with updates).
pub fn batch_check_updates_detailed(db: &Database) -> (u32, Vec<String>) {
let records = match db.get_all_appimages() {
Ok(r) => r,
Err(e) => {
log::error!("Failed to get appimages for update check: {}", e);
return 0;
return (0, vec![]);
}
};
let mut updates_found = 0u32;
let mut updated_names = Vec::new();
for record in &records {
if record.pinned {
continue;
}
let appimage_path = std::path::Path::new(&record.path);
if !appimage_path.exists() {
continue;
@@ -292,6 +297,8 @@ pub fn batch_check_updates(db: &Database) -> u32 {
if let Some(ref version) = result.latest_version {
db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok();
updates_found += 1;
let name = record.app_name.as_deref().unwrap_or(&record.filename);
updated_names.push(name.to_string());
}
} else {
db.clear_update_available(record.id).ok();
@@ -299,5 +306,9 @@ pub fn batch_check_updates(db: &Database) -> u32 {
}
}
updates_found
(updates_found, updated_names)
}
pub fn batch_check_updates(db: &Database) -> u32 {
batch_check_updates_detailed(db).0
}

View File

@@ -25,7 +25,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
// Check Now button
let check_btn = gtk::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text(&i18n("Check for updates"))
.tooltip_text(&i18n("Check for updates (Ctrl+U)"))
.build();
header.pack_end(&check_btn);
@@ -86,6 +86,18 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
.build();
updates_content.append(&last_checked_label);
// "What will happen" explanation
let explanation = gtk::Label::builder()
.label(&i18n("Each app will be downloaded fresh. Your settings and data are kept. You can choose to keep old versions as backup in Preferences."))
.css_classes(["caption", "dim-label"])
.wrap(true)
.xalign(0.0)
.margin_start(18)
.margin_end(18)
.margin_top(6)
.build();
updates_content.append(&explanation);
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
@@ -255,10 +267,15 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
.activatable(false)
.build();
// Show version info: current -> latest
// 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");
row.set_subtitle(&format!("{} -> {}", current, latest));
let subtitle = if record.size_bytes > 0 {
format!("{} -> {} ({})", current, latest, widgets::format_size(record.size_bytes))
} else {
format!("{} -> {}", current, latest)
};
row.set_subtitle(&subtitle);
// App icon
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32);

View File

@@ -339,9 +339,8 @@ pub fn show_crash_dialog(
/// Generate a plain-text explanation of why an app crashed based on stderr patterns.
fn crash_explanation(stderr: &str) -> String {
if stderr.contains("Could not find the Qt platform plugin") || stderr.contains("qt.qpa.plugin") {
return "The app couldn't find a required display plugin. This usually means \
it needs a Qt library that isn't bundled inside the AppImage or \
available on your system.".to_string();
return "This app needs a display system plugin that is not installed. \
Try installing the Qt platform packages for your system.".to_string();
}
if stderr.contains("cannot open shared object file") {
if let Some(pos) = stderr.find("cannot open shared object file") {
@@ -350,29 +349,31 @@ fn crash_explanation(stderr: &str) -> String {
let lib = before[start + 2..].trim();
if !lib.is_empty() {
return format!(
"The app needs a system library ({}) that isn't installed. \
You may be able to fix this by installing the missing package.",
"This app is missing a component it needs to run \
(similar to a missing DLL on Windows). \
The missing component is: {}",
lib,
);
}
}
}
return "The app needs a system library that isn't installed on your system.".to_string();
return "This app is missing a component it needs to run \
(similar to a missing DLL on Windows).".to_string();
}
if stderr.contains("Segmentation fault") || stderr.contains("SIGSEGV") {
return "The app crashed due to a memory error. This is usually a bug \
in the app itself, not something you can fix.".to_string();
return "This app crashed immediately. This is usually a bug in the \
app itself, not something you can fix.".to_string();
}
if stderr.contains("Permission denied") {
return "The app was blocked from accessing something it needs. \
Check that the AppImage file has the right permissions.".to_string();
return "This app does not have permission to run. Driftwood usually \
fixes this automatically - try removing and re-adding the app.".to_string();
}
if stderr.contains("fatal IO error") || stderr.contains("display connection") {
return "The app lost its connection to the display server. This can happen \
with apps that don't fully support your display system.".to_string();
return "This app could not connect to your display. If you are using \
a remote session or container, this may not work.".to_string();
}
if stderr.contains("FATAL:") || stderr.contains("Aborted") {
return "The app hit a fatal error and had to stop. The error details \
return "This app encountered an error during startup. The error details \
below may help identify the cause.".to_string();
}
if stderr.contains("Failed to initialize") {

View File

@@ -232,9 +232,10 @@ impl DriftwoodWindow {
.build();
let drop_overlay_subtitle = gtk::Label::builder()
.label(&i18n("Drop a file here or click to browse"))
.label(&i18n("Drop an AppImage file (.AppImage) here, or click to browse your files"))
.css_classes(["body", "dimmed"])
.halign(gtk::Align::Center)
.wrap(true)
.build();
// The card itself - acts as a clickable button to open file picker
@@ -1061,8 +1062,8 @@ impl DriftwoodWindow {
&db,
is_integrated,
&fp_paths,
Some(Box::new(move || {
// Refresh the library view after uninstall
Some(Rc::new(move || {
// Refresh the library view after uninstall (or undo)
if let Some(lib_view) = window_ref.imp().library_view.get() {
if let Ok(records) = db_refresh.get_all_appimages() {
lib_view.populate(records);
@@ -1112,6 +1113,17 @@ impl DriftwoodWindow {
}
self.add_action(&show_updates_action);
// Command palette (Ctrl+K)
let palette_action = gio::SimpleAction::new("command-palette", None);
{
let window_weak = self.downgrade();
palette_action.connect_activate(move |_, _| {
let Some(window) = window_weak.upgrade() else { return };
window.show_command_palette();
});
}
self.add_action(&palette_action);
// Keyboard shortcuts
if let Some(app) = self.application() {
let gtk_app = app.downcast_ref::<gtk::Application>().unwrap();
@@ -1124,6 +1136,7 @@ impl DriftwoodWindow {
gtk_app.set_accels_for_action("win.show-installed", &["<Control>1"]);
gtk_app.set_accels_for_action("win.show-catalog", &["<Control>2"]);
gtk_app.set_accels_for_action("win.show-updates", &["<Control>3"]);
gtk_app.set_accels_for_action("win.command-palette", &["<Control>k"]);
}
}
@@ -1149,6 +1162,14 @@ impl DriftwoodWindow {
// Scan on startup if enabled in preferences
if self.settings().boolean("auto-scan-on-startup") {
if let Some(toast_overlay) = self.imp().toast_overlay.get() {
toast_overlay.add_toast(
adw::Toast::builder()
.title(&i18n("Scanning for apps in your configured folders..."))
.timeout(2)
.build(),
);
}
self.trigger_scan();
}
@@ -1206,14 +1227,31 @@ impl DriftwoodWindow {
};
if should_check {
let settings_save = settings_upd.clone();
let update_toast = self.imp().toast_overlay.get().cloned();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("DB open failed");
update_dialog::batch_check_updates(&bg_db)
update_dialog::batch_check_updates_detailed(&bg_db)
})
.await;
if let Ok(count) = result {
if let Ok((count, names)) = result {
log::info!("Background update check: {} updates available", count);
if count > 0 {
if let Some(toast_overlay) = update_toast {
let title = if names.len() <= 3 {
format!("Updates available: {}", names.join(", "))
} else {
format!("{} app updates available ({}, ...)",
count, names[..2].join(", "))
};
let toast = adw::Toast::builder()
.title(&title)
.button_label("View")
.action_name("win.show-updates")
.build();
toast_overlay.add_toast(toast);
}
}
}
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
settings_save.set_string("last-update-check", &now).ok();
@@ -1643,6 +1681,174 @@ impl DriftwoodWindow {
}
}
fn show_command_palette(&self) {
let db = self.database().clone();
let dialog = adw::Dialog::builder()
.title("Quick Launch")
.content_width(450)
.content_height(400)
.build();
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);
let content_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_start(12)
.margin_end(12)
.margin_top(8)
.margin_bottom(8)
.build();
let search_entry = gtk::SearchEntry::builder()
.placeholder_text(&i18n("Type to search installed and catalog apps..."))
.hexpand(true)
.build();
content_box.append(&search_entry);
let scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.build();
let results_list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::Single)
.css_classes(["boxed-list"])
.build();
scrolled.set_child(Some(&results_list));
content_box.append(&scrolled);
// Populate results based on search
let db_ref = db.clone();
let results_ref = results_list.clone();
let dialog_ref = dialog.clone();
let window_weak = self.downgrade();
let update_results = std::rc::Rc::new(move |query: &str| {
// Clear existing
while let Some(child) = results_ref.first_child() {
results_ref.remove(&child);
}
if query.is_empty() {
return;
}
let q = query.to_lowercase();
// Search installed apps
let installed = db_ref.get_all_appimages().unwrap_or_default();
let mut count = 0;
for record in &installed {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
if name.to_lowercase().contains(&q) && count < 10 {
let row = adw::ActionRow::builder()
.title(name)
.subtitle(&i18n("Installed - click to launch"))
.activatable(true)
.build();
let icon = widgets::app_icon(
record.icon_path.as_deref(), name, 32,
);
row.add_prefix(&icon);
let play_icon = gtk::Image::from_icon_name("media-playback-start-symbolic");
row.add_suffix(&play_icon);
let record_id = record.id;
let dialog_c = dialog_ref.clone();
let window_w = window_weak.clone();
row.connect_activated(move |_| {
dialog_c.close();
if let Some(win) = window_w.upgrade() {
gio::prelude::ActionGroupExt::activate_action(
&win, "launch-appimage", Some(&record_id.to_variant()),
);
}
});
results_ref.append(&row);
count += 1;
}
}
// Search catalog apps
if let Ok(catalog_results) = db_ref.search_catalog(
query, None, 10, 0,
crate::core::database::CatalogSortOrder::PopularityDesc,
) {
for app in &catalog_results {
if count >= 15 { break; }
let row = adw::ActionRow::builder()
.title(&app.name)
.subtitle(&i18n("Catalog - click to view"))
.activatable(true)
.build();
let icon = widgets::app_icon(None, &app.name, 32);
row.add_prefix(&icon);
let nav_icon = gtk::Image::from_icon_name("go-next-symbolic");
row.add_suffix(&nav_icon);
let app_id = app.id;
let dialog_c = dialog_ref.clone();
let window_w = window_weak.clone();
let db_c = db_ref.clone();
row.connect_activated(move |_| {
dialog_c.close();
if let Some(win) = window_w.upgrade() {
// Switch to catalog tab
if let Some(vs) = win.imp().view_stack.get() {
vs.set_visible_child_name("catalog");
}
// Navigate to the app detail
if let Ok(Some(catalog_app)) = db_c.get_catalog_app(app_id) {
if let Some(toast) = win.imp().toast_overlay.get() {
let detail = crate::ui::catalog_detail::build_catalog_detail_page(
&catalog_app, &db_c, toast,
);
// Push onto the catalog NavigationView
// The catalog page is a NavigationView inside the ViewStack
if let Some(vs) = win.imp().view_stack.get() {
if let Some(child) = vs.child_by_name("catalog") {
if let Ok(nav) = child.downcast::<adw::NavigationView>() {
nav.push(&detail);
}
}
}
}
}
}
});
results_ref.append(&row);
count += 1;
}
}
if count == 0 {
let row = adw::ActionRow::builder()
.title(&i18n("No results found"))
.sensitive(false)
.build();
results_ref.append(&row);
}
});
{
let update_fn = update_results.clone();
search_entry.connect_search_changed(move |entry| {
let query = entry.text().to_string();
update_fn(&query);
});
}
toolbar.set_content(Some(&content_box));
dialog.set_child(Some(&toolbar));
dialog.present(Some(self));
// Focus the search entry after presenting
search_entry.grab_focus();
}
fn show_shortcuts_dialog(&self) {
let dialog = adw::Dialog::builder()
.title("Keyboard Shortcuts")
@@ -1674,6 +1880,7 @@ impl DriftwoodWindow {
nav_group.add(&shortcut_row("Ctrl+1", "Installed"));
nav_group.add(&shortcut_row("Ctrl+2", "Catalog"));
nav_group.add(&shortcut_row("Ctrl+3", "Updates"));
nav_group.add(&shortcut_row("Ctrl+K", "Quick Launch"));
nav_group.add(&shortcut_row("Ctrl+F", "Search"));
nav_group.add(&shortcut_row("Ctrl+D", "Dashboard"));
nav_group.add(&shortcut_row("Ctrl+,", "Preferences"));