mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-12 14:07:42 +00:00
commands/list: implement clipboard history search
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I57f00cbd9d02b1981cf3ea5dc908e72c6a6a6964
This commit is contained in:
parent
88c1f0f158
commit
b850a54f7b
2 changed files with 325 additions and 118 deletions
|
|
@ -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()
|
||||||
|
|
@ -177,7 +238,7 @@ impl TuiState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query the maximum id digit-width and maximum mime byte-length across
|
/// 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.
|
/// not blobs.
|
||||||
fn global_column_widths(
|
fn global_column_widths(
|
||||||
db: &SqliteClipboardDb,
|
db: &SqliteClipboardDb,
|
||||||
|
|
@ -266,21 +327,29 @@ impl SqliteClipboardDb {
|
||||||
|
|
||||||
/// Accumulated actions from draining the event queue.
|
/// Accumulated actions from draining the event queue.
|
||||||
struct EventActions {
|
struct EventActions {
|
||||||
quit: bool,
|
quit: bool,
|
||||||
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,23 +358,46 @@ 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()))?
|
||||||
{
|
{
|
||||||
match (key.code, key.modifiers) {
|
if tui.search_mode {
|
||||||
(KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true,
|
// In search mode, handle text input
|
||||||
(KeyCode::Down | KeyCode::Char('j'), _) => {
|
match (key.code, key.modifiers) {
|
||||||
// Cap at +1 per frame for smooth scrolling
|
(KeyCode::Esc, _) => {
|
||||||
if actions.net_down < 1 {
|
actions.clear_search = true;
|
||||||
actions.net_down += 1;
|
},
|
||||||
}
|
(KeyCode::Enter, _) => {
|
||||||
},
|
actions.toggle_search = true; // exit search mode
|
||||||
(KeyCode::Up | KeyCode::Char('k'), _) => {
|
},
|
||||||
// Cap at -1 per frame for smooth scrolling
|
(KeyCode::Backspace, _) => {
|
||||||
if actions.net_down > -1 {
|
actions.search_backspace = true;
|
||||||
actions.net_down -= 1;
|
},
|
||||||
}
|
(KeyCode::Char(c), _) => {
|
||||||
},
|
actions.search_input = Some(c);
|
||||||
(KeyCode::Enter, _) => actions.copy = true,
|
},
|
||||||
(KeyCode::Char('D'), KeyModifiers::SHIFT) => actions.delete = true,
|
_ => {},
|
||||||
_ => {},
|
}
|
||||||
|
} 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_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,75 +587,119 @@ 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 actions.net_down > 0 {
|
if !tui.search_mode {
|
||||||
tui.move_down();
|
if actions.net_down > 0 {
|
||||||
} else if actions.net_down < 0 {
|
tui.move_down();
|
||||||
tui.move_up();
|
} else if actions.net_down < 0 {
|
||||||
}
|
tui.move_up();
|
||||||
|
}
|
||||||
|
|
||||||
if actions.delete
|
if actions.delete
|
||||||
&& let Some(&(id, ..)) = tui.selected_entry()
|
&& let Some(&(id, ..)) = tui.selected_entry()
|
||||||
{
|
{
|
||||||
self
|
self
|
||||||
.conn
|
.conn
|
||||||
.execute(
|
.execute(
|
||||||
"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| {
|
||||||
tui.on_delete();
|
StashError::DeleteEntry(id, e.to_string().into())
|
||||||
let _ = Notification::new()
|
})?;
|
||||||
.summary("Stash")
|
tui.on_delete();
|
||||||
.body("Deleted entry")
|
let _ = Notification::new()
|
||||||
.show();
|
.summary("Stash")
|
||||||
}
|
.body("Deleted entry")
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
if actions.copy
|
if actions.copy
|
||||||
&& let Some(&(id, ..)) = tui.selected_entry()
|
&& let Some(&(id, ..)) = tui.selected_entry()
|
||||||
{
|
{
|
||||||
match self.copy_entry(id) {
|
match self.copy_entry(id) {
|
||||||
Ok((new_id, contents, mime)) => {
|
Ok((new_id, contents, mime)) => {
|
||||||
if new_id != id {
|
if new_id != id {
|
||||||
tui.dirty = true;
|
tui.dirty = true;
|
||||||
}
|
}
|
||||||
let opts = Options::new();
|
let opts = Options::new();
|
||||||
let mime_type = match mime {
|
let mime_type = match mime {
|
||||||
Some(ref m) if m == "text/plain" => MimeType::Text,
|
Some(ref m) if m == "text/plain" => MimeType::Text,
|
||||||
Some(ref m) => MimeType::Specific(m.clone().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()
|
||||||
.summary("Stash")
|
.summary("Stash")
|
||||||
.body("Copied entry to clipboard")
|
.body("Copied entry to clipboard")
|
||||||
.show();
|
.show();
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to copy entry to clipboard: {e}");
|
log::error!("Failed to copy entry to clipboard: {e}");
|
||||||
let _ = Notification::new()
|
let _ = Notification::new()
|
||||||
.summary("Stash")
|
.summary("Stash")
|
||||||
.body(&format!("Failed to copy to clipboard: {e}"))
|
.body(&format!("Failed to copy to clipboard: {e}"))
|
||||||
.show();
|
.show();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to fetch entry {id}: {e}");
|
log::error!("Failed to fetch entry {id}: {e}");
|
||||||
let _ = Notification::new()
|
let _ = Notification::new()
|
||||||
.summary("Stash")
|
.summary("Stash")
|
||||||
.body(&format!("Failed to fetch entry: {e}"))
|
.body(&format!("Failed to fetch entry: {e}"))
|
||||||
.show();
|
.show();
|
||||||
},
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
102
src/db/mod.rs
102
src/db/mod.rs
|
|
@ -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| {
|
||||||
self
|
// Avoid backslash escaping issues
|
||||||
.conn
|
let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_");
|
||||||
.query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0))
|
format!("%{escaped}%")
|
||||||
} else {
|
});
|
||||||
self.conn.query_row(
|
|
||||||
"SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \
|
let count: i64 = match (include_expired, search_pattern.as_deref()) {
|
||||||
is_expired = 0)",
|
(true, None) => {
|
||||||
[],
|
self
|
||||||
|r| r.get(0),
|
.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()))?;
|
.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| {
|
||||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_");
|
||||||
COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2"
|
format!("%{escaped}%")
|
||||||
} 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 \
|
let query = match (include_expired, search_pattern.as_deref()) {
|
||||||
?1 OFFSET ?2"
|
(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
|
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
|
|
||||||
.query(rusqlite::params![limit as i64, offset as i64])
|
let mut rows = if let Some(pattern) = search_pattern.as_deref() {
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
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);
|
let mut window = Vec::with_capacity(limit);
|
||||||
while let Some(row) = rows
|
while let Some(row) = rows
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue