use super::*;
impl App {
pub fn start_add(&mut self) {
crate::debug_log::info("Initiating repository add");
self.start_repo_scan();
}
pub fn start_edit(&mut self) {
if let Some(current) = self.get_selected_item() {
crate::debug_log::info(format!("Editing repository entry: {}", current));
self.set_input_buffer(current.clone());
self.mode = Mode::Editing;
}
}
pub fn request_delete(&mut self) {
if self.get_items_len() > 0 {
self.mode = Mode::ConfirmDelete;
}
}
pub fn commit_add(&mut self) {
let trimmed = self.input_buffer.trim().to_string();
self.commit_add_with_labels(trimmed, vec![]);
}
pub fn commit_add_with_labels(&mut self, path: String, labels: Vec<String>) {
let trimmed = path.trim().to_string();
if !trimmed.is_empty() {
if self.is_repo_tracked(&trimmed) {
self.status_message = Some("Repository already added".to_string());
self.input_buffer.clear();
self.mode = Mode::Normal;
return;
}
let status = repo::inspect_summary(&trimmed);
self.statuses.push(status);
self.config.items.push(trimmed.clone());
self.original_items.push(trimmed.clone());
if !labels.is_empty() {
self.config.labels.insert(trimmed.clone(), labels);
}
self.sort_items_in_place();
self.repo_search_query = None;
self.reveal_home_row(&trimmed);
self.persist("Saved");
}
self.input_buffer.clear();
self.mode = Mode::Normal;
}
pub fn commit_add_label_input(&mut self) {
let labels_str = self.input_buffer.trim().to_string();
let labels: Vec<String> = if labels_str.is_empty() {
Vec::new()
} else {
labels_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
};
let path = self.pending_add_repo.take().unwrap_or_default();
self.commit_add_with_labels(path, labels);
}
pub fn is_repo_tracked(&self, path: &str) -> bool {
self.is_repo_tracked_except(path, None)
}
fn is_repo_tracked_except(&self, path: &str, except: Option<usize>) -> bool {
self.tracked_item_index_except(path, except).is_some()
}
pub fn tracked_item(&self, path: &str) -> Option<String> {
self.tracked_item_index_except(path, None).map(|idx| self.config.items[idx].clone())
}
fn tracked_item_index_except(&self, path: &str, except: Option<usize>) -> Option<usize> {
let trimmed = path.trim();
let expanded = repo::expand_tilde(trimmed);
let canonical = Self::canonical_path(&expanded);
self.config.items.iter().enumerate().position(|(idx, item)| {
if Some(idx) == except {
return false;
}
let item_expanded = repo::expand_tilde(item);
item.trim() == trimmed
|| item_expanded == expanded
|| Self::canonical_path(&item_expanded) == canonical
})
}
pub fn detect_cwd_repo(&mut self, cwd: &std::path::Path) {
if !self.config.prompt_cwd_repo {
return;
}
let Some(root) = repo::discover_repo_root(cwd) else {
return;
};
let root_str = root.to_string_lossy().to_string();
if self.is_repo_tracked(&root_str) {
return;
}
crate::debug_log::info(format!("Offering to track launch directory repo: {}", root_str));
self.pending_cwd_repo = Some(root_str);
self.mode = Mode::AddCwdRepoConfirm;
}
pub fn confirm_add_cwd_repo(&mut self) {
self.mode = Mode::Normal;
if let Some(path) = self.pending_cwd_repo.take() {
self.add_repo_path(path);
}
}
pub fn dismiss_cwd_repo(&mut self) {
self.pending_cwd_repo = None;
self.mode = Mode::Normal;
}
pub fn add_repo_path(&mut self, path: String) {
let trimmed = path.trim().to_string();
if !trimmed.is_empty() {
if self.is_repo_tracked(&trimmed) {
self.status_message = Some("Repository already added".to_string());
return;
}
let status = repo::inspect_summary(&trimmed);
self.statuses.push(status);
self.config.items.push(trimmed.clone());
self.original_items.push(trimmed.clone());
self.sort_items_in_place();
self.repo_search_query = None;
self.reveal_home_row(&trimmed);
self.persist("Added repository");
}
}
pub fn commit_edit(&mut self) {
let trimmed = self.input_buffer.trim().to_string();
if !trimmed.is_empty() {
let group = self.home_cursor().map(|(_, group)| group);
if let Some(orig_idx) = self.get_selected_item_index() {
if orig_idx < self.config.items.len() {
if self.is_repo_tracked_except(&trimmed, Some(orig_idx)) {
self.status_message = Some("Repository already added".to_string());
self.input_buffer.clear();
self.mode = Mode::Normal;
return;
}
let old_item = self.config.items[orig_idx].clone();
if let Some(pos) = self.original_items.iter().position(|x| x == &old_item) {
self.original_items[pos] = trimmed.clone();
}
self.migrate_repo_path(&old_item, &trimmed);
self.config.items[orig_idx] = trimmed.clone();
self.statuses[orig_idx] = repo::inspect_summary(&trimmed);
self.sort_items_in_place();
self.repo_search_query = None;
self.select_home_row(&trimmed, group.as_deref());
self.persist("Saved");
}
}
}
self.input_buffer.clear();
self.mode = Mode::Normal;
}
fn migrate_repo_path(&mut self, old: &str, new: &str) {
rekey_map(&mut self.config.labels, old, new);
rekey_map(&mut self.config.repo_configs, old, new);
rekey_set(&mut self.config.pinned, old, new);
rekey_set(&mut self.config.starred, old, new);
rekey_map(&mut self.state.visits, old, new);
rekey_set(&mut self.multi_selected, old, new);
let old_resolved = repo::expand_tilde(old).to_string_lossy().to_string();
let new_resolved = repo::expand_tilde(new).to_string_lossy().to_string();
rekey_map(&mut self.state.commit_history, &old_resolved, &new_resolved);
}
pub fn confirm_delete(&mut self) {
if !self.multi_selected.is_empty() {
let to_remove = self.multi_selected.clone();
self.multi_selected.clear();
for item in to_remove {
if let Some(pos) = self.config.items.iter().position(|x| x == &item) {
self.config.items.remove(pos);
if pos < self.statuses.len() {
self.statuses.remove(pos);
}
}
if let Some(pos) = self.original_items.iter().position(|x| x == &item) {
self.original_items.remove(pos);
}
self.state.visits.remove(&item);
self.config.pinned.remove(&item);
}
self.clear_label_filter_if_orphaned();
self.persist("Deleted selected repositories");
} else if let Some(orig_idx) = self.get_selected_item_index() {
if orig_idx < self.config.items.len() {
let item = self.config.items.remove(orig_idx);
if orig_idx < self.statuses.len() {
self.statuses.remove(orig_idx);
}
if let Some(pos) = self.original_items.iter().position(|x| x == &item) {
self.original_items.remove(pos);
}
self.state.visits.remove(&item);
self.config.pinned.remove(&item);
self.clear_label_filter_if_orphaned();
self.persist("Deleted");
}
}
self.repo_search_query = None;
self.selected_index = 0;
self.mode = Mode::Normal;
}
pub fn start_edit_labels(&mut self) {
if let Some(current) = self.get_selected_item() {
crate::debug_log::info(format!("Editing labels for repository: {}", current));
let current_labels = self
.config
.labels
.get(current.as_str())
.map(|lbls| lbls.join(", "))
.unwrap_or_default();
self.set_input_buffer(current_labels);
self.mode = Mode::LabelInput;
}
}
pub fn commit_edit_labels(&mut self) {
let current = self.get_selected_item().cloned();
if let Some(current) = current {
let trimmed = self.input_buffer.trim();
if trimmed.is_empty() {
self.config.labels.remove(current.as_str());
} else {
let lbls: Vec<String> = trimmed
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if lbls.is_empty() {
self.config.labels.remove(current.as_str());
} else {
self.config.labels.insert(current.clone(), lbls);
}
}
let filter_cleared = self.clear_label_filter_if_orphaned();
self.persist(if filter_cleared {
"Saved labels (label filter cleared)"
} else {
"Saved labels"
});
}
self.input_buffer.clear();
self.mode = Mode::Normal;
}
pub fn start_repo_scan(&mut self) {
self.scanned_repos.clear();
self.repo_scan_selection = 0;
self.repo_scan_active = true;
self.repo_scan_count = 0;
self.input_buffer.clear();
self.previous_mode = Some(self.mode);
self.mode = Mode::RepoScanPicker;
let start_dir = repo::expand_tilde(&self.config.scan.start_dir);
let max_depth = self.config.scan.max_depth;
let excludes = self.config.scan.excludes.clone();
let tx = self.tx.clone();
run_directory_scan(start_dir, max_depth, excludes, tx, false);
}
pub fn start_bulk_repo_scan(&mut self) {
self.scanned_repos.clear();
self.repo_scan_selection = 0;
self.repo_scan_active = true;
self.repo_scan_count = 0;
self.input_buffer.clear();
self.previous_mode = Some(self.mode);
self.mode = Mode::BulkAddScanPicker;
let start_dir = repo::expand_tilde(&self.config.scan.start_dir);
let max_depth = self.config.scan.max_depth;
let excludes = self.config.scan.excludes.clone();
let tx = self.tx.clone();
run_directory_scan(start_dir, max_depth, excludes, tx, true);
}
pub fn get_scan_matches(&self) -> Vec<(String, String)> {
let query = self.input_buffer.to_lowercase();
let mut results = Vec::new();
if !self.input_buffer.trim().is_empty() {
results.push((
format!("[Use manual path: {}]", self.input_buffer.trim()),
self.input_buffer.trim().to_string(),
));
}
if query.is_empty() {
results.extend(self.scanned_repos.clone());
return results;
}
let mut matches = Vec::new();
for (name, path) in &self.scanned_repos {
let name_lower = name.to_lowercase();
let path_lower = path.to_lowercase();
if name_lower.contains(&query) {
let score = 1000 - (name_lower.len() - query.len());
matches.push((score, name.clone(), path.clone()));
} else if path_lower.contains(&query) {
let score = 500 - (path_lower.len() - query.len());
matches.push((score, name.clone(), path.clone()));
} else {
let mut name_chars = name_lower.chars();
let mut matched = true;
for qc in query.chars() {
if !name_chars.any(|nc| nc == qc) {
matched = false;
break;
}
}
if matched {
matches.push((100, name.clone(), path.clone()));
}
}
}
matches.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
results.extend(matches.into_iter().map(|(_, name, path)| (name, path)));
results
}
pub fn start_branch_search(&mut self) {
self.branch_search_selection = 0;
self.input_buffer.clear();
self.previous_mode = Some(self.mode);
self.mode = Mode::BranchSearchInput;
}
pub fn get_branch_search_matches(&self) -> Vec<(String, bool)> {
let mut results = Vec::new();
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
let query = self.input_buffer.to_lowercase();
let all_branches: Vec<(String, bool)> = info
.local_branches
.iter()
.map(|b| (b.name.clone(), false))
.chain(info.remote_branches.iter().map(|b| (b.name.clone(), true)))
.collect();
if query.is_empty() {
return all_branches;
}
let mut matches = Vec::new();
for (name, is_remote) in all_branches {
let name_lower = name.to_lowercase();
if name_lower.contains(&query) {
let score = 1000 - (name_lower.len() - query.len());
matches.push((score, name, is_remote));
} else {
let mut name_chars = name_lower.chars();
let mut matched = true;
for qc in query.chars() {
if !name_chars.any(|nc| nc == qc) {
matched = false;
break;
}
}
if matched {
matches.push((100, name, is_remote));
}
}
}
matches.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
results = matches.into_iter().map(|(_, name, is_remote)| (name, is_remote)).collect();
}
results
}
pub fn start_file_search(&mut self) {
self.file_search_selection = 0;
self.input_buffer.clear();
self.previous_mode = Some(self.mode);
self.mode = Mode::FileSearchInput;
}
pub fn get_file_search_matches(&self) -> Vec<String> {
let mut results = Vec::new();
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
let query = self.input_buffer.to_lowercase();
let all_files = if let repo::TabData::Loaded(files) = &info.files {
files.clone()
} else {
Vec::new()
};
if query.is_empty() {
return all_files;
}
let mut matches = Vec::new();
for file in all_files {
let file_lower = file.to_lowercase();
if file_lower.contains(&query) {
let score = 1000 - (file_lower.len() - query.len());
matches.push((score, file));
} else {
let mut file_chars = file_lower.chars();
let mut matched = true;
for qc in query.chars() {
if !file_chars.any(|nc| nc == qc) {
matched = false;
break;
}
}
if matched {
matches.push((100, file));
}
}
}
matches.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
results = matches.into_iter().map(|(_, file)| file).collect();
}
results
}
pub fn start_commit_fuzzy_search(&mut self) {
self.commit_search_selection = 0;
self.input_buffer.clear();
self.previous_mode = Some(self.mode);
self.mode = Mode::CommitFuzzySearch;
}
pub fn get_commit_fuzzy_matches(&self) -> Vec<(usize, String, String, String)> {
let mut results = Vec::new();
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
let query = self.input_buffer.to_lowercase();
if query.is_empty() {
return info
.commits
.iter()
.enumerate()
.map(|(idx, c)| (idx, c.id.clone(), c.author.clone(), c.summary.clone()))
.collect();
}
let mut matches = Vec::new();
for (idx, c) in info.commits.iter().enumerate() {
let summary_lower = c.summary.to_lowercase();
let author_lower = c.author.to_lowercase();
let id_lower = c.id.to_lowercase();
if summary_lower.contains(&query) {
let score = 1000 - (summary_lower.len() - query.len());
matches.push((score, idx, c.id.clone(), c.author.clone(), c.summary.clone()));
} else if author_lower.contains(&query) {
let score = 500 - (author_lower.len() - query.len());
matches.push((score, idx, c.id.clone(), c.author.clone(), c.summary.clone()));
} else if id_lower.contains(&query) {
let score = 500 - (id_lower.len() - query.len());
matches.push((score, idx, c.id.clone(), c.author.clone(), c.summary.clone()));
} else {
let mut summary_chars = summary_lower.chars();
let mut matched = true;
for qc in query.chars() {
if !summary_chars.any(|nc| nc == qc) {
matched = false;
break;
}
}
if matched {
matches.push((100, idx, c.id.clone(), c.author.clone(), c.summary.clone()));
}
}
}
matches.sort_by(|a, b| b.0.cmp(&a.0).then(a.4.cmp(&b.4)));
results = matches
.into_iter()
.map(|(_, idx, sha, author, summary)| (idx, sha, author, summary))
.collect();
}
results
}
pub fn start_tag_search(&mut self) {
self.tag_search_selection = 0;
self.input_buffer.clear();
self.previous_mode = Some(self.mode);
self.mode = Mode::TagSearchInput;
}
pub fn get_tag_search_matches(&self) -> Vec<String> {
let mut results = Vec::new();
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
let query = self.input_buffer.to_lowercase();
let all_tags = if let repo::TabData::Loaded(tags) = &info.local_tags {
tags.iter().map(|t| t.name.clone()).collect()
} else {
Vec::new()
};
if query.is_empty() {
return all_tags;
}
let mut matches = Vec::new();
for tag in all_tags {
let tag_lower = tag.to_lowercase();
if tag_lower.contains(&query) {
let score = 1000 - (tag_lower.len() - query.len());
matches.push((score, tag));
} else {
let mut tag_chars = tag_lower.chars();
let mut matched = true;
for qc in query.chars() {
if !tag_chars.any(|nc| nc == qc) {
matched = false;
break;
}
}
if matched {
matches.push((100, tag));
}
}
}
matches.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
results = matches.into_iter().map(|(_, tag)| tag).collect();
}
results
}
pub fn trigger_global_search(&mut self) {
let query = self.input_buffer.clone();
if query.trim().is_empty() {
return;
}
self.global_search_query = query.clone();
self.global_search_running = true;
self.global_search_results.clear();
self.global_search_selection = 0;
let tx = self.global_search_tx.clone();
let repo_paths = self.config.items.clone();
std::thread::spawn(move || {
let mut handles = Vec::new();
let query_lower = query.to_lowercase();
for path_str in repo_paths {
let path = repo::expand_tilde(&path_str);
let query_lower = query_lower.clone();
let path_str_clone = path_str.clone();
let handle = std::thread::spawn(move || {
let mut local_results = Vec::new();
let repo_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path_str_clone.clone());
fn walk_and_search(
current_dir: &std::path::Path,
base_dir: &std::path::Path,
repo_name: &str,
repo_path: &str,
query: &str,
results: &mut Vec<SearchResult>,
) {
if let Ok(entries) = std::fs::read_dir(current_dir) {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.')
|| name_str == "target"
|| name_str == "node_modules"
|| name_str == "build"
|| name_str == "dist"
|| name_str == "vendor"
{
continue;
}
if path.is_dir() {
walk_and_search(
&path, base_dir, repo_name, repo_path, query, results,
);
} else if path.is_file() {
if let Ok(meta) = entry.metadata() {
if meta.len() > 1_000_000 {
continue;
}
}
if let Ok(content) = std::fs::read_to_string(&path) {
for (i, line) in content.lines().enumerate() {
if line.to_lowercase().contains(query) {
let rel_path = path
.strip_prefix(base_dir)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
results.push(SearchResult {
repo_name: repo_name.to_string(),
repo_path: repo_path.to_string(),
file_rel_path: rel_path,
line_number: i + 1,
line_content: line.trim().to_string(),
});
if results.len() > 100 {
return;
}
}
}
}
}
}
}
}
walk_and_search(
&path,
&path,
&repo_name,
&path_str_clone,
&query_lower,
&mut local_results,
);
local_results
});
handles.push(handle);
}
let mut all_results = Vec::new();
for h in handles {
if let Ok(res) = h.join() {
all_results.extend(res);
}
}
all_results.truncate(500);
let _ = tx.send(all_results);
});
}
pub fn select_global_search_result(&mut self) {
if self.global_search_results.is_empty()
|| self.global_search_selection >= self.global_search_results.len()
{
return;
}
let result = self.global_search_results[self.global_search_selection].clone();
if let Some(pos) = self.config.items.iter().position(|x| x == &result.repo_path) {
self.jump_to_repo(pos);
self.open_repo(result.repo_path);
self.input_buffer.clear();
self.mode = Mode::Detail;
}
}
pub fn auto_discover_add(&mut self, path: String) {
let trimmed = path.trim().to_string();
if !trimmed.is_empty() {
let cursor = self.home_cursor();
let status = repo::inspect_summary(&trimmed);
self.statuses.push(status);
self.config.items.push(trimmed.clone());
self.original_items.push(trimmed.clone());
self.sort_items_in_place();
if let Some((item, group)) = cursor {
self.select_home_row(&item, Some(&group));
}
self.persist("Auto-discovered new repository");
self.setup_watcher();
}
}
}
fn rekey_map<V>(map: &mut std::collections::HashMap<String, V>, from: &str, to: &str) {
if from != to
&& let Some(value) = map.remove(from)
{
map.insert(to.to_string(), value);
}
}
fn rekey_set(set: &mut std::collections::HashSet<String>, from: &str, to: &str) {
if from != to && set.remove(from) {
set.insert(to.to_string());
}
}