Skip to main content

tmux_workspace/
lib.rs

1#![doc = include_str!("../docs/library.md")]
2#![forbid(unsafe_code)]
3
4mod config;
5mod freeze;
6
7pub use config::{ConfigError, PaneConfig, ShellCommand, WindowConfig, Workspace};
8pub use freeze::freeze;
9
10use std::path::Path;
11
12use libtmux::plan::{
13    KillWindow, NewSession, NewWindow, PaneSlot, Pause, Plan, Planner, SelectLayout, SelectPane,
14    SelectWindow, SendKeys, SessionSlot, SetEnvironment, SetOption, Slot, SplitWindow, WindowSlot,
15};
16use libtmux::{Server, Session, SessionId};
17
18/// A failure while building a workspace.
19#[derive(Debug, thiserror::Error)]
20#[non_exhaustive]
21pub enum BuildError {
22    /// The workspace configuration could not be read.
23    #[error(transparent)]
24    Config(#[from] ConfigError),
25
26    /// tmux refused an operation, or could not be reached.
27    #[error(transparent)]
28    Tmux(#[from] libtmux::Error),
29
30    /// tmux created a session without the window it always creates.
31    ///
32    /// This cannot be reported as a libtmux error: its variants are
33    /// `#[non_exhaustive]`, so only that crate constructs them. A consumer
34    /// describes its own failures in its own vocabulary.
35    #[error("session {name} was created without its initial window")]
36    MissingInitialWindow {
37        /// The session that was created.
38        name: String,
39    },
40
41    /// tmux refused a step of the build.
42    #[error("building session {name} was refused: {detail}")]
43    Refused {
44        /// The session being built.
45        name: String,
46        /// What tmux said, or that it said nothing.
47        detail: String,
48    },
49
50    /// A session with the requested name already exists.
51    ///
52    /// Building into an existing session would interleave windows with
53    /// whatever is already there, so it is refused rather than guessed at.
54    #[error("a session named {name} already exists")]
55    SessionExists {
56        /// The name that was already taken.
57        name: String,
58    },
59}
60
61/// Creates tmux sessions from workspace configurations.
62#[derive(Debug)]
63pub struct WorkspaceBuilder<'server> {
64    server: &'server Server,
65}
66
67impl<'server> WorkspaceBuilder<'server> {
68    /// Build workspaces on one server.
69    #[must_use]
70    pub const fn new(server: &'server Server) -> Self {
71        Self { server }
72    }
73
74    /// Describe what building this workspace would do, without doing it.
75    ///
76    /// The returned plan is inert, so a caller can render it, count what it
77    /// costs, or explain it before anything reaches tmux. Every object a later
78    /// step addresses is a forward reference to the step that makes it, so the
79    /// whole file lowers without a single round trip to look an id up.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use tmux_workspace::{Workspace, WorkspaceBuilder};
85    ///
86    /// let workspace = Workspace::from_yaml(
87    ///     "
88    /// session_name: dev
89    /// windows:
90    ///   - window_name: editor
91    ///     panes:
92    ///       - vim
93    /// ",
94    /// )?;
95    ///
96    /// let server = libtmux::Server::builder()
97    ///     .socket_path("/tmp/libtmux-rs-test/plan-example.sock")
98    ///     .build()?;
99    /// let plan = WorkspaceBuilder::new(&server).plan(&workspace);
100    ///
101    /// // Nothing has run, but the first command is already known.
102    /// assert!(plan.preview()[0]
103    ///     .as_ref()
104    ///     .is_some_and(|command| command.summary().to_string().contains("new-session")));
105    /// # Ok::<(), tmux_workspace::BuildError>(())
106    /// ```
107    #[must_use]
108    pub fn plan(&self, workspace: &Workspace) -> Plan {
109        let mut plan = Plan::new();
110        let session = plan.add(Self::session_op(workspace));
111
112        // Environment and options land before any pane runs a command, so a
113        // command sees the environment the file describes rather than the one
114        // it happened to start in.
115        for (name, value) in &workspace.environment {
116            plan.add(SetEnvironment::new(session, name.as_str(), value.as_str()));
117        }
118        for (name, value) in &workspace.options {
119            plan.add(SetOption::session(session, name.as_str(), value.as_str()));
120        }
121        for (name, value) in &workspace.global_options {
122            plan.add(SetOption::global(name.as_str(), value.as_str()));
123        }
124
125        let mut focus_window = None;
126        for config in &workspace.windows {
127            let directory = config
128                .start_directory
129                .as_deref()
130                .or(workspace.start_directory.as_deref());
131            let first_pane = config.panes.first();
132            let first_directory = first_pane
133                .and_then(|pane| pane.start_directory.as_deref())
134                .or(directory);
135            let pane_environments = Self::pane_environments(workspace, config);
136            let window = plan.add(Self::window_op(
137                session,
138                config,
139                pane_environments.first().map_or(&[], Vec::as_slice),
140                first_directory,
141                first_pane.and_then(|pane| pane.shell.as_deref()),
142            ));
143            for (name, value) in &config.options {
144                plan.add(SetOption::window(window, name.as_str(), value.as_str()));
145            }
146
147            // A new window arrives holding exactly one pane, so the count is
148            // known rather than looked up: the first configured pane is that
149            // one, and the rest are splits.
150            //
151            // Each split targets the pane the previous one made
152            // (`SplitWindow::from_pane`): `-t <window>` always divides
153            // the active pane, which a detached split never changes.
154            let mut source = window.pane();
155            let mut panes = vec![source];
156            for (pane, environment) in config.panes.iter().zip(&pane_environments).skip(1) {
157                source = Self::split_op(
158                    &mut plan,
159                    window,
160                    source,
161                    config,
162                    directory,
163                    pane,
164                    environment,
165                );
166                panes.push(source);
167            }
168
169            // Layout is applied once the pane count is final, or tmux would
170            // rebalance it away on the next split.
171            if let Some(layout) = config.layout.as_deref().filter(|layout| !layout.is_empty()) {
172                plan.add(SelectLayout::new(window, layout));
173            }
174
175            let mut focus_pane = None;
176            for (pane, pane_config) in panes.iter().zip(&config.panes) {
177                // Narrowest wins: pane, then window, then workspace.
178                let suppress = pane_config
179                    .suppress_history
180                    .or(config.suppress_history)
181                    .unwrap_or(workspace.suppress_history);
182
183                // As in tmuxp, the `shell_command_before` commands lead the
184                // pane's own list, and a command's `enter` and sleeps hold
185                // for the rest.
186                let mut enter = pane_config.enter;
187                let mut sleep_before = pane_config.sleep_before;
188                let mut sleep_after = pane_config.sleep_after;
189                let commands = workspace
190                    .shell_command_before
191                    .iter()
192                    .chain(&config.shell_command_before)
193                    .chain(&pane_config.shell_command_before)
194                    .chain(&pane_config.shell_commands);
195                for command in commands {
196                    enter = command.enter.unwrap_or(enter);
197                    sleep_before = command.sleep_before.or(sleep_before);
198                    sleep_after = command.sleep_after.or(sleep_after);
199                    if let Some(duration) = sleep_before {
200                        plan.add(Pause::new(duration));
201                    }
202                    plan.add(Self::typing(*pane, &command.cmd, suppress, enter));
203                    if let Some(duration) = sleep_after {
204                        plan.add(Pause::new(duration));
205                    }
206                }
207                if pane_config.focus {
208                    focus_pane = Some(*pane);
209                }
210            }
211
212            if let Some(pane) = focus_pane {
213                plan.add(SelectPane::new(pane));
214            }
215            if config.focus {
216                focus_window = Some(window);
217            }
218        }
219
220        // Killed last: a session with no windows is a session tmux destroys,
221        // so this only happens once the configured windows exist. A workspace
222        // that names no windows keeps the one tmux made.
223        if !workspace.windows.is_empty() {
224            plan.add(KillWindow::new(session.window()));
225        }
226        if let Some(window) = focus_window {
227            plan.add(SelectWindow::new(window));
228        }
229
230        plan
231    }
232
233    /// Create the session a workspace describes, and return it.
234    ///
235    /// Windows and panes are created in the order the configuration lists
236    /// them. Nothing is attached: the caller decides whether to take over a
237    /// terminal.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error when a session of the same name exists, or when tmux
242    /// refuses any step.
243    pub async fn build(&self, workspace: &Workspace) -> Result<Session, BuildError> {
244        self.server
245            .validate_layouts(workspace.windows.iter().filter_map(|window| {
246                window
247                    .layout
248                    .as_deref()
249                    .filter(|layout| !layout.is_empty())
250                    .map(|layout| (std::ffi::OsStr::new(layout), window.panes.len().max(1)))
251            }))
252            .await?;
253        let plan = self.plan(workspace);
254        // Marked, because a workspace is mostly a creation followed by the
255        // typing that decorates it, which is the shape the fold is for.
256        let result = plan.run(self.server, Planner::Marked).await?;
257        if !result.is_complete() {
258            // Asked after the fact rather than checked before: a name can be
259            // taken between a check and a create, so tmux refusing is the only
260            // answer that cannot be stale.
261            let refusal = result
262                .steps()
263                .iter()
264                .find_map(libtmux::plan::StepOutcome::refusal);
265            if matches!(refusal.as_ref(), Some(libtmux::Error::SessionExists { .. })) {
266                return Err(BuildError::SessionExists {
267                    name: workspace.session_name.clone(),
268                });
269            }
270            return match refusal {
271                Some(error) if result.created(0).is_some() => {
272                    Err(error.after_effect("workspace-build").into())
273                }
274                Some(error) => Err(BuildError::Refused {
275                    name: workspace.session_name.clone(),
276                    detail: error.to_string(),
277                }),
278                None => Err(BuildError::Refused {
279                    name: workspace.session_name.clone(),
280                    detail: String::from("tmux refused a step without saying why"),
281                }),
282            };
283        }
284
285        let created = result
286            .created(0)
287            .and_then(|id| id.to_str())
288            .and_then(|id| id.parse::<SessionId>().ok())
289            .ok_or_else(|| BuildError::MissingInitialWindow {
290                name: workspace.session_name.clone(),
291            })?;
292        self.server
293            .session_by_id(&created)
294            .await
295            .map_err(|error| error.after_effect("workspace-build"))?
296            .ok_or_else(|| BuildError::MissingInitialWindow {
297                name: workspace.session_name.clone(),
298            })
299    }
300
301    fn session_op(workspace: &Workspace) -> NewSession {
302        // A workspace file is not this program's own text, and every sink
303        // below escapes what it is given, so a `#(command)` in one arrives as
304        // the characters someone typed rather than as a shell command.
305        let mut session = NewSession::new(workspace.session_name.as_str());
306        if let Some(directory) = workspace.start_directory.as_deref() {
307            session = session.start_directory(directory);
308        }
309        session
310    }
311
312    fn window_op(
313        session: Slot<SessionSlot>,
314        config: &WindowConfig,
315        environment: &[(String, String)],
316        directory: Option<&Path>,
317        pane_shell: Option<&str>,
318    ) -> NewWindow {
319        let mut window = NewWindow::new(session);
320        if let Some(name) = config.window_name.as_deref() {
321            window = window.name(name);
322        }
323        if let Some(directory) = directory {
324            window = window.start_directory(directory);
325        }
326        if let Ok(index) = u32::try_from(config.window_index.unwrap_or(-1)) {
327            window = window.index(index);
328        }
329        // tmuxp's window_shell replaces the window's shell rather than being
330        // typed into it, so the window closes when the command ends; the
331        // first pane's own `shell` overrides it, the same as for a split.
332        if let Some(shell) = pane_shell.or(config.window_shell.as_deref()) {
333            window = window.command(shell);
334        }
335        for (name, value) in environment {
336            window = window.environment(name.as_str(), value.as_str());
337        }
338        window
339    }
340
341    /// Split `source` into a new pane of `window`, and rebalance the layout.
342    ///
343    /// Halving each pane in turn runs out of rows before the fifth at a
344    /// default terminal size; rebalancing after every split reclaims them.
345    /// The window's own layout, applied once the pane count is final, still
346    /// has the last say. `window_shell` is the default shell for every pane
347    /// in the window, not only the one that comes with it, unless the pane
348    /// sets its own [`PaneConfig::shell`].
349    fn split_op(
350        plan: &mut Plan,
351        window: Slot<WindowSlot>,
352        source: Slot<PaneSlot>,
353        config: &WindowConfig,
354        directory: Option<&Path>,
355        pane: &PaneConfig,
356        environment: &[(String, String)],
357    ) -> Slot<PaneSlot> {
358        let directory = pane.start_directory.as_deref().or(directory);
359        let mut split = SplitWindow::from_pane(source);
360        if let Some(directory) = directory {
361            split = split.start_directory(directory);
362        }
363        for (name, value) in environment {
364            split = split.environment(name.as_str(), value.as_str());
365        }
366        if let Some(shell) = pane.shell.as_deref().or(config.window_shell.as_deref()) {
367            split = split.command(shell);
368        }
369        let pane = plan.add(split);
370        plan.add(SelectLayout::new(window, "tiled"));
371        pane
372    }
373
374    /// Each pane's [`Self::pane_environment`] for a window, in order.
375    fn pane_environments(
376        workspace: &Workspace,
377        config: &WindowConfig,
378    ) -> Vec<Vec<(String, String)>> {
379        config
380            .panes
381            .iter()
382            .map(|pane_config| Self::pane_environment(workspace, config, pane_config))
383            .collect()
384    }
385
386    /// This pane's own environment if it set one, else the window's, plus
387    /// the loader's value of every `$NAME` its commands -- and its shell,
388    /// or the window's when it sets none of its own -- reference, when the
389    /// document does not set that name and the loader has it.
390    ///
391    /// tmuxp pastes the value into the command instead, so a value holding
392    /// `;` or `$(...)` would run as a command of its own; here it stays data
393    /// in the environment for the pane's own shell to expand.
394    fn pane_environment(
395        workspace: &Workspace,
396        config: &WindowConfig,
397        pane_config: &PaneConfig,
398    ) -> Vec<(String, String)> {
399        let mut environment = pane_config
400            .environment
401            .clone()
402            .unwrap_or_else(|| config.environment.clone());
403        let commands = workspace
404            .shell_command_before
405            .iter()
406            .chain(&config.shell_command_before)
407            .chain(&pane_config.shell_command_before)
408            .chain(&pane_config.shell_commands)
409            .map(|command| command.cmd.as_str());
410        let shell = pane_config
411            .shell
412            .as_deref()
413            .or(config.window_shell.as_deref());
414        for name in commands.chain(shell).flat_map(referenced) {
415            if environment.iter().any(|(set, _)| set == name) {
416                continue;
417            }
418            if let Ok(value) = std::env::var(name) {
419                environment.push((name.to_owned(), value));
420            }
421        }
422        environment
423    }
424
425    /// Type one command into a pane, optionally running it.
426    ///
427    /// A leading space keeps the command out of shell history, which is what
428    /// tmuxp's `suppress_history` means. It only works for shells configured
429    /// to ignore space-prefixed commands, which is the same caveat tmuxp has.
430    ///
431    /// `enter: false` types the command and leaves it, so a workspace can set
432    /// something up for the user to read before running.
433    fn typing(
434        pane: Slot<PaneSlot>,
435        command: &str,
436        suppress_history: bool,
437        enter: bool,
438    ) -> SendKeys {
439        let text = if suppress_history {
440            format!(" {command}")
441        } else {
442            command.to_owned()
443        };
444        let keys = SendKeys::new(pane).text(text);
445        if enter { keys.enter() } else { keys }
446    }
447}
448
449/// The variables `text` names as `$NAME` or `${NAME...}`, for a shell to
450/// expand. A braced form counts by its leading name, as in `${NAME:-default}`.
451fn referenced(text: &str) -> impl Iterator<Item = &str> {
452    text.match_indices('$').filter_map(|(index, _)| {
453        let tail = &text[index + 1..];
454        let tail = tail.strip_prefix('{').unwrap_or(tail);
455        let end = tail
456            .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
457            .unwrap_or(tail.len());
458        let name = &tail[..end];
459        name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
460            .then_some(name)
461    })
462}
463
464/// Compiles the `libtmux-macros` README's examples, and nothing else.
465///
466/// It cannot be compiled from `libtmux-macros`, whose only dependency on
467/// `libtmux` is deliberately renamed so the UI tests prove the derive resolves
468/// the crate. Here `libtmux` is an ordinary dependency under its own name,
469/// which is the case a reader of that README is in.
470#[cfg(doctest)]
471#[doc = include_str!("../libtmux-macros-README.md")]
472pub struct MacrosReadme;
473
474/// The `tmux-workspace` command's reference, rendered with the library's
475/// documentation so it is readable on docs.rs and a link to it is checked
476/// when the documentation builds. Documentation only; it holds no items.
477#[cfg(doc)]
478#[doc = include_str!("../docs/cli.md")]
479pub mod command {}
480
481/// Compiles the README's Rust blocks. The README is the command's page on
482/// crates.io, not this crate's documentation, so it renders nowhere here.
483#[cfg(doctest)]
484#[doc = include_str!("../README.md")]
485pub struct Readme;