diff --git a/src/commands/list.rs b/src/commands/list.rs index 2651370..03309aa 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -46,6 +46,12 @@ struct TuiState { /// Whether the window needs to be re-fetched from the DB. 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 { @@ -56,9 +62,15 @@ impl TuiState { window_size: usize, preview_width: u32, ) -> Result { - let total = db.count_entries(include_expired)?; + let total = db.count_entries(include_expired, None)?; 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 { Vec::new() }; @@ -69,9 +81,56 @@ impl TuiState { window, window_size, 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 /// (`window[local_cursor]` == the selected entry). #[inline] @@ -161,12 +220,14 @@ impl TuiState { 0 }; + let search = self.search_filter(); self.window = if self.total > 0 { db.fetch_entries_window( include_expired, self.viewport_offset, self.window_size, preview_width, + search, )? } else { Vec::new() @@ -177,7 +238,7 @@ impl TuiState { } /// Query the maximum id digit-width and maximum mime byte-length across -/// all entries. This is pretty damn fast as it touches only index/metadata, +/// all entries. This is pretty damn fast as it touches only index/metadata, /// not blobs. fn global_column_widths( db: &SqliteClipboardDb, @@ -266,21 +327,29 @@ impl SqliteClipboardDb { /// Accumulated actions from draining the event queue. struct EventActions { - quit: bool, - net_down: i64, // positive=down, negative=up, 0=none - copy: bool, - delete: bool, + quit: bool, + net_down: i64, // positive=down, negative=up, 0=none + copy: bool, + delete: bool, + toggle_search: bool, // enter/exit search mode + search_input: Option, // 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. - /// 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. - fn drain_events() -> Result { + fn drain_events(tui: &TuiState) -> Result { let mut actions = EventActions { - quit: false, - net_down: 0, - copy: false, - delete: false, + quit: false, + net_down: 0, + copy: false, + delete: false, + toggle_search: false, + search_input: None, + search_backspace: false, + clear_search: false, }; while event::poll(std::time::Duration::from_millis(0)) @@ -289,23 +358,46 @@ impl SqliteClipboardDb { if let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - match (key.code, key.modifiers) { - (KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true, - (KeyCode::Down | KeyCode::Char('j'), _) => { - // Cap at +1 per frame for smooth scrolling - if actions.net_down < 1 { - actions.net_down += 1; - } - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { - // Cap at -1 per frame for smooth scrolling - if actions.net_down > -1 { - actions.net_down -= 1; - } - }, - (KeyCode::Enter, _) => actions.copy = true, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => actions.delete = true, - _ => {}, + 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) { + (KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true, + (KeyCode::Down | KeyCode::Char('j'), _) => { + // Cap at +1 per frame for smooth scrolling + if actions.net_down < 1 { + actions.net_down += 1; + } + }, + (KeyCode::Up | KeyCode::Char('k'), _) => { + // Cap at -1 per frame for smooth scrolling + if actions.net_down > -1 { + actions.net_down -= 1; + } + }, + (KeyCode::Enter, _) => actions.copy = true, + (KeyCode::Char('D'), KeyModifiers::SHIFT) => { + actions.delete = true + }, + (KeyCode::Char('/'), _) => actions.toggle_search = true, + _ => {}, + } } } } @@ -319,9 +411,11 @@ impl SqliteClipboardDb { max_id_width: usize, max_mime_width: usize| -> 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 .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) .max(1); tui.resize(term_height); @@ -336,12 +430,23 @@ impl SqliteClipboardDb { terminal .draw(|f| { let area = f.area(); - let block = Block::default() - .title( - "Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \ - to delete, q/ESC to quit)", + + // Build title based on search state + let title = if tui.search_mode { + 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 highlight_symbol = ">"; @@ -482,75 +587,119 @@ impl SqliteClipboardDb { if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let actions = drain_events()?; + let actions = drain_events(&tui)?; if actions.quit { 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::() + }) + .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). - if actions.net_down > 0 { - tui.move_down(); - } else if actions.net_down < 0 { - tui.move_up(); - } + if !tui.search_mode { + if actions.net_down > 0 { + tui.move_down(); + } else if actions.net_down < 0 { + tui.move_up(); + } - if actions.delete - && let Some(&(id, ..)) = tui.selected_entry() - { - self - .conn - .execute( - "DELETE FROM clipboard WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; - tui.on_delete(); - let _ = Notification::new() - .summary("Stash") - .body("Deleted entry") - .show(); - } + if actions.delete + && let Some(&(id, ..)) = tui.selected_entry() + { + self + .conn + .execute( + "DELETE FROM clipboard WHERE id = ?1", + rusqlite::params![id], + ) + .map_err(|e| { + StashError::DeleteEntry(id, e.to_string().into()) + })?; + tui.on_delete(); + let _ = Notification::new() + .summary("Stash") + .body("Deleted entry") + .show(); + } - if actions.copy - && let Some(&(id, ..)) = tui.selected_entry() - { - match self.copy_entry(id) { - Ok((new_id, contents, mime)) => { - if new_id != id { - tui.dirty = true; - } - let opts = Options::new(); - let mime_type = match mime { - Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => MimeType::Specific(m.clone().to_owned()), - None => MimeType::Text, - }; - let copy_result = - opts.copy(Source::Bytes(contents.clone().into()), mime_type); - match copy_result { - Ok(()) => { - let _ = Notification::new() - .summary("Stash") - .body("Copied entry to clipboard") - .show(); - }, - Err(e) => { - log::error!("Failed to copy entry to clipboard: {e}"); - let _ = Notification::new() - .summary("Stash") - .body(&format!("Failed to copy to clipboard: {e}")) - .show(); - }, - } - }, - Err(e) => { - log::error!("Failed to fetch entry {id}: {e}"); - let _ = Notification::new() - .summary("Stash") - .body(&format!("Failed to fetch entry: {e}")) - .show(); - }, + if actions.copy + && let Some(&(id, ..)) = tui.selected_entry() + { + match self.copy_entry(id) { + Ok((new_id, contents, mime)) => { + if new_id != id { + tui.dirty = true; + } + let opts = Options::new(); + let mime_type = match mime { + Some(ref m) if m == "text/plain" => MimeType::Text, + Some(ref m) => MimeType::Specific(m.clone().to_owned()), + None => MimeType::Text, + }; + let copy_result = opts + .copy(Source::Bytes(contents.clone().into()), mime_type); + match copy_result { + Ok(()) => { + let _ = Notification::new() + .summary("Stash") + .body("Copied entry to clipboard") + .show(); + }, + Err(e) => { + log::error!("Failed to copy entry to clipboard: {e}"); + let _ = Notification::new() + .summary("Stash") + .body(&format!("Failed to copy to clipboard: {e}")) + .show(); + }, + } + }, + Err(e) => { + log::error!("Failed to fetch entry {id}: {e}"); + let _ = Notification::new() + .summary("Stash") + .body(&format!("Failed to fetch entry: {e}")) + .show(); + }, + } } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 23e622e..ca8ed37 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -734,22 +734,50 @@ impl ClipboardDb for 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( &self, include_expired: bool, + search: Option<&str>, ) -> Result { - let count: i64 = if include_expired { - self - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) - } else { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0)", - [], - |r| r.get(0), - ) + 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 + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) + }, + (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( + "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 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()))?; Ok(count.max(0) as usize) @@ -760,28 +788,58 @@ impl SqliteClipboardDb { /// Returns `(id, preview_string, mime_string)` tuples for at most /// `limit` rows starting at `offset` (0-indexed) in the canonical /// 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( &self, include_expired: bool, offset: usize, limit: usize, preview_width: u32, + search: Option<&str>, ) -> Result, StashError> { - let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" - } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ - ?1 OFFSET ?2" + 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 \ + COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + }, + (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 \ + 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" + }, }; + let mut stmt = self .conn .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query(rusqlite::params![limit as i64, offset as i64]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + 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]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + }; let mut window = Vec::with_capacity(limit); while let Some(row) = rows