Skip to main content

par_term/
tmux_session_picker_ui.rs

1//! tmux Session Picker UI
2//!
3//! An egui dialog that lists available tmux sessions and allows the user to:
4//! - Attach to an existing session
5//! - Create a new session
6
7use crate::ui_constants::{
8    TMUX_PICKER_LIST_MAX_HEIGHT, TMUX_PICKER_WINDOW_DEFAULT_HEIGHT,
9    TMUX_PICKER_WINDOW_DEFAULT_WIDTH,
10};
11use egui::{Color32, Context, Frame, RichText, Window, epaint::Shadow};
12use std::process::Command;
13use std::time::Duration;
14
15/// Deadline for `tmux list-sessions`. Instant against a healthy server; an
16/// unresponsive one must not hold the egui frame.
17const TMUX_LIST_TIMEOUT: Duration = Duration::from_secs(2);
18
19/// Information about a tmux session
20#[derive(Debug, Clone)]
21pub struct TmuxSessionInfo {
22    /// Session ID (e.g., "$0")
23    pub id: String,
24    /// Session name
25    pub name: String,
26    /// Number of windows
27    pub window_count: usize,
28    /// Whether the session has attached clients
29    pub attached: bool,
30}
31
32/// Action requested by the session picker
33#[derive(Debug, Clone)]
34pub enum SessionPickerAction {
35    /// No action
36    None,
37    /// Attach to the specified session
38    Attach(String),
39    /// Create a new session with optional name
40    CreateNew(Option<String>),
41}
42
43/// tmux Session Picker UI
44pub struct TmuxSessionPickerUI {
45    /// Whether the picker is visible
46    pub visible: bool,
47    /// List of available sessions
48    sessions: Vec<TmuxSessionInfo>,
49    /// New session name input
50    new_session_name: String,
51    /// Error message to display
52    error_message: Option<String>,
53    /// Whether we've loaded sessions
54    sessions_loaded: bool,
55}
56
57impl TmuxSessionPickerUI {
58    /// Create a new session picker UI
59    pub fn new() -> Self {
60        Self {
61            visible: false,
62            sessions: Vec::new(),
63            new_session_name: String::new(),
64            error_message: None,
65            sessions_loaded: false,
66        }
67    }
68
69    /// Show the session picker
70    pub fn show_picker(&mut self) {
71        self.visible = true;
72        self.sessions_loaded = false; // Refresh on open
73        self.error_message = None;
74        self.new_session_name.clear();
75    }
76
77    /// Hide the session picker
78    pub fn hide(&mut self) {
79        self.visible = false;
80    }
81
82    /// Toggle visibility
83    pub fn toggle(&mut self) {
84        if self.visible {
85            self.hide();
86        } else {
87            self.show_picker();
88        }
89    }
90
91    /// Refresh the session list
92    pub fn refresh_sessions(&mut self, tmux_path: &str) {
93        match Self::list_tmux_sessions(tmux_path) {
94            Ok(sessions) => {
95                self.sessions = sessions;
96                self.error_message = None;
97                self.sessions_loaded = true;
98            }
99            Err(e) => {
100                self.sessions.clear();
101                self.error_message = Some(e);
102                self.sessions_loaded = true;
103            }
104        }
105    }
106
107    /// List available tmux sessions by running `tmux list-sessions`
108    ///
109    /// Bounded by [`TMUX_LIST_TIMEOUT`]: this runs inside the egui closure on first
110    /// show and on Refresh, so an unresponsive tmux server would otherwise block the
111    /// frame indefinitely.
112    fn list_tmux_sessions(tmux_path: &str) -> Result<Vec<TmuxSessionInfo>, String> {
113        let mut cmd = Command::new(tmux_path);
114        cmd.args([
115            "list-sessions",
116            "-F",
117            "#{session_id}:#{session_name}:#{session_attached}:#{session_windows}",
118        ]);
119        let output = crate::process_timeout::output_with_timeout(&mut cmd, TMUX_LIST_TIMEOUT)
120            .map_err(|e| format!("Failed to run tmux: {}", e))?;
121
122        if !output.status.success() {
123            let stderr = &output.stderr;
124            // "no server running" is expected when there are no sessions
125            if stderr.contains("no server running") || stderr.contains("no sessions") {
126                return Ok(Vec::new());
127            }
128            return Err(format!("tmux error: {}", stderr.trim()));
129        }
130
131        let mut sessions = Vec::new();
132
133        for line in output.stdout.lines() {
134            let parts: Vec<&str> = line.split(':').collect();
135            if parts.len() >= 4 {
136                sessions.push(TmuxSessionInfo {
137                    id: parts[0].to_string(),
138                    name: parts[1].to_string(),
139                    attached: parts[2] == "1",
140                    window_count: parts[3].parse().unwrap_or(0),
141                });
142            }
143        }
144
145        Ok(sessions)
146    }
147
148    /// Show the session picker UI and return any requested action
149    pub fn show(&mut self, ctx: &Context, tmux_path: &str) -> SessionPickerAction {
150        if !self.visible {
151            return SessionPickerAction::None;
152        }
153
154        // Load sessions on first show
155        if !self.sessions_loaded {
156            self.refresh_sessions(tmux_path);
157        }
158
159        let mut action = SessionPickerAction::None;
160        let mut close_requested = false;
161
162        // Ensure picker is fully opaque
163        let mut style = (*ctx.global_style()).clone();
164        let solid_bg = Color32::from_rgba_unmultiplied(24, 24, 24, 255);
165        style.visuals.window_fill = solid_bg;
166        style.visuals.panel_fill = solid_bg;
167        ctx.set_global_style(style);
168
169        let mut open = true;
170        let viewport = ctx.input(|i| i.viewport_rect());
171
172        Window::new("tmux Sessions")
173            .resizable(true)
174            .default_width(TMUX_PICKER_WINDOW_DEFAULT_WIDTH)
175            .default_height(TMUX_PICKER_WINDOW_DEFAULT_HEIGHT)
176            .default_pos(viewport.center())
177            .pivot(egui::Align2::CENTER_CENTER)
178            .open(&mut open)
179            .frame(
180                Frame::window(&ctx.global_style())
181                    .fill(solid_bg)
182                    .stroke(egui::Stroke::NONE)
183                    .shadow(Shadow {
184                        offset: [0, 0],
185                        blur: 0,
186                        spread: 0,
187                        color: Color32::TRANSPARENT,
188                    }),
189            )
190            .show(ctx, |ui| {
191                // Error message
192                if let Some(ref err) = self.error_message {
193                    ui.colored_label(Color32::from_rgb(255, 100, 100), err);
194                    ui.add_space(8.0);
195                }
196
197                // Existing sessions section
198                ui.heading("Existing Sessions");
199                ui.separator();
200
201                if self.sessions.is_empty() {
202                    ui.label(RichText::new("No tmux sessions found").italics());
203                } else {
204                    egui::ScrollArea::vertical()
205                        .max_height(TMUX_PICKER_LIST_MAX_HEIGHT)
206                        .show(ui, |ui| {
207                            for session in &self.sessions {
208                                ui.horizontal(|ui| {
209                                    // Session name
210                                    let name_text = if session.attached {
211                                        RichText::new(&session.name).strong()
212                                    } else {
213                                        RichText::new(&session.name)
214                                    };
215                                    ui.label(name_text);
216
217                                    // Window count
218                                    ui.label(
219                                        RichText::new(format!(
220                                            "({} window{})",
221                                            session.window_count,
222                                            if session.window_count == 1 { "" } else { "s" }
223                                        ))
224                                        .weak(),
225                                    );
226
227                                    // Attached indicator
228                                    if session.attached {
229                                        ui.label(
230                                            RichText::new("(attached)")
231                                                .color(Color32::from_rgb(100, 200, 100)),
232                                        );
233                                    }
234
235                                    ui.with_layout(
236                                        egui::Layout::right_to_left(egui::Align::Center),
237                                        |ui| {
238                                            if ui.button("Attach").clicked() {
239                                                action = SessionPickerAction::Attach(
240                                                    session.name.clone(),
241                                                );
242                                                close_requested = true;
243                                            }
244                                        },
245                                    );
246                                });
247                            }
248                        });
249                }
250
251                ui.add_space(16.0);
252
253                // Refresh button
254                if ui.button("Refresh").clicked() {
255                    self.refresh_sessions(tmux_path);
256                }
257
258                ui.add_space(16.0);
259
260                // Create new session section
261                ui.heading("Create New Session");
262                ui.separator();
263
264                ui.horizontal(|ui| {
265                    ui.label("Session name:");
266                    ui.text_edit_singleline(&mut self.new_session_name);
267                });
268
269                ui.add_space(8.0);
270
271                ui.horizontal(|ui| {
272                    if ui.button("Create").clicked() {
273                        let name = if self.new_session_name.is_empty() {
274                            None
275                        } else {
276                            Some(self.new_session_name.clone())
277                        };
278                        action = SessionPickerAction::CreateNew(name);
279                        close_requested = true;
280                    }
281
282                    ui.label(
283                        RichText::new("(leave empty for auto-generated name)")
284                            .small()
285                            .weak(),
286                    );
287                });
288            });
289
290        // Handle close
291        if !open || close_requested {
292            self.visible = false;
293        }
294
295        action
296    }
297}
298
299impl Default for TmuxSessionPickerUI {
300    fn default() -> Self {
301        Self::new()
302    }
303}