1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub struct WindowPane {
29 pub target: PaneRef,
31 pub id: PaneId,
33 pub active: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39#[non_exhaustive]
40pub enum WindowCloseOutcome {
41 Closed {
43 active: WindowRef,
45 },
46 AlreadyClosed {
48 target: WindowRef,
50 },
51}
52
53#[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 #[must_use]
86 pub const fn target(&self) -> &WindowRef {
87 &self.target
88 }
89
90 #[must_use]
92 pub const fn endpoint(&self) -> &RmuxEndpoint {
93 &self.endpoint
94 }
95
96 #[must_use]
98 pub const fn configured_default_timeout(&self) -> Option<Duration> {
99 self.default_timeout
100 }
101
102 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 pub async fn exists(&self) -> Result<bool> {
112 Ok(self.id().await?.is_some())
113 }
114
115 pub async fn panes(&self) -> Result<Vec<WindowPane>> {
117 list_window_panes_or_empty(&self.transport, &self.target).await
118 }
119
120 pub async fn info(&self) -> Result<InfoSnapshot> {
127 window_info_snapshot(&self.transport, &self.target).await
128 }
129
130 pub async fn select(&self) -> Result<()> {
132 select_window(&self.transport, &self.target).await
133 }
134
135 pub async fn rename(&self, name: impl Into<String>) -> Result<()> {
137 rename_window(&self.transport, &self.target, name.into()).await
138 }
139
140 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 pub async fn select_layout(&self, layout: LayoutName) -> Result<()> {
149 select_window_layout(&self.transport, &self.target, layout).await
150 }
151
152 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;