1use std::{
2 collections::HashMap,
3 env::current_dir,
4 fs::canonicalize,
5 path::{Path, PathBuf},
6};
7
8use crate::{
9 configs::{Config, SearchDirectory, SessionSortOrderConfig},
10 dirty_paths::DirtyUtf8Path,
11 execute_command, get_single_selection,
12 picker::Preview,
13 session::{create_sessions, SessionContainer},
14 tmux::Tmux,
15 Result, TmsError,
16};
17use clap::{Args, Command, CommandFactory, Parser, Subcommand};
18use clap_complete::{generate, Generator, Shell};
19use error_stack::ResultExt;
20use git2::{build::RepoBuilder, FetchOptions, RemoteCallbacks, Repository};
21use ratatui::style::Color;
22
23#[derive(Debug, Parser)]
24#[command(author, version)]
25pub struct Cli {
27 #[arg(long = "generate", value_enum)]
28 generator: Option<Shell>,
29 #[command(subcommand)]
30 command: Option<CliCommand>,
31}
32
33#[derive(Debug, Subcommand)]
34pub enum CliCommand {
35 #[command(arg_required_else_help = true)]
36 Config(Box<ConfigCommand>),
38 Start,
40 Switch,
42 Windows,
44 Kill,
46 Sessions,
48 #[command(arg_required_else_help = true)]
49 Rename(RenameCommand),
51 Refresh(RefreshCommand),
53 CloneRepo(CloneRepoCommand),
55 InitRepo(InitRepoCommand),
57 Bookmark(BookmarkCommand),
59}
60
61#[derive(Debug, Args)]
62pub struct ConfigCommand {
63 #[arg(short = 'p', long = "paths", value_name = "search paths", num_args = 1..)]
64 search_paths: Option<Vec<String>>,
66 #[arg(short = 's', long = "session", value_name = "default session")]
67 default_session: Option<String>,
69 #[arg(long = "excluded", value_name = "excluded dirs", num_args = 1..)]
70 excluded_dirs: Option<Vec<String>>,
72 #[arg(long = "remove", value_name = "remove dir", num_args = 1..)]
73 remove_dir: Option<Vec<String>>,
75 #[arg(long = "full-path", value_name = "true | false")]
76 display_full_path: Option<bool>,
78 #[arg(long, value_name = "true | false")]
79 search_submodules: Option<bool>,
81 #[arg(long, value_name = "true | false")]
82 recursive_submodules: Option<bool>,
84 #[arg(long, value_name = "true | false")]
85 switch_filter_unknown: Option<bool>,
87 #[arg(long, short = 'd', value_name = "max depth", num_args = 1..)]
88 max_depths: Option<Vec<usize>>,
91 #[arg(long, value_name = "#rrggbb")]
92 picker_highlight_color: Option<Color>,
94 #[arg(long, value_name = "#rrggbb")]
95 picker_highlight_text_color: Option<Color>,
97 #[arg(long, value_name = "#rrggbb")]
98 picker_border_color: Option<Color>,
100 #[arg(long, value_name = "#rrggbb")]
101 picker_info_color: Option<Color>,
103 #[arg(long, value_name = "#rrggbb")]
104 picker_prompt_color: Option<Color>,
106 #[arg(long, value_name = "Alphabetical | LastAttached")]
107 session_sort_order: Option<SessionSortOrderConfig>,
109}
110
111#[derive(Debug, Args)]
112pub struct RenameCommand {
113 name: String,
115}
116
117#[derive(Debug, Args)]
118pub struct RefreshCommand {
119 name: Option<String>,
121}
122
123#[derive(Debug, Args)]
124pub struct CloneRepoCommand {
125 repository: String,
127}
128
129#[derive(Debug, Args)]
130pub struct InitRepoCommand {
131 repository: String,
133}
134
135#[derive(Debug, Args)]
136pub struct BookmarkCommand {
137 #[arg(long, short)]
138 delete: bool,
140 path: Option<String>,
142}
143
144impl Cli {
145 pub fn handle_sub_commands(&self, tmux: &Tmux) -> Result<SubCommandGiven> {
146 if let Some(generator) = self.generator {
147 let mut cmd = Cli::command();
148 print_completions(generator, &mut cmd);
149 return Ok(SubCommandGiven::Yes);
150 }
151
152 let config = Config::new().change_context(TmsError::ConfigError)?;
154
155 match &self.command {
156 Some(CliCommand::Start) => {
157 start_command(config, tmux)?;
158 Ok(SubCommandGiven::Yes)
159 }
160
161 Some(CliCommand::Switch) => {
162 switch_command(config, tmux)?;
163 Ok(SubCommandGiven::Yes)
164 }
165
166 Some(CliCommand::Windows) => {
167 windows_command(&config, tmux)?;
168 Ok(SubCommandGiven::Yes)
169 }
170 Some(CliCommand::Config(args)) => {
172 config_command(args, config)?;
173 Ok(SubCommandGiven::Yes)
174 }
175
176 Some(CliCommand::Kill) => {
178 kill_subcommand(config, tmux)?;
179 Ok(SubCommandGiven::Yes)
180 }
181
182 Some(CliCommand::Sessions) => {
185 sessions_subcommand(tmux)?;
186 Ok(SubCommandGiven::Yes)
187 }
188
189 Some(CliCommand::Rename(args)) => {
192 rename_subcommand(args, tmux)?;
193 Ok(SubCommandGiven::Yes)
194 }
195 Some(CliCommand::Refresh(args)) => {
196 refresh_command(args, tmux)?;
197 Ok(SubCommandGiven::Yes)
198 }
199
200 Some(CliCommand::CloneRepo(args)) => {
201 clone_repo_command(args, config, tmux)?;
202 Ok(SubCommandGiven::Yes)
203 }
204
205 Some(CliCommand::InitRepo(args)) => {
206 init_repo_command(args, config, tmux)?;
207 Ok(SubCommandGiven::Yes)
208 }
209
210 Some(CliCommand::Bookmark(args)) => {
211 bookmark_command(args, config)?;
212 Ok(SubCommandGiven::Yes)
213 }
214
215 None => Ok(SubCommandGiven::No(config.into())),
216 }
217 }
218}
219
220fn start_command(config: Config, tmux: &Tmux) -> Result<()> {
221 if let Some(sessions) = &config.sessions {
222 for session in sessions {
223 let session_path = session
224 .path
225 .as_ref()
226 .map(shellexpand::full)
227 .transpose()
228 .change_context(TmsError::IoError)?;
229
230 tmux.new_session(session.name.as_deref(), session_path.as_deref());
231
232 if let Some(windows) = &session.windows {
233 for window in windows {
234 let window_path = window
235 .path
236 .as_ref()
237 .map(shellexpand::full)
238 .transpose()
239 .change_context(TmsError::IoError)?;
240
241 tmux.new_window(window.name.as_deref(), window_path.as_deref(), None);
242
243 if let Some(window_command) = &window.command {
244 tmux.send_keys(window_command, None);
245 }
246 }
247 tmux.kill_window(":1");
248 }
249 }
250 tmux.attach_session(None, None);
251 } else {
252 tmux.tmux();
253 }
254
255 Ok(())
256}
257
258fn switch_command(config: Config, tmux: &Tmux) -> Result<()> {
259 let sessions = tmux
260 .list_sessions("'#{?session_attached,,#{session_name}#,#{session_last_attached}}'")
261 .replace('\'', "")
262 .replace("\n\n", "\n");
263
264 let mut sessions: Vec<(&str, &str)> = sessions
265 .trim()
266 .split('\n')
267 .filter_map(|s| s.split_once(','))
268 .collect();
269
270 if let Some(SessionSortOrderConfig::LastAttached) = config.session_sort_order {
271 sessions.sort_by(|a, b| b.1.cmp(a.1));
272 }
273
274 let mut sessions: Vec<String> = sessions.into_iter().map(|s| s.0.to_string()).collect();
275 if let Some(true) = config.switch_filter_unknown {
276 let configured = create_sessions(&config)?;
277
278 sessions = sessions
279 .into_iter()
280 .filter(|session| configured.find_session(session).is_some())
281 .collect::<Vec<String>>();
282 }
283
284 if let Some(target_session) =
285 get_single_selection(&sessions, Preview::SessionPane, &config, tmux)?
286 {
287 tmux.switch_client(&target_session.replace('.', "_"));
288 }
289
290 Ok(())
291}
292
293fn windows_command(config: &Config, tmux: &Tmux) -> Result<()> {
294 let windows = tmux.list_windows("'#{?window_attached,,#{window_id} #{window_name}}'", None);
295
296 let windows: Vec<String> = windows
297 .replace('\'', "")
298 .replace("\n\n", "\n")
299 .trim()
300 .split('\n')
301 .map(|s| s.to_string())
302 .collect();
303
304 if let Some(target_window) = get_single_selection(&windows, Preview::WindowPane, config, tmux)?
305 {
306 if let Some((windex, _)) = target_window.split_once(' ') {
307 tmux.select_window(windex);
308 }
309 }
310 Ok(())
311}
312
313fn config_command(args: &ConfigCommand, mut config: Config) -> Result<()> {
314 let max_depths = args.max_depths.clone().unwrap_or_default();
315 config.search_dirs = match &args.search_paths {
316 Some(paths) => Some(
317 paths
318 .iter()
319 .zip(max_depths.into_iter().chain(std::iter::repeat(10)))
320 .map(|(path, depth)| {
321 let path = if path.ends_with('/') {
322 let mut modified_path = path.clone();
323 modified_path.pop();
324 modified_path
325 } else {
326 path.clone()
327 };
328 shellexpand::full(&path)
329 .map(|val| (val.to_string(), depth))
330 .change_context(TmsError::IoError)
331 })
332 .collect::<Result<Vec<(String, usize)>>>()?
333 .iter()
334 .map(|(path, depth)| {
335 canonicalize(path)
336 .map(|val| SearchDirectory::new(val, *depth))
337 .change_context(TmsError::IoError)
338 })
339 .collect::<Result<Vec<SearchDirectory>>>()?,
340 ),
341 None => config.search_dirs,
342 };
343
344 if let Some(default_session) = args
345 .default_session
346 .clone()
347 .map(|val| val.replace('.', "_"))
348 {
349 config.default_session = Some(default_session);
350 }
351
352 if let Some(display) = args.display_full_path {
353 config.display_full_path = Some(display.to_owned());
354 }
355
356 if let Some(submodules) = args.search_submodules {
357 config.search_submodules = Some(submodules.to_owned());
358 }
359
360 if let Some(submodules) = args.recursive_submodules {
361 config.recursive_submodules = Some(submodules.to_owned());
362 }
363
364 if let Some(switch_filter_unknown) = args.switch_filter_unknown {
365 config.switch_filter_unknown = Some(switch_filter_unknown.to_owned());
366 }
367
368 if let Some(dirs) = &args.excluded_dirs {
369 let current_excluded = config.excluded_dirs;
370 match current_excluded {
371 Some(mut excl_dirs) => {
372 excl_dirs.extend(dirs.iter().map(|str| str.to_string()));
373 config.excluded_dirs = Some(excl_dirs)
374 }
375 None => {
376 config.excluded_dirs = Some(dirs.iter().map(|str| str.to_string()).collect());
377 }
378 }
379 }
380 if let Some(dirs) = &args.remove_dir {
381 let current_excluded = config.excluded_dirs;
382 match current_excluded {
383 Some(mut excl_dirs) => {
384 dirs.iter().for_each(|dir| excl_dirs.retain(|x| x != dir));
385 config.excluded_dirs = Some(excl_dirs);
386 }
387 None => todo!(),
388 }
389 }
390
391 if let Some(color) = &args.picker_highlight_color {
392 let mut picker_colors = config.picker_colors.unwrap_or_default();
393 picker_colors.highlight_color = Some(*color);
394 config.picker_colors = Some(picker_colors);
395 }
396 if let Some(color) = &args.picker_highlight_text_color {
397 let mut picker_colors = config.picker_colors.unwrap_or_default();
398 picker_colors.highlight_text_color = Some(*color);
399 config.picker_colors = Some(picker_colors);
400 }
401 if let Some(color) = &args.picker_border_color {
402 let mut picker_colors = config.picker_colors.unwrap_or_default();
403 picker_colors.border_color = Some(*color);
404 config.picker_colors = Some(picker_colors);
405 }
406 if let Some(color) = &args.picker_info_color {
407 let mut picker_colors = config.picker_colors.unwrap_or_default();
408 picker_colors.info_color = Some(*color);
409 config.picker_colors = Some(picker_colors);
410 }
411 if let Some(color) = &args.picker_prompt_color {
412 let mut picker_colors = config.picker_colors.unwrap_or_default();
413 picker_colors.prompt_color = Some(*color);
414 config.picker_colors = Some(picker_colors);
415 }
416
417 if let Some(order) = &args.session_sort_order {
418 config.session_sort_order = Some(order.to_owned());
419 }
420
421 config.save().change_context(TmsError::ConfigError)?;
422 println!("Configuration has been stored");
423 Ok(())
424}
425
426fn kill_subcommand(config: Config, tmux: &Tmux) -> Result<()> {
427 let mut current_session = tmux.display_message("'#S'");
428 current_session.retain(|x| x != '\'' && x != '\n');
429
430 let sessions = tmux
431 .list_sessions("'#{?session_attached,,#{session_name}#,#{session_last_attached}}'")
432 .replace('\'', "")
433 .replace("\n\n", "\n");
434
435 let mut sessions: Vec<(&str, &str)> = sessions
436 .trim()
437 .split('\n')
438 .filter_map(|s| s.split_once(','))
439 .collect();
440
441 if let Some(SessionSortOrderConfig::LastAttached) = config.session_sort_order {
442 sessions.sort_by(|a, b| b.1.cmp(a.1));
443 }
444
445 let to_session = if config.default_session.is_some()
446 && sessions
447 .iter()
448 .any(|session| session.0 == config.default_session.as_deref().unwrap())
449 && current_session != config.default_session.as_deref().unwrap()
450 {
451 config.default_session.as_deref()
452 } else {
453 sessions.first().map(|s| s.0)
454 };
455 if let Some(to_session) = to_session {
456 tmux.switch_client(to_session);
457 }
458 tmux.kill_session(¤t_session);
459
460 Ok(())
461}
462
463fn sessions_subcommand(tmux: &Tmux) -> Result<()> {
464 let mut current_session = tmux.display_message("'#S'");
465 current_session.retain(|x| x != '\'' && x != '\n');
466 let current_session_star = format!("{current_session}*");
467
468 let sessions = tmux
469 .list_sessions("#S")
470 .split('\n')
471 .map(String::from)
472 .collect::<Vec<String>>();
473
474 let mut new_string = String::new();
475
476 for session in &sessions {
477 if session == ¤t_session {
478 new_string.push_str(¤t_session_star);
479 } else {
480 new_string.push_str(session);
481 }
482 new_string.push(' ')
483 }
484 println!("{new_string}");
485 std::thread::sleep(std::time::Duration::from_millis(100));
486 tmux.refresh_client();
487
488 Ok(())
489}
490
491fn rename_subcommand(args: &RenameCommand, tmux: &Tmux) -> Result<()> {
492 let new_session_name = &args.name;
493
494 let current_session = tmux.display_message("'#S'");
495 let current_session = current_session.trim();
496
497 let panes = tmux.list_windows(
498 "'#{window_index}.#{pane_index},#{pane_current_command},#{pane_current_path}'",
499 None,
500 );
501
502 let mut paneid_to_pane_deatils: HashMap<String, HashMap<String, String>> = HashMap::new();
503 let all_panes: Vec<String> = panes
504 .trim()
505 .split('\n')
506 .map(|window| {
507 let mut _window: Vec<&str> = window.split(',').collect();
508
509 let pane_index = _window[0];
510 let pane_details: HashMap<String, String> = HashMap::from([
511 (String::from("command"), _window[1].to_string()),
512 (String::from("cwd"), _window[2].to_string()),
513 ]);
514
515 paneid_to_pane_deatils.insert(pane_index.to_string(), pane_details);
516
517 _window[0].to_string()
518 })
519 .collect();
520
521 let first_pane_details = &paneid_to_pane_deatils[all_panes.first().unwrap()];
522
523 let new_session_path: String =
524 String::from(&first_pane_details["cwd"]).replace(current_session, new_session_name);
525
526 let move_command_args: Vec<String> =
527 [first_pane_details["cwd"].clone(), new_session_path.clone()].to_vec();
528 execute_command("mv", move_command_args);
529
530 for pane_index in all_panes.iter() {
531 let pane_details = &paneid_to_pane_deatils[pane_index];
532
533 let old_path = &pane_details["cwd"];
534 let new_path = old_path.replace(current_session, new_session_name);
535
536 let change_dir_cmd = format!("\"cd {new_path}\"");
537 tmux.send_keys(&change_dir_cmd, Some(pane_index));
538 }
539
540 tmux.rename_session(new_session_name);
541 tmux.attach_session(None, Some(&new_session_path));
542
543 Ok(())
544}
545
546fn refresh_command(args: &RefreshCommand, tmux: &Tmux) -> Result<()> {
547 let session_name = args
548 .name
549 .clone()
550 .unwrap_or(tmux.display_message("'#S'"))
551 .trim()
552 .replace('\'', "");
553 let session_path = tmux
555 .display_message("'#{session_path}'")
556 .trim()
557 .replace('\'', "");
558
559 let existing_window_names: Vec<_> = tmux
560 .list_windows("'#{window_name}'", Some(&session_name))
561 .lines()
562 .map(|line| line.replace('\'', ""))
563 .collect();
564
565 if let Ok(repository) = Repository::open(&session_path) {
566 let mut num_worktree_windows = 0;
567 if let Ok(worktrees) = repository.worktrees() {
568 for worktree_name in worktrees.iter().flatten() {
569 let worktree = repository
570 .find_worktree(worktree_name)
571 .change_context(TmsError::GitError)?;
572 if existing_window_names.contains(&String::from(worktree_name)) {
573 num_worktree_windows += 1;
574 continue;
575 }
576 if !worktree.is_prunable(None).unwrap_or_default() {
577 num_worktree_windows += 1;
578 tmux.new_window(
580 Some(worktree_name),
581 Some(&worktree.path().to_string()?),
582 Some(&session_name),
583 );
584 }
585 }
586 }
587 if !repository.is_bare() {
589 let count_current_windows = tmux
590 .list_windows("'#{window_name}'", Some(&session_name))
591 .lines()
592 .count();
593 if count_current_windows <= num_worktree_windows {
594 tmux.new_window(None, Some(&session_path), Some(&session_name));
595 }
596 }
597 }
598
599 Ok(())
600}
601
602fn pick_search_path(config: &Config, tmux: &Tmux) -> Result<Option<PathBuf>> {
603 let search_dirs = config
604 .search_dirs
605 .as_ref()
606 .ok_or(TmsError::ConfigError)
607 .attach_printable("No search path configured")?
608 .iter()
609 .map(|dir| dir.path.to_string())
610 .filter_map(|path| path.ok())
611 .collect::<Vec<String>>();
612
613 let path = if search_dirs.len() > 1 {
614 get_single_selection(&search_dirs, Preview::Directory, config, tmux)?
615 } else {
616 let first = search_dirs
617 .first()
618 .ok_or(TmsError::ConfigError)
619 .attach_printable("No search path configured")?;
620 Some(first.clone())
621 };
622
623 let expanded = path
624 .as_ref()
625 .map(|path| shellexpand::full(path).change_context(TmsError::IoError))
626 .transpose()?
627 .map(|path| PathBuf::from(path.as_ref()));
628 Ok(expanded)
629}
630
631fn clone_repo_command(args: &CloneRepoCommand, config: Config, tmux: &Tmux) -> Result<()> {
632 let Some(mut path) = pick_search_path(&config, tmux)? else {
633 return Ok(());
634 };
635
636 let (_, repo_name) = args
637 .repository
638 .rsplit_once('/')
639 .expect("Repository path contains '/'");
640 let repo_name = repo_name.trim_end_matches(".git");
641 path.push(repo_name);
642
643 let repo = git_clone(&args.repository, &path)?;
644
645 let mut session_name = repo_name.to_string();
646
647 if tmux.session_exists(&session_name) {
648 session_name = format!(
649 "{}/{}",
650 path.parent()
651 .unwrap()
652 .file_name()
653 .expect("The file name doesn't end in `..`")
654 .to_string()?,
655 session_name
656 );
657 }
658
659 tmux.new_session(Some(&session_name), Some(&path.display().to_string()));
660 tmux.set_up_tmux_env(&repo, &session_name)?;
661 tmux.switch_to_session(&session_name);
662
663 Ok(())
664}
665
666fn git_clone(repo: &str, target: &Path) -> Result<Repository> {
667 let mut callbacks = RemoteCallbacks::new();
668 callbacks.credentials(git_credentials_callback);
669 let mut fo = FetchOptions::new();
670 fo.remote_callbacks(callbacks);
671 let mut builder = RepoBuilder::new();
672 builder.fetch_options(fo);
673
674 builder
675 .clone(repo, target)
676 .change_context(TmsError::GitError)
677}
678
679fn git_credentials_callback(
680 user: &str,
681 user_from_url: Option<&str>,
682 _cred: git2::CredentialType,
683) -> std::result::Result<git2::Cred, git2::Error> {
684 let user = match user_from_url {
685 Some(user) => user,
686 None => user,
687 };
688
689 git2::Cred::ssh_key_from_agent(user)
690}
691
692fn init_repo_command(args: &InitRepoCommand, config: Config, tmux: &Tmux) -> Result<()> {
693 let Some(mut path) = pick_search_path(&config, tmux)? else {
694 return Ok(());
695 };
696 path.push(&args.repository);
697
698 let repo = Repository::init(&path).change_context(TmsError::GitError)?;
699
700 let mut session_name = args.repository.to_string();
701
702 if tmux.session_exists(&session_name) {
703 session_name = format!(
704 "{}/{}",
705 path.parent()
706 .unwrap()
707 .file_name()
708 .expect("The file name doesn't end in `..`")
709 .to_string()?,
710 session_name
711 );
712 }
713
714 tmux.new_session(Some(&session_name), Some(&path.display().to_string()));
715 tmux.set_up_tmux_env(&repo, &session_name)?;
716 tmux.switch_to_session(&session_name);
717
718 Ok(())
719}
720
721fn bookmark_command(args: &BookmarkCommand, mut config: Config) -> Result<()> {
722 let path = if let Some(path) = &args.path {
723 path.to_owned()
724 } else {
725 current_dir()
726 .change_context(TmsError::IoError)?
727 .to_string()
728 .change_context(TmsError::IoError)?
729 };
730
731 if !args.delete {
732 config.add_bookmark(path);
733 } else {
734 config.delete_bookmark(path);
735 }
736
737 config.save().change_context(TmsError::ConfigError)?;
738
739 Ok(())
740}
741
742fn print_completions<G: Generator>(gen: G, cmd: &mut Command) {
743 let name = if let Ok(exe) = std::env::current_exe() {
744 if let Some(exe) = exe.file_name() {
745 exe.to_string_lossy().to_string()
746 } else {
747 cmd.get_name().to_string()
748 }
749 } else {
750 cmd.get_name().to_string()
751 };
752 generate(gen, cmd, name, &mut std::io::stdout());
753}
754
755pub enum SubCommandGiven {
756 Yes,
757 No(Box<Config>),
758}