Skip to main content

rmux_sdk/
pane_set.rs

1//! Pane group helpers built from ordinary [`Pane`] handles.
2//!
3//! `PaneSet` is SDK-side composition. It does not add daemon-side batching or
4//! atomic cross-pane ordering; it gives callers a small, typed surface for
5//! common fan-out and fan-in workflows while preserving per-pane results.
6
7use std::future::{Future, IntoFuture};
8use std::pin::Pin;
9use std::time::Duration;
10
11use tokio::task::JoinSet;
12
13use crate::{
14    BroadcastResult, Input, Pane, PaneCloseOutcome, PaneId, PaneRef, PaneSnapshot, Result,
15    RmuxError,
16};
17
18/// Owned group of pane handles.
19#[derive(Debug, Clone, Default)]
20#[must_use = "pane sets do nothing unless one of their async methods is awaited"]
21pub struct PaneSet {
22    panes: Vec<Pane>,
23}
24
25impl PaneSet {
26    /// Creates a pane set from pane handles.
27    ///
28    /// Preserves caller order exactly. It does not deduplicate repeated pane
29    /// handles and may contain panes from different daemon endpoints.
30    pub fn new<I>(panes: I) -> Self
31    where
32        I: IntoIterator<Item = Pane>,
33    {
34        Self {
35            panes: panes.into_iter().collect(),
36        }
37    }
38
39    /// Returns the panes in their caller-provided order.
40    #[must_use]
41    pub fn panes(&self) -> &[Pane] {
42        &self.panes
43    }
44
45    /// Returns the number of panes in the set.
46    #[must_use]
47    pub fn len(&self) -> usize {
48        self.panes.len()
49    }
50
51    /// Returns true when the set contains no panes.
52    #[must_use]
53    pub fn is_empty(&self) -> bool {
54        self.panes.is_empty()
55    }
56
57    /// Broadcasts text or one key token to every pane.
58    ///
59    /// This delegates to the client-side broadcast implementation and returns
60    /// the same partial-broadcast error when at least one pane rejects the input.
61    pub async fn broadcast(&self, input: Input<'_>) -> Result<BroadcastResult> {
62        crate::broadcast::broadcast(&self.panes, input).await
63    }
64
65    /// Captures one fresh snapshot per pane.
66    ///
67    /// The returned batch always contains per-pane successes and failures.
68    /// Call [`PaneSetBatch::is_success`] when the caller requires every pane
69    /// to succeed.
70    pub async fn snapshot_all(&self) -> PaneSetBatch<PaneSnapshot> {
71        run_all(
72            self.panes.clone(),
73            |pane| async move { pane.snapshot().await },
74        )
75        .await
76    }
77
78    /// Closes every pane by consuming this pane set.
79    ///
80    /// Stale panes use the ordinary [`Pane::close`] idempotent semantics and
81    /// return [`PaneCloseOutcome::AlreadyClosed`] as a success.
82    pub async fn close_all(self) -> PaneSetBatch<PaneCloseOutcome> {
83        run_all(self.panes, |pane| async move { pane.close().await }).await
84    }
85
86    /// Starts an all-panes visible-text expectation builder.
87    #[must_use]
88    pub fn expect_all(&self) -> PaneSetExpectation<'_> {
89        PaneSetExpectation {
90            panes: &self.panes,
91            mode: ExpectMode::All,
92        }
93    }
94
95    /// Starts an any-pane visible-text expectation builder.
96    #[must_use]
97    pub fn expect_any(&self) -> PaneSetExpectation<'_> {
98        PaneSetExpectation {
99            panes: &self.panes,
100            mode: ExpectMode::Any,
101        }
102    }
103
104    /// Alias for [`Self::expect_all`].
105    #[must_use]
106    pub fn wait_all(&self) -> PaneSetExpectation<'_> {
107        self.expect_all()
108    }
109
110    /// Alias for [`Self::expect_any`].
111    #[must_use]
112    pub fn wait_any(&self) -> PaneSetExpectation<'_> {
113        self.expect_any()
114    }
115}
116
117impl From<Vec<Pane>> for PaneSet {
118    fn from(panes: Vec<Pane>) -> Self {
119        Self { panes }
120    }
121}
122
123impl FromIterator<Pane> for PaneSet {
124    fn from_iter<T: IntoIterator<Item = Pane>>(iter: T) -> Self {
125        Self::new(iter)
126    }
127}
128
129impl IntoIterator for PaneSet {
130    type Item = Pane;
131    type IntoIter = std::vec::IntoIter<Pane>;
132
133    fn into_iter(self) -> Self::IntoIter {
134        self.panes.into_iter()
135    }
136}
137
138/// Successful result for one pane in a [`PaneSet`] batch.
139#[derive(Debug)]
140pub struct PaneSetSuccess<T> {
141    target: PaneRef,
142    pane_id: Option<PaneId>,
143    value: T,
144}
145
146impl<T> PaneSetSuccess<T> {
147    fn new(target: PaneRef, pane_id: Option<PaneId>, value: T) -> Self {
148        Self {
149            target,
150            pane_id,
151            value,
152        }
153    }
154
155    /// Returns the slot target observed before the operation.
156    #[must_use]
157    pub const fn target(&self) -> &PaneRef {
158        &self.target
159    }
160
161    /// Returns the pane id observed before the operation, when available.
162    #[must_use]
163    pub const fn pane_id(&self) -> Option<PaneId> {
164        self.pane_id
165    }
166
167    /// Returns the operation result value.
168    #[must_use]
169    pub const fn value(&self) -> &T {
170        &self.value
171    }
172
173    /// Consumes the success and returns the operation result value.
174    pub fn into_value(self) -> T {
175        self.value
176    }
177}
178
179/// Failed result for one pane in a [`PaneSet`] batch.
180#[derive(Debug)]
181pub struct PaneSetFailure {
182    target: PaneRef,
183    pane_id: Option<PaneId>,
184    error: RmuxError,
185}
186
187impl PaneSetFailure {
188    fn new(target: PaneRef, pane_id: Option<PaneId>, error: RmuxError) -> Self {
189        Self {
190            target,
191            pane_id,
192            error,
193        }
194    }
195
196    /// Returns the slot target observed before the operation.
197    #[must_use]
198    pub const fn target(&self) -> &PaneRef {
199        &self.target
200    }
201
202    /// Returns the pane id observed before the operation, when available.
203    #[must_use]
204    pub const fn pane_id(&self) -> Option<PaneId> {
205        self.pane_id
206    }
207
208    /// Returns the per-pane error.
209    #[must_use]
210    pub const fn error(&self) -> &RmuxError {
211        &self.error
212    }
213
214    /// Consumes the failure and returns the per-pane error.
215    pub fn into_error(self) -> RmuxError {
216        self.error
217    }
218}
219
220/// Per-pane results for a group operation that targets every pane.
221#[derive(Debug)]
222pub struct PaneSetBatch<T> {
223    successes: Vec<PaneSetSuccess<T>>,
224    failures: Vec<PaneSetFailure>,
225}
226
227impl<T> PaneSetBatch<T> {
228    fn new(successes: Vec<PaneSetSuccess<T>>, failures: Vec<PaneSetFailure>) -> Self {
229        Self {
230            successes,
231            failures,
232        }
233    }
234
235    /// Returns true when every targeted pane succeeded.
236    #[must_use]
237    pub fn is_success(&self) -> bool {
238        self.failures.is_empty()
239    }
240
241    /// Returns successful per-pane results.
242    #[must_use]
243    pub fn successes(&self) -> &[PaneSetSuccess<T>] {
244        &self.successes
245    }
246
247    /// Returns failed per-pane results.
248    #[must_use]
249    pub fn failures(&self) -> &[PaneSetFailure] {
250        &self.failures
251    }
252
253    /// Returns the total number of panes targeted by the batch.
254    #[must_use]
255    pub fn len(&self) -> usize {
256        self.successes.len() + self.failures.len()
257    }
258
259    /// Returns true when the batch targeted no panes.
260    #[must_use]
261    pub fn is_empty(&self) -> bool {
262        self.successes.is_empty() && self.failures.is_empty()
263    }
264}
265
266/// Per-pane results for an any-pane wait.
267#[derive(Debug)]
268pub struct PaneSetAny<T> {
269    success: Option<PaneSetSuccess<T>>,
270    failures: Vec<PaneSetFailure>,
271}
272
273impl<T> PaneSetAny<T> {
274    fn from_success(success: PaneSetSuccess<T>, failures: Vec<PaneSetFailure>) -> Self {
275        Self {
276            success: Some(success),
277            failures,
278        }
279    }
280
281    fn failure(failures: Vec<PaneSetFailure>) -> Self {
282        Self {
283            success: None,
284            failures,
285        }
286    }
287
288    /// Returns true when at least one pane satisfied the wait.
289    #[must_use]
290    pub fn matched(&self) -> bool {
291        self.success.is_some()
292    }
293
294    /// Returns the successful pane result, if any.
295    #[must_use]
296    pub const fn success(&self) -> Option<&PaneSetSuccess<T>> {
297        self.success.as_ref()
298    }
299
300    /// Returns failures observed before the first match, or all failures when
301    /// no pane matched.
302    #[must_use]
303    pub fn failures(&self) -> &[PaneSetFailure] {
304        &self.failures
305    }
306}
307
308/// Visible-text expectation builder for a [`PaneSet`].
309#[derive(Debug, Clone, Copy)]
310pub struct PaneSetExpectation<'a> {
311    panes: &'a [Pane],
312    mode: ExpectMode,
313}
314
315impl<'a> PaneSetExpectation<'a> {
316    /// Waits until visible text on the selected pane set contains any literal.
317    pub fn visible_text_matches_any<I, S>(self, patterns: I) -> PaneSetVisibleTextWait<'a>
318    where
319        I: IntoIterator<Item = S>,
320        S: Into<String>,
321    {
322        PaneSetVisibleTextWait::new(
323            self.panes,
324            self.mode,
325            VisibleSetMatcher::Any(patterns.into_iter().map(Into::into).collect()),
326        )
327    }
328
329    /// Waits until visible text on the selected pane set contains all
330    /// literals.
331    pub fn visible_text_matches_all<I, S>(self, patterns: I) -> PaneSetVisibleTextWait<'a>
332    where
333        I: IntoIterator<Item = S>,
334        S: Into<String>,
335    {
336        PaneSetVisibleTextWait::new(
337            self.panes,
338            self.mode,
339            VisibleSetMatcher::All(patterns.into_iter().map(Into::into).collect()),
340        )
341    }
342
343    /// Waits until visible text on the selected pane set contains one
344    /// literal.
345    pub fn visible_text_contains(self, pattern: impl Into<String>) -> PaneSetVisibleTextWait<'a> {
346        PaneSetVisibleTextWait::new(
347            self.panes,
348            self.mode,
349            VisibleSetMatcher::Contains(pattern.into()),
350        )
351    }
352}
353
354/// Awaitable visible-text wait over a [`PaneSet`].
355#[derive(Debug)]
356#[must_use = "pane-set visible waits do nothing unless awaited"]
357pub struct PaneSetVisibleTextWait<'a> {
358    panes: &'a [Pane],
359    mode: ExpectMode,
360    matcher: VisibleSetMatcher,
361    timeout: Option<Duration>,
362    poll_interval: Option<Duration>,
363}
364
365impl<'a> PaneSetVisibleTextWait<'a> {
366    fn new(panes: &'a [Pane], mode: ExpectMode, matcher: VisibleSetMatcher) -> Self {
367        Self {
368            panes,
369            mode,
370            matcher,
371            timeout: None,
372            poll_interval: None,
373        }
374    }
375
376    /// Overrides the timeout used by each per-pane visible wait.
377    pub const fn timeout(mut self, timeout: Duration) -> Self {
378        self.timeout = Some(timeout);
379        self
380    }
381
382    /// Overrides the polling interval used by each per-pane visible wait.
383    pub const fn poll_interval(mut self, interval: Duration) -> Self {
384        self.poll_interval = Some(interval);
385        self
386    }
387
388    async fn run(self) -> PaneSetVisibleTextOutcome {
389        match self.mode {
390            ExpectMode::All => {
391                let matcher = self.matcher;
392                let timeout = self.timeout;
393                let poll_interval = self.poll_interval;
394                PaneSetVisibleTextOutcome::All(
395                    run_all(self.panes.to_vec(), move |pane| {
396                        let matcher = matcher.clone();
397                        async move { wait_visible_text(pane, matcher, timeout, poll_interval).await }
398                    })
399                    .await,
400                )
401            }
402            ExpectMode::Any => PaneSetVisibleTextOutcome::Any(self.run_any().await),
403        }
404    }
405
406    async fn run_any(self) -> PaneSetAny<PaneSnapshot> {
407        let mut tasks = JoinSet::new();
408        for pane in self.panes.iter().cloned() {
409            let matcher = self.matcher.clone();
410            let timeout = self.timeout;
411            let poll_interval = self.poll_interval;
412            tasks.spawn(async move {
413                let target = pane.target().clone();
414                let pane_id = pane.id().await.ok().flatten();
415                let result = wait_visible_text(pane, matcher, timeout, poll_interval).await;
416                (target, pane_id, result)
417            });
418        }
419
420        let mut failures = Vec::new();
421        while let Some(joined) = tasks.join_next().await {
422            let (target, pane_id, result) = match joined {
423                Ok(outcome) => outcome,
424                Err(error) => {
425                    failures.push(join_failure(error));
426                    continue;
427                }
428            };
429            match result {
430                Ok(snapshot) => {
431                    tasks.abort_all();
432                    return PaneSetAny::from_success(
433                        PaneSetSuccess::new(target, pane_id, snapshot),
434                        failures,
435                    );
436                }
437                Err(error) => failures.push(PaneSetFailure::new(target, pane_id, error)),
438            }
439        }
440        PaneSetAny::failure(failures)
441    }
442}
443
444impl<'a> IntoFuture for PaneSetVisibleTextWait<'a> {
445    type Output = PaneSetVisibleTextOutcome;
446    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send + 'a>>;
447
448    fn into_future(self) -> Self::IntoFuture {
449        Box::pin(self.run())
450    }
451}
452
453/// Result of awaiting a [`PaneSetVisibleTextWait`].
454#[derive(Debug)]
455#[non_exhaustive]
456pub enum PaneSetVisibleTextOutcome {
457    /// Result for an all-panes wait.
458    All(PaneSetBatch<PaneSnapshot>),
459    /// Result for an any-pane wait.
460    Any(PaneSetAny<PaneSnapshot>),
461}
462
463impl PaneSetVisibleTextOutcome {
464    /// Returns the all-panes batch when this outcome came from
465    /// [`PaneSet::expect_all`] or [`PaneSet::wait_all`].
466    #[must_use]
467    pub const fn all(&self) -> Option<&PaneSetBatch<PaneSnapshot>> {
468        match self {
469            Self::All(batch) => Some(batch),
470            Self::Any(_) => None,
471        }
472    }
473
474    /// Returns the any-pane result when this outcome came from
475    /// [`PaneSet::expect_any`] or [`PaneSet::wait_any`].
476    #[must_use]
477    pub const fn any(&self) -> Option<&PaneSetAny<PaneSnapshot>> {
478        match self {
479            Self::Any(result) => Some(result),
480            Self::All(_) => None,
481        }
482    }
483}
484
485#[derive(Debug, Clone, Copy)]
486enum ExpectMode {
487    All,
488    Any,
489}
490
491#[derive(Debug, Clone)]
492enum VisibleSetMatcher {
493    Contains(String),
494    Any(Vec<String>),
495    All(Vec<String>),
496}
497
498async fn wait_visible_text(
499    pane: Pane,
500    matcher: VisibleSetMatcher,
501    timeout: Option<Duration>,
502    poll_interval: Option<Duration>,
503) -> Result<PaneSnapshot> {
504    match matcher {
505        VisibleSetMatcher::Contains(pattern) => {
506            let wait = pane.expect_visible_text().to_contain(pattern);
507            apply_visible_options(wait, timeout, poll_interval).await
508        }
509        VisibleSetMatcher::Any(patterns) => {
510            let wait = pane.expect_visible_text().to_match_any(patterns);
511            apply_visible_options(wait, timeout, poll_interval).await
512        }
513        VisibleSetMatcher::All(patterns) => {
514            let wait = pane.expect_visible_text().to_match_all(patterns);
515            apply_visible_options(wait, timeout, poll_interval).await
516        }
517    }
518}
519
520async fn apply_visible_options(
521    mut wait: crate::VisibleTextWait<'_>,
522    timeout: Option<Duration>,
523    poll_interval: Option<Duration>,
524) -> Result<PaneSnapshot> {
525    if let Some(timeout) = timeout {
526        wait = wait.timeout(timeout);
527    }
528    if let Some(poll_interval) = poll_interval {
529        wait = wait.poll_interval(poll_interval);
530    }
531    wait.await
532}
533
534async fn run_all<T, Fut>(
535    panes: Vec<Pane>,
536    operation: impl Fn(Pane) -> Fut + Clone + Send + Sync + 'static,
537) -> PaneSetBatch<T>
538where
539    T: Send + 'static,
540    Fut: Future<Output = Result<T>> + Send + 'static,
541{
542    let mut tasks = JoinSet::new();
543    for (index, pane) in panes.into_iter().enumerate() {
544        let operation = operation.clone();
545        tasks.spawn(async move {
546            let target = pane.target().clone();
547            let pane_id = pane.id().await.ok().flatten();
548            let result = operation(pane).await;
549            (index, target, pane_id, result)
550        });
551    }
552
553    let mut outcomes = Vec::new();
554    while let Some(joined) = tasks.join_next().await {
555        match joined {
556            Ok(outcome) => outcomes.push(outcome),
557            Err(error) => outcomes.push((
558                usize::MAX,
559                PaneRef::new(
560                    crate::SessionName::new("unknown").expect("static session name"),
561                    0,
562                    0,
563                ),
564                None,
565                Err(RmuxError::transport(
566                    "join pane-set worker task",
567                    std::io::Error::other(error.to_string()),
568                )),
569            )),
570        }
571    }
572    outcomes.sort_by_key(|(index, _, _, _)| *index);
573
574    let mut successes = Vec::new();
575    let mut failures = Vec::new();
576    for (_, target, pane_id, result) in outcomes {
577        match result {
578            Ok(value) => successes.push(PaneSetSuccess::new(target, pane_id, value)),
579            Err(error) => failures.push(PaneSetFailure::new(target, pane_id, error)),
580        }
581    }
582    PaneSetBatch::new(successes, failures)
583}
584
585fn join_failure(error: tokio::task::JoinError) -> PaneSetFailure {
586    PaneSetFailure::new(
587        PaneRef::new(
588            crate::SessionName::new("unknown").expect("static session name"),
589            0,
590            0,
591        ),
592        None,
593        RmuxError::transport(
594            "join pane-set worker task",
595            std::io::Error::other(error.to_string()),
596        ),
597    )
598}