fresh/services/terminal/manager.rs
1//! Terminal Manager - manages multiple terminal sessions
2//!
3//! This module provides a manager for terminal sessions that:
4//! - Spawns PTY processes with proper shell detection
5//! - Manages multiple concurrent terminals
6//! - Routes input/output between the editor and terminal processes
7//! - Handles terminal resize events
8//!
9//! # Role in Incremental Streaming Architecture
10//!
11//! The manager owns the PTY read loop which is the entry point for incremental
12//! scrollback streaming. See `super` module docs for the full architecture overview.
13//!
14//! ## PTY Read Loop
15//!
16//! The read loop in `spawn()` performs incremental streaming: for each PTY read,
17//! it calls `process_output()` to update the terminal grid, then `flush_new_scrollback()`
18//! to append any new scrollback lines to the backing file. This ensures scrollback is
19//! written incrementally as lines scroll off screen, avoiding O(n) work on mode switches.
20
21use super::term::TerminalState;
22use crate::services::async_bridge::AsyncBridge;
23use crate::services::authority::TerminalWrapper;
24use portable_pty::{native_pty_system, CommandBuilder, PtySize};
25use std::borrow::Cow;
26use std::collections::HashMap;
27use std::io::{Read, Write};
28use std::sync::atomic::AtomicBool;
29use std::sync::mpsc;
30use std::sync::{Arc, Mutex};
31use std::thread;
32
33pub use fresh_core::TerminalId;
34
35/// Messages sent to terminal I/O thread
36enum TerminalCommand {
37 /// Write data to PTY
38 Write(Vec<u8>),
39 /// Resize the PTY
40 Resize { cols: u16, rows: u16 },
41 /// Shutdown the terminal
42 Shutdown,
43}
44
45/// Handle to a running terminal session
46pub struct TerminalHandle {
47 /// Terminal state (grid, cursor, etc.)
48 pub state: Arc<Mutex<TerminalState>>,
49 /// Command sender to I/O thread
50 command_tx: mpsc::Sender<TerminalCommand>,
51 /// Whether the terminal is still alive
52 alive: Arc<std::sync::atomic::AtomicBool>,
53 /// Current dimensions
54 cols: u16,
55 rows: u16,
56 /// Working directory used for the terminal
57 cwd: Option<std::path::PathBuf>,
58 /// Shell executable used to spawn the terminal
59 shell: String,
60 /// PID of the shell child process at the head of the pty's
61 /// session. `kill(-pid, signal)` (note the negation) signals
62 /// the entire process group, which catches subprocesses the
63 /// shell or agent forked. `None` on Windows or when
64 /// portable_pty couldn't report the pid.
65 pid: Option<u32>,
66 /// PTY master file descriptor, captured at spawn. Used to read the
67 /// terminal's foreground process group via `tcgetpgrp` for tmux-style
68 /// tab auto-naming. `None` on Windows or when the platform doesn't
69 /// expose it. Only read on Linux (the only `/proc`-backed target).
70 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
71 master_fd: Option<i32>,
72}
73
74impl TerminalHandle {
75 /// Write data to the terminal (sends to PTY)
76 pub fn write(&self, data: &[u8]) {
77 // Receiver may be dropped if terminal exited; nothing to do in that case.
78 #[allow(clippy::let_underscore_must_use)]
79 let _ = self.command_tx.send(TerminalCommand::Write(data.to_vec()));
80 }
81
82 /// Resize the terminal
83 pub fn resize(&mut self, cols: u16, rows: u16) {
84 if cols != self.cols || rows != self.rows {
85 self.cols = cols;
86 self.rows = rows;
87 // Receiver may be dropped if terminal exited; nothing to do in that case.
88 #[allow(clippy::let_underscore_must_use)]
89 let _ = self.command_tx.send(TerminalCommand::Resize { cols, rows });
90 // Also resize the terminal state
91 if let Ok(mut state) = self.state.lock() {
92 state.resize(cols, rows);
93 }
94 }
95 }
96
97 /// Check if the terminal is still running
98 pub fn is_alive(&self) -> bool {
99 self.alive.load(std::sync::atomic::Ordering::Relaxed)
100 }
101
102 /// Shutdown the terminal
103 pub fn shutdown(&self) {
104 // Receiver may be dropped if terminal already exited; nothing to do in that case.
105 #[allow(clippy::let_underscore_must_use)]
106 let _ = self.command_tx.send(TerminalCommand::Shutdown);
107 }
108
109 /// Pid of the shell at the head of the pty session, when
110 /// portable_pty was able to report it. Returns `None` on
111 /// platforms / configurations that don't expose a pid.
112 pub fn pid(&self) -> Option<u32> {
113 self.pid
114 }
115
116 /// Name of the command currently in the foreground of this terminal,
117 /// e.g. `"bash"` at the prompt or `"python3"` while a REPL runs.
118 ///
119 /// Derived from the PTY's foreground process *group* (`tcgetpgrp` on
120 /// the master fd) rather than the shell pid, so it tracks whatever the
121 /// user is actually interacting with — the same signal tmux uses for
122 /// `#{pane_current_command}`. This is how a tab can read `python3`
123 /// even though `python3` never emits an OSC title sequence.
124 ///
125 /// Only implemented on Linux (via `/proc/<pgid>/comm`); returns `None`
126 /// elsewhere so callers fall back to the OSC title or default name.
127 pub fn foreground_process_name(&self) -> Option<String> {
128 #[cfg(target_os = "linux")]
129 {
130 let fd = self.master_fd?;
131 // SAFETY: `fd` is the PTY master, kept open by the writer
132 // thread for the terminal's lifetime. `tcgetpgrp` only reads.
133 let pgid = unsafe { libc::tcgetpgrp(fd) };
134 if pgid <= 0 {
135 return None;
136 }
137 // Local OS introspection of a local fd. The `FileSystem` trait
138 // abstracts the *editing* filesystem (possibly remote); it does
139 // not apply to reading this host's `/proc`.
140 let comm = std::fs::read_to_string(format!("/proc/{pgid}/comm")).ok()?;
141 let name = comm.trim();
142 if name.is_empty() {
143 None
144 } else {
145 Some(name.to_string())
146 }
147 }
148 #[cfg(not(target_os = "linux"))]
149 {
150 None
151 }
152 }
153
154 /// Send `signal` to the terminal's process group. Returns
155 /// `Ok(false)` when the terminal has no recorded pid
156 /// (Windows, or platforms where portable_pty didn't report
157 /// one) — caller can fall back to `shutdown()` (SIGKILL via
158 /// child_killer). The shell is always its own session
159 /// leader inside a pty, so `kill(-pid, …)` reaches the
160 /// shell *and* any subprocesses it forked.
161 ///
162 /// Recognised signal names: `"SIGTERM"`, `"SIGKILL"`,
163 /// `"SIGINT"`, `"SIGHUP"`. Unknown names return an Err
164 /// instead of dropping silently.
165 #[cfg(unix)]
166 pub fn signal(&self, signal_name: &str) -> Result<bool, String> {
167 let Some(pid) = self.pid else {
168 return Ok(false);
169 };
170 let sig = match signal_name {
171 "SIGTERM" => libc::SIGTERM,
172 "SIGKILL" => libc::SIGKILL,
173 "SIGINT" => libc::SIGINT,
174 "SIGHUP" => libc::SIGHUP,
175 other => return Err(format!("unsupported signal: {}", other)),
176 };
177 // `kill(-pid, sig)` targets the process group whose
178 // leader is `pid`. The pty puts the spawned shell at
179 // the head of its own session, so this catches
180 // sub-processes the shell or agent forked.
181 let rc = unsafe { libc::kill(-(pid as i32), sig) };
182 if rc == 0 {
183 Ok(true)
184 } else {
185 let err = std::io::Error::last_os_error();
186 // ESRCH = no such process group. Treat as
187 // "nothing to signal" rather than an error so the
188 // caller's stop flow stays idempotent.
189 if err.raw_os_error() == Some(libc::ESRCH) {
190 Ok(false)
191 } else {
192 Err(format!("kill(-{}, {}): {}", pid, signal_name, err))
193 }
194 }
195 }
196
197 /// Windows fallback: no real signal semantics. SIGKILL is
198 /// modelled as the existing `shutdown()` (which calls the
199 /// pty child killer); other signals are unsupported and
200 /// return Ok(false).
201 #[cfg(windows)]
202 pub fn signal(&self, signal_name: &str) -> Result<bool, String> {
203 if signal_name == "SIGKILL" {
204 self.shutdown();
205 return Ok(true);
206 }
207 Ok(false)
208 }
209
210 /// Get current dimensions
211 pub fn size(&self) -> (u16, u16) {
212 (self.cols, self.rows)
213 }
214
215 /// Get the working directory configured for the terminal
216 pub fn cwd(&self) -> Option<std::path::PathBuf> {
217 self.cwd.clone()
218 }
219
220 /// Get the shell executable path used for this terminal
221 pub fn shell(&self) -> &str {
222 &self.shell
223 }
224}
225
226/// Manager for multiple terminal sessions
227pub struct TerminalManager {
228 /// The window that owns this manager. Terminal IDs are only unique
229 /// within a single manager (each starts numbering at 0), so output
230 /// messages are tagged with `(window_id, terminal_id)` — see
231 /// [`fresh_core::WindowTerminalId`] — to stay unambiguous once they
232 /// leave this window's context (e.g. on the async bus).
233 window_id: fresh_core::WindowId,
234 /// Map from terminal ID to handle
235 terminals: HashMap<TerminalId, TerminalHandle>,
236 /// Next terminal ID
237 next_id: usize,
238 /// Async bridge for sending notifications to main loop
239 async_bridge: Option<AsyncBridge>,
240}
241
242impl TerminalManager {
243 /// Create a new terminal manager owned by `window_id`. The owner is
244 /// required (not defaulted) so output can never be attributed to the
245 /// wrong window: every terminal this manager spawns is tagged with
246 /// it.
247 pub fn new(window_id: fresh_core::WindowId) -> Self {
248 Self {
249 window_id,
250 terminals: HashMap::new(),
251 next_id: 0,
252 async_bridge: None,
253 }
254 }
255
256 /// The window that owns this manager.
257 pub fn window_id(&self) -> fresh_core::WindowId {
258 self.window_id
259 }
260
261 /// Set the async bridge for communication with main loop
262 pub fn set_async_bridge(&mut self, bridge: AsyncBridge) {
263 self.async_bridge = Some(bridge);
264 }
265
266 /// Peek at the next terminal ID that would be assigned.
267 pub fn next_terminal_id(&self) -> TerminalId {
268 TerminalId(self.next_id)
269 }
270
271 /// Spawn a new terminal session
272 ///
273 /// # Arguments
274 /// * `cols` - Initial terminal width in columns
275 /// * `rows` - Initial terminal height in rows
276 /// * `cwd` - Optional working directory (defaults to current directory)
277 /// * `log_path` - Optional path for raw PTY log (for session restore)
278 /// * `backing_path` - Optional path for rendered scrollback (incremental streaming)
279 ///
280 /// # Returns
281 /// The terminal ID if successful
282 pub fn spawn(
283 &mut self,
284 cols: u16,
285 rows: u16,
286 cwd: Option<std::path::PathBuf>,
287 log_path: Option<std::path::PathBuf>,
288 backing_path: Option<std::path::PathBuf>,
289 terminal_wrapper: crate::services::authority::TerminalWrapper,
290 ) -> Result<TerminalId, String> {
291 let id = TerminalId(self.next_id);
292 self.next_id += 1;
293
294 // Try to spawn a real PTY-backed terminal first.
295 let handle_result: Result<TerminalHandle, String> = (|| {
296 // Create PTY
297 let pty_system = native_pty_system();
298 let pty_pair = pty_system
299 .openpty(PtySize {
300 rows,
301 cols,
302 pixel_width: 0,
303 pixel_height: 0,
304 })
305 .map_err(|e| {
306 #[cfg(windows)]
307 {
308 format!(
309 "Failed to open PTY: {}. Note: Terminal requires Windows 10 version 1809 or later with ConPTY support.",
310 e
311 )
312 }
313 #[cfg(not(windows))]
314 {
315 format!("Failed to open PTY: {}", e)
316 }
317 })?;
318
319 // The active authority's terminal wrapper drives the shell
320 // command unconditionally — local wraps `detect_shell()` with
321 // no args; container/remote authorities re-parent into
322 // `docker exec -w …`, `ssh …`, etc. `manages_cwd` says
323 // whether the wrapper's args already establish cwd (in which
324 // case `CommandBuilder::cwd()` is skipped).
325 let TerminalWrapper {
326 command: shell,
327 args: cmd_args,
328 manages_cwd: skip_cwd,
329 } = terminal_wrapper;
330 tracing::info!("Spawning terminal with shell: {}", shell);
331
332 let mut cmd = CommandBuilder::new(&shell);
333 for arg in &cmd_args {
334 cmd.arg(arg);
335 }
336 if !skip_cwd {
337 if let Some(ref dir) = cwd {
338 // Hand the shell a non-verbatim path. Fresh canonicalizes
339 // working_dir at startup, which on Windows yields
340 // `\\?\C:\…`. PowerShell can't infer a drive from a
341 // verbatim path and falls back to the fully-qualified
342 // provider form, producing prompts like
343 // `PS Microsoft.PowerShell.Core\FileSystem::\\?\C:\…>`.
344 cmd.cwd(strip_verbatim_prefix(dir).as_ref());
345 }
346 }
347
348 // Set TERM so programs like less know the terminal capabilities.
349 // The built-in emulator is alacritty-based so xterm-256color is appropriate.
350 cmd.env("TERM", "xterm-256color");
351
352 // Advertise this editor's local control socket to local child
353 // shells via FRESH_SESSION, so a `fresh` launched from inside
354 // this embedded terminal forwards file/dir opens back to us
355 // instead of starting a second editor. Only for the local host
356 // shell: `manages_cwd` (== `skip_cwd`) marks docker/ssh-style
357 // wrappers, whose inner `fresh` runs on another host where this
358 // unix socket isn't reachable. (A nested `fresh` that can't reach
359 // the socket falls back to running inline, so this gate is an
360 // optimization, not a correctness requirement.)
361 if !skip_cwd {
362 if let Some(session_id) = crate::server::local_control::local_session_id() {
363 cmd.env("FRESH_SESSION", session_id);
364 }
365 }
366
367 // On Windows, set additional environment variables that help with ConPTY
368 #[cfg(windows)]
369 {
370 // Ensure PROMPT is set for cmd.exe
371 if shell.to_lowercase().contains("cmd") {
372 cmd.env("PROMPT", "$P$G");
373 }
374 }
375
376 // Spawn the shell process
377 let mut child = pty_pair
378 .slave
379 .spawn_command(cmd)
380 .map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
381
382 tracing::debug!("Shell process spawned successfully");
383
384 // Capture the child's pid before it moves into the
385 // wait-thread. The pty puts the shell at the head of
386 // its own session, so `kill(-pid, signal)` signals
387 // every process the shell has spawned.
388 let child_pid = child.process_id();
389
390 // Pull a separate killer handle so the writer thread
391 // can request termination on `Shutdown` without owning
392 // `child` itself. `child` moves into the dedicated
393 // wait-thread below, where its exit status is captured
394 // and propagated through `AsyncMessage::TerminalExited`.
395 let mut child_killer = child.clone_killer();
396
397 // Create terminal state
398 let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
399
400 // Initialize backing_file_history_end if backing file already exists (session restore)
401 // This ensures enter_terminal_mode doesn't truncate existing history to 0
402 if let Some(ref p) = backing_path {
403 if let Ok(metadata) = std::fs::metadata(p) {
404 if metadata.len() > 0 {
405 if let Ok(mut s) = state.lock() {
406 s.set_backing_file_history_end(metadata.len());
407 }
408 }
409 }
410 }
411
412 // Create communication channel
413 let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
414
415 // Alive flag
416 let alive = Arc::new(AtomicBool::new(true));
417 let alive_clone = alive.clone();
418
419 // Get master for I/O
420 let mut master = pty_pair
421 .master
422 .take_writer()
423 .map_err(|e| format!("Failed to get PTY writer: {}", e))?;
424
425 let mut reader = pty_pair
426 .master
427 .try_clone_reader()
428 .map_err(|e| format!("Failed to get PTY reader: {}", e))?;
429
430 // Clone state for reader thread
431 let state_clone = state.clone();
432 let async_bridge = self.async_bridge.clone();
433
434 // Optional raw log writer for full-session capture (for live terminal resume)
435 let mut log_writer = log_path
436 .as_ref()
437 .and_then(|p| {
438 std::fs::OpenOptions::new()
439 .create(true)
440 .append(true)
441 .open(p)
442 .ok()
443 })
444 .map(std::io::BufWriter::new);
445
446 // Backing file writer for incremental scrollback streaming
447 // During session restore, the backing file may already contain scrollback content.
448 // We open for append to continue streaming new scrollback after the existing content.
449 // For new terminals, append mode also works (creates file if needed).
450 let mut backing_writer = backing_path
451 .as_ref()
452 .and_then(|p| {
453 // Check if backing file exists and has content (session restore case)
454 let existing_has_content =
455 p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
456
457 if existing_has_content {
458 // Session restore: open for append to continue streaming new scrollback
459 // The existing content is preserved and loaded into buffer separately.
460 // Note: enter_terminal_mode will truncate when user re-enters terminal.
461 std::fs::OpenOptions::new()
462 .create(true)
463 .append(true)
464 .open(p)
465 .ok()
466 } else {
467 // New terminal: start fresh with truncate
468 std::fs::OpenOptions::new()
469 .create(true)
470 .write(true)
471 .truncate(true)
472 .open(p)
473 .ok()
474 }
475 })
476 .map(std::io::BufWriter::new);
477
478 // Spawn reader thread
479 let terminal_id = id;
480 // Tag output/exit with the owning window so the main loop
481 // never has to guess which session a `Terminal-N` belongs to
482 // (ids collide across windows). `Copy`, so both threads below
483 // capture it independently.
484 let wt_id = fresh_core::WindowTerminalId::new(self.window_id, terminal_id);
485 let pty_response_tx = command_tx.clone();
486 thread::spawn(move || {
487 tracing::debug!("Terminal {:?} reader thread started", terminal_id);
488 let mut buf = [0u8; 4096];
489 let mut total_bytes = 0usize;
490 loop {
491 match reader.read(&mut buf) {
492 Ok(0) => {
493 // EOF - process exited
494 tracing::info!(
495 "Terminal {:?} EOF after {} total bytes",
496 terminal_id,
497 total_bytes
498 );
499 break;
500 }
501 Ok(n) => {
502 total_bytes += n;
503 tracing::debug!(
504 "Terminal {:?} received {} bytes (total: {})",
505 terminal_id,
506 n,
507 total_bytes
508 );
509 // Process output through terminal emulator and stream scrollback
510 if let Ok(mut state) = state_clone.lock() {
511 state.process_output(&buf[..n]);
512
513 // Send any PTY write responses (e.g., DSR cursor position)
514 // This is critical for Windows ConPTY where PowerShell waits
515 // for cursor position response before showing the prompt
516 for response in state.drain_pty_write_queue() {
517 tracing::debug!(
518 "Terminal {:?} sending PTY response: {:?}",
519 terminal_id,
520 response
521 );
522 // Receiver may be dropped if writer thread exited.
523 #[allow(clippy::let_underscore_must_use)]
524 let _ = pty_response_tx
525 .send(TerminalCommand::Write(response.into_bytes()));
526 }
527
528 // Incrementally stream new scrollback lines to backing file
529 if let Some(ref mut writer) = backing_writer {
530 match state.flush_new_scrollback(writer) {
531 Ok(lines_written) => {
532 if lines_written > 0 {
533 // Update the history end offset
534 if let Ok(pos) = writer.get_ref().metadata() {
535 state.set_backing_file_history_end(pos.len());
536 }
537 // Best-effort flush; backing file errors handled below.
538 #[allow(clippy::let_underscore_must_use)]
539 let _ = writer.flush();
540 }
541 }
542 Err(e) => {
543 tracing::warn!(
544 "Terminal backing file write error: {}",
545 e
546 );
547 backing_writer = None;
548 }
549 }
550 }
551 }
552
553 // Append raw bytes to log if available (for session restore replay)
554 if let Some(w) = log_writer.as_mut() {
555 if let Err(e) = w.write_all(&buf[..n]) {
556 tracing::warn!("Terminal log write error: {}", e);
557 log_writer = None; // stop logging on error
558 } else if let Err(e) = w.flush() {
559 tracing::warn!("Terminal log flush error: {}", e);
560 log_writer = None;
561 }
562 }
563
564 // Notify main loop to redraw (receiver may be dropped during shutdown).
565 if let Some(ref bridge) = async_bridge {
566 #[allow(clippy::let_underscore_must_use)]
567 let _ = bridge.sender().send(
568 crate::services::async_bridge::AsyncMessage::TerminalOutput {
569 terminal: wt_id,
570 },
571 );
572 }
573 }
574 Err(e) => {
575 tracing::error!("Terminal read error: {}", e);
576 break;
577 }
578 }
579 }
580 alive_clone.store(false, std::sync::atomic::Ordering::Relaxed);
581 // Best-effort flush of log/backing files during teardown.
582 if let Some(mut w) = log_writer {
583 #[allow(clippy::let_underscore_must_use)]
584 let _ = w.flush();
585 }
586 if let Some(mut w) = backing_writer {
587 #[allow(clippy::let_underscore_must_use)]
588 let _ = w.flush();
589 }
590 // The wait-thread (spawned below) owns `child` and
591 // is the single source of `TerminalExited`, so the
592 // reader intentionally does not fire it here. Firing
593 // from both threads would race and sometimes produce
594 // `exit_code: None` despite a clean exit.
595 });
596
597 // Wait-thread: blocks on `child.wait()`, captures the
598 // exit status, and fires `TerminalExited` exactly once
599 // with the real code. The reader thread above has
600 // already dropped `alive_clone` to false on PTY EOF, so
601 // by the time we get here the child is either gone or
602 // about to be (the writer thread's killer.kill()
603 // accelerates the latter case for user-initiated
604 // shutdown).
605 let async_bridge_for_wait = self.async_bridge.clone();
606 thread::spawn(move || {
607 let exit_code = match child.wait() {
608 Ok(status) => Some(status.exit_code() as i32),
609 Err(e) => {
610 tracing::warn!("child.wait() failed for {:?}: {}", terminal_id, e);
611 None
612 }
613 };
614 if let Some(ref bridge) = async_bridge_for_wait {
615 #[allow(clippy::let_underscore_must_use)]
616 let _ = bridge.sender().send(
617 crate::services::async_bridge::AsyncMessage::TerminalExited {
618 terminal: wt_id,
619 exit_code,
620 },
621 );
622 }
623 });
624
625 // Capture the PTY master fd before the master moves into the
626 // writer thread below. Used later by `foreground_process_name`
627 // (tmux-style tab auto-naming). The fd stays valid for the
628 // terminal's lifetime because the writer thread owns the master.
629 let master_fd: Option<i32> = {
630 #[cfg(unix)]
631 {
632 pty_pair.master.as_raw_fd()
633 }
634 #[cfg(not(unix))]
635 {
636 None
637 }
638 };
639
640 // Spawn writer thread
641 let pty_size_ref = pty_pair.master;
642 thread::spawn(move || {
643 loop {
644 match command_rx.recv() {
645 Ok(TerminalCommand::Write(data)) => {
646 if let Err(e) = master.write_all(&data) {
647 tracing::error!("Terminal write error: {}", e);
648 break;
649 }
650 // Best-effort flush — PTY write errors are handled above.
651 #[allow(clippy::let_underscore_must_use)]
652 let _ = master.flush();
653 }
654 Ok(TerminalCommand::Resize { cols, rows }) => {
655 if let Err(e) = pty_size_ref.resize(PtySize {
656 rows,
657 cols,
658 pixel_width: 0,
659 pixel_height: 0,
660 }) {
661 tracing::warn!("Failed to resize PTY: {}", e);
662 }
663 }
664 Ok(TerminalCommand::Shutdown) | Err(_) => {
665 break;
666 }
667 }
668 }
669 // User-initiated shutdown: ask the OS to terminate
670 // the child via the cloned killer. The wait-thread
671 // (above) owns `child` and will reap the exit status
672 // for us; we don't call `wait` here because that
673 // would race the wait-thread for the exit status.
674 #[allow(clippy::let_underscore_must_use)]
675 let _ = child_killer.kill();
676 });
677
678 // Create handle
679 Ok(TerminalHandle {
680 state,
681 command_tx,
682 alive,
683 cols,
684 rows,
685 cwd: cwd.clone(),
686 shell,
687 pid: child_pid,
688 master_fd,
689 })
690 })();
691
692 let handle = handle_result?;
693
694 self.terminals.insert(id, handle);
695 tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
696
697 Ok(id)
698 }
699
700 /// Get a terminal handle by ID
701 pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
702 self.terminals.get(&id)
703 }
704
705 /// Get a mutable terminal handle by ID
706 pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
707 self.terminals.get_mut(&id)
708 }
709
710 /// Close a terminal
711 pub fn close(&mut self, id: TerminalId) -> bool {
712 if let Some(handle) = self.terminals.remove(&id) {
713 handle.shutdown();
714 true
715 } else {
716 false
717 }
718 }
719
720 /// Get all terminal IDs
721 pub fn terminal_ids(&self) -> Vec<TerminalId> {
722 self.terminals.keys().copied().collect()
723 }
724
725 /// Get count of open terminals
726 pub fn count(&self) -> usize {
727 self.terminals.len()
728 }
729
730 /// Shutdown all terminals
731 pub fn shutdown_all(&mut self) {
732 for (_, handle) in self.terminals.drain() {
733 handle.shutdown();
734 }
735 }
736
737 /// Clean up dead terminals
738 pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
739 let dead: Vec<TerminalId> = self
740 .terminals
741 .iter()
742 .filter(|(_, h)| !h.is_alive())
743 .map(|(id, _)| *id)
744 .collect();
745
746 for id in &dead {
747 self.terminals.remove(id);
748 }
749
750 dead
751 }
752}
753
754impl Drop for TerminalManager {
755 fn drop(&mut self) {
756 self.shutdown_all();
757 }
758}
759
760/// Convert a Windows verbatim path (`\\?\C:\…` or `\\?\UNC\server\share\…`)
761/// into its non-verbatim equivalent (`C:\…` or `\\server\share\…`).
762///
763/// Returns the input unchanged on non-Windows platforms or for paths that
764/// have no verbatim prefix.
765pub(crate) fn strip_verbatim_prefix(path: &std::path::Path) -> Cow<'_, std::path::Path> {
766 #[cfg(windows)]
767 {
768 use std::path::{Component, Prefix};
769
770 let mut components = path.components();
771 let prefix = match components.next() {
772 Some(Component::Prefix(p)) => p,
773 _ => return Cow::Borrowed(path),
774 };
775
776 let mut rebuilt = std::path::PathBuf::new();
777 match prefix.kind() {
778 Prefix::VerbatimDisk(drive) => {
779 rebuilt.push(format!("{}:\\", drive as char));
780 }
781 Prefix::VerbatimUNC(server, share) => {
782 rebuilt.push(format!(
783 r"\\{}\{}\",
784 server.to_string_lossy(),
785 share.to_string_lossy()
786 ));
787 }
788 _ => return Cow::Borrowed(path),
789 }
790 // Skip the original RootDir (which the rebuilt prefix already includes)
791 // and append the rest of the components.
792 for component in components {
793 if matches!(component, Component::RootDir) {
794 continue;
795 }
796 rebuilt.push(component.as_os_str());
797 }
798 Cow::Owned(rebuilt)
799 }
800 #[cfg(not(windows))]
801 {
802 Cow::Borrowed(path)
803 }
804}
805
806/// Detect the user's shell
807pub fn detect_shell() -> String {
808 // Try $SHELL environment variable first
809 if let Ok(shell) = std::env::var("SHELL") {
810 if !shell.is_empty() {
811 return shell;
812 }
813 }
814
815 // Fall back to platform defaults
816 #[cfg(unix)]
817 {
818 "/bin/sh".to_string()
819 }
820 #[cfg(windows)]
821 {
822 super::windows_shell::select_windows_shell()
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829
830 #[test]
831 fn test_terminal_id_display() {
832 let id = TerminalId(42);
833 assert_eq!(format!("{}", id), "Terminal-42");
834 }
835
836 /// Terminal ids are per-window: each manager numbers from 0, so two
837 /// windows both hand out `Terminal-0`. The owning window is what
838 /// disambiguates them — output messages are tagged with the
839 /// `(window, terminal)` pair so a `Terminal-0` from one session can't
840 /// be attributed to another session's `Terminal-0`. (Regression
841 /// guard for the dock "pending output on the wrong session" bug.)
842 #[test]
843 fn terminal_ids_collide_across_windows_but_window_disambiguates() {
844 use fresh_core::{WindowId, WindowTerminalId};
845
846 let win_a = TerminalManager::new(WindowId(1));
847 let win_b = TerminalManager::new(WindowId(2));
848
849 // Both managers would assign the same local id to their first
850 // terminal — the namespaces are independent.
851 assert_eq!(win_a.next_terminal_id(), win_b.next_terminal_id());
852 assert_eq!(win_a.next_terminal_id(), TerminalId(0));
853
854 // Each manager knows its owner, so the global identity differs.
855 assert_eq!(win_a.window_id(), WindowId(1));
856 assert_eq!(win_b.window_id(), WindowId(2));
857 let a0 = WindowTerminalId::new(win_a.window_id(), win_a.next_terminal_id());
858 let b0 = WindowTerminalId::new(win_b.window_id(), win_b.next_terminal_id());
859 assert_ne!(
860 a0, b0,
861 "same local terminal id in different windows must be distinct globally"
862 );
863 }
864
865 #[test]
866 fn test_detect_shell() {
867 let shell = detect_shell();
868 assert!(!shell.is_empty());
869 }
870
871 #[cfg(not(windows))]
872 #[test]
873 fn strip_verbatim_prefix_is_noop_on_unix() {
874 use std::path::Path;
875 let p = Path::new("/home/user/project");
876 assert_eq!(strip_verbatim_prefix(p).as_ref(), p);
877 }
878
879 #[cfg(windows)]
880 #[test]
881 fn strip_verbatim_prefix_removes_verbatim_disk() {
882 use std::path::{Path, PathBuf};
883 let verbatim = PathBuf::from(r"\\?\C:\Users\HP\OneDrive\Desktop\PY'PGMS");
884 let stripped = strip_verbatim_prefix(&verbatim);
885 assert_eq!(
886 stripped.as_ref(),
887 Path::new(r"C:\Users\HP\OneDrive\Desktop\PY'PGMS"),
888 "verbatim disk prefix should be replaced with plain drive form"
889 );
890 }
891
892 #[cfg(windows)]
893 #[test]
894 fn strip_verbatim_prefix_removes_verbatim_unc() {
895 use std::path::{Path, PathBuf};
896 let verbatim = PathBuf::from(r"\\?\UNC\server\share\dir\file");
897 let stripped = strip_verbatim_prefix(&verbatim);
898 assert_eq!(
899 stripped.as_ref(),
900 Path::new(r"\\server\share\dir\file"),
901 "verbatim UNC prefix should be replaced with plain UNC form"
902 );
903 }
904
905 #[cfg(windows)]
906 #[test]
907 fn strip_verbatim_prefix_passes_plain_paths_through() {
908 use std::path::{Path, PathBuf};
909 let plain = PathBuf::from(r"C:\Users\HP\project");
910 let result = strip_verbatim_prefix(&plain);
911 assert_eq!(result.as_ref(), Path::new(r"C:\Users\HP\project"));
912 }
913}