use std::cmp::Reverse;
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
use crate::domain::repo::{GitInfo, Repo, RepoKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TabKind {
Git,
Files,
}
impl TabKind {
pub fn repo_kind(self) -> RepoKind {
match self {
TabKind::Git => RepoKind::Git,
TabKind::Files => RepoKind::Path,
}
}
pub fn active_tab(self) -> Tab {
match self {
TabKind::Git => Tab::GitActive,
TabKind::Files => Tab::FilesActive,
}
}
pub fn index(self) -> usize {
match self {
TabKind::Git => 0,
TabKind::Files => 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Tab {
#[default]
GitActive,
GitArchive,
FilesActive,
FilesArchive,
}
impl Tab {
pub const ACTIVE: [Tab; 2] = [Tab::GitActive, Tab::FilesActive];
pub const ALL: [Tab; 4] = [
Tab::GitActive,
Tab::GitArchive,
Tab::FilesActive,
Tab::FilesArchive,
];
pub fn kind(self) -> TabKind {
match self {
Tab::GitActive | Tab::GitArchive => TabKind::Git,
Tab::FilesActive | Tab::FilesArchive => TabKind::Files,
}
}
pub fn repo_kind(self) -> RepoKind {
self.kind().repo_kind()
}
pub fn is_archived(self) -> bool {
matches!(self, Tab::GitArchive | Tab::FilesArchive)
}
pub fn active(self) -> Tab {
self.kind().active_tab()
}
pub fn archive(self) -> Tab {
match self.kind() {
TabKind::Git => Tab::GitArchive,
TabKind::Files => Tab::FilesArchive,
}
}
pub fn toggle_archived(self) -> Tab {
if self.is_archived() {
self.active()
} else {
self.archive()
}
}
pub fn kind_index(self) -> usize {
self.kind().index()
}
pub fn title(self) -> &'static str {
match self.kind() {
TabKind::Git => "Git Repos",
TabKind::Files => "Files and Folders",
}
}
pub fn as_key(self) -> &'static str {
match self {
Tab::GitActive => "git",
Tab::GitArchive => "git-archive",
Tab::FilesActive => "files",
Tab::FilesArchive => "files-archive",
}
}
pub fn from_key(value: &str) -> Self {
match value.trim().to_lowercase().as_str() {
"git-archive" | "archive" => Tab::GitArchive,
"files" => Tab::FilesActive,
"files-archive" => Tab::FilesArchive,
_ => Tab::GitActive,
}
}
}
pub fn belongs_to_tab(repo: &Repo, tab: Tab) -> bool {
repo.kind == tab.repo_kind() && repo.archived == tab.is_archived()
}
pub fn searchable_text(repo: &Repo) -> String {
let mut parts = vec![repo.display_name()];
if let Some(slug) = &repo.slug {
parts.push(slug.clone());
}
if let Some(info) =
repo.git_info.as_ref().or(repo.example_git_info.as_ref())
{
push_git_parts(&mut parts, info);
}
parts.push(repo.path.to_string_lossy().into_owned());
parts.join(" ")
}
fn push_git_parts(parts: &mut Vec<String>, info: &GitInfo) {
if let Some(branch) = &info.current_branch_name {
parts.push(branch.clone());
}
if let Some(name) = &info.github_repo_name {
parts.push(name.clone());
}
}
pub fn fuzzy_indices(repos: &[Repo], query: &str) -> Vec<usize> {
if query.trim().is_empty() {
return (0..repos.len()).collect();
}
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern =
Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut scored: Vec<(usize, u32)> = Vec::new();
let mut buf = Vec::new();
for (index, repo) in repos.iter().enumerate() {
let haystack = searchable_text(repo);
let utf32 = Utf32Str::new(&haystack, &mut buf);
if let Some(score) = pattern.score(utf32, &mut matcher) {
scored.push((index, score));
}
}
scored.sort_by_key(|&(_, score)| Reverse(score));
scored.into_iter().map(|(index, _)| index).collect()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn git(name: &str, archived: bool) -> Repo {
let mut repo = Repo::new(PathBuf::from(format!("/code/{name}")));
repo.name = Some(name.to_string());
repo.archived = archived;
repo
}
#[test]
fn git_tab_excludes_archived_and_non_git() {
let mut folder = git("notes", false);
folder.kind = RepoKind::Path;
assert!(belongs_to_tab(&git("hop", false), Tab::GitActive));
assert!(!belongs_to_tab(&git("hop", true), Tab::GitActive));
assert!(!belongs_to_tab(&folder, Tab::GitActive));
assert!(belongs_to_tab(&folder, Tab::FilesActive));
}
#[test]
fn archived_entries_go_to_their_kind_archive() {
let mut folder = git("notes", true);
folder.kind = RepoKind::Path;
assert!(belongs_to_tab(&git("old", true), Tab::GitArchive));
assert!(!belongs_to_tab(&git("old", true), Tab::FilesArchive));
assert!(!belongs_to_tab(&git("new", false), Tab::GitArchive));
assert!(belongs_to_tab(&folder, Tab::FilesArchive));
assert!(!belongs_to_tab(&folder, Tab::GitArchive));
}
#[test]
fn tab_helpers_and_keys_round_trip() {
assert_eq!(Tab::GitActive.toggle_archived(), Tab::GitArchive);
assert_eq!(Tab::GitArchive.toggle_archived(), Tab::GitActive);
assert_eq!(Tab::FilesArchive.active(), Tab::FilesActive);
assert!(Tab::FilesArchive.is_archived());
assert_eq!(Tab::GitArchive.kind(), TabKind::Git);
for tab in Tab::ALL {
assert_eq!(Tab::from_key(tab.as_key()), tab);
}
assert_eq!(Tab::from_key("archive"), Tab::GitArchive);
assert_eq!(Tab::from_key("bogus"), Tab::GitActive);
}
#[test]
fn searchable_text_includes_name_and_path() {
let text = searchable_text(&git("mdtask", false));
assert!(text.contains("mdtask"));
assert!(text.contains("/code/mdtask"));
}
#[test]
fn empty_query_keeps_all_in_order() {
let repos = vec![git("a", false), git("b", false)];
assert_eq!(fuzzy_indices(&repos, " "), vec![0, 1]);
}
#[test]
fn fuzzy_query_filters_and_ranks() {
let repos =
vec![git("alpha", false), git("beta", false), git("gamma", false)];
let hits = fuzzy_indices(&repos, "beta");
assert_eq!(hits.first(), Some(&1));
assert!(!hits.contains(&0) || hits[0] == 1);
}
}