From 71fc1ff40f5e71dd5f946e8c51e0a8b032d04bcf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 14:32:00 +0300 Subject: [PATCH 1/9] db: add `expires_at column and expiration management methods Schema v4: add expires_at REAL column with partial index for NULL values Other relevant methods that were added: - `now()` for Unix timestamp with sub-second precision - `cleanup_expired()` to remove all expired entries - `get_expired_entries()` for for diagnostic output (`stash list --expired`) - `get_next_expiration()` for heap initialization - `set_expiration()` to update expiration timestamp This feature has proven larger than I had anticipated (and hoped) but that's the reality of dealing with databases. Some of the methods are slightly redundant but it helps keep tracing the code manageable and semantically correct. We'll probably not regret those later. Probably. Signed-off-by: NotAShelf Change-Id: Ie9e5b0767673e74389b8e59c466afd946a6a6964 --- Cargo.lock | 35 ++++++++------ Cargo.toml | 3 +- src/db/mod.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3b5980..26321a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "ident_case" version = "1.0.1" @@ -1121,9 +1127,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -2128,6 +2134,7 @@ dependencies = [ "ctrlc", "dirs", "env_logger", + "humantime", "imagesize", "inquire", "libc", @@ -2561,18 +2568,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -2583,9 +2590,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2593,9 +2600,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -2606,9 +2613,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] @@ -2924,9 +2931,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wl-clipboard-rs" diff --git a/Cargo.toml b/Cargo.toml index 94ec687..cd21891 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.90" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] @@ -22,6 +22,7 @@ crossterm = "0.29.0" ctrlc = "3.5.1" dirs = "6.0.0" env_logger = "0.11.8" +humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.2", default-features = false, features = [ "crossterm", diff --git a/src/db/mod.rs b/src/db/mod.rs index 97e2bb3..8c98ddd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -256,6 +256,46 @@ impl SqliteClipboardDb { })?; } + // Add expires_at column if it doesn't exist (v4) + if schema_version < 4 { + let has_expires_at: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("expires_at")) + }, + ) + .unwrap_or(false); + + if !has_expires_at { + tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add expires_at column: {e}").into(), + ) + })?; + } + + // Create partial index for expires_at (only index non-NULL values) + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \ + WHERE expires_at IS NOT NULL", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create expires_at index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 4", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + tx.commit().map_err(|e| { StashError::Store( format!("Failed to commit migration transaction: {e}").into(), @@ -653,6 +693,93 @@ impl ClipboardDb for SqliteClipboardDb { } } +impl SqliteClipboardDb { + /// Get current Unix timestamp with sub-second precision + pub fn now() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64() + } + + /// Clean up all expired entries. Returns count deleted. + pub fn cleanup_expired(&self) -> Result { + let now = Self::now(); + self + .conn + .execute( + "DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \ + ?1", + [now], + ) + .map_err(|e| StashError::Trim(e.to_string().into())) + } + + /// Get expired entries (for --expired flag diagnostic output) + pub fn get_expired_entries( + &self, + ) -> Result, Option)>, StashError> { + let now = Self::now(); + let mut stmt = self + .conn + .prepare( + "SELECT id, contents, mime FROM clipboard WHERE expires_at IS NOT \ + NULL AND expires_at <= ?1 ORDER BY expires_at ASC", + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut rows = stmt + .query([now]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut entries = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + let id: i64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + entries.push((id, contents, mime)); + } + Ok(entries) + } + + /// Get the earliest expiration (timestamp, id) for heap initialization + pub fn get_next_expiration(&self) -> Result, StashError> { + match self.conn.query_row( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \ + ORDER BY expires_at ASC LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) { + Ok(result) => Ok(Some(result)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(StashError::Store(e.to_string().into())), + } + } + + /// Set expiration timestamp for an entry + pub fn set_expiration( + &self, + id: i64, + expires_at: f64, + ) -> Result<(), StashError> { + self + .conn + .execute( + "UPDATE clipboard SET expires_at = ?2 WHERE id = ?1", + params![id, expires_at], + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + Ok(()) + } +} + /// Try to load a sensitive regex from systemd credential or env. /// /// # Returns From dd7a55c7604f037ace290ed204e704eff097127e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 15:12:33 +0300 Subject: [PATCH 2/9] watch: implement expiration queue w/ sub-second precision This adds a Neg wrapper struct for min-heap behaviour on BinaryHeap which has proven *really* valuable. Also modify `watch()` to take the `expire_after` argument for various new features. See my previous commit for what is actually new. Signed-off-by: NotAShelf Change-Id: I8705d404eae5d93ad48f738a24f698196a6a6964 --- src/commands/watch.rs | 171 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 4 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index ce2acf7..e7ac13e 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,5 +1,5 @@ use std::{ - collections::hash_map::DefaultHasher, + collections::{BinaryHeap, hash_map::DefaultHasher}, hash::{Hash, Hasher}, io::Read, time::Duration, @@ -10,12 +10,89 @@ use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; use crate::db::{ClipboardDb, SqliteClipboardDb}; +/// Wrapper to provide Ord implementation for f64 by negating values. +/// This allows BinaryHeap (which is a max-heap) to function as a min-heap. +#[derive(Debug, Clone, Copy)] +struct Neg(f64); + +impl Neg { + fn inner(&self) -> f64 { + self.0 + } +} + +impl std::cmp::PartialEq for Neg { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl std::cmp::Eq for Neg {} + +impl std::cmp::PartialOrd for Neg { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for Neg { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reverse ordering for min-heap behavior + other + .0 + .partial_cmp(&self.0) + .unwrap_or(std::cmp::Ordering::Equal) + } +} + +/// Min-heap for tracking entry expirations with sub-second precision. +/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior. +#[derive(Debug, Default)] +struct ExpirationQueue { + heap: BinaryHeap<(Neg, i64)>, +} + +impl ExpirationQueue { + /// Create a new empty expiration queue + fn new() -> Self { + Self { + heap: BinaryHeap::new(), + } + } + + /// Push a new expiration into the queue + fn push(&mut self, expires_at: f64, id: i64) { + self.heap.push((Neg(expires_at), id)); + } + + /// Peek at the next expiration timestamp without removing it + fn peek_next(&self) -> Option { + self.heap.peek().map(|(neg, _)| neg.inner()) + } + + /// Remove and return all entries that have expired by `now` + fn pop_expired(&mut self, now: f64) -> Vec { + let mut expired = Vec::new(); + while let Some((neg_exp, id)) = self.heap.peek() { + let expires_at = neg_exp.inner(); + if expires_at <= now { + expired.push(*id); + self.heap.pop(); + } else { + break; + } + } + expired + } +} + pub trait WatchCommand { fn watch( &self, max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], + expire_after: Option, ); } @@ -25,10 +102,52 @@ impl WatchCommand for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], + expire_after: Option, ) { smol::block_on(async { log::info!("Starting clipboard watch daemon"); + // Cleanup any already-expired entries on startup + if let Ok(count) = self.cleanup_expired() { + if count > 0 { + log::info!("Cleaned up {} expired entries on startup", count); + } + } + + // Build expiration queue from existing entries + let mut exp_queue = ExpirationQueue::new(); + if let Ok(Some((expires_at, id))) = self.get_next_expiration() { + exp_queue.push(expires_at, id); + // Load remaining expirations + let mut stmt = self + .conn + .prepare( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \ + NULL ORDER BY expires_at ASC", + ) + .ok(); + if let Some(ref mut stmt) = stmt { + let mut rows = stmt.query([]).ok(); + if let Some(ref mut rows) = rows { + while let Ok(Some(row)) = rows.next() { + if let (Ok(exp), Ok(row_id)) = + (row.get::<_, f64>(0), row.get::<_, i64>(1)) + { + // Skip first entry which is already added + if exp_queue + .heap + .iter() + .any(|(_, existing_id)| *existing_id == row_id) + { + continue; + } + exp_queue.push(exp, row_id); + } + } + } + } + } + // We use hashes for comparison instead of storing full contents let mut last_hash: Option = None; let mut buf = Vec::with_capacity(4096); @@ -53,6 +172,39 @@ impl WatchCommand for SqliteClipboardDb { } loop { + // Process any pending expirations + if let Some(next_exp) = exp_queue.peek_next() { + let now = SqliteClipboardDb::now(); + if next_exp <= now { + // Expired entries to process + let expired_ids = exp_queue.pop_expired(now); + for id in expired_ids { + // Verify entry still exists (handles stale heap entries) + let exists = self + .conn + .query_row( + "SELECT 1 FROM clipboard WHERE id = ?1", + [id], + |_| Ok(()), + ) + .is_ok(); + if exists { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", [id]) + .ok(); + log::info!("Entry {id} expired and removed"); + } + } + } else { + // Sleep precisely until next expiration (sub-second precision) + let sleep_duration = next_exp - now; + Timer::after(Duration::from_secs_f64(sleep_duration)).await; + continue; // Skip normal poll, process expirations first + } + } + + // Normal clipboard polling match get_contents( ClipboardType::Regular, Seat::Unspecified, @@ -70,16 +222,23 @@ impl WatchCommand for SqliteClipboardDb { if !buf.is_empty() { let current_hash = hash_contents(&buf); if last_hash != Some(current_hash) { - let id = self.next_sequence(); match self.store_entry( &buf[..], max_dedupe_search, max_items, Some(excluded_apps), ) { - Ok(_) => { + Ok(id) => { log::info!("Stored new clipboard entry (id: {id})"); last_hash = Some(current_hash); + + // Set expiration if configured + if let Some(duration) = expire_after { + let expires_at = + SqliteClipboardDb::now() + duration.as_secs_f64(); + self.set_expiration(id, expires_at).ok(); + exp_queue.push(expires_at, id); + } }, Err(crate::db::StashError::ExcludedByApp(_)) => { log::info!("Clipboard entry excluded by app filter"); @@ -106,7 +265,11 @@ impl WatchCommand for SqliteClipboardDb { } }, } - Timer::after(Duration::from_millis(500)).await; + + // Normal poll interval (only if no expirations pending) + if exp_queue.peek_next().is_none() { + Timer::after(Duration::from_millis(500)).await; + } } }); } From f4936e56ffc311ca4fe22d986d1304c678282c28 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 15:14:16 +0300 Subject: [PATCH 3/9] cli: add `--expire-after` flag to watch and `--expired` flag to list Signed-off-by: NotAShelf Change-Id: I833e7bfaecb5e3254d2ea16f2b880e246a6a6964 --- src/main.rs | 100 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/src/main.rs b/src/main.rs index 28f9fb0..1bf87ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,11 @@ use std::{ env, io::{self, IsTerminal}, path::PathBuf, + time::Duration, }; use clap::{CommandFactory, Parser, Subcommand}; +use humantime::parse_duration; use inquire::Confirm; mod commands; @@ -71,6 +73,10 @@ enum Command { /// Output format: "tsv" (default) or "json" #[arg(long, value_parser = ["tsv", "json"])] format: Option, + + /// Show only expired entries (diagnostic, does not remove them) + #[arg(long)] + expired: bool, }, /// Decode and output clipboard entry by id @@ -111,7 +117,11 @@ enum Command { }, /// Start a process to watch clipboard for changes and store automatically. - Watch, + Watch { + /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). + #[arg(long, value_parser = parse_duration)] + expire_after: Option, + }, } fn report_error( @@ -186,40 +196,67 @@ fn main() -> color_eyre::eyre::Result<()> { "failed to store entry", ); }, - Some(Command::List { format }) => { - match format.as_deref() { - Some("tsv") => { - report_error( - db.list(io::stdout(), cli.preview_width), - "failed to list entries", - ); - }, - Some("json") => { - match db.list_json() { - Ok(json) => { - println!("{json}"); - }, - Err(e) => { - log::error!("failed to list entries as JSON: {e}"); - }, + Some(Command::List { format, expired }) => { + if expired { + // Diagnostic mode: show expired entries only (does not cleanup) + match db.get_expired_entries() { + Ok(entries) => { + for (id, contents, mime) in entries { + let preview = db::preview_entry( + &contents, + mime.as_deref(), + cli.preview_width, + ); + println!("{id}\t{preview}"); + } + }, + Err(e) => { + log::error!("failed to list expired entries: {e}"); + }, + } + } else { + // Normal list mode + // Cleanup expired entries when daemon is not running + if let Ok(count) = db.cleanup_expired() { + if count > 0 { + log::info!("Cleaned up {} expired entries", count); } - }, - Some(other) => { - log::error!("unsupported format: {other}"); - }, - None => { - if std::io::stdout().is_terminal() { - report_error( - db.list_tui(cli.preview_width), - "failed to list entries in TUI", - ); - } else { + } + + match format.as_deref() { + Some("tsv") => { report_error( db.list(io::stdout(), cli.preview_width), "failed to list entries", ); - } - }, + }, + Some("json") => { + match db.list_json() { + Ok(json) => { + println!("{json}"); + }, + Err(e) => { + log::error!("failed to list entries as JSON: {e}"); + }, + } + }, + Some(other) => { + log::error!("unsupported format: {other}"); + }, + None => { + if std::io::stdout().is_terminal() { + report_error( + db.list_tui(cli.preview_width), + "failed to list entries in TUI", + ); + } else { + report_error( + db.list(io::stdout(), cli.preview_width), + "failed to list entries", + ); + } + }, + } } }, Some(Command::Decode { input }) => { @@ -334,7 +371,7 @@ fn main() -> color_eyre::eyre::Result<()> { } } }, - Some(Command::Watch) => { + Some(Command::Watch { expire_after }) => { db.watch( cli.max_dedupe_search, cli.max_items, @@ -342,6 +379,7 @@ fn main() -> color_eyre::eyre::Result<()> { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], + expire_after, ); }, From d40b547c07ddee30b4b8b058a88f2602f6a24940 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:07:30 +0300 Subject: [PATCH 4/9] db: add is_expired column and implement vacuum/stats commands Migrates schema to v5; `is_expired` column is added with partial index and `include_expired` parameter to `list_entries()` and `list_json()` methods. Also adds `vacuum()` and `stats()` methods for SQlite "administration", and removes `next_sequence()` from trait and impl. This has been a valuable addition to stash, as the database is now *less abstract* in the sense that user is made aware of its presence (stash wipe -> stash db wipe) and can modify it. Signed-off-by: NotAShelf Change-Id: Icfab67753d7f18e3798c0a930b16d05e6a6a6964 --- src/db/mod.rs | 177 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 122 insertions(+), 55 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 8c98ddd..f2048cd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -80,6 +80,7 @@ pub trait ClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result; fn decode_entry( &self, @@ -93,7 +94,6 @@ pub trait ClipboardDb { &self, id: i64, ) -> Result<(i64, Vec, Option), StashError>; - fn next_sequence(&self) -> i64; } #[derive(Serialize, Deserialize)] @@ -296,6 +296,49 @@ impl SqliteClipboardDb { })?; } + // Add is_expired column if it doesn't exist (v5) + if schema_version < 5 { + let has_is_expired: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("is_expired")) + }, + ) + .unwrap_or(false); + + if !has_is_expired { + tx.execute( + "ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to add is_expired column: {e}").into(), + ) + })?; + } + + // Create index for is_expired (for filtering) + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \ + WHERE is_expired = 1", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create is_expired index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 5", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + tx.commit().map_err(|e| { StashError::Store( format!("Failed to commit migration transaction: {e}").into(), @@ -311,13 +354,17 @@ impl SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn list_json(&self) -> Result { + pub fn list_json(&self, include_expired: bool) -> Result { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -526,13 +573,18 @@ impl ClipboardDb for SqliteClipboardDb { &self, mut out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -680,17 +732,6 @@ impl ClipboardDb for SqliteClipboardDb { Ok((id, contents, mime)) } - - fn next_sequence(&self) -> i64 { - match self - .conn - .query_row("SELECT MAX(id) FROM clipboard", [], |row| { - row.get::<_, Option>(0) - }) { - Ok(Some(max_id)) => max_id + 1, - Ok(None) | Err(_) => 1, - } - } } impl SqliteClipboardDb { @@ -715,40 +756,6 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Trim(e.to_string().into())) } - /// Get expired entries (for --expired flag diagnostic output) - pub fn get_expired_entries( - &self, - ) -> Result, Option)>, StashError> { - let now = Self::now(); - let mut stmt = self - .conn - .prepare( - "SELECT id, contents, mime FROM clipboard WHERE expires_at IS NOT \ - NULL AND expires_at <= ?1 ORDER BY expires_at ASC", - ) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query([now]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut entries = Vec::new(); - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - let id: i64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - entries.push((id, contents, mime)); - } - Ok(entries) - } - /// Get the earliest expiration (timestamp, id) for heap initialization pub fn get_next_expiration(&self) -> Result, StashError> { match self.conn.query_row( @@ -778,6 +785,66 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Store(e.to_string().into()))?; Ok(()) } + + /// Optimize database using VACUUM + pub fn vacuum(&self) -> Result<(), StashError> { + self + .conn + .execute("VACUUM", []) + .map_err(|e| StashError::Store(e.to_string().into()))?; + Ok(()) + } + + /// Get database statistics + pub fn stats(&self) -> Result { + let total: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let expired: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE is_expired = 1", + [], + |row| row.get(0), + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let active = total - expired; + + let with_expiration: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \ + (is_expired IS NULL OR is_expired = 0)", + [], + |row| row.get(0), + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + // Get database file size + let page_count: i64 = self + .conn + .query_row("PRAGMA page_count", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let page_size: i64 = self + .conn + .query_row("PRAGMA page_size", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let size_bytes = page_count * page_size; + let size_mb = size_bytes as f64 / 1024.0 / 1024.0; + + Ok(format!( + "Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \ + {active}\nExpired: {expired}\nWith TTL: \ + {with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \ + ({size_bytes} bytes)\nPages: {page_count}\nPage size: \ + {page_size} bytes" + )) + } } /// Try to load a sensitive regex from systemd credential or env. From b070d4d93d158c7a9a50ed44f4300e66f1dd86bb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:52:29 +0300 Subject: [PATCH 5/9] watch: implement soft-delete behaviour for expired entries The previous `--expire-after` flag behave more like *delete* after rather than *expire*. This fixes that, and changes the behaviour to excluding expired entries from list commands and already-marked expired entries from expiration queue. Updates log messages accordingly. Signed-off-by: NotAShelf Change-Id: Ib162dff3a76e23edcdfbd1af13b01b916a6a6964 --- src/commands/watch.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index e7ac13e..018eeca 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -107,23 +107,17 @@ impl WatchCommand for SqliteClipboardDb { smol::block_on(async { log::info!("Starting clipboard watch daemon"); - // Cleanup any already-expired entries on startup - if let Ok(count) = self.cleanup_expired() { - if count > 0 { - log::info!("Cleaned up {} expired entries on startup", count); - } - } - // Build expiration queue from existing entries let mut exp_queue = ExpirationQueue::new(); if let Ok(Some((expires_at, id))) = self.get_next_expiration() { exp_queue.push(expires_at, id); - // Load remaining expirations + // Load remaining expirations (exclude already-marked expired entries) let mut stmt = self .conn .prepare( "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \ - NULL ORDER BY expires_at ASC", + NULL AND (is_expired IS NULL OR is_expired = 0) ORDER BY \ + expires_at ASC", ) .ok(); if let Some(ref mut stmt) = stmt { @@ -189,11 +183,15 @@ impl WatchCommand for SqliteClipboardDb { ) .is_ok(); if exists { + // Mark as expired instead of deleting self .conn - .execute("DELETE FROM clipboard WHERE id = ?1", [id]) + .execute( + "UPDATE clipboard SET is_expired = 1 WHERE id = ?1", + [id], + ) .ok(); - log::info!("Entry {id} expired and removed"); + log::info!("Entry {id} marked as expired"); } } } else { From 2e555ee043366bd1cdc7aa059508c46dc6ebc746 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:53:44 +0300 Subject: [PATCH 6/9] commands/list: add include_expired parameter for filtering Signed-off-by: NotAShelf Change-Id: Ia1ab13345cfa5e2cf9a92f8b32a6a9826a6a6964 --- src/commands/list.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 1afab2a..25903f3 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -6,8 +6,12 @@ use unicode_width::UnicodeWidthStr; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait ListCommand { - fn list(&self, out: impl Write, preview_width: u32) - -> Result<(), StashError>; + fn list( + &self, + out: impl Write, + preview_width: u32, + include_expired: bool, + ) -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { @@ -15,14 +19,21 @@ impl ListCommand for SqliteClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result<(), StashError> { - self.list_entries(out, preview_width).map(|_| ()) + self + .list_entries(out, preview_width, include_expired) + .map(|_| ()) } } impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] - pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { + pub fn list_tui( + &self, + preview_width: u32, + include_expired: bool, + ) -> Result<(), StashError> { use std::io::stdout; use crossterm::{ @@ -53,12 +64,16 @@ impl SqliteClipboardDb { use wl_clipboard_rs::copy::{MimeType, Options, Source}; // Query entries from DB + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \ + id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY last_accessed DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \ - DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) From 5731fb08a523a3b2f210ea17d0d18bb8dfec8750 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:54:22 +0300 Subject: [PATCH 7/9] cli: add db subcommand Adds a `db` subcommand with `DbAction` for three new database operations: wipe, vacuum and stats. We can extend this database later, but this is a very good start for now and plays nicely with the `--expired` flag. This soft-deprecates `stash wipe` in favor of a `stash db wipe` with the addition of a new `--expired` flag that wipes the expired entries only. The `list` subcommand has also been refactored to allow for a similar `--expired` flag that lists only expired entries. Signed-off-by: NotAShelf Change-Id: I34107880185d231d207b0dab7782d5d96a6a6964 --- src/main.rs | 174 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 118 insertions(+), 56 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1bf87ba..aca9838 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,12 +99,21 @@ enum Command { }, /// Wipe all clipboard history + /// + /// DEPRECATED: Use `stash db wipe` instead + #[command(hide = true)] Wipe { /// Ask for confirmation before wiping #[arg(long)] ask: bool, }, + /// Database management operations + Db { + #[command(subcommand)] + action: DbAction, + }, + /// Import clipboard data from stdin (default: TSV format) Import { /// Explicitly specify format: "tsv" (default) @@ -124,6 +133,26 @@ enum Command { }, } +#[derive(Subcommand)] +enum DbAction { + /// Wipe database entries + Wipe { + /// Only wipe expired entries instead of all entries + #[arg(long)] + expired: bool, + + /// Ask for confirmation before wiping + #[arg(long)] + ask: bool, + }, + + /// Optimize database using VACUUM + Vacuum, + + /// Show database statistics + Stats, +} + fn report_error( result: Result, context: &str, @@ -197,66 +226,39 @@ fn main() -> color_eyre::eyre::Result<()> { ); }, Some(Command::List { format, expired }) => { - if expired { - // Diagnostic mode: show expired entries only (does not cleanup) - match db.get_expired_entries() { - Ok(entries) => { - for (id, contents, mime) in entries { - let preview = db::preview_entry( - &contents, - mime.as_deref(), - cli.preview_width, - ); - println!("{id}\t{preview}"); - } - }, - Err(e) => { - log::error!("failed to list expired entries: {e}"); - }, - } - } else { - // Normal list mode - // Cleanup expired entries when daemon is not running - if let Ok(count) = db.cleanup_expired() { - if count > 0 { - log::info!("Cleaned up {} expired entries", count); + match format.as_deref() { + Some("tsv") => { + report_error( + db.list(io::stdout(), cli.preview_width, expired), + "failed to list entries", + ); + }, + Some("json") => { + match db.list_json(expired) { + Ok(json) => { + println!("{json}"); + }, + Err(e) => { + log::error!("failed to list entries as JSON: {e}"); + }, } - } - - match format.as_deref() { - Some("tsv") => { + }, + Some(other) => { + log::error!("unsupported format: {other}"); + }, + None => { + if std::io::stdout().is_terminal() { report_error( - db.list(io::stdout(), cli.preview_width), + db.list_tui(cli.preview_width, expired), + "failed to list entries in TUI", + ); + } else { + report_error( + db.list(io::stdout(), cli.preview_width, expired), "failed to list entries", ); - }, - Some("json") => { - match db.list_json() { - Ok(json) => { - println!("{json}"); - }, - Err(e) => { - log::error!("failed to list entries as JSON: {e}"); - }, - } - }, - Some(other) => { - log::error!("unsupported format: {other}"); - }, - None => { - if std::io::stdout().is_terminal() { - report_error( - db.list_tui(cli.preview_width), - "failed to list entries in TUI", - ); - } else { - report_error( - db.list(io::stdout(), cli.preview_width), - "failed to list entries", - ); - } - }, - } + } + }, } }, Some(Command::Decode { input }) => { @@ -324,6 +326,10 @@ fn main() -> color_eyre::eyre::Result<()> { } }, Some(Command::Wipe { ask }) => { + eprintln!( + "Warning: The 'stash wipe' command is deprecated. Use 'stash db \ + wipe' instead." + ); let mut should_proceed = true; if ask { should_proceed = Confirm::new( @@ -341,6 +347,62 @@ fn main() -> color_eyre::eyre::Result<()> { } }, + Some(Command::Db { action }) => { + match action { + DbAction::Wipe { expired, ask } => { + let mut should_proceed = true; + if ask { + let message = if expired { + "Are you sure you want to wipe all expired clipboard entries?" + } else { + "Are you sure you want to wipe ALL clipboard history?" + }; + should_proceed = Confirm::new(message) + .with_default(false) + .prompt() + .unwrap_or(false); + if !should_proceed { + log::info!("db wipe command aborted by user."); + } + } + if should_proceed { + if expired { + match db.cleanup_expired() { + Ok(count) => { + log::info!("Wiped {} expired entries", count); + }, + Err(e) => { + log::error!("failed to wipe expired entries: {e}"); + }, + } + } else { + report_error(db.wipe(), "failed to wipe database"); + } + } + }, + DbAction::Vacuum => { + match db.vacuum() { + Ok(()) => { + log::info!("Database optimized successfully"); + }, + Err(e) => { + log::error!("failed to vacuum database: {e}"); + }, + } + }, + DbAction::Stats => { + match db.stats() { + Ok(stats) => { + println!("{}", stats); + }, + Err(e) => { + log::error!("failed to get database stats: {e}"); + }, + } + }, + } + }, + Some(Command::Import { r#type, ask }) => { let mut should_proceed = true; if ask { From b00e9b5f3a8e4135b81820dbd1407aa0123b7822 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 18:02:38 +0300 Subject: [PATCH 8/9] watch: clear clipboard when expired entry content matches current clipboard Signed-off-by: NotAShelf Change-Id: I4bede5db16cea993ed8e8591e8d198d56a6a6964 --- src/commands/watch.rs | 71 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 018eeca..54706bb 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -6,12 +6,19 @@ use std::{ }; use smol::Timer; -use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; +use wl_clipboard_rs::{ + copy::{MimeType as CopyMimeType, Options, Source}, + paste::{ClipboardType, Seat, get_contents}, +}; use crate::db::{ClipboardDb, SqliteClipboardDb}; -/// Wrapper to provide Ord implementation for f64 by negating values. -/// This allows BinaryHeap (which is a max-heap) to function as a min-heap. +/// Wrapper to provide [`Ord`] implementation for `f64` by negating values. +/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. +/// Also see: +/// - +/// - +/// - #[derive(Debug, Clone, Copy)] struct Neg(f64); @@ -173,17 +180,18 @@ impl WatchCommand for SqliteClipboardDb { // Expired entries to process let expired_ids = exp_queue.pop_expired(now); for id in expired_ids { - // Verify entry still exists (handles stale heap entries) - let exists = self + // Verify entry still exists and get its content_hash + let expired_hash: Option = self .conn .query_row( - "SELECT 1 FROM clipboard WHERE id = ?1", + "SELECT content_hash FROM clipboard WHERE id = ?1", [id], - |_| Ok(()), + |row| row.get(0), ) - .is_ok(); - if exists { - // Mark as expired instead of deleting + .ok(); + + if let Some(stored_hash) = expired_hash { + // Mark as expired self .conn .execute( @@ -192,13 +200,52 @@ impl WatchCommand for SqliteClipboardDb { ) .ok(); log::info!("Entry {id} marked as expired"); + + // Check if this expired entry is currently in the clipboard + if let Ok((mut reader, _)) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + let mut current_buf = Vec::new(); + if reader.read_to_end(&mut current_buf).is_ok() + && !current_buf.is_empty() + { + let current_hash = hash_contents(¤t_buf); + // Compare as i64 (database stores as i64) + if current_hash as i64 == stored_hash { + // Clear the clipboard since expired content is still + // there + let mut opts = Options::new(); + opts.clipboard( + wl_clipboard_rs::copy::ClipboardType::Regular, + ); + if opts + .copy( + Source::Bytes(Vec::new().into()), + CopyMimeType::Autodetect, + ) + .is_ok() + { + log::info!( + "Cleared clipboard containing expired entry {id}" + ); + last_hash = None; // reset tracked hash + } else { + log::warn!( + "Failed to clear clipboard for expired entry {id}" + ); + } + } + } + } } } } else { - // Sleep precisely until next expiration (sub-second precision) + // Sleep *precisely* until next expiration let sleep_duration = next_exp - now; Timer::after(Duration::from_secs_f64(sleep_duration)).await; - continue; // Skip normal poll, process expirations first + continue; // skip normal poll, process expirations first } } From e185ecd32a7bf9151fccdb8bb66bfba27edabeda Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 18:43:17 +0300 Subject: [PATCH 9/9] docs: document entry expiry features for `stash watch` & db cmds Signed-off-by: NotAShelf Change-Id: I60fe5afdb6e903b96023ca420bb7902d6a6a6964 --- README.md | 132 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c3fd56c..72ea591 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ with many features such as but not necessarily limited to: - Import clipboard history from TSV (e.g., from `cliphist list`) - Image preview (shows dimensions and format) - Text previews with customizable width -- Deduplication and entry limit control +- De-duplication, whitespace prevention and entry limit control - Automatic clipboard monitoring with `stash watch` + - Configurable auto-expiry of old entries in watch mode as a safety buffer - Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`) - Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering by application (see below) @@ -141,7 +142,7 @@ Commands: list List clipboard history decode Decode and output clipboard entry by id delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly - wipe Wipe all clipboard history + db Database management operations import Import clipboard data from stdin (default: TSV format) watch Start a process to watch clipboard for changes and store automatically help Print this message or the help of the given subcommand(s) @@ -154,7 +155,7 @@ Options: --preview-width Maximum width (in characters) for clipboard entry previews in list output [default: 100] --db-path - Path to the `SQLite` clipboard database file + Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=] --excluded-apps Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=] --ask @@ -188,6 +189,11 @@ and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will display entries in Cliphist-compatible TSV format in Bash scripts. You may also enforce the output format with `stash list --format `. +You may also view your clipboard _with the addition of expired entries_, i.e., +entries that have reached their TTL and are marked as expired, using the +`--expired` flag as `stash list --expired`. Expired entries are not cleaned up +when using this flag, allowing you to inspect them before running cleanup. + ### Decode an entry by ID ```bash @@ -219,10 +225,33 @@ stash delete --type id < ids.txt ### Wipe all entries +> [!WARNING] +> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe` +> instead. + ```bash stash wipe ``` +### Database management + +Stash provides a `db` subcommand for database maintenance operations: + +```bash +stash db wipe [--expired] [--ask] +stash db vacuum +stash db stats +``` + +- `stash db wipe`: Remove all entries from the database. Use `--expired` to only + wipe expired entries instead of all entries. Requires `--ask` confirmation by + default. +- `stash db vacuum`: Optimize the database using SQLite's VACUUM command, + reclaiming space and improving performance. +- `stash db stats`: Display database statistics including total/active/expired + entry counts, storage size, and page information. This is provided purely for + convenience and the rule of the cool. + ### Watch clipboard for changes and store automatically ```bash @@ -235,13 +264,16 @@ automatically. This is designed as an alternative to shelling out to premade Systemd service in `contrib/`. Packagers are encouraged to vendor the service unless adding their own. -> [!TIP] -> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility -> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you -> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs` -> wrappers on your system. In other words, you can use -> `wl-paste --watch stash store` as an alternative to `stash watch` if -> preferred. +#### Automatic Clipboard Clearing on Expiration + +When `stash watch` is running and a clipboard entry expires, Stash will detect +if the current clipboard still contains that expired content and automatically +clear it. This prevents stale data from remaining in your clipboard after an +entry has expired from history. + +> [!NOTE] +> This behavior only applies when the watch daemon is actively running. Manual +> expiration or deletion of entries will not clear the clipboard. ### Options @@ -406,6 +438,86 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! the packagers. While building from source, you may link `target/release/stash` manually. +### Entry Expiration + +Stash supports time-to-live (TTL) for clipboard entries. When an entry's +expiration time is reached, it is marked as expired rather than immediately +deleted. This allows for inspection of expired entries and automatic clipboard +cleanup. + +#### How Expiration Works + +When `stash watch` is running with `--expire-after`, it monitors the clipboard +and processes expired entries periodically. Upon expiration: + +1. The entry's `is_expired` flag is set to `1` in the database +2. If the current clipboard content matches the expired entry, Stash clears the + clipboard to prevent pasting stale data +3. Expired entries are excluded from normal list operations unless `--expired` + is specified + +> [!NOTE] +> By default, entries do not expire. Use `stash watch --expire-after DURATION` +> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL). + +#### Viewing Expired Entries + +Use `stash list --expired` to include expired entries in the output. This is +useful for: + +- Inspecting what has expired from your clipboard history +- Verifying that sensitive data has been properly expired +- Debugging expiration behavior + +```bash +# View all entries including expired ones +stash list --expired + +# View expired entries in JSON format +stash list --expired --format json +``` + +#### Cleaning Up Expired Entries + +The watch daemon automatically cleans up expired entries when it processes them. +For manual cleanup, use: + +```bash +# Remove all expired entries from the database +stash db wipe --expired +``` + +> [!NOTE] +> If you have a large number of expired entries, consider running +> `stash db vacuum` afterward to reclaim disk space. + +#### Automatic Clipboard Clearing + +When `stash watch` is running and an entry expires, Stash checks if the current +clipboard still contains that expired content. If it matches, Stash clears the +clipboard automatically. This prevents accidentally pasting outdated content. + +> [!TIP] +> This behavior only applies when the watch daemon is actively running. Manual +> expiration or deletion of entries will not clear the clipboard. + +#### Database Maintenance + +Stash uses SQLite for persistent storage. Over time, deleted entries and +fragmentation can affect performance. Use the `stash db` command to maintain +your database: + +- **Check statistics**: `stash db stats` shows entry counts and storage usage. + Use this to monitor growth and decide when to clean up. +- **Remove expired entries**: `stash db wipe --expired` removes entries that + have reached their TTL. The daemon normally handles this, but this is useful + for manual cleanup. +- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to + reclaim space and defragment the database. This is safe to run periodically. + +It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep +the database compact, especially after deleting many entries. + ## Attributions My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the