Skip to main content

lattice_agent/
editor_access.rs

1//! AG-2a: the concrete editor-capability handle for the read surface.
2//!
3//! `EditorAccess` composes *generic* services — the crate-owned
4//! [`EditorStateCache`] and the generic `BufferStore` (on-demand text /
5//! dirty) — into Rust-typed answers for `current_selection` /
6//! `open_editors` / `workspace_folders` / `document_dirty`. It carries no
7//! wire-protocol shape (no JSON, no MCP envelope): that translation is each
8//! adapter's job (`lattice_ai::mcp::reads`, and `lattice-ai`'s ACP
9//! mapping).
10//!
11//! Not a trait — one implementation exists. Tests construct it over
12//! in-memory seams (an `EditorStateCache` built directly from events),
13//! mirroring the pre-port `ctx_with` test helper.
14
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18use lattice_grammar::Utf16Pos;
19use lattice_mode::inbound::InboundBus;
20use lattice_protocol::ids::DocumentId;
21use lattice_protocol::{Position, Selection};
22use tokio::sync::oneshot;
23
24use crate::error::{AgentError, Result};
25use crate::state_cache::EditorStateHandle;
26use crate::write_bus::{EditorWriteRequest, InboundKind};
27
28/// Backstop so a write can never hang the caller even if the editor never
29/// resolves the oneshot (it always should — the drain resolves synchronously
30/// once the actor wakes).
31const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
32
33/// The active selection plus the text it covers.
34#[derive(Debug, Clone)]
35pub struct SelectionInfo {
36    pub file_path: Option<std::path::PathBuf>,
37    pub selected_text: String,
38    pub selection: Option<Selection>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct OpenEditor {
43    pub path: String,
44    pub is_active: bool,
45}
46
47/// The concrete editor-capability handle. Clone-able (all fields are cheap
48/// handles), shared between agent tasks off the editor thread.
49#[derive(Clone)]
50pub struct EditorAccess {
51    cache: EditorStateHandle,
52    buffer_store: Option<lattice_mode::BufferStoreHandle>,
53    workspace_folders: Vec<String>,
54    writes: Option<InboundBus<EditorWriteRequest>>,
55}
56
57impl EditorAccess {
58    pub fn new(
59        cache: EditorStateHandle,
60        buffer_store: Option<lattice_mode::BufferStoreHandle>,
61        workspace_folders: Vec<String>,
62        writes: Option<InboundBus<EditorWriteRequest>>,
63    ) -> Self {
64        Self {
65            cache,
66            buffer_store,
67            workspace_folders,
68            writes,
69        }
70    }
71
72    /// The active buffer's path + selection + covered text, if any buffer
73    /// is active. `None` when nothing is active (the "nothing selected"
74    /// case — callers map this to their own empty-result shape).
75    pub fn current_selection(&self) -> Option<SelectionInfo> {
76        let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
77        let active = cache.active.as_ref()?;
78        let path = cache
79            .open_buffers
80            .get(&active.buffer)
81            .and_then(|b| b.path.clone());
82        let sel = *active.selections.primary();
83        let selected_text = self
84            .buffer_store
85            .as_ref()
86            .and_then(|bs| bs.handle_for(core_id(active.buffer)))
87            .map(|doc| slice_selection(&doc.text(), &sel))
88            .unwrap_or_default();
89        Some(SelectionInfo {
90            file_path: path,
91            selected_text,
92            selection: Some(sel),
93        })
94    }
95
96    /// The open file-editor buffers (skips synthetic / unsaved buffers with
97    /// no path).
98    pub fn open_editors(&self) -> Vec<OpenEditor> {
99        let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
100        let active = cache.active.as_ref().map(|a| a.buffer);
101        cache
102            .open_buffers
103            .iter()
104            .filter_map(|(id, b)| {
105                b.path.as_ref().map(|p| OpenEditor {
106                    path: p.display().to_string(),
107                    is_active: Some(*id) == active,
108                })
109            })
110            .collect()
111    }
112
113    pub fn workspace_folders(&self) -> &[String] {
114        &self.workspace_folders
115    }
116
117    /// Dirty flag for `path`. An unknown path (not open, or no buffer-store
118    /// service) is `false`, never an error.
119    pub fn document_dirty(&self, path: &str) -> bool {
120        let id = {
121            let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
122            cache
123                .open_buffers
124                .iter()
125                .find(|(_, b)| b.path.as_deref() == Some(Path::new(path)))
126                .map(|(id, _)| *id)
127        };
128        id.and_then(|id| {
129            self.buffer_store
130                .as_ref()
131                .and_then(|bs| bs.handle_for(core_id(id)))
132        })
133        .map(|doc| doc.dirty())
134        .unwrap_or(false)
135    }
136
137    /// Open `path`, optionally forcing the cursor to `column` (a UTF-16
138    /// position; `None` opens without moving the cursor — re-opening an
139    /// already-open file keeps its position).
140    pub async fn open_file(&self, path: PathBuf, column: Option<Utf16Pos>) -> Result<()> {
141        self.run_write(InboundKind::OpenFile { path, column }).await
142    }
143
144    /// Save the document for `path` (only when it is the active buffer —
145    /// option C, the I3 limitation).
146    pub async fn save_document(&self, path: PathBuf) -> Result<()> {
147        self.run_write(InboundKind::SaveDocument { path }).await
148    }
149
150    /// Close the tab named `tab_name`, scoped to connection
151    /// `origin_session` — the host rejects that connection's diff
152    /// session(s), falling back to the active-buffer file-close.
153    pub async fn close_tab(&self, origin_session: u64, tab_name: String) -> Result<()> {
154        self.run_write(InboundKind::CloseTab {
155            origin_session,
156            tab_name,
157        })
158        .await
159    }
160
161    /// Reject every programmatic diff connection `origin_session` opened.
162    pub async fn close_session_diffs(&self, origin_session: u64) -> Result<()> {
163        self.run_write(InboundKind::CloseAllDiffTabs { origin_session })
164            .await
165    }
166
167    /// Send `kind` on the write bus, await the reply, and map it onto
168    /// [`AgentError`]. The single graceful path for all four write methods —
169    /// a missing bus, a dropped receiver, a timeout, and an `ok: false` reply
170    /// all map to a distinct `AgentError` carrying the ORIGINAL message text
171    /// (never re-wrapped through `Display`, so adapters that emit
172    /// `e.to_string()` don't double-prefix).
173    async fn run_write(&self, kind: InboundKind) -> Result<()> {
174        let Some(bus) = &self.writes else {
175            return Err(AgentError::Bus(
176                "write unavailable: IDE server not fully initialized".to_string(),
177            ));
178        };
179        let (tx, rx) = oneshot::channel();
180        if bus.send(EditorWriteRequest { kind, response: tx }).is_err() {
181            // Receiver dropped — the editor/server is gone.
182            return Err(AgentError::Bus(
183                "write failed: editor not reachable".to_string(),
184            ));
185        }
186        match tokio::time::timeout(WRITE_TIMEOUT, rx).await {
187            Ok(Ok(reply)) if reply.ok => Ok(()),
188            Ok(Ok(reply)) => Err(AgentError::Io(reply.message.unwrap_or_default())),
189            // Sender dropped without replying, or timed out.
190            _ => Err(AgentError::Io(
191                "write failed: editor did not respond".to_string(),
192            )),
193        }
194    }
195}
196
197/// `DocumentId` → the `BufferStore`'s core `BufferId` (same underlying id,
198/// distinct newtypes — mirrors `HostDiagnosticsQuery`).
199fn core_id(id: DocumentId) -> lattice_core::BufferId {
200    lattice_core::BufferId(id.raw() as u32)
201}
202
203/// A `file://` URI for `path`. Used by adapters building a selection result
204/// (`lattice_ai::mcp::reads::selection_result`) alongside `file_path`.
205pub fn path_to_uri(path: &Path) -> String {
206    format!("file://{}", path.display())
207}
208
209/// Absolute byte offset of `pos` within `text` (clamped to `text.len()`).
210fn abs_offset(text: &str, pos: Position) -> usize {
211    let mut offset = 0usize;
212    for (i, line) in text.split_inclusive('\n').enumerate() {
213        if i as u32 == pos.line {
214            return (offset + pos.byte as usize).min(text.len());
215        }
216        offset += line.len();
217    }
218    (offset + pos.byte as usize).min(text.len())
219}
220
221/// The text covered by `sel` within `text` (empty for a cursor). Defensive:
222/// a non-char-boundary range yields `""` rather than a panic.
223fn slice_selection(text: &str, sel: &Selection) -> String {
224    if sel.is_cursor() {
225        return String::new();
226    }
227    let (start, end) = ordered(sel);
228    let (s, e) = (abs_offset(text, start), abs_offset(text, end));
229    text.get(s..e).unwrap_or("").to_string()
230}
231
232/// `(start, end)` in document order. Used by adapters building a selection
233/// result (`lattice_ai::mcp::reads::selection_result`) as well as
234/// internally by [`slice_selection`].
235pub fn ordered(sel: &Selection) -> (Position, Position) {
236    if sel.anchor <= sel.head {
237        (sel.anchor, sel.head)
238    } else {
239        (sel.head, sel.anchor)
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    #![allow(clippy::unwrap_used)]
246    use super::*;
247    use crate::state_cache::EditorStateCache;
248    use lattice_protocol::Event;
249    use std::path::PathBuf;
250    use std::sync::{Arc, Mutex};
251
252    fn access(state: EditorStateCache, workspace: Vec<String>) -> EditorAccess {
253        EditorAccess::new(Arc::new(Mutex::new(state)), None, workspace, None)
254    }
255
256    #[test]
257    fn current_selection_none_when_no_active_buffer() {
258        let ea = access(EditorStateCache::default(), vec![]);
259        assert!(ea.current_selection().is_none());
260    }
261
262    #[test]
263    fn current_selection_some_when_active_buffer_present() {
264        let mut s = EditorStateCache::default();
265        s.apply_event(&Event::DocumentOpened {
266            id: DocumentId::new(1),
267            path: Some(PathBuf::from("/a.rs")),
268            version: 1,
269            text: String::new(),
270        });
271        s.apply_event(&Event::SelectionsChanged {
272            id: DocumentId::new(1),
273            version: 2,
274            selections: lattice_protocol::SelectionSet::default(),
275        });
276        let ea = access(s, vec![]);
277        let sel = ea.current_selection().expect("active buffer set");
278        assert_eq!(sel.file_path.as_deref(), Some(Path::new("/a.rs")));
279        assert!(sel.selection.is_some());
280        // No buffer-store service wired — selected text degrades to empty
281        // rather than erroring.
282        assert_eq!(sel.selected_text, "");
283    }
284
285    #[test]
286    fn document_dirty_false_when_path_not_open() {
287        let ea = access(EditorStateCache::default(), vec![]);
288        assert!(!ea.document_dirty("/nope.rs"));
289    }
290
291    #[test]
292    fn document_dirty_false_when_open_but_no_buffer_store() {
293        let mut s = EditorStateCache::default();
294        s.apply_event(&Event::DocumentOpened {
295            id: DocumentId::new(1),
296            path: Some(PathBuf::from("/a.rs")),
297            version: 1,
298            text: String::new(),
299        });
300        let ea = access(s, vec![]);
301        // The path is known to the cache, but with no buffer-store service
302        // wired there is no dirty flag to read — degrades to false.
303        assert!(!ea.document_dirty("/a.rs"));
304    }
305
306    #[test]
307    fn slice_selection_extracts_between_positions() {
308        let text = "hello\nworld\n";
309        let sel = Selection {
310            anchor: Position::new(0, 0),
311            head: Position::new(0, 5),
312            visual: None,
313        };
314        assert_eq!(slice_selection(text, &sel), "hello");
315        let cur = Selection::cursor(Position::new(1, 2));
316        assert_eq!(slice_selection(text, &cur), "");
317    }
318
319    // --- write half: the two failure paths with no direct coverage before
320    // the port move (AG-2b). ---
321
322    fn access_with_writes(writes: InboundBus<EditorWriteRequest>) -> EditorAccess {
323        EditorAccess::new(
324            Arc::new(Mutex::new(EditorStateCache::default())),
325            None,
326            vec![],
327            Some(writes),
328        )
329    }
330
331    #[tokio::test]
332    async fn open_file_with_dropped_receiver_is_bus_error() {
333        // Build a raw bus and immediately drop the receiver — mirrors the
334        // "server stopped" case: nothing is draining the channel.
335        let (bus, rx) = lattice_mode::inbound::make_inbound_raw::<EditorWriteRequest>(Arc::new(
336            tokio::sync::Notify::new(),
337        ));
338        drop(rx);
339        let ea = access_with_writes(bus);
340        let err = ea
341            .open_file(PathBuf::from("/a.rs"), None)
342            .await
343            .expect_err("dropped receiver must fail the write");
344        assert!(
345            matches!(err, AgentError::Bus(ref m) if m == "write failed: editor not reachable"),
346            "expected Bus(\"write failed: editor not reachable\"), got {err:?}"
347        );
348    }
349
350    #[tokio::test(start_paused = true)]
351    async fn open_file_times_out_when_nothing_replies() {
352        // A live receiver that never replies — the request sits in the
353        // channel forever, so `run_write`'s 5s backstop must fire.
354        let (bus, _rx) = lattice_mode::inbound::make_inbound_raw::<EditorWriteRequest>(Arc::new(
355            tokio::sync::Notify::new(),
356        ));
357        let ea = access_with_writes(bus);
358        let call = tokio::spawn(async move { ea.open_file(PathBuf::from("/a.rs"), None).await });
359        tokio::time::advance(Duration::from_secs(6)).await;
360        let err = call
361            .await
362            .expect("task join")
363            .expect_err("no reply within WRITE_TIMEOUT must fail the write");
364        assert!(
365            matches!(err, AgentError::Io(ref m) if m == "write failed: editor did not respond"),
366            "expected Io(\"write failed: editor did not respond\"), got {err:?}"
367        );
368    }
369}