Skip to main content

par_term/pane/types/
pane.rs

1//! `Pane` — a single terminal pane with its own PTY session and display state.
2
3use std::sync::Arc;
4use std::sync::atomic::AtomicBool;
5
6use tokio::runtime::Runtime;
7use tokio::sync::RwLock;
8use tokio::task::JoinHandle;
9
10use crate::config::Config;
11use crate::pane::bell::BellState;
12use crate::pane::mouse::MouseState;
13use crate::pane::render_cache::RenderCache;
14use crate::scroll_state::ScrollState;
15use crate::session_logger::{SharedSessionLogger, create_shared_logger};
16use crate::tab::{
17    apply_login_shell_flag, build_shell_env, configure_terminal_from_config, get_shell_command,
18};
19use crate::ui_constants::VISUAL_BELL_FLASH_DURATION_MS;
20use par_term_terminal::TerminalManager;
21
22use super::bounds::PaneBounds;
23use super::common::{PaneBackground, PaneId, RestartState};
24
25/// A single terminal pane with its own state
26///
27/// # RwLock Strategy
28///
29/// `terminal` uses `tokio::sync::RwLock` for the same reason as `Tab::terminal`:
30/// `TerminalManager` is shared between the async PTY reader task and the sync winit
31/// event loop.
32///
33/// Access rules:
34/// - **From async tasks**: `terminal.read().await` or `terminal.write().await`
35/// - **From the sync event loop**: `terminal.try_read()` or `terminal.try_write()` for polling;
36///   `terminal.blocking_write()` for infrequent user-initiated operations only.
37pub struct Pane {
38    /// Unique identifier for this pane
39    pub id: PaneId,
40    /// The terminal session for this pane.
41    ///
42    /// Uses `tokio::sync::RwLock`. From sync contexts use `.try_read()` or `.try_write()` for
43    /// non-blocking access or `.blocking_write()` for user-initiated operations.
44    pub terminal: Arc<RwLock<TerminalManager>>,
45    /// Scroll state for this pane
46    pub scroll_state: ScrollState,
47    /// Mouse state for this pane
48    pub mouse: MouseState,
49    /// Bell state for this pane
50    pub bell: BellState,
51    /// Render cache for this pane
52    pub cache: RenderCache,
53    /// Async task for refresh polling
54    pub refresh_task: Option<JoinHandle<()>>,
55    /// Working directory when pane was created
56    pub working_directory: Option<String>,
57    /// Last time terminal output (activity) was detected
58    pub last_activity_time: std::time::Instant,
59    /// Last terminal update generation seen
60    pub last_seen_generation: u64,
61    /// Last activity time for anti-idle keep-alive
62    pub anti_idle_last_activity: std::time::Instant,
63    /// Last terminal generation recorded for anti-idle tracking
64    pub anti_idle_last_generation: u64,
65    /// Whether silence notification has been sent for current idle period
66    pub silence_notified: bool,
67    /// Whether exit notification has been sent for this pane
68    pub exit_notified: bool,
69    /// Session logger for automatic session recording
70    pub session_logger: SharedSessionLogger,
71    /// Current bounds of this pane (updated on layout calculation)
72    pub bounds: PaneBounds,
73    /// Per-pane background settings (overrides global config if image_path is set)
74    pub background: PaneBackground,
75    /// Last-known title from OSC sequences or CWD fallback (empty if never set)
76    pub title: String,
77    /// True when pane still has its default/fallback title
78    pub has_default_title: bool,
79    /// State for shell restart behavior (None = shell running or closed normally)
80    pub restart_state: Option<RestartState>,
81    /// Whether the parent tab is active (shared with tab for refresh throttling)
82    pub is_active: Arc<AtomicBool>,
83    /// When true, Drop impl skips cleanup (terminal Arcs are dropped on background threads)
84    pub(crate) shutdown_fast: bool,
85}
86
87impl Pane {
88    /// Create a new pane with a terminal session
89    pub fn new(
90        id: PaneId,
91        config: &Config,
92        _runtime: Arc<Runtime>,
93        working_directory: Option<String>,
94    ) -> anyhow::Result<Self> {
95        // Create terminal with scrollback from config
96        let mut terminal = TerminalManager::new_with_scrollback(
97            config.cols,
98            config.rows,
99            config.scrollback.scrollback_lines,
100        )?;
101
102        // Apply common terminal configuration (theme, clipboard limits, cursor style, unicode)
103        configure_terminal_from_config(&mut terminal, config);
104
105        // Determine working directory
106        let work_dir = working_directory
107            .as_deref()
108            .or(config.shell.working_directory.as_deref());
109
110        // Get shell command and apply login shell flag
111        #[allow(unused_mut)] // mut is needed on Unix for login shell modification
112        let (shell_cmd, mut shell_args) = get_shell_command(config);
113        apply_login_shell_flag(&mut shell_args, config);
114
115        let shell_args_deref = shell_args.as_deref();
116        let shell_env = build_shell_env(config.shell.shell_env.as_ref());
117        terminal.spawn_custom_shell_with_dir(
118            &shell_cmd,
119            shell_args_deref,
120            work_dir,
121            shell_env.as_ref(),
122        )?;
123
124        // Create shared session logger
125        let session_logger = create_shared_logger();
126
127        let terminal = Arc::new(RwLock::new(terminal));
128
129        Ok(Self {
130            id,
131            terminal,
132            scroll_state: ScrollState::new(),
133            mouse: MouseState::new(),
134            bell: BellState::new(),
135            cache: RenderCache::new(),
136            refresh_task: None,
137            working_directory: working_directory.or_else(|| config.shell.working_directory.clone()),
138            last_activity_time: std::time::Instant::now(),
139            last_seen_generation: 0,
140            anti_idle_last_activity: std::time::Instant::now(),
141            anti_idle_last_generation: 0,
142            silence_notified: false,
143            exit_notified: false,
144            session_logger,
145            bounds: PaneBounds::default(),
146            title: String::new(),
147            has_default_title: true,
148            background: PaneBackground::new(),
149            restart_state: None,
150            is_active: Arc::new(AtomicBool::new(false)),
151            shutdown_fast: false,
152        })
153    }
154
155    /// Create a pane that launches `command args` instead of the configured login shell.
156    ///
157    /// Identical to `Pane::new()` except the PTY is started with the given command.
158    /// All other fields (scroll state, cache, refresh task, etc.) are the same as `new()`.
159    pub fn new_with_command(
160        id: PaneId,
161        config: &Config,
162        _runtime: Arc<Runtime>,
163        working_directory: Option<String>,
164        command: String,
165        args: Vec<String>,
166    ) -> anyhow::Result<Self> {
167        // Create terminal with scrollback from config
168        let mut terminal = TerminalManager::new_with_scrollback(
169            config.cols,
170            config.rows,
171            config.scrollback.scrollback_lines,
172        )?;
173
174        // Apply common terminal configuration (theme, clipboard limits, cursor style, unicode)
175        configure_terminal_from_config(&mut terminal, config);
176
177        // Determine working directory
178        let work_dir = working_directory
179            .as_deref()
180            .or(config.shell.working_directory.as_deref());
181
182        // Spawn the caller-supplied command instead of the login shell
183        let shell_env = build_shell_env(config.shell.shell_env.as_ref());
184        terminal.spawn_custom_shell_with_dir(
185            &command,
186            Some(args.as_slice()),
187            work_dir,
188            shell_env.as_ref(),
189        )?;
190
191        // Create shared session logger
192        let session_logger = create_shared_logger();
193
194        let terminal = Arc::new(RwLock::new(terminal));
195
196        Ok(Self {
197            id,
198            terminal,
199            scroll_state: ScrollState::new(),
200            mouse: MouseState::new(),
201            bell: BellState::new(),
202            cache: RenderCache::new(),
203            refresh_task: None,
204            working_directory: working_directory.or_else(|| config.shell.working_directory.clone()),
205            last_activity_time: std::time::Instant::now(),
206            last_seen_generation: 0,
207            anti_idle_last_activity: std::time::Instant::now(),
208            anti_idle_last_generation: 0,
209            silence_notified: false,
210            exit_notified: false,
211            session_logger,
212            bounds: PaneBounds::default(),
213            title: String::new(),
214            has_default_title: true,
215            background: PaneBackground::new(),
216            restart_state: None,
217            is_active: Arc::new(AtomicBool::new(false)),
218            shutdown_fast: false,
219        })
220    }
221
222    /// Create a primary pane that wraps an already-running terminal session.
223    ///
224    /// Unlike [`Pane::new`], this constructor does **not** spawn a new shell.
225    /// It shares the caller's `Arc<RwLock<TerminalManager>>` directly, so the
226    /// pane's terminal is the same session that `Tab::terminal` points to.
227    ///
228    /// Used by `Tab::new_internal` to always initialise a `PaneManager` with a
229    /// single primary pane at tab creation, removing the need for tab-level
230    /// `scroll_state`, `mouse`, `bell`, and `cache` fallback fields (R-32).
231    ///
232    /// # Arguments
233    /// * `id` — Pane identifier (typically `1`)
234    /// * `terminal` — Shared `Arc` cloned from the owning `Tab::terminal`
235    /// * `working_directory` — Optional CWD exposed via [`Pane::get_cwd`]
236    /// * `is_active` — Shared atomic flag cloned from the owning `Tab::is_active`
237    pub fn new_wrapping_terminal(
238        id: PaneId,
239        terminal: Arc<RwLock<TerminalManager>>,
240        working_directory: Option<String>,
241        is_active: Arc<AtomicBool>,
242    ) -> Self {
243        let session_logger = create_shared_logger();
244
245        Self {
246            id,
247            terminal,
248            scroll_state: ScrollState::new(),
249            mouse: MouseState::new(),
250            bell: BellState::new(),
251            cache: RenderCache::new(),
252            refresh_task: None,
253            working_directory,
254            last_activity_time: std::time::Instant::now(),
255            last_seen_generation: 0,
256            anti_idle_last_activity: std::time::Instant::now(),
257            anti_idle_last_generation: 0,
258            silence_notified: false,
259            exit_notified: false,
260            session_logger,
261            bounds: PaneBounds::default(),
262            title: String::new(),
263            has_default_title: true,
264            background: PaneBackground::new(),
265            restart_state: None,
266            is_active,
267            shutdown_fast: false,
268        }
269    }
270
271    /// Create a new pane for tmux integration (no shell spawned)
272    ///
273    /// This creates a terminal that receives output from tmux control mode
274    /// rather than a local PTY.
275    pub fn new_for_tmux(
276        id: PaneId,
277        config: &Config,
278        _runtime: Arc<Runtime>,
279    ) -> anyhow::Result<Self> {
280        // Create terminal with scrollback from config but don't spawn a shell
281        let mut terminal = TerminalManager::new_with_scrollback(
282            config.cols,
283            config.rows,
284            config.scrollback.scrollback_lines,
285        )?;
286
287        // Apply common terminal configuration (theme, clipboard limits, cursor style, unicode)
288        configure_terminal_from_config(&mut terminal, config);
289
290        // Don't spawn any shell - tmux provides the output
291        // Create shared session logger
292        let session_logger = create_shared_logger();
293
294        let terminal = Arc::new(RwLock::new(terminal));
295
296        Ok(Self {
297            id,
298            terminal,
299            scroll_state: ScrollState::new(),
300            mouse: MouseState::new(),
301            bell: BellState::new(),
302            cache: RenderCache::new(),
303            refresh_task: None,
304            working_directory: None,
305            last_activity_time: std::time::Instant::now(),
306            last_seen_generation: 0,
307            anti_idle_last_activity: std::time::Instant::now(),
308            anti_idle_last_generation: 0,
309            silence_notified: false,
310            exit_notified: false,
311            session_logger,
312            bounds: PaneBounds::default(),
313            title: String::new(),
314            has_default_title: true,
315            background: PaneBackground::new(),
316            restart_state: None,
317            is_active: Arc::new(AtomicBool::new(false)),
318            shutdown_fast: false,
319        })
320    }
321
322    /// Check if the visual bell is currently active
323    pub fn is_bell_active(&self) -> bool {
324        if let Some(flash_start) = self.bell.visual_flash {
325            flash_start.elapsed().as_millis() < VISUAL_BELL_FLASH_DURATION_MS
326        } else {
327            false
328        }
329    }
330
331    /// Check if the terminal in this pane is still running
332    pub fn is_running(&self) -> bool {
333        if let Ok(term) = self.terminal.try_read() {
334            let running = term.is_running();
335            if !running {
336                crate::debug_info!(
337                    "PANE_EXIT",
338                    "Pane {} terminal detected as NOT running (shell exited)",
339                    self.id
340                );
341            }
342            running
343        } else {
344            true // Assume running if locked
345        }
346    }
347
348    /// Get the current working directory of this pane's shell
349    pub fn get_cwd(&self) -> Option<String> {
350        if let Ok(term) = self.terminal.try_read() {
351            term.shell_integration_cwd()
352        } else {
353            self.working_directory.clone()
354        }
355    }
356
357    /// Set per-pane background settings (overrides global config)
358    pub fn set_background(&mut self, background: PaneBackground) {
359        self.background = background;
360    }
361
362    /// Get per-pane background settings
363    pub fn background(&self) -> &PaneBackground {
364        &self.background
365    }
366
367    /// Set a per-pane background image (overrides global config)
368    pub fn set_background_image(&mut self, path: Option<String>) {
369        self.background.image_path = path;
370    }
371
372    /// Get the per-pane background image path (if set)
373    pub fn get_background_image(&self) -> Option<&str> {
374        self.background.image_path.as_deref()
375    }
376}
377
378impl Drop for Pane {
379    fn drop(&mut self) {
380        if self.shutdown_fast {
381            log::info!(
382                "Fast-dropping pane {} (cleanup handled externally)",
383                self.id
384            );
385            return;
386        }
387
388        log::info!("Dropping pane {}", self.id);
389
390        // Stop session logging first
391        if let Some(ref mut logger) = *self.session_logger.lock() {
392            match logger.stop() {
393                Ok(path) => {
394                    log::info!("Session log saved to: {:?}", path);
395                }
396                Err(e) => {
397                    log::warn!("Failed to stop session logging: {}", e);
398                }
399            }
400        }
401
402        self.stop_refresh_task();
403
404        // `JoinHandle::abort` is asynchronous, so the refresh task may still hold a
405        // read guard on `terminal` for the duration of one `update_generation()`
406        // call.  Losing the race means the shell is never killed, so retry briefly
407        // instead of sleeping unconditionally — an uncontended drop acquires the
408        // write lock on the first attempt and pays nothing.  Sequential pane closes
409        // happen inside a single `RedrawRequested`, so a fixed per-pane delay is
410        // multiplied by the pane count.
411        const KILL_LOCK_ATTEMPTS: u32 = 20;
412        const KILL_LOCK_RETRY: std::time::Duration = std::time::Duration::from_millis(1);
413
414        let mut acquired = false;
415        for _ in 0..KILL_LOCK_ATTEMPTS {
416            if let Ok(mut term) = self.terminal.try_write() {
417                if term.is_running() {
418                    log::info!("Killing terminal for pane {}", self.id);
419                    if let Err(e) = term.kill() {
420                        log::warn!("Failed to kill terminal for pane {}: {}", self.id, e);
421                    }
422                }
423                acquired = true;
424                break;
425            }
426            std::thread::sleep(KILL_LOCK_RETRY);
427        }
428
429        if !acquired {
430            log::warn!(
431                "Pane {}: terminal write lock unavailable at drop; shell may outlive the pane",
432                self.id
433            );
434        }
435    }
436}