feat(vterm): establish Stage 2 view authority contracts
Add exact frontend/window/buffer view identities, logical-cell selections, durable controller ownership, lifecycle cleanup, default-name uniquification, and a nested authenticated interactive-command origin shared with Lua. These contracts let the terminal core and TUI lanes proceed without inventing parallel state. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9dafdd4c89
commit
1b32f81033
|
|
@ -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<Cell<Option<FrontendId>>>);
|
||||
|
||||
impl InteractiveCommandOrigin {
|
||||
/// Current authenticated frontend while an interactive command runs.
|
||||
#[must_use]
|
||||
pub(crate) fn current(&self) -> Option<FrontendId> {
|
||||
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<FrontendId>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<Value>)| {
|
||||
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||
let frontend_id = lua.app_data_ref::<SharedCore>().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::<crate::editor::InteractiveCommandOrigin>()
|
||||
.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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<BufferId, TerminalSession>,
|
||||
pub(super) sessions: HashMap<BufferId, TerminalSession>,
|
||||
process_to_buffer: HashMap<ProcessId, BufferId>,
|
||||
/// Removed buffers whose children are still being reaped. Their events
|
||||
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
|
||||
closing: HashSet<ProcessId>,
|
||||
/// Per-frontend/window projections over the one session screen.
|
||||
pub(super) views: HashMap<TerminalViewKey, TerminalViewState>,
|
||||
/// At most one authenticated frontend/window controls each session PTY.
|
||||
pub(super) controllers: HashMap<BufferId, TerminalController>,
|
||||
}
|
||||
|
||||
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<TerminalViewKey>,
|
||||
) {
|
||||
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<TerminalController> {
|
||||
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<TerminalSnapshot> {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<LogicalCellAnchor>,
|
||||
/// Inclusive logical-cell selection.
|
||||
pub selection: Option<TerminalSelection>,
|
||||
/// Current editor-owned drag endpoint; cleared on release.
|
||||
pub drag: Option<LogicalCellAnchor>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue