From 83d45c64143847b21ca2c68295e0a9c076a83bfb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 14 Aug 2025 19:22:02 +0300 Subject: [PATCH 1/7] list: if we're in a TTY, output data in a TUI Signed-off-by: NotAShelf Change-Id: I6a6a696493f2e7ca911b6c1e2692b67f357a4b6b --- Cargo.lock | 326 ++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 6 +- src/commands/list.rs | 284 ++++++++++++++++++++++++++++++++++++- src/main.rs | 46 +++--- 4 files changed, 635 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4810791..13fd399 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.20" @@ -186,6 +192,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -229,6 +246,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.32" @@ -300,6 +332,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -309,6 +355,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -324,13 +379,47 @@ dependencies = [ "bitflags 1.3.2", "crossterm_winapi", "libc", - "mio", + "mio 0.8.11", "parking_lot", "signal-hook", "signal-hook-mio", "winapi", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "mio 1.0.4", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio 1.0.4", + "parking_lot", + "rustix 1.0.8", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -340,6 +429,62 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dirs" version = "6.0.0" @@ -361,6 +506,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -373,6 +527,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "env_filter" version = "0.1.3" @@ -457,6 +617,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -526,6 +692,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -544,12 +712,27 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "imagesize" version = "0.14.0" @@ -566,6 +749,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + [[package]] name = "inquire" version = "0.7.5" @@ -573,13 +762,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ "bitflags 2.9.1", - "crossterm", + "crossterm 0.25.0", "dyn-clone", "fxhash", "newline-converter", "once_cell", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", +] + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -588,6 +790,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -657,6 +868,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litrs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" + [[package]] name = "lock_api" version = "0.4.13" @@ -673,6 +890,15 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.5" @@ -697,6 +923,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + [[package]] name = "newline-converter" version = "0.3.0" @@ -829,7 +1067,7 @@ checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", "rustix 1.0.8", "windows-sys 0.60.2", @@ -883,6 +1121,27 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.9.1", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -994,6 +1253,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" @@ -1061,7 +1326,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.0.4", "signal-hook", ] @@ -1107,14 +1373,17 @@ dependencies = [ name = "stash" version = "0.2.4" dependencies = [ + "atty", "base64", "clap", "clap-verbosity-flag", + "crossterm 0.29.0", "dirs", "env_logger", "imagesize", "inquire", "log", + "ratatui", "regex", "rmp-serde", "rusqlite", @@ -1122,15 +1391,45 @@ dependencies = [ "serde_json", "smol", "thiserror", + "unicode-segmentation", + "unicode-width 0.2.0", "wl-clipboard-rs", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.105" @@ -1199,12 +1498,29 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 248e525..09abd5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,11 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" base64 = "0.22.1" regex = "1.11.1" - +ratatui = "0.29.0" +atty = "0.2.14" +crossterm = "0.29.0" +unicode-segmentation = "1.12.0" +unicode-width = "0.2.0" [profile.release] lto = true diff --git a/src/commands/list.rs b/src/commands/list.rs index f79f407..0dd1ac1 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,14 +1,292 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; use std::io::Write; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; +use unicode_width::UnicodeWidthStr; + pub trait ListCommand { - fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError>; + fn list(&self, out: impl Write, preview_width: u32) -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { - fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError> { + fn list(&self, out: impl Write, preview_width: u32) -> Result<(), StashError> { self.list_entries(out, preview_width)?; log::info!("Listed clipboard entries"); Ok(()) } } + +impl SqliteClipboardDb { + /// Public TUI listing function for use in main.rs + #[allow(clippy::too_many_lines)] + pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { + use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, + }, + }; + use ratatui::{ + Terminal, + backend::CrosstermBackend, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, + }; + use std::io::stdout; + + // Query entries from DB + let mut stmt = self + .conn + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + + struct EntryRow { + id: u64, + preview: String, + mime: String, + } + + let mut entries: Vec = 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()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let preview = crate::db::preview_entry(&contents, mime.as_deref(), preview_width); + let mime_str = mime.as_deref().unwrap_or("").to_string(); + let id_str = id.to_string(); + max_id_width = max_id_width.max(id_str.width()); + max_mime_width = max_mime_width.max(mime_str.width()); + entries.push(EntryRow { + id, + preview, + mime: mime_str, + }); + } + + enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = + Terminal::new(backend).map_err(|e| StashError::ListDecode(e.to_string()))?; + + let mut state = ListState::default(); + if !entries.is_empty() { + state.select(Some(0)); + } + + let res = (|| -> Result<(), StashError> { + loop { + terminal + .draw(|f| { + let area = f.area(); + let block = Block::default() + .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") + .borders(Borders::ALL); + + use unicode_segmentation::UnicodeSegmentation; + use unicode_width::UnicodeWidthStr; + + let border_width = 2; + let highlight_symbol = ">"; + let highlight_width = 1; + let content_width = area.width as usize - border_width; + + // Minimum widths for columns + let min_id_width = 2; + let min_mime_width = 6; + let min_preview_width = 4; + let spaces = 3; // [id][ ][preview][ ][mime] + + // Dynamically allocate widths + let mut id_col = max_id_width.max(min_id_width); + let mut mime_col = max_mime_width.max(min_mime_width); + let mut preview_col = content_width + .saturating_sub(highlight_width) + .saturating_sub(id_col) + .saturating_sub(mime_col) + .saturating_sub(spaces); + + // If not enough space, shrink columns + if preview_col < min_preview_width { + let needed = min_preview_width - preview_col; + if mime_col > min_mime_width { + let reduce = mime_col - min_mime_width; + let take = reduce.min(needed); + mime_col -= take; + preview_col += take; + } + } + if preview_col < min_preview_width { + let needed = min_preview_width - preview_col; + if id_col > min_id_width { + let reduce = id_col - min_id_width; + let take = reduce.min(needed); + id_col -= take; + preview_col += take; + } + } + if preview_col < min_preview_width { + preview_col = min_preview_width; + } + + let selected = state.selected(); + + let list_items: Vec = entries + .iter() + .enumerate() + .map(|(i, entry)| { + // Truncate preview by grapheme clusters and display width + let mut preview = String::new(); + let mut width = 0; + for g in entry.preview.graphemes(true) { + let g_width = UnicodeWidthStr::width(g); + if width + g_width > preview_col { + preview.push('…'); + break; + } + preview.push_str(g); + width += g_width; + } + // Truncate and pad mimetype + let mut mime = String::new(); + let mut mwidth = 0; + for g in entry.mime.graphemes(true) { + let g_width = UnicodeWidthStr::width(g); + if mwidth + g_width > mime_col { + mime.push('…'); + break; + } + mime.push_str(g); + mwidth += g_width; + } + + let preview_str = format!("{preview:mime_col$}"); + + // Compose the row as highlight + id + space + preview + space + mimetype + let mut spans = Vec::new(); + if Some(i) == selected { + spans.push(Span::styled( + highlight_symbol, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!("{:>width$}", entry.id, width = id_col), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + preview_str, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + mime_str, + Style::default().fg(Color::Green), + )); + } else { + spans.push(Span::raw(" ")); + spans.push(Span::raw(format!( + "{:>width$}", + entry.id, + width = id_col + ))); + spans.push(Span::raw(" ")); + spans.push(Span::raw(preview_str)); + spans.push(Span::raw(" ")); + spans.push(Span::raw(mime_str)); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(list_items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(""); // handled manually + + f.render_stateful_widget(list, area, &mut state); + }) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + + if event::poll(std::time::Duration::from_millis(250)) + .map_err(|e| StashError::ListDecode(e.to_string()))? + { + if let Event::Key(key) = + event::read().map_err(|e| StashError::ListDecode(e.to_string()))? + { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Down | KeyCode::Char('j') => { + let i = match state.selected() { + Some(i) => { + if i >= entries.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + state.select(Some(i)); + } + KeyCode::Up | KeyCode::Char('k') => { + let i = match state.selected() { + Some(i) => { + if i == 0 { + entries.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + state.select(Some(i)); + } + _ => {} + } + } + } + } + Ok(()) + })(); + + disable_raw_mode().ok(); + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .ok(); + terminal.show_cursor().ok(); + + res + } +} diff --git a/src/main.rs b/src/main.rs index d4a1255..334afef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ use std::{ process, }; +use atty::Stream; + use clap::{CommandFactory, Parser, Subcommand}; use inquire::Confirm; @@ -153,30 +155,38 @@ fn main() { "Failed to store entry", ); } - Some(Command::List { format }) => { - let format = format.as_deref().unwrap_or("tsv"); - match format { - "tsv" => { + 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(other) => { + log::error!("Unsupported format: {other}"); + } + None => { + if atty::is(Stream::Stdout) { + 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", ); } - - "json" => match db.list_json() { - Ok(json) => { - println!("{json}"); - } - Err(e) => { - log::error!("Failed to list entries as JSON: {e}"); - } - }, - - _ => { - log::error!("Unsupported format: {format}"); - } } - } + }, Some(Command::Decode { input }) => { report_error( db.decode(io::stdin(), io::stdout(), input), From 7c26947437890612bd0349d9b3e40873de40ae83 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 14 Aug 2025 19:55:17 +0300 Subject: [PATCH 2/7] commands/list: resolve clippy warnings Signed-off-by: NotAShelf Change-Id: I6a6a6964e1bb8c22b5026ce65889e3aec1b90a71 --- src/commands/list.rs | 42 ++++++++++++------------------------------ 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 0dd1ac1..c926a5a 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,6 +1,7 @@ use std::io::Write; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; pub trait ListCommand { @@ -44,13 +45,7 @@ impl SqliteClipboardDb { .query([]) .map_err(|e| StashError::ListDecode(e.to_string()))?; - struct EntryRow { - id: u64, - preview: String, - mime: String, - } - - let mut entries: Vec = Vec::new(); + 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 @@ -71,11 +66,7 @@ impl SqliteClipboardDb { let id_str = id.to_string(); max_id_width = max_id_width.max(id_str.width()); max_mime_width = max_mime_width.max(mime_str.width()); - entries.push(EntryRow { - id, - preview, - mime: mime_str, - }); + entries.push((id, preview, mime_str)); } enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; @@ -100,9 +91,6 @@ impl SqliteClipboardDb { .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") .borders(Borders::ALL); - use unicode_segmentation::UnicodeSegmentation; - use unicode_width::UnicodeWidthStr; - let border_width = 2; let highlight_symbol = ">"; let highlight_width = 1; @@ -155,7 +143,7 @@ impl SqliteClipboardDb { // Truncate preview by grapheme clusters and display width let mut preview = String::new(); let mut width = 0; - for g in entry.preview.graphemes(true) { + for g in entry.1.graphemes(true) { let g_width = UnicodeWidthStr::width(g); if width + g_width > preview_col { preview.push('…'); @@ -167,7 +155,7 @@ impl SqliteClipboardDb { // Truncate and pad mimetype let mut mime = String::new(); let mut mwidth = 0; - for g in entry.mime.graphemes(true) { + for g in entry.2.graphemes(true) { let g_width = UnicodeWidthStr::width(g); if mwidth + g_width > mime_col { mime.push('…'); @@ -177,11 +165,9 @@ impl SqliteClipboardDb { mwidth += g_width; } - let preview_str = format!("{preview:mime_col$}"); - // Compose the row as highlight + id + space + preview + space + mimetype let mut spans = Vec::new(); + let (id, preview, mime) = entry; if Some(i) == selected { spans.push(Span::styled( highlight_symbol, @@ -190,34 +176,30 @@ impl SqliteClipboardDb { .add_modifier(Modifier::BOLD), )); spans.push(Span::styled( - format!("{:>width$}", entry.id, width = id_col), + format!("{id:>id_col$}"), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), )); spans.push(Span::raw(" ")); spans.push(Span::styled( - preview_str, + format!("{preview:mime_col$}"), Style::default().fg(Color::Green), )); } else { spans.push(Span::raw(" ")); - spans.push(Span::raw(format!( - "{:>width$}", - entry.id, - width = id_col - ))); + spans.push(Span::raw(format!("{id:>id_col$}"))); spans.push(Span::raw(" ")); - spans.push(Span::raw(preview_str)); + spans.push(Span::raw(format!("{preview:mime_col$}"))); } ListItem::new(Line::from(spans)) }) From 47fd5e496470932314d8c629b5336ce0d28edb7d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 15 Aug 2025 07:52:16 +0300 Subject: [PATCH 3/7] db: support other image formats supported by `imagesize` Signed-off-by: NotAShelf Change-Id: I6a6a69646b72b8bc223fe9729be3dbefbae2b353 --- src/db/mod.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/db/mod.rs b/src/db/mod.rs index 52dee84..6dbb357 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -430,6 +430,21 @@ pub fn detect_mime(data: &[u8]) -> Option { ImageType::Bmp => "image/bmp", ImageType::Tiff => "image/tiff", ImageType::Webp => "image/webp", + ImageType::Aseprite => "image/x-aseprite", + ImageType::Dds => "image/vnd.ms-dds", + ImageType::Exr => "image/aces", + ImageType::Farbfeld => "image/farbfeld", + ImageType::Hdr => "image/vnd.radiance", + ImageType::Ico => "image/x-icon", + ImageType::Ilbm => "image/ilbm", + ImageType::Jxl => "image/jxl", + ImageType::Ktx2 => "image/ktx2", + ImageType::Pnm => "image/x-portable-anymap", + ImageType::Psd => "image/vnd.adobe.photoshop", + ImageType::Qoi => "image/qoi", + ImageType::Tga => "image/x-tga", + ImageType::Vtf => "image/x-vtf", + ImageType::Heif(_) => "image/heif", _ => "application/octet-stream", } .to_string(), From 2db9a2904dc29ada1bee82365354fea0c21f740e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 Aug 2025 09:23:08 +0300 Subject: [PATCH 4/7] meta: set formatter Signed-off-by: NotAShelf Change-Id: I6a6a6964ca3adbea0b57e460543416888190d8d9 --- .rustfmt.toml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .rustfmt.toml diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..cb120a3 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,27 @@ +condense_wildcard_suffixes = true +doc_comment_code_block_width = 80 +edition = "2024" # Keep in sync with Cargo.toml. +enum_discrim_align_threshold = 60 +force_explicit_abi = false +force_multiline_blocks = true +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +group_imports = "StdExternalCrate" +hex_literal_case = "Upper" +imports_granularity = "Crate" +imports_layout = "HorizontalVertical" +inline_attribute_width = 60 +match_block_trailing_comma = true +max_width = 80 +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true +struct_field_align_threshold = 60 +tab_spaces = 2 +unstable_features = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true + From 404990f9282a1224dd5cf8e4073469bd6d3024d0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 Aug 2025 09:40:06 +0300 Subject: [PATCH 5/7] nix: update devshell Signed-off-by: NotAShelf Change-Id: I6a6a696451203d6ac74ae44dddec1bdce19e78d9 --- nix/shell.nix | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/nix/shell.nix b/nix/shell.nix index ceecfc0..9df0432 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,20 +1,25 @@ { mkShell, - rust-analyzer, - rustfmt, rustc, - clippy, cargo, + rustfmt, + clippy, + taplo, + rust-analyzer-unwrapped, rustPlatform, }: mkShell { name = "rust"; + packages = [ - rust-analyzer - rustfmt + rustc + cargo + + (rustfmt.override {asNightly = true;}) clippy cargo - rustc + taplo + rust-analyzer-unwrapped ]; RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; From 6a5cd9b95d0abb0e826c900680993ed22ae92f57 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 Aug 2025 09:40:20 +0300 Subject: [PATCH 6/7] treewide: format with rustfmt Signed-off-by: NotAShelf Change-Id: I6a6a69642c2865f41a4b141ddf39a198a3fc2e09 --- src/commands/decode.rs | 129 +++--- src/commands/delete.rs | 28 +- src/commands/list.rs | 526 ++++++++++++------------ src/commands/query.rs | 12 +- src/commands/store.rs | 48 +-- src/commands/watch.rs | 131 +++--- src/commands/wipe.rs | 16 +- src/db/mod.rs | 883 +++++++++++++++++++++-------------------- src/import.rs | 66 +-- src/main.rs | 534 +++++++++++++------------ 10 files changed, 1216 insertions(+), 1157 deletions(-) diff --git a/src/commands/decode.rs b/src/commands/decode.rs index 4cff4a2..e6df237 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -1,75 +1,78 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; - use std::io::{Read, Write}; -use crate::db::StashError; use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents}; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; + pub trait DecodeCommand { - fn decode( - &self, - in_: impl Read, - out: impl Write, - input: Option, - ) -> Result<(), StashError>; + fn decode( + &self, + in_: impl Read, + out: impl Write, + input: Option, + ) -> Result<(), StashError>; } impl DecodeCommand for SqliteClipboardDb { - fn decode( - &self, - mut in_: impl Read, - mut out: impl Write, - input: Option, - ) -> Result<(), StashError> { - let input_str = if let Some(s) = input { - s + fn decode( + &self, + mut in_: impl Read, + mut out: impl Write, + input: Option, + ) -> Result<(), StashError> { + let input_str = if let Some(s) = input { + s + } else { + let mut buf = String::new(); + if let Err(e) = in_.read_to_string(&mut buf) { + log::error!("Failed to read stdin for decode: {e}"); + } + buf + }; + + // If input is empty or whitespace, treat as error and trigger fallback + if input_str.trim().is_empty() { + log::info!("No input provided to decode; relaying clipboard to stdout"); + if let Ok((mut reader, _mime)) = + get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) + { + let mut buf = Vec::new(); + if let Err(err) = reader.read_to_end(&mut buf) { + log::error!("Failed to read clipboard for relay: {err}"); } else { - let mut buf = String::new(); - if let Err(e) = in_.read_to_string(&mut buf) { - log::error!("Failed to read stdin for decode: {e}"); - } - buf - }; - - // If input is empty or whitespace, treat as error and trigger fallback - if input_str.trim().is_empty() { - log::info!("No input provided to decode; relaying clipboard to stdout"); - if let Ok((mut reader, _mime)) = - get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) - { - let mut buf = Vec::new(); - if let Err(err) = reader.read_to_end(&mut buf) { - log::error!("Failed to read clipboard for relay: {err}"); - } else { - let _ = out.write_all(&buf); - } - } else { - log::error!("Failed to get clipboard contents for relay"); - } - return Ok(()); + let _ = out.write_all(&buf); } - - // Try decode as usual - match self.decode_entry(input_str.as_bytes(), &mut out, Some(input_str.clone())) { - Ok(()) => { - log::info!("Entry decoded"); - } - Err(e) => { - log::error!("Failed to decode entry: {e}"); - if let Ok((mut reader, _mime)) = - get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) - { - let mut buf = Vec::new(); - if let Err(err) = reader.read_to_end(&mut buf) { - log::error!("Failed to read clipboard for relay: {err}"); - } else { - let _ = out.write_all(&buf); - } - } else { - log::error!("Failed to get clipboard contents for relay"); - } - } - } - Ok(()) + } else { + log::error!("Failed to get clipboard contents for relay"); + } + return Ok(()); } + + // Try decode as usual + match self.decode_entry( + input_str.as_bytes(), + &mut out, + Some(input_str.clone()), + ) { + Ok(()) => { + log::info!("Entry decoded"); + }, + Err(e) => { + log::error!("Failed to decode entry: {e}"); + if let Ok((mut reader, _mime)) = + get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) + { + let mut buf = Vec::new(); + if let Err(err) = reader.read_to_end(&mut buf) { + log::error!("Failed to read clipboard for relay: {err}"); + } else { + let _ = out.write_all(&buf); + } + } else { + log::error!("Failed to get clipboard contents for relay"); + } + }, + } + Ok(()) + } } diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 64fd1cf..e7e2c92 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -1,22 +1,22 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; - use std::io::Read; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; + pub trait DeleteCommand { - fn delete(&self, input: impl Read) -> Result; + fn delete(&self, input: impl Read) -> Result; } impl DeleteCommand for SqliteClipboardDb { - fn delete(&self, input: impl Read) -> Result { - match self.delete_entries(input) { - Ok(deleted) => { - log::info!("Deleted {deleted} entries"); - Ok(deleted) - } - Err(e) => { - log::error!("Failed to delete entries: {e}"); - Err(e) - } - } + fn delete(&self, input: impl Read) -> Result { + match self.delete_entries(input) { + Ok(deleted) => { + log::info!("Deleted {deleted} entries"); + Ok(deleted) + }, + Err(e) => { + log::error!("Failed to delete entries: {e}"); + Err(e) + }, } + } } diff --git a/src/commands/list.rs b/src/commands/list.rs index c926a5a..1464956 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,274 +1,286 @@ use std::io::Write; -use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; use unicode_segmentation::UnicodeSegmentation; 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) + -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { - fn list(&self, out: impl Write, preview_width: u32) -> Result<(), StashError> { - self.list_entries(out, preview_width)?; - log::info!("Listed clipboard entries"); - Ok(()) - } + fn list( + &self, + out: impl Write, + preview_width: u32, + ) -> Result<(), StashError> { + self.list_entries(out, preview_width)?; + log::info!("Listed clipboard entries"); + Ok(()) + } } impl SqliteClipboardDb { - /// Public TUI listing function for use in main.rs - #[allow(clippy::too_many_lines)] - pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { - use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, - execute, - terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, - }, - }; - use ratatui::{ - Terminal, - backend::CrosstermBackend, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState}, - }; - use std::io::stdout; + /// Public TUI listing function for use in main.rs + #[allow(clippy::too_many_lines)] + pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { + use std::io::stdout; - // Query entries from DB - let mut stmt = self - .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{ + EnterAlternateScreen, + LeaveAlternateScreen, + disable_raw_mode, + enable_raw_mode, + }, + }; + use ratatui::{ + Terminal, + backend::CrosstermBackend, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, + }; - 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()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let preview = crate::db::preview_entry(&contents, mime.as_deref(), preview_width); - let mime_str = mime.as_deref().unwrap_or("").to_string(); - let id_str = id.to_string(); - max_id_width = max_id_width.max(id_str.width()); - max_mime_width = max_mime_width.max(mime_str.width()); - entries.push((id, preview, mime_str)); - } + // Query entries from DB + let mut stmt = self + .conn + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string()))?; - enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut stdout = stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = - Terminal::new(backend).map_err(|e| StashError::ListDecode(e.to_string()))?; - - let mut state = ListState::default(); - if !entries.is_empty() { - state.select(Some(0)); - } - - let res = (|| -> Result<(), StashError> { - loop { - terminal - .draw(|f| { - let area = f.area(); - let block = Block::default() - .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") - .borders(Borders::ALL); - - let border_width = 2; - let highlight_symbol = ">"; - let highlight_width = 1; - let content_width = area.width as usize - border_width; - - // Minimum widths for columns - let min_id_width = 2; - let min_mime_width = 6; - let min_preview_width = 4; - let spaces = 3; // [id][ ][preview][ ][mime] - - // Dynamically allocate widths - let mut id_col = max_id_width.max(min_id_width); - let mut mime_col = max_mime_width.max(min_mime_width); - let mut preview_col = content_width - .saturating_sub(highlight_width) - .saturating_sub(id_col) - .saturating_sub(mime_col) - .saturating_sub(spaces); - - // If not enough space, shrink columns - if preview_col < min_preview_width { - let needed = min_preview_width - preview_col; - if mime_col > min_mime_width { - let reduce = mime_col - min_mime_width; - let take = reduce.min(needed); - mime_col -= take; - preview_col += take; - } - } - if preview_col < min_preview_width { - let needed = min_preview_width - preview_col; - if id_col > min_id_width { - let reduce = id_col - min_id_width; - let take = reduce.min(needed); - id_col -= take; - preview_col += take; - } - } - if preview_col < min_preview_width { - preview_col = min_preview_width; - } - - let selected = state.selected(); - - let list_items: Vec = entries - .iter() - .enumerate() - .map(|(i, entry)| { - // Truncate preview by grapheme clusters and display width - let mut preview = String::new(); - let mut width = 0; - for g in entry.1.graphemes(true) { - let g_width = UnicodeWidthStr::width(g); - if width + g_width > preview_col { - preview.push('…'); - break; - } - preview.push_str(g); - width += g_width; - } - // Truncate and pad mimetype - let mut mime = String::new(); - let mut mwidth = 0; - for g in entry.2.graphemes(true) { - let g_width = UnicodeWidthStr::width(g); - if mwidth + g_width > mime_col { - mime.push('…'); - break; - } - mime.push_str(g); - mwidth += g_width; - } - - // Compose the row as highlight + id + space + preview + space + mimetype - let mut spans = Vec::new(); - let (id, preview, mime) = entry; - if Some(i) == selected { - spans.push(Span::styled( - highlight_symbol, - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - format!("{id:>id_col$}"), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("{preview:mime_col$}"), - Style::default().fg(Color::Green), - )); - } else { - spans.push(Span::raw(" ")); - spans.push(Span::raw(format!("{id:>id_col$}"))); - spans.push(Span::raw(" ")); - spans.push(Span::raw(format!("{preview:mime_col$}"))); - } - ListItem::new(Line::from(spans)) - }) - .collect(); - - let list = List::new(list_items) - .block(block) - .highlight_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(""); // handled manually - - f.render_stateful_widget(list, area, &mut state); - }) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - - if event::poll(std::time::Duration::from_millis(250)) - .map_err(|e| StashError::ListDecode(e.to_string()))? - { - if let Event::Key(key) = - event::read().map_err(|e| StashError::ListDecode(e.to_string()))? - { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, - KeyCode::Down | KeyCode::Char('j') => { - let i = match state.selected() { - Some(i) => { - if i >= entries.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - state.select(Some(i)); - } - KeyCode::Up | KeyCode::Char('k') => { - let i = match state.selected() { - Some(i) => { - if i == 0 { - entries.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - state.select(Some(i)); - } - _ => {} - } - } - } - } - Ok(()) - })(); - - disable_raw_mode().ok(); - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ) - .ok(); - terminal.show_cursor().ok(); - - res + 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()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let preview = + crate::db::preview_entry(&contents, mime.as_deref(), preview_width); + let mime_str = mime.as_deref().unwrap_or("").to_string(); + let id_str = id.to_string(); + max_id_width = max_id_width.max(id_str.width()); + max_mime_width = max_mime_width.max(mime_str.width()); + entries.push((id, preview, mime_str)); } + + enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + + let mut state = ListState::default(); + if !entries.is_empty() { + state.select(Some(0)); + } + + let res = (|| -> Result<(), StashError> { + loop { + terminal + .draw(|f| { + let area = f.area(); + let block = Block::default() + .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") + .borders(Borders::ALL); + + let border_width = 2; + let highlight_symbol = ">"; + let highlight_width = 1; + let content_width = area.width as usize - border_width; + + // Minimum widths for columns + let min_id_width = 2; + let min_mime_width = 6; + let min_preview_width = 4; + let spaces = 3; // [id][ ][preview][ ][mime] + + // Dynamically allocate widths + let mut id_col = max_id_width.max(min_id_width); + let mut mime_col = max_mime_width.max(min_mime_width); + let mut preview_col = content_width + .saturating_sub(highlight_width) + .saturating_sub(id_col) + .saturating_sub(mime_col) + .saturating_sub(spaces); + + // If not enough space, shrink columns + if preview_col < min_preview_width { + let needed = min_preview_width - preview_col; + if mime_col > min_mime_width { + let reduce = mime_col - min_mime_width; + let take = reduce.min(needed); + mime_col -= take; + preview_col += take; + } + } + if preview_col < min_preview_width { + let needed = min_preview_width - preview_col; + if id_col > min_id_width { + let reduce = id_col - min_id_width; + let take = reduce.min(needed); + id_col -= take; + preview_col += take; + } + } + if preview_col < min_preview_width { + preview_col = min_preview_width; + } + + let selected = state.selected(); + + let list_items: Vec = entries + .iter() + .enumerate() + .map(|(i, entry)| { + // Truncate preview by grapheme clusters and display width + let mut preview = String::new(); + let mut width = 0; + for g in entry.1.graphemes(true) { + let g_width = UnicodeWidthStr::width(g); + if width + g_width > preview_col { + preview.push('…'); + break; + } + preview.push_str(g); + width += g_width; + } + // Truncate and pad mimetype + let mut mime = String::new(); + let mut mwidth = 0; + for g in entry.2.graphemes(true) { + let g_width = UnicodeWidthStr::width(g); + if mwidth + g_width > mime_col { + mime.push('…'); + break; + } + mime.push_str(g); + mwidth += g_width; + } + + // Compose the row as highlight + id + space + preview + space + + // mimetype + let mut spans = Vec::new(); + let (id, preview, mime) = entry; + if Some(i) == selected { + spans.push(Span::styled( + highlight_symbol, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!("{id:>id_col$}"), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{preview:mime_col$}"), + Style::default().fg(Color::Green), + )); + } else { + spans.push(Span::raw(" ")); + spans.push(Span::raw(format!("{id:>id_col$}"))); + spans.push(Span::raw(" ")); + spans.push(Span::raw(format!("{preview:mime_col$}"))); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(list_items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(""); // handled manually + + f.render_stateful_widget(list, area, &mut state); + }) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + + if event::poll(std::time::Duration::from_millis(250)) + .map_err(|e| StashError::ListDecode(e.to_string()))? + { + if let Event::Key(key) = + event::read().map_err(|e| StashError::ListDecode(e.to_string()))? + { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Down | KeyCode::Char('j') => { + let i = match state.selected() { + Some(i) => { + if i >= entries.len() - 1 { + 0 + } else { + i + 1 + } + }, + None => 0, + }; + state.select(Some(i)); + }, + KeyCode::Up | KeyCode::Char('k') => { + let i = match state.selected() { + Some(i) => { + if i == 0 { + entries.len() - 1 + } else { + i - 1 + } + }, + None => 0, + }; + state.select(Some(i)); + }, + _ => {}, + } + } + } + } + Ok(()) + })(); + + disable_raw_mode().ok(); + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .ok(); + terminal.show_cursor().ok(); + + res + } } diff --git a/src/commands/query.rs b/src/commands/query.rs index 6673648..c5b5851 100644 --- a/src/commands/query.rs +++ b/src/commands/query.rs @@ -1,13 +1,11 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; - -use crate::db::StashError; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait QueryCommand { - fn query_delete(&self, query: &str) -> Result; + fn query_delete(&self, query: &str) -> Result; } impl QueryCommand for SqliteClipboardDb { - fn query_delete(&self, query: &str) -> Result { - ::delete_query(self, query) - } + fn query_delete(&self, query: &str) -> Result { + ::delete_query(self, query) + } } diff --git a/src/commands/store.rs b/src/commands/store.rs index 4e2c769..6ddfb60 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -1,32 +1,32 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; - use std::io::Read; +use crate::db::{ClipboardDb, SqliteClipboardDb}; + pub trait StoreCommand { - fn store( - &self, - input: impl Read, - max_dedupe_search: u64, - max_items: u64, - state: Option, - ) -> Result<(), crate::db::StashError>; + fn store( + &self, + input: impl Read, + max_dedupe_search: u64, + max_items: u64, + state: Option, + ) -> Result<(), crate::db::StashError>; } impl StoreCommand for SqliteClipboardDb { - fn store( - &self, - input: impl Read, - max_dedupe_search: u64, - max_items: u64, - state: Option, - ) -> Result<(), crate::db::StashError> { - if let Some("sensitive" | "clear") = state.as_deref() { - self.delete_last()?; - log::info!("Entry deleted"); - } else { - self.store_entry(input, max_dedupe_search, max_items)?; - log::info!("Entry stored"); - } - Ok(()) + fn store( + &self, + input: impl Read, + max_dedupe_search: u64, + max_items: u64, + state: Option, + ) -> Result<(), crate::db::StashError> { + if let Some("sensitive" | "clear") = state.as_deref() { + self.delete_last()?; + log::info!("Entry deleted"); + } else { + self.store_entry(input, max_dedupe_search, max_items)?; + log::info!("Entry stored"); } + Ok(()) + } } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index dcf334d..01e922e 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,79 +1,80 @@ -use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; +use std::{io::Read, time::Duration}; + use smol::Timer; -use std::io::Read; -use std::time::Duration; use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; +use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; + pub trait WatchCommand { - fn watch(&self, max_dedupe_search: u64, max_items: u64); + fn watch(&self, max_dedupe_search: u64, max_items: u64); } impl WatchCommand for SqliteClipboardDb { - fn watch(&self, max_dedupe_search: u64, max_items: u64) { - smol::block_on(async { - log::info!("Starting clipboard watch daemon"); + fn watch(&self, max_dedupe_search: u64, max_items: u64) { + smol::block_on(async { + log::info!("Starting clipboard watch daemon"); - // Preallocate buffer for clipboard contents - let mut last_contents: Option> = None; - let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully + // Preallocate buffer for clipboard contents + let mut last_contents: Option> = None; + let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully - // Initialize with current clipboard to avoid duplicating on startup - if let Ok((mut reader, _)) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, - ) { - buf.clear(); - if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { - last_contents = Some(buf.clone()); - } + // Initialize with current clipboard to avoid duplicating on startup + if let Ok((mut reader, _)) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + buf.clear(); + if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + last_contents = Some(buf.clone()); + } + } + + loop { + match get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + 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}"); + Timer::after(Duration::from_millis(500)).await; + continue; } - loop { - match get_contents( - ClipboardType::Regular, - Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, - ) { - 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}"); - Timer::after(Duration::from_millis(500)).await; - continue; - } + // Only store if changed and not empty + if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { + last_contents = Some(std::mem::take(&mut buf)); + let mime = Some(mime_type.to_string()); + let entry = Entry { + contents: last_contents.as_ref().unwrap().clone(), + mime, + }; + let id = self.next_sequence(); + match self.store_entry( + &entry.contents[..], + max_dedupe_search, + max_items, + ) { + Ok(_) => log::info!("Stored new clipboard entry (id: {id})"), + Err(e) => log::error!("Failed to store clipboard entry: {e}"), + } - // Only store if changed and not empty - if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { - last_contents = Some(std::mem::take(&mut buf)); - let mime = Some(mime_type.to_string()); - let entry = Entry { - contents: last_contents.as_ref().unwrap().clone(), - mime, - }; - let id = self.next_sequence(); - match self.store_entry( - &entry.contents[..], - max_dedupe_search, - max_items, - ) { - Ok(_) => log::info!("Stored new clipboard entry (id: {id})"), - Err(e) => log::error!("Failed to store clipboard entry: {e}"), - } - - // Drop clipboard contents after storing - last_contents = None; - } - } - Err(e) => { - let error_msg = e.to_string(); - if !error_msg.contains("empty") { - log::error!("Failed to get clipboard contents: {e}"); - } - } - } - Timer::after(Duration::from_millis(500)).await; + // Drop clipboard contents after storing + last_contents = None; } - }); - } + }, + Err(e) => { + let error_msg = e.to_string(); + if !error_msg.contains("empty") { + log::error!("Failed to get clipboard contents: {e}"); + } + }, + } + Timer::after(Duration::from_millis(500)).await; + } + }); + } } diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs index d815527..c0bb9ee 100644 --- a/src/commands/wipe.rs +++ b/src/commands/wipe.rs @@ -1,15 +1,13 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; - -use crate::db::StashError; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait WipeCommand { - fn wipe(&self) -> Result<(), StashError>; + fn wipe(&self) -> Result<(), StashError>; } impl WipeCommand for SqliteClipboardDb { - fn wipe(&self) -> Result<(), StashError> { - self.wipe_db()?; - log::info!("Database wiped"); - Ok(()) - } + fn wipe(&self) -> Result<(), StashError> { + self.wipe_db()?; + log::info!("Database wiped"); + Ok(()) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 6dbb357..6a7bf6b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,392 +1,419 @@ -use std::env; -use std::fmt; -use std::fs; -use std::io::{BufRead, BufReader, Read, Write}; -use std::str; +use std::{ + env, + fmt, + fs, + io::{BufRead, BufReader, Read, Write}, + str, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; use imagesize::{ImageSize, ImageType}; use log::{error, info, warn}; use regex::Regex; - use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD; use serde_json::json; +use thiserror::Error; #[derive(Error, Debug)] pub enum StashError { - #[error("Input is empty or too large, skipping store.")] - EmptyOrTooLarge, - #[error("Input is all whitespace, skipping store.")] - AllWhitespace, + #[error("Input is empty or too large, skipping store.")] + EmptyOrTooLarge, + #[error("Input is all whitespace, skipping store.")] + AllWhitespace, - #[error("Failed to store entry: {0}")] - Store(String), - #[error("Error reading entry during deduplication: {0}")] - DeduplicationRead(String), - #[error("Error decoding entry during deduplication: {0}")] - DeduplicationDecode(String), - #[error("Failed to remove entry during deduplication: {0}")] - DeduplicationRemove(String), - #[error("Failed to trim entry: {0}")] - Trim(String), - #[error("No entries to delete")] - NoEntriesToDelete, - #[error("Failed to delete last entry: {0}")] - DeleteLast(String), - #[error("Failed to wipe database: {0}")] - Wipe(String), - #[error("Failed to decode entry during list: {0}")] - ListDecode(String), - #[error("Failed to read input for decode: {0}")] - DecodeRead(String), - #[error("Failed to extract id for decode: {0}")] - DecodeExtractId(String), - #[error("Failed to get entry for decode: {0}")] - DecodeGet(String), + #[error("Failed to store entry: {0}")] + Store(String), + #[error("Error reading entry during deduplication: {0}")] + DeduplicationRead(String), + #[error("Error decoding entry during deduplication: {0}")] + DeduplicationDecode(String), + #[error("Failed to remove entry during deduplication: {0}")] + DeduplicationRemove(String), + #[error("Failed to trim entry: {0}")] + Trim(String), + #[error("No entries to delete")] + NoEntriesToDelete, + #[error("Failed to delete last entry: {0}")] + DeleteLast(String), + #[error("Failed to wipe database: {0}")] + Wipe(String), + #[error("Failed to decode entry during list: {0}")] + ListDecode(String), + #[error("Failed to read input for decode: {0}")] + DecodeRead(String), + #[error("Failed to extract id for decode: {0}")] + DecodeExtractId(String), + #[error("Failed to get entry for decode: {0}")] + DecodeGet(String), - #[error("Failed to write decoded entry: {0}")] - DecodeWrite(String), - #[error("Failed to delete entry during query delete: {0}")] - QueryDelete(String), - #[error("Failed to delete entry with id {0}: {1}")] - DeleteEntry(u64, String), + #[error("Failed to write decoded entry: {0}")] + DecodeWrite(String), + #[error("Failed to delete entry during query delete: {0}")] + QueryDelete(String), + #[error("Failed to delete entry with id {0}: {1}")] + DeleteEntry(u64, String), } pub trait ClipboardDb { - fn store_entry( - &self, - input: impl Read, - max_dedupe_search: u64, - max_items: u64, - ) -> Result; - fn deduplicate(&self, buf: &[u8], max: u64) -> Result; - fn trim_db(&self, max: u64) -> Result<(), StashError>; - fn delete_last(&self) -> Result<(), StashError>; - fn wipe_db(&self) -> Result<(), StashError>; - fn list_entries(&self, out: impl Write, preview_width: u32) -> Result; - fn decode_entry( - &self, - in_: impl Read, - out: impl Write, - input: Option, - ) -> Result<(), StashError>; - fn delete_query(&self, query: &str) -> Result; - fn delete_entries(&self, in_: impl Read) -> Result; - fn next_sequence(&self) -> u64; + fn store_entry( + &self, + input: impl Read, + max_dedupe_search: u64, + max_items: u64, + ) -> Result; + fn deduplicate(&self, buf: &[u8], max: u64) -> Result; + fn trim_db(&self, max: u64) -> Result<(), StashError>; + fn delete_last(&self) -> Result<(), StashError>; + fn wipe_db(&self) -> Result<(), StashError>; + fn list_entries( + &self, + out: impl Write, + preview_width: u32, + ) -> Result; + fn decode_entry( + &self, + in_: impl Read, + out: impl Write, + input: Option, + ) -> Result<(), StashError>; + fn delete_query(&self, query: &str) -> Result; + fn delete_entries(&self, in_: impl Read) -> Result; + fn next_sequence(&self) -> u64; } #[derive(Serialize, Deserialize)] pub struct Entry { - pub contents: Vec, - pub mime: Option, + pub contents: Vec, + pub mime: Option, } impl fmt::Display for Entry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let preview = preview_entry(&self.contents, self.mime.as_deref(), 100); - write!(f, "{preview}") - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let preview = preview_entry(&self.contents, self.mime.as_deref(), 100); + write!(f, "{preview}") + } } pub struct SqliteClipboardDb { - pub conn: Connection, + pub conn: Connection, } impl SqliteClipboardDb { - pub fn new(conn: Connection) -> Result { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS clipboard ( + pub fn new(conn: Connection) -> Result { + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard ( id INTEGER PRIMARY KEY AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT );", - ) - .map_err(|e| StashError::Store(e.to_string()))?; - Ok(Self { conn }) - } + ) + .map_err(|e| StashError::Store(e.to_string()))?; + Ok(Self { conn }) + } } impl SqliteClipboardDb { - pub fn list_json(&self) -> Result { - let mut stmt = self - .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + pub fn list_json(&self) -> Result { + let mut stmt = self + .conn + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut entries = Vec::new(); + let mut entries = Vec::new(); - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let contents_str = match mime.as_deref() { - Some(m) if m.starts_with("text/") || m == "application/json" => { - String::from_utf8_lossy(&contents).to_string() - } - _ => STANDARD.encode(&contents), - }; - entries.push(json!({ - "id": id, - "contents": contents_str, - "mime": mime, - })); - } - - serde_json::to_string_pretty(&entries).map_err(|e| StashError::ListDecode(e.to_string())) + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents_str = match mime.as_deref() { + Some(m) if m.starts_with("text/") || m == "application/json" => { + String::from_utf8_lossy(&contents).to_string() + }, + _ => STANDARD.encode(&contents), + }; + entries.push(json!({ + "id": id, + "contents": contents_str, + "mime": mime, + })); } + + serde_json::to_string_pretty(&entries) + .map_err(|e| StashError::ListDecode(e.to_string())) + } } impl ClipboardDb for SqliteClipboardDb { - fn store_entry( - &self, - mut input: impl Read, - max_dedupe_search: u64, - max_items: u64, - ) -> Result { - let mut buf = Vec::new(); - if input.read_to_end(&mut buf).is_err() || buf.is_empty() || buf.len() > 5 * 1_000_000 { - return Err(StashError::EmptyOrTooLarge); - } - if buf.iter().all(u8::is_ascii_whitespace) { - return Err(StashError::AllWhitespace); - } - - let mime = match detect_mime(&buf) { - None => { - // If valid UTF-8, treat as text/plain - if std::str::from_utf8(&buf).is_ok() { - Some("text/plain".to_string()) - } else { - None - } - } - other => other, - }; - - // Try to load regex from systemd credential file, then env var - 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".to_string())); - } - } - } - - self.deduplicate(&buf, max_dedupe_search)?; - - self.conn - .execute( - "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", - params![buf, mime], - ) - .map_err(|e| StashError::Store(e.to_string()))?; - - self.trim_db(max_items)?; - Ok(self.next_sequence()) + fn store_entry( + &self, + mut input: impl Read, + max_dedupe_search: u64, + max_items: u64, + ) -> Result { + let mut buf = Vec::new(); + if input.read_to_end(&mut buf).is_err() + || buf.is_empty() + || buf.len() > 5 * 1_000_000 + { + return Err(StashError::EmptyOrTooLarge); + } + if buf.iter().all(u8::is_ascii_whitespace) { + return Err(StashError::AllWhitespace); } - fn deduplicate(&self, buf: &[u8], max: u64) -> Result { - let mut stmt = self - .conn - .prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") - .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; - let mut rows = stmt - .query(params![i64::try_from(max).unwrap_or(i64::MAX)]) - .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; - let mut deduped = 0; - while let Some(row) = rows - .next() - .map_err(|e| StashError::DeduplicationRead(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; - if contents == buf { - self.conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeduplicationRemove(e.to_string()))?; - deduped += 1; - } - } - Ok(deduped) - } - - fn trim_db(&self, max: u64) -> Result<(), StashError> { - let count: u64 = self - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .map_err(|e| StashError::Trim(e.to_string()))?; - if count > max { - let to_delete = count - max; - self.conn.execute( - "DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER BY id ASC LIMIT ?1)", - params![i64::try_from(to_delete).unwrap_or(i64::MAX)], - ).map_err(|e| StashError::Trim(e.to_string()))?; - } - Ok(()) - } - - fn delete_last(&self) -> Result<(), StashError> { - let id: Option = self - .conn - .query_row( - "SELECT id FROM clipboard ORDER BY id DESC LIMIT 1", - [], - |row| row.get(0), - ) - .optional() - .map_err(|e| StashError::DeleteLast(e.to_string()))?; - if let Some(id) = id { - self.conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteLast(e.to_string()))?; - Ok(()) + let mime = match detect_mime(&buf) { + None => { + // If valid UTF-8, treat as text/plain + if std::str::from_utf8(&buf).is_ok() { + Some("text/plain".to_string()) } else { - Err(StashError::NoEntriesToDelete) + None } - } + }, + other => other, + }; - fn wipe_db(&self) -> Result<(), StashError> { - self.conn - .execute("DELETE FROM clipboard", []) - .map_err(|e| StashError::Wipe(e.to_string()))?; - Ok(()) - } - - fn list_entries(&self, mut out: impl Write, preview_width: u32) -> Result { - let mut stmt = self - .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut listed = 0; - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let preview = preview_entry(&contents, mime.as_deref(), preview_width); - if writeln!(out, "{id}\t{preview}").is_ok() { - listed += 1; - } + // Try to load regex from systemd credential file, then env var + 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".to_string(), + )); } - Ok(listed) + } } - fn decode_entry( - &self, - mut in_: impl Read, - mut out: impl Write, - input: Option, - ) -> Result<(), StashError> { - let s = if let Some(input) = input { - input - } else { - let mut buf = String::new(); - in_.read_to_string(&mut buf) - .map_err(|e| StashError::DecodeRead(e.to_string()))?; - buf - }; - let id = extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; - let (contents, _mime): (Vec, Option) = self - .conn - .query_row( - "SELECT contents, mime FROM clipboard WHERE id = ?1", - params![id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|e| StashError::DecodeGet(e.to_string()))?; - out.write_all(&contents) - .map_err(|e| StashError::DecodeWrite(e.to_string()))?; - info!("Decoded entry with id {id}"); - Ok(()) - } + self.deduplicate(&buf, max_dedupe_search)?; - fn delete_query(&self, query: &str) -> Result { - let mut stmt = self - .conn - .prepare("SELECT id, contents FROM clipboard") - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - let mut deleted = 0; - while let Some(row) = rows - .next() - .map_err(|e| StashError::QueryDelete(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - if contents.windows(query.len()).any(|w| w == query.as_bytes()) { - self.conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - deleted += 1; - } - } - Ok(deleted) - } + self + .conn + .execute( + "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", + params![buf, mime], + ) + .map_err(|e| StashError::Store(e.to_string()))?; - fn delete_entries(&self, in_: impl Read) -> Result { - let reader = BufReader::new(in_); - let mut deleted = 0; - for line in reader.lines().map_while(Result::ok) { - if let Ok(id) = extract_id(&line) { - self.conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; - deleted += 1; - } - } - Ok(deleted) - } + self.trim_db(max_items)?; + Ok(self.next_sequence()) + } - fn next_sequence(&self) -> u64 { - 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, - } + fn deduplicate(&self, buf: &[u8], max: u64) -> Result { + let mut stmt = self + .conn + .prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") + .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; + let mut rows = stmt + .query(params![i64::try_from(max).unwrap_or(i64::MAX)]) + .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; + let mut deduped = 0; + while let Some(row) = rows + .next() + .map_err(|e| StashError::DeduplicationRead(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; + if contents == buf { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeduplicationRemove(e.to_string()))?; + deduped += 1; + } } + Ok(deduped) + } + + fn trim_db(&self, max: u64) -> Result<(), StashError> { + let count: u64 = self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .map_err(|e| StashError::Trim(e.to_string()))?; + if count > max { + let to_delete = count - max; + self + .conn + .execute( + "DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER \ + BY id ASC LIMIT ?1)", + params![i64::try_from(to_delete).unwrap_or(i64::MAX)], + ) + .map_err(|e| StashError::Trim(e.to_string()))?; + } + Ok(()) + } + + fn delete_last(&self) -> Result<(), StashError> { + let id: Option = self + .conn + .query_row( + "SELECT id FROM clipboard ORDER BY id DESC LIMIT 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| StashError::DeleteLast(e.to_string()))?; + if let Some(id) = id { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeleteLast(e.to_string()))?; + Ok(()) + } else { + Err(StashError::NoEntriesToDelete) + } + } + + fn wipe_db(&self) -> Result<(), StashError> { + self + .conn + .execute("DELETE FROM clipboard", []) + .map_err(|e| StashError::Wipe(e.to_string()))?; + Ok(()) + } + + fn list_entries( + &self, + mut out: impl Write, + preview_width: u32, + ) -> Result { + let mut stmt = self + .conn + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut listed = 0; + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let preview = preview_entry(&contents, mime.as_deref(), preview_width); + if writeln!(out, "{id}\t{preview}").is_ok() { + listed += 1; + } + } + Ok(listed) + } + + fn decode_entry( + &self, + mut in_: impl Read, + mut out: impl Write, + input: Option, + ) -> Result<(), StashError> { + let s = if let Some(input) = input { + input + } else { + let mut buf = String::new(); + in_ + .read_to_string(&mut buf) + .map_err(|e| StashError::DecodeRead(e.to_string()))?; + buf + }; + let id = + extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; + let (contents, _mime): (Vec, Option) = self + .conn + .query_row( + "SELECT contents, mime FROM clipboard WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| StashError::DecodeGet(e.to_string()))?; + out + .write_all(&contents) + .map_err(|e| StashError::DecodeWrite(e.to_string()))?; + info!("Decoded entry with id {id}"); + Ok(()) + } + + fn delete_query(&self, query: &str) -> Result { + let mut stmt = self + .conn + .prepare("SELECT id, contents FROM clipboard") + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + let mut deleted = 0; + while let Some(row) = rows + .next() + .map_err(|e| StashError::QueryDelete(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + if contents.windows(query.len()).any(|w| w == query.as_bytes()) { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + deleted += 1; + } + } + Ok(deleted) + } + + fn delete_entries(&self, in_: impl Read) -> Result { + let reader = BufReader::new(in_); + let mut deleted = 0; + for line in reader.lines().map_while(Result::ok) { + if let Ok(id) = extract_id(&line) { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; + deleted += 1; + } + } + Ok(deleted) + } + + fn next_sequence(&self) -> u64 { + 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, + } + } } // Helper functions @@ -396,116 +423,116 @@ impl ClipboardDb for SqliteClipboardDb { /// # Returns /// `Some(Regex)` if present and valid, `None` otherwise. fn load_sensitive_regex() -> Option { - if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { - let file = format!("{regex_path}/clipboard_filter"); - if let Ok(contents) = fs::read_to_string(&file) { - if let Ok(re) = Regex::new(contents.trim()) { - return Some(re); - } - } + if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{regex_path}/clipboard_filter"); + if let Ok(contents) = fs::read_to_string(&file) { + if let Ok(re) = Regex::new(contents.trim()) { + return Some(re); + } } + } - // Fallback to an environment variable - if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { - if let Ok(re) = Regex::new(&pattern) { - return Some(re); - } + // Fallback to an environment variable + if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { + if let Ok(re) = Regex::new(&pattern) { + return Some(re); } + } - None + None } pub fn extract_id(input: &str) -> Result { - let id_str = input.split('\t').next().unwrap_or(""); - id_str.parse().map_err(|_| "invalid id") + let id_str = input.split('\t').next().unwrap_or(""); + id_str.parse().map_err(|_| "invalid id") } pub fn detect_mime(data: &[u8]) -> Option { - if let Ok(img_type) = imagesize::image_type(data) { - Some( - match img_type { - ImageType::Png => "image/png", - ImageType::Jpeg => "image/jpeg", - ImageType::Gif => "image/gif", - ImageType::Bmp => "image/bmp", - ImageType::Tiff => "image/tiff", - ImageType::Webp => "image/webp", - ImageType::Aseprite => "image/x-aseprite", - ImageType::Dds => "image/vnd.ms-dds", - ImageType::Exr => "image/aces", - ImageType::Farbfeld => "image/farbfeld", - ImageType::Hdr => "image/vnd.radiance", - ImageType::Ico => "image/x-icon", - ImageType::Ilbm => "image/ilbm", - ImageType::Jxl => "image/jxl", - ImageType::Ktx2 => "image/ktx2", - ImageType::Pnm => "image/x-portable-anymap", - ImageType::Psd => "image/vnd.adobe.photoshop", - ImageType::Qoi => "image/qoi", - ImageType::Tga => "image/x-tga", - ImageType::Vtf => "image/x-vtf", - ImageType::Heif(_) => "image/heif", - _ => "application/octet-stream", - } - .to_string(), - ) - } else { - None - } + if let Ok(img_type) = imagesize::image_type(data) { + Some( + match img_type { + ImageType::Png => "image/png", + ImageType::Jpeg => "image/jpeg", + ImageType::Gif => "image/gif", + ImageType::Bmp => "image/bmp", + ImageType::Tiff => "image/tiff", + ImageType::Webp => "image/webp", + ImageType::Aseprite => "image/x-aseprite", + ImageType::Dds => "image/vnd.ms-dds", + ImageType::Exr => "image/aces", + ImageType::Farbfeld => "image/farbfeld", + ImageType::Hdr => "image/vnd.radiance", + ImageType::Ico => "image/x-icon", + ImageType::Ilbm => "image/ilbm", + ImageType::Jxl => "image/jxl", + ImageType::Ktx2 => "image/ktx2", + ImageType::Pnm => "image/x-portable-anymap", + ImageType::Psd => "image/vnd.adobe.photoshop", + ImageType::Qoi => "image/qoi", + ImageType::Tga => "image/x-tga", + ImageType::Vtf => "image/x-vtf", + ImageType::Heif(_) => "image/heif", + _ => "application/octet-stream", + } + .to_string(), + ) + } else { + None + } } pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { - if let Some(mime) = mime { - if mime.starts_with("image/") { - if let Ok(ImageSize { - width: img_width, - height: img_height, - }) = imagesize::blob_size(data) - { - return format!( - "[[ binary data {} {} {}x{} ]]", - size_str(data.len()), - mime, - img_width, - img_height - ); - } - } else if mime == "application/json" || mime.starts_with("text/") { - let s = match str::from_utf8(data) { - Ok(s) => s, - Err(e) => { - error!("Failed to decode UTF-8 clipboard data: {e}"); - "" - } - }; - let s = s.trim().replace(|c: char| c.is_whitespace(), " "); - return truncate(&s, width as usize, "…"); - } + if let Some(mime) = mime { + if mime.starts_with("image/") { + if let Ok(ImageSize { + width: img_width, + height: img_height, + }) = imagesize::blob_size(data) + { + return format!( + "[[ binary data {} {} {}x{} ]]", + size_str(data.len()), + mime, + img_width, + img_height + ); + } + } else if mime == "application/json" || mime.starts_with("text/") { + let s = match str::from_utf8(data) { + Ok(s) => s, + Err(e) => { + error!("Failed to decode UTF-8 clipboard data: {e}"); + "" + }, + }; + let s = s.trim().replace(|c: char| c.is_whitespace(), " "); + return truncate(&s, width as usize, "…"); } - let s = String::from_utf8_lossy(data); - truncate(s.trim(), width as usize, "…") + } + let s = String::from_utf8_lossy(data); + truncate(s.trim(), width as usize, "…") } pub fn truncate(s: &str, max: usize, ellip: &str) -> String { - if s.chars().count() > max { - s.chars().take(max).collect::() + ellip - } else { - s.to_string() - } + if s.chars().count() > max { + s.chars().take(max).collect::() + ellip + } else { + s.to_string() + } } pub fn size_str(size: usize) -> String { - let units = ["B", "KiB", "MiB"]; - let mut fsize = if let Ok(val) = u32::try_from(size) { - f64::from(val) - } else { - error!("Clipboard entry size too large for display: {size}"); - f64::from(u32::MAX) - }; - let mut i = 0; - while fsize >= 1024.0 && i < units.len() - 1 { - fsize /= 1024.0; - i += 1; - } - format!("{:.0} {}", fsize, units[i]) + let units = ["B", "KiB", "MiB"]; + let mut fsize = if let Ok(val) = u32::try_from(size) { + f64::from(val) + } else { + error!("Clipboard entry size too large for display: {size}"); + f64::from(u32::MAX) + }; + let mut i = 0; + while fsize >= 1024.0 && i < units.len() - 1 { + fsize /= 1024.0; + i += 1; + } + format!("{:.0} {}", fsize, units[i]) } diff --git a/src/import.rs b/src/import.rs index 9c4f421..7cb741f 100644 --- a/src/import.rs +++ b/src/import.rs @@ -1,43 +1,45 @@ -use crate::db::{Entry, SqliteClipboardDb, detect_mime}; -use log::{error, info}; use std::io::{self, BufRead}; +use log::{error, info}; + +use crate::db::{Entry, SqliteClipboardDb, detect_mime}; + pub trait ImportCommand { - fn import_tsv(&self, input: impl io::Read); + fn import_tsv(&self, input: impl io::Read); } impl ImportCommand for SqliteClipboardDb { - fn import_tsv(&self, input: impl io::Read) { - let reader = io::BufReader::new(input); - let mut imported = 0; - for line in reader.lines().map_while(Result::ok) { - let mut parts = line.splitn(2, '\t'); - let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { - error!("Malformed TSV line: {line:?}"); - continue; - }; + fn import_tsv(&self, input: impl io::Read) { + let reader = io::BufReader::new(input); + let mut imported = 0; + for line in reader.lines().map_while(Result::ok) { + let mut parts = line.splitn(2, '\t'); + let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { + error!("Malformed TSV line: {line:?}"); + continue; + }; - let Ok(_id) = id_str.parse::() else { - error!("Failed to parse id from line: {id_str}"); - continue; - }; + let Ok(_id) = id_str.parse::() else { + error!("Failed to parse id from line: {id_str}"); + continue; + }; - let entry = Entry { - contents: val.as_bytes().to_vec(), - mime: detect_mime(val.as_bytes()), - }; + let entry = Entry { + contents: val.as_bytes().to_vec(), + mime: detect_mime(val.as_bytes()), + }; - match self.conn.execute( - "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", - rusqlite::params![entry.contents, entry.mime], - ) { - Ok(_) => { - imported += 1; - info!("Imported entry from TSV"); - } - Err(e) => error!("Failed to insert entry: {e}"), - } - } - info!("Imported {imported} records from TSV into SQLite database."); + match self.conn.execute( + "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", + rusqlite::params![entry.contents, entry.mime], + ) { + Ok(_) => { + imported += 1; + info!("Imported entry from TSV"); + }, + Err(e) => error!("Failed to insert entry: {e}"), + } } + info!("Imported {imported} records from TSV into SQLite database."); + } } diff --git a/src/main.rs b/src/main.rs index 334afef..5b418c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,11 @@ use std::{ - env, - io::{self}, - path::PathBuf, - process, + env, + io::{self}, + path::PathBuf, + process, }; use atty::Stream; - use clap::{CommandFactory, Parser, Subcommand}; use inquire::Confirm; @@ -14,289 +13,308 @@ mod commands; mod db; mod import; -use crate::commands::decode::DecodeCommand; -use crate::commands::delete::DeleteCommand; -use crate::commands::list::ListCommand; -use crate::commands::query::QueryCommand; -use crate::commands::store::StoreCommand; -use crate::commands::watch::WatchCommand; -use crate::commands::wipe::WipeCommand; -use crate::import::ImportCommand; +use crate::{ + commands::{ + decode::DecodeCommand, + delete::DeleteCommand, + list::ListCommand, + query::QueryCommand, + store::StoreCommand, + watch::WatchCommand, + wipe::WipeCommand, + }, + import::ImportCommand, +}; #[derive(Parser)] #[command(name = "stash")] #[command(about = "Wayland clipboard manager", version)] struct Cli { - #[command(subcommand)] - command: Option, + #[command(subcommand)] + command: Option, - #[arg(long, default_value_t = 750)] - max_items: u64, + #[arg(long, default_value_t = 750)] + max_items: u64, - #[arg(long, default_value_t = 100)] - max_dedupe_search: u64, + #[arg(long, default_value_t = 100)] + max_dedupe_search: u64, - #[arg(long, default_value_t = 100)] - preview_width: u32, + #[arg(long, default_value_t = 100)] + preview_width: u32, - #[arg(long)] - db_path: Option, + #[arg(long)] + db_path: Option, - /// Ask for confirmation before destructive operations - #[arg(long)] - ask: bool, + /// Ask for confirmation before destructive operations + #[arg(long)] + ask: bool, - #[command(flatten)] - verbosity: clap_verbosity_flag::Verbosity, + #[command(flatten)] + verbosity: clap_verbosity_flag::Verbosity, } #[derive(Subcommand)] enum Command { - /// Store clipboard contents - Store, + /// Store clipboard contents + Store, - /// List clipboard history - List { - /// Output format: "tsv" (default) or "json" - #[arg(long, value_parser = ["tsv", "json"])] - format: Option, - }, + /// List clipboard history + List { + /// Output format: "tsv" (default) or "json" + #[arg(long, value_parser = ["tsv", "json"])] + format: Option, + }, - /// Decode and output clipboard entry by id - Decode { input: Option }, + /// Decode and output clipboard entry by id + Decode { input: Option }, - /// 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. - Delete { - /// Id or query string - arg: Option, + /// 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. + Delete { + /// Id or query string + arg: Option, - /// Explicitly specify type: "id" or "query" - #[arg(long, value_parser = ["id", "query"])] - r#type: Option, + /// Explicitly specify type: "id" or "query" + #[arg(long, value_parser = ["id", "query"])] + r#type: Option, - /// Ask for confirmation before deleting - #[arg(long)] - ask: bool, - }, + /// Ask for confirmation before deleting + #[arg(long)] + ask: bool, + }, - /// Wipe all clipboard history - Wipe { - /// Ask for confirmation before wiping - #[arg(long)] - ask: bool, - }, + /// Wipe all clipboard history + Wipe { + /// Ask for confirmation before wiping + #[arg(long)] + ask: bool, + }, - /// Import clipboard data from stdin (default: TSV format) - Import { - /// Explicitly specify format: "tsv" (default) - #[arg(long, value_parser = ["tsv"])] - r#type: Option, + /// Import clipboard data from stdin (default: TSV format) + Import { + /// Explicitly specify format: "tsv" (default) + #[arg(long, value_parser = ["tsv"])] + r#type: Option, - /// Ask for confirmation before importing - #[arg(long)] - ask: bool, - }, + /// Ask for confirmation before importing + #[arg(long)] + ask: bool, + }, - /// Watch clipboard for changes and store automatically - Watch, + /// Watch clipboard for changes and store automatically + Watch, } -fn report_error(result: Result, context: &str) -> Option { - match result { - Ok(val) => Some(val), - Err(e) => { - log::error!("{context}: {e}"); - None - } - } +fn report_error( + result: Result, + context: &str, +) -> Option { + match result { + Ok(val) => Some(val), + Err(e) => { + log::error!("{context}: {e}"); + None + }, + } } #[allow(clippy::too_many_lines)] // whatever fn main() { - smol::block_on(async { - let cli = Cli::parse(); - env_logger::Builder::new() - .filter_level(cli.verbosity.into()) - .init(); + smol::block_on(async { + let cli = Cli::parse(); + env_logger::Builder::new() + .filter_level(cli.verbosity.into()) + .init(); - let db_path = cli.db_path.unwrap_or_else(|| { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("stash") - .join("db") - }); - - if let Some(parent) = db_path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - log::error!("Failed to create database directory: {e}"); - process::exit(1); - } - } - - let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| { - log::error!("Failed to open SQLite database: {e}"); - process::exit(1); - }); - - let db = match db::SqliteClipboardDb::new(conn) { - Ok(db) => db, - Err(e) => { - log::error!("Failed to initialize SQLite database: {e}"); - process::exit(1); - } - }; - - match cli.command { - Some(Command::Store) => { - let state = env::var("STASH_CLIPBOARD_STATE").ok(); - report_error( - db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state), - "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(other) => { - log::error!("Unsupported format: {other}"); - } - None => { - if atty::is(Stream::Stdout) { - 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 }) => { - report_error( - db.decode(io::stdin(), io::stdout(), input), - "Failed to decode entry", - ); - } - Some(Command::Delete { arg, r#type, ask }) => { - let mut should_proceed = true; - if ask { - should_proceed = - Confirm::new("Are you sure you want to delete clipboard entries?") - .with_default(false) - .prompt() - .unwrap_or(false); - - if !should_proceed { - log::info!("Aborted by user."); - } - } - if should_proceed { - match (arg, r#type.as_deref()) { - (Some(s), Some("id")) => { - if let Ok(id) = s.parse::() { - use std::io::Cursor; - report_error( - db.delete(Cursor::new(format!("{id}\n"))), - "Failed to delete entry by id", - ); - } else { - log::error!("Argument is not a valid id"); - } - } - (Some(s), Some("query")) => { - report_error(db.query_delete(&s), "Failed to delete entry by query"); - } - (Some(s), None) => { - if let Ok(id) = s.parse::() { - use std::io::Cursor; - report_error( - db.delete(Cursor::new(format!("{id}\n"))), - "Failed to delete entry by id", - ); - } else { - report_error( - db.query_delete(&s), - "Failed to delete entry by query", - ); - } - } - (None, _) => { - report_error( - db.delete(io::stdin()), - "Failed to delete entry from stdin", - ); - } - (_, Some(_)) => { - log::error!("Unknown type for --type. Use \"id\" or \"query\"."); - } - } - } - } - Some(Command::Wipe { ask }) => { - let mut should_proceed = true; - if ask { - should_proceed = - Confirm::new("Are you sure you want to wipe all clipboard history?") - .with_default(false) - .prompt() - .unwrap_or(false); - if !should_proceed { - log::info!("Aborted by user."); - } - } - if should_proceed { - report_error(db.wipe(), "Failed to wipe database"); - } - } - - Some(Command::Import { r#type, ask }) => { - let mut should_proceed = true; - if ask { - should_proceed = Confirm::new("Are you sure you want to import clipboard data? This may overwrite existing entries.") - .with_default(false) - .prompt() - .unwrap_or(false); - if !should_proceed { - log::info!("Aborted by user."); - } - } - if should_proceed { - let format = r#type.as_deref().unwrap_or("tsv"); - match format { - "tsv" => { - db.import_tsv(io::stdin()); - } - _ => { - log::error!("Unsupported import format: {format}"); - } - } - } - } - Some(Command::Watch) => { - db.watch(cli.max_dedupe_search, cli.max_items); - } - None => { - if let Err(e) = Cli::command().print_help() { - log::error!("Failed to print help: {e}"); - } - println!(); - } - } + let db_path = cli.db_path.unwrap_or_else(|| { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("stash") + .join("db") }); + + if let Some(parent) = db_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::error!("Failed to create database directory: {e}"); + process::exit(1); + } + } + + let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| { + log::error!("Failed to open SQLite database: {e}"); + process::exit(1); + }); + + let db = match db::SqliteClipboardDb::new(conn) { + Ok(db) => db, + Err(e) => { + log::error!("Failed to initialize SQLite database: {e}"); + process::exit(1); + }, + }; + + match cli.command { + Some(Command::Store) => { + let state = env::var("STASH_CLIPBOARD_STATE").ok(); + report_error( + db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state), + "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(other) => { + log::error!("Unsupported format: {other}"); + }, + None => { + if atty::is(Stream::Stdout) { + 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 }) => { + report_error( + db.decode(io::stdin(), io::stdout(), input), + "Failed to decode entry", + ); + }, + Some(Command::Delete { arg, r#type, ask }) => { + let mut should_proceed = true; + if ask { + should_proceed = + Confirm::new("Are you sure you want to delete clipboard entries?") + .with_default(false) + .prompt() + .unwrap_or(false); + + if !should_proceed { + log::info!("Aborted by user."); + } + } + if should_proceed { + match (arg, r#type.as_deref()) { + (Some(s), Some("id")) => { + if let Ok(id) = s.parse::() { + use std::io::Cursor; + report_error( + db.delete(Cursor::new(format!("{id}\n"))), + "Failed to delete entry by id", + ); + } else { + log::error!("Argument is not a valid id"); + } + }, + (Some(s), Some("query")) => { + report_error( + db.query_delete(&s), + "Failed to delete entry by query", + ); + }, + (Some(s), None) => { + if let Ok(id) = s.parse::() { + use std::io::Cursor; + report_error( + db.delete(Cursor::new(format!("{id}\n"))), + "Failed to delete entry by id", + ); + } else { + report_error( + db.query_delete(&s), + "Failed to delete entry by query", + ); + } + }, + (None, _) => { + report_error( + db.delete(io::stdin()), + "Failed to delete entry from stdin", + ); + }, + (_, Some(_)) => { + log::error!("Unknown type for --type. Use \"id\" or \"query\"."); + }, + } + } + }, + Some(Command::Wipe { ask }) => { + let mut should_proceed = true; + if ask { + should_proceed = Confirm::new( + "Are you sure you want to wipe all clipboard history?", + ) + .with_default(false) + .prompt() + .unwrap_or(false); + if !should_proceed { + log::info!("Aborted by user."); + } + } + if should_proceed { + report_error(db.wipe(), "Failed to wipe database"); + } + }, + + Some(Command::Import { r#type, ask }) => { + let mut should_proceed = true; + if ask { + should_proceed = Confirm::new( + "Are you sure you want to import clipboard data? This may \ + overwrite existing entries.", + ) + .with_default(false) + .prompt() + .unwrap_or(false); + if !should_proceed { + log::info!("Aborted by user."); + } + } + if should_proceed { + let format = r#type.as_deref().unwrap_or("tsv"); + match format { + "tsv" => { + db.import_tsv(io::stdin()); + }, + _ => { + log::error!("Unsupported import format: {format}"); + }, + } + } + }, + Some(Command::Watch) => { + db.watch(cli.max_dedupe_search, cli.max_items); + }, + None => { + if let Err(e) = Cli::command().print_help() { + log::error!("Failed to print help: {e}"); + } + println!(); + }, + } + }); } From f39937d3cab4caf953edaa11ad2877358eaac681 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 Aug 2025 09:43:18 +0300 Subject: [PATCH 7/7] stash: import all entries by default; log if db import fails Signed-off-by: NotAShelf Change-Id: I6a6a6964804698d2e83a37ec2688e9c126cf412b --- src/main.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5b418c2..eefb248 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,8 @@ struct Cli { #[command(subcommand)] command: Option, - #[arg(long, default_value_t = 750)] + /// Maximum number of clipboard entries to keep + #[arg(long, default_value_t = u64::MAX)] max_items: u64, #[arg(long, default_value_t = 100)] @@ -298,7 +299,9 @@ fn main() { let format = r#type.as_deref().unwrap_or("tsv"); match format { "tsv" => { - db.import_tsv(io::stdin()); + if let Err(e) = db.import_tsv(io::stdin(), cli.max_items) { + log::error!("Failed to import TSV: {e}"); + } }, _ => { log::error!("Unsupported import format: {format}");