Skip to main content

termesh_editor/
transaction.rs

1//! [`EditTransaction`] — the one and only way a buffer changes (ARCHITECTURE.md §8).
2//!
3//! Every mutation, whoever authored it, is stamped with the buffer revision it was
4//! written against. That stamp is what makes agent diff-review safe: a proposal authored
5//! at version `N` can be carried forward through whatever the human typed since, instead
6//! of being written blind over their work.
7
8use termesh_core::{BufferId, ProposalId};
9
10use crate::change::ChangeSet;
11use crate::selection::Selection;
12
13/// Monotonic buffer revision. Bumps on every applied transaction.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
15pub struct Version(pub u64);
16
17impl Version {
18    pub fn next(self) -> Self {
19        Version(self.0 + 1)
20    }
21}
22
23/// Groups transactions that undo together (ADR-0006 §6).
24///
25/// The unit the user thinks in: a run of typing is one group, and an accepted agent
26/// proposal is one group however many hunks it touched — so "accept, undo" undoes *the
27/// agent's change*, not one insertion of it.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
29pub struct UndoGroupId(pub u64);
30
31/// Where an edit came from. `Agent` carries the [`ProposalId`] so accepted agent edits
32/// stay traceable through undo history and review.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum EditSource {
35    Keyboard,
36    Paste,
37    Formatter,
38    Lsp,
39    Agent(ProposalId),
40    Replace,
41}
42
43impl EditSource {
44    /// Whether consecutive edits from this source may merge into one undo step.
45    ///
46    /// Only free-running typing does. A paste, a format, an LSP fix, and an agent
47    /// proposal are each a discrete act the user should be able to undo on its own.
48    pub fn coalesces(&self) -> bool {
49        matches!(self, EditSource::Keyboard)
50    }
51}
52
53/// A change to one buffer, stamped with the revision it was authored against.
54#[derive(Debug, Clone)]
55pub struct EditTransaction {
56    pub buffer: BufferId,
57    pub base_version: Version,
58    pub changes: ChangeSet,
59    pub source: EditSource,
60    pub undo_group: UndoGroupId,
61    /// Where the cursor should end up. `None` means "derive it" — map the current
62    /// selection through `changes`, which is right for edits made somewhere else in the
63    /// document (an agent hunk, a formatter run) where the cursor should simply hold its
64    /// place.
65    ///
66    /// ARCHITECTURE.md §8 calls this field a `SelectionMap`; mapping *is* the default
67    /// behaviour here, and the `Some` case exists because an author sometimes knows
68    /// better — after typing, the cursor belongs after the inserted text, which is not
69    /// something position mapping can infer.
70    pub selection: Option<Selection>,
71}
72
73impl EditTransaction {
74    /// A transaction from `source` against `base_version`, deriving the resulting
75    /// selection by mapping.
76    pub fn new(
77        buffer: BufferId,
78        base_version: Version,
79        changes: ChangeSet,
80        source: EditSource,
81        undo_group: UndoGroupId,
82    ) -> Self {
83        Self { buffer, base_version, changes, source, undo_group, selection: None }
84    }
85
86    /// Pin the post-edit selection explicitly.
87    pub fn with_selection(mut self, selection: Selection) -> Self {
88        self.selection = Some(selection);
89        self
90    }
91
92    /// Whether this transaction changes nothing.
93    pub fn is_empty(&self) -> bool {
94        self.changes.is_identity()
95    }
96
97    /// The proposal this edit came from, if any.
98    pub fn proposal(&self) -> Option<ProposalId> {
99        match self.source {
100            EditSource::Agent(id) => Some(id),
101            _ => None,
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn tx(source: EditSource) -> EditTransaction {
111        EditTransaction::new(
112            BufferId::new(1),
113            Version(3),
114            ChangeSet::replace(10, 0, 0, "x"),
115            source,
116            UndoGroupId(7),
117        )
118    }
119
120    #[test]
121    fn agent_edits_are_traceable_through_source() {
122        let t = tx(EditSource::Agent(ProposalId::new(42)));
123        assert_eq!(t.proposal(), Some(ProposalId::new(42)));
124        assert_eq!(t.base_version, Version(3));
125    }
126
127    #[test]
128    fn non_agent_edits_carry_no_proposal() {
129        assert_eq!(tx(EditSource::Keyboard).proposal(), None);
130    }
131
132    #[test]
133    fn only_typing_coalesces_into_one_undo_step() {
134        assert!(EditSource::Keyboard.coalesces());
135        for source in [
136            EditSource::Paste,
137            EditSource::Formatter,
138            EditSource::Lsp,
139            EditSource::Replace,
140            EditSource::Agent(ProposalId::new(1)),
141        ] {
142            assert!(!source.coalesces(), "{source:?} should be its own undo step");
143        }
144    }
145
146    #[test]
147    fn selection_defaults_to_derived_and_can_be_pinned() {
148        let t = tx(EditSource::Keyboard);
149        assert!(t.selection.is_none(), "derived by mapping unless told otherwise");
150
151        let pinned = tx(EditSource::Keyboard).with_selection(Selection::point(4));
152        assert_eq!(pinned.selection.unwrap().primary().head, 4);
153    }
154
155    #[test]
156    fn an_identity_change_is_an_empty_transaction() {
157        let t = EditTransaction::new(
158            BufferId::new(1),
159            Version(0),
160            ChangeSet::identity(5),
161            EditSource::Keyboard,
162            UndoGroupId(0),
163        );
164        assert!(t.is_empty());
165    }
166
167    #[test]
168    fn versions_advance_monotonically() {
169        assert_eq!(Version(4).next(), Version(5));
170        assert!(Version(4) < Version(5));
171    }
172}