list: add content_hash and last_accessed tracking with de-duplication

Adds a `content_hash` column and index for deduplication, and a
`last_accessed` column & index for time tracking. We now de-duplicate on
copy by not copying if present, but instead bubbling up matching entry.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icbcdbd6ac28bbb21324785cae30911f96a6a6964
This commit is contained in:
raf 2026-01-20 10:14:32 +03:00
commit 59423f9ae4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 180 additions and 98 deletions

View file

@ -55,7 +55,10 @@ impl SqliteClipboardDb {
// Query entries from DB // Query entries from DB
let mut stmt = self let mut stmt = self
.conn .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()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt let mut rows = stmt
.query([]) .query([])
@ -242,8 +245,7 @@ impl SqliteClipboardDb {
if event::poll(std::time::Duration::from_millis(250)) if event::poll(std::time::Duration::from_millis(250))
.map_err(|e| StashError::ListDecode(e.to_string().into()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ && let Event::Key(key) = event::read()
if let Event::Key(key) = event::read()
.map_err(|e| StashError::ListDecode(e.to_string().into()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ {
match (key.code, key.modifiers) { match (key.code, key.modifiers) {
@ -275,29 +277,29 @@ impl SqliteClipboardDb {
state.select(Some(i)); state.select(Some(i));
}, },
(KeyCode::Enter, _) => { (KeyCode::Enter, _) => {
if let Some(idx) = state.selected() { if let Some(idx) = state.selected()
if let Some((id, ..)) = entries.get(idx) { && let Some((id, ..)) = entries.get(idx) {
// Fetch full contents for the selected entry match self.copy_entry(*id) {
let (contents, mime): (Vec<u8>, Option<String>) = self Ok((new_id, contents, mime)) => {
.conn if new_id != *id {
.query_row( entries[idx] = (
"SELECT contents, mime FROM clipboard WHERE id = ?1", new_id,
rusqlite::params![id], entries[idx].1.clone(),
|row| Ok((row.get(0)?, row.get(1)?)), entries[idx].2.clone(),
) );
.map_err(|e| { }
StashError::ListDecode(e.to_string().into())
})?;
// Copy to clipboard
let opts = Options::new(); let opts = Options::new();
// Default clipboard is regular, seat is default
let mime_type = match mime { let mime_type = match mime {
Some(ref m) if m == "text/plain" => MimeType::Text, Some(ref m) if m == "text/plain" => MimeType::Text,
Some(ref m) => MimeType::Specific(m.clone()), Some(ref m) => {
MimeType::Specific(m.clone().to_owned())
},
None => MimeType::Text, None => MimeType::Text,
}; };
let copy_result = opts let copy_result = opts.copy(
.copy(Source::Bytes(contents.clone().into()), mime_type); Source::Bytes(contents.clone().into()),
mime_type,
);
match copy_result { match copy_result {
Ok(()) => { Ok(()) => {
let _ = Notification::new() let _ = Notification::new()
@ -306,19 +308,31 @@ impl SqliteClipboardDb {
.show(); .show();
}, },
Err(e) => { Err(e) => {
log::error!("Failed to copy entry to clipboard: {e}"); log::error!(
"Failed to copy entry to clipboard: {e}"
);
let _ = Notification::new() let _ = Notification::new()
.summary("Stash") .summary("Stash")
.body(&format!("Failed to copy to clipboard: {e}")) .body(&format!(
"Failed to copy to clipboard: {e}"
))
.show();
},
}
},
Err(e) => {
log::error!("Failed to fetch entry {id}: {e}");
let _ = Notification::new()
.summary("Stash")
.body(&format!("Failed to fetch entry: {e}"))
.show(); .show();
}, },
} }
} }
}
}, },
(KeyCode::Char('D'), KeyModifiers::SHIFT) => { (KeyCode::Char('D'), KeyModifiers::SHIFT) => {
if let Some(idx) = state.selected() { if let Some(idx) = state.selected()
if let Some((id, ..)) = entries.get(idx) { && let Some((id, ..)) = entries.get(idx) {
// Delete entry from DB // Delete entry from DB
self self
.conn .conn
@ -345,13 +359,11 @@ impl SqliteClipboardDb {
.body("Deleted entry") .body("Deleted entry")
.show(); .show();
} }
}
}, },
_ => {}, _ => {},
} }
} }
} }
}
Ok(()) Ok(())
})(); })();

View file

