stash: refactor error handling and entry deduplication

This includes breaking changes to the database entries, where we have
started deduplicating based on hashes instead of full entries. Entry
collisions are possible, but highly unlikely.

Additionally we use `Box<str>` for error variants to reduce allocations.
This is *yet* to give me a non-marginal performance benefit but doesn't
hurt to be more correct.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964d0a33392da61372214ca3088551564ac
This commit is contained in:
raf 2025-09-19 13:46:09 +03:00
commit a41d72fb6b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 348 additions and 258 deletions

View file

@ -26,7 +26,7 @@ impl DecodeCommand for SqliteClipboardDb {
let mut buf = String::new();
in_
.read_to_string(&mut buf)
.map_err(|e| StashError::DecodeRead(e.to_string()))?;
.map_err(|e| StashError::DecodeRead(e.to_string().into()))?;
buf
};
@ -38,18 +38,18 @@ impl DecodeCommand for SqliteClipboardDb {
{
let mut buf = Vec::new();
reader.read_to_end(&mut buf).map_err(|e| {
StashError::DecodeRead(format!(
"Failed to read clipboard for relay: {e}"
))
StashError::DecodeRead(
format!("Failed to read clipboard for relay: {e}").into(),
)
})?;
out.write_all(&buf).map_err(|e| {
StashError::DecodeWrite(format!(
"Failed to write clipboard relay: {e}"
))
StashError::DecodeWrite(
format!("Failed to write clipboard relay: {e}").into(),
)
})?;
} else {
return Err(StashError::DecodeGet(
"Failed to get clipboard contents for relay".to_string(),
"Failed to get clipboard contents for relay".into(),
));
}
return Ok(());
@ -69,14 +69,14 @@ impl DecodeCommand for SqliteClipboardDb {
{
let mut buf = Vec::new();
reader.read_to_end(&mut buf).map_err(|err| {
StashError::DecodeRead(format!(
"Failed to read clipboard for relay: {err}"
))
StashError::DecodeRead(
format!("Failed to read clipboard for relay: {err}").into(),
)
})?;
out.write_all(&buf).map_err(|err| {
StashError::DecodeWrite(format!(
"Failed to write clipboard relay: {err}"
))
StashError::DecodeWrite(
format!("Failed to write clipboard relay: {err}").into(),
)
})?;
Ok(())
} else {

View file

@ -27,19 +27,19 @@ impl ImportCommand for SqliteClipboardDb {
let mut imported = 0;
for (lineno, line) in reader.lines().enumerate() {
let line = line.map_err(|e| {
StashError::Store(format!("Failed to read line {lineno}: {e}"))
StashError::Store(format!("Failed to read line {lineno}: {e}").into())
})?;
let mut parts = line.splitn(2, '\t');
let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else {
return Err(StashError::Store(format!(
"Malformed TSV line {lineno}: {line:?}"
)));
return Err(StashError::Store(
format!("Malformed TSV line {lineno}: {line:?}").into(),
));
};
let Ok(_id) = id_str.parse::<u64>() else {
return Err(StashError::Store(format!(
"Failed to parse id from line {lineno}: {id_str}"
)));
return Err(StashError::Store(
format!("Failed to parse id from line {lineno}: {id_str}").into(),
));
};
let entry = Entry {
@ -54,9 +54,9 @@ impl ImportCommand for SqliteClipboardDb {
rusqlite::params![entry.contents, entry.mime],
)
.map_err(|e| {
StashError::Store(format!(
"Failed to insert entry at line {lineno}: {e}"
))
StashError::Store(
format!("Failed to insert entry at line {lineno}: {e}").into(),
)
})?;
imported += 1;
}

View file

@ -47,27 +47,27 @@ impl SqliteClipboardDb {
let mut stmt = self
.conn
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
.map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([])
.map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut entries: Vec<(u64, String, String)> = Vec::new();
let mut max_id_width = 2;
let mut max_mime_width = 8;
while let Some(row) = rows
.next()
.map_err(|e| StashError::ListDecode(e.to_string()))?
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
let id: u64 = row
.get(0)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let contents: Vec<u8> = row
.get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mime: Option<String> = row
.get(2)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let preview =
crate::db::preview_entry(&contents, mime.as_deref(), preview_width);
let mime_str = mime.as_deref().unwrap_or("").to_string();
@ -77,13 +77,14 @@ impl SqliteClipboardDb {
entries.push((id, preview, mime_str));
}
enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?;
enable_raw_mode()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut state = ListState::default();
if !entries.is_empty() {
@ -225,13 +226,13 @@ impl SqliteClipboardDb {
f.render_stateful_widget(list, area, &mut state);
})
.map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
if event::poll(std::time::Duration::from_millis(250))
.map_err(|e| StashError::ListDecode(e.to_string()))?
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
if let Event::Key(key) =
event::read().map_err(|e| StashError::ListDecode(e.to_string()))?
if let Event::Key(key) = event::read()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,

View file

@ -1,9 +1,14 @@
use std::{io::Read, time::Duration};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
io::Read,
time::Duration,
};
use smol::Timer;
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb};
use crate::db::{ClipboardDb, SqliteClipboardDb};
pub trait WatchCommand {
fn watch(
@ -24,11 +29,18 @@ impl WatchCommand for SqliteClipboardDb {
smol::block_on(async {
log::info!("Starting clipboard watch daemon");
// Preallocate buffer for clipboard contents
let mut last_contents: Option<Vec<u8>> = None;
let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully
// We use hashes for comparison instead of storing full contents
let mut last_hash: Option<u64> = None;
let mut buf = Vec::with_capacity(4096);
// Initialize with current clipboard to avoid duplicating on startup
// Helper to hash clipboard contents
let hash_contents = |data: &[u8]| -> u64 {
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
};
// Initialize with current clipboard
if let Ok((mut reader, _)) = get_contents(
ClipboardType::Regular,
Seat::Unspecified,
@ -36,7 +48,7 @@ impl WatchCommand for SqliteClipboardDb {
) {
buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_contents = Some(buf.clone());
last_hash = Some(hash_contents(&buf));
}
}
@ -46,7 +58,7 @@ impl WatchCommand for SqliteClipboardDb {
Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any,
) {
Ok((mut reader, mime_type)) => {
Ok((mut reader, _mime_type)) => {
buf.clear();
if let Err(e) = reader.read_to_end(&mut buf) {
log::error!("Failed to read clipboard contents: {e}");
@ -55,38 +67,35 @@ impl WatchCommand for SqliteClipboardDb {
}
// Only store if changed and not empty
if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) {
let new_contents = std::mem::take(&mut buf);
let mime = Some(mime_type.to_string());
let entry = Entry {
contents: new_contents.clone(),
mime,
};
let id = self.next_sequence();
match self.store_entry(
&entry.contents[..],
max_dedupe_search,
max_items,
Some(excluded_apps),
) {
Ok(_) => {
log::info!("Stored new clipboard entry (id: {id})");
last_contents = Some(new_contents);
},
Err(crate::db::StashError::ExcludedByApp(_)) => {
log::info!("Clipboard entry excluded by app filter");
last_contents = Some(new_contents);
},
Err(crate::db::StashError::Store(ref msg))
if msg.contains("Excluded by app filter") =>
{
log::info!("Clipboard entry excluded by app filter");
last_contents = Some(new_contents);
},
Err(e) => {
log::error!("Failed to store clipboard entry: {e}");
last_contents = Some(new_contents);
},
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(_) => {
log::info!("Stored new clipboard entry (id: {id})");
last_hash = Some(current_hash);
},
Err(crate::db::StashError::ExcludedByApp(_)) => {
log::info!("Clipboard entry excluded by app filter");
last_hash = Some(current_hash);
},
Err(crate::db::StashError::Store(ref msg))
if msg.contains("Excluded by app filter") =>
{
log::info!("Clipboard entry excluded by app filter");
last_hash = Some(current_hash);
},
Err(e) => {
log::error!("Failed to store clipboard entry: {e}");
last_hash = Some(current_hash);
},
}
}
}
},