2026-08-02 19:04:30 -04:00
|
|
|
//! The Manage Index tab: detailed status, mode controls, indexed roots,
|
|
|
|
|
//! and the content/ignore filter editors.
|
|
|
|
|
|
2026-08-03 03:06:19 -04:00
|
|
|
use std::path::Path;
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
use quicksearch_core::config::Config;
|
2026-08-05 18:05:04 -04:00
|
|
|
use quicksearch_core::coordinator::{IndexMode, IndexerState, ReconcileState, WatcherStatus};
|
|
|
|
|
use quicksearch_core::indexing::{
|
|
|
|
|
IndexingStatus, PrepStep, ReconcileProgress, RootPhase, RootProgress,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use crate::format::{
|
|
|
|
|
fmt_duration_clock, fmt_interval, fmt_rate, fmt_reconcile_summary, group_thousands, human_size,
|
|
|
|
|
middle_truncate,
|
|
|
|
|
};
|
|
|
|
|
use crate::tips::{self, Tipped};
|
2026-08-02 19:04:30 -04:00
|
|
|
use crate::tracker::SpeedTracker;
|
2026-08-09 16:25:43 -04:00
|
|
|
use crate::ui_util::hint;
|
2026-08-05 18:05:04 -04:00
|
|
|
use crate::ui_util::middle_elide;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
/// What the tab asks the app to do after this frame.
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
pub struct ManageActions {
|
|
|
|
|
pub start_now: bool,
|
|
|
|
|
pub stop: bool,
|
|
|
|
|
pub auto: bool,
|
|
|
|
|
/// Ask the app to confirm and delete the index.
|
|
|
|
|
pub clear_index: bool,
|
2026-08-02 22:21:39 -04:00
|
|
|
/// A full edited config to apply (roots / filters).
|
2026-08-02 19:04:30 -04:00
|
|
|
pub apply_config: Option<Config>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct ManageTab {
|
|
|
|
|
pub speed: SpeedTracker,
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Multiline editor, one extension per line; parsed back on Apply.
|
2026-08-02 19:04:30 -04:00
|
|
|
ext_filter_text: String,
|
|
|
|
|
new_root: String,
|
2026-08-02 20:21:19 -04:00
|
|
|
/// Text of the inline "add ignore pattern" box.
|
|
|
|
|
new_ignore: String,
|
2026-08-02 19:04:30 -04:00
|
|
|
/// Inline error from a rejected root add (nested/duplicate).
|
|
|
|
|
root_error: Option<String>,
|
2026-08-09 16:25:43 -04:00
|
|
|
/// The config the draft was last synced from; `None` forces a full resync.
|
2026-08-02 20:21:19 -04:00
|
|
|
baseline: Option<Config>,
|
2026-08-02 22:21:39 -04:00
|
|
|
/// Draft of the roots/filters edited in-place.
|
2026-08-02 19:04:30 -04:00
|
|
|
draft: Option<Config>,
|
2026-08-03 03:06:19 -04:00
|
|
|
/// Cached on-disk footprint of the index, restatted on a timer.
|
|
|
|
|
db_size: DbSizeProbe,
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ManageTab {
|
|
|
|
|
pub fn new() -> ManageTab {
|
|
|
|
|
ManageTab {
|
|
|
|
|
speed: SpeedTracker::new(),
|
|
|
|
|
ext_filter_text: String::new(),
|
|
|
|
|
new_root: String::new(),
|
2026-08-02 20:21:19 -04:00
|
|
|
new_ignore: String::new(),
|
2026-08-02 19:04:30 -04:00
|
|
|
root_error: None,
|
2026-08-02 20:21:19 -04:00
|
|
|
baseline: None,
|
2026-08-02 19:04:30 -04:00
|
|
|
draft: None,
|
2026-08-03 03:06:19 -04:00
|
|
|
db_size: DbSizeProbe::default(),
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Feed the tracker from the polled status (called every frame, on
|
2026-08-09 16:25:43 -04:00
|
|
|
/// every tab).
|
2026-08-02 19:04:30 -04:00
|
|
|
pub fn observe(&mut self, status: &IndexingStatus) {
|
|
|
|
|
match status {
|
|
|
|
|
IndexingStatus::Running { roots, .. } => {
|
|
|
|
|
// Monotonic within a run: walks and extractions only grow.
|
|
|
|
|
let total: usize = roots.iter().map(|r| r.walked + r.extracted).sum();
|
|
|
|
|
self.speed.record(total);
|
|
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
// Preparing included: a stale files/sec left over from the last
|
2026-08-05 18:05:04 -04:00
|
|
|
// run would read as progress that is not happening.
|
|
|
|
|
IndexingStatus::Idle
|
|
|
|
|
| IndexingStatus::Error(_)
|
|
|
|
|
| IndexingStatus::Optimizing
|
|
|
|
|
| IndexingStatus::Preparing { .. } => self.speed.reset(),
|
2026-08-02 19:04:30 -04:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Reconcile the draft with the live config, every frame, so a filter
|
|
|
|
|
/// persisted elsewhere shows up here on the next frame.
|
2026-08-02 19:04:30 -04:00
|
|
|
fn sync_editors(&mut self, config: &Config) {
|
2026-08-02 20:21:19 -04:00
|
|
|
let Some(baseline) = &self.baseline else {
|
|
|
|
|
// First frame, or right after our own Apply.
|
|
|
|
|
return self.resync(config);
|
|
|
|
|
};
|
|
|
|
|
if baseline == config {
|
|
|
|
|
return;
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
// The config changed elsewhere.
|
2026-08-04 03:27:05 -04:00
|
|
|
if !self.is_dirty() {
|
2026-08-02 20:21:19 -04:00
|
|
|
// Nothing staged, nothing to lose.
|
|
|
|
|
return self.resync(config);
|
|
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
// Staged edits exist: keep the sections this tab edits, adopt the
|
|
|
|
|
// rest so a later Apply cannot revert changes made elsewhere.
|
2026-08-02 20:21:19 -04:00
|
|
|
let draft = self.draft.take().expect("synced");
|
|
|
|
|
let mut merged = config.clone();
|
|
|
|
|
merged.paths.indexing_paths = draft.paths.indexing_paths;
|
|
|
|
|
merged.indexing = draft.indexing;
|
2026-08-09 16:25:43 -04:00
|
|
|
// Live state, not a user edit: a stale `auto_index` frozen into the
|
|
|
|
|
// draft would read as permanently dirty.
|
2026-08-04 03:27:05 -04:00
|
|
|
merged.indexing.auto_index = config.indexing.auto_index;
|
2026-08-02 20:21:19 -04:00
|
|
|
merged.processing = draft.processing;
|
|
|
|
|
for pat in &config.indexing.ignore_patterns {
|
|
|
|
|
if !baseline.indexing.ignore_patterns.contains(pat)
|
|
|
|
|
&& !merged.indexing.ignore_patterns.contains(pat)
|
|
|
|
|
{
|
|
|
|
|
merged.indexing.ignore_patterns.push(pat.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.draft = Some(merged);
|
|
|
|
|
self.baseline = Some(config.clone());
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-02 20:21:19 -04:00
|
|
|
fn resync(&mut self, config: &Config) {
|
|
|
|
|
self.ext_filter_text = config.indexing.content_extensions.join("\n");
|
|
|
|
|
self.draft = Some(config.clone());
|
|
|
|
|
self.baseline = Some(config.clone());
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Whether the editors hold changes not yet applied. Live fields are
|
|
|
|
|
/// pinned before comparing (`pin_live_fields`) and the extension text is
|
|
|
|
|
/// compared parsed, so a trailing newline never reads as dirty. False
|
|
|
|
|
/// before the first sync.
|
2026-08-04 03:27:05 -04:00
|
|
|
pub fn is_dirty(&self) -> bool {
|
|
|
|
|
let (Some(draft), Some(baseline)) = (&self.draft, &self.baseline) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let mut d = draft.clone();
|
|
|
|
|
crate::app::pin_live_fields(&mut d, baseline);
|
|
|
|
|
d != *baseline
|
|
|
|
|
|| parse_lines(&self.ext_filter_text)
|
|
|
|
|
!= parse_lines(&baseline.indexing.content_extensions.join("\n"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// The Apply & Save action. Syncs against `live` first so a config
|
|
|
|
|
/// applied elsewhere moments ago is not reverted. Does NOT clear
|
|
|
|
|
/// `baseline`: the app calls [`ManageTab::mark_applied`] only after the
|
|
|
|
|
/// apply succeeds, so a rejected apply keeps the staged edits.
|
2026-08-04 03:27:05 -04:00
|
|
|
pub fn take_apply_config(&mut self, live: &Config) -> Option<Config> {
|
|
|
|
|
self.sync_editors(live);
|
|
|
|
|
let draft = self.draft.as_ref()?;
|
|
|
|
|
let mut new_config = draft.clone();
|
|
|
|
|
new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text);
|
|
|
|
|
let roots = new_config.paths.indexing_paths.clone();
|
|
|
|
|
new_config
|
|
|
|
|
.indexing
|
|
|
|
|
.root_workers
|
|
|
|
|
.retain(|root, _| roots.contains(root));
|
|
|
|
|
Some(new_config)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The last apply landed: resync from the applied config next frame.
|
|
|
|
|
pub fn mark_applied(&mut self) {
|
|
|
|
|
self.baseline = None;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Drop every staged edit; the next frame resyncs from the live config.
|
2026-08-04 03:27:05 -04:00
|
|
|
pub fn discard(&mut self) {
|
|
|
|
|
self.draft = None;
|
|
|
|
|
self.baseline = None;
|
|
|
|
|
self.new_root.clear();
|
|
|
|
|
self.new_ignore.clear();
|
|
|
|
|
self.root_error = None;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
pub fn ui(
|
|
|
|
|
&mut self,
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
state: &IndexerState,
|
|
|
|
|
config: &Config,
|
|
|
|
|
) -> ManageActions {
|
|
|
|
|
let mut actions = ManageActions::default();
|
|
|
|
|
self.sync_editors(config);
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
let scroll = egui::ScrollArea::vertical()
|
|
|
|
|
.auto_shrink([false; 2])
|
|
|
|
|
.show(ui, |ui| {
|
|
|
|
|
// --- Status ---------------------------------------------------
|
2026-08-03 03:06:19 -04:00
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
ui.heading(egui::RichText::new("Status").strong());
|
|
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
|
|
|
db_size_label(ui, self.db_size.size(config, Instant::now()));
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-02 22:21:39 -04:00
|
|
|
status_panel(ui, state, &self.speed);
|
|
|
|
|
watch_panel(ui, state, config);
|
|
|
|
|
ui.add_space(8.0);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
// --- Controls -------------------------------------------------
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
let running = !matches!(
|
|
|
|
|
state.activity,
|
|
|
|
|
IndexingStatus::Idle | IndexingStatus::Error(_)
|
2026-08-02 19:04:30 -04:00
|
|
|
);
|
2026-08-02 22:21:39 -04:00
|
|
|
if ui
|
|
|
|
|
.add_enabled(!running, egui::Button::new("Start indexing now"))
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::START_NOW)
|
2026-08-02 22:21:39 -04:00
|
|
|
.clicked()
|
|
|
|
|
{
|
|
|
|
|
actions.start_now = true;
|
|
|
|
|
}
|
|
|
|
|
if ui
|
|
|
|
|
.add_enabled(
|
|
|
|
|
running || state.mode == IndexMode::Auto,
|
|
|
|
|
egui::Button::new("Stop"),
|
|
|
|
|
)
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::STOP_INDEXING)
|
2026-08-02 22:21:39 -04:00
|
|
|
.clicked()
|
|
|
|
|
{
|
|
|
|
|
actions.stop = true;
|
|
|
|
|
}
|
|
|
|
|
if ui
|
|
|
|
|
.add_enabled(
|
|
|
|
|
state.mode != IndexMode::Auto,
|
|
|
|
|
egui::Button::new("Return to Automatic"),
|
|
|
|
|
)
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::RETURN_TO_AUTO)
|
2026-08-02 22:21:39 -04:00
|
|
|
.clicked()
|
|
|
|
|
{
|
|
|
|
|
actions.auto = true;
|
|
|
|
|
}
|
|
|
|
|
let mode = match state.mode {
|
|
|
|
|
IndexMode::Auto => "Automatic",
|
|
|
|
|
IndexMode::ManualStopped => "Manual (stopped)",
|
|
|
|
|
IndexMode::ManualRunning => "Manual (running)",
|
|
|
|
|
};
|
|
|
|
|
ui.label(egui::RichText::new(format!("Mode: {}", mode)).weak());
|
|
|
|
|
ui.separator();
|
|
|
|
|
if ui
|
|
|
|
|
.button(
|
|
|
|
|
egui::RichText::new("Clear index…").color(ui.visuals().error_fg_color),
|
|
|
|
|
)
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::CLEAR_INDEX)
|
2026-08-02 22:21:39 -04:00
|
|
|
.clicked()
|
|
|
|
|
{
|
|
|
|
|
actions.clear_index = true;
|
|
|
|
|
}
|
|
|
|
|
if state.queued_events > 0 {
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint(format!("{} changes queued", state.queued_events)));
|
2026-08-02 22:21:39 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
ui.separator();
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
// --- Indexed roots ---------------------------------------------
|
|
|
|
|
ui.heading(egui::RichText::new("Indexed folders").strong());
|
|
|
|
|
let draft = self.draft.as_mut().expect("synced");
|
|
|
|
|
let mut remove: Option<usize> = None;
|
2026-08-09 16:25:43 -04:00
|
|
|
let (paths, indexing) = (&draft.paths, &mut draft.indexing);
|
|
|
|
|
for (i, root) in paths.indexing_paths.iter().enumerate() {
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.horizontal(|ui| {
|
2026-08-09 16:25:43 -04:00
|
|
|
// Controls claim the right edge first so a long path
|
|
|
|
|
// can never push them out of view.
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
2026-08-05 18:05:04 -04:00
|
|
|
if ui.small_button("Remove").tip(&tips::REMOVE_ROOT).clicked() {
|
2026-08-02 22:21:39 -04:00
|
|
|
remove = Some(i);
|
|
|
|
|
}
|
|
|
|
|
// Per-root walker override; 0 = auto (4 local / 16
|
|
|
|
|
// network, detected per root). Applies on the next run.
|
2026-08-09 16:25:43 -04:00
|
|
|
let mut workers = indexing.root_workers.get(root).copied().unwrap_or(0);
|
2026-08-02 22:21:39 -04:00
|
|
|
let response = ui
|
|
|
|
|
.add(
|
|
|
|
|
egui::DragValue::new(&mut workers)
|
|
|
|
|
.range(0..=64)
|
|
|
|
|
.custom_formatter(|n, _| {
|
|
|
|
|
if n == 0.0 {
|
|
|
|
|
"auto".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("{:.0}", n)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.custom_parser(|s| {
|
|
|
|
|
let s = s.trim();
|
|
|
|
|
if s.is_empty() || s.eq_ignore_ascii_case("auto") {
|
|
|
|
|
Some(0.0)
|
|
|
|
|
} else {
|
|
|
|
|
s.parse().ok()
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::ROOT_WORKERS);
|
2026-08-03 03:06:19 -04:00
|
|
|
#[cfg(test)]
|
|
|
|
|
tests::record_widget("workers", &response);
|
2026-08-02 22:21:39 -04:00
|
|
|
if response.changed() {
|
|
|
|
|
if workers == 0 {
|
2026-08-09 16:25:43 -04:00
|
|
|
indexing.root_workers.remove(root);
|
2026-08-02 22:21:39 -04:00
|
|
|
} else {
|
2026-08-09 16:25:43 -04:00
|
|
|
indexing.root_workers.insert(root.clone(), workers);
|
2026-08-02 22:21:39 -04:00
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint("workers:"));
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-09 18:36:47 -04:00
|
|
|
// Unconditional, placeholder and all: egui names a
|
|
|
|
|
// widget by how many precede it, so a label that
|
|
|
|
|
// came and went would rename the field above and
|
|
|
|
|
// cost it any edit in progress. After that field
|
|
|
|
|
// for the same reason — in this right-to-left
|
|
|
|
|
// layout "after" is to its left.
|
|
|
|
|
ui.label(hint(root_counts_text(state, root)))
|
|
|
|
|
.tip(&tips::ROOT_COUNTS);
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.with_layout(
|
|
|
|
|
egui::Layout::left_to_right(egui::Align::Center),
|
|
|
|
|
|ui| {
|
|
|
|
|
let font_id = egui::TextStyle::Monospace.resolve(ui.style());
|
2026-08-05 18:05:04 -04:00
|
|
|
let shown =
|
|
|
|
|
middle_elide(ui, root, ui.available_width(), &font_id);
|
|
|
|
|
ui.monospace(shown.as_ref()).on_hover_text(root);
|
2026-08-02 22:21:39 -04:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
});
|
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
if let Some(i) = remove {
|
|
|
|
|
let removed = draft.paths.indexing_paths.remove(i);
|
|
|
|
|
draft.indexing.root_workers.remove(&removed);
|
|
|
|
|
}
|
|
|
|
|
ui.horizontal(|ui| {
|
2026-08-05 18:05:04 -04:00
|
|
|
if ui.button("Add folder…").tip(&tips::ADD_ROOT).clicked() {
|
2026-08-02 22:21:39 -04:00
|
|
|
if let Some(dir) = rfd::FileDialog::new().pick_folder() {
|
|
|
|
|
let path = dir.to_string_lossy().into_owned();
|
|
|
|
|
try_add_root(draft, path, &mut self.root_error);
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.add(
|
|
|
|
|
egui::TextEdit::singleline(&mut self.new_root)
|
|
|
|
|
.desired_width(240.0)
|
|
|
|
|
.hint_text("or type a path"),
|
2026-08-05 18:05:04 -04:00
|
|
|
)
|
|
|
|
|
.tip(&tips::ADD_ROOT);
|
|
|
|
|
if ui.button("Add").tip(&tips::ADD_ROOT).clicked()
|
|
|
|
|
&& !self.new_root.trim().is_empty()
|
|
|
|
|
{
|
2026-08-02 22:21:39 -04:00
|
|
|
let path = self.new_root.trim().to_string();
|
|
|
|
|
if try_add_root(draft, path, &mut self.root_error) {
|
|
|
|
|
self.new_root.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-03 03:06:19 -04:00
|
|
|
crate::ui_util::stable_section(ui, |ui| {
|
|
|
|
|
if let Some(err) = &self.root_error {
|
|
|
|
|
ui.colored_label(ui.visuals().error_fg_color, err);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint(
|
|
|
|
|
"Removing a folder removes its entries and leaves the rest of \
|
2026-08-04 23:33:28 -04:00
|
|
|
the index untouched; adding one reindexes to pick it up. \
|
|
|
|
|
Neither rebuilds.",
|
2026-08-09 16:25:43 -04:00
|
|
|
));
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.separator();
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
// --- Filters ---------------------------------------------------
|
|
|
|
|
ui.heading(egui::RichText::new("Content filters").strong());
|
|
|
|
|
ui.columns(2, |cols| {
|
2026-08-05 18:05:04 -04:00
|
|
|
cols[0]
|
|
|
|
|
.label("Full-text extensions whitelist (empty = all supported):")
|
|
|
|
|
.tip(&tips::EXT_WHITELIST);
|
2026-08-03 12:33:47 -04:00
|
|
|
cols[0]
|
|
|
|
|
.add(
|
|
|
|
|
egui::TextEdit::multiline(&mut self.ext_filter_text)
|
|
|
|
|
.desired_rows(4)
|
|
|
|
|
.desired_width(f32::INFINITY)
|
|
|
|
|
.hint_text("txt\nmd\npdf # comments allowed\n(none)"),
|
|
|
|
|
)
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::EXT_WHITELIST);
|
|
|
|
|
cols[1]
|
|
|
|
|
.label("Ignore patterns (excluded entirely):")
|
|
|
|
|
.tip(&tips::IGNORE_PATTERNS);
|
2026-08-02 22:21:39 -04:00
|
|
|
let mut remove_pat: Option<usize> = None;
|
2026-08-09 16:25:43 -04:00
|
|
|
// The list grows and shrinks, so it is kept off the id of
|
|
|
|
|
// the editor below it (see `ui_util::stable_section`).
|
2026-08-03 03:06:19 -04:00
|
|
|
crate::ui_util::stable_section(&mut cols[1], |ui| {
|
|
|
|
|
for (i, pat) in draft.indexing.ignore_patterns.iter().enumerate() {
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
ui.with_layout(
|
|
|
|
|
egui::Layout::right_to_left(egui::Align::Center),
|
|
|
|
|
|ui| {
|
|
|
|
|
if ui.small_button("Remove").clicked() {
|
|
|
|
|
remove_pat = Some(i);
|
|
|
|
|
}
|
|
|
|
|
ui.with_layout(
|
|
|
|
|
egui::Layout::left_to_right(egui::Align::Center),
|
|
|
|
|
|ui| {
|
|
|
|
|
ui.monospace(pat);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if draft.indexing.ignore_patterns.is_empty() {
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint("No ignore patterns."));
|
2026-08-03 03:06:19 -04:00
|
|
|
}
|
|
|
|
|
});
|
2026-08-02 22:21:39 -04:00
|
|
|
if let Some(i) = remove_pat {
|
|
|
|
|
draft.indexing.ignore_patterns.remove(i);
|
|
|
|
|
}
|
2026-08-02 20:21:19 -04:00
|
|
|
cols[1].horizontal(|ui| {
|
2026-08-02 22:21:39 -04:00
|
|
|
let (response, valid) = crate::ui_util::pattern_edit(
|
|
|
|
|
ui,
|
|
|
|
|
&mut self.new_ignore,
|
|
|
|
|
180.0,
|
|
|
|
|
"*.tmp or node_modules",
|
2026-08-02 20:21:19 -04:00
|
|
|
);
|
2026-08-02 22:21:39 -04:00
|
|
|
let submitted =
|
|
|
|
|
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
2026-08-05 18:05:04 -04:00
|
|
|
response.tip(&tips::IGNORE_PATTERNS);
|
|
|
|
|
if ui
|
|
|
|
|
.add_enabled(valid, egui::Button::new("Add"))
|
|
|
|
|
.tip(&tips::IGNORE_PATTERNS)
|
|
|
|
|
.clicked()
|
2026-08-02 22:21:39 -04:00
|
|
|
|| (submitted && valid)
|
|
|
|
|
{
|
|
|
|
|
let pat = self.new_ignore.trim().to_string();
|
|
|
|
|
if !draft.indexing.ignore_patterns.contains(&pat) {
|
|
|
|
|
draft.indexing.ignore_patterns.push(pat);
|
|
|
|
|
}
|
|
|
|
|
self.new_ignore.clear();
|
|
|
|
|
}
|
2026-08-02 20:21:19 -04:00
|
|
|
});
|
2026-08-04 03:27:05 -04:00
|
|
|
crate::ui_util::pattern_hint_label(&mut cols[1], &self.new_ignore);
|
2026-08-09 16:25:43 -04:00
|
|
|
cols[1].label(hint(
|
|
|
|
|
"Changes apply on Apply & Save. A new pattern removes the \
|
2026-08-04 23:33:28 -04:00
|
|
|
entries it matches; removing one reindexes to bring them \
|
|
|
|
|
back.",
|
2026-08-09 16:25:43 -04:00
|
|
|
));
|
2026-08-02 20:21:19 -04:00
|
|
|
});
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.separator();
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint(
|
|
|
|
|
"Reindex interval, symlinks, hidden files, tokenizer, and size \
|
2026-08-02 22:21:39 -04:00
|
|
|
limits are in Options (⚙ in the toolbar).",
|
2026-08-09 16:25:43 -04:00
|
|
|
));
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.add_space(8.0);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-04 03:27:05 -04:00
|
|
|
let dirty = self.is_dirty();
|
2026-08-09 02:58:13 -04:00
|
|
|
let p = crate::color::palette(ui.visuals().dark_mode);
|
2026-08-04 03:27:05 -04:00
|
|
|
ui.horizontal(|ui| {
|
2026-08-05 18:05:04 -04:00
|
|
|
let apply = ui
|
|
|
|
|
.add(crate::ui_util::bordered_button(
|
|
|
|
|
"Apply & Save",
|
2026-08-09 02:58:13 -04:00
|
|
|
if dirty { p.orange } else { p.blue },
|
2026-08-05 18:05:04 -04:00
|
|
|
))
|
|
|
|
|
.tip(&tips::APPLY_SAVE);
|
2026-08-04 03:27:05 -04:00
|
|
|
#[cfg(test)]
|
|
|
|
|
tests::record_widget("apply", &apply);
|
|
|
|
|
// The label comes and goes with the dirty state; keep it
|
|
|
|
|
// off the ids of whatever sits after it.
|
|
|
|
|
crate::ui_util::stable_section(ui, |ui| {
|
|
|
|
|
if dirty {
|
|
|
|
|
ui.label(
|
|
|
|
|
egui::RichText::new("Unsaved changes")
|
|
|
|
|
.small()
|
2026-08-09 02:58:13 -04:00
|
|
|
.color(p.orange),
|
2026-08-04 03:27:05 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if apply.clicked() {
|
|
|
|
|
actions.apply_config = self.take_apply_config(config);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-02 22:21:39 -04:00
|
|
|
});
|
2026-08-02 20:21:19 -04:00
|
|
|
crate::ui_util::more_below_hint(ui, &scroll);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
// Nothing else asks for repaints while the app sits idle; without
|
|
|
|
|
// this the size would freeze until the pointer next moved.
|
2026-08-03 03:06:19 -04:00
|
|
|
ui.ctx().request_repaint_after(DB_SIZE_REFRESH);
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
actions
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// How often the index files are re-statted.
|
2026-08-03 03:06:19 -04:00
|
|
|
const DB_SIZE_REFRESH: Duration = Duration::from_secs(10);
|
|
|
|
|
|
|
|
|
|
/// Total on-disk footprint of the index: the database plus its `-wal` and
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `-shm` sidecars — mid-run the `-wal` can hold hundreds of megabytes the
|
|
|
|
|
/// database does not show yet. A file that is not there counts as zero.
|
2026-08-03 03:06:19 -04:00
|
|
|
fn measure_db_size(db: &Path) -> u64 {
|
|
|
|
|
// Only regular files: a misconfigured path pointing at a directory
|
|
|
|
|
// would otherwise report that directory's own inode size as an index.
|
|
|
|
|
let len = |path: &Path| {
|
|
|
|
|
std::fs::metadata(path)
|
|
|
|
|
.map(|m| if m.is_file() { m.len() } else { 0 })
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
};
|
|
|
|
|
let name = db.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
|
|
|
|
// No `-journal`: the index runs in WAL mode, so a rollback journal is
|
|
|
|
|
// not part of a live database.
|
|
|
|
|
len(db)
|
|
|
|
|
+ ["-wal", "-shm"]
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|suffix| len(&db.with_file_name(format!("{}{}", name, suffix))))
|
|
|
|
|
.sum::<u64>()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Caches the last measurement so the tab can ask for it every frame.
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
struct DbSizeProbe {
|
|
|
|
|
/// Configured (unresolved) path the cached size belongs to.
|
|
|
|
|
path: String,
|
|
|
|
|
bytes: u64,
|
|
|
|
|
measured_at: Option<Instant>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DbSizeProbe {
|
|
|
|
|
/// The cached size, restatted when it has gone stale or the configured
|
|
|
|
|
/// database path changed under it. `now` is a parameter so the refresh
|
|
|
|
|
/// cadence can be tested without sleeping.
|
|
|
|
|
fn size(&mut self, config: &Config, now: Instant) -> u64 {
|
|
|
|
|
let expired = self
|
|
|
|
|
.measured_at
|
|
|
|
|
.is_none_or(|at| now.duration_since(at) >= DB_SIZE_REFRESH);
|
|
|
|
|
if expired || self.path != config.paths.database_path {
|
|
|
|
|
self.bytes = measure_db_size(&config.resolved_database_path());
|
|
|
|
|
self.path = config.paths.database_path.clone();
|
|
|
|
|
self.measured_at = Some(now);
|
|
|
|
|
}
|
|
|
|
|
self.bytes
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// The index's footprint, with the levers for shrinking it on hover.
|
2026-08-03 03:06:19 -04:00
|
|
|
fn db_size_label(ui: &mut egui::Ui, bytes: u64) {
|
|
|
|
|
let response = ui.label(format!("Index size: {}", human_size(bytes)));
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
tests::record_widget("db-size", &response);
|
|
|
|
|
response.on_hover_ui(db_size_tooltip);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn db_size_tooltip(ui: &mut egui::Ui) {
|
|
|
|
|
ui.set_max_width(440.0);
|
|
|
|
|
ui.strong("To reduce the index size");
|
|
|
|
|
for lever in [
|
2026-08-05 18:05:04 -04:00
|
|
|
"Add ignore filters for files and folders you never search, in the ignore \
|
2026-08-03 03:06:19 -04:00
|
|
|
pattern list further down this tab.",
|
|
|
|
|
"Remove indexed folders you do not need, in Indexed folders above.",
|
|
|
|
|
"Narrow the full-text extension whitelist, so text is only extracted \
|
|
|
|
|
from the file types you actually search.",
|
|
|
|
|
"Turn off \"Store text for snippets\" in Options: full-text search keeps \
|
|
|
|
|
working, but without previews, occurrence ranking or fuzzy matching \
|
|
|
|
|
inside file contents.",
|
|
|
|
|
"Lower \"Max text file size\" and \"Max stored text\" in Options.",
|
|
|
|
|
] {
|
|
|
|
|
ui.label(format!("• {}", lever));
|
|
|
|
|
}
|
|
|
|
|
ui.add_space(6.0);
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint(
|
|
|
|
|
"Narrowing any of these removes the entries it excludes straight away, \
|
2026-08-05 18:05:04 -04:00
|
|
|
but the file does not shrink on its own. The freed space is reused by \
|
2026-08-04 23:33:28 -04:00
|
|
|
the index rather than returned to the disk, until an indexing run's \
|
|
|
|
|
optimize pass compacts it.",
|
2026-08-09 16:25:43 -04:00
|
|
|
));
|
2026-08-03 03:06:19 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 18:36:47 -04:00
|
|
|
/// What `root` held when indexing last completed, worded as the live
|
|
|
|
|
/// per-root rows word it (see [`root_row`]) so the list does not rename the
|
|
|
|
|
/// same two figures once the run that produced them is over.
|
|
|
|
|
///
|
|
|
|
|
/// A root the coordinator has no figures for — never indexed to completion,
|
|
|
|
|
/// staged in the draft but not yet applied, or an index that was cleared —
|
|
|
|
|
/// says so rather than claiming zero.
|
|
|
|
|
fn root_counts_text(state: &IndexerState, root: &str) -> String {
|
|
|
|
|
match state.root_counts.iter().find(|c| c.root == root) {
|
|
|
|
|
Some(c) => format!(
|
|
|
|
|
"indexed {} · extracted {}",
|
|
|
|
|
group_thousands(c.counts.files.max(0) as u64),
|
|
|
|
|
group_thousands(c.counts.fts.max(0) as u64)
|
|
|
|
|
),
|
|
|
|
|
None => "not yet indexed".to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
/// Append a root to the draft unless it would duplicate or nest with an
|
|
|
|
|
/// existing one; the rejection reason lands in `error`.
|
|
|
|
|
fn try_add_root(draft: &mut Config, candidate: String, error: &mut Option<String>) -> bool {
|
|
|
|
|
if draft.paths.indexing_paths.contains(&candidate) {
|
|
|
|
|
*error = Some(format!("{} is already in the list", candidate));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let mut probe = draft.paths.indexing_paths.clone();
|
|
|
|
|
probe.push(candidate.clone());
|
|
|
|
|
if let Some((child, parent)) = quicksearch_core::config::nested_roots(&probe).first() {
|
|
|
|
|
*error = Some(format!(
|
|
|
|
|
"Not added: {} is nested under {}; indexed folders may not overlap",
|
|
|
|
|
child, parent
|
|
|
|
|
));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
draft.paths.indexing_paths.push(candidate);
|
|
|
|
|
*error = None;
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_lines(text: &str) -> Vec<String> {
|
|
|
|
|
text.lines()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|l| !l.is_empty())
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Live-update health. Empty in manual mode and a line long otherwise,
|
|
|
|
|
/// hence the stable section (see [`status_panel`]).
|
2026-08-02 19:04:30 -04:00
|
|
|
fn watch_panel(ui: &mut egui::Ui, state: &IndexerState, config: &Config) {
|
2026-08-03 03:06:19 -04:00
|
|
|
crate::ui_util::stable_section(ui, |ui| watch_contents(ui, state, config));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn watch_contents(ui: &mut egui::Ui, state: &IndexerState, config: &Config) {
|
2026-08-02 19:04:30 -04:00
|
|
|
match &state.watcher {
|
|
|
|
|
WatcherStatus::Off => {}
|
|
|
|
|
WatcherStatus::Starting => {
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint("Setting up live updates…"));
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
WatcherStatus::Active { dirs } => {
|
|
|
|
|
ui.label(
|
|
|
|
|
egui::RichText::new(format!(
|
|
|
|
|
"Live updates on, watching {} folders",
|
|
|
|
|
group_thousands(*dirs as u64)
|
|
|
|
|
))
|
|
|
|
|
.small()
|
|
|
|
|
.weak(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
WatcherStatus::Disabled { reason } => {
|
|
|
|
|
ui.colored_label(
|
|
|
|
|
ui.visuals().warn_fg_color,
|
|
|
|
|
format!(
|
|
|
|
|
"⚠ Live updates off; reindexing every {}",
|
|
|
|
|
fmt_interval(config.indexing.reindex_interval_minutes)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.on_hover_text(reason.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Live progress. Its widget count tracks the run, so it renders inside a
|
|
|
|
|
/// [`crate::ui_util::stable_section`]: without one, every id below it would
|
|
|
|
|
/// move mid-run.
|
2026-08-02 19:04:30 -04:00
|
|
|
fn status_panel(ui: &mut egui::Ui, state: &IndexerState, speed: &SpeedTracker) {
|
2026-08-03 03:06:19 -04:00
|
|
|
crate::ui_util::stable_section(ui, |ui| status_contents(ui, state, speed));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn status_contents(ui: &mut egui::Ui, state: &IndexerState, speed: &SpeedTracker) {
|
2026-08-02 19:04:30 -04:00
|
|
|
match &state.activity {
|
|
|
|
|
IndexingStatus::Idle => {
|
2026-08-09 16:25:43 -04:00
|
|
|
// A between-runs reconcile: the activity really is Idle, but the
|
|
|
|
|
// thread may be scanning every row for minutes.
|
2026-08-05 18:05:04 -04:00
|
|
|
match &state.reconcile {
|
|
|
|
|
Some(ReconcileState::Running(r)) => return reconcile_row(ui, r, None),
|
2026-08-09 16:25:43 -04:00
|
|
|
// Kept on screen a few seconds after the work ends: a small
|
|
|
|
|
// index applies a filter faster than the display could show.
|
2026-08-05 18:05:04 -04:00
|
|
|
Some(ReconcileState::Finished(r)) => {
|
|
|
|
|
ui.label(fmt_reconcile_summary(r.deleted, r.recontented));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
None => {}
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
let last = state
|
|
|
|
|
.last_full_index
|
|
|
|
|
.map(crate::format::fmt_ago)
|
|
|
|
|
.unwrap_or_else(|| "never".to_string());
|
|
|
|
|
ui.label(format!("Idle; last full index: {}", last));
|
|
|
|
|
}
|
2026-08-05 18:05:04 -04:00
|
|
|
IndexingStatus::Preparing { start_time, step } => {
|
|
|
|
|
prep_row(ui, step, start_time.elapsed());
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
IndexingStatus::Error(e) => {
|
|
|
|
|
ui.colored_label(ui.visuals().error_fg_color, format!("Error: {}", e));
|
|
|
|
|
}
|
|
|
|
|
IndexingStatus::Stopping => {
|
|
|
|
|
ui.label("Stopping…");
|
|
|
|
|
}
|
2026-08-03 03:06:19 -04:00
|
|
|
IndexingStatus::Optimizing => {
|
2026-08-09 16:25:43 -04:00
|
|
|
// One bulk rewrite of the whole file; no per-file progress exists.
|
2026-08-03 03:06:19 -04:00
|
|
|
ui.label("Optimizing index; reclaiming unused space…");
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
IndexingStatus::Running { roots, .. } => {
|
|
|
|
|
for root in roots {
|
|
|
|
|
root_row(ui, root);
|
|
|
|
|
}
|
|
|
|
|
if let Some(rate) = speed.files_per_sec() {
|
|
|
|
|
ui.label(
|
2026-08-07 20:44:07 -04:00
|
|
|
egui::RichText::new(format!(
|
|
|
|
|
"last {}s: {}",
|
|
|
|
|
crate::tracker::WINDOW.as_secs(),
|
|
|
|
|
fmt_rate(rate)
|
|
|
|
|
))
|
|
|
|
|
.small()
|
|
|
|
|
.weak(),
|
2026-08-02 19:04:30 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// What a run is doing before it walks its first file. Each step can
|
|
|
|
|
/// outlast the walk itself on a large index; the elapsed clock is what
|
|
|
|
|
/// distinguishes slow work from a hang.
|
2026-08-05 18:05:04 -04:00
|
|
|
fn prep_row(ui: &mut egui::Ui, step: &PrepStep, elapsed: Duration) {
|
|
|
|
|
match step {
|
|
|
|
|
PrepStep::PreviousRun => waiting_row(ui, "Finishing the previous run…", elapsed),
|
|
|
|
|
PrepStep::OpeningIndex => waiting_row(ui, "Opening the index…", elapsed),
|
|
|
|
|
PrepStep::Reconciling(r) => reconcile_row(ui, r, Some(elapsed)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A prologue step with no counters: label, clock, indeterminate bar.
|
|
|
|
|
fn waiting_row(ui: &mut egui::Ui, label: &str, elapsed: Duration) {
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
ui.label(label);
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint(fmt_duration_clock(elapsed)));
|
|
|
|
|
crate::ui_util::progress_bar(ui, None, 160.0);
|
2026-08-05 18:05:04 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// A configuration reconciliation, from either place one runs. `elapsed`
|
|
|
|
|
/// is `Some` for a run's prologue; the between-runs pass has no start time.
|
2026-08-05 18:05:04 -04:00
|
|
|
fn reconcile_row(ui: &mut egui::Ui, r: &ReconcileProgress, elapsed: Option<Duration>) {
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
ui.label("Applying configuration change");
|
|
|
|
|
ui.label(egui::RichText::new("|").weak());
|
|
|
|
|
match (r.total, r.fraction()) {
|
|
|
|
|
(Some(total), Some(frac)) => {
|
|
|
|
|
ui.label(format!(
|
|
|
|
|
"{} / {} ({:.0}%) entries checked",
|
|
|
|
|
group_thousands(r.examined as u64),
|
|
|
|
|
group_thousands(total as u64),
|
|
|
|
|
frac * 100.0
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
ui.label(format!(
|
|
|
|
|
"{} entries checked",
|
|
|
|
|
group_thousands(r.examined as u64)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(elapsed) = elapsed {
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint(fmt_duration_clock(elapsed)));
|
2026-08-05 18:05:04 -04:00
|
|
|
}
|
|
|
|
|
match r.fraction() {
|
|
|
|
|
Some(frac) => {
|
2026-08-09 16:25:43 -04:00
|
|
|
crate::ui_util::progress_bar(ui, Some(frac as f32), 160.0);
|
2026-08-05 18:05:04 -04:00
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
// Whole-range deletions read no rows, so they reach the bar
|
|
|
|
|
// with no denominator.
|
2026-08-05 18:05:04 -04:00
|
|
|
None => {
|
2026-08-09 16:25:43 -04:00
|
|
|
crate::ui_util::progress_bar(ui, None, 160.0);
|
2026-08-05 18:05:04 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if r.deleted > 0 || r.recontented > 0 {
|
|
|
|
|
ui.label(
|
|
|
|
|
egui::RichText::new(format!(
|
|
|
|
|
"{} entries removed, {} re-examined for text extraction",
|
|
|
|
|
group_thousands(r.deleted as u64),
|
|
|
|
|
group_thousands(r.recontented as u64)
|
|
|
|
|
))
|
|
|
|
|
.small()
|
|
|
|
|
.weak(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
/// One root's progress: path, phase, bar, counters, current file.
|
|
|
|
|
fn root_row(ui: &mut egui::Ui, r: &RootProgress) {
|
|
|
|
|
// Weak "|" separators split the row into folder | status | numbers.
|
|
|
|
|
let divider = |ui: &mut egui::Ui| {
|
|
|
|
|
ui.label(egui::RichText::new("|").weak());
|
|
|
|
|
};
|
2026-08-09 02:58:13 -04:00
|
|
|
let phase = crate::color::palette(ui.visuals().dark_mode);
|
2026-08-02 19:04:30 -04:00
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
ui.monospace(middle_truncate(&r.root, 48));
|
|
|
|
|
divider(ui);
|
|
|
|
|
match r.phase {
|
|
|
|
|
RootPhase::Walking => {
|
2026-08-09 02:58:13 -04:00
|
|
|
ui.label(egui::RichText::new("indexing").color(phase.yellow));
|
2026-08-02 19:04:30 -04:00
|
|
|
divider(ui);
|
|
|
|
|
let workers = format!("{}/{} workers", r.active_workers, r.total_workers);
|
2026-08-03 03:06:19 -04:00
|
|
|
match r.walk_denominator() {
|
2026-08-02 19:04:30 -04:00
|
|
|
Some(total) if total > 0 => {
|
|
|
|
|
let frac = (r.walked as f32 / total as f32).clamp(0.0, 1.0);
|
|
|
|
|
ui.label(format!(
|
|
|
|
|
"{} / {} ({:.0}%) · {}",
|
|
|
|
|
group_thousands(r.walked as u64),
|
|
|
|
|
group_thousands(total as u64),
|
|
|
|
|
frac * 100.0,
|
|
|
|
|
workers
|
|
|
|
|
));
|
2026-08-09 16:25:43 -04:00
|
|
|
crate::ui_util::progress_bar(ui, Some(frac), 160.0);
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
ui.label(format!(
|
|
|
|
|
"{} files · {}",
|
|
|
|
|
group_thousands(r.walked as u64),
|
|
|
|
|
workers
|
|
|
|
|
));
|
2026-08-09 16:25:43 -04:00
|
|
|
crate::ui_util::progress_bar(ui, None, 160.0);
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
RootPhase::Extracting => {
|
2026-08-09 02:58:13 -04:00
|
|
|
ui.label(egui::RichText::new("extracting text").color(phase.green));
|
2026-08-02 19:04:30 -04:00
|
|
|
divider(ui);
|
|
|
|
|
let frac = if r.extract_total > 0 {
|
|
|
|
|
(r.extracted as f32 / r.extract_total as f32).clamp(0.0, 1.0)
|
|
|
|
|
} else {
|
|
|
|
|
1.0
|
|
|
|
|
};
|
|
|
|
|
ui.label(format!(
|
2026-08-03 03:06:19 -04:00
|
|
|
"{} / {} ({:.0}%) · {}/{} workers",
|
2026-08-02 19:04:30 -04:00
|
|
|
group_thousands(r.extracted as u64),
|
|
|
|
|
group_thousands(r.extract_total as u64),
|
2026-08-03 03:06:19 -04:00
|
|
|
frac * 100.0,
|
|
|
|
|
r.active_workers,
|
|
|
|
|
r.total_workers
|
2026-08-02 19:04:30 -04:00
|
|
|
));
|
2026-08-09 16:25:43 -04:00
|
|
|
crate::ui_util::progress_bar(ui, Some(frac), 160.0);
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
RootPhase::Done => {
|
2026-08-09 16:25:43 -04:00
|
|
|
// Whole-root totals: `walked` counts every file the walk saw
|
|
|
|
|
// and `extracted` all rows with searchable text, not just
|
|
|
|
|
// this run's new work.
|
2026-08-09 02:58:13 -04:00
|
|
|
ui.label(egui::RichText::new("done").color(phase.blue));
|
2026-08-02 19:04:30 -04:00
|
|
|
divider(ui);
|
|
|
|
|
ui.label(format!(
|
|
|
|
|
"indexed {}, extracted {}",
|
|
|
|
|
group_thousands(r.walked as u64),
|
|
|
|
|
group_thousands(r.extracted as u64)
|
|
|
|
|
));
|
2026-08-09 16:25:43 -04:00
|
|
|
crate::ui_util::progress_bar(ui, Some(1.0), 160.0);
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if let Some(f) = &r.current_file {
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint(middle_truncate(f, 90)));
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 20:21:19 -04:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
2026-08-09 16:25:43 -04:00
|
|
|
mod tests;
|