#![doc = include_str!("../docs/library.md")]
#![forbid(unsafe_code)]
mod config;
mod freeze;
pub use config::{ConfigError, PaneConfig, ShellCommand, WindowConfig, Workspace};
pub use freeze::freeze;
use std::path::Path;
use libtmux::plan::{
KillWindow, NewSession, NewWindow, PaneSlot, Pause, Plan, Planner, SelectLayout, SelectPane,
SelectWindow, SendKeys, SessionSlot, SetEnvironment, SetOption, Slot, SplitWindow, WindowSlot,
};
use libtmux::{Server, Session, SessionId};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BuildError {
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Tmux(#[from] libtmux::Error),
#[error("session {name} was created without its initial window")]
MissingInitialWindow {
name: String,
},
#[error("building session {name} was refused: {detail}")]
Refused {
name: String,
detail: String,
},
#[error("a session named {name} already exists")]
SessionExists {
name: String,
},
}
#[derive(Debug)]
pub struct WorkspaceBuilder<'server> {
server: &'server Server,
}
impl<'server> WorkspaceBuilder<'server> {
#[must_use]
pub const fn new(server: &'server Server) -> Self {
Self { server }
}
#[must_use]
pub fn plan(&self, workspace: &Workspace) -> Plan {
let mut plan = Plan::new();
let session = plan.add(Self::session_op(workspace));
for (name, value) in &workspace.environment {
plan.add(SetEnvironment::new(session, name.as_str(), value.as_str()));
}
for (name, value) in &workspace.options {
plan.add(SetOption::session(session, name.as_str(), value.as_str()));
}
for (name, value) in &workspace.global_options {
plan.add(SetOption::global(name.as_str(), value.as_str()));
}
let mut focus_window = None;
for config in &workspace.windows {
let directory = config
.start_directory
.as_deref()
.or(workspace.start_directory.as_deref());
let first_pane = config.panes.first();
let first_directory = first_pane
.and_then(|pane| pane.start_directory.as_deref())
.or(directory);
let pane_environments = Self::pane_environments(workspace, config);
let window = plan.add(Self::window_op(
session,
config,
pane_environments.first().map_or(&[], Vec::as_slice),
first_directory,
first_pane.and_then(|pane| pane.shell.as_deref()),
));
for (name, value) in &config.options {
plan.add(SetOption::window(window, name.as_str(), value.as_str()));
}
let mut source = window.pane();
let mut panes = vec![source];
for (pane, environment) in config.panes.iter().zip(&pane_environments).skip(1) {
source = Self::split_op(
&mut plan,
window,
source,
config,
directory,
pane,
environment,
);
panes.push(source);
}
if let Some(layout) = config.layout.as_deref().filter(|layout| !layout.is_empty()) {
plan.add(SelectLayout::new(window, layout));
}
let mut focus_pane = None;
for (pane, pane_config) in panes.iter().zip(&config.panes) {
let suppress = pane_config
.suppress_history
.or(config.suppress_history)
.unwrap_or(workspace.suppress_history);
let mut enter = pane_config.enter;
let mut sleep_before = pane_config.sleep_before;
let mut sleep_after = pane_config.sleep_after;
let commands = workspace
.shell_command_before
.iter()
.chain(&config.shell_command_before)
.chain(&pane_config.shell_command_before)
.chain(&pane_config.shell_commands);
for command in commands {
enter = command.enter.unwrap_or(enter);
sleep_before = command.sleep_before.or(sleep_before);
sleep_after = command.sleep_after.or(sleep_after);
if let Some(duration) = sleep_before {
plan.add(Pause::new(duration));
}
plan.add(Self::typing(*pane, &command.cmd, suppress, enter));
if let Some(duration) = sleep_after {
plan.add(Pause::new(duration));
}
}
if pane_config.focus {
focus_pane = Some(*pane);
}
}
if let Some(pane) = focus_pane {
plan.add(SelectPane::new(pane));
}
if config.focus {
focus_window = Some(window);
}
}
if !workspace.windows.is_empty() {
plan.add(KillWindow::new(session.window()));
}
if let Some(window) = focus_window {
plan.add(SelectWindow::new(window));
}
plan
}
pub async fn build(&self, workspace: &Workspace) -> Result<Session, BuildError> {
self.server
.validate_layouts(workspace.windows.iter().filter_map(|window| {
window
.layout
.as_deref()
.filter(|layout| !layout.is_empty())
.map(|layout| (std::ffi::OsStr::new(layout), window.panes.len().max(1)))
}))
.await?;
let plan = self.plan(workspace);
let result = plan.run(self.server, Planner::Marked).await?;
if !result.is_complete() {
let refusal = result
.steps()
.iter()
.find_map(libtmux::plan::StepOutcome::refusal);
if matches!(refusal.as_ref(), Some(libtmux::Error::SessionExists { .. })) {
return Err(BuildError::SessionExists {
name: workspace.session_name.clone(),
});
}
return match refusal {
Some(error) if result.created(0).is_some() => {
Err(error.after_effect("workspace-build").into())
}
Some(error) => Err(BuildError::Refused {
name: workspace.session_name.clone(),
detail: error.to_string(),
}),
None => Err(BuildError::Refused {
name: workspace.session_name.clone(),
detail: String::from("tmux refused a step without saying why"),
}),
};
}
let created = result
.created(0)
.and_then(|id| id.to_str())
.and_then(|id| id.parse::<SessionId>().ok())
.ok_or_else(|| BuildError::MissingInitialWindow {
name: workspace.session_name.clone(),
})?;
self.server
.session_by_id(&created)
.await
.map_err(|error| error.after_effect("workspace-build"))?
.ok_or_else(|| BuildError::MissingInitialWindow {
name: workspace.session_name.clone(),
})
}
fn session_op(workspace: &Workspace) -> NewSession {
let mut session = NewSession::new(workspace.session_name.as_str());
if let Some(directory) = workspace.start_directory.as_deref() {
session = session.start_directory(directory);
}
session
}
fn window_op(
session: Slot<SessionSlot>,
config: &WindowConfig,
environment: &[(String, String)],
directory: Option<&Path>,
pane_shell: Option<&str>,
) -> NewWindow {
let mut window = NewWindow::new(session);
if let Some(name) = config.window_name.as_deref() {
window = window.name(name);
}
if let Some(directory) = directory {
window = window.start_directory(directory);
}
if let Ok(index) = u32::try_from(config.window_index.unwrap_or(-1)) {
window = window.index(index);
}
if let Some(shell) = pane_shell.or(config.window_shell.as_deref()) {
window = window.command(shell);
}
for (name, value) in environment {
window = window.environment(name.as_str(), value.as_str());
}
window
}
fn split_op(
plan: &mut Plan,
window: Slot<WindowSlot>,
source: Slot<PaneSlot>,
config: &WindowConfig,
directory: Option<&Path>,
pane: &PaneConfig,
environment: &[(String, String)],
) -> Slot<PaneSlot> {
let directory = pane.start_directory.as_deref().or(directory);
let mut split = SplitWindow::from_pane(source);
if let Some(directory) = directory {
split = split.start_directory(directory);
}
for (name, value) in environment {
split = split.environment(name.as_str(), value.as_str());
}
if let Some(shell) = pane.shell.as_deref().or(config.window_shell.as_deref()) {
split = split.command(shell);
}
let pane = plan.add(split);
plan.add(SelectLayout::new(window, "tiled"));
pane
}
fn pane_environments(
workspace: &Workspace,
config: &WindowConfig,
) -> Vec<Vec<(String, String)>> {
config
.panes
.iter()
.map(|pane_config| Self::pane_environment(workspace, config, pane_config))
.collect()
}
fn pane_environment(
workspace: &Workspace,
config: &WindowConfig,
pane_config: &PaneConfig,
) -> Vec<(String, String)> {
let mut environment = pane_config
.environment
.clone()
.unwrap_or_else(|| config.environment.clone());
let commands = workspace
.shell_command_before
.iter()
.chain(&config.shell_command_before)
.chain(&pane_config.shell_command_before)
.chain(&pane_config.shell_commands)
.map(|command| command.cmd.as_str());
let shell = pane_config
.shell
.as_deref()
.or(config.window_shell.as_deref());
for name in commands.chain(shell).flat_map(referenced) {
if environment.iter().any(|(set, _)| set == name) {
continue;
}
if let Ok(value) = std::env::var(name) {
environment.push((name.to_owned(), value));
}
}
environment
}
fn typing(
pane: Slot<PaneSlot>,
command: &str,
suppress_history: bool,
enter: bool,
) -> SendKeys {
let text = if suppress_history {
format!(" {command}")
} else {
command.to_owned()
};
let keys = SendKeys::new(pane).text(text);
if enter { keys.enter() } else { keys }
}
}
fn referenced(text: &str) -> impl Iterator<Item = &str> {
text.match_indices('$').filter_map(|(index, _)| {
let tail = &text[index + 1..];
let tail = tail.strip_prefix('{').unwrap_or(tail);
let end = tail
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.unwrap_or(tail.len());
let name = &tail[..end];
name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
.then_some(name)
})
}
#[cfg(doctest)]
#[doc = include_str!("../libtmux-macros-README.md")]
pub struct MacrosReadme;
#[cfg(doc)]
#[doc = include_str!("../docs/cli.md")]
pub mod command {}
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct Readme;