Skip to main content

rmux_sdk/handles/
window.rs

1//! Daemon-backed window handle.
2
3use std::fmt;
4use std::time::Duration;
5
6use crate::handles::session::unexpected_response;
7use crate::transport::TransportClient;
8use crate::{
9    InfoSnapshot, PaneId, PaneInfo, PaneProcessState, PaneRef, Result, RmuxEndpoint, RmuxError,
10    SessionId, SessionInfo, TerminalSizeSpec, WindowId, WindowInfo, WindowRef,
11};
12use rmux_proto::{
13    KillWindowRequest, LayoutName, ListPanesRequest, ListSessionsRequest, ListWindowsRequest,
14    RenameWindowRequest, Request, ResizeWindowRequest, Response, SelectLayoutRequest,
15    SelectLayoutTarget, SelectWindowRequest,
16};
17
18#[path = "window/new_builder.rs"]
19mod new_builder;
20
21pub use new_builder::NewWindowBuilder;
22
23const SESSION_INFO_FORMAT: &str = "#{session_name}\t#{session_id}";
24const PANE_INFO_FORMAT: &str = "#{window_index}:#{pane_index}:#{pane_id}:#{pane_active}";
25
26/// One pane listed inside a [`Window`] handle.
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub struct WindowPane {
29    /// Exact pane selector inside the window's session and index.
30    pub target: PaneRef,
31    /// Stable tmux-style pane identity, rendered as `%N` by formats.
32    pub id: PaneId,
33    /// Whether this pane is the active pane for its window.
34    pub active: bool,
35}
36
37/// Result of consuming a [`Window`] handle with [`Window::close`].
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39#[non_exhaustive]
40pub enum WindowCloseOutcome {
41    /// The daemon killed the addressed window and selected another window.
42    Closed {
43        /// The surviving active window reported by the daemon.
44        active: WindowRef,
45    },
46    /// The addressed window was already absent by the time close ran.
47    AlreadyClosed {
48        /// The stale target consumed by the close call.
49        target: WindowRef,
50    },
51}
52
53/// Opaque handle for one daemon window slot.
54///
55/// A window handle addresses a session/index pair rather than caching a
56/// `WindowId`. Every operation resolves that slot against the daemon's current
57/// state, so linked windows and grouped sessions follow tmux visibility rules:
58/// closing one visible link removes the underlying window and panes from every
59/// linked or grouped listing, while stale handles for any affected slot return
60/// typed empty/already-closed results where the operation supports them.
61#[derive(Clone)]
62pub struct Window {
63    target: WindowRef,
64    endpoint: RmuxEndpoint,
65    default_timeout: Option<Duration>,
66    transport: TransportClient,
67}
68
69impl Window {
70    pub(crate) fn new(
71        target: WindowRef,
72        endpoint: RmuxEndpoint,
73        default_timeout: Option<Duration>,
74        transport: TransportClient,
75    ) -> Self {
76        Self {
77            target,
78            endpoint,
79            default_timeout,
80            transport,
81        }
82    }
83
84    /// Returns the exact protocol-owned window target addressed by this handle.
85    #[must_use]
86    pub const fn target(&self) -> &WindowRef {
87        &self.target
88    }
89
90    /// Returns the endpoint that was resolved when this handle was created.
91    #[must_use]
92    pub const fn endpoint(&self) -> &RmuxEndpoint {
93        &self.endpoint
94    }
95
96    /// Returns the default timeout configured on the parent facade.
97    #[must_use]
98    pub const fn configured_default_timeout(&self) -> Option<Duration> {
99        self.default_timeout
100    }
101
102    /// Returns the stable daemon window identity for this slot, when it is
103    /// currently listed.
104    pub async fn id(&self) -> Result<Option<WindowId>> {
105        Ok(current_window_entry(&self.transport, &self.target)
106            .await?
107            .map(|entry| entry.id))
108    }
109
110    /// Checks whether this exact window slot is currently listed by the daemon.
111    pub async fn exists(&self) -> Result<bool> {
112        Ok(self.id().await?.is_some())
113    }
114
115    /// Lists panes currently visible through this window slot.
116    pub async fn panes(&self) -> Result<Vec<WindowPane>> {
117        list_window_panes_or_empty(&self.transport, &self.target).await
118    }
119
120    /// Returns a sticky info snapshot for this window and its listed panes.
121    ///
122    /// The snapshot is assembled from live daemon `list-sessions`,
123    /// `list-windows`, and `list-panes` responses. If the target has already
124    /// been closed, the returned snapshot contains only the still-observable
125    /// session metadata, or is empty when the session is gone.
126    pub async fn info(&self) -> Result<InfoSnapshot> {
127        window_info_snapshot(&self.transport, &self.target).await
128    }
129
130    /// Selects this window in its session.
131    pub async fn select(&self) -> Result<()> {
132        select_window(&self.transport, &self.target).await
133    }
134
135    /// Renames this window.
136    pub async fn rename(&self, name: impl Into<String>) -> Result<()> {
137        rename_window(&self.transport, &self.target, name.into()).await
138    }
139
140    /// Requests an absolute size for this window.
141    ///
142    /// Passing `None` for one dimension leaves that dimension to the daemon.
143    pub async fn resize(&self, width: Option<u16>, height: Option<u16>) -> Result<()> {
144        resize_window(&self.transport, &self.target, width, height).await
145    }
146
147    /// Applies a named layout to this window.
148    pub async fn select_layout(&self, layout: LayoutName) -> Result<()> {
149        select_window_layout(&self.transport, &self.target, layout).await
150    }
151
152    /// Consumes this handle and kills the addressed window through the daemon.
153    ///
154    /// A stale handle is treated as an idempotent no-op and returns
155    /// [`WindowCloseOutcome::AlreadyClosed`]. Linked or grouped views of the
156    /// same underlying window are removed together by the daemon. Other daemon
157    /// errors, such as attempting to kill the only window in a session, are
158    /// returned.
159    pub async fn close(self) -> Result<WindowCloseOutcome> {
160        close_window(&self.transport, self.target).await
161    }
162}
163
164impl fmt::Debug for Window {
165    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
166        formatter
167            .debug_struct("Window")
168            .field("target", &self.target)
169            .finish_non_exhaustive()
170    }
171}
172
173async fn select_window(client: &TransportClient, target: &WindowRef) -> Result<()> {
174    match client
175        .request(Request::SelectWindow(SelectWindowRequest {
176            target: target.to_proto(),
177        }))
178        .await?
179    {
180        Response::SelectWindow(_) => Ok(()),
181        response => Err(unexpected_response("select-window", response)),
182    }
183}
184
185async fn rename_window(client: &TransportClient, target: &WindowRef, name: String) -> Result<()> {
186    match client
187        .request(Request::RenameWindow(RenameWindowRequest {
188            target: target.to_proto(),
189            name,
190        }))
191        .await?
192    {
193        Response::RenameWindow(_) => Ok(()),
194        response => Err(unexpected_response("rename-window", response)),
195    }
196}
197
198async fn resize_window(
199    client: &TransportClient,
200    target: &WindowRef,
201    width: Option<u16>,
202    height: Option<u16>,
203) -> Result<()> {
204    match client
205        .request(Request::ResizeWindow(ResizeWindowRequest {
206            target: target.to_proto(),
207            width,
208            height,
209            adjustment: None,
210        }))
211        .await?
212    {
213        Response::ResizeWindow(_) => Ok(()),
214        response => Err(unexpected_response("resize-window", response)),
215    }
216}
217
218async fn select_window_layout(
219    client: &TransportClient,
220    target: &WindowRef,
221    layout: LayoutName,
222) -> Result<()> {
223    match client
224        .request(Request::SelectLayout(SelectLayoutRequest {
225            target: SelectLayoutTarget::Window(target.to_proto()),
226            layout,
227        }))
228        .await?
229    {
230        Response::SelectLayout(_) => Ok(()),
231        response => Err(unexpected_response("select-layout", response)),
232    }
233}
234
235async fn close_window(client: &TransportClient, target: WindowRef) -> Result<WindowCloseOutcome> {
236    let response = client
237        .request(Request::KillWindow(KillWindowRequest {
238            target: (&target).into(),
239            kill_all_others: false,
240        }))
241        .await;
242
243    match response {
244        Ok(Response::KillWindow(response)) => Ok(WindowCloseOutcome::Closed {
245            active: response.target.into(),
246        }),
247        Ok(response) => Err(unexpected_response("kill-window", response)),
248        Err(error) if is_already_closed_error(&error, &target) => {
249            Ok(WindowCloseOutcome::AlreadyClosed { target })
250        }
251        Err(error) => Err(error),
252    }
253}
254
255async fn current_window_entry(
256    client: &TransportClient,
257    target: &WindowRef,
258) -> Result<Option<ListedWindow>> {
259    match list_window_entries(client, &target.session_name).await {
260        Ok(entries) => Ok(entries
261            .into_iter()
262            .find(|entry| entry.index == target.window_index)),
263        Err(error) if is_already_closed_error(&error, target) => Ok(None),
264        Err(error) => Err(error),
265    }
266}
267
268async fn window_info_snapshot(
269    client: &TransportClient,
270    target: &WindowRef,
271) -> Result<InfoSnapshot> {
272    let session = current_session_info(client, &target.session_name).await?;
273    let Some(session) = session else {
274        return Ok(InfoSnapshot::default());
275    };
276
277    let Some(window) = current_window_entry(client, target).await? else {
278        return Ok(InfoSnapshot::new(vec![session], Vec::new(), Vec::new()));
279    };
280
281    let panes = list_window_panes_or_empty(client, target).await?;
282    let session_id = session.id;
283    let pane_infos = panes
284        .into_iter()
285        .map(|pane| {
286            let mut info = PaneInfo::new(pane.id, window.id, session_id);
287            info.index = pane.target.pane_index;
288            info.size = window.size;
289            info.process = PaneProcessState::Unknown;
290            info
291        })
292        .collect();
293
294    Ok(InfoSnapshot::new(
295        vec![session],
296        vec![window.into_info(session_id)],
297        pane_infos,
298    ))
299}
300
301async fn current_session_info(
302    client: &TransportClient,
303    session_name: &rmux_proto::SessionName,
304) -> Result<Option<SessionInfo>> {
305    let response = client
306        .request(Request::ListSessions(ListSessionsRequest {
307            format: Some(SESSION_INFO_FORMAT.to_owned()),
308            filter: None,
309            sort_order: Some("name".to_owned()),
310            reversed: false,
311        }))
312        .await?;
313
314    let output = match response {
315        Response::ListSessions(response) => response.output.stdout,
316        response => return Err(unexpected_response("list-sessions", response)),
317    };
318
319    for line in String::from_utf8_lossy(&output).lines() {
320        let info = parse_session_info_line(line)?;
321        if &info.name == session_name {
322            return Ok(Some(info));
323        }
324    }
325
326    Ok(None)
327}
328
329async fn list_window_entries(
330    client: &TransportClient,
331    session_name: &rmux_proto::SessionName,
332) -> Result<Vec<ListedWindow>> {
333    match client
334        .request(Request::ListWindows(ListWindowsRequest {
335            target: session_name.clone(),
336            format: None,
337        }))
338        .await?
339    {
340        Response::ListWindows(response) => response
341            .windows
342            .into_iter()
343            .map(ListedWindow::try_from)
344            .collect(),
345        response => Err(unexpected_response("list-windows", response)),
346    }
347}
348
349async fn list_window_panes_or_empty(
350    client: &TransportClient,
351    target: &WindowRef,
352) -> Result<Vec<WindowPane>> {
353    match list_window_panes(client, target).await {
354        Ok(panes) => Ok(panes),
355        Err(error) if is_already_closed_error(&error, target) => Ok(Vec::new()),
356        Err(error) => Err(error),
357    }
358}
359
360async fn list_window_panes(
361    client: &TransportClient,
362    target: &WindowRef,
363) -> Result<Vec<WindowPane>> {
364    let response = client
365        .request(Request::ListPanes(ListPanesRequest {
366            target: target.session_name.clone(),
367            target_window_index: Some(target.window_index),
368            format: Some(PANE_INFO_FORMAT.to_owned()),
369        }))
370        .await?;
371
372    let output = match response {
373        Response::ListPanes(response) => response.output.stdout,
374        response => return Err(unexpected_response("list-panes", response)),
375    };
376
377    String::from_utf8_lossy(&output)
378        .lines()
379        .map(|line| parse_pane_info_line(target, line))
380        .collect()
381}
382
383#[derive(Debug, Clone)]
384struct ListedWindow {
385    index: u32,
386    id: WindowId,
387    name: Option<String>,
388    size: TerminalSizeSpec,
389}
390
391impl ListedWindow {
392    fn into_info(self, session_id: SessionId) -> WindowInfo {
393        let mut info = WindowInfo::new(self.id, session_id);
394        info.index = self.index;
395        info.name = self.name;
396        info.size = self.size;
397        info
398    }
399}
400
401impl TryFrom<rmux_proto::WindowListEntry> for ListedWindow {
402    type Error = RmuxError;
403
404    fn try_from(entry: rmux_proto::WindowListEntry) -> Result<Self> {
405        Ok(Self {
406            index: entry.target.window_index(),
407            id: parse_window_id(&entry.window_id)?,
408            name: entry.name,
409            size: entry.size.into(),
410        })
411    }
412}
413
414fn parse_session_info_line(line: &str) -> Result<SessionInfo> {
415    let mut fields = line.split('\t');
416    let name = fields
417        .next()
418        .ok_or_else(|| parse_error("session info line omitted session name"))?;
419    let id = fields
420        .next()
421        .ok_or_else(|| parse_error("session info line omitted session id"))?;
422    if fields.next().is_some() {
423        return Err(parse_error("session info line had trailing fields"));
424    }
425
426    Ok(SessionInfo::new(
427        parse_session_id(id)?,
428        rmux_proto::SessionName::new(name)?,
429    ))
430}
431
432fn parse_pane_info_line(target: &WindowRef, line: &str) -> Result<WindowPane> {
433    let mut fields = line.split(':');
434    let window_index = fields
435        .next()
436        .ok_or_else(|| parse_error("pane info line omitted window index"))?;
437    let pane_index = fields
438        .next()
439        .ok_or_else(|| parse_error("pane info line omitted pane index"))?;
440    let pane_id = fields
441        .next()
442        .ok_or_else(|| parse_error("pane info line omitted pane id"))?;
443    let active = fields
444        .next()
445        .ok_or_else(|| parse_error("pane info line omitted active flag"))?;
446    if fields.next().is_some() {
447        return Err(parse_error("pane info line had trailing fields"));
448    }
449
450    let window_index = parse_u32(window_index, "pane window index")?;
451    if window_index != target.window_index {
452        return Err(parse_error(format!(
453            "list-panes returned window index {window_index} for target {}",
454            target.to_proto()
455        )));
456    }
457
458    Ok(WindowPane {
459        target: PaneRef::new(
460            target.session_name.clone(),
461            window_index,
462            parse_u32(pane_index, "pane index")?,
463        ),
464        id: parse_pane_id(pane_id)?,
465        active: parse_bool_flag(active, "pane active flag")?,
466    })
467}
468
469fn parse_session_id(value: &str) -> Result<SessionId> {
470    parse_prefixed_u32(value, '$', "session id").map(SessionId::new)
471}
472
473fn parse_window_id(value: &str) -> Result<WindowId> {
474    parse_prefixed_u32(value, '@', "window id").map(WindowId::new)
475}
476
477fn parse_pane_id(value: &str) -> Result<PaneId> {
478    parse_prefixed_u32(value, '%', "pane id").map(PaneId::new)
479}
480
481fn parse_prefixed_u32(value: &str, prefix: char, field: &str) -> Result<u32> {
482    let raw = value
483        .strip_prefix(prefix)
484        .ok_or_else(|| parse_error(format!("{field} `{value}` omitted `{prefix}` prefix")))?;
485    parse_u32(raw, field)
486}
487
488fn parse_u32(value: &str, field: &str) -> Result<u32> {
489    value
490        .parse::<u32>()
491        .map_err(|error| parse_error(format!("invalid {field} `{value}`: {error}")))
492}
493
494fn parse_bool_flag(value: &str, field: &str) -> Result<bool> {
495    match value {
496        "0" => Ok(false),
497        "1" => Ok(true),
498        _ => Err(parse_error(format!("invalid {field} `{value}`"))),
499    }
500}
501
502fn parse_error(message: impl Into<String>) -> RmuxError {
503    RmuxError::protocol(rmux_proto::RmuxError::Server(message.into()))
504}
505
506fn is_already_closed_error(error: &RmuxError, target: &WindowRef) -> bool {
507    match error {
508        RmuxError::Protocol {
509            source: rmux_proto::RmuxError::SessionNotFound(session),
510        } => session == target.session_name.as_str(),
511        RmuxError::Protocol {
512            source: rmux_proto::RmuxError::InvalidTarget { value, reason },
513        } => {
514            value == &target.to_proto().to_string()
515                && reason == "window index does not exist in session"
516        }
517        _ => false,
518    }
519}
520
521#[cfg(test)]
522#[path = "window/tests.rs"]
523mod tests;