diff --git a/src/commands/list.rs b/src/commands/list.rs index 25903f3..03309aa 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -27,6 +27,239 @@ impl ListCommand for SqliteClipboardDb { } } +/// All mutable state for the TUI list view. +struct TuiState { + /// Total number of entries matching the current filter in the DB. + total: usize, + + /// Global cursor position: index into the full ordered result set. + cursor: usize, + + /// DB offset of `window[0]`, i.e., the first row currently loaded. + viewport_offset: usize, + + /// The loaded slice of entries: `(id, preview, mime)`. + window: Vec<(i64, String, String)>, + + /// How many rows the window holds (== visible list height). + window_size: usize, + + /// 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 { + /// Create initial state: count total rows, load the first window. + fn new( + db: &SqliteClipboardDb, + include_expired: bool, + window_size: usize, + preview_width: u32, + ) -> Result { + let total = db.count_entries(include_expired, None)?; + let window = if total > 0 { + db.fetch_entries_window( + include_expired, + 0, + window_size, + preview_width, + None, + )? + } else { + Vec::new() + }; + Ok(Self { + total, + cursor: 0, + viewport_offset: 0, + 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] + fn local_cursor(&self) -> usize { + self.cursor.saturating_sub(self.viewport_offset) + } + + /// Return the selected `(id, preview, mime)` if any entry is selected. + fn selected_entry(&self) -> Option<&(i64, String, String)> { + if self.total == 0 { + return None; + } + self.window.get(self.local_cursor()) + } + + /// Move the cursor down by one, wrapping to 0 at the bottom. + fn move_down(&mut self) { + if self.total == 0 { + return; + } + self.cursor = if self.cursor + 1 >= self.total { + 0 + } else { + self.cursor + 1 + }; + self.dirty = true; + } + + /// Move the cursor up by one, wrapping to `total - 1` at the top. + fn move_up(&mut self) { + if self.total == 0 { + return; + } + self.cursor = if self.cursor == 0 { + self.total - 1 + } else { + self.cursor - 1 + }; + self.dirty = true; + } + + /// Resize the window (e.g. terminal resized). Marks dirty so the + /// viewport is reloaded on the next frame. + fn resize(&mut self, new_size: usize) { + if new_size != self.window_size { + self.window_size = new_size; + self.dirty = true; + } + } + + /// After a delete the total shrinks by one and the cursor may need + /// clamping. The caller is responsible for the DB deletion itself. + fn on_delete(&mut self) { + if self.total == 0 { + return; + } + self.total -= 1; + if self.total == 0 { + self.cursor = 0; + } else if self.cursor >= self.total { + self.cursor = self.total - 1; + } + self.dirty = true; + } + + /// Reload the window from the DB if `dirty` is set or if the cursor + /// has drifted outside the currently loaded range. + fn sync( + &mut self, + db: &SqliteClipboardDb, + include_expired: bool, + preview_width: u32, + ) -> Result<(), StashError> { + let cursor_out_of_window = self.cursor < self.viewport_offset + || self.cursor >= self.viewport_offset + self.window.len().max(1); + + if !self.dirty && !cursor_out_of_window { + return Ok(()); + } + + // Re-anchor the viewport so the cursor sits in the upper half when + // scrolling downward, or at a sensible position when wrapping. + let half = self.window_size / 2; + self.viewport_offset = if self.cursor >= half { + (self.cursor - half).min(self.total.saturating_sub(self.window_size)) + } else { + 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() + }; + self.dirty = false; + Ok(()) + } +} + +/// 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, +/// not blobs. +fn global_column_widths( + db: &SqliteClipboardDb, + include_expired: bool, +) -> Result<(usize, usize), StashError> { + let filter = if include_expired { + "" + } else { + "WHERE (is_expired IS NULL OR is_expired = 0)" + }; + let query = format!( + "SELECT COALESCE(MAX(LENGTH(CAST(id AS TEXT))), 2), \ + COALESCE(MAX(LENGTH(mime)), 8) FROM clipboard {filter}" + ); + let (id_w, mime_w): (i64, i64) = db + .conn + .query_row(&query, [], |r| Ok((r.get(0)?, r.get(1)?))) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + Ok((id_w.max(2) as usize, mime_w.max(8) as usize)) +} + impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] pub fn list_tui( @@ -63,46 +296,9 @@ impl SqliteClipboardDb { }; use wl_clipboard_rs::copy::{MimeType, Options, Source}; - // Query entries from DB - let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \ - id DESC" - } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY last_accessed DESC, id DESC" - }; - let mut stmt = self - .conn - .prepare(query) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let mut entries: Vec<(i64, 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().into()))? - { - let id: i64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let preview = - crate::db::preview_entry(&contents, mime.as_deref(), preview_width); - let mime_str = mime.as_deref().unwrap_or("").to_string(); - 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)); - } + // One-time column-width metadata (no blob reads). + let (max_id_width, max_mime_width) = + global_column_widths(self, include_expired)?; enable_raw_mode() .map_err(|e| StashError::ListDecode(e.to_string().into()))?; @@ -113,35 +309,155 @@ impl SqliteClipboardDb { let mut terminal = Terminal::new(backend) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut state = ListState::default(); - if !entries.is_empty() { - state.select(Some(0)); + // Derive initial window size from current terminal height. + let initial_height = terminal + .size() + .map(|r| r.height.saturating_sub(2) as usize) + .unwrap_or(24); + let initial_height = initial_height.max(1); + + let mut tui = + TuiState::new(self, include_expired, initial_height, preview_width)?; + + // ratatui ListState; only tracks selection within the *window* slice. + let mut list_state = ListState::default(); + if tui.total > 0 { + list_state.select(Some(0)); } - let res = (|| -> Result<(), StashError> { - loop { + /// Accumulated actions from draining the event queue. + struct EventActions { + 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 + /// the key-repeat rate exceeds the render frame rate. + fn drain_events(tui: &TuiState) -> Result { + let mut actions = EventActions { + 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)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + if let Event::Key(key) = event::read() + .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) { + (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, + _ => {}, + } + } + } + } + Ok(actions) + } + + let draw_frame = + |terminal: &mut Terminal>, + tui: &mut TuiState, + list_state: &mut ListState, + 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 + search_bar_height) as usize) + .unwrap_or(24) + .max(1); + tui.resize(term_height); + tui.sync(self, include_expired, preview_width)?; + + if tui.total == 0 { + list_state.select(None); + } else { + list_state.select(Some(tui.local_cursor())); + } + 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 = ">"; 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] + let spaces = 3; - // 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 @@ -150,7 +466,6 @@ impl SqliteClipboardDb { .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 { @@ -173,13 +488,13 @@ impl SqliteClipboardDb { preview_col = min_preview_width; } - let selected = state.selected(); + let selected = list_state.selected(); - let list_items: Vec = entries + let list_items: Vec = tui + .window .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) { @@ -191,7 +506,6 @@ impl SqliteClipboardDb { 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) { @@ -204,8 +518,6 @@ impl SqliteClipboardDb { 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 { @@ -252,133 +564,153 @@ impl SqliteClipboardDb { .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) - .highlight_symbol(""); // handled manually + .highlight_symbol(""); - f.render_stateful_widget(list, area, &mut state); + f.render_stateful_widget(list, area, list_state); }) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + Ok(()) + }; + // Initial draw. + draw_frame( + &mut terminal, + &mut tui, + &mut list_state, + max_id_width, + max_mime_width, + )?; + + let res = (|| -> Result<(), StashError> { + loop { + // Block waiting for events, then drain and process all queued input. if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? - && 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, _) => break, - (KeyCode::Down | KeyCode::Char('j'), _) => { - if entries.is_empty() { - state.select(None); - } else { - 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'), _) => { - if entries.is_empty() { - state.select(None); - } else { - let i = match state.selected() { - Some(i) => { - if i == 0 { - entries.len() - 1 - } else { - i - 1 - } - }, - None => 0, - }; - state.select(Some(i)); - } - }, - (KeyCode::Enter, _) => { - if let Some(idx) = state.selected() - && let Some((id, ..)) = entries.get(idx) - { - match self.copy_entry(*id) { - Ok((new_id, contents, mime)) => { - if new_id != *id { - entries[idx] = ( - new_id, - entries[idx].1.clone(), - entries[idx].2.clone(), - ); - } - 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(); - }, - } - } - }, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - if let Some(idx) = state.selected() - && let Some((id, ..)) = entries.get(idx) - { - // Delete entry from DB - self - .conn - .execute( - "DELETE FROM clipboard WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| { - StashError::DeleteEntry(*id, e.to_string().into()) - })?; - // Remove from entries and update selection - entries.remove(idx); - let new_len = entries.len(); - if new_len == 0 { - state.select(None); - } else if idx >= new_len { - state.select(Some(new_len - 1)); - } else { - state.select(Some(idx)); - } - // Show notification - let _ = Notification::new() - .summary("Stash") - .body("Deleted entry") - .show(); - } - }, - _ => {}, + 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 !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.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(); + }, + } + } + } + + // Redraw once after processing all accumulated input. + draw_frame( + &mut terminal, + &mut tui, + &mut list_state, + max_id_width, + max_mime_width, + )?; } } Ok(()) diff --git a/src/db/mod.rs b/src/db/mod.rs index 4b57ae5..ca8ed37 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -734,6 +734,134 @@ impl ClipboardDb for SqliteClipboardDb { } impl SqliteClipboardDb { + /// 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 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) + } + + /// Fetch a window of entries for TUI virtual scrolling. + /// + /// 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 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 = 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 + .next() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + let id: i64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let preview = preview_entry(&contents, mime.as_deref(), preview_width); + let mime_str = mime.unwrap_or_default(); + window.push((id, preview, mime_str)); + } + Ok(window) + } + /// Get current Unix timestamp with sub-second precision pub fn now() -> f64 { std::time::SystemTime::now()