diff --git a/src/cli.rs b/src/cli.rs index 6f1bfa9..4bff029 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use crate::{ dirty_paths::DirtyUtf8Path, execute_command, get_single_selection, picker::Preview, - repos::{find_repos, RepoContainer}, + session::{create_sessions, SessionContainer}, session_exists, set_up_tmux_env, switch_to_session, tmux::Tmux, Result, TmsError, @@ -246,17 +246,11 @@ fn switch_command(config: Config, tmux: &Tmux) -> Result<()> { let mut sessions: Vec = sessions.into_iter().map(|s| s.0.to_string()).collect(); if let Some(true) = config.switch_filter_unknown { - let repos = find_repos( - config.search_dirs().change_context(TmsError::ConfigError)?, - config.excluded_dirs, - config.display_full_path, - config.search_submodules, - config.recursive_submodules, - )?; + let configured = create_sessions(&config)?; sessions = sessions .into_iter() - .filter(|session| repos.find_repo(session).is_some()) + .filter(|session| configured.find_session(session).is_some()) .collect::>(); } diff --git a/src/configs.rs b/src/configs.rs index a9f9871..14aee77 100644 --- a/src/configs.rs +++ b/src/configs.rs @@ -1,11 +1,11 @@ use clap::ValueEnum; use error_stack::ResultExt; use serde_derive::{Deserialize, Serialize}; -use std::{collections::HashMap, env, fmt::Display, fs::canonicalize, io::Write, path::PathBuf}; +use std::{env, fmt::Display, fs::canonicalize, io::Write, path::PathBuf}; use ratatui::style::{Color, Style}; -use crate::{dirty_paths::DirtyUtf8Path, keymap::Keymap, Suggestion}; +use crate::{keymap::Keymap, Suggestion}; type Result = error_stack::Result; @@ -204,29 +204,25 @@ impl Config { } } - pub fn bookmark_paths(&self) -> HashMap { - let mut ret = HashMap::new(); - + pub fn bookmark_paths(&self) -> Vec { if let Some(bookmarks) = &self.bookmarks { - for bookmark in bookmarks { - if let Ok(path) = PathBuf::from(bookmark).canonicalize() { - let name = if let Some(true) = self.display_full_path { - Some(path.display().to_string()) + bookmarks + .iter() + .filter_map(|b| { + if let Ok(expanded) = shellexpand::full(b) { + if let Ok(path) = PathBuf::from(expanded.to_string()).canonicalize() { + Some(path) + } else { + None + } } else { - path.file_name() - .expect("should end with a directory") - .to_string() - .ok() - }; - - if let Some(name) = name { - ret.insert(name, path); + None } - } - } + }) + .collect() + } else { + Vec::new() } - - ret } } diff --git a/src/lib.rs b/src/lib.rs index 87adc2d..dd75a04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod error; pub mod keymap; pub mod picker; pub mod repos; +pub mod session; pub mod tmux; use error_stack::ResultExt; diff --git a/src/main.rs b/src/main.rs index 76d7166..e47b359 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,12 @@ -use std::{collections::HashMap, path::PathBuf}; - use clap::Parser; -use error_stack::{Report, ResultExt}; +use error_stack::Report; -use git2::Repository; use tms::{ cli::{Cli, SubCommandGiven}, - dirty_paths::DirtyUtf8Path, - error::{Result, TmsError}, + error::Result, get_single_selection, picker::Preview, - repos::find_repos, - repos::RepoContainer, - session_exists, set_up_tmux_env, switch_to_session, + session::{create_sessions, SessionContainer}, tmux::Tmux, Suggestion, }; @@ -35,23 +29,11 @@ fn main() -> Result<()> { SubCommandGiven::No(config) => config, // continue }; - let bookmarks = config.bookmark_paths(); - - // Find repositories and present them with the fuzzy finder - let repos = find_repos( - config.search_dirs().change_context(TmsError::ConfigError)?, - config.excluded_dirs, - config.display_full_path, - config.search_submodules, - config.recursive_submodules, - )?; - - let mut dirs = repos.list(); - - dirs.append(&mut bookmarks.keys().map(|b| b.to_string()).collect()); + let sessions = create_sessions(&config)?; + let session_strings = sessions.list(); let selected_str = if let Some(str) = get_single_selection( - &dirs, + &session_strings, Preview::None, config.picker_colors, config.shortcuts, @@ -62,68 +44,9 @@ fn main() -> Result<()> { return Ok(()); }; - if let Some(found_repo) = repos.find_repo(&selected_str) { - switch_to_repo_session(selected_str, found_repo, &tmux, config.display_full_path)?; - } else { - switch_to_bookmark_session(selected_str, &tmux, bookmarks)?; - } - - Ok(()) -} - -fn switch_to_repo_session( - selected_str: String, - found_repo: &Repository, - tmux: &Tmux, - display_full_path: Option, -) -> Result<()> { - let path = if found_repo.is_bare() { - found_repo.path().to_string()? - } else { - found_repo - .workdir() - .expect("bare repositories should all have parent directories") - .canonicalize() - .change_context(TmsError::IoError)? - .to_string()? - }; - let repo_short_name = (if display_full_path == Some(true) { - std::path::PathBuf::from(&selected_str) - .file_name() - .expect("None of the paths here should terminate in `..`") - .to_string()? - } else { - selected_str - }) - .replace('.', "_"); - - if !session_exists(&repo_short_name, tmux) { - tmux.new_session(Some(&repo_short_name), Some(&path)); - set_up_tmux_env(found_repo, &repo_short_name, tmux)?; + if let Some(session) = sessions.find_session(&selected_str) { + session.switch_to(&tmux)?; } - switch_to_session(&repo_short_name, tmux); - - Ok(()) -} - -fn switch_to_bookmark_session( - selected_str: String, - tmux: &Tmux, - bookmarks: HashMap, -) -> Result<()> { - let path = &bookmarks[&selected_str]; - let session_name = path - .file_name() - .expect("Bookmarks should not end in `..`") - .to_string()? - .replace('.', "_"); - - if !session_exists(&session_name, tmux) { - tmux.new_session(Some(&session_name), path.to_str()); - } - - switch_to_session(&session_name, tmux); - Ok(()) } diff --git a/src/repos.rs b/src/repos.rs index 0bb85a9..421bc06 100644 --- a/src/repos.rs +++ b/src/repos.rs @@ -1,88 +1,58 @@ use aho_corasick::{AhoCorasickBuilder, MatchKind}; use error_stack::ResultExt; -use git2::{Repository, Submodule}; +use git2::Submodule; use std::{ collections::{HashMap, VecDeque}, fs, - path::Path, }; -use crate::{configs::SearchDirectory, dirty_paths::DirtyUtf8Path, Result, TmsError}; - -pub trait RepoContainer { - fn find_repo(&self, name: &str) -> Option<&Repository>; - fn insert_repo(&mut self, name: String, repo: Repository); - fn list(&self) -> Vec; -} - -impl RepoContainer for HashMap { - fn find_repo(&self, name: &str) -> Option<&Repository> { - self.get(name) - } - - fn insert_repo(&mut self, name: String, repo: Repository) { - self.insert(name, repo); - } - - fn list(&self) -> Vec { - let mut list: Vec = self.keys().map(|s| s.to_owned()).collect(); - list.sort(); - - list - } -} - -pub fn find_repos( - directories: Vec, - excluded_dirs: Option>, - display_full_path: Option, - search_submodules: Option, - recursive_submodules: Option, -) -> Result { - let mut repos = HashMap::new(); - let mut to_search = VecDeque::new(); - - for search_directory in directories { - to_search.push_back(search_directory); - } +use crate::{ + configs::{Config, SearchDirectory}, + dirty_paths::DirtyUtf8Path, + session::{Session, SessionContainer, SessionType}, + Result, TmsError, +}; - let excluded_dirs = match excluded_dirs { - Some(excluded_dirs) => excluded_dirs, - None => Vec::new(), +pub fn find_repos(config: &Config) -> Result>> { + let directories = config.search_dirs().change_context(TmsError::ConfigError)?; + let mut repos: HashMap> = HashMap::new(); + let mut to_search: VecDeque = directories.into(); + + let excluder = if let Some(excluded_dirs) = &config.excluded_dirs { + Some( + AhoCorasickBuilder::new() + .match_kind(MatchKind::LeftmostFirst) + .build(excluded_dirs) + .change_context(TmsError::IoError)?, + ) + } else { + None }; - let excluder = AhoCorasickBuilder::new() - .match_kind(MatchKind::LeftmostFirst) - .build(excluded_dirs) - .change_context(TmsError::IoError)?; + while let Some(file) = to_search.pop_front() { - if excluder.is_match(&file.path.to_string()?) { - continue; + if let Some(ref excluder) = excluder { + if excluder.is_match(&file.path.to_string()?) { + continue; + } } - let file_name = get_repo_name(&file.path, &repos)?; - if let Ok(repo) = git2::Repository::open(file.path.clone()) { if repo.is_worktree() { continue; } - let name = if let Some(true) = display_full_path { - file.path.to_string()? - } else { - file_name - }; - if search_submodules == Some(true) { - if let Ok(submodules) = repo.submodules() { - find_submodules( - submodules, - &name, - &mut repos, - display_full_path, - recursive_submodules, - )?; - } + let session_name = file + .path + .file_name() + .expect("The file name doesn't end in `..`") + .to_string()?; + + let session = Session::new(session_name, SessionType::Git(repo)); + if let Some(list) = repos.get_mut(&session.name) { + list.push(session); + } else { + repos.insert(session.name.clone(), vec![session]); } - repos.insert_repo(name, repo); } else if file.path.is_dir() && file.depth > 0 { let read_dir = fs::read_dir(&file.path) .change_context(TmsError::IoError) @@ -96,35 +66,11 @@ pub fn find_repos( Ok(repos) } -fn get_repo_name(path: &Path, repos: &impl RepoContainer) -> Result { - let mut repo_name = path - .file_name() - .expect("The file name doesn't end in `..`") - .to_string()?; - - repo_name = if repos.find_repo(&repo_name).is_some() { - if let Some(parent) = path.parent() { - if let Some(parent) = parent.file_name() { - format!("{}/{}", parent.to_string()?, repo_name) - } else { - repo_name - } - } else { - repo_name - } - } else { - repo_name - }; - - Ok(repo_name) -} - -fn find_submodules( +pub fn find_submodules( submodules: Vec, parent_name: &String, - repos: &mut impl RepoContainer, - display_full_path: Option, - recursive: Option, + repos: &mut impl SessionContainer, + config: &Config, ) -> Result<()> { for submodule in submodules.iter() { let repo = match submodule.open() { @@ -139,18 +85,20 @@ fn find_submodules( .file_name() .expect("The file name doesn't end in `..`") .to_string()?; - let name = if let Some(true) = display_full_path { - path.to_string()? + let session_name = format!("{}>{}", parent_name, submodule_file_name); + let name = if let Some(true) = config.display_full_path { + path.display().to_string() } else { - format!("{}>{}", parent_name, submodule_file_name) + session_name.clone() }; - if recursive == Some(true) { + if config.recursive_submodules == Some(true) { if let Ok(submodules) = repo.submodules() { - find_submodules(submodules, &name, repos, display_full_path, recursive)?; + find_submodules(submodules, &name, repos, config)?; } } - repos.insert_repo(name, repo); + let session = Session::new(session_name, SessionType::Git(repo)); + repos.insert_session(name, session); } Ok(()) } diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..34ef554 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,261 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use error_stack::ResultExt; +use git2::Repository; + +use crate::{ + configs::Config, + dirty_paths::DirtyUtf8Path, + error::TmsError, + repos::{find_repos, find_submodules}, + session_exists, set_up_tmux_env, switch_to_session, + tmux::Tmux, + Result, +}; + +pub struct Session { + pub name: String, + pub session_type: SessionType, +} + +pub enum SessionType { + Git(Repository), + Bookmark(PathBuf), +} + +impl Session { + pub fn new(name: String, session_type: SessionType) -> Self { + Session { name, session_type } + } + + pub fn path(&self) -> &Path { + match &self.session_type { + SessionType::Git(repo) => repo.path().parent().unwrap(), + SessionType::Bookmark(path) => path, + } + } + + pub fn switch_to(&self, tmux: &Tmux) -> Result<()> { + match &self.session_type { + SessionType::Git(repo) => switch_to_repo_session(&self.name, repo, tmux), + SessionType::Bookmark(path) => switch_to_bookmark_session(&self.name, tmux, path), + } + } +} + +pub trait SessionContainer { + fn find_session(&self, name: &str) -> Option<&Session>; + fn insert_session(&mut self, name: String, repo: Session); + fn list(&self) -> Vec; +} + +impl SessionContainer for HashMap { + fn find_session(&self, name: &str) -> Option<&Session> { + self.get(name) + } + + fn insert_session(&mut self, name: String, session: Session) { + self.insert(name, session); + } + + fn list(&self) -> Vec { + let mut list: Vec = self.keys().map(|s| s.to_owned()).collect(); + list.sort(); + + list + } +} + +pub fn create_sessions(config: &Config) -> Result { + let mut sessions = find_repos(config)?; + sessions = append_bookmarks(config, sessions)?; + + let sessions = generate_session_container(sessions, config)?; + + Ok(sessions) +} + +fn generate_session_container( + mut sessions: HashMap>, + config: &Config, +) -> Result { + let mut ret = HashMap::new(); + + for list in sessions.values_mut() { + if list.len() == 1 { + let session = list.pop().unwrap(); + insert_session(&mut ret, session, config)?; + } else { + let deduplicated = deduplicate_sessions(list); + + for session in deduplicated { + insert_session(&mut ret, session, config)?; + } + } + } + + Ok(ret) +} + +fn insert_session( + sessions: &mut impl SessionContainer, + session: Session, + config: &Config, +) -> Result<()> { + let visible_name = if config.display_full_path == Some(true) { + session.path().display().to_string() + } else { + session.name.clone() + }; + if let SessionType::Git(repo) = &session.session_type { + if config.search_submodules == Some(true) { + if let Ok(submodules) = repo.submodules() { + find_submodules(submodules, &visible_name, sessions, config)?; + } + } + } + sessions.insert_session(visible_name, session); + Ok(()) +} + +fn deduplicate_sessions(duplicate_sessions: &mut Vec) -> Vec { + let mut depth = 1; + let mut deduplicated = Vec::new(); + while let Some(current_session) = duplicate_sessions.pop() { + let mut equal = true; + let current_path = current_session.path(); + let mut current_depth = 1; + + while equal { + equal = false; + if let Some(current_str) = current_path.iter().rev().nth(current_depth) { + for session in &mut *duplicate_sessions { + if let Some(str) = session.path().iter().rev().nth(current_depth) { + if str == current_str { + current_depth += 1; + equal = true; + break; + } + } + } + } + } + + deduplicated.push(current_session); + depth = depth.max(current_depth); + } + + for session in &mut deduplicated { + session.name = { + let mut count = depth + 1; + let mut iterator = session.path().iter().rev(); + let mut str = String::new(); + + while count > 0 { + if let Some(dir) = iterator.next() { + if str.is_empty() { + str = dir.to_string_lossy().to_string(); + } else { + str = format!("{}/{}", dir.to_string_lossy(), str); + } + count -= 1; + } else { + count = 0; + } + } + + str + }; + } + + deduplicated +} + +fn append_bookmarks( + config: &Config, + mut sessions: HashMap>, +) -> Result>> { + let bookmarks = config.bookmark_paths(); + + for path in bookmarks { + let session_name = path + .file_name() + .expect("The file name doesn't end in `..`") + .to_string()?; + let session = Session::new(session_name, SessionType::Bookmark(path)); + if let Some(list) = sessions.get_mut(&session.name) { + list.push(session); + } else { + sessions.insert(session.name.clone(), vec![session]); + } + } + + Ok(sessions) +} + +fn switch_to_repo_session(selected_str: &str, found_repo: &Repository, tmux: &Tmux) -> Result<()> { + let path = if found_repo.is_bare() { + found_repo.path().to_string()? + } else { + found_repo + .workdir() + .expect("bare repositories should all have parent directories") + .canonicalize() + .change_context(TmsError::IoError)? + .to_string()? + }; + let repo_short_name = selected_str.replace('.', "_"); + + if !session_exists(&repo_short_name, tmux) { + tmux.new_session(Some(&repo_short_name), Some(&path)); + set_up_tmux_env(found_repo, &repo_short_name, tmux)?; + } + + switch_to_session(&repo_short_name, tmux); + + Ok(()) +} + +fn switch_to_bookmark_session(selected_str: &str, tmux: &Tmux, path: &Path) -> Result<()> { + let session_name = selected_str.replace('.', "_"); + + if !session_exists(&session_name, tmux) { + tmux.new_session(Some(&session_name), path.to_str()); + } + + switch_to_session(&session_name, tmux); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_session_name_deduplication() { + let mut test_sessions = vec![ + Session::new( + "test".into(), + SessionType::Bookmark("/search/path/to/proj1/test".into()), + ), + Session::new( + "test".into(), + SessionType::Bookmark("/search/path/to/proj2/test".into()), + ), + Session::new( + "test".into(), + SessionType::Bookmark("/other/path/to/projects/proj2/test".into()), + ), + ]; + + let deduplicated = deduplicate_sessions(&mut test_sessions); + + assert_eq!(deduplicated[0].name, "projects/proj2/test"); + assert_eq!(deduplicated[1].name, "to/proj2/test"); + assert_eq!(deduplicated[2].name, "to/proj1/test"); + } +}