1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5
6use crate::cli::{Cli, Commands};
7use crate::config;
8use crate::docs;
9use crate::doctor;
10use crate::folder_search;
11use crate::fzf;
12use crate::project_export;
13use crate::session;
14use crate::tmux::Tmux;
15use crate::ui::DisplayStyle;
16use crate::util;
17use crate::zoxide;
18
19const BUILTIN_TEMPLATE_LABEL: &str = "<builtin>";
20
21pub fn run(cli: Cli) -> Result<()> {
22 let tmux = Tmux::new();
23
24 match cli.command {
25 Commands::Select {
26 choose_template,
27 no_project_detect,
28 config,
29 } => {
30 let loaded = config::load_optional(config.as_deref())?;
31 run_select(
32 &tmux,
33 loaded,
34 config.as_deref(),
35 choose_template,
36 no_project_detect,
37 )
38 }
39 Commands::Connect {
40 path,
41 template,
42 session_name,
43 config,
44 } => {
45 let loaded = config::load_optional(config.as_deref())?;
46 session::connect_path(
47 &tmux,
48 &path,
49 loaded.as_ref(),
50 template.as_deref(),
51 session_name.as_deref(),
52 session::ProjectDetection::Enabled,
53 )
54 }
55 Commands::Switch { session } => session::switch_existing(&tmux, &session),
56 Commands::ListSessions => {
57 for session in tmux.list_sessions()? {
58 println!("{session}");
59 }
60
61 Ok(())
62 }
63 Commands::Doctor { fix, config } => doctor::run(config.as_deref(), fix),
64 Commands::SaveProject {
65 name,
66 session,
67 path,
68 stdout,
69 force,
70 config,
71 } => {
72 if let Some(path) = project_export::save_project(
73 &tmux,
74 &name,
75 session.as_deref(),
76 path.as_deref(),
77 stdout,
78 force,
79 config.as_deref(),
80 )? {
81 println!("{}", path.display());
82 }
83 Ok(())
84 }
85 Commands::ListTemplates { config } => {
86 let loaded = config::load(config.as_deref())?;
87 let mut names = loaded.config.templates.keys().cloned().collect::<Vec<_>>();
88 names.sort();
89 for name in names {
90 println!("{name}");
91 }
92 Ok(())
93 }
94 Commands::ListProjects { config } => {
95 let loaded = config::load_workspace(config.as_deref())?;
96 let mut names = loaded.projects.keys().cloned().collect::<Vec<_>>();
97 names.sort();
98 for name in names {
99 let project = &loaded.projects[&name];
100 let resolved = util::expand_and_normalize_path(Path::new(&project.path))?;
101 println!("{name}\t{}", resolved.display());
102 }
103 Ok(())
104 }
105 Commands::Init { config } => {
106 let path = config::init(config.as_deref())?;
107 println!("{}", path.display());
108 Ok(())
109 }
110 Commands::Completions { shell, dir } => {
111 if let Some(path) = docs::generate_completions(shell, dir.as_deref())? {
112 println!("{}", path.display());
113 }
114 Ok(())
115 }
116 Commands::Man { dir } => {
117 if let Some(paths) = docs::generate_man_pages(dir.as_deref())? {
118 for path in paths {
119 println!("{}", path.display());
120 }
121 }
122 Ok(())
123 }
124 }
125}
126
127fn run_select(
128 tmux: &Tmux,
129 mut loaded: Option<config::LoadedConfig>,
130 config_path: Option<&Path>,
131 choose_template: bool,
132 no_project_detect: bool,
133) -> Result<()> {
134 let project_detection = if no_project_detect {
135 session::ProjectDetection::Disabled
136 } else {
137 session::ProjectDetection::Enabled
138 };
139
140 loop {
141 let config = loaded.as_ref().map(|loaded| &loaded.config);
142 let display_style = DisplayStyle::from_config(config);
143 let picker_bindings = config
144 .map(|config| config.settings.picker.bindings.clone())
145 .unwrap_or_default();
146 let picker_preview = config
147 .map(|config| config.settings.picker.preview.clone())
148 .unwrap_or_default();
149 let current_session = tmux.current_session().ok().flatten();
150 let entries = select_entries(
151 tmux,
152 loaded.as_ref(),
153 display_style,
154 current_session.as_deref(),
155 )?;
156
157 let Some(selection) = fzf::select(entries, &picker_bindings, &picker_preview)? else {
158 return Ok(());
159 };
160
161 match (selection.action, selection.entry.kind) {
162 (fzf::SelectAction::Open, fzf::EntryKind::Session) => {
163 return session::switch_existing(tmux, &selection.entry.value);
164 }
165 (fzf::SelectAction::Delete, fzf::EntryKind::Session) => {
166 if current_session.as_deref() == Some(selection.entry.value.as_str()) {
167 continue;
168 }
169 session::kill_existing(tmux, &selection.entry.value)?;
170 }
171 (fzf::SelectAction::Delete, fzf::EntryKind::Project)
172 | (fzf::SelectAction::Delete, fzf::EntryKind::InvalidProject) => {
173 match delete_project_from_picker(loaded.as_ref(), &selection.entry.value) {
174 Ok(path) => {
175 eprintln!("deleted project {}", path.display());
176 loaded = config::load_optional(config_path)?;
177 }
178 Err(error) => eprintln!("warning: {error:#}"),
179 }
180 }
181 (fzf::SelectAction::SaveProject, fzf::EntryKind::Session) => {
182 match save_project_from_picker(tmux, &selection.entry.value, config_path) {
183 Ok(Some(path)) => {
184 eprintln!("saved project {}", path.display());
185 loaded = config::load_optional(config_path)?;
186 }
187 Ok(None) => {}
188 Err(error) => eprintln!("warning: {error:#}"),
189 }
190 }
191 (fzf::SelectAction::Open, fzf::EntryKind::Directory) => {
192 let template = if choose_template {
193 let Some(template) = choose_template_name(config, display_style)? else {
194 return Ok(());
195 };
196 Some(template)
197 } else {
198 None
199 };
200
201 return session::connect_path(
202 tmux,
203 Path::new(&selection.entry.value),
204 loaded.as_ref(),
205 template.as_deref(),
206 None,
207 project_detection,
208 );
209 }
210 (fzf::SelectAction::Open, fzf::EntryKind::Project) => {
211 let loaded = loaded
212 .as_ref()
213 .context("project selection requires config or project files")?;
214 return session::connect_project(tmux, loaded, &selection.entry.value);
215 }
216 (fzf::SelectAction::Open, fzf::EntryKind::InvalidProject) => continue,
217 (fzf::SelectAction::Delete, _) => continue,
218 (fzf::SelectAction::SaveProject, _) => continue,
219 }
220 }
221}
222
223fn delete_project_from_picker(
224 loaded: Option<&config::LoadedConfig>,
225 project_name: &str,
226) -> Result<PathBuf> {
227 let loaded = loaded.context("project deletion requires config or project files")?;
228 config::delete_project_file(loaded, project_name)
229}
230
231fn save_project_from_picker(
232 tmux: &Tmux,
233 session_name: &str,
234 config_path: Option<&Path>,
235) -> Result<Option<PathBuf>> {
236 project_export::save_project(
237 tmux,
238 session_name,
239 Some(session_name),
240 None,
241 false,
242 false,
243 config_path,
244 )
245}
246
247fn select_entries(
248 tmux: &Tmux,
249 loaded: Option<&config::LoadedConfig>,
250 display_style: DisplayStyle,
251 current_session: Option<&str>,
252) -> Result<Vec<fzf::Entry>> {
253 let mut entries = Vec::new();
254 let sessions = tmux.list_sessions()?;
255 let session_count = sessions.len();
256
257 for session in sessions {
258 let entry = if current_session == Some(session.as_str()) {
259 fzf::Entry {
260 kind: fzf::EntryKind::Session,
261 label: display_style.current_session_label(&session),
262 value: session,
263 preview: None,
264 }
265 } else {
266 fzf::Entry::session(display_style, session)
267 };
268 entries.push(entry);
269 }
270
271 if let Some(loaded) = loaded {
272 let mut project_names = loaded.projects.keys().cloned().collect::<Vec<_>>();
273 project_names.sort();
274 for project_name in project_names {
275 let preview = loaded
276 .project_files
277 .get(&project_name)
278 .map(|path| path.display().to_string());
279 let project = &loaded.projects[&project_name];
280 let label_value = project
281 .session_name
282 .as_deref()
283 .unwrap_or(&project_name)
284 .to_string();
285 entries.push(fzf::Entry::project(
286 display_style,
287 project_name,
288 label_value,
289 preview,
290 ));
291 }
292 let mut invalid_projects = loaded.invalid_projects.clone();
293 invalid_projects.sort_by(|left, right| left.name.cmp(&right.name));
294 for project in invalid_projects {
295 entries.push(fzf::Entry::invalid_project(
296 display_style,
297 project.name,
298 &project.error,
299 Some(project.path.display().to_string()),
300 ));
301 }
302 }
303
304 let mut zoxide_available = true;
305 let mut directory_count = 0;
306 let mut directory_keys = HashSet::new();
307
308 match zoxide::list_directories() {
309 Ok(directories) => {
310 for directory in directories {
311 if insert_directory_key(&mut directory_keys, &directory) {
312 directory_count += 1;
313 entries.push(fzf::Entry::directory(display_style, directory));
314 }
315 }
316 }
317 Err(error) => {
318 zoxide_available = false;
319 eprintln!("warning: {error:#}");
320 }
321 }
322
323 let folder_search_settings = loaded
324 .map(|loaded| loaded.config.settings.folder_search.clone())
325 .unwrap_or_default();
326 let result = folder_search::list_directories(&folder_search_settings);
327 for warning in result.warnings {
328 eprintln!(
329 "warning: folder search {}: {}",
330 warning.root, warning.message
331 );
332 }
333 for directory in result.directories {
334 if insert_directory_key(&mut directory_keys, &directory) {
335 directory_count += 1;
336 entries.push(fzf::Entry::directory(display_style, directory));
337 }
338 }
339
340 if entries.is_empty() {
341 bail!(
342 "{}",
343 empty_select_message(session_count, directory_count, zoxide_available)
344 );
345 }
346
347 Ok(entries)
348}
349
350fn insert_directory_key(seen: &mut HashSet<PathBuf>, directory: &str) -> bool {
351 let key = util::expand_and_normalize_path(Path::new(directory))
352 .unwrap_or_else(|_| PathBuf::from(directory));
353 seen.insert(key)
354}
355
356fn choose_template_name(
357 config: Option<&config::Config>,
358 display_style: DisplayStyle,
359) -> Result<Option<String>> {
360 let mut template_names = config
361 .map(|config| config.templates.keys().cloned().collect::<Vec<_>>())
362 .unwrap_or_default();
363 template_names.sort();
364 template_names.insert(0, BUILTIN_TEMPLATE_LABEL.to_owned());
365
366 let choices = template_names
367 .into_iter()
368 .map(|name| fzf::Choice::new("template", display_style.template_label(&name), name))
369 .collect();
370
371 Ok(resolve_template_choice(fzf::select_value(
372 "template> ",
373 choices,
374 )?))
375}
376
377fn resolve_template_choice(choice: Option<String>) -> Option<String> {
378 match choice.as_deref() {
379 None => None,
380 Some(BUILTIN_TEMPLATE_LABEL) => Some(session::BUILTIN_TEMPLATE_NAME.to_owned()),
381 Some(choice) => Some(choice.to_owned()),
382 }
383}
384
385fn empty_select_message(
386 session_count: usize,
387 directory_count: usize,
388 zoxide_available: bool,
389) -> String {
390 match (session_count, directory_count, zoxide_available) {
391 (0, 0, true) => {
392 "nothing to select: tmux has no sessions, zoxide has no indexed directories, and folder search found no directories; run `smux connect <path>` or adjust `[settings.folder_search]`".to_owned()
393 }
394 (0, 0, false) => {
395 "nothing to select: tmux has no sessions, zoxide is unavailable, and folder search found no directories; run `smux connect <path>` or adjust `[settings.folder_search]`".to_owned()
396 }
397 _ => "nothing to select".to_owned(),
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::{empty_select_message, resolve_template_choice};
404 use crate::session;
405
406 #[test]
407 fn cancelling_template_choice_returns_none() {
408 assert_eq!(resolve_template_choice(None), None);
409 }
410
411 #[test]
412 fn builtin_template_choice_maps_to_builtin_template_name() {
413 assert_eq!(
414 resolve_template_choice(Some("<builtin>".to_owned())).as_deref(),
415 Some(session::BUILTIN_TEMPLATE_NAME)
416 );
417 }
418
419 #[test]
420 fn named_template_choice_is_preserved() {
421 assert_eq!(
422 resolve_template_choice(Some("rust".to_owned())).as_deref(),
423 Some("rust")
424 );
425 }
426
427 #[test]
428 fn empty_select_message_is_actionable_with_empty_sources() {
429 assert!(empty_select_message(0, 0, true).contains("smux connect <path>"));
430 assert!(empty_select_message(0, 0, true).contains("zoxide"));
431 assert!(empty_select_message(0, 0, true).contains("folder search"));
432 }
433
434 #[test]
435 fn empty_select_message_mentions_missing_zoxide() {
436 assert!(empty_select_message(0, 0, false).contains("zoxide is unavailable"));
437 }
438}