diff --git a/src/commands/list.rs b/src/commands/list.rs index e501b45..c1e0164 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -55,7 +55,10 @@ impl SqliteClipboardDb { // Query entries from DB let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \ + DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -242,8 +245,7 @@ impl SqliteClipboardDb { if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - if let Event::Key(key) = event::read() + && let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { match (key.code, key.modifiers) { @@ -275,50 +277,62 @@ impl SqliteClipboardDb { state.select(Some(i)); }, (KeyCode::Enter, _) => { - if let Some(idx) = state.selected() { - if let Some((id, ..)) = entries.get(idx) { - // Fetch full contents for the selected entry - let (contents, mime): (Vec, Option) = self - .conn - .query_row( - "SELECT contents, mime FROM clipboard WHERE id = ?1", - rusqlite::params![id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|e| { - StashError::ListDecode(e.to_string().into()) - })?; - // Copy to clipboard - let opts = Options::new(); - // Default clipboard is regular, seat is default - let mime_type = match mime { - Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => MimeType::Specific(m.clone()), - None => MimeType::Text, - }; - let copy_result = opts - .copy(Source::Bytes(contents.clone().into()), mime_type); - match copy_result { - Ok(()) => { - let _ = Notification::new() - .summary("Stash") - .body("Copied entry to clipboard") - .show(); + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) { + match self.copy_entry(*id) { + Ok((new_id, contents, mime)) => { + if new_id != *id { + entries[idx] = ( + new_id, + entries[idx].1.clone(), + entries[idx].2.clone(), + ); + } + let opts = Options::new(); + let mime_type = match mime { + Some(ref m) if m == "text/plain" => MimeType::Text, + Some(ref m) => { + MimeType::Specific(m.clone().to_owned()) + }, + None => MimeType::Text, + }; + let copy_result = opts.copy( + Source::Bytes(contents.clone().into()), + mime_type, + ); + match copy_result { + Ok(()) => { + let _ = Notification::new() + .summary("Stash") + .body("Copied entry to clipboard") + .show(); + }, + Err(e) => { + log::error!( + "Failed to copy entry to clipboard: {e}" + ); + let _ = Notification::new() + .summary("Stash") + .body(&format!( + "Failed to copy to clipboard: {e}" + )) + .show(); + }, + } }, Err(e) => { - log::error!("Failed to copy entry to clipboard: {e}"); + log::error!("Failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") - .body(&format!("Failed to copy to clipboard: {e}")) + .body(&format!("Failed to fetch entry: {e}")) .show(); }, } } - } }, (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - if let Some(idx) = state.selected() { - if let Some((id, ..)) = entries.get(idx) { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) { // Delete entry from DB self .conn @@ -345,12 +359,10 @@ impl SqliteClipboardDb { .body("Deleted entry") .show(); } - } }, _ => {}, } } - } } Ok(()) })(); diff --git a/src/db/mod.rs b/src/db/mod.rs index 469ec2a..b5445a7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -89,6 +89,10 @@ pub trait ClipboardDb { ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; fn delete_entries(&self, input: impl Read) -> Result; + fn copy_entry( + &self, + id: i64, + ) -> Result<(i64, Vec, Option), StashError>; fn next_sequence(&self) -> i64; } @@ -149,17 +153,44 @@ impl SqliteClipboardDb { ) .map_err(|e| StashError::Store(e.to_string().into()))?; + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contents BLOB NOT NULL, + mime TEXT, + content_hash INTEGER, + last_accessed INTEGER DEFAULT (CAST(strftime('%s', 'now') AS \ + INTEGER)) + );", + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + // Add content_hash column if it doesn't exist // Migration MUST be done to avoid breaking existing installations. let _ = conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []); + // Add last_accessed column if it doesn't exist + let _ = conn.execute( + "ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER DEFAULT \ + (CAST(strftime('%s', 'now') AS INTEGER))", + [], + ); + // Create index for content_hash if it doesn't exist let _ = conn.execute( "CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)", [], ); + // Create index for last_accessed if it doesn't exist + let _ = conn.execute( + "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ + clipboard(last_accessed)", + [], + ); + // Initialize Wayland state in background thread. This will be used to track // focused window state. #[cfg(feature = "use-toplevel")] @@ -172,7 +203,10 @@ impl SqliteClipboardDb { pub fn list_json(&self) -> Result { let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -243,11 +277,11 @@ impl ClipboardDb for SqliteClipboardDb { let regex = load_sensitive_regex(); if let Some(re) = regex { // Only check text data - if let Ok(s) = std::str::from_utf8(&buf) { - if re.is_match(s) { - warn!("Clipboard entry matches sensitive regex, skipping store."); - return Err(StashError::Store("Filtered by sensitive regex".into())); - } + if let Ok(s) = std::str::from_utf8(&buf) + && re.is_match(s) + { + warn!("Clipboard entry matches sensitive regex, skipping store."); + return Err(StashError::Store("Filtered by sensitive regex".into())); } } @@ -317,6 +351,8 @@ impl ClipboardDb for SqliteClipboardDb { let max_i64 = i64::try_from(max).unwrap_or(i64::MAX); if count > max_i64 { let to_delete = count - max_i64; + + #[allow(clippy::useless_conversion)] self .conn .execute( @@ -369,7 +405,10 @@ impl ClipboardDb for SqliteClipboardDb { ) -> Result { let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -476,6 +515,48 @@ impl ClipboardDb for SqliteClipboardDb { Ok(deleted) } + fn copy_entry( + &self, + id: i64, + ) -> Result<(i64, Vec, Option), StashError> { + let (contents, mime, content_hash): (Vec, Option, Option) = + self + .conn + .query_row( + "SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + if let Some(hash) = content_hash { + let most_recent_id: Option = self + .conn + .query_row( + "SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \ + = (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \ + ?1)", + params![hash], + |row| row.get(0), + ) + .optional() + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + if most_recent_id != Some(id) { + self + .conn + .execute( + "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \ + AS INTEGER) WHERE id = ?1", + params![id], + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + } + } + + Ok((id, contents, mime)) + } + fn next_sequence(&self) -> i64 { match self .conn @@ -693,11 +774,11 @@ fn get_focused_window_app() -> Option { } // Fallback: Check WAYLAND_CLIENT_NAME environment variable - if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") { - if !client.is_empty() { - debug!("Found WAYLAND_CLIENT_NAME: {client}"); - return Some(client); - } + if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") + && !client.is_empty() + { + debug!("Found WAYLAND_CLIENT_NAME: {client}"); + return Some(client); } debug!("No focused window detection method worked"); @@ -717,19 +798,17 @@ fn get_recently_active_excluded_app( if let Ok(entries) = std::fs::read_dir(proc_dir) { for entry in entries.flatten() { - if let Ok(pid) = entry.file_name().to_string_lossy().parse::() { - if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { - let process_name = comm.trim(); + if let Ok(pid) = entry.file_name().to_string_lossy().parse::() + && let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) + { + let process_name = comm.trim(); - // Check process name against exclusion list - if app_matches_exclusion(process_name, excluded_apps) - && has_recent_activity(pid) - { - candidates.push(( - process_name.to_string(), - get_process_activity_score(pid), - )); - } + // Check process name against exclusion list + if app_matches_exclusion(process_name, excluded_apps) + && has_recent_activity(pid) + { + candidates + .push((process_name.to_string(), get_process_activity_score(pid))); } } } @@ -763,15 +842,13 @@ fn has_recent_activity(pid: u32) -> bool { // Check /proc/PID/io for recent I/O activity if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { - if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { - if let Some(value_str) = line.split(':').nth(1) { - if let Ok(value) = value_str.trim().parse::() { - if value > 1024 * 1024 { - // 1MB threshold - return true; - } - } - } + if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) + && let Some(value_str) = line.split(':').nth(1) + && let Ok(value) = value_str.trim().parse::() + && value > 1024 * 1024 + { + // 1MB threshold + return true; } } } @@ -786,24 +863,22 @@ fn get_process_activity_score(pid: u32) -> u64 { // Add CPU time to score if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let fields: Vec<&str> = stat.split_whitespace().collect(); - if fields.len() > 14 { - if let (Ok(utime), Ok(stime)) = + if fields.len() > 14 + && let (Ok(utime), Ok(stime)) = (fields[13].parse::(), fields[14].parse::()) - { - score += utime + stime; - } + { + score += utime + stime; } } // Add I/O activity to score if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { - if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { - if let Some(value_str) = line.split(':').nth(1) { - if let Ok(value) = value_str.trim().parse::() { - score += value / 1024; // convert to KB - } - } + if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) + && let Some(value_str) = line.split(':').nth(1) + && let Ok(value) = value_str.trim().parse::() + { + score += value / 1024; // convert to KB } } } @@ -834,11 +909,11 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { } else if excluded.contains('*') { // Simple wildcard matching let pattern = excluded.replace('*', ".*"); - if let Ok(regex) = regex::Regex::new(&pattern) { - if regex.is_match(app_name) { - debug!("Matched wildcard pattern: {app_name} matches {excluded}"); - return true; - } + if let Ok(regex) = regex::Regex::new(&pattern) + && regex.is_match(app_name) + { + debug!("Matched wildcard pattern: {app_name} matches {excluded}"); + return true; } } } diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 016d609..17e655f 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -33,12 +33,11 @@ pub fn init_wayland_state() { /// Get the currently focused window application name using Wayland protocols pub fn get_focused_window_app() -> Option { // Try Wayland protocol first - if let Ok(focused) = FOCUSED_APP.lock() { - if let Some(ref app) = *focused { + if let Ok(focused) = FOCUSED_APP.lock() + && let Some(ref app) = *focused { debug!("Found focused app via Wayland protocol: {app}"); return Some(app.clone()); } - } debug!("No focused window detection method worked"); None @@ -81,12 +80,10 @@ impl Dispatch for AppState { interface, version: _, } = event - { - if interface == "zwlr_foreign_toplevel_manager_v1" { + && interface == "zwlr_foreign_toplevel_manager_v1" { let _manager: ZwlrForeignToplevelManagerV1 = registry.bind(name, 1, qh, ()); } - } } fn event_created_child( @@ -155,12 +152,10 @@ impl Dispatch for AppState { // Update focused app to the `app_id` of this handle if let (Ok(apps), Ok(mut focused)) = (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) - { - if let Some(app_id) = apps.get(&handle_id) { + && let Some(app_id) = apps.get(&handle_id) { debug!("Setting focused app to: {app_id}"); *focused = Some(app_id.clone()); } - } } }, zwlr_foreign_toplevel_handle_v1::Event::Closed => {