commands/list: allow printing in reversed order with --reverse

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I305cfdc68d877dc5d5083a76dccc62db6a6a6964
This commit is contained in:
raf 2026-02-27 14:53:25 +03:00
commit ffdc13e8f5
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 83 additions and 32 deletions

View file

@ -11,6 +11,7 @@ pub trait ListCommand {
out: impl Write, out: impl Write,
preview_width: u32, preview_width: u32,
include_expired: bool, include_expired: bool,
reverse: bool,
) -> Result<(), StashError>; ) -> Result<(), StashError>;
} }
@ -20,9 +21,10 @@ impl ListCommand for SqliteClipboardDb {
out: impl Write, out: impl Write,
preview_width: u32, preview_width: u32,
include_expired: bool, include_expired: bool,
reverse: bool,
) -> Result<(), StashError> { ) -> Result<(), StashError> {
self self
.list_entries(out, preview_width, include_expired) .list_entries(out, preview_width, include_expired, reverse)
.map(|_| ()) .map(|_| ())
} }
} }
@ -52,6 +54,9 @@ struct TuiState {
/// Whether we're currently in search input mode. /// Whether we're currently in search input mode.
search_mode: bool, search_mode: bool,
/// Whether to show entries in reverse order (oldest first).
reverse: bool,
} }
impl TuiState { impl TuiState {
@ -61,6 +66,7 @@ impl TuiState {
include_expired: bool, include_expired: bool,
window_size: usize, window_size: usize,
preview_width: u32, preview_width: u32,
reverse: bool,
) -> Result<Self, StashError> { ) -> Result<Self, StashError> {
let total = db.count_entries(include_expired, None)?; let total = db.count_entries(include_expired, None)?;
let window = if total > 0 { let window = if total > 0 {
@ -70,6 +76,7 @@ impl TuiState {
window_size, window_size,
preview_width, preview_width,
None, None,
reverse,
)? )?
} else { } else {
Vec::new() Vec::new()
@ -83,6 +90,7 @@ impl TuiState {
dirty: false, dirty: false,
search_query: String::new(), search_query: String::new(),
search_mode: false, search_mode: false,
reverse,
}) })
} }
@ -228,6 +236,7 @@ impl TuiState {
self.window_size, self.window_size,
preview_width, preview_width,
search, search,
self.reverse,
)? )?
} else { } else {
Vec::new() Vec::new()
@ -266,6 +275,7 @@ impl SqliteClipboardDb {
&self, &self,
preview_width: u32, preview_width: u32,
include_expired: bool, include_expired: bool,
reverse: bool,
) -> Result<(), StashError> { ) -> Result<(), StashError> {
use std::io::stdout; use std::io::stdout;
@ -316,8 +326,13 @@ impl SqliteClipboardDb {
.unwrap_or(24); .unwrap_or(24);
let initial_height = initial_height.max(1); let initial_height = initial_height.max(1);
let mut tui = let mut tui = TuiState::new(
TuiState::new(self, include_expired, initial_height, preview_width)?; self,
include_expired,
initial_height,
preview_width,
reverse,
)?;
// ratatui ListState; only tracks selection within the *window* slice. // ratatui ListState; only tracks selection within the *window* slice.
let mut list_state = ListState::default(); let mut list_state = ListState::default();

View file

@ -89,6 +89,7 @@ pub trait ClipboardDb {
out: impl Write, out: impl Write,
preview_width: u32, preview_width: u32,
include_expired: bool, include_expired: bool,
reverse: bool,
) -> Result<usize, StashError>; ) -> Result<usize, StashError>;
fn decode_entry( fn decode_entry(
&self, &self,
@ -362,17 +363,27 @@ impl SqliteClipboardDb {
} }
impl SqliteClipboardDb { impl SqliteClipboardDb {
pub fn list_json(&self, include_expired: bool) -> Result<String, StashError> { pub fn list_json(
&self,
include_expired: bool,
reverse: bool,
) -> Result<String, StashError> {
let order = if reverse { "ASC" } else { "DESC" };
let query = if include_expired { let query = if include_expired {
"SELECT id, contents, mime FROM clipboard ORDER BY \ format!(
COALESCE(last_accessed, 0) DESC, id DESC" "SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) {order}, id {order}"
)
} else { } else {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ format!(
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" "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 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 = stmt
.query([]) .query([])
@ -594,17 +605,24 @@ impl ClipboardDb for SqliteClipboardDb {
mut out: impl Write, mut out: impl Write,
preview_width: u32, preview_width: u32,
include_expired: bool, include_expired: bool,
reverse: bool,
) -> Result<usize, StashError> { ) -> Result<usize, StashError> {
let order = if reverse { "ASC" } else { "DESC" };
let query = if include_expired { let query = if include_expired {
"SELECT id, contents, mime FROM clipboard ORDER BY \ format!(
COALESCE(last_accessed, 0) DESC, id DESC" "SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) {order}, id {order}"
)
} else { } else {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ format!(
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" "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 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 = stmt
.query([]) .query([])
@ -818,38 +836,48 @@ impl SqliteClipboardDb {
limit: usize, limit: usize,
preview_width: u32, preview_width: u32,
search: Option<&str>, search: Option<&str>,
reverse: bool,
) -> Result<Vec<(i64, String, String)>, StashError> { ) -> Result<Vec<(i64, String, String)>, StashError> {
let search_pattern = search.map(|s| { let search_pattern = search.map(|s| {
let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_");
format!("%{escaped}%") format!("%{escaped}%")
}); });
let order = if reverse { "ASC" } else { "DESC" };
let query = match (include_expired, search_pattern.as_deref()) { let query = match (include_expired, search_pattern.as_deref()) {
(true, None) => { (true, None) => {
"SELECT id, contents, mime FROM clipboard ORDER BY \ format!(
COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" "SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2"
)
}, },
(true, Some(_)) => { (true, Some(_)) => {
"SELECT id, contents, mime FROM clipboard WHERE (LOWER(CAST(contents \ format!(
AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, \ "SELECT id, contents, mime FROM clipboard WHERE \
0) DESC, id DESC LIMIT ?1 OFFSET ?2" (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY \
COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2"
)
}, },
(false, None) => { (false, None) => {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ format!(
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC \ "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \
LIMIT ?1 OFFSET ?2" OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \
{order} LIMIT ?1 OFFSET ?2"
)
}, },
(false, Some(_)) => { (false, Some(_)) => {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ format!(
is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) \ "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \
ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ OR is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE \
?1 OFFSET ?2" LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) {order}, \
id {order} 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 = if let Some(pattern) = search_pattern.as_deref() { let mut rows = if let Some(pattern) = search_pattern.as_deref() {

View file

@ -91,6 +91,10 @@ enum Command {
/// Show only expired entries (diagnostic, does not remove them) /// Show only expired entries (diagnostic, does not remove them)
#[arg(long)] #[arg(long)]
expired: bool, expired: bool,
/// Reverse the order of entries (oldest first instead of newest first)
#[arg(long)]
reverse: bool,
}, },
/// Decode and output clipboard entry by id /// Decode and output clipboard entry by id
@ -245,16 +249,20 @@ fn main() -> color_eyre::eyre::Result<()> {
"failed to store entry", "failed to store entry",
); );
}, },
Some(Command::List { format, expired }) => { Some(Command::List {
format,
expired,
reverse,
}) => {
match format.as_deref() { match format.as_deref() {
Some("tsv") => { Some("tsv") => {
report_error( report_error(
db.list(io::stdout(), cli.preview_width, expired), db.list(io::stdout(), cli.preview_width, expired, reverse),
"failed to list entries", "failed to list entries",
); );
}, },
Some("json") => { Some("json") => {
match db.list_json(expired) { match db.list_json(expired, reverse) {
Ok(json) => { Ok(json) => {
println!("{json}"); println!("{json}");
}, },
@ -269,12 +277,12 @@ fn main() -> color_eyre::eyre::Result<()> {
None => { None => {
if std::io::stdout().is_terminal() { if std::io::stdout().is_terminal() {
report_error( report_error(
db.list_tui(cli.preview_width, expired), db.list_tui(cli.preview_width, expired, reverse),
"failed to list entries in TUI", "failed to list entries in TUI",
); );
} else { } else {
report_error( report_error(
db.list(io::stdout(), cli.preview_width, expired), db.list(io::stdout(), cli.preview_width, expired, reverse),
"failed to list entries", "failed to list entries",
); );
} }