This commit is contained in:
LoricAndre 2026-07-16 20:27:41 +00:00 committed by GitHub
commit 20c5f625c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 2368 additions and 29 deletions

2114
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,10 @@ ouroboros = "0.18.3"
serde = { version = "1.0.116", features = ["derive"] }
time = { version = "0.3.47", default-features = false, features = ["parsing", "macros", "std"] }
ratatui = { version = "0.30.0", optional = true }
shlex = { version = "2.0.1", optional = true }
skim = { version = "5.1.2", default-features = false, features = ["cli"], optional = true }
[target.'cfg(windows)'.dependencies]
which = "8.0.2"
@ -51,6 +55,7 @@ tempfile = "3.15.0"
[features]
default = []
nix-dev = []
skim-tui = ["dep:ratatui", "dep:shlex", "dep:skim"]
[profile.release]
codegen-units = 1

View File

@ -8,6 +8,7 @@ use crate::error::BrokenPipeHandler;
use crate::util::{self, Fzf, FzfChild};
impl Run for Edit {
#[cfg_attr(feature = "skim-tui", allow(unreachable_code))]
fn run(&self) -> Result<()> {
let now = util::current_time()?;
let db = &mut Database::open()?;
@ -34,6 +35,9 @@ impl Run for Edit {
None => {
db.sort_by_score(now);
db.save()?;
#[cfg(feature = "skim-tui")]
return crate::skim::edit(db);
Self::get_fzf()?.wait()?;
Ok(())
}

View File

@ -29,7 +29,11 @@ impl Query {
}
}
#[cfg_attr(feature = "skim-tui", allow(unreachable_code))]
fn query_interactive(&self, stream: &mut Stream, now: Epoch) -> Result<()> {
#[cfg(feature = "skim-tui")]
return crate::skim::query(self, stream, now);
let mut fzf = Self::get_fzf()?;
let selection = loop {
match stream.next() {

View File

@ -6,6 +6,8 @@ mod db;
mod error;
mod import;
mod shell;
#[cfg(feature = "skim-tui")]
mod skim;
mod util;
use std::env;

268
src/skim.rs Normal file
View File

@ -0,0 +1,268 @@
use std::borrow::Cow;
use std::env;
use std::io;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, anyhow, bail};
use clap::Parser;
use ratatui::text::Line;
use skim::tui::event::{Action, ActionCallback, Event};
use skim::{DisplayContext, Matches, Skim, SkimItem, SkimOptions};
use crate::cmd::{EditCommand, Query};
use crate::db::{Database, Dir, Epoch, Stream};
use crate::error::SilentExit;
use crate::{config, util};
pub fn options(overrides: &[&str]) -> Result<SkimOptions> {
let skim_opts = env::var_os("SKIM_DEFAULT_OPTIONS")
.map(|opts| {
opts.into_string().map_err(|_| anyhow!("invalid unicode in SKIM_DEFAULT_OPTIONS"))
})
.transpose()?;
let fzf_opts = config::fzf_opts()
.map(|opts| opts.into_string().map_err(|_| anyhow!("invalid unicode in _ZO_FZF_OPTS")))
.transpose()?;
parse_options(overrides, skim_opts.as_deref(), fzf_opts.as_deref())
}
fn parse_options(
overrides: &[&str],
skim_opts: Option<&str>,
fzf_opts: Option<&str>,
) -> Result<SkimOptions> {
let mut args = vec!["zoxide".to_owned()];
if let Some(opts) = skim_opts {
args.extend(shlex::split(opts).context("could not parse SKIM_DEFAULT_OPTIONS")?);
}
args.extend(overrides.iter().map(ToString::to_string));
if let Some(opts) = fzf_opts {
args.extend(shlex::split(opts).context("could not parse _ZO_FZF_OPTS")?);
}
SkimOptions::try_parse_from(args).context("invalid skim options")
}
impl SkimItem for Dir<'static> {
fn text(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.path)
}
fn display(&self, mut context: DisplayContext) -> Line<'_> {
const SCORE_WIDTH: usize = 7;
match &mut context.matches {
Matches::CharIndices(indices) => {
indices.iter_mut().for_each(|index| *index += SCORE_WIDTH)
}
Matches::CharRange(start, end) | Matches::ByteRange(start, end) => {
*start += SCORE_WIDTH;
*end += SCORE_WIDTH;
}
Matches::None => {}
}
let now = util::current_time().unwrap_or(self.last_accessed);
context.to_line(Cow::Owned(
Dir::display(self).with_score(now).with_separator('\t').to_string(),
))
}
}
pub fn query(query: &Query, stream: &mut Stream, now: Epoch) -> Result<()> {
let mut dirs = Vec::new();
while let Some(dir) = stream.next() {
if Some(dir.path.as_ref()) != query.exclude.as_deref() {
dirs.push(Dir {
path: Cow::Owned(dir.path.clone().into_owned()),
rank: dir.rank,
last_accessed: dir.last_accessed,
});
}
}
let mut args = vec![
"--exact",
"--no-sort",
"--bind=ctrl-z:ignore,btab:up,tab:down",
"--cycle",
"--keep-right",
"--border=sharp",
"--height=45%",
"--info=inline",
"--layout=reverse",
"--tabstop=1",
"--exit-0",
];
if cfg!(unix) {
args.extend([
if cfg!(target_os = "linux") {
r"--preview=\command -p ls -Cp --color=always --group-directories-first {}"
} else {
r"--preview=\command -p ls -Cp {}"
},
"--preview-window=down,30%,sharp",
]);
}
let (tx, rx) = skim::prelude::unbounded();
tx.send(dirs.into_iter().map(|dir| Arc::new(dir) as _).collect())?;
drop(tx);
let output =
Skim::run_with(options(&args)?, Some(rx)).map_err(|error| anyhow!(error.to_string()))?;
if output.is_abort {
bail!(SilentExit { code: 130 });
}
let dir = output
.selected_items
.first()
.and_then(|item| item.as_any().downcast_ref::<Dir<'static>>())
.context("no match found")?;
if query.score {
print!("{}", dir.display().with_score(now));
} else {
print!("{}", dir.path);
}
Ok(())
}
#[derive(Clone, Copy)]
enum Edit {
Decrement,
Delete,
Increment,
Reload,
}
impl Edit {
fn command(self, path: String) -> Option<EditCommand> {
match self {
Self::Decrement => Some(EditCommand::Decrement { path }),
Self::Delete => Some(EditCommand::Delete { path }),
Self::Increment => Some(EditCommand::Increment { path }),
Self::Reload => None,
}
}
}
fn apply_edit(db: &mut Database, edit: &EditCommand, now: Epoch) {
match edit {
EditCommand::Decrement { path } => db.add(path, -1.0, now),
EditCommand::Delete { path } => {
db.remove(path);
}
EditCommand::Increment { path } => db.add(path, 1.0, now),
EditCommand::Reload => {}
}
}
fn owned_dirs(db: &Database) -> Vec<Dir<'static>> {
db.dirs()
.iter()
.rev()
.map(|dir| Dir {
path: Cow::Owned(dir.path.clone().into_owned()),
rank: dir.rank,
last_accessed: dir.last_accessed,
})
.collect()
}
fn edit_action(dirs: Arc<Mutex<Vec<Dir<'static>>>>, edit: Edit) -> Action {
Action::Custom(ActionCallback::new_sync(move |app| {
let path = app.item_list.selected().and_then(|item| {
item.as_any().downcast_ref::<Dir<'static>>().map(|dir| dir.path.clone().into_owned())
});
let command = path.and_then(|path| edit.command(path));
if command.is_none() && !matches!(edit, Edit::Reload) {
return Ok(Vec::new());
}
let mut db = Database::open().map_err(|error| io::Error::other(error.to_string()))?;
if let Some(command) = command {
apply_edit(
&mut db,
&command,
util::current_time().map_err(|error| io::Error::other(error.to_string()))?,
);
db.save().map_err(|error| io::Error::other(error.to_string()))?;
}
let items = owned_dirs(&db);
*dirs.lock().map_err(|_| io::Error::other("skim directory lock poisoned"))? = items.clone();
Ok(vec![
Event::ClearItems,
Event::AppendItems(
items.into_iter().map(|dir| Arc::new(dir) as Arc<dyn SkimItem>).collect(),
),
])
}))
}
pub fn edit(db: &mut Database) -> Result<()> {
let dirs = Arc::new(Mutex::new(owned_dirs(db)));
let mut args = vec![
"--exact",
"--no-sort",
"--bind=btab:up,ctrl-z:ignore,double-click:ignore,enter:abort,tab:down",
"--cycle",
"--keep-right",
"--border=sharp",
"--header=ctrl-r:reload ctrl-d:delete\nctrl-w:increment ctrl-s:decrement\n\n SCORE\tPATH",
"--info=inline",
"--layout=reverse",
"--tabstop=1",
];
if cfg!(unix) {
args.extend([
if cfg!(target_os = "linux") {
r"--preview=\command -p ls -Cp --color=always --group-directories-first {}"
} else {
r"--preview=\command -p ls -Cp {}"
},
"--preview-window=down:30%",
]);
}
let mut options = options(&args)?;
for (key, edit) in [
("ctrl-r", Edit::Reload),
("ctrl-d", Edit::Delete),
("ctrl-w", Edit::Increment),
("ctrl-s", Edit::Decrement),
] {
options.keymap.insert(
skim::binds::parse_key(key).map_err(|error| anyhow!(error))?,
vec![edit_action(dirs.clone(), edit)],
);
}
let items = dirs.lock().map_err(|_| anyhow!("skim directory lock poisoned"))?.clone();
let output = Skim::run_items(options, items).map_err(|error| anyhow!(error.to_string()))?;
if output.is_abort {
bail!(SilentExit { code: 130 });
}
Ok(())
}
#[cfg(test)]
mod tests {
use skim::{DisplayContext, SkimItem};
use super::{Edit, parse_options};
use crate::db::Dir;
use crate::{cmd::EditCommand, util};
#[test]
fn skim_options_and_edits() -> anyhow::Result<()> {
let options = parse_options(
&["--prompt=default"],
Some("--prompt='skim: '"),
Some("--prompt='zoxide: '"),
)?;
assert_eq!(options.prompt, "zoxide: ");
assert!(matches!(
Edit::Increment.command("/tmp".to_owned()),
Some(EditCommand::Increment { path }) if path == "/tmp"
));
assert!(Edit::Reload.command(String::new()).is_none());
let dir =
Dir { path: "/tmp".to_owned().into(), rank: 2.0, last_accessed: util::current_time()? };
assert_eq!(SkimItem::display(&dir, DisplayContext::default()).to_string(), " 8.0\t/tmp");
Ok(())
}
}