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::{
|
2026-08-25 01:04:36 -04:00
|
|
|
IndexingStatus, MaintenanceStep, PrepStep, ReconcileProgress, RootPhase, RootProgress,
|
2026-08-05 18:05:04 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use crate::format::{
|
2026-08-25 01:04:36 -04:00
|
|
|
fmt_duration_clock, fmt_interval, fmt_maintenance, fmt_rate, fmt_reconcile_summary,
|
|
|
|
|
group_thousands, human_size, middle_truncate,
|
2026-08-05 18:05:04 -04:00
|
|
|
};
|
|
|
|
|
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
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
pub struct ManageActions {
|
|
|
|
|
pub start_now: bool,
|
|
|
|
|
pub stop: bool,
|
|
|
|
|
pub auto: bool,
|
|
|
|
|
pub clear_index: bool,
|
|
|
|
|
pub apply_config: Option<Config>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct ManageTab {
|
|
|
|
|
pub speed: SpeedTracker,
|
|
|
|
|
ext_filter_text: String,
|
|
|
|
|
new_root: String,
|
2026-08-02 20:21:19 -04:00
|
|
|
new_ignore: String,
|
2026-08-02 19:04:30 -04:00
|
|
|
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 19:04:30 -04:00
|
|
|
draft: Option<Config>,
|
2026-08-03 03:06:19 -04:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn observe(&mut self, status: &IndexingStatus) {
|
|
|
|
|
match status {
|
2026-08-25 01:04:36 -04:00
|
|
|
IndexingStatus::Running {
|
|
|
|
|
maintenance: None,
|
|
|
|
|
roots,
|
|
|
|
|
..
|
|
|
|
|
} => {
|
2026-08-02 19:04:30 -04:00
|
|
|
let total: usize = roots.iter().map(|r| r.walked + r.extracted).sum();
|
|
|
|
|
self.speed.record(total);
|
|
|
|
|
}
|
2026-08-25 01:04:36 -04:00
|
|
|
// Preparing and a run's upkeep steps included: no file is moving
|
|
|
|
|
// in either, and a stale files/sec would read as progress.
|
2026-08-05 18:05:04 -04:00
|
|
|
IndexingStatus::Idle
|
|
|
|
|
| IndexingStatus::Error(_)
|
|
|
|
|
| IndexingStatus::Optimizing
|
2026-08-25 01:04:36 -04:00
|
|
|
| IndexingStatus::Preparing { .. }
|
|
|
|
|
| IndexingStatus::Running { .. } => self.speed.reset(),
|
2026-08-02 19:04:30 -04:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Reconcile the draft with the live config, every 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-04 03:27:05 -04:00
|
|
|
if !self.is_dirty() {
|
2026-08-02 20:21:19 -04:00
|
|
|
return self.resync(config);
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// 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-23 00:33:53 -04:00
|
|
|
// Live state: a stale `auto_index` 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-23 00:33:53 -04:00
|
|
|
/// Live fields are pinned before comparing and the extension text is
|
|
|
|
|
/// compared parsed, so a trailing newline never reads as dirty.
|
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-23 00:33:53 -04:00
|
|
|
/// Syncs against `live` first so a config applied elsewhere is not
|
|
|
|
|
/// reverted. Does NOT clear `baseline`: a rejected apply keeps the 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn mark_applied(&mut self) {
|
|
|
|
|
self.baseline = None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-08-23 00:33:53 -04:00
|
|
|
// Widget ids here are positional (egui 0.32's push_id cannot
|
|
|
|
|
// make them row-stable), so a Remove renames every row below.
|
|
|
|
|
// Safe because the Remove click surrenders focus before the
|
|
|
|
|
// shift — pinned by tests::removing_a_root_does_not_leak_an_edit_onto_another_row.
|
2026-08-09 16:25:43 -04:00
|
|
|
for (i, root) in paths.indexing_paths.iter().enumerate() {
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.horizontal(|ui| {
|
2026-08-23 00:33:53 -04:00
|
|
|
// Controls claim the right edge first.
|
2026-08-02 22:21:39 -04:00
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
2026-08-23 00:33:53 -04:00
|
|
|
let remove_btn = ui.small_button("Remove").tip(&tips::REMOVE_ROOT);
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
tests::record_widget("remove", &remove_btn);
|
|
|
|
|
if remove_btn.clicked() {
|
2026-08-02 22:21:39 -04:00
|
|
|
remove = Some(i);
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// Per-root walker override; 0 = auto. Applies 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
|
2026-08-23 00:33:53 -04:00
|
|
|
// cost it any edit in progress ("after" is to its
|
|
|
|
|
// left in this right-to-left layout).
|
2026-08-09 18:36:47 -04:00
|
|
|
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);
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
let add_row = 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-23 00:33:53 -04:00
|
|
|
// All three ways to add a folder, as one target for the tour.
|
|
|
|
|
crate::spotlight::mark(
|
|
|
|
|
ui.ctx(),
|
|
|
|
|
crate::spotlight::Spot::IndexedFolderAdd,
|
|
|
|
|
add_row.response.rect,
|
|
|
|
|
);
|
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-23 00:33:53 -04:00
|
|
|
// The list grows and shrinks: keep it 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-17 19:26:18 -04:00
|
|
|
limits are on the Settings tab.",
|
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);
|
2026-08-23 00:33:53 -04:00
|
|
|
// The label comes and goes with the dirty state.
|
2026-08-04 03:27:05 -04:00
|
|
|
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-23 00:33:53 -04:00
|
|
|
// 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-03 03:06:19 -04:00
|
|
|
const DB_SIZE_REFRESH: Duration = Duration::from_secs(10);
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The database plus its `-wal` and `-shm` sidecars — mid-run the `-wal`
|
|
|
|
|
/// can hold hundreds of megabytes the database does not show yet.
|
2026-08-03 03:06:19 -04:00
|
|
|
fn measure_db_size(db: &Path) -> u64 {
|
2026-08-23 00:33:53 -04:00
|
|
|
// Only regular files: a directory would report its own inode size.
|
2026-08-03 03:06:19 -04:00
|
|
|
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("");
|
2026-08-23 00:33:53 -04:00
|
|
|
// No `-journal`: the index runs in WAL mode.
|
2026-08-03 03:06:19 -04:00
|
|
|
len(db)
|
|
|
|
|
+ ["-wal", "-shm"]
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|suffix| len(&db.with_file_name(format!("{}{}", name, suffix))))
|
|
|
|
|
.sum::<u64>()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
struct DbSizeProbe {
|
|
|
|
|
/// Configured (unresolved) path the cached size belongs to.
|
|
|
|
|
path: String,
|
|
|
|
|
bytes: u64,
|
|
|
|
|
measured_at: Option<Instant>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DbSizeProbe {
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Restatted when stale or the path changed; `now` is a parameter for tests.
|
2026-08-03 03:06:19 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.",
|
2026-08-17 19:26:18 -04:00
|
|
|
"Turn off \"Store text for snippets\" on the Settings tab: full-text search \
|
|
|
|
|
keeps working, but without previews, occurrence ranking or fuzzy matching \
|
2026-08-03 03:06:19 -04:00
|
|
|
inside file contents.",
|
2026-08-17 19:26:18 -04:00
|
|
|
"Lower \"Max text file size\" and \"Max stored text\", both on the Settings tab.",
|
2026-08-03 03:06:19 -04:00
|
|
|
] {
|
|
|
|
|
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-23 00:33:53 -04:00
|
|
|
/// Worded as [`root_row`] words it; a root the coordinator has no figures
|
|
|
|
|
/// for says so rather than claiming zero.
|
2026-08-09 18:36:47 -04:00
|
|
|
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-23 00:33:53 -04:00
|
|
|
/// Append a root unless it duplicates or nests; the reason lands in `error`.
|
2026-08-02 19:04:30 -04:00
|
|
|
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-23 00:33:53 -04:00
|
|
|
/// Empty in manual mode and a line long otherwise, hence the stable section.
|
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!(
|
2026-08-17 22:30:43 -04:00
|
|
|
"Live updates off; reindexing every {}",
|
2026-08-02 19:04:30 -04:00
|
|
|
fmt_interval(config.indexing.reindex_interval_minutes)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.on_hover_text(reason.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Its widget count tracks the run, so it renders inside a
|
|
|
|
|
/// [`crate::ui_util::stable_section`] or every id below 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-23 00:33:53 -04:00
|
|
|
// A between-runs reconcile: 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-23 00:33:53 -04:00
|
|
|
// Kept on screen a few seconds after the work ends.
|
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 => {
|
|
|
|
|
ui.label("Optimizing index; reclaiming unused space…");
|
|
|
|
|
}
|
2026-08-25 01:04:36 -04:00
|
|
|
IndexingStatus::Running {
|
|
|
|
|
roots, maintenance, ..
|
|
|
|
|
} => {
|
2026-08-02 19:04:30 -04:00
|
|
|
for root in roots {
|
2026-08-25 01:04:36 -04:00
|
|
|
root_row(ui, root, *maintenance);
|
|
|
|
|
}
|
|
|
|
|
// Run-wide, so said once rather than once per root. Purely
|
|
|
|
|
// additional: the per-root file hints stay put underneath it, so
|
|
|
|
|
// a step starting does not reflow the block.
|
|
|
|
|
if let Some(step) = maintenance {
|
|
|
|
|
ui.label(hint(fmt_maintenance(*step)));
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
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-23 00:33:53 -04:00
|
|
|
/// The elapsed clock distinguishes slow prologue 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)),
|
2026-08-25 01:04:36 -04:00
|
|
|
PrepStep::Starting => waiting_row(ui, "Getting the index ready…", elapsed),
|
2026-08-05 18:05:04 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-23 00:33:53 -04:00
|
|
|
/// `elapsed` is `Some` for a run's prologue; the between-runs pass has none.
|
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-23 00:33:53 -04:00
|
|
|
// Whole-range deletions read no rows: 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-25 01:04:36 -04:00
|
|
|
/// `maintenance` is the run's, not the root's: while it is set the writer is
|
|
|
|
|
/// inside a database step and every figure here is the last one published
|
|
|
|
|
/// before it began.
|
|
|
|
|
fn root_row(ui: &mut egui::Ui, r: &RootProgress, maintenance: Option<MaintenanceStep>) {
|
2026-08-02 19:04:30 -04:00
|
|
|
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-25 01:04:36 -04:00
|
|
|
// The counters stay keyed on the root's phase; only the word changes.
|
|
|
|
|
let (word, color) = match (maintenance, r.phase) {
|
|
|
|
|
(Some(_), _) => ("maintenance", phase.orange),
|
|
|
|
|
(None, RootPhase::Walking) => ("indexing", phase.yellow),
|
|
|
|
|
(None, RootPhase::Extracting) => ("extracting text", phase.green),
|
|
|
|
|
(None, RootPhase::Done) => ("done", phase.blue),
|
|
|
|
|
};
|
2026-08-02 19:04:30 -04:00
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
ui.monospace(middle_truncate(&r.root, 48));
|
|
|
|
|
divider(ui);
|
2026-08-25 01:04:36 -04:00
|
|
|
ui.label(egui::RichText::new(word).color(color));
|
|
|
|
|
divider(ui);
|
2026-08-02 19:04:30 -04:00
|
|
|
match r.phase {
|
|
|
|
|
RootPhase::Walking => {
|
|
|
|
|
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-17 19:26:18 -04:00
|
|
|
let workers = format!("{}/{} workers", r.active_workers, r.total_workers);
|
|
|
|
|
match r.extract_total {
|
|
|
|
|
Some(total) => {
|
|
|
|
|
let frac = if total > 0 {
|
|
|
|
|
(r.extracted as f32 / total as f32).clamp(0.0, 1.0)
|
|
|
|
|
} else {
|
|
|
|
|
1.0
|
|
|
|
|
};
|
|
|
|
|
ui.label(format!(
|
|
|
|
|
"{} / {} ({:.0}%) · {}",
|
|
|
|
|
group_thousands(r.extracted as u64),
|
|
|
|
|
group_thousands(total as u64),
|
|
|
|
|
frac * 100.0,
|
|
|
|
|
workers
|
|
|
|
|
));
|
|
|
|
|
crate::ui_util::progress_bar(ui, Some(frac), 160.0);
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// The pass is still counting its range.
|
2026-08-17 19:26:18 -04:00
|
|
|
None => {
|
|
|
|
|
ui.label(format!(
|
|
|
|
|
"{} files · {}",
|
|
|
|
|
group_thousands(r.extracted as u64),
|
|
|
|
|
workers
|
|
|
|
|
));
|
|
|
|
|
crate::ui_util::progress_bar(ui, None, 160.0);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
RootPhase::Done => {
|
2026-08-23 00:33:53 -04:00
|
|
|
// Whole-root totals, not just this run's new work.
|
2026-08-02 19:04:30 -04:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-25 01:04:36 -04:00
|
|
|
// Drawn during upkeep too, where it names the last file *written* rather
|
|
|
|
|
// than one in flight. That moment of staleness is worth less than the row
|
|
|
|
|
// keeping its height: with a checkpoint every few seconds, a line
|
|
|
|
|
// vanishing and returning per root reflowed the whole block on a loop.
|
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;
|