search feature and pre-install script

This commit is contained in:
2026-06-16 18:53:51 -05:00
parent 1ae516beca
commit 8165119f29
9 changed files with 1397 additions and 38 deletions
Generated
+6
View File
@@ -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]]
+4
View File
@@ -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.
+349 -38
View File
@@ -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
+59
View File
@@ -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();
+430
View File
@@ -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");
}
}
+2
View File
@@ -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};
+319
View File
@@ -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();
}
}
+29
View File
@@ -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.
+199
View File
@@ -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
}