1use 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
28const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
32
33#[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#[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 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 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 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 pub async fn open_file(&self, path: PathBuf, column: Option<Utf16Pos>) -> Result<()> {
141 self.run_write(InboundKind::OpenFile { path, column }).await
142 }
143
144 pub async fn save_document(&self, path: PathBuf) -> Result<()> {
147 self.run_write(InboundKind::SaveDocument { path }).await
148 }
149
150 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 pub async fn close_session_diffs(&self, origin_session: u64) -> Result<()> {
163 self.run_write(InboundKind::CloseAllDiffTabs { origin_session })
164 .await
165 }
166
167 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 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 _ => Err(AgentError::Io(
191 "write failed: editor did not respond".to_string(),
192 )),
193 }
194 }
195}
196
197fn core_id(id: DocumentId) -> lattice_core::BufferId {
200 lattice_core::BufferId(id.raw() as u32)
201}
202
203pub fn path_to_uri(path: &Path) -> String {
206 format!("file://{}", path.display())
207}
208
209fn 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
221fn 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
232pub 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 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 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 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 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 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}