use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::cli::{Cli, Commands};
use crate::config;
use crate::docs;
use crate::doctor;
use crate::folder_search;
use crate::fzf;
use crate::project_export;
use crate::session;
use crate::tmux::Tmux;
use crate::ui::DisplayStyle;
use crate::util;
use crate::zoxide;
const BUILTIN_TEMPLATE_LABEL: &str = "<builtin>";
pub fn run(cli: Cli) -> Result<()> {
let tmux = Tmux::new();
match cli.command {
Commands::Select {
choose_template,
no_project_detect,
config,
} => {
let loaded = config::load_optional(config.as_deref())?;
run_select(
&tmux,
loaded,
config.as_deref(),
choose_template,
no_project_detect,
)
}
Commands::Connect {
path,
template,
session_name,
config,
} => {
let loaded = config::load_optional(config.as_deref())?;
session::connect_path(
&tmux,
&path,
loaded.as_ref(),
template.as_deref(),
session_name.as_deref(),
session::ProjectDetection::Enabled,
)
}
Commands::Switch { session } => session::switch_existing(&tmux, &session),
Commands::ListSessions => {
for session in tmux.list_sessions()? {
println!("{session}");
}
Ok(())
}
Commands::Doctor { fix, config } => doctor::run(config.as_deref(), fix),
Commands::SaveProject {
name,
session,
path,
stdout,
force,
config,
} => {
if let Some(path) = project_export::save_project(
&tmux,
&name,
session.as_deref(),
path.as_deref(),
stdout,
force,
config.as_deref(),
)? {
println!("{}", path.display());
}
Ok(())
}
Commands::ListTemplates { config } => {
let loaded = config::load(config.as_deref())?;
let mut names = loaded.config.templates.keys().cloned().collect::<Vec<_>>();
names.sort();
for name in names {
println!("{name}");
}
Ok(())
}
Commands::ListProjects { config } => {
let loaded = config::load_workspace(config.as_deref())?;
let mut names = loaded.projects.keys().cloned().collect::<Vec<_>>();
names.sort();
for name in names {
let project = &loaded.projects[&name];
let resolved = util::expand_and_normalize_path(Path::new(&project.path))?;
println!("{name}\t{}", resolved.display());
}
Ok(())
}
Commands::Init { config } => {
let path = config::init(config.as_deref())?;
println!("{}", path.display());
Ok(())
}
Commands::Completions { shell, dir } => {
if let Some(path) = docs::generate_completions(shell, dir.as_deref())? {
println!("{}", path.display());
}
Ok(())
}
Commands::Man { dir } => {
if let Some(paths) = docs::generate_man_pages(dir.as_deref())? {
for path in paths {
println!("{}", path.display());
}
}
Ok(())
}
}
}
fn run_select(
tmux: &Tmux,
mut loaded: Option<config::LoadedConfig>,
config_path: Option<&Path>,
choose_template: bool,
no_project_detect: bool,
) -> Result<()> {
let project_detection = if no_project_detect {
session::ProjectDetection::Disabled
} else {
session::ProjectDetection::Enabled
};
loop {
let config = loaded.as_ref().map(|loaded| &loaded.config);
let display_style = DisplayStyle::from_config(config);
let picker_bindings = config
.map(|config| config.settings.picker.bindings.clone())
.unwrap_or_default();
let picker_preview = config
.map(|config| config.settings.picker.preview.clone())
.unwrap_or_default();
let current_session = tmux.current_session().ok().flatten();
let entries = select_entries(
tmux,
loaded.as_ref(),
display_style,
current_session.as_deref(),
)?;
let Some(selection) = fzf::select(entries, &picker_bindings, &picker_preview)? else {
return Ok(());
};
match (selection.action, selection.entry.kind) {
(fzf::SelectAction::Open, fzf::EntryKind::Session) => {
return session::switch_existing(tmux, &selection.entry.value);
}
(fzf::SelectAction::Delete, fzf::EntryKind::Session) => {
if current_session.as_deref() == Some(selection.entry.value.as_str()) {
continue;
}
session::kill_existing(tmux, &selection.entry.value)?;
}
(fzf::SelectAction::Delete, fzf::EntryKind::Project)
| (fzf::SelectAction::Delete, fzf::EntryKind::InvalidProject) => {
match delete_project_from_picker(loaded.as_ref(), &selection.entry.value) {
Ok(path) => {
eprintln!("deleted project {}", path.display());
loaded = config::load_optional(config_path)?;
}
Err(error) => eprintln!("warning: {error:#}"),
}
}
(fzf::SelectAction::SaveProject, fzf::EntryKind::Session) => {
match save_project_from_picker(tmux, &selection.entry.value, config_path) {
Ok(Some(path)) => {
eprintln!("saved project {}", path.display());
loaded = config::load_optional(config_path)?;
}
Ok(None) => {}
Err(error) => eprintln!("warning: {error:#}"),
}
}
(fzf::SelectAction::Open, fzf::EntryKind::Directory) => {
let template = if choose_template {
let Some(template) = choose_template_name(config, display_style)? else {
return Ok(());
};
Some(template)
} else {
None
};
return session::connect_path(
tmux,
Path::new(&selection.entry.value),
loaded.as_ref(),
template.as_deref(),
None,
project_detection,
);
}
(fzf::SelectAction::Open, fzf::EntryKind::Project) => {
let loaded = loaded
.as_ref()
.context("project selection requires config or project files")?;
return session::connect_project(tmux, loaded, &selection.entry.value);
}
(fzf::SelectAction::Open, fzf::EntryKind::InvalidProject) => continue,
(fzf::SelectAction::Delete, _) => continue,
(fzf::SelectAction::SaveProject, _) => continue,
}
}
}
fn delete_project_from_picker(
loaded: Option<&config::LoadedConfig>,
project_name: &str,
) -> Result<PathBuf> {
let loaded = loaded.context("project deletion requires config or project files")?;
config::delete_project_file(loaded, project_name)
}
fn save_project_from_picker(
tmux: &Tmux,
session_name: &str,
config_path: Option<&Path>,
) -> Result<Option<PathBuf>> {
project_export::save_project(
tmux,
session_name,
Some(session_name),
None,
false,
false,
config_path,
)
}
fn select_entries(
tmux: &Tmux,
loaded: Option<&config::LoadedConfig>,
display_style: DisplayStyle,
current_session: Option<&str>,
) -> Result<Vec<fzf::Entry>> {
let mut entries = Vec::new();
let sessions = tmux.list_sessions()?;
let session_count = sessions.len();
for session in sessions {
let entry = if current_session == Some(session.as_str()) {
fzf::Entry {
kind: fzf::EntryKind::Session,
label: display_style.current_session_label(&session),
value: session,
preview: None,
}
} else {
fzf::Entry::session(display_style, session)
};
entries.push(entry);
}
if let Some(loaded) = loaded {
let mut project_names = loaded.projects.keys().cloned().collect::<Vec<_>>();
project_names.sort();
for project_name in project_names {
let preview = loaded
.project_files
.get(&project_name)
.map(|path| path.display().to_string());
let project = &loaded.projects[&project_name];
let label_value = project
.session_name
.as_deref()
.unwrap_or(&project_name)
.to_string();
entries.push(fzf::Entry::project(
display_style,
project_name,
label_value,
preview,
));
}
let mut invalid_projects = loaded.invalid_projects.clone();
invalid_projects.sort_by(|left, right| left.name.cmp(&right.name));
for project in invalid_projects {
entries.push(fzf::Entry::invalid_project(
display_style,
project.name,
&project.error,
Some(project.path.display().to_string()),
));
}
}
let mut zoxide_available = true;
let mut directory_count = 0;
let mut directory_keys = HashSet::new();
match zoxide::list_directories() {
Ok(directories) => {
for directory in directories {
if insert_directory_key(&mut directory_keys, &directory) {
directory_count += 1;
entries.push(fzf::Entry::directory(display_style, directory));
}
}
}
Err(error) => {
zoxide_available = false;
eprintln!("warning: {error:#}");
}
}
let folder_search_settings = loaded
.map(|loaded| loaded.config.settings.folder_search.clone())
.unwrap_or_default();
let result = folder_search::list_directories(&folder_search_settings);
for warning in result.warnings {
eprintln!(
"warning: folder search {}: {}",
warning.root, warning.message
);
}
for directory in result.directories {
if insert_directory_key(&mut directory_keys, &directory) {
directory_count += 1;
entries.push(fzf::Entry::directory(display_style, directory));
}
}
if entries.is_empty() {
bail!(
"{}",
empty_select_message(session_count, directory_count, zoxide_available)
);
}
Ok(entries)
}
fn insert_directory_key(seen: &mut HashSet<PathBuf>, directory: &str) -> bool {
let key = util::expand_and_normalize_path(Path::new(directory))
.unwrap_or_else(|_| PathBuf::from(directory));
seen.insert(key)
}
fn choose_template_name(
config: Option<&config::Config>,
display_style: DisplayStyle,
) -> Result<Option<String>> {
let mut template_names = config
.map(|config| config.templates.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
template_names.sort();
template_names.insert(0, BUILTIN_TEMPLATE_LABEL.to_owned());
let choices = template_names
.into_iter()
.map(|name| fzf::Choice::new("template", display_style.template_label(&name), name))
.collect();
Ok(resolve_template_choice(fzf::select_value(
"template> ",
choices,
)?))
}
fn resolve_template_choice(choice: Option<String>) -> Option<String> {
match choice.as_deref() {
None => None,
Some(BUILTIN_TEMPLATE_LABEL) => Some(session::BUILTIN_TEMPLATE_NAME.to_owned()),
Some(choice) => Some(choice.to_owned()),
}
}
fn empty_select_message(
session_count: usize,
directory_count: usize,
zoxide_available: bool,
) -> String {
match (session_count, directory_count, zoxide_available) {
(0, 0, true) => {
"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()
}
(0, 0, false) => {
"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()
}
_ => "nothing to select".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::{empty_select_message, resolve_template_choice};
use crate::session;
#[test]
fn cancelling_template_choice_returns_none() {
assert_eq!(resolve_template_choice(None), None);
}
#[test]
fn builtin_template_choice_maps_to_builtin_template_name() {
assert_eq!(
resolve_template_choice(Some("<builtin>".to_owned())).as_deref(),
Some(session::BUILTIN_TEMPLATE_NAME)
);
}
#[test]
fn named_template_choice_is_preserved() {
assert_eq!(
resolve_template_choice(Some("rust".to_owned())).as_deref(),
Some("rust")
);
}
#[test]
fn empty_select_message_is_actionable_with_empty_sources() {
assert!(empty_select_message(0, 0, true).contains("smux connect <path>"));
assert!(empty_select_message(0, 0, true).contains("zoxide"));
assert!(empty_select_message(0, 0, true).contains("folder search"));
}
#[test]
fn empty_select_message_mentions_missing_zoxide() {
assert!(empty_select_message(0, 0, false).contains("zoxide is unavailable"));
}
}