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 directories you use most frequently, and uses a ranking algorithm to navigate
to the best match. to the best match.
.SH USAGE .SH USAGE
.nf
\fBz\fR \fIfoo\fR # cd into highest ranked directory matching foo \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 \fBz\fR \fIfoo bar\fR # cd into highest ranked directory matching foo and bar
.sp .sp
@ -17,6 +18,7 @@ to the best match.
\fBz\fR \fI-\fR # cd into previous directory \fBz\fR \fI-\fR # cd into previous directory
.sp .sp
\fBzi\fR \fIfoo\fR # cd with interactive selection (using fzf) \fBzi\fR \fIfoo\fR # cd with interactive selection (using fzf)
.fi
.SH SUBCOMMANDS .SH SUBCOMMANDS
.TP .TP
\fBzoxide-add\fR(1) \fBzoxide-add\fR(1)

View File

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

View File

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

View File

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

View File

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

View File

@ -1,33 +1,89 @@
use super::{Database, Dir, Epoch};
use crate::util; use crate::util;
use ordered_float::OrderedFloat;
use std::fs; use std::fs;
use std::iter::Rev;
use std::ops::Range;
use std::path; use std::path;
#[derive(Debug, Default)] pub struct Stream<'db, 'file> {
pub struct Matcher { db: &'db mut Database<'file>,
idxs: Rev<Range<usize>>,
keywords: Vec<String>, keywords: Vec<String>,
check_exists: bool, check_exists: bool,
expire_below: Epoch,
resolve_symlinks: bool, resolve_symlinks: bool,
exclude_path: Option<String>,
} }
impl Matcher { impl<'db, 'file> Stream<'db, 'file> {
pub fn new() -> Matcher { pub fn new(db: &'db mut Database<'file>, now: Epoch) -> Self {
Matcher::default() // 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.check_exists = true;
self.resolve_symlinks = resolve_symlinks; self.resolve_symlinks = resolve_symlinks;
self 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.keywords = keywords.iter().map(util::to_lowercase).collect();
self self
} }
pub fn matches<S: AsRef<str>>(&self, path: S) -> bool { pub fn next(&mut self) -> Option<&Dir<'file>> {
self.matches_keywords(&path) && self.matches_exists(path) 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 { fn matches_exists<S: AsRef<str>>(&self, path: S) -> bool {
@ -77,7 +133,9 @@ impl Matcher {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Matcher; use super::Database;
use std::path::PathBuf;
#[test] #[test]
fn query() { fn query() {
@ -103,9 +161,16 @@ mod tests {
(&["/foo/", "/bar"], "/foo/baz/bar", true), (&["/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 { for &(keywords, path, is_match) in CASES {
let matcher = Matcher::new().with_keywords(keywords); let stream = db.stream(now).with_keywords(keywords);
assert_eq!(is_match, matcher.matches(path)) assert_eq!(is_match, stream.matches_keywords(path));
} }
} }
} }