Skip to main content

tms/
cli.rs

1use std::{
2    collections::HashMap,
3    env::current_dir,
4    fs::canonicalize,
5    path::{Path, PathBuf},
6    process::{Command, Stdio},
7};
8
9use crate::{
10    configs::{
11        CloneRepoSwitchConfig, Config, ConfigExport, SearchDirectory, SessionSortOrderConfig,
12    },
13    dirty_paths::DirtyUtf8Path,
14    execute_command, get_single_selection,
15    marks::{marks_command, MarksCommand},
16    picker::Preview,
17    repos::RepoProvider,
18    session::{create_sessions, SessionContainer},
19    tmux::Tmux,
20    Result, TmsError,
21};
22use clap::{Args, Parser, Subcommand};
23use clap_complete::{ArgValueCandidates, CompletionCandidate};
24use error_stack::ResultExt;
25use ratatui::style::Color;
26
27#[derive(Debug, Parser)]
28#[command(author, version)]
29///Scan for all git folders in specified directorires, select one and open it as a new tmux session
30pub struct Cli {
31    #[command(subcommand)]
32    command: Option<CliCommand>,
33}
34
35#[derive(Debug, Subcommand)]
36pub enum CliCommand {
37    #[command(arg_required_else_help = true)]
38    /// Configure the defaults for search paths and excluded directories
39    Config(Box<ConfigCommand>),
40    /// Initialize tmux with the default sessions
41    Start,
42    /// Display other sessions with a fuzzy finder and a preview window
43    Switch,
44    /// Display the current session's windows with a fuzzy finder and a preview window
45    Windows,
46    /// Kill the current tmux session and jump to another
47    Kill,
48    /// Show running tmux sessions with asterisk on the current session
49    Sessions,
50    #[command(arg_required_else_help = true)]
51    /// Rename the active session and the working directory
52    Rename(RenameCommand),
53    /// Creates new worktree windows for the selected session
54    Refresh(RefreshCommand),
55    /// Clone repository and create a new session for it
56    CloneRepo(CloneRepoCommand),
57    /// Initialize empty repository
58    InitRepo(InitRepoCommand),
59    /// Bookmark a directory so it is available to select along with the Git repositories
60    Bookmark(BookmarkCommand),
61    /// Open a session
62    OpenSession(OpenSessionCommand),
63    /// Manage list of sessions that can be instantly accessed by their index
64    Marks(MarksCommand),
65}
66
67#[derive(Debug, Args)]
68#[clap(args_conflicts_with_subcommands = true)]
69pub struct ConfigCommand {
70    #[command(flatten)]
71    args: ConfigArgs,
72    #[command(subcommand)]
73    subcommand: Option<ConfigSubCommand>,
74}
75
76#[derive(Debug, Subcommand)]
77pub enum ConfigSubCommand {
78    /// List current config including all default values
79    List(ConfigSubCommandArgs),
80}
81
82#[derive(Debug, Args)]
83pub struct ConfigSubCommandArgs {
84    #[arg(short, long)]
85    /// List only defaults without user set values
86    defaults: bool,
87}
88
89#[derive(Debug, Args)]
90pub struct ConfigArgs {
91    #[arg(short = 'p', long = "paths", value_name = "search paths", num_args = 1..)]
92    /// The paths to search through. Shell like expansions such as '~' are supported
93    search_paths: Option<Vec<String>>,
94    #[arg(short = 's', long = "session", value_name = "default session")]
95    /// The default session to switch to (if available) when killing another session
96    default_session: Option<String>,
97    #[arg(long = "excluded", value_name = "excluded dirs", num_args = 1..)]
98    /// As many directory names as desired to not be searched over
99    excluded_dirs: Option<Vec<String>>,
100    #[arg(long = "remove", value_name = "remove dir", num_args = 1..)]
101    /// As many directory names to be removed from exclusion list
102    remove_dir: Option<Vec<String>>,
103    #[arg(long = "full-path", value_name = "true | false")]
104    /// Use the full path when displaying directories
105    display_full_path: Option<bool>,
106    #[arg(long, value_name = "true | false")]
107    /// Also show initialized submodules
108    search_submodules: Option<bool>,
109    #[arg(long, value_name = "true | false")]
110    /// Search submodules for submodules
111    recursive_submodules: Option<bool>,
112    #[arg(long, value_name = "true | false")]
113    ///Only include sessions from search paths in the switcher
114    switch_filter_unknown: Option<bool>,
115    #[arg(long, short = 'd', value_name = "max depth", num_args = 1..)]
116    /// The maximum depth to traverse when searching for repositories in search paths, length
117    /// should match the number of search paths if specified (defaults to 10)
118    max_depths: Option<Vec<usize>>,
119    #[arg(long, value_name = "#rrggbb")]
120    /// Background color of the highlighted item in the picker
121    picker_highlight_color: Option<Color>,
122    #[arg(long, value_name = "#rrggbb")]
123    /// Text color of the hightlighted item in the picker
124    picker_highlight_text_color: Option<Color>,
125    #[arg(long, value_name = "#rrggbb")]
126    /// Color of the borders between widgets in the picker
127    picker_border_color: Option<Color>,
128    #[arg(long, value_name = "#rrggbb")]
129    /// Color of the item count in the picker
130    picker_info_color: Option<Color>,
131    #[arg(long, value_name = "#rrggbb")]
132    /// Color of the prompt in the picker
133    picker_prompt_color: Option<Color>,
134    #[arg(long, value_name = "Alphabetical | LastAttached")]
135    /// Set the sort order of the sessions in the switch command
136    session_sort_order: Option<SessionSortOrderConfig>,
137    #[arg(long, value_name = "Always | Never | Foreground", verbatim_doc_comment)]
138    /// Whether to automatically switch to the new session after the `clone-repo` command finishes
139    /// `Always` will always switch tmux to the new session
140    /// `Never` will always create the new session in the background
141    /// When set to `Foreground`, the new session will only be opened in the background if the active
142    /// tmux session has changed since starting the clone process (for long clone processes on larger repos)
143    clone_repo_switch: Option<CloneRepoSwitchConfig>,
144}
145
146#[derive(Debug, Args)]
147pub struct RenameCommand {
148    /// The new session's name
149    name: String,
150}
151
152#[derive(Debug, Args)]
153pub struct RefreshCommand {
154    /// The session's name. If not provided gets current session
155    name: Option<String>,
156}
157
158#[derive(Debug, Args)]
159pub struct CloneRepoCommand {
160    /// Git repository to clone
161    repository: String,
162    #[arg(long)]
163    /// Directory to create new repository in, if left empty will pick default
164    /// search path or prompt if multiple are configured
165    path: Option<String>,
166    #[arg(long)]
167    /// Name of cloned repository directory, will be picked based on remote url
168    /// if left empty
169    name: Option<String>,
170}
171
172#[derive(Debug, Args)]
173pub struct InitRepoCommand {
174    /// Name of the repository to initialize
175    repository: String,
176}
177
178#[derive(Debug, Args)]
179pub struct BookmarkCommand {
180    #[arg(long, short)]
181    /// Delete instead of add a bookmark
182    delete: bool,
183    /// Path to bookmark, if left empty bookmark the current directory.
184    path: Option<String>,
185}
186
187#[derive(Debug, Args)]
188pub struct OpenSessionCommand {
189    #[arg(add = ArgValueCandidates::new(open_session_completion_candidates))]
190    /// Name of the session to open.
191    session: Box<str>,
192}
193
194impl Cli {
195    pub fn handle_sub_commands(&self, tmux: &Tmux) -> Result<SubCommandGiven> {
196        // Get the configuration from the config file
197        let config = Config::new().change_context(TmsError::ConfigError)?;
198
199        match &self.command {
200            Some(CliCommand::Start) => {
201                start_command(config, tmux)?;
202                Ok(SubCommandGiven::Yes)
203            }
204
205            Some(CliCommand::Switch) => {
206                switch_command(config, tmux)?;
207                Ok(SubCommandGiven::Yes)
208            }
209
210            Some(CliCommand::Windows) => {
211                windows_command(&config, tmux)?;
212                Ok(SubCommandGiven::Yes)
213            }
214            // Handle the config subcommand
215            Some(CliCommand::Config(args)) => {
216                config_command(args, config)?;
217                Ok(SubCommandGiven::Yes)
218            }
219
220            // The kill subcommand will kill the current session and switch to another one
221            Some(CliCommand::Kill) => {
222                kill_subcommand(config, tmux)?;
223                Ok(SubCommandGiven::Yes)
224            }
225
226            // The sessions subcommand will print the sessions with an asterisk over the current
227            // session
228            Some(CliCommand::Sessions) => {
229                sessions_subcommand(tmux)?;
230                Ok(SubCommandGiven::Yes)
231            }
232
233            // Rename the active session and the working directory
234            // rename
235            Some(CliCommand::Rename(args)) => {
236                rename_subcommand(args, tmux)?;
237                Ok(SubCommandGiven::Yes)
238            }
239            Some(CliCommand::Refresh(args)) => {
240                refresh_command(args, &config, tmux)?;
241                Ok(SubCommandGiven::Yes)
242            }
243
244            Some(CliCommand::CloneRepo(args)) => {
245                clone_repo_command(args, config, tmux)?;
246                Ok(SubCommandGiven::Yes)
247            }
248
249            Some(CliCommand::InitRepo(args)) => {
250                init_repo_command(args, config, tmux)?;
251                Ok(SubCommandGiven::Yes)
252            }
253
254            Some(CliCommand::Bookmark(args)) => {
255                bookmark_command(args, config)?;
256                Ok(SubCommandGiven::Yes)
257            }
258
259            Some(CliCommand::OpenSession(args)) => {
260                open_session_command(args, config, tmux)?;
261                Ok(SubCommandGiven::Yes)
262            }
263
264            Some(CliCommand::Marks(args)) => {
265                marks_command(args, config, tmux)?;
266                Ok(SubCommandGiven::Yes)
267            }
268
269            None => Ok(SubCommandGiven::No(config.into())),
270        }
271    }
272}
273
274fn start_command(config: Config, tmux: &Tmux) -> Result<()> {
275    if let Some(sessions) = &config.sessions {
276        for session in sessions {
277            let session_path = session
278                .path
279                .as_ref()
280                .map(shellexpand::full)
281                .transpose()
282                .change_context(TmsError::IoError)?;
283
284            tmux.new_session(session.name.as_deref(), session_path.as_deref());
285
286            if let Some(windows) = &session.windows {
287                for window in windows {
288                    let window_path = window
289                        .path
290                        .as_ref()
291                        .map(shellexpand::full)
292                        .transpose()
293                        .change_context(TmsError::IoError)?;
294
295                    tmux.new_window(window.name.as_deref(), window_path.as_deref(), None);
296
297                    if let Some(window_command) = &window.command {
298                        tmux.send_keys(window_command, None);
299                    }
300                }
301                tmux.kill_window(":1");
302            }
303        }
304        tmux.attach_session(None, None);
305    } else {
306        tmux.tmux();
307    }
308
309    Ok(())
310}
311
312fn switch_command(config: Config, tmux: &Tmux) -> Result<()> {
313    let sessions = tmux
314        .list_sessions("'#{?session_attached,,#{session_name}#,#{session_last_attached}}'")
315        .replace('\'', "")
316        .replace("\n\n", "\n");
317
318    let mut sessions: Vec<(&str, &str)> = sessions
319        .trim()
320        .split('\n')
321        .filter_map(|s| s.split_once(','))
322        .collect();
323
324    if let Some(SessionSortOrderConfig::LastAttached) = config.session_sort_order {
325        sessions.sort_by(|a, b| b.1.cmp(a.1));
326    }
327
328    let mut sessions: Vec<String> = sessions.into_iter().map(|s| s.0.to_string()).collect();
329    if let Some(true) = config.switch_filter_unknown {
330        let configured = create_sessions(&config)?;
331
332        sessions = sessions
333            .into_iter()
334            .filter(|session| configured.find_session(session).is_some())
335            .collect::<Vec<String>>();
336    }
337
338    if let Some(target_session) =
339        get_single_selection(&sessions, Some(Preview::SessionPane), &config, tmux)?
340    {
341        tmux.switch_client(&target_session.replace('.', "_"));
342    }
343
344    Ok(())
345}
346
347fn windows_command(config: &Config, tmux: &Tmux) -> Result<()> {
348    let windows = tmux.list_windows("'#{?window_attached,,#{window_id} #{window_name}}'", None);
349
350    let windows: Vec<String> = windows
351        .replace('\'', "")
352        .replace("\n\n", "\n")
353        .trim()
354        .split('\n')
355        .map(|s| s.to_string())
356        .collect();
357
358    if let Some(target_window) =
359        get_single_selection(&windows, Some(Preview::WindowPane), config, tmux)?
360    {
361        if let Some((windex, _)) = target_window.split_once(' ') {
362            tmux.select_window(windex);
363        }
364    }
365    Ok(())
366}
367
368fn config_command(cmd: &ConfigCommand, mut config: Config) -> Result<()> {
369    match &cmd.subcommand {
370        None => {}
371        Some(ConfigSubCommand::List(args)) => {
372            let config = if args.defaults {
373                Config::default()
374            } else {
375                config
376            };
377            let config = ConfigExport::from(config);
378            let toml_pretty =
379                toml::to_string_pretty(&config).change_context(TmsError::ConfigError)?;
380            println!("{}", toml_pretty);
381            return Ok(());
382        }
383    };
384    let args = &cmd.args;
385    let max_depths = args.max_depths.clone().unwrap_or_default();
386    config.search_dirs = match &args.search_paths {
387        Some(paths) => Some(
388            paths
389                .iter()
390                .zip(max_depths.into_iter().chain(std::iter::repeat(10)))
391                .map(|(path, depth)| {
392                    let path = if path.ends_with('/') {
393                        let mut modified_path = path.clone();
394                        modified_path.pop();
395                        modified_path
396                    } else {
397                        path.clone()
398                    };
399                    shellexpand::full(&path)
400                        .map(|val| (val.to_string(), depth))
401                        .change_context(TmsError::IoError)
402                })
403                .collect::<Result<Vec<(String, usize)>>>()?
404                .iter()
405                .map(|(path, depth)| {
406                    canonicalize(path)
407                        .map(|val| SearchDirectory::new(val, *depth))
408                        .change_context(TmsError::IoError)
409                })
410                .collect::<Result<Vec<SearchDirectory>>>()?,
411        ),
412        None => config.search_dirs,
413    };
414
415    if let Some(default_session) = args
416        .default_session
417        .clone()
418        .map(|val| val.replace('.', "_"))
419    {
420        config.default_session = Some(default_session);
421    }
422
423    if let Some(display) = args.display_full_path {
424        config.display_full_path = Some(display.to_owned());
425    }
426
427    if let Some(submodules) = args.search_submodules {
428        config.search_submodules = Some(submodules.to_owned());
429    }
430
431    if let Some(submodules) = args.recursive_submodules {
432        config.recursive_submodules = Some(submodules.to_owned());
433    }
434
435    if let Some(switch_filter_unknown) = args.switch_filter_unknown {
436        config.switch_filter_unknown = Some(switch_filter_unknown.to_owned());
437    }
438
439    if let Some(dirs) = &args.excluded_dirs {
440        let current_excluded = config.excluded_dirs;
441        match current_excluded {
442            Some(mut excl_dirs) => {
443                excl_dirs.extend(dirs.iter().map(|str| str.to_string()));
444                config.excluded_dirs = Some(excl_dirs)
445            }
446            None => {
447                config.excluded_dirs = Some(dirs.iter().map(|str| str.to_string()).collect());
448            }
449        }
450    }
451    if let Some(dirs) = &args.remove_dir {
452        let current_excluded = config.excluded_dirs;
453        match current_excluded {
454            Some(mut excl_dirs) => {
455                dirs.iter().for_each(|dir| excl_dirs.retain(|x| x != dir));
456                config.excluded_dirs = Some(excl_dirs);
457            }
458            None => todo!(),
459        }
460    }
461
462    if let Some(color) = &args.picker_highlight_color {
463        let mut picker_colors = config.picker_colors.unwrap_or_default();
464        picker_colors.highlight_color = Some(*color);
465        config.picker_colors = Some(picker_colors);
466    }
467    if let Some(color) = &args.picker_highlight_text_color {
468        let mut picker_colors = config.picker_colors.unwrap_or_default();
469        picker_colors.highlight_text_color = Some(*color);
470        config.picker_colors = Some(picker_colors);
471    }
472    if let Some(color) = &args.picker_border_color {
473        let mut picker_colors = config.picker_colors.unwrap_or_default();
474        picker_colors.border_color = Some(*color);
475        config.picker_colors = Some(picker_colors);
476    }
477    if let Some(color) = &args.picker_info_color {
478        let mut picker_colors = config.picker_colors.unwrap_or_default();
479        picker_colors.info_color = Some(*color);
480        config.picker_colors = Some(picker_colors);
481    }
482    if let Some(color) = &args.picker_prompt_color {
483        let mut picker_colors = config.picker_colors.unwrap_or_default();
484        picker_colors.prompt_color = Some(*color);
485        config.picker_colors = Some(picker_colors);
486    }
487
488    if let Some(order) = &args.session_sort_order {
489        config.session_sort_order = Some(order.to_owned());
490    }
491
492    if let Some(switch) = &args.clone_repo_switch {
493        config.clone_repo_switch = Some(switch.to_owned());
494    }
495
496    config.save().change_context(TmsError::ConfigError)?;
497    println!("Configuration has been stored");
498    Ok(())
499}
500
501fn kill_subcommand(config: Config, tmux: &Tmux) -> Result<()> {
502    let mut current_session = tmux.display_message("'#S'");
503    current_session.retain(|x| x != '\'' && x != '\n');
504
505    let sessions = tmux
506        .list_sessions("'#{?session_attached,,#{session_name}#,#{session_last_attached}}'")
507        .replace('\'', "")
508        .replace("\n\n", "\n");
509
510    let mut sessions: Vec<(&str, &str)> = sessions
511        .trim()
512        .split('\n')
513        .filter_map(|s| s.split_once(','))
514        .collect();
515
516    if let Some(SessionSortOrderConfig::LastAttached) = config.session_sort_order {
517        sessions.sort_by(|a, b| b.1.cmp(a.1));
518    }
519
520    let to_session = if config.default_session.is_some()
521        && sessions
522            .iter()
523            .any(|session| session.0 == config.default_session.as_deref().unwrap())
524        && current_session != config.default_session.as_deref().unwrap()
525    {
526        config.default_session.as_deref()
527    } else {
528        sessions.first().map(|s| s.0)
529    };
530    if let Some(to_session) = to_session {
531        tmux.switch_client(to_session);
532    }
533    tmux.kill_session(&current_session);
534
535    Ok(())
536}
537
538fn sessions_subcommand(tmux: &Tmux) -> Result<()> {
539    let mut current_session = tmux.display_message("'#S'");
540    current_session.retain(|x| x != '\'' && x != '\n');
541    let current_session_star = format!("{current_session}*");
542
543    let sessions = tmux
544        .list_sessions("#S")
545        .split('\n')
546        .map(String::from)
547        .collect::<Vec<String>>();
548
549    let mut new_string = String::new();
550
551    for session in &sessions {
552        if session == &current_session {
553            new_string.push_str(&current_session_star);
554        } else {
555            new_string.push_str(session);
556        }
557        new_string.push(' ')
558    }
559    println!("{new_string}");
560    std::thread::sleep(std::time::Duration::from_millis(100));
561    tmux.refresh_client();
562
563    Ok(())
564}
565
566fn rename_subcommand(args: &RenameCommand, tmux: &Tmux) -> Result<()> {
567    let new_session_name = &args.name;
568
569    let current_session = tmux
570        .display_message("'#S'")
571        .trim()
572        .replace('\'', "")
573        .to_string();
574
575    let panes = tmux.list_windows(
576        "'#{window_index}.#{pane_index},#{pane_current_command},#{pane_current_path}'",
577        None,
578    );
579
580    let mut paneid_to_pane_deatils: HashMap<String, HashMap<String, String>> = HashMap::new();
581    let all_panes: Vec<String> = panes
582        .trim()
583        .split('\n')
584        .map(|window| {
585            let mut _window: Vec<&str> = window.split(',').collect();
586
587            let pane_index = _window[0].replace('\'', "");
588            let pane_details: HashMap<String, String> = HashMap::from([
589                (String::from("command"), _window[1].to_string()),
590                (
591                    String::from("cwd"),
592                    _window[2].to_string().replace('\'', ""),
593                ),
594            ]);
595
596            paneid_to_pane_deatils.insert(pane_index.to_string(), pane_details);
597
598            pane_index.to_string()
599        })
600        .collect();
601
602    let first_pane_details = &paneid_to_pane_deatils[all_panes.first().unwrap()];
603
604    let new_session_path: String =
605        String::from(&first_pane_details["cwd"]).replace(&current_session, new_session_name);
606
607    let move_command_args: Vec<String> =
608        [first_pane_details["cwd"].clone(), new_session_path.clone()].to_vec();
609    execute_command("mv", move_command_args);
610
611    for pane_index in all_panes.iter() {
612        let pane_details = &paneid_to_pane_deatils[pane_index];
613
614        let old_path = &pane_details["cwd"];
615        let new_path = old_path.replace(&current_session, new_session_name);
616
617        let change_dir_cmd = format!("cd {new_path}");
618        tmux.send_keys(&change_dir_cmd, Some(pane_index));
619    }
620
621    tmux.rename_session(new_session_name);
622    tmux.attach_session(None, Some(&new_session_path));
623
624    Ok(())
625}
626
627fn refresh_command(args: &RefreshCommand, config: &Config, tmux: &Tmux) -> Result<()> {
628    let session_name = args
629        .name
630        .clone()
631        .unwrap_or(tmux.display_message("'#S'"))
632        .trim()
633        .replace('\'', "");
634    // For each window there should be the branch names
635    let session_path = tmux
636        .display_message("'#{session_path}'")
637        .trim()
638        .replace('\'', "");
639
640    let existing_window_names: Vec<_> = tmux
641        .list_windows("'#{window_name}'", Some(&session_name))
642        .lines()
643        .map(|line| line.replace('\'', ""))
644        .collect();
645
646    if let Ok(repository) = RepoProvider::open(Path::new(&session_path), config) {
647        let mut num_worktree_windows = 0;
648        if let Ok(worktrees) = repository.worktrees() {
649            for worktree in worktrees.iter() {
650                let worktree_name = worktree.name();
651                if existing_window_names.contains(&worktree_name) {
652                    num_worktree_windows += 1;
653                    continue;
654                }
655                if worktree.is_prunable() {
656                    // prunable worktrees can have an invalid path so skip that
657                    continue;
658                }
659                num_worktree_windows += 1;
660                tmux.new_window(
661                    Some(&worktree_name),
662                    Some(&worktree.path()?.to_string()?),
663                    Some(&session_name),
664                );
665            }
666        }
667        //check if a window is needed for non worktree
668        if !repository.is_bare() {
669            let count_current_windows = tmux
670                .list_windows("'#{window_name}'", Some(&session_name))
671                .lines()
672                .count();
673            if count_current_windows <= num_worktree_windows {
674                tmux.new_window(None, Some(&session_path), Some(&session_name));
675            }
676        }
677    }
678
679    Ok(())
680}
681
682fn pick_search_path(config: &Config, tmux: &Tmux) -> Result<Option<PathBuf>> {
683    let search_dirs = config
684        .search_dirs
685        .as_ref()
686        .ok_or(TmsError::ConfigError)
687        .attach_printable("No search path configured")?
688        .iter()
689        .filter(|dir| dir.depth > 0)
690        .map(|dir| dir.path.to_string())
691        .filter_map(|path| path.ok())
692        .collect::<Vec<String>>();
693
694    let path = if search_dirs.len() > 1 {
695        get_single_selection(&search_dirs, Some(Preview::Directory), config, tmux)?
696    } else {
697        let first = search_dirs
698            .first()
699            .ok_or(TmsError::ConfigError)
700            .attach_printable("No search path configured")?;
701        Some(first.clone())
702    };
703
704    let expanded = path
705        .as_ref()
706        .map(|path| shellexpand::full(path).change_context(TmsError::IoError))
707        .transpose()?
708        .map(|path| PathBuf::from(path.as_ref()));
709    Ok(expanded)
710}
711
712fn clone_repo_command(args: &CloneRepoCommand, config: Config, tmux: &Tmux) -> Result<()> {
713    let Some(mut path) = (if let Some(p) = &args.path {
714        Some(
715            PathBuf::from(p)
716                .canonicalize()
717                .change_context(TmsError::IoError)?,
718        )
719    } else {
720        pick_search_path(&config, tmux)?
721    }) else {
722        return Ok(());
723    };
724
725    let repo_name = args.name.as_deref().unwrap_or_else(|| {
726        let (_, name) = args
727            .repository
728            .trim_end_matches('/')
729            .rsplit_once('/')
730            .expect("Repository path contains '/'");
731        name.trim_end_matches(".git")
732    });
733    path.push(repo_name);
734
735    let previous_session = tmux.current_session("#{session_name}");
736
737    let repo = RepoProvider::open(git_clone(&args.repository, &path)?, &config)?;
738
739    let mut session_name = repo_name.to_string();
740
741    let switch = match config.clone_repo_switch.unwrap_or_default() {
742        CloneRepoSwitchConfig::Always => true,
743        CloneRepoSwitchConfig::Never => false,
744        CloneRepoSwitchConfig::Foreground => {
745            let active_session = tmux.current_session("#{session_name}");
746            previous_session == active_session
747        }
748    };
749
750    if tmux.session_exists(&session_name) {
751        session_name = format!(
752            "{}/{}",
753            path.parent()
754                .unwrap()
755                .file_name()
756                .expect("The file name doesn't end in `..`")
757                .to_string()?,
758            session_name
759        );
760    }
761
762    tmux.new_session(Some(&session_name), Some(&path.display().to_string()));
763    tmux.set_up_tmux_env(&repo, &session_name)?;
764    if switch {
765        tmux.switch_to_session(&session_name);
766    }
767
768    Ok(())
769}
770
771fn git_clone<'a>(repo: &str, target: &'a Path) -> Result<&'a Path> {
772    std::fs::create_dir_all(target).change_context(TmsError::IoError)?;
773    let mut cmd = Command::new("git")
774        .current_dir(target.parent().ok_or(TmsError::IoError)?)
775        .args(["clone", repo, target.to_str().ok_or(TmsError::NonUtf8Path)?])
776        .stdout(Stdio::inherit())
777        .stderr(Stdio::inherit())
778        .spawn()
779        .change_context(TmsError::GitError)?;
780
781    cmd.wait().change_context(TmsError::GitError)?;
782    Ok(target)
783}
784
785fn init_repo_command(args: &InitRepoCommand, config: Config, tmux: &Tmux) -> Result<()> {
786    let Some(mut path) = pick_search_path(&config, tmux)? else {
787        return Ok(());
788    };
789    path.push(&args.repository);
790
791    let repo = gix::init(&path).change_context(TmsError::GitError)?;
792    let repo = RepoProvider::Git(Box::new(repo));
793
794    let mut session_name = args.repository.to_string();
795
796    if tmux.session_exists(&session_name) {
797        session_name = format!(
798            "{}/{}",
799            path.parent()
800                .unwrap()
801                .file_name()
802                .expect("The file name doesn't end in `..`")
803                .to_string()?,
804            session_name
805        );
806    }
807
808    tmux.new_session(Some(&session_name), Some(&path.display().to_string()));
809    tmux.set_up_tmux_env(&repo, &session_name)?;
810    tmux.switch_to_session(&session_name);
811
812    Ok(())
813}
814
815fn bookmark_command(args: &BookmarkCommand, mut config: Config) -> Result<()> {
816    let path = if let Some(path) = &args.path {
817        path.to_owned()
818    } else {
819        current_dir()
820            .change_context(TmsError::IoError)?
821            .to_string()
822            .change_context(TmsError::IoError)?
823    };
824
825    if !args.delete {
826        config.add_bookmark(path);
827    } else {
828        config.delete_bookmark(path);
829    }
830
831    config.save().change_context(TmsError::ConfigError)?;
832
833    Ok(())
834}
835
836fn open_session_command(args: &OpenSessionCommand, config: Config, tmux: &Tmux) -> Result<()> {
837    let sessions = create_sessions(&config)?;
838
839    if let Some(session) = sessions.find_session(&args.session) {
840        session.switch_to(tmux, &config)?;
841        Ok(())
842    } else {
843        Err(TmsError::SessionNotFound(args.session.to_string()).into())
844    }
845}
846
847fn open_session_completion_candidates() -> Vec<CompletionCandidate> {
848    Config::new()
849        .change_context(TmsError::ConfigError)
850        .and_then(|config| create_sessions(&config))
851        .map(|sessions| {
852            sessions
853                .list()
854                .iter()
855                .map(CompletionCandidate::new)
856                .collect::<Vec<_>>()
857        })
858        .unwrap_or_default()
859}
860
861pub enum SubCommandGiven {
862    Yes,
863    No(Box<Config>),
864}