Skip to main content

smux/
app.rs

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}