From 9fee5618eefeca2478e14962717a8f1b8d40efe1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:59:34 +0200 Subject: [PATCH] feat(window): a destination any async continuation can capture Journey Stage 1a built `pmacs.window.commit_to` for the continuation boundary --- "the listing settles a tick or more later, and by then the ambient frontend, selected window, and active buffer may all name something else" --- but nothing outside the `path.open-directory` dispatch could mint a destination to hand it. Every other async Lua continuation therefore resolved its target from ambient state a tick after the request, which is PR #227's P1a finding: run `git.status` in frontend A, let B become active, and A's panel opens in B. This is the prerequisite lane #227 blocks on (`docs/destination-capture-framing.md`, revision 5). No adopter here: git's adoption is #227's work, since a prerequisite that converts its own first consumer cannot be reviewed separately from it. Three parts. **`pmacs.window.capture_destination()`** returns the same nonconstructible userdata for the current frontend. No arguments, and that is load-bearing rather than minimal (Q#DC-1): a Lua-supplied frontend id would reintroduce exactly the fabrication hole the userdata design closes. Profile-blind for the same kind of reason (Q#DC-4) --- capture freezes what is true now, and what a commit depends on is declared later, at the commit. **`DirectoryDestination` -> `ViewDestination`**, with the Lua userdata and the capture renamed to match. The captured triple was already generic; only its name and its capture site were not. The document pair is now `Option`, set and cleared together, so a frontend with no live document window still captures rather than returning nothing and sending the caller back to the ambient state this exists to replace. **`commit_to(dest, body [, profile])`** (Q#DC-2/Q#DC-5), a closed set of two. The document profile keeps all four preflight checks. The panel profile keeps only the first --- the requesting frontend still has a layout --- because a panel result does not occupy the captured document window, does not replace its buffer, and does not need it to exist, so each of the other three would refuse for a reason unrelated to what the continuation does. Omitting the profile means `"document"`, which is what makes the preservation promise contractual rather than careful: every existing two-argument caller keeps all four checks by definition of the signature. The profile argument is typed `mlua::Value`, NOT `Option`, so its error is REACHABLE: with the narrower type mlua rejects a number or a table during argument conversion, before the closure body runs, and the message naming the accepted values never appears. That is the same trap the `dest` argument documents one position to its left. `nil` and absence are the same answer; anything else is refused by one message that names both accepted values. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 2 +- src/editor.rs | 28 +++-- src/editor_core.rs | 81 +++++++++---- src/lua_bindings/mod.rs | 33 ++++-- src/lua_bindings/window_panel.rs | 189 +++++++++++++++++++++++++------ 5 files changed, 259 insertions(+), 74 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index fff5e21..00c648b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1801,7 +1801,7 @@ fn open_initial_target( let (buffer_id, fire) = match resolved { crate::editor_core::ResolvedTarget::Directory { path } => { let dest = editor - .capture_directory_destination(frontend_id, origin_window) + .capture_view_destination(frontend_id, origin_window) .ok_or_else(|| format!("cannot open {}: no document window", path.display()))?; editor.dispatch_directory_open(&path, dest); editor.reconcile_panel_layout(frontend_id); diff --git a/src/editor.rs b/src/editor.rs index 8d80a6c..eb58138 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1219,22 +1219,32 @@ impl EditorState { } /// Capture the destination a directory open must commit to - /// (Q#JR14), or `None` when `frontend` has no document window. + /// (Q#JR14), or `None` when `window` is gone. /// /// Synchronous by necessity: the listing settles a tick or more /// later, and by then the ambient frontend, selected window, and /// active buffer may all name something else. - pub(crate) fn capture_directory_destination( + /// + /// Takes the window **explicitly**, unlike + /// [`crate::editor_core::EditorCore::capture_view_destination`], + /// which reads the ambient one. Both directory callers already hold + /// the exact window the open was resolved against — the daemon's is + /// read before `resolve_target_buffer` runs (Q#BP11b) — and + /// recapturing it from ambient state here would discard that. + /// A directory open therefore always yields a full document pair, + /// which is why this keeps returning `Option` rather than the total + /// capture's `ViewDestination`. + pub(crate) fn capture_view_destination( &self, frontend: crate::protocol::FrontendId, window: crate::window::WindowId, - ) -> Option { + ) -> Option { let core = self.core.borrow(); let buffer = core.windows.get(&window)?.buffer_id; - Some(crate::editor_core::DirectoryDestination { + Some(crate::editor_core::ViewDestination { frontend, - window, - buffer, + window: Some(window), + buffer: Some(buffer), }) } @@ -1258,7 +1268,7 @@ impl EditorState { .borrow() .primary_document_window(crate::protocol::FrontendId::LOCAL); let dest = window.and_then(|window| { - self.capture_directory_destination(crate::protocol::FrontendId::LOCAL, window) + self.capture_view_destination(crate::protocol::FrontendId::LOCAL, window) }); let Some(dest) = dest else { self.core.borrow_mut().status = @@ -1288,13 +1298,13 @@ impl EditorState { pub(crate) fn dispatch_directory_open( &mut self, path: &std::path::Path, - dest: crate::editor_core::DirectoryDestination, + dest: crate::editor_core::ViewDestination, ) { let display = path.display().to_string(); let args = { let lua = self.lua_host.lua(); let destination = - match lua.create_userdata(crate::lua_bindings::DirectoryDestinationLua(dest)) { + match lua.create_userdata(crate::lua_bindings::ViewDestinationLua(dest)) { Ok(userdata) => mlua::Value::UserData(userdata), Err(error) => { self.core.borrow_mut().status = format!("cannot open {display}: {error}"); diff --git a/src/editor_core.rs b/src/editor_core.rs index 0976756..0243968 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -130,39 +130,55 @@ pub enum ResolvedTarget { }, } -/// Where a directory open was requested, captured **synchronously** at -/// resolve time (Journey Stage 1a, Q#JR14). +/// Where an asynchronous continuation's result belongs, captured +/// **synchronously** at request time (Journey Stage 1a, Q#JR14; +/// generalized by `docs/destination-capture-framing.md`). /// -/// The listing that satisfies a directory open is asynchronous -/// (`pmacs.fs.read_dir` is worker-dispatched and must be awaited), so the -/// code that finally builds and displays the listing runs a tick or more -/// later — outside interactive dispatch, where `pmacs.window.*` acts on -/// the *ambient* frontend by documented design (`builtin/runtime/dired.lua`). -/// Without a captured destination, a second frontend dispatching in the -/// meantime silently redirects the listing. +/// The work that satisfies such a request is asynchronous (a directory +/// listing is worker-dispatched and must be awaited; so is a `git` +/// invocation), so the code that finally builds and displays the result +/// runs a tick or more later — outside interactive dispatch, where +/// `pmacs.window.*` acts on the *ambient* frontend by documented design +/// (`builtin/runtime/dired.lua`). Without a captured destination, a +/// second frontend dispatching in the meantime silently redirects the +/// result. /// -/// All three fields are load-bearing: +/// The fields are load-bearing, and the document pair is **optional** +/// (Q#DC-4) because a frontend showing only a side window can still host +/// a panel result: /// -/// * `frontend` — the scope the commit must run in. +/// * `frontend` — the scope the commit must run in. Always present. /// * `window` — the exact destination; the ambient selected window is -/// not it. +/// not it. Absent when the frontend had no document window at capture +/// time. /// * `buffer` — what that window held at capture time, so **stale /// intent loses to the user** (Q#JR14c). A user who replaced the -/// buffer while the listing was in flight is newer information than -/// the launch argument, and must not be overwritten. +/// buffer while the work was in flight is newer information than the +/// launch argument, and must not be overwritten. Present exactly when +/// `window` is. +/// +/// The pair is set or cleared together — see +/// [`EditorCore::capture_view_destination`], which is the only place +/// that reads them off ambient state. +/// +/// Which of those a commit actually requires is the **profile**, chosen +/// at `pmacs.window.commit_to` rather than at capture (Q#DC-2/Q#DC-5): +/// the document profile requires all of them, the panel profile requires +/// only a live `frontend`. Capture stays profile-blind so a caller does +/// not have to know at capture time what it will do at commit time. /// /// Exposed to Lua only as nonconstructible userdata (Q#JR14d): as a /// table, the *same* value is handed to every resolver listener in turn, /// so one could mutate it and then decline — redirecting later listeners /// — and any Lua could fabricate a plausible triple. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct DirectoryDestination { - /// Frontend that requested the directory. +pub struct ViewDestination { + /// Frontend that requested the work. pub frontend: FrontendId, - /// Window the listing must land in. - pub window: WindowId, + /// Window the result must land in, when there is one. + pub window: Option, /// Buffer that window held at capture time (stale-intent check). - pub buffer: BufferId, + pub buffer: Option, } /// A `display_buffer` request (Q#BP3). @@ -3042,6 +3058,33 @@ impl EditorCore { self.non_side_target(fid).ok() } + /// Capture where `fid`'s next asynchronous result belongs (Q#JR14, + /// generalized by Q#DC-1/Q#DC-4). + /// + /// **Profile-blind and total**: it records what is there rather than + /// what a caller intends to do later, and it never fails while a + /// frontend id exists. A frontend with no document window yields a + /// destination carrying only `frontend` — enough for a panel commit, + /// and refused by a document commit with a reason naming the missing + /// window. Returning `None` here instead would push the caller back + /// onto ambient state, which is the misrouting the capture exists to + /// remove. + /// + /// The document pair is set or cleared **together**: a window whose + /// entry has gone yields neither half, so no consumer has to handle + /// a window without its captured buffer. + #[must_use] + pub fn capture_view_destination(&self, fid: FrontendId) -> ViewDestination { + let pair = self + .primary_document_window(fid) + .and_then(|window| Some((window, self.windows.get(&window)?.buffer_id))); + ViewDestination { + frontend: fid, + window: pair.map(|(window, _)| window), + buffer: pair.map(|(_, buffer)| buffer), + } + } + /// [`Self::primary_document_window`]'s buffer, falling back to the /// focused window's when the layout is degenerate. #[must_use] diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index b2de320..d4386d5 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -4239,25 +4239,34 @@ fn install_path_module(lua: &Lua) -> mlua::Result { Ok(path) } -/// Lua handle for a captured directory destination (Q#JR14d). +/// Lua handle for a captured view destination (Q#JR14d). /// -/// Deliberately **nonconstructible from Lua** and read-only. The same -/// value is passed to every `path.open-directory` listener in turn: as a -/// table, an earlier listener could mutate it and then decline, -/// redirecting later listeners or the fallback to a window the user -/// never asked for — and any Lua could fabricate a plausible -/// frontend/window/buffer triple and hand it to `commit_to`. Userdata -/// with no constructor and no setters makes both unrepresentable rather -/// than merely discouraged. +/// Deliberately **nonconstructible from Lua** and read-only, which the +/// generalization to `pmacs.window.capture_destination()` preserves: +/// capture mints one from editor state, and there is still no +/// constructor and no setter. The same value is passed to every +/// `path.open-directory` listener in turn: as a table, an earlier +/// listener could mutate it and then decline, redirecting later +/// listeners or the fallback to a window the user never asked for — and +/// any Lua could fabricate a plausible frontend/window/buffer triple and +/// hand it to `commit_to`. Userdata with no constructor and no setters +/// makes both unrepresentable rather than merely discouraged. /// /// The single accessor exists because dired needs the exact window for /// its `display{window = …}` target; nothing needs the frontend or the /// captured buffer, which stay private to the preflight. -pub(crate) struct DirectoryDestinationLua(pub(crate) crate::editor_core::DirectoryDestination); +/// +/// `window()` returns **nil** when the capturing frontend had no +/// document window (Q#DC-4) — such a destination is still commitable +/// under the panel profile, so the accessor reports the absence rather +/// than inventing an id. +pub(crate) struct ViewDestinationLua(pub(crate) crate::editor_core::ViewDestination); -impl mlua::UserData for DirectoryDestinationLua { +impl mlua::UserData for ViewDestinationLua { fn add_methods>(methods: &mut M) { - methods.add_method("window", |_, this, ()| Ok(this.0.window.raw())); + methods.add_method("window", |_, this, ()| { + Ok(this.0.window.map(crate::window::WindowId::raw)) + }); } } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index fe8b758..11e92b7 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -63,6 +63,65 @@ pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId { .unwrap_or_else(|| core.borrow().active_frontend_key()) } +/// Which of `commit_to`'s preconditions a body actually depends on +/// (Q#DC-2). +/// +/// A **closed** set of two, not an open string namespace: a third +/// profile is a decision about what a continuation may depend on, not a +/// spelling. Chosen at `commit_to` rather than at capture, because the +/// caller knows what it is about to do only then. +#[derive(Clone, Copy, PartialEq, Eq)] +enum CommitProfile { + /// The body replaces the captured window's buffer: **all four** + /// preflight checks apply. This is what an omitted profile means, + /// so every caller written before the profile existed keeps exactly + /// the guarantees it was written against. + Document, + /// The body puts its result somewhere that is not the captured + /// document window — a bottom panel, typically. Only the "requesting + /// frontend still has a layout" check applies; see the preflight for + /// why each of the other three is *deliberately* omitted. + Panel, +} + +/// One message for every bad profile — an unrecognized string and a +/// non-string alike (Q#DC-5). +/// +/// Stated once so the parser and the message cannot drift, and phrased +/// to name the accepted values *and* the default, because a caller who +/// gets this wrong is guessing at the vocabulary. +const BAD_COMMIT_PROFILE: &str = "pmacs.window.commit_to: profile must be the string \"document\" \ + or \"panel\" (omitting it, or passing nil, means \"document\")"; + +/// Resolve the optional third argument of `commit_to`. +/// +/// Takes a [`Value`] rather than an `Option` **so this refusal +/// is reachable**: with the narrower type mlua rejects a number or a +/// table during argument conversion, before the closure body runs, and +/// the caller gets a generic conversion error that names neither the +/// accepted values nor the default. That is the same trap the `dest` +/// argument documents at its own borrow site. +/// +/// `Nil` and absence are the **same** answer, not two: a Lua caller +/// threading an optional variable produces `commit_to(dest, body, nil)`, +/// and a third behaviour there would stay invisible until someone hit +/// it. +fn commit_profile(value: &Value) -> mlua::Result { + match value { + Value::Nil => Ok(CommitProfile::Document), + Value::String(name) => match &*name.to_str()? { + "document" => Ok(CommitProfile::Document), + "panel" => Ok(CommitProfile::Panel), + // An unrecognized profile ERRORS rather than falling back to + // the document one: a fallback would silently hand a caller + // stricter or looser checks than it asked for, which is the + // failure the parameterization exists to prevent. + _ => Err(mlua::Error::runtime(BAD_COMMIT_PROFILE)), + }, + _ => Err(mlua::Error::runtime(BAD_COMMIT_PROFILE)), + } +} + /// Run the panel-reconciliation transaction from a Lua-owning context /// (Q#BP2b). /// @@ -452,7 +511,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result "commit_to", lua.create_function( move |lua, - (dest, body): (mlua::Value, mlua::Function)| + (dest, body, profile): (mlua::Value, mlua::Function, mlua::Value)| -> mlua::Result { // Journey Stage 1a (Q#JR14). Preflight FIRST, then // scope, then run. The ordering is the whole point: @@ -472,7 +531,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result // rule nor how to get a real destination. let dest = match &dest { mlua::Value::UserData(userdata) => { - userdata.borrow::().ok() + userdata.borrow::().ok() } _ => None, }; @@ -484,43 +543,74 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result ) })? .0; + // Q#DC-5. Resolved AFTER the destination so a caller + // who got both wrong hears about the destination + // first --- it is the argument that cannot be fixed + // by reading this signature. + let profile = commit_profile(&profile)?; - // 1. The requesting frontend still has a layout. let refusal = { let core = cc.borrow(); + // 1. The requesting frontend still has a layout. + // Required by BOTH profiles: it is the whole + // of the panel profile (Q#DC-2), because a + // frontend that is gone can host nothing. if !core.views.contains_key(&dest.frontend) { Some("requesting frontend is gone".to_string()) - } else if !core - .views - .get(&dest.frontend) - .is_some_and(|view| view.layout.iter_ids().contains(&dest.window)) - { - // 2. The destination window is still live in it. - Some(format!("window {} is gone", dest.window.raw())) - } else if core - .windows - .get(&dest.window) - .is_some_and(|w| w.buffer_id != dest.buffer) - { - // 3. Stale intent (Q#JR14c): the user - // replaced the buffer while the work was - // in flight. Their action is newer - // information than the request, so the - // request loses. - Some(format!( - "window {} now shows another buffer", - dest.window.raw() - )) - } else if !core.window_accepts_buffer(dest.window, None) { - // 4. Replaceability (Q#JR14f). `None` - // because the replacement does not exist - // yet — passing the captured buffer would - // approve a window dedicated to *it*, and - // the handler's different buffer would be - // refused later, after mutating. - Some(format!("window {} is dedicated", dest.window.raw())) - } else { + } else if profile == CommitProfile::Panel { + // 2, 3 and 4 are DELIBERATELY OMITTED here, + // not overlooked (Q#DC-2). A panel result + // does not occupy the captured document + // window, does not replace its buffer, and + // does not need it to exist --- so each of + // those checks would refuse for a reason + // unrelated to what the continuation does, + // and a refusal a user cannot explain is how + // a mechanism gets worked around. Every one + // of the three is pinned as NOT refusing + // under this profile. None + } else if let Some(window) = dest.window { + if !core + .views + .get(&dest.frontend) + .is_some_and(|view| view.layout.iter_ids().contains(&window)) + { + // 2. The destination window is still live in it. + Some(format!("window {} is gone", window.raw())) + } else if core + .windows + .get(&window) + .is_some_and(|w| Some(w.buffer_id) != dest.buffer) + { + // 3. Stale intent (Q#JR14c): the user + // replaced the buffer while the work was + // in flight. Their action is newer + // information than the request, so the + // request loses. + Some(format!("window {} now shows another buffer", window.raw())) + } else if !core.window_accepts_buffer(window, None) { + // 4. Replaceability (Q#JR14f). `None` + // because the replacement does not exist + // yet — passing the captured buffer would + // approve a window dedicated to *it*, and + // the handler's different buffer would be + // refused later, after mutating. + Some(format!("window {} is dedicated", window.raw())) + } else { + None + } + } else { + // The capture found no document window + // (Q#DC-4). A refusal rather than a raise, so + // it joins the four above as one more thing + // the destination can fail to satisfy and an + // adopter handles it the same way. + Some( + "destination has no document window (capture it from a frontend \ + that has one, or commit with the \"panel\" profile)" + .to_string(), + ) } }; if let Some(reason) = refusal { @@ -563,6 +653,39 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result )?; } + { + // Q#DC-1 — the capture half, reachable from Lua at last. + // + // Journey Stage 1a built `commit_to` for the continuation + // boundary, but the only thing that could mint a destination was + // the `path.open-directory` dispatch, so every other async + // continuation had to resolve its target from ambient state a + // tick after the request --- which is a misrouting waiting for a + // second frontend to become active. + // + // NO ARGUMENTS, and that is load-bearing rather than + // minimalism. A Lua-supplied frontend id would reintroduce + // exactly the fabrication hole the nonconstructible userdata + // closes (Q#JR14d): the point of userdata is that Lua names a + // destination it was *given*, never one it composed. + // + // PROFILE-BLIND, likewise (Q#DC-4). Capture records what is + // there; what a commit depends on is declared at `commit_to`, + // because a caller knows what it is about to do only then. + // Making capture profile-aware would force it to know at capture + // time what it will do at commit time, which is the opposite of + // why capture exists --- freeze the truth early, decide later. + let cc = core.clone(); + win.set( + "capture_destination", + lua.create_function(move |lua, ()| { + let fid = acting_frontend(lua, &cc); + let dest = cc.borrow().capture_view_destination(fid); + lua.create_userdata(super::ViewDestinationLua(dest)) + })?, + )?; + } + { // Q#S3-1 — the shared adopter-display rule, reachable from Lua. //