diff --git a/src/editor.rs b/src/editor.rs index a8590ae..95d2139 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -12,7 +12,7 @@ //! them through the dispatcher, and invokes the resulting Lua commands //! until the user quits. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::io; use std::path::PathBuf; @@ -37,6 +37,43 @@ use crate::protocol::FrontendId; use crate::view::{View, Viewport}; use crate::window::{Rect, WindowId}; +/// Ephemeral authenticated origin for one interactive command invocation. +/// +/// The shared slot is installed as Lua app data so Rust dispatch and nested +/// `pmacs.command.invoke_interactive` calls use the same authority. Guards +/// restore the prior value, which makes nesting safe and clears the outermost +/// origin even when a Lua command errors. +#[derive(Clone, Default)] +pub(crate) struct InteractiveCommandOrigin(Rc>>); + +impl InteractiveCommandOrigin { + /// Current authenticated frontend while an interactive command runs. + #[must_use] + pub(crate) fn current(&self) -> Option { + self.0.get() + } + + /// Enter an interactive command scope for `frontend_id`. + pub(crate) fn enter(&self, frontend_id: FrontendId) -> InteractiveCommandOriginGuard { + let previous = self.0.replace(Some(frontend_id)); + InteractiveCommandOriginGuard { + origin: self.clone(), + previous, + } + } +} + +pub(crate) struct InteractiveCommandOriginGuard { + origin: InteractiveCommandOrigin, + previous: Option, +} + +impl Drop for InteractiveCommandOriginGuard { + fn drop(&mut self) { + self.origin.0.set(self.previous); + } +} + // --------------------------------------------------------------------------- // EditorState // --------------------------------------------------------------------------- @@ -50,6 +87,8 @@ pub struct EditorState { pub lua_host: LuaHost, /// Multi-key prefix state machine. Driven by the run loop. pub dispatcher: KeyDispatcher, + /// Authenticated frontend scoped to the current interactive invocation. + pub(crate) interactive_origin: InteractiveCommandOrigin, /// Main-thread async runtime (T M3.3). Owns the worker pool and /// the message bus pair; [`Self::tick_async`] drives one /// drain-and-resume pass per run-loop iteration. @@ -177,6 +216,8 @@ impl EditorState { Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new())); let core = Rc::new(RefCell::new(EditorCore::new(registry.clone()))); let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization"); + let interactive_origin = InteractiveCommandOrigin::default(); + lua_host.lua().set_app_data(interactive_origin.clone()); lua_host .attach_editor(&core) .expect("editor bindings + builtin chunks"); @@ -487,6 +528,7 @@ impl EditorState { core, lua_host, dispatcher: KeyDispatcher::new(), + interactive_origin, async_runtime, syntax_registry, process_supervisor, diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 9af5b5b..7821d4b 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -5239,11 +5239,18 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua:: command.set( "invoke_interactive", lua.create_function(move |lua, (name, args): (String, Variadic)| { - if let Some(core) = lua.app_data_ref::() { + let frontend_id = lua.app_data_ref::().map(|core| { let mut core = core.borrow_mut(); - let fid = core.active_frontend; - core.rotate_command(fid, &name); - } + let frontend_id = core.active_frontend; + core.rotate_command(frontend_id, &name); + frontend_id + }); + let origin = lua + .app_data_ref::() + .map(|origin| origin.clone()); + let _origin_guard = frontend_id + .zip(origin.as_ref()) + .map(|(frontend_id, origin)| origin.enter(frontend_id)); let body = { let r = cmds.borrow(); r.get(&name) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index ca205c5..267ff32 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -8,11 +8,16 @@ pub mod input; /// Stateful terminal screen model. pub mod screen; pub mod session; +/// Per-context terminal viewport, selection, and controller identities. +pub mod view; pub use session::{ SharedTerminalManager, TerminalError, TerminalManager, TerminalProcessState, TerminalSelectionSpan, TerminalSnapshot, TerminalSpec, }; +pub use view::{ + LogicalCellAnchor, TerminalController, TerminalSelection, TerminalViewKey, TerminalViewState, +}; /// Maximum terminal rows accepted at creation or resize. pub const MAX_TERMINAL_ROWS: u16 = 512; diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 2c1876b..78f62ba 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -17,6 +17,7 @@ use crate::process::{ RestartPolicy, StdinMode, TerminalMode, }; use crate::terminal::screen::TerminalScreen; +use crate::terminal::view::{TerminalController, TerminalViewKey, TerminalViewState}; use crate::terminal::{ MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, @@ -206,22 +207,26 @@ pub enum TerminalError { Process(String), } -struct TerminalSession { - process_id: ProcessId, - pid: u32, - screen: TerminalScreen, - process: TerminalProcessState, - annotated: bool, +pub(super) struct TerminalSession { + pub(super) process_id: ProcessId, + pub(super) pid: u32, + pub(super) screen: TerminalScreen, + pub(super) process: TerminalProcessState, + pub(super) annotated: bool, } /// Owns the one-buffer/one-process/one-screen terminal registry. #[derive(Default)] pub struct TerminalManager { - sessions: HashMap, + pub(super) sessions: HashMap, process_to_buffer: HashMap, /// Removed buffers whose children are still being reaped. Their events /// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch. closing: HashSet, + /// Per-frontend/window projections over the one session screen. + pub(super) views: HashMap, + /// At most one authenticated frontend/window controls each session PTY. + pub(super) controllers: HashMap, } impl TerminalManager { @@ -255,7 +260,12 @@ impl TerminalManager { let screen = TerminalScreen::new(size, spec.scrollback_rows) .map_err(|error| TerminalError::Screen(error.to_string()))?; - let buffer_name = spec.buffer_name(); + let base_name = spec.buffer_name(); + let buffer_name = if spec.name.is_some() { + base_name + } else { + unique_terminal_name(core, &base_name) + }; let buffer_id = BufferId::next(); let mut buffer = Buffer::new(buffer_id, buffer_name.clone()); buffer.set_read_only(true); @@ -338,6 +348,85 @@ impl TerminalManager { .map(|session| session.process_id) } + /// Ensure an exact terminal view exists without changing its controller. + /// + /// Returns `false` when the key's buffer is not a published terminal. + pub fn register_view(&mut self, key: TerminalViewKey) -> bool { + if !self.sessions.contains_key(&key.buffer_id) { + return false; + } + self.views.entry(key).or_default(); + true + } + + /// Borrow fresh mutable state for an already registered exact view. + pub fn view_state_mut(&mut self, key: TerminalViewKey) -> Option<&mut TerminalViewState> { + self.views.get_mut(&key) + } + + /// Borrow fresh state for an already registered exact view. + #[must_use] + pub fn view_state(&self, key: TerminalViewKey) -> Option<&TerminalViewState> { + self.views.get(&key) + } + + /// Retain only `live` views belonging to one authenticated frontend. + pub fn retain_frontend_views( + &mut self, + frontend_id: crate::protocol::FrontendId, + live: &HashSet, + ) { + self.views.retain(|key, _| { + key.frontend_id != frontend_id + || (live.contains(key) && self.sessions.contains_key(&key.buffer_id)) + }); + self.controllers.retain(|buffer_id, controller| { + controller.frontend_id != frontend_id + || live.contains(&TerminalViewKey::new( + frontend_id, + controller.window_id, + *buffer_id, + )) + }); + } + + /// Drop all view and controller state owned by a detached frontend. + pub fn detach_frontend(&mut self, frontend_id: crate::protocol::FrontendId) { + self.views.retain(|key, _| key.frontend_id != frontend_id); + self.controllers + .retain(|_, controller| controller.frontend_id != frontend_id); + } + + /// Give an exact registered view durable PTY control for its session. + pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool { + if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) { + return false; + } + self.controllers + .insert(key.buffer_id, TerminalController::from_view(key)); + true + } + + /// Release control only when `key` is the current controller. + pub fn release_controller(&mut self, key: TerminalViewKey) -> bool { + if self + .controllers + .get(&key.buffer_id) + .is_some_and(|controller| controller.matches(key)) + { + self.controllers.remove(&key.buffer_id); + true + } else { + false + } + } + + /// Current durable controller for one terminal session. + #[must_use] + pub fn controller(&self, buffer_id: BufferId) -> Option { + self.controllers.get(&buffer_id).copied() + } + /// Capture context-free owned visible state after the latest tick. #[must_use] pub fn snapshot(&self, buffer_id: BufferId) -> Option { @@ -488,6 +577,8 @@ impl TerminalManager { continue; }; self.process_to_buffer.remove(&session.process_id); + self.views.retain(|key, _| key.buffer_id != buffer_id); + self.controllers.remove(&buffer_id); match supervisor.state(session.process_id) { Some( ProcessState::Starting @@ -528,9 +619,25 @@ impl TerminalManager { } self.sessions.clear(); self.process_to_buffer.clear(); + self.views.clear(); + self.controllers.clear(); } } +fn unique_terminal_name(core: &EditorCore, base: &str) -> String { + let registry = core.registry.borrow(); + if registry.find_by_name(base).is_none() { + return base.to_owned(); + } + for suffix in 2usize.. { + let candidate = format!("{base}<{suffix}>"); + if registry.find_by_name(&candidate).is_none() { + return candidate; + } + } + unreachable!("unbounded terminal suffix search must find a free name") +} + fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) { if session.annotated { session.process = outcome; diff --git a/src/terminal/view.rs b/src/terminal/view.rs new file mode 100644 index 0000000..b16c462 --- /dev/null +++ b/src/terminal/view.rs @@ -0,0 +1,86 @@ +//! Per-frontend terminal viewport and selection identities. +//! +//! These types identify projections over one [`super::screen::TerminalScreen`]. +//! They never own or mirror terminal cells. + +use crate::buffer::BufferId; +use crate::protocol::FrontendId; +use crate::window::WindowId; + +/// One frontend/window projection of a terminal session. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct TerminalViewKey { + /// Authenticated frontend that owns this view state. + pub frontend_id: FrontendId, + /// Stable editor window showing the terminal. + pub window_id: WindowId, + /// Identity buffer whose session is projected. + pub buffer_id: BufferId, +} + +impl TerminalViewKey { + /// Construct an exact terminal view identity. + #[must_use] + pub const fn new(frontend_id: FrontendId, window_id: WindowId, buffer_id: BufferId) -> Self { + Self { + frontend_id, + window_id, + buffer_id, + } + } +} + +/// Leading display-cell offset within one retained logical line. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LogicalCellAnchor { + /// Stable logical line identity preserved by main-screen reflow. + pub logical_line_id: u64, + /// Leading display-cell offset within that logical line. + pub cell_offset: u32, +} + +/// Inclusive terminal selection endpoints. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalSelection { + /// Fixed endpoint where the drag began. + pub anchor: LogicalCellAnchor, + /// Moving endpoint under the pointer. + pub head: LogicalCellAnchor, +} + +/// Mutable state for one [`TerminalViewKey`]. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TerminalViewState { + /// First visible retained logical cell, or live-tail following when absent. + pub top: Option, + /// Inclusive logical-cell selection. + pub selection: Option, + /// Current editor-owned drag endpoint; cleared on release. + pub drag: Option, +} + +/// The one authenticated frontend/window allowed to control a session's PTY. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalController { + /// Authenticated controlling frontend. + pub frontend_id: FrontendId, + /// Active terminal window on that frontend. + pub window_id: WindowId, +} + +impl TerminalController { + /// Construct a controller from an exact view identity. + #[must_use] + pub const fn from_view(key: TerminalViewKey) -> Self { + Self { + frontend_id: key.frontend_id, + window_id: key.window_id, + } + } + + /// Whether this controller names `key`'s frontend and window. + #[must_use] + pub fn matches(self, key: TerminalViewKey) -> bool { + self.frontend_id == key.frontend_id && self.window_id == key.window_id + } +}