Remove deleted entries if not accessed in the last 90 days

This commit is contained in:
Ajeet D'Souza 2021-05-17 11:39:26 +05:30
parent 97dc08347d
commit a4fcb39c8b
6 changed files with 104 additions and 43 deletions

View File

@ -8,6 +8,7 @@ zoxide is a smarter replacement for your cd command. It keeps track of the
directories you use most frequently, and uses a ranking algorithm to navigate
to the best match.
.SH USAGE
.nf
\fBz\fR \fIfoo\fR # cd into highest ranked directory matching foo
\fBz\fR \fIfoo bar\fR # cd into highest ranked directory matching foo and bar
.sp
@ -17,6 +18,7 @@ to the best match.
\fBz\fR \fI-\fR # cd into previous directory
.sp
\fBzi\fR \fIfoo\fR # cd with interactive selection (using fzf)
.fi
.SH SUBCOMMANDS
.TP
\fBzoxide-add\fR(1)

View File

@ -116,7 +116,7 @@ pub struct Query {
pub score: bool,
/// Exclude a path from results
#[clap(long, hidden = true)]
#[clap(long, value_name = "path")]
pub exclude: Option<String>,
}

View File

@ -1,6 +1,6 @@
use crate::app::{Query, Run};
use crate::config;
use crate::db::{DatabaseFile, Matcher};
use crate::db::DatabaseFile;
use crate::error::BrokenPipeHandler;
use crate::fzf::Fzf;
use crate::util;
@ -16,19 +16,18 @@ impl Run for Query {
let mut db = db.open()?;
let now = util::current_time()?;
let mut matcher = Matcher::new().with_keywords(&self.keywords);
let mut stream = db.stream(now).with_keywords(&self.keywords);
if !self.all {
let resolve_symlinks = config::zo_resolve_symlinks();
matcher = matcher.with_exists(resolve_symlinks);
stream = stream.with_exists(resolve_symlinks);
}
if let Some(path) = &self.exclude {
stream = stream.with_exclude(path);
}
let mut matches = db
.iter(&matcher, now)
.filter(|dir| Some(dir.path.as_ref()) != self.exclude.as_deref());
if self.interactive {
let mut fzf = Fzf::new(false)?;
for dir in matches {
while let Some(dir) = stream.next() {
writeln!(fzf.stdin(), "{}", dir.display_score(now)).pipe_exit("fzf")?;
}
@ -49,7 +48,7 @@ impl Run for Query {
let stdout = stdout.lock();
let mut handle = BufWriter::new(stdout);
for dir in matches {
while let Some(dir) = stream.next() {
if self.score {
writeln!(handle, "{}", dir.display_score(now))
} else {
@ -59,7 +58,7 @@ impl Run for Query {
}
handle.flush().pipe_exit("stdout")?;
} else {
let dir = matches.next().context("no match found")?;
let dir = stream.next().context("no match found")?;
if self.score {
writeln!(io::stdout(), "{}", dir.display_score(now))
} else {

View File

@ -1,6 +1,6 @@
use crate::app::{Remove, Run};
use crate::config;
use crate::db::{DatabaseFile, Matcher};
use crate::db::DatabaseFile;
use crate::error::BrokenPipeHandler;
use crate::fzf::Fzf;
use crate::util;
@ -18,11 +18,11 @@ impl Run for Remove {
let selection;
match &self.interactive {
Some(keywords) => {
let matcher = Matcher::new().with_keywords(keywords);
let now = util::current_time()?;
let mut stream = db.stream(now).with_keywords(keywords);
let mut fzf = Fzf::new(true)?;
for dir in db.iter(&matcher, now) {
while let Some(dir) = stream.next() {
writeln!(fzf.stdin(), "{}", dir.display_score(now)).pipe_exit("fzf")?;
}

View File

@ -1,26 +1,24 @@
mod dir;
mod query;
mod stream;
pub use dir::{Dir, DirList, Epoch, Rank};
pub use query::Matcher;
pub use stream::Stream;
use anyhow::{Context, Result};
use ordered_float::OrderedFloat;
use tempfile::{NamedTempFile, PersistError};
use std::borrow::Cow;
use std::cmp::Reverse;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
pub struct Database<'a> {
pub dirs: DirList<'a>,
pub struct Database<'file> {
pub dirs: DirList<'file>,
pub modified: bool,
data_dir: &'a Path,
data_dir: &'file PathBuf,
}
impl<'a> Database<'a> {
impl<'file> Database<'file> {
pub fn save(&mut self) -> Result<()> {
if !self.modified {
return Ok(());
@ -72,12 +70,9 @@ impl<'a> Database<'a> {
self.modified = true;
}
pub fn iter<'i>(&'i mut self, m: &'i Matcher, now: Epoch) -> impl Iterator<Item = &'i Dir> {
self.dirs
.sort_unstable_by_key(|dir| Reverse(OrderedFloat(dir.score(now))));
self.dirs
.iter()
.filter(move |dir| m.matches(dir.path.as_ref()))
// Streaming iterator for directories.
pub fn stream(&mut self, now: Epoch) -> Stream<'_, 'file> {
Stream::new(self, now)
}
/// Removes the directory with `path` from the store.
@ -159,15 +154,15 @@ fn persist<P: AsRef<Path>>(file: NamedTempFile, path: P) -> Result<(), PersistEr
}
pub struct DatabaseFile {
data_dir: PathBuf,
buffer: Vec<u8>,
data_dir: PathBuf,
}
impl DatabaseFile {
pub fn new<P: Into<PathBuf>>(data_dir: P) -> DatabaseFile {
pub fn new<P: Into<PathBuf>>(data_dir: P) -> Self {
DatabaseFile {
data_dir: data_dir.into(),
buffer: Vec::new(),
data_dir: data_dir.into(),
}
}

View File

@ -1,33 +1,89 @@
use super::{Database, Dir, Epoch};
use crate::util;
use ordered_float::OrderedFloat;
use std::fs;
use std::iter::Rev;
use std::ops::Range;
use std::path;
#[derive(Debug, Default)]
pub struct Matcher {
pub struct Stream<'db, 'file> {
db: &'db mut Database<'file>,
idxs: Rev<Range<usize>>,
keywords: Vec<String>,
check_exists: bool,
expire_below: Epoch,
resolve_symlinks: bool,
exclude_path: Option<String>,
}
impl Matcher {
pub fn new() -> Matcher {
Matcher::default()
impl<'db, 'file> Stream<'db, 'file> {
pub fn new(db: &'db mut Database<'file>, now: Epoch) -> Self {
// Iterate in descending order of score.
db.dirs
.sort_unstable_by_key(|dir| OrderedFloat(dir.score(now)));
let idxs = (0..db.dirs.len()).rev();
// If a directory is deleted and hasn't been used for 90 days, delete
// it from the database.
let expire_below = now.saturating_sub(90 * 24 * 60 * 60);
Stream {
db,
idxs,
keywords: Vec::new(),
check_exists: false,
expire_below,
resolve_symlinks: false,
exclude_path: None,
}
}
pub fn with_exists(mut self, resolve_symlinks: bool) -> Matcher {
pub fn with_exclude<S: Into<String>>(mut self, path: S) -> Self {
self.exclude_path = Some(path.into());
self
}
pub fn with_exists(mut self, resolve_symlinks: bool) -> Self {
self.check_exists = true;
self.resolve_symlinks = resolve_symlinks;
self
}
pub fn with_keywords<S: AsRef<str>>(mut self, keywords: &[S]) -> Matcher {
pub fn with_keywords<S: AsRef<str>>(mut self, keywords: &[S]) -> Self {
self.keywords = keywords.iter().map(util::to_lowercase).collect();
self
}
pub fn matches<S: AsRef<str>>(&self, path: S) -> bool {
self.matches_keywords(&path) && self.matches_exists(path)
pub fn next(&mut self) -> Option<&Dir<'file>> {
while let Some(idx) = self.idxs.next() {
let dir = &self.db.dirs[idx];
if !self.matches_keywords(&dir.path) {
continue;
}
if !self.matches_exists(&dir.path) {
if dir.last_accessed < self.expire_below {
self.db.dirs.swap_remove(idx);
self.db.modified = true;
}
continue;
}
if Some(dir.path.as_ref()) == self.exclude_path.as_deref() {
continue;
}
let dir = &self.db.dirs[idx];
return Some(dir);
}
None
}
fn matches_exists<S: AsRef<str>>(&self, path: S) -> bool {
@ -77,7 +133,9 @@ impl Matcher {
#[cfg(test)]
mod tests {
use super::Matcher;
use super::Database;
use std::path::PathBuf;
#[test]
fn query() {
@ -103,9 +161,16 @@ mod tests {
(&["/foo/", "/bar"], "/foo/baz/bar", true),
];
let mut db = Database {
dirs: Vec::new().into(),
modified: false,
data_dir: &PathBuf::new(),
};
let now = 0;
for &(keywords, path, is_match) in CASES {
let matcher = Matcher::new().with_keywords(keywords);
assert_eq!(is_match, matcher.matches(path))
let stream = db.stream(now).with_keywords(keywords);
assert_eq!(is_match, stream.matches_keywords(path));
}
}
}