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;