1use std::ops::Range;
9use std::path::{Path, PathBuf};
10use std::rc::Rc;
11
12use gpui_kit::{App, SharedString, Window};
13
14use super::{
15 bibtex::bibtex_entry_offset,
16 protocol::{EditorBuffer, RoundtripContext, char_range_to_bytes},
17};
18
19#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct EditorSnapshot {
25 pub id: u64,
27 pub name: SharedString,
29 pub path: Option<PathBuf>,
31 pub cursor: (usize, usize),
33 pub content: SharedString,
35}
36
37impl EditorSnapshot {
38 #[must_use]
40 pub fn buffer(&self) -> EditorBuffer<'_> {
41 EditorBuffer {
42 name: self.name.as_ref(),
43 path: self.path.as_deref(),
44 cursor: self.cursor,
45 content: self.content.as_ref(),
46 }
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct SelectionSnapshot {
53 pub document: EditorSnapshot,
55 pub range: Range<usize>,
57}
58
59pub trait EditorSurface {
65 fn id(&self) -> u64;
67 fn name(&self) -> SharedString;
69 fn path(&self) -> Option<&Path>;
71 fn text(&self, cx: &App) -> SharedString;
73 fn cursor(&self, cx: &App) -> (usize, usize);
75 fn selection(&self, window: &mut Window, cx: &mut App) -> Option<Range<usize>>;
77 fn replace(&self, range: Range<usize>, text: &str, window: &mut Window, cx: &mut App);
79 fn focus(&self, window: &mut Window, cx: &mut App);
81
82 fn reveal(&self, offset: usize, window: &mut Window, cx: &mut App);
88
89 fn caret(&self, cx: &App) -> usize {
95 let snapshot = self.snapshot(cx);
96 char_index_for_line_column(&snapshot.content, snapshot.cursor)
97 }
98
99 fn insert_text(&self, text: &str, window: &mut Window, cx: &mut App) {
106 if text.is_empty() {
107 return;
108 }
109 let content = self.text(cx);
110 let range = self.selection(window, cx).unwrap_or_else(|| {
111 let caret = self.caret(cx);
112 caret..caret
113 });
114 self.replace(char_range_to_bytes(&content, range), text, window, cx);
115 }
116
117 fn snapshot(&self, cx: &App) -> EditorSnapshot {
119 EditorSnapshot {
120 id: self.id(),
121 name: self.name(),
122 path: self.path().map(Path::to_path_buf),
123 cursor: self.cursor(cx),
124 content: self.text(cx),
125 }
126 }
127}
128
129#[must_use]
134pub fn char_index_for_line_column(content: &str, cursor: (usize, usize)) -> usize {
135 let mut line = 0usize;
136 let mut column = 0usize;
137 for (byte, character) in content.char_indices() {
138 if line == cursor.0 && column == cursor.1 {
139 return content[..byte].chars().count();
140 }
141 if character == '\n' {
142 line += 1;
143 column = 0;
144 } else {
145 column += 1;
146 }
147 }
148 content.chars().count()
149}
150
151pub trait RoundtripHost {
153 fn active_document(&self, cx: &App) -> Option<EditorSnapshot>;
155 fn selection(&self, window: &mut Window, cx: &mut App) -> Option<SelectionSnapshot>;
157 fn insert(&self, _text: &str, _window: &mut Window, _cx: &mut App) -> bool {
162 false
163 }
164 fn bibliography(&self, _cx: &App) -> Option<PathBuf> {
170 None
171 }
172
173 fn open_at(&self, _path: &Path, _offset: usize, _window: &mut Window, _cx: &mut App) -> bool {
178 false
179 }
180
181 fn edit_reference(&self, key: &str, window: &mut Window, cx: &mut App) -> Result<(), String> {
187 let Some(path) = self.bibliography(cx) else {
188 return Err("No bibliography configured".to_owned());
189 };
190 let text = std::fs::read_to_string(&path)
191 .map_err(|error| format!("cannot read '{}' for @{key}: {error}", path.display()))?;
192 let Some(offset) = bibtex_entry_offset(&text, key) else {
193 return Err(format!("Entry @{key} is not in {}", path.display()));
194 };
195 if self.open_at(&path, offset, window, cx) {
196 Ok(())
197 } else {
198 Err(format!("'{}' could not be opened", path.display()))
199 }
200 }
201
202 fn apply_replacement(
204 &self,
205 target: ReplacementTarget,
206 text: &str,
207 window: &mut Window,
208 cx: &mut App,
209 ) -> ReplacementOutcome;
210}
211
212#[derive(Clone, Debug, PartialEq, Eq)]
214pub struct ReplacementTarget {
215 pub document_id: u64,
217 pub range: Range<usize>,
219 pub digest: String,
221}
222
223#[derive(Clone, Debug, PartialEq, Eq)]
225pub enum ReplacementOutcome {
226 Applied {
228 message: String,
230 },
231 Offered {
233 text: String,
235 reason: String,
237 },
238 Empty,
240}
241
242#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct SelectionContext {
245 pub document_id: u64,
247 pub context: RoundtripContext,
249}
250
251#[derive(Clone)]
256pub struct Roundtrip {
257 host: Rc<dyn RoundtripHost>,
258}
259
260impl Roundtrip {
261 pub fn new(host: Rc<dyn RoundtripHost>) -> Self {
263 Self { host }
264 }
265
266 #[must_use]
268 pub fn active_document(&self, cx: &App) -> Option<EditorSnapshot> {
269 self.host.active_document(cx)
270 }
271
272 #[must_use]
274 pub fn document_context(&self, cx: &App, limit: usize) -> Option<RoundtripContext> {
275 let document = self.host.active_document(cx)?;
276 RoundtripContext::document_with_limit(document.buffer(), limit)
277 }
278
279 #[must_use]
281 pub fn selection_context(
282 &self,
283 window: &mut Window,
284 cx: &mut App,
285 limit: usize,
286 ) -> Option<SelectionContext> {
287 let selection = self.host.selection(window, cx)?;
288 let context = RoundtripContext::selection_with_limit(
289 selection.document.buffer(),
290 selection.range,
291 limit,
292 )?;
293 Some(SelectionContext {
294 document_id: selection.document.id,
295 context,
296 })
297 }
298
299 pub fn apply(
301 &self,
302 target: ReplacementTarget,
303 text: &str,
304 window: &mut Window,
305 cx: &mut App,
306 ) -> ReplacementOutcome {
307 self.host.apply_replacement(target, text, window, cx)
308 }
309
310 pub fn insert(&self, text: &str, window: &mut Window, cx: &mut App) -> bool {
314 self.host.insert(text, window, cx)
315 }
316
317 pub fn edit_reference(
319 &self,
320 key: &str,
321 window: &mut Window,
322 cx: &mut App,
323 ) -> Result<(), String> {
324 self.host.edit_reference(key, window, cx)
325 }
326
327 pub fn open_at(&self, path: &Path, offset: usize, window: &mut Window, cx: &mut App) -> bool {
329 self.host.open_at(path, offset, window, cx)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use std::cell::RefCell;
337
338 struct MockEditor {
339 content: RefCell<String>,
340 caret: RefCell<usize>,
341 selection: RefCell<Option<Range<usize>>>,
342 }
343
344 impl MockEditor {
345 fn new(content: &str, caret: usize) -> Self {
346 Self {
347 content: RefCell::new(content.to_owned()),
348 caret: RefCell::new(caret),
349 selection: RefCell::new(None),
350 }
351 }
352
353 fn selected(mut self, range: Range<usize>) -> Self {
354 self.selection = RefCell::new(Some(range));
355 self
356 }
357
358 fn value(&self) -> String {
359 self.content.borrow().clone()
360 }
361 }
362
363 impl EditorSurface for MockEditor {
364 fn id(&self) -> u64 {
365 1
366 }
367
368 fn name(&self) -> SharedString {
369 "mock.md".into()
370 }
371
372 fn path(&self) -> Option<&Path> {
373 None
374 }
375
376 fn text(&self, _: &App) -> SharedString {
377 self.content.borrow().clone().into()
378 }
379
380 fn cursor(&self, _: &App) -> (usize, usize) {
381 let content = self.content.borrow();
382 let caret = (*self.caret.borrow()).min(content.chars().count());
383 let before: String = content.chars().take(caret).collect();
384 (
385 before.matches('\n').count(),
386 before.rsplit('\n').next().unwrap_or("").chars().count(),
387 )
388 }
389
390 fn selection(&self, _: &mut Window, _: &mut App) -> Option<Range<usize>> {
391 self.selection.borrow().clone()
392 }
393
394 fn replace(&self, range: Range<usize>, text: &str, _: &mut Window, _: &mut App) {
395 let content = self.content.borrow().clone();
396 let mut next = content[..range.start].to_owned();
397 next.push_str(text);
398 next.push_str(&content[range.end..]);
399 *self.caret.borrow_mut() =
400 content[..range.start].chars().count() + text.chars().count();
401 *self.content.borrow_mut() = next;
402 *self.selection.borrow_mut() = None;
403 }
404
405 fn focus(&self, _: &mut Window, _: &mut App) {}
406
407 fn reveal(&self, offset: usize, window: &mut Window, cx: &mut App) {
408 *self.caret.borrow_mut() = offset;
409 *self.selection.borrow_mut() = None;
410 self.focus(window, cx);
411 }
412 }
413
414 struct MockHost {
415 bibliography: Option<PathBuf>,
416 opened: RefCell<Vec<(PathBuf, usize)>>,
417 can_open: bool,
418 }
419
420 impl MockHost {
421 fn with_bibliography(path: PathBuf) -> Self {
422 Self {
423 bibliography: Some(path),
424 opened: RefCell::new(Vec::new()),
425 can_open: true,
426 }
427 }
428 }
429
430 impl RoundtripHost for MockHost {
431 fn active_document(&self, _: &App) -> Option<EditorSnapshot> {
432 None
433 }
434
435 fn selection(&self, _: &mut Window, _: &mut App) -> Option<SelectionSnapshot> {
436 None
437 }
438
439 fn bibliography(&self, _: &App) -> Option<PathBuf> {
440 self.bibliography.clone()
441 }
442
443 fn open_at(&self, path: &Path, offset: usize, _: &mut Window, _: &mut App) -> bool {
444 self.opened.borrow_mut().push((path.to_path_buf(), offset));
445 self.can_open
446 }
447
448 fn apply_replacement(
449 &self,
450 _: ReplacementTarget,
451 _: &str,
452 _: &mut Window,
453 _: &mut App,
454 ) -> ReplacementOutcome {
455 ReplacementOutcome::Empty
456 }
457 }
458
459 fn temp_bibliography(label: &str) -> (PathBuf, PathBuf) {
460 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
461 let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
462 let dir = std::env::temp_dir().join(format!(
463 "ragrig-gpui-host-{label}-{}-{counter}",
464 std::process::id()
465 ));
466 std::fs::create_dir_all(&dir).expect("temp dir");
467 let path = dir.join("references.bib");
468 std::fs::write(
469 &path,
470 "@article{Kano2025,\n title = {Gaze},\n}\n\n@book{Mila2023,\n title = {Sclera},\n}\n",
471 )
472 .expect("bib");
473 (dir, path)
474 }
475
476 struct Root;
477
478 impl gpui_kit::Render for Root {
479 fn render(
480 &mut self,
481 _: &mut Window,
482 _: &mut gpui_kit::Context<Self>,
483 ) -> impl gpui_kit::IntoElement {
484 gpui_kit::div()
485 }
486 }
487
488 #[gpui_kit::test]
489 fn the_default_insert_lands_at_the_caret(cx: &mut gpui_kit::TestAppContext) {
490 let (_root, cx) = cx.add_window_view(|_, _| Root);
491 let editor = MockEditor::new("hello world", 5);
492 cx.update(|window, cx| editor.insert_text(" brave", window, cx));
493 assert_eq!(editor.value(), "hello brave world");
494 }
495
496 #[gpui_kit::test]
497 fn the_default_insert_replaces_the_selection(cx: &mut gpui_kit::TestAppContext) {
498 let (_root, cx) = cx.add_window_view(|_, _| Root);
499 let editor = MockEditor::new("hello world", 0).selected(0..5);
500 cx.update(|window, cx| editor.insert_text("hi", window, cx));
501 assert_eq!(editor.value(), "hi world");
502 }
503
504 #[gpui_kit::test]
505 fn the_default_edit_reference_opens_the_entry(cx: &mut gpui_kit::TestAppContext) {
506 let (dir, path) = temp_bibliography("edit");
507 let host = MockHost::with_bibliography(path.clone());
508 let (_root, cx) = cx.add_window_view(|_, _| Root);
509
510 let result = cx.update(|window, cx| {
511 host.edit_reference("Mila2023", window, cx)
512 .expect("entry is opened");
513 host.opened.borrow().clone()
514 });
515 let text = std::fs::read_to_string(&path).expect("bib");
516 assert_eq!(
517 result,
518 vec![(
519 path,
520 bibtex_entry_offset(&text, "Mila2023").expect("offset")
521 )]
522 );
523
524 let _ = std::fs::remove_dir_all(dir);
525 }
526
527 #[gpui_kit::test]
528 fn the_default_edit_reference_reports_a_missing_key(cx: &mut gpui_kit::TestAppContext) {
529 let (dir, path) = temp_bibliography("missing-key");
530 let host = MockHost::with_bibliography(path);
531 let (_root, cx) = cx.add_window_view(|_, _| Root);
532
533 let error = cx.update(|window, cx| {
534 host.edit_reference("Nope", window, cx)
535 .expect_err("missing key")
536 });
537 assert!(error.contains("@Nope"), "unexpected error: {error}");
538 assert!(host.opened.borrow().is_empty());
539
540 let _ = std::fs::remove_dir_all(dir);
541 }
542
543 #[gpui_kit::test]
544 fn the_default_edit_reference_requires_a_bibliography(cx: &mut gpui_kit::TestAppContext) {
545 let host = MockHost {
546 bibliography: None,
547 opened: RefCell::new(Vec::new()),
548 can_open: true,
549 };
550 let (_root, cx) = cx.add_window_view(|_, _| Root);
551
552 let error = cx.update(|window, cx| {
553 host.edit_reference("Kano2025", window, cx)
554 .expect_err("no bibliography")
555 });
556 assert_eq!(error, "No bibliography configured");
557 assert!(host.opened.borrow().is_empty());
558 }
559
560 #[test]
561 fn line_columns_convert_to_char_indices() {
562 assert_eq!(char_index_for_line_column("abc", (0, 0)), 0);
563 assert_eq!(char_index_for_line_column("abc", (0, 3)), 3);
564 assert_eq!(char_index_for_line_column("ab\ncd", (1, 1)), 4);
565 assert_eq!(char_index_for_line_column("ab\ncd", (1, 99)), 5);
566 assert_eq!(char_index_for_line_column("ab\ncd", (9, 0)), 5);
567 }
568}