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    /// True when the user named this pane (Rename Pane): auto-title updates
80    /// from OSC/CWD skip it, mirroring `Tab::user_named` for tabs. For mux
81    /// panes the daemon's `-T` user title is the source of truth and this
82    /// flag re-marks on reattach.
83    pub user_named: bool,
84    /// State for shell restart behavior (None = shell running or closed normally)
85    pub restart_state: Option<RestartState>,
86    /// Whether the parent tab is active (shared with tab for refresh throttling)
87    pub is_active: Arc<AtomicBool>,
88    /// When true, Drop impl skips cleanup (terminal Arcs are dropped on background threads)
89    pub(crate) shutdown_fast: bool,
90}
91
92impl Pane {
93    /// Create a new pane with a terminal session
94    pub fn new(
95        id: PaneId,
96        config: &Config,
97        _runtime: Arc<Runtime>,
98        working_directory: Option<String>,
99    ) -> anyhow::Result<Self> {
100        // Create terminal with scrollback from config
101        let mut terminal = TerminalManager::new_with_scrollback(
102            config.cols,
103            config.rows,
104            config.scrollback.scrollback_lines,
105        )?;
106
107        // Apply common terminal configuration (theme, clipboard limits, cursor style, unicode)
108        configure_terminal_from_config(&mut terminal, config);
109
110        // Determine working directory
111        let work_dir = working_directory
112            .as_deref()
113            .or(config.shell.working_directory.as_deref());
114
115        // Get shell command and apply login shell flag
116        #[allow(unused_mut)] // mut is needed on Unix for login shell modification
117        let (shell_cmd, mut shell_args) = get_shell_command(config);
118        apply_login_shell_flag(&mut shell_args, config);
119
120        let shell_args_deref = shell_args.as_deref();
121        let shell_env = build_shell_env(config.shell.shell_env.as_ref());
122        terminal.spawn_custom_shell_with_dir(
123            &shell_cmd,
124            shell_args_deref,
125            work_dir,
126            shell_env.as_ref(),
127        )?;
128
129        // Create shared session logger
130        let session_logger = create_shared_logger();
131
132        let terminal = Arc::new(RwLock::new(terminal));
133
134        Ok(Self {
135            id,
136            terminal,
137            scroll_state: ScrollState::new(),
138            mouse: MouseState::new(),
139            bell: BellState::new(),
140            cache: RenderCache::new(),
141            refresh_task: None,
142            working_directory: working_directory.or_else(|| config.shell.working_directory.clone()),
143            last_activity_time: std::time::Instant::now(),
144            last_seen_generation: 0,
145            anti_idle_last_activity: std::time::Instant::now(),
146            anti_idle_last_generation: 0,
147            silence_notified: false,
148            exit_notified: false,
149            session_logger,
150            bounds: PaneBounds::default(),
151            title: String::new(),
152            has_default_title: true,
153            user_named: false,
154            background: PaneBackground::new(),
155            restart_state: None,
156            is_active: Arc::new(AtomicBool::new(false)),
157            shutdown_fast: false,
158        })
159    }
160
161    /// Create a pane that launches `command args` instead of the configured login shell.
162    ///
163    /// Identical to `Pane::new()` except the PTY is started with the given command.
164    /// All other fields (scroll state, cache, refresh task, etc.) are the same as `new()`.
165    pub fn new_with_command(
166        id: PaneId,
167        config: &Config,
168        _runtime: Arc<Runtime>,
169        working_directory: Option<String>,
170        command: String,
171        args: Vec<String>,
172    ) -> anyhow::Result<Self> {
173        // Create terminal with scrollback from config
174        let mut terminal = TerminalManager::new_with_scrollback(
175            config.cols,
176            config.rows,
177            config.scrollback.scrollback_lines,
178        )?;
179
180        // Apply common terminal configuration (theme, clipboard limits, cursor style, unicode)
181        configure_terminal_from_config(&mut terminal, config);
182
183        // Determine working directory
184        let work_dir = working_directory
185            .as_deref()
186            .or(config.shell.working_directory.as_deref());
187
188        // Spawn the caller-supplied command instead of the login shell
189        let shell_env = build_shell_env(config.shell.shell_env.as_ref());
190        terminal.spawn_custom_shell_with_dir(
191            &command,
192            Some(args.as_slice()),
193            work_dir,
194            shell_env.as_ref(),
195        )?;
196
197        // Create shared session logger
198        let session_logger = create_shared_logger();
199
200        let terminal = Arc::new(RwLock::new(terminal));
201
202        Ok(Self {
203            id,
204            terminal,
205            scroll_state: ScrollState::new(),
206            mouse: MouseState::new(),
207            bell: BellState::new(),
208            cache: RenderCache::new(),
209            refresh_task: None,
210            working_directory: working_directory.or_else(|| config.shell.working_directory.clone()),
211            last_activity_time: std::time::Instant::now(),
212            last_seen_generation: 0,
213            anti_idle_last_activity: std::time::Instant::now(),
214            anti_idle_last_generation: 0,
215            silence_notified: false,
216            exit_notified: false,
217            session_logger,
218            bounds: PaneBounds::default(),
219            title: String::new(),
220            has_default_title: true,
221            user_named: false,
222            background: PaneBackground::new(),
223            restart_state: None,
224            is_active: Arc::new(AtomicBool::new(false)),
225            shutdown_fast: false,
226        })
227    }
228
229    /// Create a primary pane that wraps an already-running terminal session.
230    ///
231    /// Unlike [`Pane::new`], this constructor does **not** spawn a new shell.
232    /// It shares the caller's `Arc<RwLock<TerminalManager>>` directly, so the
233    /// pane's terminal is the same session that `Tab::terminal` points to.
234    ///
235    /// Used by `Tab::new_internal` to always initialise a `PaneManager` with a
236    /// single primary pane at tab creation, removing the need for tab-level
237    /// `scroll_state`, `mouse`, `bell`, and `cache` fallback fields (R-32).
238    ///
239    /// # Arguments
240    /// * `id` — Pane identifier (typically `1`)
241    /// * `terminal` — Shared `Arc` cloned from the owning `Tab::terminal`
242    /// * `working_directory` — Optional CWD exposed via [`Pane::get_cwd`]
243    /// * `is_active` — Shared atomic flag cloned from the owning `Tab::is_active`
244    pub fn new_wrapping_terminal(
245        id: PaneId,
246        terminal: Arc<RwLock<TerminalManager>>,
247        working_directory: Option<String>,
248        is_active: Arc<AtomicBool>,
249    ) -> Self {
250        let session_logger = create_shared_logger();
251
252        Self {
253            id,
254            terminal,
255            scroll_state: ScrollState::new(),
256            mouse: MouseState::new(),
257            bell: BellState::new(),
258            cache: RenderCache::new(),
259            refresh_task: None,
260            working_directory,
261            last_activity_time: std::time::Instant::now(),
262            last_seen_generation: 0,
263            anti_idle_last_activity: std::time::Instant::now(),
264            anti_idle_last_generation: 0,
265            silence_notified: false,
266            exit_notified: false,
267            session_logger,
268            bounds: PaneBounds::default(),
269            title: String::new(),
270            has_default_title: true,
271            user_named: false,
272            background: PaneBackground::new(),
273            restart_state: None,
274            is_active,
275            shutdown_fast: false,
276        }
277    }
278
279    /// Create a new pane for tmux integration (no shell spawned)
280    ///
281    /// This creates a terminal that receives output from tmux control mode
282    /// rather than a local PTY.
283    pub fn new_for_tmux(
284        id: PaneId,
285        config: &Config,
286        _runtime: Arc<Runtime>,
287    ) -> anyhow::Result<Self> {
288        // Create terminal with scrollback from config but don't spawn a shell
289        let mut terminal = TerminalManager::new_with_scrollback(
290            config.cols,
291            config.rows,
292            config.scrollback.scrollback_lines,
293        )?;
294
295        // Apply common terminal configuration (theme, clipboard limits, cursor style, unicode)
296        configure_terminal_from_config(&mut terminal, config);
297
298        // Mirrors are created after `Tab::new_internal`'s one-shot trigger
299        // sync (a layout change can arrive at any time), and daemon output
300        // is scanned on this terminal — without installing the registry
301        // here, mux panes match against an empty trigger set forever.
302        terminal.sync_triggers(&config.automation.triggers);
303
304        // Don't spawn any shell - tmux provides the output
305        // Create shared session logger
306        let session_logger = create_shared_logger();
307
308        let terminal = Arc::new(RwLock::new(terminal));
309
310        Ok(Self {
311            id,
312            terminal,
313            scroll_state: ScrollState::new(),
314            mouse: MouseState::new(),
315            bell: BellState::new(),
316            cache: RenderCache::new(),
317            refresh_task: None,
318            working_directory: None,
319            last_activity_time: std::time::Instant::now(),
320            last_seen_generation: 0,
321            anti_idle_last_activity: std::time::Instant::now(),
322            anti_idle_last_generation: 0,
323            silence_notified: false,
324            exit_notified: false,
325            session_logger,
326            bounds: PaneBounds::default(),
327            title: String::new(),
328            has_default_title: true,
329            user_named: false,
330            background: PaneBackground::new(),
331            restart_state: None,
332            is_active: Arc::new(AtomicBool::new(false)),
333            shutdown_fast: false,
334        })
335    }
336
337    /// Check if the visual bell is currently active
338    pub fn is_bell_active(&self) -> bool {
339        if let Some(flash_start) = self.bell.visual_flash {
340            flash_start.elapsed().as_millis() < VISUAL_BELL_FLASH_DURATION_MS
341        } else {
342            false
343        }
344    }
345
346    /// Check if the terminal in this pane is still running
347    pub fn is_running(&self) -> bool {
348        if let Ok(term) = self.terminal.try_read() {
349            let running = term.is_running();
350            if !running {
351                crate::debug_info!(
352                    "PANE_EXIT",
353                    "Pane {} terminal detected as NOT running (shell exited)",
354                    self.id
355                );
356            }
357            running
358        } else {
359            true // Assume running if locked
360        }
361    }
362
363    /// Get the current working directory of this pane's shell
364    pub fn get_cwd(&self) -> Option<String> {
365        if let Ok(term) = self.terminal.try_read() {
366            term.shell_integration_cwd()
367        } else {
368            self.working_directory.clone()
369        }
370    }
371
372    /// Set per-pane background settings (overrides global config)
373    pub fn set_background(&mut self, background: PaneBackground) {
374        self.background = background;
375    }
376
377    /// Get per-pane background settings
378    pub fn background(&self) -> &PaneBackground {
379        &self.background
380    }
381
382    /// Set a per-pane background image (overrides global config)
383    pub fn set_background_image(&mut self, path: Option<String>) {
384        self.background.image_path = path;
385    }
386
387    /// Get the per-pane background image path (if set)
388    pub fn get_background_image(&self) -> Option<&str> {
389        self.background.image_path.as_deref()
390    }
391}
392
393impl Drop for Pane {
394    fn drop(&mut self) {
395        if self.shutdown_fast {
396            log::info!(
397                "Fast-dropping pane {} (cleanup handled externally)",
398                self.id
399            );
400            return;
401        }
402
403        log::info!("Dropping pane {}", self.id);
404
405        // Stop session logging first
406        if let Some(ref mut logger) = *self.session_logger.lock() {
407            match logger.stop() {
408                Ok(path) => {
409                    log::info!("Session log saved to: {:?}", path);
410                }
411                Err(e) => {
412                    log::warn!("Failed to stop session logging: {}", e);
413                }
414            }
415        }
416
417        self.stop_refresh_task();
418
419        // `JoinHandle::abort` is asynchronous, so the refresh task may still hold a
420        // read guard on `terminal` for the duration of one `update_generation()`
421        // call.  Losing the race means the shell is never killed, so retry briefly
422        // instead of sleeping unconditionally — an uncontended drop acquires the
423        // write lock on the first attempt and pays nothing.  Sequential pane closes
424        // happen inside a single `RedrawRequested`, so a fixed per-pane delay is
425        // multiplied by the pane count.
426        const KILL_LOCK_ATTEMPTS: u32 = 20;
427        const KILL_LOCK_RETRY: std::time::Duration = std::time::Duration::from_millis(1);
428
429        let mut acquired = false;
430        for _ in 0..KILL_LOCK_ATTEMPTS {
431            if let Ok(mut term) = self.terminal.try_write() {
432                if term.is_running() {
433                    log::info!("Killing terminal for pane {}", self.id);
434                    if let Err(e) = term.kill() {
435                        log::warn!("Failed to kill terminal for pane {}: {}", self.id, e);
436                    }
437                }
438                acquired = true;
439                break;
440            }
441            std::thread::sleep(KILL_LOCK_RETRY);
442        }
443
444        if !acquired {
445            log::warn!(
446                "Pane {}: terminal write lock unavailable at drop; shell may outlive the pane",
447                self.id
448            );
449        }
450    }
451}