diff --git a/src/commands/list.rs b/src/commands/list.rs index 03309aa..3f1fd62 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -11,6 +11,7 @@ pub trait ListCommand { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError>; } @@ -20,9 +21,10 @@ impl ListCommand for SqliteClipboardDb { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError> { self - .list_entries(out, preview_width, include_expired) + .list_entries(out, preview_width, include_expired, reverse) .map(|_| ()) } } @@ -52,6 +54,9 @@ struct TuiState { /// Whether we're currently in search input mode. search_mode: bool, + + /// Whether to show entries in reverse order (oldest first). + reverse: bool, } impl TuiState { @@ -61,6 +66,7 @@ impl TuiState { include_expired: bool, window_size: usize, preview_width: u32, + reverse: bool, ) -> Result { let total = db.count_entries(include_expired, None)?; let window = if total > 0 { @@ -70,6 +76,7 @@ impl TuiState { window_size, preview_width, None, + reverse, )? } else { Vec::new() @@ -83,6 +90,7 @@ impl TuiState { dirty: false, search_query: String::new(), search_mode: false, + reverse, }) } @@ -228,6 +236,7 @@ impl TuiState { self.window_size, preview_width, search, + self.reverse, )? } else { Vec::new() @@ -266,6 +275,7 @@ impl SqliteClipboardDb { &self, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError> { use std::io::stdout; @@ -316,8 +326,13 @@ impl SqliteClipboardDb { .unwrap_or(24); let initial_height = initial_height.max(1); - let mut tui = - TuiState::new(self, include_expired, initial_height, preview_width)?; + let mut tui = TuiState::new( + self, + include_expired, + initial_height, + preview_width, + reverse, + )?; // ratatui ListState; only tracks selection within the *window* slice. let mut list_state = ListState::default(); diff --git a/src/db/mod.rs b/src/db/mod.rs index 5bbfffb..2c3921f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -89,6 +89,7 @@ pub trait ClipboardDb { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result; fn decode_entry( &self, @@ -362,17 +363,27 @@ impl SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn list_json(&self, include_expired: bool) -> Result { + pub fn list_json( + &self, + include_expired: bool, + reverse: bool, + ) -> Result { + let order = if reverse { "ASC" } else { "DESC" }; let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order}" + ) } 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" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order}" + ) }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -594,17 +605,24 @@ impl ClipboardDb for SqliteClipboardDb { mut out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result { + let order = if reverse { "ASC" } else { "DESC" }; let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order}" + ) } 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" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order}" + ) }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -818,38 +836,48 @@ impl SqliteClipboardDb { limit: usize, preview_width: u32, search: Option<&str>, + reverse: bool, ) -> Result, StashError> { let search_pattern = search.map(|s| { let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); format!("%{escaped}%") }); + let order = if reverse { "ASC" } else { "DESC" }; 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" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order} 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" + format!( + "SELECT id, contents, mime FROM clipboard WHERE \ + (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order} 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" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order} 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" + format!( + "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) {order}, \ + id {order} LIMIT ?1 OFFSET ?2" + ) }, }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = if let Some(pattern) = search_pattern.as_deref() { diff --git a/src/main.rs b/src/main.rs index ef12ed1..fd74b1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,10 @@ enum Command { /// Show only expired entries (diagnostic, does not remove them) #[arg(long)] expired: bool, + + /// Reverse the order of entries (oldest first instead of newest first) + #[arg(long)] + reverse: bool, }, /// Decode and output clipboard entry by id @@ -245,16 +249,20 @@ fn main() -> color_eyre::eyre::Result<()> { "failed to store entry", ); }, - Some(Command::List { format, expired }) => { + Some(Command::List { + format, + expired, + reverse, + }) => { match format.as_deref() { Some("tsv") => { report_error( - db.list(io::stdout(), cli.preview_width, expired), + db.list(io::stdout(), cli.preview_width, expired, reverse), "failed to list entries", ); }, Some("json") => { - match db.list_json(expired) { + match db.list_json(expired, reverse) { Ok(json) => { println!("{json}"); }, @@ -269,12 +277,12 @@ fn main() -> color_eyre::eyre::Result<()> { None => { if std::io::stdout().is_terminal() { report_error( - db.list_tui(cli.preview_width, expired), + db.list_tui(cli.preview_width, expired, reverse), "failed to list entries in TUI", ); } else { report_error( - db.list(io::stdout(), cli.preview_width, expired), + db.list(io::stdout(), cli.preview_width, expired, reverse), "failed to list entries", ); }