use std::{
collections::HashMap,
env::current_dir,
fs::canonicalize,
path::{Path, PathBuf},
process::{Command, Stdio},
};
use crate::{
configs::{
CloneRepoSwitchConfig, Config, ConfigExport, SearchDirectory, SessionSortOrderConfig,
},
dirty_paths::DirtyUtf8Path,
execute_command, get_single_selection,
marks::{marks_command, MarksCommand},
picker::Preview,
repos::RepoProvider,
session::{create_sessions, SessionContainer},
tmux::Tmux,
Result, TmsError,
};
use clap::{Args, Parser, Subcommand};
use clap_complete::{ArgValueCandidates, CompletionCandidate};
use error_stack::ResultExt;
use ratatui::style::Color;
#[derive(Debug, Parser)]
#[command(author, version)]
pub struct Cli {
#[command(subcommand)]
command: Option<CliCommand>,
}
#[derive(Debug, Subcommand)]
pub enum CliCommand {
#[command(arg_required_else_help = true)]
Config(Box<ConfigCommand>),
Start,
Switch,
Windows,
Kill,
Sessions,
#[command(arg_required_else_help = true)]
Rename(RenameCommand),
Refresh(RefreshCommand),
CloneRepo(CloneRepoCommand),
InitRepo(InitRepoCommand),
Bookmark(BookmarkCommand),
OpenSession(OpenSessionCommand),
Marks(MarksCommand),
}
#[derive(Debug, Args)]
#[clap(args_conflicts_with_subcommands = true)]
pub struct ConfigCommand {
#[command(flatten)]
args: ConfigArgs,
#[command(subcommand)]
subcommand: Option<ConfigSubCommand>,
}
#[derive(Debug, Subcommand)]
pub enum ConfigSubCommand {
List(ConfigSubCommandArgs),
}
#[derive(Debug, Args)]
pub struct ConfigSubCommandArgs {
#[arg(short, long)]
defaults: bool,
}
#[derive(Debug, Args)]
pub struct ConfigArgs {
#[arg(short = 'p', long = "paths", value_name = "search paths", num_args = 1..)]
search_paths: Option<Vec<String>>,
#[arg(short = 's', long = "session", value_name = "default session")]
default_session: Option<String>,
#[arg(long = "excluded", value_name = "excluded dirs", num_args = 1..)]
excluded_dirs: Option<Vec<String>>,
#[arg(long = "remove", value_name = "remove dir", num_args = 1..)]
remove_dir: Option<Vec<String>>,
#[arg(long = "full-path", value_name = "true | false")]
display_full_path: Option<bool>,
#[arg(long, value_name = "true | false")]
search_submodules: Option<bool>,
#[arg(long, value_name = "true | false")]
recursive_submodules: Option<bool>,
#[arg(long, value_name = "true | false")]
switch_filter_unknown: Option<bool>,
#[arg(long, short = 'd', value_name = "max depth", num_args = 1..)]
max_depths: Option<Vec<usize>>,
#[arg(long, value_name = "#rrggbb")]
picker_highlight_color: Option<Color>,
#[arg(long, value_name = "#rrggbb")]
picker_highlight_text_color: Option<Color>,
#[arg(long, value_name = "#rrggbb")]
picker_border_color: Option<Color>,
#[arg(long, value_name = "#rrggbb")]
picker_info_color: Option<Color>,
#[arg(long, value_name = "#rrggbb")]
picker_prompt_color: Option<Color>,
#[arg(long, value_name = "Alphabetical | LastAttached")]
session_sort_order: Option<SessionSortOrderConfig>,
#[arg(long, value_name = "Always | Never | Foreground", verbatim_doc_comment)]
clone_repo_switch: Option<CloneRepoSwitchConfig>,
}
#[derive(Debug, Args)]
pub struct RenameCommand {
name: String,
}
#[derive(Debug, Args)]
pub struct RefreshCommand {
name: Option<String>,
}
#[derive(Debug, Args)]
pub struct CloneRepoCommand {
repository: String,
#[arg(long)]
path: Option<String>,
#[arg(long)]
name: Option<String>,
}
#[derive(Debug, Args)]
pub struct InitRepoCommand {
repository: String,
}
#[derive(Debug, Args)]
pub struct BookmarkCommand {
#[arg(long, short)]
delete: bool,
path: Option<String>,
}
#[derive(Debug, Args)]
pub struct OpenSessionCommand {
#[arg(add = ArgValueCandidates::new(open_session_completion_candidates))]
session: Box<str>,
}
impl Cli {
pub fn handle_sub_commands(&self, tmux: &Tmux) -> Result<SubCommandGiven> {
let config = Config::new().change_context(TmsError::ConfigError)?;
match &self.command {
Some(CliCommand::Start) => {
start_command(config, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Switch) => {
switch_command(config, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Windows) => {
windows_command(&config, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Config(args)) => {
config_command(args, config)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Kill) => {
kill_subcommand(config, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Sessions) => {
sessions_subcommand(tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Rename(args)) => {
rename_subcommand(args, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Refresh(args)) => {
refresh_command(args, &config, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::CloneRepo(args)) => {
clone_repo_command(args, config, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::InitRepo(args)) => {
init_repo_command(args, config, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Bookmark(args)) => {
bookmark_command(args, config)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::OpenSession(args)) => {
open_session_command(args, config, tmux)?;
Ok(SubCommandGiven::Yes)
}
Some(CliCommand::Marks(args)) => {
marks_command(args, config, tmux)?;
Ok(SubCommandGiven::Yes)
}
None => Ok(SubCommandGiven::No(config.into())),
}
}
}
fn start_command(config: Config, tmux: &Tmux) -> Result<()> {
if let Some(sessions) = &config.sessions {
for session in sessions {
let session_path = session
.path
.as_ref()
.map(shellexpand::full)
.transpose()
.change_context(TmsError::IoError)?;
tmux.new_session(session.name.as_deref(), session_path.as_deref());
if let Some(windows) = &session.windows {
for window in windows {
let window_path = window
.path
.as_ref()
.map(shellexpand::full)
.transpose()
.change_context(TmsError::IoError)?;
tmux.new_window(window.name.as_deref(), window_path.as_deref(), None);
if let Some(window_command) = &window.command {
tmux.send_keys(window_command, None);
}
}
tmux.kill_window(":1");
}
}
tmux.attach_session(None, None);
} else {
tmux.tmux();
}
Ok(())
}
fn switch_command(config: Config, tmux: &Tmux) -> Result<()> {
let sessions = tmux
.list_sessions("'#{?session_attached,,#{session_name}#,#{session_last_attached}}'")
.replace('\'', "")
.replace("\n\n", "\n");
let mut sessions: Vec<(&str, &str)> = sessions
.trim()
.split('\n')
.filter_map(|s| s.split_once(','))
.collect();
if let Some(SessionSortOrderConfig::LastAttached) = config.session_sort_order {
sessions.sort_by(|a, b| b.1.cmp(a.1));
}
let mut sessions: Vec<String> = sessions.into_iter().map(|s| s.0.to_string()).collect();
if let Some(true) = config.switch_filter_unknown {
let configured = create_sessions(&config)?;
sessions = sessions
.into_iter()
.filter(|session| configured.find_session(session).is_some())
.collect::<Vec<String>>();
}
if let Some(target_session) =
get_single_selection(&sessions, Some(Preview::SessionPane), &config, tmux)?
{
tmux.switch_client(&target_session.replace('.', "_"));
}
Ok(())
}
fn windows_command(config: &Config, tmux: &Tmux) -> Result<()> {
let windows = tmux.list_windows("'#{?window_attached,,#{window_id} #{window_name}}'", None);
let windows: Vec<String> = windows
.replace('\'', "")
.replace("\n\n", "\n")
.trim()
.split('\n')
.map(|s| s.to_string())
.collect();
if let Some(target_window) =
get_single_selection(&windows, Some(Preview::WindowPane), config, tmux)?
{
if let Some((windex, _)) = target_window.split_once(' ') {
tmux.select_window(windex);
}
}
Ok(())
}
fn config_command(cmd: &ConfigCommand, mut config: Config) -> Result<()> {
match &cmd.subcommand {
None => {}
Some(ConfigSubCommand::List(args)) => {
let config = if args.defaults {
Config::default()
} else {
config
};
let config = ConfigExport::from(config);
let toml_pretty =
toml::to_string_pretty(&config).change_context(TmsError::ConfigError)?;
println!("{}", toml_pretty);
return Ok(());
}
};
let args = &cmd.args;
let max_depths = args.max_depths.clone().unwrap_or_default();
config.search_dirs = match &args.search_paths {
Some(paths) => Some(
paths
.iter()
.zip(max_depths.into_iter().chain(std::iter::repeat(10)))
.map(|(path, depth)| {
let path = if path.ends_with('/') {
let mut modified_path = path.clone();
modified_path.pop();
modified_path
} else {
path.clone()
};
shellexpand::full(&path)
.map(|val| (val.to_string(), depth))
.change_context(TmsError::IoError)
})
.collect::<Result<Vec<(String, usize)>>>()?
.iter()
.map(|(path, depth)| {
canonicalize(path)
.map(|val| SearchDirectory::new(val, *depth))
.change_context(TmsError::IoError)
})
.collect::<Result<Vec<SearchDirectory>>>()?,
),
None => config.search_dirs,
};
if let Some(default_session) = args
.default_session
.clone()
.map(|val| val.replace('.', "_"))
{
config.default_session = Some(default_session);
}
if let Some(display) = args.display_full_path {
config.display_full_path = Some(display.to_owned());
}
if let Some(submodules) = args.search_submodules {
config.search_submodules = Some(submodules.to_owned());
}
if let Some(submodules) = args.recursive_submodules {
config.recursive_submodules = Some(submodules.to_owned());
}
if let Some(switch_filter_unknown) = args.switch_filter_unknown {
config.switch_filter_unknown = Some(switch_filter_unknown.to_owned());
}
if let Some(dirs) = &args.excluded_dirs {
let current_excluded = config.excluded_dirs;
match current_excluded {
Some(mut excl_dirs) => {
excl_dirs.extend(dirs.iter().map(|str| str.to_string()));
config.excluded_dirs = Some(excl_dirs)
}
None => {
config.excluded_dirs = Some(dirs.iter().map(|str| str.to_string()).collect());
}
}
}
if let Some(dirs) = &args.remove_dir {
let current_excluded = config.excluded_dirs;
match current_excluded {
Some(mut excl_dirs) => {
dirs.iter().for_each(|dir| excl_dirs.retain(|x| x != dir));
config.excluded_dirs = Some(excl_dirs);
}
None => todo!(),
}
}
if let Some(color) = &args.picker_highlight_color {
let mut picker_colors = config.picker_colors.unwrap_or_default();
picker_colors.highlight_color = Some(*color);
config.picker_colors = Some(picker_colors);
}
if let Some(color) = &args.picker_highlight_text_color {
let mut picker_colors = config.picker_colors.unwrap_or_default();
picker_colors.highlight_text_color = Some(*color);
config.picker_colors = Some(picker_colors);
}
if let Some(color) = &args.picker_border_color {
let mut picker_colors = config.picker_colors.unwrap_or_default();
picker_colors.border_color = Some(*color);
config.picker_colors = Some(picker_colors);
}
if let Some(color) = &args.picker_info_color {
let mut picker_colors = config.picker_colors.unwrap_or_default();
picker_colors.info_color = Some(*color);
config.picker_colors = Some(picker_colors);
}
if let Some(color) = &args.picker_prompt_color {
let mut picker_colors = config.picker_colors.unwrap_or_default();
picker_colors.prompt_color = Some(*color);
config.picker_colors = Some(picker_colors);
}
if let Some(order) = &args.session_sort_order {
config.session_sort_order = Some(order.to_owned());
}
if let Some(switch) = &args.clone_repo_switch {
config.clone_repo_switch = Some(switch.to_owned());
}
config.save().change_context(TmsError::ConfigError)?;
println!("Configuration has been stored");
Ok(())
}
fn kill_subcommand(config: Config, tmux: &Tmux) -> Result<()> {
let mut current_session = tmux.display_message("'#S'");
current_session.retain(|x| x != '\'' && x != '\n');
let sessions = tmux
.list_sessions("'#{?session_attached,,#{session_name}#,#{session_last_attached}}'")
.replace('\'', "")
.replace("\n\n", "\n");
let mut sessions: Vec<(&str, &str)> = sessions
.trim()
.split('\n')
.filter_map(|s| s.split_once(','))
.collect();
if let Some(SessionSortOrderConfig::LastAttached) = config.session_sort_order {
sessions.sort_by(|a, b| b.1.cmp(a.1));
}
let to_session = if config.default_session.is_some()
&& sessions
.iter()
.any(|session| session.0 == config.default_session.as_deref().unwrap())
&& current_session != config.default_session.as_deref().unwrap()
{
config.default_session.as_deref()
} else {
sessions.first().map(|s| s.0)
};
if let Some(to_session) = to_session {
tmux.switch_client(to_session);
}
tmux.kill_session(¤t_session);
Ok(())
}
fn sessions_subcommand(tmux: &Tmux) -> Result<()> {
let mut current_session = tmux.display_message("'#S'");
current_session.retain(|x| x != '\'' && x != '\n');
let current_session_star = format!("{current_session}*");
let sessions = tmux
.list_sessions("#S")
.split('\n')
.map(String::from)
.collect::<Vec<String>>();
let mut new_string = String::new();
for session in &sessions {
if session == ¤t_session {
new_string.push_str(¤t_session_star);
} else {
new_string.push_str(session);
}
new_string.push(' ')
}
println!("{new_string}");
std::thread::sleep(std::time::Duration::from_millis(100));
tmux.refresh_client();
Ok(())
}
fn rename_subcommand(args: &RenameCommand, tmux: &Tmux) -> Result<()> {
let new_session_name = &args.name;
let current_session = tmux
.display_message("'#S'")
.trim()
.replace('\'', "")
.to_string();
let panes = tmux.list_windows(
"'#{window_index}.#{pane_index},#{pane_current_command},#{pane_current_path}'",
None,
);
let mut paneid_to_pane_deatils: HashMap<String, HashMap<String, String>> = HashMap::new();
let all_panes: Vec<String> = panes
.trim()
.split('\n')
.map(|window| {
let mut _window: Vec<&str> = window.split(',').collect();
let pane_index = _window[0].replace('\'', "");
let pane_details: HashMap<String, String> = HashMap::from([
(String::from("command"), _window[1].to_string()),
(
String::from("cwd"),
_window[2].to_string().replace('\'', ""),
),
]);
paneid_to_pane_deatils.insert(pane_index.to_string(), pane_details);
pane_index.to_string()
})
.collect();
let first_pane_details = &paneid_to_pane_deatils[all_panes.first().unwrap()];
let new_session_path: String =
String::from(&first_pane_details["cwd"]).replace(¤t_session, new_session_name);
let move_command_args: Vec<String> =
[first_pane_details["cwd"].clone(), new_session_path.clone()].to_vec();
execute_command("mv", move_command_args);
for pane_index in all_panes.iter() {
let pane_details = &paneid_to_pane_deatils[pane_index];
let old_path = &pane_details["cwd"];
let new_path = old_path.replace(¤t_session, new_session_name);
let change_dir_cmd = format!("cd {new_path}");
tmux.send_keys(&change_dir_cmd, Some(pane_index));
}
tmux.rename_session(new_session_name);
tmux.attach_session(None, Some(&new_session_path));
Ok(())
}
fn refresh_command(args: &RefreshCommand, config: &Config, tmux: &Tmux) -> Result<()> {
let session_name = args
.name
.clone()
.unwrap_or(tmux.display_message("'#S'"))
.trim()
.replace('\'', "");
let session_path = tmux
.display_message("'#{session_path}'")
.trim()
.replace('\'', "");
let existing_window_names: Vec<_> = tmux
.list_windows("'#{window_name}'", Some(&session_name))
.lines()
.map(|line| line.replace('\'', ""))
.collect();
if let Ok(repository) = RepoProvider::open(Path::new(&session_path), config) {
let mut num_worktree_windows = 0;
if let Ok(worktrees) = repository.worktrees() {
for worktree in worktrees.iter() {
let worktree_name = worktree.name();
if existing_window_names.contains(&worktree_name) {
num_worktree_windows += 1;
continue;
}
if worktree.is_prunable() {
continue;
}
num_worktree_windows += 1;
tmux.new_window(
Some(&worktree_name),
Some(&worktree.path()?.to_string()?),
Some(&session_name),
);
}
}
if !repository.is_bare() {
let count_current_windows = tmux
.list_windows("'#{window_name}'", Some(&session_name))
.lines()
.count();
if count_current_windows <= num_worktree_windows {
tmux.new_window(None, Some(&session_path), Some(&session_name));
}
}
}
Ok(())
}
fn pick_search_path(config: &Config, tmux: &Tmux) -> Result<Option<PathBuf>> {
let search_dirs = config
.search_dirs
.as_ref()
.ok_or(TmsError::ConfigError)
.attach_printable("No search path configured")?
.iter()
.filter(|dir| dir.depth > 0)
.map(|dir| dir.path.to_string())
.filter_map(|path| path.ok())
.collect::<Vec<String>>();
let path = if search_dirs.len() > 1 {
get_single_selection(&search_dirs, Some(Preview::Directory), config, tmux)?
} else {
let first = search_dirs
.first()
.ok_or(TmsError::ConfigError)
.attach_printable("No search path configured")?;
Some(first.clone())
};
let expanded = path
.as_ref()
.map(|path| shellexpand::full(path).change_context(TmsError::IoError))
.transpose()?
.map(|path| PathBuf::from(path.as_ref()));
Ok(expanded)
}
fn clone_repo_command(args: &CloneRepoCommand, config: Config, tmux: &Tmux) -> Result<()> {
let Some(mut path) = (if let Some(p) = &args.path {
Some(
PathBuf::from(p)
.canonicalize()
.change_context(TmsError::IoError)?,
)
} else {
pick_search_path(&config, tmux)?
}) else {
return Ok(());
};
let repo_name = args.name.as_deref().unwrap_or_else(|| {
let (_, name) = args
.repository
.trim_end_matches('/')
.rsplit_once('/')
.expect("Repository path contains '/'");
name.trim_end_matches(".git")
});
path.push(repo_name);
let previous_session = tmux.current_session("#{session_name}");
let repo = RepoProvider::open(git_clone(&args.repository, &path)?, &config)?;
let mut session_name = repo_name.to_string();
let switch = match config.clone_repo_switch.unwrap_or_default() {
CloneRepoSwitchConfig::Always => true,
CloneRepoSwitchConfig::Never => false,
CloneRepoSwitchConfig::Foreground => {
let active_session = tmux.current_session("#{session_name}");
previous_session == active_session
}
};
if tmux.session_exists(&session_name) {
session_name = format!(
"{}/{}",
path.parent()
.unwrap()
.file_name()
.expect("The file name doesn't end in `..`")
.to_string()?,
session_name
);
}
tmux.new_session(Some(&session_name), Some(&path.display().to_string()));
tmux.set_up_tmux_env(&repo, &session_name)?;
if switch {
tmux.switch_to_session(&session_name);
}
Ok(())
}
fn git_clone<'a>(repo: &str, target: &'a Path) -> Result<&'a Path> {
std::fs::create_dir_all(target).change_context(TmsError::IoError)?;
let mut cmd = Command::new("git")
.current_dir(target.parent().ok_or(TmsError::IoError)?)
.args(["clone", repo, target.to_str().ok_or(TmsError::NonUtf8Path)?])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.change_context(TmsError::GitError)?;
cmd.wait().change_context(TmsError::GitError)?;
Ok(target)
}
fn init_repo_command(args: &InitRepoCommand, config: Config, tmux: &Tmux) -> Result<()> {
let Some(mut path) = pick_search_path(&config, tmux)? else {
return Ok(());
};
path.push(&args.repository);
let repo = gix::init(&path).change_context(TmsError::GitError)?;
let repo = RepoProvider::Git(Box::new(repo));
let mut session_name = args.repository.to_string();
if tmux.session_exists(&session_name) {
session_name = format!(
"{}/{}",
path.parent()
.unwrap()
.file_name()
.expect("The file name doesn't end in `..`")
.to_string()?,
session_name
);
}
tmux.new_session(Some(&session_name), Some(&path.display().to_string()));
tmux.set_up_tmux_env(&repo, &session_name)?;
tmux.switch_to_session(&session_name);
Ok(())
}
fn bookmark_command(args: &BookmarkCommand, mut config: Config) -> Result<()> {
let path = if let Some(path) = &args.path {
path.to_owned()
} else {
current_dir()
.change_context(TmsError::IoError)?
.to_string()
.change_context(TmsError::IoError)?
};
if !args.delete {
config.add_bookmark(path);
} else {
config.delete_bookmark(path);
}
config.save().change_context(TmsError::ConfigError)?;
Ok(())
}
fn open_session_command(args: &OpenSessionCommand, config: Config, tmux: &Tmux) -> Result<()> {
let sessions = create_sessions(&config)?;
if let Some(session) = sessions.find_session(&args.session) {
session.switch_to(tmux, &config)?;
Ok(())
} else {
Err(TmsError::SessionNotFound(args.session.to_string()).into())
}
}
fn open_session_completion_candidates() -> Vec<CompletionCandidate> {
Config::new()
.change_context(TmsError::ConfigError)
.and_then(|config| create_sessions(&config))
.map(|sessions| {
sessions
.list()
.iter()
.map(CompletionCandidate::new)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub enum SubCommandGiven {
Yes,
No(Box<Config>),
}