quinjet 0.0.74

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;
use crate::search::{
    SearchFile, SearchMode, SearchRequest, SearchSource, SearchTarget, name_matches,
};

impl App {
    pub(crate) fn search_header_status(&self) -> Option<String> {
        if self.filter.is_empty() {
            return None;
        }
        let mut label = format!(
            "  Search: {}  [{}]  ",
            self.filter,
            self.search_mode.label()
        );
        if self.search_pending {
            label.push_str("searching...");
        } else {
            let count = match self.view {
                View::Changes => self.visible_change_indices().len(),
                View::History => self.visible_commit_indices().len(),
                View::PullRequests => self
                    .pull_request_tree
                    .iter()
                    .filter(|entry| matches!(entry, PullRequestTreeEntry::File { .. }))
                    .count(),
            };
            label.push_str(&count.to_string());
            label.push_str(if count == 1 { " result" } else { " results" });
        }
        Some(label)
    }

    pub(crate) fn search_title_suffix(&self) -> String {
        if self.filter.is_empty() {
            return String::new();
        }
        let mut suffix = format!("  /{}", self.filter);
        if self.search_mode != SearchMode::Name {
            suffix.push(' ');
            suffix.push_str(self.search_mode.label());
        }
        if self.search_pending {
            suffix.push_str(" …");
        }
        suffix
    }

    pub(super) fn list_item_visible(&self, name: &str, key: &str) -> bool {
        if self.filter.is_empty() {
            return true;
        }
        if self.search_mode == SearchMode::Contents && self.search_pending {
            return true;
        }
        let name_hit = self.search_mode.includes_name() && name_matches(&self.filter, name);
        let content_hit = self.search_mode.includes_contents() && self.content_hits.contains(key);
        match self.search_mode {
            SearchMode::Name => name_hit,
            SearchMode::Contents => content_hit,
            SearchMode::Both => name_hit || content_hit,
        }
    }

    pub(super) fn open_list_search(&mut self) {
        self.modal = Some(Modal::Prompt {
            title: "Search".to_owned(),
            input: TextBuffer::new(self.filter.clone()),
            kind: PromptKind::Filter {
                previous: self.filter.clone(),
                previous_mode: self.search_mode,
                mode: self.search_mode,
            },
        });
    }

    pub(super) const fn cycle_search_mode(&mut self, reverse: bool) {
        if let Some(Modal::Prompt {
            kind: PromptKind::Filter { mode, .. },
            ..
        }) = &mut self.modal
        {
            *mode = if reverse {
                mode.previous()
            } else {
                mode.cycle()
            };
            self.search_mode = *mode;
        }
    }

    pub(super) fn update_live_search(&mut self, value: &str, mode: SearchMode, now: Instant) {
        if self.filter == value && self.search_mode == mode {
            return;
        }
        self.filter.clear();
        self.filter.push_str(value);
        self.search_mode = mode;
        self.schedule_search(now);
        self.normalize_selection();
        self.schedule_preview(now);
    }

    pub(super) fn restore_search(
        &mut self,
        previous: String,
        previous_mode: SearchMode,
        now: Instant,
    ) {
        self.filter = previous;
        self.search_mode = previous_mode;
        self.schedule_search(now);
    }

    pub(super) fn schedule_search(&mut self, now: Instant) {
        self.search_generation = self.search_generation.wrapping_add(1);
        self.search_pending = false;
        self.search_due = None;
        self.content_hits.clear();
        if self.filter.is_empty() || !self.search_mode.includes_contents() {
            if self.view == View::PullRequests {
                self.rebuild_pull_request_tree();
            }
            return;
        }
        self.search_pending = true;
        self.search_due = Some(now + PREVIEW_DEBOUNCE);
        if self.view == View::PullRequests {
            self.rebuild_pull_request_tree();
        }
    }

    pub(super) fn request_search(&mut self, effects: &mut Vec<AppEffect>) {
        if self.filter.is_empty() || !self.search_mode.includes_contents() {
            self.search_pending = false;
            self.content_hits.clear();
            return;
        }
        let Some(request) = self.search_request() else {
            self.search_pending = false;
            return;
        };
        self.search_generation = self.search_generation.wrapping_add(1);
        self.search_pending = true;
        effects.push(AppEffect::Git(Box::new(WorkerCommand::Search {
            generation: self.search_generation,
            request: Box::new(request),
        })));
    }

    fn search_request(&self) -> Option<SearchRequest> {
        let target = match self.view {
            View::Changes => SearchTarget::Changes {
                files: self
                    .status
                    .changes
                    .iter()
                    .map(|change| SearchFile {
                        path: change.path.clone(),
                        source: if change.area == ChangeArea::Staged {
                            SearchSource::Index
                        } else {
                            SearchSource::Worktree
                        },
                    })
                    .collect(),
            },
            View::History => SearchTarget::History {
                revision: self.history_revision(),
                skip: 0,
                limit: self.history.len().max(HISTORY_PAGE_SIZE),
                selected: self.selected_commit().map(|commit| commit.id.clone()),
            },
            View::PullRequests => {
                let workspace = self.pull_request_workspace_generation?;
                SearchTarget::PullRequest {
                    workspace,
                    paths: self
                        .pull_request_files
                        .iter()
                        .map(|file| file.path.clone())
                        .collect(),
                }
            }
        };
        Some(SearchRequest {
            query: self.filter.clone(),
            mode: self.search_mode,
            target,
        })
    }

    pub(super) fn apply_search_hits(
        &mut self,
        generation: u64,
        result: Result<crate::search::SearchHits, String>,
        now: Instant,
    ) {
        if generation != self.search_generation {
            return;
        }
        self.search_pending = false;
        match result {
            Ok(hits) => {
                self.content_hits.clear();
                self.content_hits.extend(hits.paths);
                self.content_hits.extend(hits.commits);
                if self.view == View::PullRequests {
                    self.rebuild_pull_request_tree();
                }
                self.normalize_selection();
                self.schedule_preview(now);
            }
            Err(error) => self.show_toast(error, ToastLevel::Error, now),
        }
    }
}