search feature and pre-install script
This commit is contained in:
Generated
+6
@@ -1934,6 +1934,7 @@ dependencies = [
|
||||
"librarian-core",
|
||||
"librarian-win",
|
||||
"notify-debouncer-mini",
|
||||
"tokio",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
@@ -3774,7 +3775,12 @@ version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -15,6 +15,10 @@ librarian-win.workspace = true
|
||||
iced.workspace = true
|
||||
chrono = "0.4"
|
||||
notify-debouncer-mini = "0.7.0"
|
||||
# Async child-process I/O for streaming ripgrep search results. The runtime
|
||||
# itself is provided by iced's "tokio" feature; we only need process spawning
|
||||
# and async buffered reads here.
|
||||
tokio = { version = "1", features = ["process", "io-util", "rt", "time"] }
|
||||
|
||||
# Embeds the application icon as a Win32 resource (so the .exe shows it in
|
||||
# Explorer / on shortcuts). Windows-only; see build.rs.
|
||||
|
||||
@@ -5,6 +5,7 @@ mod config;
|
||||
mod ellipsis;
|
||||
mod icons;
|
||||
mod rows;
|
||||
mod search;
|
||||
mod selection;
|
||||
mod thumbs;
|
||||
mod tree;
|
||||
@@ -32,6 +33,7 @@ use librarian_win::{
|
||||
use ellipsis::ellipsized;
|
||||
use icons::{IconCache, IconKey, extract_icons};
|
||||
use rows::{Row, format_time, human_size};
|
||||
use search::{SearchEvent, SearchHit, SearchMode, SearchSpec};
|
||||
use selection::Selection;
|
||||
use thumbs::{CacheSweep, ThumbCache, ThumbKey, extract_cached, extract_full};
|
||||
use tree::{Reveal, Tree, TreeChild, TreeRow};
|
||||
@@ -99,6 +101,9 @@ const MAX_BACKGROUND_PRECACHE: usize = 4000;
|
||||
const SPINNER_FRAMES: [&str; 4] = ["◐", "◓", "◑", "◒"];
|
||||
/// How often the loading spinner advances a frame.
|
||||
const SPINNER_TICK: Duration = Duration::from_millis(120);
|
||||
/// Idle time after the last search keystroke before the live search runs, so we
|
||||
/// don't spawn a ripgrep process for every intermediate character.
|
||||
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
fn main() -> iced::Result {
|
||||
let start = startup_location();
|
||||
@@ -243,6 +248,18 @@ enum Content {
|
||||
entries: Vec<Entry>,
|
||||
loading: bool,
|
||||
},
|
||||
/// Recursive search results rooted at `root`, streamed in from ripgrep. The
|
||||
/// browsed folder (in `history`) is unchanged underneath, so leaving the
|
||||
/// search (clearing it, or navigating) restores the normal listing.
|
||||
Search {
|
||||
root: PathBuf,
|
||||
query: String,
|
||||
mode: SearchMode,
|
||||
hits: Vec<SearchHit>,
|
||||
/// True once ripgrep has finished (so an empty `hits` means "no results"
|
||||
/// rather than "still searching").
|
||||
done: bool,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -272,7 +289,6 @@ struct Librarian {
|
||||
/// Details list vs. one of the icon-grid sizes.
|
||||
view_mode: ViewMode,
|
||||
show_hidden: bool,
|
||||
filter: String,
|
||||
address: String,
|
||||
selection: Selection,
|
||||
icons: IconCache,
|
||||
@@ -327,6 +343,23 @@ struct Librarian {
|
||||
/// the current size while the user stays in the folder. Drained in chunks; a
|
||||
/// new session releases and replaces it.
|
||||
bg_queue: Vec<ThumbKey>,
|
||||
// --- search --------------------------------------------------------------
|
||||
/// Current text in the search box (per tab).
|
||||
search_query: String,
|
||||
/// Whether the search box matches names or contents (per tab).
|
||||
search_mode: SearchMode,
|
||||
/// The running search, if any. Set as the search subscription's identity, so
|
||||
/// assigning a new spec supersedes the previous one (killing its `rg` child);
|
||||
/// cleared when the search finishes or is dismissed. Not parked across tab
|
||||
/// switches — leaving a tab abandons its in-flight search.
|
||||
search_active: Option<SearchSpec>,
|
||||
/// Monotonic id for searches, stamped onto each spec so streamed results from
|
||||
/// a superseded search are recognized as stale and dropped.
|
||||
search_token: u64,
|
||||
/// Monotonic id for search *input*, bumped on each keystroke (and on submit /
|
||||
/// tab switch). A debounced live-search check only runs if it still matches,
|
||||
/// so intermediate keystrokes don't each launch a search.
|
||||
search_seq: u64,
|
||||
// --- tabs ----------------------------------------------------------------
|
||||
/// All open tabs. The entry at `active` is `None` — that tab's data lives in
|
||||
/// the flat fields above; every other entry parks its state in `Some(..)`.
|
||||
@@ -346,7 +379,6 @@ struct TabState {
|
||||
content: Content,
|
||||
rows: Vec<Row>,
|
||||
selection: Selection,
|
||||
filter: String,
|
||||
address: String,
|
||||
status: String,
|
||||
scroll_y: f32,
|
||||
@@ -354,6 +386,8 @@ struct TabState {
|
||||
renaming: Option<Rename>,
|
||||
pending_rename: Option<(PathBuf, String)>,
|
||||
load_token: u64,
|
||||
search_query: String,
|
||||
search_mode: SearchMode,
|
||||
}
|
||||
|
||||
/// Which lane a finished full-extraction belongs to, so its completion is
|
||||
@@ -374,9 +408,35 @@ enum Message {
|
||||
Refresh,
|
||||
AddressChanged(String),
|
||||
AddressSubmit,
|
||||
FilterChanged(String),
|
||||
SetHidden(bool),
|
||||
SortBy(SortKey),
|
||||
// --- search ---
|
||||
/// The search box text changed; schedules a debounced live search.
|
||||
SearchChanged(String),
|
||||
/// A debounced live-search check fired, tagged with the input sequence it was
|
||||
/// scheduled for (so superseded keystrokes are ignored).
|
||||
SearchDebounced(u64),
|
||||
/// Run a recursive search for the current box text in the current folder.
|
||||
SearchSubmit,
|
||||
/// Switch between name and contents matching.
|
||||
SearchModeChanged(SearchMode),
|
||||
/// Dismiss search results and restore the folder listing.
|
||||
SearchClear,
|
||||
/// A streamed batch of results from search `token`.
|
||||
SearchBatch {
|
||||
token: u64,
|
||||
hits: Vec<SearchHit>,
|
||||
},
|
||||
/// Search `token` finished (`capped` if it hit the result cap).
|
||||
SearchFinished {
|
||||
token: u64,
|
||||
capped: bool,
|
||||
},
|
||||
/// Search `token` could not run.
|
||||
SearchFailed {
|
||||
token: u64,
|
||||
error: String,
|
||||
},
|
||||
RowClicked(usize),
|
||||
RowRightClicked(usize),
|
||||
BackgroundRightClicked,
|
||||
@@ -472,7 +532,6 @@ impl Librarian {
|
||||
sort: settings.sort,
|
||||
view_mode: settings.view_mode,
|
||||
show_hidden: settings.show_hidden,
|
||||
filter: String::new(),
|
||||
address: "This PC".to_string(),
|
||||
selection: Selection::default(),
|
||||
icons: IconCache::default(),
|
||||
@@ -494,6 +553,11 @@ impl Librarian {
|
||||
overlay_loading: false,
|
||||
spinner_frame: 0,
|
||||
bg_queue: Vec::new(),
|
||||
search_query: String::new(),
|
||||
search_mode: SearchMode::default(),
|
||||
search_active: None,
|
||||
search_token: 0,
|
||||
search_seq: 0,
|
||||
// Start with a single tab that owns the flat state above.
|
||||
tabs: vec![None],
|
||||
active: 0,
|
||||
@@ -535,12 +599,19 @@ impl Librarian {
|
||||
/// leaving the flat fields as cheap placeholders (about to be overwritten by
|
||||
/// the incoming tab). Moves, never clones, so big row/entry vecs stay cheap.
|
||||
fn snapshot_flat(&mut self) -> TabState {
|
||||
// Abandon any in-flight search: dropping its spec stops the subscription
|
||||
// (and kills rg). Mark the parked results "done" so returning to the tab
|
||||
// shows the partial results statically instead of looking unfinished.
|
||||
if self.search_active.take().is_some()
|
||||
&& let Content::Search { done, .. } = &mut self.content
|
||||
{
|
||||
*done = true;
|
||||
}
|
||||
TabState {
|
||||
history: std::mem::replace(&mut self.history, History::new(Location::ThisPc)),
|
||||
content: std::mem::replace(&mut self.content, Content::ThisPc { drives: Vec::new() }),
|
||||
rows: std::mem::take(&mut self.rows),
|
||||
selection: std::mem::take(&mut self.selection),
|
||||
filter: std::mem::take(&mut self.filter),
|
||||
address: std::mem::take(&mut self.address),
|
||||
status: std::mem::take(&mut self.status),
|
||||
scroll_y: self.scroll_y,
|
||||
@@ -548,6 +619,8 @@ impl Librarian {
|
||||
renaming: self.renaming.take(),
|
||||
pending_rename: self.pending_rename.take(),
|
||||
load_token: self.load_token,
|
||||
search_query: std::mem::take(&mut self.search_query),
|
||||
search_mode: self.search_mode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +630,6 @@ impl Librarian {
|
||||
self.content = state.content;
|
||||
self.rows = state.rows;
|
||||
self.selection = state.selection;
|
||||
self.filter = state.filter;
|
||||
self.address = state.address;
|
||||
self.status = state.status;
|
||||
self.scroll_y = state.scroll_y;
|
||||
@@ -565,6 +637,13 @@ impl Librarian {
|
||||
self.renaming = state.renaming;
|
||||
self.pending_rename = state.pending_rename;
|
||||
self.load_token = state.load_token;
|
||||
self.search_query = state.search_query;
|
||||
self.search_mode = state.search_mode;
|
||||
// The incoming tab carries no live search (in-flight ones were abandoned
|
||||
// when it was parked); any results it has are already in `content`.
|
||||
self.search_active = None;
|
||||
// Invalidate any debounce scheduled by the tab we just left.
|
||||
self.search_seq = self.search_seq.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Reset the flat fields to a brand-new, empty tab at `location` (its content
|
||||
@@ -575,7 +654,6 @@ impl Librarian {
|
||||
self.content = Content::ThisPc { drives: Vec::new() };
|
||||
self.rows.clear();
|
||||
self.selection = Selection::default();
|
||||
self.filter.clear();
|
||||
self.status.clear();
|
||||
self.scroll_y = 0.0;
|
||||
self.last_click = None;
|
||||
@@ -583,6 +661,10 @@ impl Librarian {
|
||||
self.pending_rename = None;
|
||||
self.load_token = 0;
|
||||
self.menu = None;
|
||||
self.search_query.clear();
|
||||
self.search_mode = SearchMode::default();
|
||||
self.search_active = None;
|
||||
self.search_seq = self.search_seq.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Bring the just-restored active tab on screen: finish a load that was
|
||||
@@ -704,7 +786,15 @@ impl Librarian {
|
||||
Subscription::none()
|
||||
};
|
||||
|
||||
Subscription::batch([events, watch, spinner])
|
||||
// The active search, keyed by its full spec: starting/changing a search
|
||||
// (new token) tears down the old stream — killing its rg child — and
|
||||
// starts a fresh one.
|
||||
let search = match &self.search_active {
|
||||
Some(spec) => Subscription::run_with(spec.clone(), search_stream),
|
||||
None => Subscription::none(),
|
||||
};
|
||||
|
||||
Subscription::batch([events, watch, spinner, search])
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
@@ -737,11 +827,6 @@ impl Librarian {
|
||||
Message::AddressSubmit => {
|
||||
return self.navigate(Location::parse(&self.address));
|
||||
}
|
||||
Message::FilterChanged(value) => {
|
||||
self.filter = value;
|
||||
self.recompute_rows();
|
||||
return self.prefetch_thumbs(false);
|
||||
}
|
||||
Message::SetHidden(value) => {
|
||||
self.show_hidden = value;
|
||||
self.recompute_rows();
|
||||
@@ -756,6 +841,88 @@ impl Librarian {
|
||||
config::save(&self.settings());
|
||||
return self.prefetch_thumbs(false);
|
||||
}
|
||||
Message::SearchChanged(value) => {
|
||||
// Search live as the user types or pastes, but debounce: schedule
|
||||
// a check tagged with the latest input sequence, and only the
|
||||
// check that's still current (no newer keystroke) actually runs.
|
||||
self.search_query = value;
|
||||
self.search_seq = self.search_seq.wrapping_add(1);
|
||||
let seq = self.search_seq;
|
||||
return Task::perform(
|
||||
async { tokio::time::sleep(SEARCH_DEBOUNCE).await },
|
||||
move |_| Message::SearchDebounced(seq),
|
||||
);
|
||||
}
|
||||
Message::SearchDebounced(seq) => {
|
||||
// Ignore if a newer keystroke (or an Enter/tab switch) arrived.
|
||||
if seq == self.search_seq {
|
||||
return self.run_search_if_changed();
|
||||
}
|
||||
}
|
||||
Message::SearchModeChanged(mode) => {
|
||||
self.search_mode = mode;
|
||||
// Re-run immediately if results are already showing, so toggling
|
||||
// the mode updates them without a second Enter.
|
||||
if matches!(self.content, Content::Search { .. }) {
|
||||
return self.run_search_if_changed();
|
||||
}
|
||||
}
|
||||
Message::SearchSubmit => {
|
||||
// Enter is the explicit fallback: cancel any pending debounce and
|
||||
// run now — but `run_search_if_changed` makes it a no-op if the
|
||||
// query+mode already match what's shown.
|
||||
self.search_seq = self.search_seq.wrapping_add(1);
|
||||
return self.run_search_if_changed();
|
||||
}
|
||||
Message::SearchClear => return self.clear_search(),
|
||||
Message::SearchBatch { token, hits } => {
|
||||
if token != self.search_token {
|
||||
return Task::none(); // a newer search superseded this one
|
||||
}
|
||||
if let Content::Search {
|
||||
hits: existing,
|
||||
done,
|
||||
..
|
||||
} = &mut self.content
|
||||
{
|
||||
existing.extend(hits);
|
||||
let found = existing.len();
|
||||
*done = false;
|
||||
self.recompute_rows();
|
||||
self.status = format!("Searching… {found} found");
|
||||
return Task::batch([self.request_icons(), self.prefetch_thumbs(false)]);
|
||||
}
|
||||
}
|
||||
Message::SearchFinished { token, capped } => {
|
||||
if token != self.search_token {
|
||||
return Task::none();
|
||||
}
|
||||
// The search is over: drop the subscription so it doesn't restart.
|
||||
self.search_active = None;
|
||||
if let Content::Search { hits, done, .. } = &mut self.content {
|
||||
*done = true;
|
||||
let found = hits.len();
|
||||
self.status = match (found, capped) {
|
||||
(0, _) => "No results".to_string(),
|
||||
(n, true) => {
|
||||
format!("{n} results (showing the first {n}; refine to narrow)")
|
||||
}
|
||||
(n, false) => format!("{n} result{}", plural(n)),
|
||||
};
|
||||
// Warm thumbnails for the full result set now that it's settled.
|
||||
return Task::batch([self.request_icons(), self.seed_background()]);
|
||||
}
|
||||
}
|
||||
Message::SearchFailed { token, error } => {
|
||||
if token != self.search_token {
|
||||
return Task::none();
|
||||
}
|
||||
self.search_active = None;
|
||||
if let Content::Search { done, .. } = &mut self.content {
|
||||
*done = true;
|
||||
}
|
||||
self.status = error;
|
||||
}
|
||||
Message::RowClicked(index) => return self.on_click(index),
|
||||
Message::ThisPcLoaded(drives) => {
|
||||
self.content = Content::ThisPc { drives };
|
||||
@@ -1142,11 +1309,91 @@ impl Librarian {
|
||||
self.load_current()
|
||||
}
|
||||
|
||||
// --- search ---------------------------------------------------------------
|
||||
|
||||
/// Launch a search for the current box text only if it differs from what's
|
||||
/// already shown — the shared entry point for live (debounced), submit, and
|
||||
/// mode-change triggers. An empty box leaves search; an unchanged query+mode
|
||||
/// is a no-op (so an Enter that matches the current results does nothing).
|
||||
fn run_search_if_changed(&mut self) -> Task<Message> {
|
||||
let query = self.search_query.trim().to_string();
|
||||
if query.is_empty() {
|
||||
return if matches!(self.content, Content::Search { .. }) {
|
||||
self.clear_search()
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
}
|
||||
let unchanged = matches!(
|
||||
&self.content,
|
||||
Content::Search { query: shown, mode, .. }
|
||||
if shown == &query && *mode == self.search_mode
|
||||
);
|
||||
if unchanged {
|
||||
return Task::none();
|
||||
}
|
||||
self.begin_search()
|
||||
}
|
||||
|
||||
/// Start a recursive ripgrep search for the current box text, rooted at the
|
||||
/// current folder. Switches `content` to [`Content::Search`] (the browsed
|
||||
/// location underneath is untouched, so leaving search restores it) and arms
|
||||
/// the search subscription. A no-op at the "This PC" root; a blank query just
|
||||
/// clears any active search.
|
||||
fn begin_search(&mut self) -> Task<Message> {
|
||||
let query = self.search_query.trim().to_string();
|
||||
let Some(root) = self.current_dir() else {
|
||||
// Search needs a real folder root, not the "This PC" landing page.
|
||||
return Task::none();
|
||||
};
|
||||
if query.is_empty() {
|
||||
return self.clear_search();
|
||||
}
|
||||
self.menu = None;
|
||||
self.selection.clear();
|
||||
self.last_click = None;
|
||||
self.search_token = self.search_token.wrapping_add(1);
|
||||
self.search_active = Some(SearchSpec {
|
||||
token: self.search_token,
|
||||
root: root.clone(),
|
||||
query: query.clone(),
|
||||
mode: self.search_mode,
|
||||
});
|
||||
self.content = Content::Search {
|
||||
root,
|
||||
query,
|
||||
mode: self.search_mode,
|
||||
hits: Vec::new(),
|
||||
done: false,
|
||||
};
|
||||
self.recompute_rows();
|
||||
self.status = "Searching…".to_string();
|
||||
// Open a fresh (empty) thumbnail session and snap to the top; results
|
||||
// populate it as batches stream in.
|
||||
Task::batch([self.scroll_to(0.0), self.begin_grid_session(false)])
|
||||
}
|
||||
|
||||
/// Dismiss search results and restore the folder listing, stopping any
|
||||
/// running search.
|
||||
fn clear_search(&mut self) -> Task<Message> {
|
||||
self.search_active = None;
|
||||
self.search_query.clear();
|
||||
if matches!(self.content, Content::Search { .. }) {
|
||||
// Reloading the current location rebuilds the normal listing.
|
||||
return self.load_current();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// (Re)load whatever the history currently points at.
|
||||
fn load_current(&mut self) -> Task<Message> {
|
||||
self.selection.clear();
|
||||
self.last_click = None;
|
||||
self.filter.clear();
|
||||
// Navigating leaves any search behind (and stops it, if running), and
|
||||
// invalidates any debounce a last-moment keystroke may have scheduled.
|
||||
self.search_active = None;
|
||||
self.search_query.clear();
|
||||
self.search_seq = self.search_seq.wrapping_add(1);
|
||||
let location = self.history.current().clone();
|
||||
self.address = address_text(&location);
|
||||
|
||||
@@ -1449,29 +1696,37 @@ impl Librarian {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild `rows` from `content`, applying the current filter and sort.
|
||||
/// Rebuild `rows` from `content`, applying the current sort.
|
||||
fn recompute_rows(&mut self) {
|
||||
self.rows = match &self.content {
|
||||
Content::ThisPc { drives } => {
|
||||
let mut rows: Vec<Row> = drives.iter().map(rows::row_from_drive).collect();
|
||||
if !self.filter.is_empty() {
|
||||
let needle = self.filter.to_lowercase();
|
||||
rows.retain(|r| r.label.to_lowercase().contains(&needle));
|
||||
}
|
||||
rows
|
||||
}
|
||||
Content::ThisPc { drives } => drives.iter().map(rows::row_from_drive).collect(),
|
||||
Content::Folder { entries, .. } => {
|
||||
let mut visible: Vec<Entry> = entries
|
||||
.iter()
|
||||
.filter(|e| is_visible(e, self.show_hidden, &self.filter))
|
||||
.filter(|e| is_visible(e, self.show_hidden, ""))
|
||||
.cloned()
|
||||
.collect();
|
||||
sort_entries(&mut visible, &self.sort);
|
||||
visible.iter().map(rows::row_from_entry).collect()
|
||||
}
|
||||
Content::Search { root, hits, .. } => {
|
||||
let mut rows: Vec<Row> = hits
|
||||
.iter()
|
||||
.map(|hit| rows::row_from_hit(hit, root))
|
||||
.collect();
|
||||
// Results stream in roughly in walk order; present folders first
|
||||
// (Explorer-style), then by the displayed root-relative path, so
|
||||
// the list stays stable and readable as it grows.
|
||||
rows.sort_by(|a, b| {
|
||||
b.is_container
|
||||
.cmp(&a.is_container)
|
||||
.then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
|
||||
});
|
||||
rows
|
||||
}
|
||||
Content::Error(_) => Vec::new(),
|
||||
};
|
||||
// Drop any selection indices that no longer exist after filtering/sort.
|
||||
// Drop any selection indices that no longer exist after the rebuild.
|
||||
self.selection.retain_below(self.rows.len());
|
||||
}
|
||||
|
||||
@@ -1806,14 +2061,23 @@ impl Librarian {
|
||||
/// The placeholder shown in the file area when there are no rows.
|
||||
fn empty_list_message(&self) -> Element<'_, Message> {
|
||||
let msg = match &self.content {
|
||||
Content::Folder { loading: true, .. } => "Loading…",
|
||||
Content::Error(e) => e.as_str(),
|
||||
_ => "Empty",
|
||||
Content::Folder { loading: true, .. } => "Loading…".to_string(),
|
||||
Content::Search {
|
||||
done: false, query, ..
|
||||
} => format!("Searching for “{query}”…"),
|
||||
Content::Search {
|
||||
done: true,
|
||||
query,
|
||||
mode,
|
||||
..
|
||||
} => match mode {
|
||||
SearchMode::Name => format!("No file names match “{query}”"),
|
||||
SearchMode::Contents => format!("No files contain “{query}”"),
|
||||
},
|
||||
Content::Error(e) => e.clone(),
|
||||
_ => "Empty".to_string(),
|
||||
};
|
||||
container(text(msg.to_string()))
|
||||
.padding(16)
|
||||
.width(Fill)
|
||||
.into()
|
||||
container(text(msg)).padding(16).width(Fill).into()
|
||||
}
|
||||
|
||||
/// The tab strip: one chip per open tab (title + close), plus a "+" button.
|
||||
@@ -1865,7 +2129,31 @@ impl Librarian {
|
||||
.on_press_maybe(msg)
|
||||
.padding([4, 10])
|
||||
};
|
||||
row![
|
||||
// Search needs a real folder to recurse from; disabled at "This PC".
|
||||
let can_search = self.current_dir().is_some();
|
||||
let searching = matches!(self.content, Content::Search { .. });
|
||||
let mut search_box = text_input("Search this folder", &self.search_query)
|
||||
.on_input(Message::SearchChanged)
|
||||
.width(200.0);
|
||||
if can_search {
|
||||
search_box = search_box.on_submit(Message::SearchSubmit);
|
||||
}
|
||||
let mode = pick_list(
|
||||
SearchMode::ALL.to_vec(),
|
||||
Some(self.search_mode),
|
||||
Message::SearchModeChanged,
|
||||
)
|
||||
.text_size(13)
|
||||
.padding([4, 6]);
|
||||
// A clear button appears while results are showing, to drop back to the
|
||||
// folder listing.
|
||||
let clear = searching.then(|| {
|
||||
button(text("✕").size(13))
|
||||
.on_press(Message::SearchClear)
|
||||
.padding([4, 8])
|
||||
});
|
||||
|
||||
let mut bar = row![
|
||||
nav("←", self.history.can_go_back().then_some(Message::GoBack)),
|
||||
nav(
|
||||
"→",
|
||||
@@ -1877,13 +2165,17 @@ impl Librarian {
|
||||
.on_input(Message::AddressChanged)
|
||||
.on_submit(Message::AddressSubmit)
|
||||
.width(Fill),
|
||||
text_input("Filter", &self.filter)
|
||||
.on_input(Message::FilterChanged)
|
||||
.width(180.0),
|
||||
search_box,
|
||||
mode,
|
||||
];
|
||||
if let Some(clear) = clear {
|
||||
bar = bar.push(clear);
|
||||
}
|
||||
bar.push(
|
||||
checkbox(self.show_hidden)
|
||||
.label("Hidden")
|
||||
.on_toggle(Message::SetHidden),
|
||||
]
|
||||
)
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.align_y(Center)
|
||||
@@ -2605,9 +2897,12 @@ fn is_text_edit(message: &Message) -> bool {
|
||||
message,
|
||||
Message::AddressChanged(_)
|
||||
| Message::AddressSubmit
|
||||
| Message::FilterChanged(_)
|
||||
| Message::RenameChanged(_)
|
||||
| Message::RenameCommit
|
||||
| Message::SearchChanged(_)
|
||||
| Message::SearchSubmit
|
||||
| Message::SearchModeChanged(_)
|
||||
| Message::SearchClear
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2662,6 +2957,22 @@ fn plural(count: usize) -> &'static str {
|
||||
if count == 1 { "" } else { "s" }
|
||||
}
|
||||
|
||||
/// Build the message stream for a running search: drive ripgrep (see
|
||||
/// [`search::run`]) and tag every event with the spec's token so the update loop
|
||||
/// can drop results from a search that's since been superseded.
|
||||
///
|
||||
/// `+ use<>` keeps the stream `'static` (it captures no borrow of `spec`), as
|
||||
/// [`Subscription::run_with`] requires.
|
||||
fn search_stream(spec: &SearchSpec) -> impl iced::futures::Stream<Item = Message> + use<> {
|
||||
use iced::futures::StreamExt;
|
||||
let token = spec.token;
|
||||
search::run(spec.clone()).map(move |event| match event {
|
||||
SearchEvent::Batch(hits) => Message::SearchBatch { token, hits },
|
||||
SearchEvent::Done { capped } => Message::SearchFinished { token, capped },
|
||||
SearchEvent::Failed(error) => Message::SearchFailed { token, error },
|
||||
})
|
||||
}
|
||||
|
||||
/// A stream that emits [`Message::DirChanged`] whenever `path`'s direct
|
||||
/// contents change on disk. Bridges the `notify` debouncer (whose callback runs
|
||||
/// on its own thread) into the async world via a channel; the debouncer is
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The display-row model: a uniform view over directory entries, drives, and
|
||||
//! known folders, plus human-friendly size/time formatting.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
@@ -8,6 +9,7 @@ use librarian_core::{Entry, Location};
|
||||
use librarian_win::{DriveInfo, DriveKind, KnownFolder};
|
||||
|
||||
use crate::icons::IconKey;
|
||||
use crate::search::SearchHit;
|
||||
|
||||
/// One row in the file list, independent of where it came from.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -73,6 +75,63 @@ pub fn row_from_known(folder: &KnownFolder) -> Row {
|
||||
}
|
||||
}
|
||||
|
||||
/// A row for one search result. The label is the path *relative to the search
|
||||
/// root* (so the result's location is visible at a glance, like an editor's
|
||||
/// search panel). A directory hit navigates when activated; a file hit opens.
|
||||
/// For contents searches `matches` is the hit count, surfaced in the Type column.
|
||||
pub fn row_from_hit(hit: &SearchHit, root: &Path) -> Row {
|
||||
let label = hit
|
||||
.path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&hit.path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
if hit.is_dir {
|
||||
return Row {
|
||||
label,
|
||||
icon: IconKey::Folder,
|
||||
is_container: true,
|
||||
target: Location::Path(hit.path.clone()),
|
||||
size: None,
|
||||
modified: None,
|
||||
type_label: "File folder".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let ext = file_extension(&hit.path);
|
||||
let type_label = match hit.matches {
|
||||
Some(n) => format!("{} match{}", n, if n == 1 { "" } else { "es" }),
|
||||
None => ext_type_label(&ext),
|
||||
};
|
||||
Row {
|
||||
label,
|
||||
icon: IconKey::Ext(ext),
|
||||
is_container: false,
|
||||
target: Location::Path(hit.path.clone()),
|
||||
size: None,
|
||||
modified: None,
|
||||
type_label,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase extension without the dot, or `""` for none.
|
||||
fn file_extension(path: &Path) -> String {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Human "type" for a file with the given (lowercase, dotless) extension.
|
||||
fn ext_type_label(ext: &str) -> String {
|
||||
if ext.is_empty() {
|
||||
"File".to_string()
|
||||
} else {
|
||||
format!("{} File", ext.to_uppercase())
|
||||
}
|
||||
}
|
||||
|
||||
fn type_label(entry: &Entry) -> String {
|
||||
if entry.is_dir() {
|
||||
return "File folder".to_string();
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Recursive search, powered by an external [ripgrep] (`rg`) process.
|
||||
//!
|
||||
//! ripgrep is a fast, parallel directory walker and content matcher, so rather
|
||||
//! than reinventing one we shell out to it and stream its stdout. Two modes:
|
||||
//!
|
||||
//! * **Name** — list files whose *name* contains the query, via
|
||||
//! `rg --files --iglob "*query*"`. ripgrep's walker does the recursion (and
|
||||
//! hidden/ignore handling); each output line is a matching file path.
|
||||
//! * **Contents** — files that *contain* the query, via `rg --count-matches`,
|
||||
//! so each output line is `path:count` for one file with at least one hit.
|
||||
//!
|
||||
//! The search is exposed as a [`Stream`] of [`SearchEvent`]s built with
|
||||
//! [`iced::stream::channel`], so the app can drive it as a subscription keyed by
|
||||
//! [`SearchSpec`]: changing any field tears the stream down, and because the
|
||||
//! child is spawned with `kill_on_drop`, dropping the stream kills `rg` — giving
|
||||
//! free cancellation when the user navigates away or starts a new search.
|
||||
//!
|
||||
//! `rg --files` lists *files* only. In **Name** mode, once ripgrep finishes we
|
||||
//! append matching **directories** found by our own [`librarian_core::NameMatcher`]
|
||||
//! automaton (same query, same case-insensitive substring semantics) — see
|
||||
//! [`librarian_core::find_matching_dirs`]. **Contents** mode stays files-only: a
|
||||
//! directory has no contents to match.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use iced::futures::channel::mpsc::Sender;
|
||||
use iced::futures::{SinkExt, Stream};
|
||||
use librarian_core::{NameMatcher, find_matching_dirs};
|
||||
|
||||
/// Stop after this many results and report the search as capped, so a query
|
||||
/// matching an entire drive can't flood the UI or run unbounded.
|
||||
const RESULT_CAP: usize = 5000;
|
||||
/// Results accumulated before a batch is pushed to the UI.
|
||||
const BATCH: usize = 128;
|
||||
|
||||
/// What the query matches against.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum SearchMode {
|
||||
/// Match against file names (default), like Explorer's search box.
|
||||
#[default]
|
||||
Name,
|
||||
/// Match against file contents.
|
||||
Contents,
|
||||
}
|
||||
|
||||
impl SearchMode {
|
||||
/// Every mode, in the order the picker lists them.
|
||||
pub const ALL: [SearchMode; 2] = [SearchMode::Name, SearchMode::Contents];
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SearchMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
SearchMode::Name => "Name",
|
||||
SearchMode::Contents => "Contents",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One search result: a file from ripgrep, or a directory from our own name
|
||||
/// walk, plus (for a [`SearchMode::Contents`] file) how many lines matched.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchHit {
|
||||
pub path: PathBuf,
|
||||
/// Matching-line count for a contents search; `None` otherwise.
|
||||
pub matches: Option<u64>,
|
||||
/// True for a directory hit (appended after ripgrep), false for a file.
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
/// The full identity of a search. Used as a subscription key, so any change
|
||||
/// supersedes the running search (and kills its `rg` child).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct SearchSpec {
|
||||
/// Monotonic id, so even an identical query re-run is treated as new.
|
||||
pub token: u64,
|
||||
pub root: PathBuf,
|
||||
pub query: String,
|
||||
pub mode: SearchMode,
|
||||
}
|
||||
|
||||
/// Streamed output of a running search.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SearchEvent {
|
||||
/// A chunk of newly found results.
|
||||
Batch(Vec<SearchHit>),
|
||||
/// The search finished; `capped` if it stopped early at [`RESULT_CAP`].
|
||||
Done { capped: bool },
|
||||
/// The search could not run (rg missing, failed to launch, …).
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Locate the ripgrep executable. Prefers a copy bundled beside our own
|
||||
/// executable (portable installs), then `PATH`, then the common per-user and
|
||||
/// machine install locations. `None` means rg isn't installed.
|
||||
pub fn ripgrep_path() -> Option<PathBuf> {
|
||||
const EXE: &str = "rg.exe";
|
||||
|
||||
// 1. Beside our own executable — a bundled/portable copy.
|
||||
if let Ok(exe) = std::env::current_exe()
|
||||
&& let Some(dir) = exe.parent()
|
||||
{
|
||||
let cand = dir.join(EXE);
|
||||
if cand.is_file() {
|
||||
return Some(cand);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Anywhere on PATH.
|
||||
if let Some(found) = find_on_path(EXE) {
|
||||
return Some(found);
|
||||
}
|
||||
|
||||
// 3. Common install locations (winget, scoop, chocolatey, and the dir our
|
||||
// install-ripgrep.ps1 uses).
|
||||
install_candidates()
|
||||
.into_iter()
|
||||
.map(|dir| dir.join(EXE))
|
||||
.find(|cand| cand.is_file())
|
||||
}
|
||||
|
||||
fn find_on_path(exe: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(exe))
|
||||
.find(|cand| cand.is_file())
|
||||
}
|
||||
|
||||
fn install_candidates() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
let mut from_env = |var: &str, tail: &[&str]| {
|
||||
if let Some(base) = std::env::var_os(var) {
|
||||
let mut p = PathBuf::from(base);
|
||||
p.extend(tail);
|
||||
dirs.push(p);
|
||||
}
|
||||
};
|
||||
// Where install-ripgrep.ps1 places rg.exe.
|
||||
from_env("LOCALAPPDATA", &["Programs", "ripgrep"]);
|
||||
// winget's shim links.
|
||||
from_env("LOCALAPPDATA", &["Microsoft", "WinGet", "Links"]);
|
||||
// scoop shims.
|
||||
from_env("USERPROFILE", &["scoop", "shims"]);
|
||||
// chocolatey.
|
||||
dirs.push(PathBuf::from(r"C:\ProgramData\chocolatey\bin"));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Build the [`SearchEvent`] stream for `spec`: spawn `rg`, read its stdout, and
|
||||
/// emit batched [`SearchHit`]s, then [`SearchEvent::Done`]. Spawned with
|
||||
/// `kill_on_drop`, so dropping the returned stream cancels the search.
|
||||
///
|
||||
/// `+ use<>` keeps the stream `'static` (it owns `spec`), as the subscription
|
||||
/// runtime requires.
|
||||
pub fn run(spec: SearchSpec) -> impl Stream<Item = SearchEvent> + use<> {
|
||||
iced::stream::channel(
|
||||
8,
|
||||
move |mut output: iced::futures::channel::mpsc::Sender<SearchEvent>| async move {
|
||||
let Some(rg) = ripgrep_path() else {
|
||||
let _ = output
|
||||
.send(SearchEvent::Failed(
|
||||
"ripgrep (rg.exe) was not found. Install it (e.g. with the \
|
||||
bundled install-ripgrep.ps1) and try again."
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let query = spec.query.trim();
|
||||
if query.is_empty() {
|
||||
let _ = output.send(SearchEvent::Done { capped: false }).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut command = build_command(&rg, query, spec.mode, &spec.root);
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
let _ = output
|
||||
.send(SearchEvent::Failed(format!(
|
||||
"Failed to launch ripgrep: {error}"
|
||||
)))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
let _ = output.send(SearchEvent::Done { capped: false }).await;
|
||||
return;
|
||||
};
|
||||
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut lines = tokio::io::BufReader::new(stdout).lines();
|
||||
let mut batch: Vec<SearchHit> = Vec::with_capacity(BATCH);
|
||||
let mut total = 0usize;
|
||||
let mut capped = false;
|
||||
|
||||
// Stops on EOF or a read error (`next_line` yields `Ok(None)`/`Err`).
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let Some(hit) = parse_line(&line, spec.mode) else {
|
||||
continue;
|
||||
};
|
||||
batch.push(hit);
|
||||
total += 1;
|
||||
if batch.len() >= BATCH
|
||||
&& output
|
||||
.send(SearchEvent::Batch(std::mem::take(&mut batch)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return; // receiver gone; kill_on_drop reaps rg
|
||||
}
|
||||
if total >= RESULT_CAP {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
let _ = output.send(SearchEvent::Batch(batch)).await;
|
||||
}
|
||||
// Reap rg promptly when we stopped early; harmless if it already exited.
|
||||
let _ = child.start_kill();
|
||||
|
||||
// ripgrep covered files. In Name mode, append matching *directories*
|
||||
// via our own name automaton (using whatever cap rg's files left).
|
||||
// Contents mode is files-only — a directory has nothing to grep.
|
||||
if spec.mode == SearchMode::Name {
|
||||
let remaining = RESULT_CAP.saturating_sub(total);
|
||||
if stream_dir_matches(&mut output, &spec.root, query, remaining).await {
|
||||
capped = true;
|
||||
}
|
||||
}
|
||||
let _ = output.send(SearchEvent::Done { capped }).await;
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets a flag on drop, so a directory walk handed to `spawn_blocking` is asked
|
||||
/// to stop when the search future is torn down (the walk checks the flag once
|
||||
/// per directory).
|
||||
struct CancelOnDrop(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for CancelOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `root` for directories whose name matches `query` and stream them as
|
||||
/// directory [`SearchHit`]s. Returns whether the walk hit `cap`. The walk runs on
|
||||
/// a blocking thread (it's synchronous filesystem work) and is cancelled if the
|
||||
/// caller's future is dropped. A no-op for an empty query or a zero cap.
|
||||
async fn stream_dir_matches(
|
||||
output: &mut Sender<SearchEvent>,
|
||||
root: &Path,
|
||||
query: &str,
|
||||
cap: usize,
|
||||
) -> bool {
|
||||
if cap == 0 || query.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
// Dropped when this future is, signalling the blocking walk to stop.
|
||||
let _guard = CancelOnDrop(Arc::clone(&cancel));
|
||||
|
||||
let root = root.to_path_buf();
|
||||
let query = query.to_string();
|
||||
let dirs = tokio::task::spawn_blocking(move || {
|
||||
let matcher = NameMatcher::new(&query);
|
||||
find_matching_dirs(&root, &matcher, cap, &cancel)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let capped = dirs.len() >= cap;
|
||||
for chunk in dirs.chunks(BATCH) {
|
||||
let hits: Vec<SearchHit> = chunk
|
||||
.iter()
|
||||
.map(|path| SearchHit {
|
||||
path: path.clone(),
|
||||
matches: None,
|
||||
is_dir: true,
|
||||
})
|
||||
.collect();
|
||||
if output.send(SearchEvent::Batch(hits)).await.is_err() {
|
||||
break; // receiver gone
|
||||
}
|
||||
}
|
||||
capped
|
||||
}
|
||||
|
||||
/// Assemble the `rg` invocation for a mode, with a hidden console window and
|
||||
/// `kill_on_drop` so cancellation reaps the process.
|
||||
fn build_command(
|
||||
rg: &PathBuf,
|
||||
query: &str,
|
||||
mode: SearchMode,
|
||||
root: &PathBuf,
|
||||
) -> tokio::process::Command {
|
||||
let mut std_cmd = std::process::Command::new(rg);
|
||||
match mode {
|
||||
SearchMode::Name => {
|
||||
// `--files` lists every file; `--iglob` filters to those whose name
|
||||
// (a path component, since the glob has no `/`) contains the query,
|
||||
// case-insensitively.
|
||||
std_cmd.args(["--files", "--no-ignore", "--hidden", "--iglob"]);
|
||||
std_cmd.arg(format!("*{}*", escape_glob(query)));
|
||||
}
|
||||
SearchMode::Contents => {
|
||||
// One `path:count` line per file containing the (literal) query.
|
||||
std_cmd.args([
|
||||
"--count-matches",
|
||||
"--no-ignore",
|
||||
"--hidden",
|
||||
"--smart-case",
|
||||
"--fixed-strings",
|
||||
"-e",
|
||||
]);
|
||||
std_cmd.arg(query);
|
||||
}
|
||||
}
|
||||
std_cmd.arg("--").arg(root);
|
||||
std_cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// CREATE_NO_WINDOW: don't flash a console for the child rg process.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
std_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::from(std_cmd);
|
||||
cmd.kill_on_drop(true);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Parse one line of `rg` output into a hit, or `None` to skip it.
|
||||
fn parse_line(line: &str, mode: SearchMode) -> Option<SearchHit> {
|
||||
// tokio's line reader strips the trailing newline (and a preceding CR).
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match mode {
|
||||
SearchMode::Name => Some(SearchHit {
|
||||
path: PathBuf::from(line),
|
||||
matches: None,
|
||||
is_dir: false,
|
||||
}),
|
||||
SearchMode::Contents => {
|
||||
// `path:count`. Split on the *last* colon: a Windows path's drive
|
||||
// colon (`C:`) is earlier, and the count field has none.
|
||||
let (path, count) = line.rsplit_once(':')?;
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(SearchHit {
|
||||
path: PathBuf::from(path),
|
||||
matches: count.trim().parse::<u64>().ok(),
|
||||
is_dir: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape glob metacharacters so a name query is matched literally. Each special
|
||||
/// character becomes a single-member character class (`*` → `[*]`), which avoids
|
||||
/// backslash escaping — awkward on Windows, where `\` is the path separator.
|
||||
fn escape_glob(query: &str) -> String {
|
||||
let mut out = String::with_capacity(query.len() + 8);
|
||||
for ch in query.chars() {
|
||||
match ch {
|
||||
'*' => out.push_str("[*]"),
|
||||
'?' => out.push_str("[?]"),
|
||||
'[' => out.push_str("[[]"),
|
||||
']' => out.push_str("[]]"),
|
||||
'{' => out.push_str("[{]"),
|
||||
'}' => out.push_str("[}]"),
|
||||
// A backslash in the query is almost certainly a path separator;
|
||||
// the glob engine wants forward slashes.
|
||||
'\\' => out.push('/'),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_a_name_result() {
|
||||
let hit = parse_line(r"C:\Users\me\photo.png", SearchMode::Name).unwrap();
|
||||
assert_eq!(hit.path, PathBuf::from(r"C:\Users\me\photo.png"));
|
||||
assert_eq!(hit.matches, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_contents_result_keeping_the_drive_colon() {
|
||||
let hit = parse_line(r"C:\Users\me\notes.txt:7", SearchMode::Contents).unwrap();
|
||||
assert_eq!(hit.path, PathBuf::from(r"C:\Users\me\notes.txt"));
|
||||
assert_eq!(hit.matches, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_are_skipped() {
|
||||
assert!(parse_line("", SearchMode::Name).is_none());
|
||||
assert!(parse_line("", SearchMode::Contents).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_glob_metacharacters_literally() {
|
||||
// A query of literal metacharacters becomes character classes, never
|
||||
// wildcards.
|
||||
assert_eq!(escape_glob("a*b?c"), "a[*]b[?]c");
|
||||
assert_eq!(escape_glob("x[y]z"), "x[[]y[]]z");
|
||||
assert_eq!(escape_glob("plain"), "plain");
|
||||
// Backslash (a path separator) normalizes to a forward slash.
|
||||
assert_eq!(escape_glob(r"a\b"), "a/b");
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
pub mod enumerate;
|
||||
pub mod history;
|
||||
pub mod matcher;
|
||||
pub mod model;
|
||||
pub mod sort;
|
||||
|
||||
pub use enumerate::{DEFAULT_BATCH, read_dir_all, read_dir_batched, read_subdirs};
|
||||
pub use history::History;
|
||||
pub use matcher::{NameMatcher, find_matching_dirs};
|
||||
pub use model::{Attributes, Entry, EntryKind, Location};
|
||||
pub use sort::{Sort, SortKey, SortOrder, is_visible, sort_entries};
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Case-insensitive substring matching via a hand-built finite automaton, plus a
|
||||
//! directory-only tree walk that uses it.
|
||||
//!
|
||||
//! ripgrep (Librarian's file-search engine) lists *files* only — it can't report
|
||||
//! matching directories. To cover folders we run our own matcher: a
|
||||
//! Knuth–Morris–Pratt automaton over the (lowercased) query that scans each
|
||||
//! directory name in a single pass with no backtracking, giving the same
|
||||
//! case-insensitive substring semantics as ripgrep's name search. The result set
|
||||
//! is appended to ripgrep's file hits by the app layer.
|
||||
|
||||
use std::fs;
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Condvar, Mutex};
|
||||
use std::thread;
|
||||
|
||||
// Reparse-point bit (junctions / symlinks); declared locally to keep this crate
|
||||
// free of the `windows` dependency, matching `model.rs`.
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||||
|
||||
/// Upper bound on directory-walk worker threads. The walk is I/O-bound, so a
|
||||
/// handful of threads saturates the disk; more just adds lock contention.
|
||||
const MAX_WALK_THREADS: usize = 8;
|
||||
|
||||
/// A compiled case-insensitive substring matcher.
|
||||
///
|
||||
/// Built as a KMP automaton: the query is lowercased and turned into a failure
|
||||
/// table, after which [`is_match`](Self::is_match) tests a candidate in `O(n)`
|
||||
/// over its characters (no re-scanning), regardless of how many partial matches
|
||||
/// occur. Folding is done per-character with [`char::to_lowercase`], so it works
|
||||
/// for non-ASCII names too.
|
||||
pub struct NameMatcher {
|
||||
/// The lowercased query, as `char`s (case folding can change length, and
|
||||
/// indexing chars keeps multi-byte handling correct).
|
||||
needle: Vec<char>,
|
||||
/// KMP failure function: `fail[i]` is the length of the longest proper
|
||||
/// prefix of `needle[..=i]` that is also a suffix of it.
|
||||
fail: Vec<usize>,
|
||||
}
|
||||
|
||||
impl NameMatcher {
|
||||
/// Compile `query` into a matcher. An empty (or all-whitespace-trimmed-away)
|
||||
/// query matches nothing.
|
||||
pub fn new(query: &str) -> Self {
|
||||
let needle: Vec<char> = query.to_lowercase().chars().collect();
|
||||
let fail = build_failure(&needle);
|
||||
Self { needle, fail }
|
||||
}
|
||||
|
||||
/// Whether `haystack` contains the query as a case-insensitive substring.
|
||||
pub fn is_match(&self, haystack: &str) -> bool {
|
||||
let m = self.needle.len();
|
||||
if m == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut state = 0usize; // count of leading needle chars matched so far
|
||||
for ch in haystack.chars().flat_map(char::to_lowercase) {
|
||||
while state > 0 && self.needle[state] != ch {
|
||||
state = self.fail[state - 1];
|
||||
}
|
||||
if self.needle[state] == ch {
|
||||
state += 1;
|
||||
if state == m {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the KMP failure function for `needle`.
|
||||
fn build_failure(needle: &[char]) -> Vec<usize> {
|
||||
let mut fail = vec![0usize; needle.len()];
|
||||
let mut k = 0usize;
|
||||
for i in 1..needle.len() {
|
||||
while k > 0 && needle[k] != needle[i] {
|
||||
k = fail[k - 1];
|
||||
}
|
||||
if needle[k] == needle[i] {
|
||||
k += 1;
|
||||
}
|
||||
fail[i] = k;
|
||||
}
|
||||
fail
|
||||
}
|
||||
|
||||
/// Recursively walk `root` and return the paths of directories whose name
|
||||
/// matches `matcher`, up to `cap` results.
|
||||
///
|
||||
/// The walk runs in parallel across a small pool of scoped threads: directories
|
||||
/// live on a shared stack; each worker pops one, reads it *off-lock* (the part
|
||||
/// that actually parallelizes), and pushes back any real subdirectories. The
|
||||
/// walk is finished once the stack is empty and no worker is mid-read. It stops
|
||||
/// early when `cap` is reached or `cancel` is set (checked as each worker claims
|
||||
/// its next directory, so a torn-down search ends promptly). Reparse points
|
||||
/// (junctions/symlinks) are matched by name but never descended into, so the
|
||||
/// walk can't cycle or escape the subtree. Unreadable directories are skipped.
|
||||
pub fn find_matching_dirs(
|
||||
root: &Path,
|
||||
matcher: &NameMatcher,
|
||||
cap: usize,
|
||||
cancel: &AtomicBool,
|
||||
) -> Vec<PathBuf> {
|
||||
if cap == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
/// Work queue + results shared across the walker threads.
|
||||
struct Shared {
|
||||
/// Directories discovered but not yet read.
|
||||
stack: Vec<PathBuf>,
|
||||
/// Workers currently reading a directory (so possibly about to push
|
||||
/// more): the walk ends only when this is 0 *and* `stack` is empty.
|
||||
active: usize,
|
||||
results: Vec<PathBuf>,
|
||||
/// Set once `cap` is hit or the walk drains, to release every worker.
|
||||
done: bool,
|
||||
}
|
||||
|
||||
/// One worker: claim directories and process them until the walk finishes.
|
||||
fn run_worker(
|
||||
shared: &Mutex<Shared>,
|
||||
idle: &Condvar,
|
||||
matcher: &NameMatcher,
|
||||
cap: usize,
|
||||
cancel: &AtomicBool,
|
||||
) {
|
||||
loop {
|
||||
// Claim the next directory, or exit when the walk is finished.
|
||||
let dir = {
|
||||
let mut state = shared.lock().unwrap();
|
||||
loop {
|
||||
if state.done || cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(dir) = state.stack.pop() {
|
||||
state.active += 1;
|
||||
break dir;
|
||||
}
|
||||
if state.active == 0 {
|
||||
// Nothing queued and no worker can produce more.
|
||||
state.done = true;
|
||||
idle.notify_all();
|
||||
return;
|
||||
}
|
||||
// Others are still reading; wait for work or for the end.
|
||||
state = idle.wait(state).unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// Read the directory off-lock — the actual parallel work.
|
||||
let mut subdirs = Vec::new();
|
||||
let mut matches = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for dirent in entries.flatten() {
|
||||
// Cheap on Windows (no extra syscall); doesn't follow links.
|
||||
let Ok(meta) = dirent.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if !meta.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if matcher.is_match(&dirent.file_name().to_string_lossy()) {
|
||||
matches.push(dirent.path());
|
||||
}
|
||||
// Descend into real directories only — not junctions/symlinks.
|
||||
if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 {
|
||||
subdirs.push(dirent.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge findings back and release this directory.
|
||||
let mut state = shared.lock().unwrap();
|
||||
if !state.done {
|
||||
for path in matches {
|
||||
if state.results.len() >= cap {
|
||||
break;
|
||||
}
|
||||
state.results.push(path);
|
||||
}
|
||||
if state.results.len() >= cap {
|
||||
state.done = true;
|
||||
} else {
|
||||
state.stack.extend(subdirs);
|
||||
}
|
||||
}
|
||||
state.active -= 1;
|
||||
idle.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
let shared = Mutex::new(Shared {
|
||||
stack: vec![root.to_path_buf()],
|
||||
active: 0,
|
||||
results: Vec::new(),
|
||||
done: false,
|
||||
});
|
||||
let idle = Condvar::new();
|
||||
|
||||
let workers = thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
.min(MAX_WALK_THREADS);
|
||||
|
||||
thread::scope(|scope| {
|
||||
for _ in 0..workers {
|
||||
scope.spawn(|| run_worker(&shared, &idle, matcher, cap, cancel));
|
||||
}
|
||||
});
|
||||
|
||||
shared.into_inner().unwrap().results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn matches_substring_case_insensitively() {
|
||||
let m = NameMatcher::new("Report");
|
||||
assert!(m.is_match("Quarterly Report 2026"));
|
||||
assert!(m.is_match("annual-report.txt"));
|
||||
assert!(!m.is_match("summary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_overlapping_prefixes() {
|
||||
// A pattern whose prefixes overlap exercises the failure function: the
|
||||
// needle nearly matches, resets, then matches.
|
||||
let m = NameMatcher::new("aabaa");
|
||||
assert!(m.is_match("xaabaabaay"));
|
||||
assert!(!m.is_match("aabab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_matches_nothing() {
|
||||
assert!(!NameMatcher::new("").is_match("anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_matching_directories_recursively() {
|
||||
let tmp = std::env::temp_dir().join(format!("librarian_match_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(tmp.join("projects").join("report-archive")).unwrap();
|
||||
fs::create_dir_all(tmp.join("Reports")).unwrap();
|
||||
fs::create_dir_all(tmp.join("misc")).unwrap();
|
||||
File::create(tmp.join("report.txt")).unwrap(); // a *file*, must be ignored
|
||||
|
||||
let matcher = NameMatcher::new("report");
|
||||
let cancel = AtomicBool::new(false);
|
||||
let mut found = find_matching_dirs(&tmp, &matcher, 100, &cancel);
|
||||
found.sort();
|
||||
|
||||
let names: Vec<String> = found
|
||||
.iter()
|
||||
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["Reports", "report-archive"]);
|
||||
|
||||
fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_matches_deep_in_a_wide_tree() {
|
||||
// A wide, several-levels-deep tree exercises the parallel workers'
|
||||
// push/pop and termination: a match is buried at the bottom of one branch.
|
||||
let tmp = std::env::temp_dir().join(format!("librarian_deep_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
for a in 0..6 {
|
||||
for b in 0..6 {
|
||||
fs::create_dir_all(tmp.join(format!("a{a}")).join(format!("b{b}")).join("leaf"))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
// One uniquely-named directory hidden deep in the tree.
|
||||
fs::create_dir_all(tmp.join("a3").join("b4").join("treasure-chest")).unwrap();
|
||||
|
||||
let matcher = NameMatcher::new("treasure");
|
||||
let cancel = AtomicBool::new(false);
|
||||
let found = find_matching_dirs(&tmp, &matcher, 100, &cancel);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(found[0].ends_with("treasure-chest"));
|
||||
|
||||
fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_walk_returns_promptly() {
|
||||
let tmp = std::env::temp_dir().join(format!("librarian_cancel_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(tmp.join("alpha")).unwrap();
|
||||
// Pre-cancelled: the walk should bail out without collecting anything.
|
||||
let cancel = AtomicBool::new(true);
|
||||
let matcher = NameMatcher::new("alpha");
|
||||
let found = find_matching_dirs(&tmp, &matcher, 100, &cancel);
|
||||
assert!(found.is_empty());
|
||||
|
||||
fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_the_cap() {
|
||||
let tmp = std::env::temp_dir().join(format!("librarian_cap_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
for i in 0..5 {
|
||||
fs::create_dir_all(tmp.join(format!("data{i}"))).unwrap();
|
||||
}
|
||||
let matcher = NameMatcher::new("data");
|
||||
let cancel = AtomicBool::new(false);
|
||||
let found = find_matching_dirs(&tmp, &matcher, 3, &cancel);
|
||||
assert_eq!(found.len(), 3);
|
||||
|
||||
fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# scripts/
|
||||
|
||||
Standalone operational helpers. These are **not** part of the application build,
|
||||
and the Librarian executable never invokes them — they exist to be picked up by
|
||||
the separate, user-maintained installer/packaging project.
|
||||
|
||||
## `install-ripgrep.ps1`
|
||||
|
||||
Provisions [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg.exe`), which
|
||||
Librarian uses as its recursive-search engine. Intended to run as a **pre-install
|
||||
step** in the installer for users who don't already have ripgrep.
|
||||
|
||||
It resolves the latest `x86_64-pc-windows-msvc` release from GitHub (with a pinned
|
||||
fallback if GitHub is unreachable), installs `rg.exe` to
|
||||
`%LOCALAPPDATA%\Programs\ripgrep`, and adds it to the user PATH. It is idempotent:
|
||||
if ripgrep is already discoverable it does nothing.
|
||||
|
||||
```powershell
|
||||
# Default per-user install (no admin needed):
|
||||
powershell -ExecutionPolicy Bypass -File .\install-ripgrep.ps1
|
||||
|
||||
# Pin a version / install next to the app instead of touching PATH:
|
||||
powershell -ExecutionPolicy Bypass -File .\install-ripgrep.ps1 -Version 15.1.0 -InstallDir "C:\Program Files\Librarian" -NoPath
|
||||
```
|
||||
|
||||
At runtime Librarian locates `rg.exe` in this order: next to its own executable,
|
||||
then on `PATH`, then the common winget/scoop/chocolatey locations and the
|
||||
install dir above. So the installer can either run this script or simply drop
|
||||
`rg.exe` beside `librarian.exe` for a fully portable install.
|
||||
@@ -0,0 +1,199 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ensures ripgrep (rg.exe) is installed, for Librarian's recursive search.
|
||||
|
||||
.DESCRIPTION
|
||||
Librarian uses ripgrep as its search engine. This script provisions it on
|
||||
machines that don't already have it. It is a standalone helper intended to be
|
||||
bundled and run by the SEPARATE Librarian installer project as a pre-install
|
||||
step; it is NOT part of the application build and the app does not invoke it.
|
||||
|
||||
Behaviour:
|
||||
1. If rg.exe is already discoverable (on PATH, or in the install dir, or in
|
||||
a known winget/scoop/choco location), it does nothing unless -Force.
|
||||
2. Otherwise it resolves the latest ripgrep release from GitHub (falling
|
||||
back to a pinned version if the GitHub API is unreachable), downloads the
|
||||
x86_64-pc-windows-msvc archive, extracts rg.exe into -InstallDir, and
|
||||
(unless -NoPath) adds that directory to the current user's PATH.
|
||||
|
||||
The default install directory matches the first location Librarian probes at
|
||||
runtime (%LOCALAPPDATA%\Programs\ripgrep), so search works immediately.
|
||||
|
||||
.PARAMETER InstallDir
|
||||
Directory to install rg.exe into. Default: %LOCALAPPDATA%\Programs\ripgrep.
|
||||
|
||||
.PARAMETER Force
|
||||
Reinstall even if ripgrep is already present.
|
||||
|
||||
.PARAMETER NoPath
|
||||
Don't modify the user PATH (e.g. when the installer adds it itself, or when
|
||||
placing rg.exe next to the Librarian executable instead).
|
||||
|
||||
.PARAMETER Version
|
||||
Install a specific ripgrep version (e.g. "15.1.0") instead of the latest.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File .\install-ripgrep.ps1
|
||||
|
||||
.NOTES
|
||||
Requires Windows PowerShell 5.1+ or PowerShell 7+. No admin rights needed for
|
||||
the default per-user install location.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$InstallDir = (Join-Path $env:LOCALAPPDATA 'Programs\ripgrep'),
|
||||
[switch]$Force,
|
||||
[switch]$NoPath,
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ripgrep target triple this script provisions, and a pinned fallback used only
|
||||
# if the GitHub "latest release" lookup fails (offline, rate-limited, etc.).
|
||||
$Triple = 'x86_64-pc-windows-msvc'
|
||||
$FallbackVersion = '15.1.0'
|
||||
$Repo = 'BurntSushi/ripgrep'
|
||||
|
||||
function Write-Step([string]$Message) { Write-Host "==> $Message" -ForegroundColor Cyan }
|
||||
function Write-Ok([string]$Message) { Write-Host " $Message" -ForegroundColor Green }
|
||||
function Write-Warn2([string]$Message) { Write-Host " $Message" -ForegroundColor Yellow }
|
||||
|
||||
# Return the path to an existing rg.exe if one is discoverable, else $null. Mirrors
|
||||
# the locations Librarian itself probes at runtime.
|
||||
function Find-Ripgrep {
|
||||
param([string]$InstallDir)
|
||||
|
||||
$onPath = Get-Command rg.exe -ErrorAction SilentlyContinue
|
||||
if ($onPath) { return $onPath.Source }
|
||||
|
||||
$candidates = @(
|
||||
(Join-Path $InstallDir 'rg.exe'),
|
||||
(Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\rg.exe'),
|
||||
(Join-Path $env:USERPROFILE 'scoop\shims\rg.exe'),
|
||||
'C:\ProgramData\chocolatey\bin\rg.exe'
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if ($c -and (Test-Path -LiteralPath $c)) { return $c }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolve the download URL + version for the requested (or latest) release.
|
||||
function Resolve-Release {
|
||||
param([string]$Version)
|
||||
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
if ($Version) {
|
||||
$tag = $Version
|
||||
}
|
||||
else {
|
||||
try {
|
||||
Write-Step "Querying GitHub for the latest ripgrep release..."
|
||||
$headers = @{ 'User-Agent' = 'librarian-install-ripgrep' }
|
||||
$rel = Invoke-RestMethod -Headers $headers -Uri "https://api.github.com/repos/$Repo/releases/latest"
|
||||
$tag = $rel.tag_name
|
||||
|
||||
# Prefer the asset URL straight from the release metadata when present.
|
||||
$asset = $rel.assets | Where-Object { $_.name -like "*$Triple.zip" } | Select-Object -First 1
|
||||
if ($asset) {
|
||||
return [pscustomobject]@{ Version = $tag; Url = $asset.browser_download_url }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warn2 "GitHub lookup failed ($($_.Exception.Message)); using pinned v$FallbackVersion."
|
||||
$tag = $FallbackVersion
|
||||
}
|
||||
}
|
||||
|
||||
$name = "ripgrep-$tag-$Triple.zip"
|
||||
$url = "https://github.com/$Repo/releases/download/$tag/$name"
|
||||
return [pscustomobject]@{ Version = $tag; Url = $url }
|
||||
}
|
||||
|
||||
# Append $Dir to the current user's PATH (persisted + this session), if missing.
|
||||
function Add-ToUserPath {
|
||||
param([string]$Dir)
|
||||
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
$parts = @()
|
||||
if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ -ne '' } }
|
||||
|
||||
if ($parts -contains $Dir) {
|
||||
Write-Ok "Already on the user PATH."
|
||||
return
|
||||
}
|
||||
|
||||
$newPath = (($parts + $Dir) -join ';')
|
||||
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
||||
# Reflect it in the current session too, so a follow-on step sees rg.
|
||||
$env:Path = "$env:Path;$Dir"
|
||||
Write-Ok "Added to the user PATH (open a new terminal to pick it up globally)."
|
||||
}
|
||||
|
||||
# --- main ---------------------------------------------------------------------
|
||||
|
||||
$existing = Find-Ripgrep -InstallDir $InstallDir
|
||||
if ($existing -and -not $Force) {
|
||||
$rgVersion = (& $existing --version | Select-Object -First 1)
|
||||
Write-Step "ripgrep is already installed: $existing"
|
||||
Write-Ok $rgVersion
|
||||
Write-Ok "Nothing to do. Re-run with -Force to reinstall."
|
||||
return
|
||||
}
|
||||
|
||||
$release = Resolve-Release -Version $Version
|
||||
Write-Step "Installing ripgrep $($release.Version) ($Triple)"
|
||||
|
||||
$tempDir = Join-Path ([IO.Path]::GetTempPath()) ("librarian-rg-" + [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
$zipPath = Join-Path $tempDir 'ripgrep.zip'
|
||||
|
||||
try {
|
||||
Write-Step "Downloading $($release.Url)"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
# Progress rendering makes Invoke-WebRequest dramatically slower; suppress it.
|
||||
$oldProgress = $ProgressPreference
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
try {
|
||||
Invoke-WebRequest -Uri $release.Url -OutFile $zipPath -UseBasicParsing
|
||||
}
|
||||
finally {
|
||||
$ProgressPreference = $oldProgress
|
||||
}
|
||||
|
||||
Write-Step "Extracting..."
|
||||
Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force
|
||||
|
||||
$rg = Get-ChildItem -Path $tempDir -Recurse -Filter 'rg.exe' | Select-Object -First 1
|
||||
if (-not $rg) {
|
||||
throw "rg.exe not found in the downloaded archive."
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $InstallDir)) {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
}
|
||||
$destExe = Join-Path $InstallDir 'rg.exe'
|
||||
Copy-Item -LiteralPath $rg.FullName -Destination $destExe -Force
|
||||
|
||||
# Bring along the license/readme that ship alongside, where present.
|
||||
foreach ($extra in @('LICENSE-MIT', 'UNLICENSE', 'COPYING', 'README.md')) {
|
||||
$src = Get-ChildItem -Path $tempDir -Recurse -Filter $extra -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($src) { Copy-Item -LiteralPath $src.FullName -Destination $InstallDir -Force }
|
||||
}
|
||||
|
||||
Write-Ok "Installed: $destExe"
|
||||
$rgVersion = (& $destExe --version | Select-Object -First 1)
|
||||
Write-Ok $rgVersion
|
||||
|
||||
if (-not $NoPath) {
|
||||
Add-ToUserPath -Dir $InstallDir
|
||||
}
|
||||
|
||||
Write-Step "ripgrep is ready for Librarian search."
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Reference in New Issue
Block a user