@ -89,6 +89,10 @@ pub trait ClipboardDb {
) -> Result<(), StashError>; ) -> Result<(), StashError>;
fn delete_query(&self, query: &str) -> Result<usize, StashError>; fn delete_query(&self, query: &str) -> Result<usize, StashError>;
fn delete_entries(&self, input: impl Read) -> Result<usize, StashError>; fn delete_entries(&self, input: impl Read) -> Result<usize, StashError>;
fn copy_entry(
&self,
id: i64,
) -> Result<(i64, Vec<u8>, Option<String>), StashError>;
fn next_sequence(&self) -> i64; fn next_sequence(&self) -> i64;
} }
@ -149,17 +153,44 @@ impl SqliteClipboardDb {
) )
.map_err(|e| StashError::Store(e.to_string().into()))?; .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 // Add content_hash column if it doesn't exist
// Migration MUST be done to avoid breaking existing installations. // Migration MUST be done to avoid breaking existing installations.
let _ = let _ =
conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []); 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 // Create index for content_hash if it doesn't exist
let _ = conn.execute( let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)", "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 // Initialize Wayland state in background thread. This will be used to track
// focused window state. // focused window state.
#[cfg(feature = "use-toplevel")] #[cfg(feature = "use-toplevel")]
@ -172,7 +203,10 @@ impl SqliteClipboardDb {
pub fn list_json(&self) -> Result<String, StashError> { pub fn list_json(&self) -> Result<String, StashError> {
let mut stmt = self let mut stmt = self
.conn .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()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt let mut rows = stmt
.query([]) .query([])
@ -243,13 +277,13 @@ impl ClipboardDb for SqliteClipboardDb {
let regex = load_sensitive_regex(); let regex = load_sensitive_regex();
if let Some(re) = regex { if let Some(re) = regex {
// Only check text data // Only check text data
if let Ok(s) = std::str::from_utf8(&buf) { if let Ok(s) = std::str::from_utf8(&buf)
if re.is_match(s) { && re.is_match(s)
{
warn!("Clipboard entry matches sensitive regex, skipping store."); warn!("Clipboard entry matches sensitive regex, skipping store.");
return Err(StashError::Store("Filtered by sensitive regex".into())); return Err(StashError::Store("Filtered by sensitive regex".into()));
} }
} }
}
// Check if clipboard should be excluded based on running apps // Check if clipboard should be excluded based on running apps
if should_exclude_by_app(excluded_apps) { if should_exclude_by_app(excluded_apps) {
@ -317,6 +351,8 @@ impl ClipboardDb for SqliteClipboardDb {
let max_i64 = i64::try_from(max).unwrap_or(i64::MAX); let max_i64 = i64::try_from(max).unwrap_or(i64::MAX);
if count > max_i64 { if count > max_i64 {
let to_delete = count - max_i64; let to_delete = count - max_i64;
#[allow(clippy::useless_conversion)]
self self
.conn .conn
.execute( .execute(
@ -369,7 +405,10 @@ impl ClipboardDb for SqliteClipboardDb {
) -> Result<usize, StashError> { ) -> Result<usize, StashError> {
let mut stmt = self let mut stmt = self
.conn .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()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt let mut rows = stmt
.query([]) .query([])
@ -476,6 +515,48 @@ impl ClipboardDb for SqliteClipboardDb {
Ok(deleted) Ok(deleted)
} }
fn copy_entry(
&self,
id: i64,
) -> Result<(i64, Vec<u8>, Option<String>), StashError> {
let (contents, mime, content_hash): (Vec<u8>, Option<String>, Option<i64>) =
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<i64> = 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 { fn next_sequence(&self) -> i64 {
match self match self
.conn .conn
@ -693,12 +774,12 @@ fn get_focused_window_app() -> Option<String> {
} }
// Fallback: Check WAYLAND_CLIENT_NAME environment variable // Fallback: Check WAYLAND_CLIENT_NAME environment variable
if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") { if let Ok(client) = env::var("WAYLAND_CLIENT_NAME")
if !client.is_empty() { && !client.is_empty()
{
debug!("Found WAYLAND_CLIENT_NAME: {client}"); debug!("Found WAYLAND_CLIENT_NAME: {client}");
return Some(client); return Some(client);
} }
}
debug!("No focused window detection method worked"); debug!("No focused window detection method worked");
None None
@ -717,19 +798,17 @@ fn get_recently_active_excluded_app(
if let Ok(entries) = std::fs::read_dir(proc_dir) { if let Ok(entries) = std::fs::read_dir(proc_dir) {
for entry in entries.flatten() { for entry in entries.flatten() {
if let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() { if let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>()
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { && let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm"))
{
let process_name = comm.trim(); let process_name = comm.trim();
// Check process name against exclusion list // Check process name against exclusion list
if app_matches_exclusion(process_name, excluded_apps) if app_matches_exclusion(process_name, excluded_apps)
&& has_recent_activity(pid) && has_recent_activity(pid)
{ {
candidates.push(( candidates
process_name.to_string(), .push((process_name.to_string(), get_process_activity_score(pid)));
get_process_activity_score(pid),
));
}
} }
} }
} }
@ -763,18 +842,16 @@ fn has_recent_activity(pid: u32) -> bool {
// Check /proc/PID/io for recent I/O activity // Check /proc/PID/io for recent I/O activity
if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) {
for line in io_stats.lines() { for line in io_stats.lines() {
if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:"))
if let Some(value_str) = line.split(':').nth(1) { && let Some(value_str) = line.split(':').nth(1)
if let Ok(value) = value_str.trim().parse::<u64>() { && let Ok(value) = value_str.trim().parse::<u64>()
if value > 1024 * 1024 { && value > 1024 * 1024
{
// 1MB threshold // 1MB threshold
return true; return true;
} }
} }
} }
}
}
}
false false
} }
@ -786,27 +863,25 @@ fn get_process_activity_score(pid: u32) -> u64 {
// Add CPU time to score // Add CPU time to score
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
let fields: Vec<&str> = stat.split_whitespace().collect(); let fields: Vec<&str> = stat.split_whitespace().collect();
if fields.len() > 14 { if fields.len() > 14
if let (Ok(utime), Ok(stime)) = && let (Ok(utime), Ok(stime)) =
(fields[13].parse::<u64>(), fields[14].parse::<u64>()) (fields[13].parse::<u64>(), fields[14].parse::<u64>())
{ {
score += utime + stime; score += utime + stime;
} }
} }
}
// Add I/O activity to score // Add I/O activity to score
if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) {
for line in io_stats.lines() { for line in io_stats.lines() {
if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:"))
if let Some(value_str) = line.split(':').nth(1) { && let Some(value_str) = line.split(':').nth(1)
if let Ok(value) = value_str.trim().parse::<u64>() { && let Ok(value) = value_str.trim().parse::<u64>()
{
score += value / 1024; // convert to KB score += value / 1024; // convert to KB
} }
} }
} }
}
}
score score
} }
@ -834,14 +909,14 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool {
} else if excluded.contains('*') { } else if excluded.contains('*') {
// Simple wildcard matching // Simple wildcard matching
let pattern = excluded.replace('*', ".*"); let pattern = excluded.replace('*', ".*");
if let Ok(regex) = regex::Regex::new(&pattern) { if let Ok(regex) = regex::Regex::new(&pattern)
if regex.is_match(app_name) { && regex.is_match(app_name)
{
debug!("Matched wildcard pattern: {app_name} matches {excluded}"); debug!("Matched wildcard pattern: {app_name} matches {excluded}");
return true; return true;
} }
} }
} }
}
debug!("No match found for '{app_name}'"); debug!("No match found for '{app_name}'");
false false

View file

@ -33,12 +33,11 @@ pub fn init_wayland_state() {
/// Get the currently focused window application name using Wayland protocols /// Get the currently focused window application name using Wayland protocols
pub fn get_focused_window_app() -> Option<String> { pub fn get_focused_window_app() -> Option<String> {
// Try Wayland protocol first // Try Wayland protocol first
if let Ok(focused) = FOCUSED_APP.lock() { if let Ok(focused) = FOCUSED_APP.lock()
if let Some(ref app) = *focused { && let Some(ref app) = *focused {
debug!("Found focused app via Wayland protocol: {app}"); debug!("Found focused app via Wayland protocol: {app}");
return Some(app.clone()); return Some(app.clone());
} }
}
debug!("No focused window detection method worked"); debug!("No focused window detection method worked");
None None
@ -81,13 +80,11 @@ impl Dispatch<wl_registry::WlRegistry, ()> for AppState {
interface, interface,
version: _, version: _,
} = event } = event
{ && interface == "zwlr_foreign_toplevel_manager_v1" {
if interface == "zwlr_foreign_toplevel_manager_v1" {
let _manager: ZwlrForeignToplevelManagerV1 = let _manager: ZwlrForeignToplevelManagerV1 =
registry.bind(name, 1, qh, ()); registry.bind(name, 1, qh, ());
} }
} }
}
fn event_created_child( fn event_created_child(
_opcode: u16, _opcode: u16,
@ -155,13 +152,11 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
// Update focused app to the `app_id` of this handle // Update focused app to the `app_id` of this handle
if let (Ok(apps), Ok(mut focused)) = if let (Ok(apps), Ok(mut focused)) =
(TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock())
{ && let Some(app_id) = apps.get(&handle_id) {
if let Some(app_id) = apps.get(&handle_id) {
debug!("Setting focused app to: {app_id}"); debug!("Setting focused app to: {app_id}");
*focused = Some(app_id.clone()); *focused = Some(app_id.clone());
} }
} }
}
}, },
zwlr_foreign_toplevel_handle_v1::Event::Closed => { zwlr_foreign_toplevel_handle_v1::Event::Closed => {
// Clean up when toplevel is closed // Clean up when toplevel is closed