commands/list: implement clipboard history search

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I57f00cbd9d02b1981cf3ea5dc908e72c6a6a6964
This commit is contained in:
raf 2026-02-26 11:24:55 +03:00
commit b850a54f7b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
2 changed files with 325 additions and 118 deletions

View file

@ -46,6 +46,12 @@ struct TuiState {
/// Whether the window needs to be re-fetched from the DB. /// Whether the window needs to be re-fetched from the DB.
dirty: bool, dirty: bool,
/// Current search query. Empty string means no filter.
search_query: String,
/// Whether we're currently in search input mode.
search_mode: bool,
} }
impl TuiState { impl TuiState {
@ -56,9 +62,15 @@ impl TuiState {
window_size: usize, window_size: usize,
preview_width: u32, preview_width: u32,
) -> Result<Self, StashError> { ) -> Result<Self, StashError> {
let total = db.count_entries(include_expired)?; let total = db.count_entries(include_expired, None)?;
let window = if total > 0 { let window = if total > 0 {
db.fetch_entries_window(include_expired, 0, window_size, preview_width)? db.fetch_entries_window(
include_expired,
0,
window_size,
preview_width,
None,
)?
} else { } else {
Vec::new() Vec::new()
}; };
@ -69,9 +81,56 @@ impl TuiState {
window, window,
window_size, window_size,
dirty: false, dirty: false,
search_query: String::new(),
search_mode: false,
}) })
} }
/// Return the current search filter (`None` if empty).
fn search_filter(&self) -> Option<&str> {
if self.search_query.is_empty() {
None
} else {
Some(&self.search_query)
}
}
/// Update search query and reset cursor. Returns true if search changed.
fn set_search(&mut self, query: String) -> bool {
let changed = self.search_query != query;
if changed {
self.search_query = query;
self.cursor = 0;
self.viewport_offset = 0;
self.dirty = true;
}
changed
}
/// Clear search and reset state. Returns true if was searching.
fn clear_search(&mut self) -> bool {
let had_search = !self.search_query.is_empty();
self.search_query.clear();
self.search_mode = false;
if had_search {
self.cursor = 0;
self.viewport_offset = 0;
self.dirty = true;
}
had_search
}
/// Toggle search mode.
fn toggle_search_mode(&mut self) {
self.search_mode = !self.search_mode;
if self.search_mode {
// When entering search mode, clear query if there was one
// or start fresh
self.search_query.clear();
self.dirty = true;
}
}
/// Return the cursor position relative to the current window /// Return the cursor position relative to the current window
/// (`window[local_cursor]` == the selected entry). /// (`window[local_cursor]` == the selected entry).
#[inline] #[inline]
@ -161,12 +220,14 @@ impl TuiState {
0 0
}; };
let search = self.search_filter();
self.window = if self.total > 0 { self.window = if self.total > 0 {
db.fetch_entries_window( db.fetch_entries_window(
include_expired, include_expired,
self.viewport_offset, self.viewport_offset,
self.window_size, self.window_size,
preview_width, preview_width,
search,
)? )?
} else { } else {
Vec::new() Vec::new()
@ -270,17 +331,25 @@ impl SqliteClipboardDb {
net_down: i64, // positive=down, negative=up, 0=none net_down: i64, // positive=down, negative=up, 0=none
copy: bool, copy: bool,
delete: bool, delete: bool,
toggle_search: bool, // enter/exit search mode
search_input: Option<char>, // character typed in search mode
search_backspace: bool, // backspace in search mode
clear_search: bool, // clear search query (ESC in search mode)
} }
/// Drain all pending key events and return what actions to perform. /// Drain all pending key events and return what actions to perform.
/// Navigation is capped to ±1 per frame to prevent jumpy scrolling when /// Navigation is capped to +-1 per frame to prevent jumpy scrolling when
/// the key-repeat rate exceeds the render frame rate. /// the key-repeat rate exceeds the render frame rate.
fn drain_events() -> Result<EventActions, StashError> { fn drain_events(tui: &TuiState) -> Result<EventActions, StashError> {
let mut actions = EventActions { let mut actions = EventActions {
quit: false, quit: false,
net_down: 0, net_down: 0,
copy: false, copy: false,
delete: false, delete: false,
toggle_search: false,
search_input: None,
search_backspace: false,
clear_search: false,
}; };
while event::poll(std::time::Duration::from_millis(0)) while event::poll(std::time::Duration::from_millis(0))
@ -289,6 +358,25 @@ impl SqliteClipboardDb {
if let Event::Key(key) = event::read() if let Event::Key(key) = event::read()
.map_err(|e| StashError::ListDecode(e.to_string().into()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ {
if tui.search_mode {
// In search mode, handle text input
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
actions.clear_search = true;
},
(KeyCode::Enter, _) => {
actions.toggle_search = true; // exit search mode
},
(KeyCode::Backspace, _) => {
actions.search_backspace = true;
},
(KeyCode::Char(c), _) => {
actions.search_input = Some(c);
},
_ => {},
}
} else {
// Normal mode navigation commands
match (key.code, key.modifiers) { match (key.code, key.modifiers) {
(KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true, (KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true,
(KeyCode::Down | KeyCode::Char('j'), _) => { (KeyCode::Down | KeyCode::Char('j'), _) => {
@ -304,11 +392,15 @@ impl SqliteClipboardDb {
} }
}, },
(KeyCode::Enter, _) => actions.copy = true, (KeyCode::Enter, _) => actions.copy = true,
(KeyCode::Char('D'), KeyModifiers::SHIFT) => actions.delete = true, (KeyCode::Char('D'), KeyModifiers::SHIFT) => {
actions.delete = true
},
(KeyCode::Char('/'), _) => actions.toggle_search = true,
_ => {}, _ => {},
} }
} }
} }
}
Ok(actions) Ok(actions)
} }
@ -319,9 +411,11 @@ impl SqliteClipboardDb {
max_id_width: usize, max_id_width: usize,
max_mime_width: usize| max_mime_width: usize|
-> Result<(), StashError> { -> Result<(), StashError> {
// Reserve 2 rows for search bar when in search mode
let search_bar_height = if tui.search_mode { 2 } else { 0 };
let term_height = terminal let term_height = terminal
.size() .size()
.map(|r| r.height.saturating_sub(2) as usize) .map(|r| r.height.saturating_sub(2 + search_bar_height) as usize)
.unwrap_or(24) .unwrap_or(24)
.max(1); .max(1);
tui.resize(term_height); tui.resize(term_height);
@ -336,12 +430,23 @@ impl SqliteClipboardDb {
terminal terminal
.draw(|f| { .draw(|f| {
let area = f.area(); let area = f.area();
let block = Block::default()
.title( // Build title based on search state
"Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \ let title = if tui.search_mode {
to delete, q/ESC to quit)", format!("Search: {}", tui.search_query)
} else if tui.search_query.is_empty() {
"Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \
Shift+D to delete, q/ESC to quit)"
.to_string()
} else {
format!(
"Clipboard Entries (filtered: '{}' - {} results, / to search, \
ESC to clear, q to quit)",
tui.search_query, tui.total
) )
.borders(Borders::ALL); };
let block = Block::default().title(title).borders(Borders::ALL);
let border_width = 2; let border_width = 2;
let highlight_symbol = ">"; let highlight_symbol = ">";
@ -482,13 +587,54 @@ impl SqliteClipboardDb {
if event::poll(std::time::Duration::from_millis(250)) if event::poll(std::time::Duration::from_millis(250))
.map_err(|e| StashError::ListDecode(e.to_string().into()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ {
let actions = drain_events()?; let actions = drain_events(&tui)?;
if actions.quit { if actions.quit {
break; break;
} }
// Handle search mode actions
if actions.toggle_search {
tui.toggle_search_mode();
}
if actions.clear_search && tui.clear_search() {
// Search was cleared, refresh count
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
if let Some(c) = actions.search_input {
let new_query = format!("{}{}", tui.search_query, c);
if tui.set_search(new_query) {
// Search changed, refresh count and reset
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
}
if actions.search_backspace {
let new_query = tui
.search_query
.chars()
.next_back()
.map(|_| {
tui
.search_query
.chars()
.take(tui.search_query.len() - 1)
.collect::<String>()
})
.unwrap_or_default();
if tui.set_search(new_query) {
// Search changed, refresh count and reset
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
}
// Apply navigation (capped at ±1 per frame for smooth scrolling). // Apply navigation (capped at ±1 per frame for smooth scrolling).
if !tui.search_mode {
if actions.net_down > 0 { if actions.net_down > 0 {
tui.move_down(); tui.move_down();
} else if actions.net_down < 0 { } else if actions.net_down < 0 {
@ -504,7 +650,9 @@ impl SqliteClipboardDb {
"DELETE FROM clipboard WHERE id = ?1", "DELETE FROM clipboard WHERE id = ?1",
rusqlite::params![id], rusqlite::params![id],
) )
.map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; .map_err(|e| {
StashError::DeleteEntry(id, e.to_string().into())
})?;
tui.on_delete(); tui.on_delete();
let _ = Notification::new() let _ = Notification::new()
.summary("Stash") .summary("Stash")
@ -526,8 +674,8 @@ impl SqliteClipboardDb {
Some(ref m) => MimeType::Specific(m.clone().to_owned()), Some(ref m) => MimeType::Specific(m.clone().to_owned()),
None => MimeType::Text, None => MimeType::Text,
}; };
let copy_result = let copy_result = opts
opts.copy(Source::Bytes(contents.clone().into()), mime_type); .copy(Source::Bytes(contents.clone().into()), mime_type);
match copy_result { match copy_result {
Ok(()) => { Ok(()) => {
let _ = Notification::new() let _ = Notification::new()
@ -553,6 +701,7 @@ impl SqliteClipboardDb {
}, },
} }
} }
}
// Redraw once after processing all accumulated input. // Redraw once after processing all accumulated input.
draw_frame( draw_frame(

View file

@ -734,22 +734,50 @@ impl ClipboardDb for SqliteClipboardDb {
} }
impl SqliteClipboardDb { impl SqliteClipboardDb {
/// Count visible clipboard entries (respects include_expired filter). /// Count visible clipboard entries, with respect to `include_expired` and
/// optional search filter.
pub fn count_entries( pub fn count_entries(
&self, &self,
include_expired: bool, include_expired: bool,
search: Option<&str>,
) -> Result<usize, StashError> { ) -> Result<usize, StashError> {
let count: i64 = if include_expired { let search_pattern = search.map(|s| {
// Avoid backslash escaping issues
let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_");
format!("%{escaped}%")
});
let count: i64 = match (include_expired, search_pattern.as_deref()) {
(true, None) => {
self self
.conn .conn
.query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0))
} else { },
(true, Some(pattern)) => {
self.conn.query_row(
"SELECT COUNT(*) FROM clipboard WHERE (LOWER(CAST(contents AS \
TEXT)) LIKE LOWER(?1) ESCAPE '!')",
[pattern],
|r| r.get(0),
)
},
(false, None) => {
self.conn.query_row( self.conn.query_row(
"SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0)", is_expired = 0)",
[], [],
|r| r.get(0), |r| r.get(0),
) )
},
(false, Some(pattern)) => {
self.conn.query_row(
"SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) \
ESCAPE '!')",
[pattern],
|r| r.get(0),
)
},
} }
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
Ok(count.max(0) as usize) Ok(count.max(0) as usize)
@ -760,28 +788,58 @@ impl SqliteClipboardDb {
/// Returns `(id, preview_string, mime_string)` tuples for at most /// Returns `(id, preview_string, mime_string)` tuples for at most
/// `limit` rows starting at `offset` (0-indexed) in the canonical /// `limit` rows starting at `offset` (0-indexed) in the canonical
/// display order (most-recently-accessed first, then id DESC). /// display order (most-recently-accessed first, then id DESC).
/// Optionally filters by search query in a case-insensitive nabber on text
/// content.
pub fn fetch_entries_window( pub fn fetch_entries_window(
&self, &self,
include_expired: bool, include_expired: bool,
offset: usize, offset: usize,
limit: usize, limit: usize,
preview_width: u32, preview_width: u32,
search: Option<&str>,
) -> Result<Vec<(i64, String, String)>, StashError> { ) -> Result<Vec<(i64, String, String)>, StashError> {
let query = if include_expired { let search_pattern = search.map(|s| {
let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_");
format!("%{escaped}%")
});
let query = match (include_expired, search_pattern.as_deref()) {
(true, None) => {
"SELECT id, contents, mime FROM clipboard ORDER BY \ "SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2"
} else { },
(true, Some(_)) => {
"SELECT id, contents, mime FROM clipboard WHERE (LOWER(CAST(contents \
AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, \
0) DESC, id DESC LIMIT ?1 OFFSET ?2"
},
(false, None) => {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC \
LIMIT ?1 OFFSET ?2"
},
(false, Some(_)) => {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) \
ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \
?1 OFFSET ?2" ?1 OFFSET ?2"
},
}; };
let mut stmt = self let mut stmt = self
.conn .conn
.prepare(query) .prepare(query)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
let mut rows = if let Some(pattern) = search_pattern.as_deref() {
stmt
.query(rusqlite::params![limit as i64, offset as i64, pattern])
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
} else {
stmt
.query(rusqlite::params![limit as i64, offset as i64]) .query(rusqlite::params![limit as i64, offset as i64])
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?
};
let mut window = Vec::with_capacity(limit); let mut window = Vec::with_capacity(limit);
while let Some(row) = rows while let Some(row) = rows