Skip to main content

lattice_magit/
magit_notes_mode.rs

1//! MG.37: `magit-notes-mode` — the note attached to one commit, as an
2//! editable buffer.
3//!
4//! `*magit:note:<sha>*`, seeded with `git notes show <sha>` (empty when
5//! there is none). `C-c C-c` writes it, `C-c C-k` closes without
6//! writing — the same pair [`crate::magit_commit_mode`] uses, and for
7//! the same reason: this is a compose buffer, not a view.
8//!
9//! **This buffer exists because `git notes edit` opens `$EDITOR`.**
10//! Inside an editor that means a child waiting on a terminal that is
11//! not there — it would hang holding a blocking-pool thread and never
12//! report. `lattice_vcs::Note::set` pipes the text to `-F -` instead,
13//! which makes this buffer the editor.
14//!
15//! Notes need no separate viewer: `git show` prints them by default, so
16//! [`crate::magit_revision_mode`] already displays a commit's note under
17//! its message. Pinned by a test in `lattice-vcs`, because a future
18//! `--no-notes` would silently remove the only place they surface.
19
20use std::sync::{Arc, Mutex, OnceLock};
21
22use lattice_config;
23use lattice_grammar::{EchoLevel, Effect};
24use lattice_mode::{
25    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
26    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
27    keymap_entry,
28};
29use lattice_vcs::{Note, Repository};
30
31use crate::buffer_state::{BufferStateGuard, BufferStates};
32use crate::headerline;
33
34pub struct MagitNotesMode;
35
36impl MagitNotesMode {
37    pub fn mode_id() -> ModeId {
38        ModeId::new("magit-notes-mode")
39    }
40}
41
42/// MR.3b: this view's word. The commit rides in `rest`, behind the
43/// repository.
44pub(crate) const NOTE_VIEW: &str = "note";
45
46/// The commit a note buffer's name names. `None` for any other name,
47/// and for an empty sha — `git notes add` on an empty rev would act on
48/// HEAD, which is not what a malformed name asked for.
49fn sha_from_name(name: &str) -> Option<String> {
50    let parsed = crate::workdir::parse_magit_name(name)?;
51    (parsed.view == NOTE_VIEW).then_some(())?;
52    parsed.rest.map(str::to_string)
53}
54
55fn magit_notes_keymap_entries() -> &'static [KeymapEntry] {
56    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
57    ENTRIES.get_or_init(|| {
58        vec![
59            keymap_entry! { mode: Insert, chord: "<C-c><C-c>", doc: "Save this note", cmd: "action:magit-note-confirm" },
60            keymap_entry! { mode: Insert, chord: "<C-c><C-k>", doc: "Close without saving", cmd: "action:magit-note-abort" },
61            keymap_entry! { mode: Normal, chord: "<C-c><C-c>", doc: "Save this note", cmd: "action:magit-note-confirm" },
62            keymap_entry! { mode: Normal, chord: "<C-c><C-k>", doc: "Close without saving", cmd: "action:magit-note-abort" },
63        ]
64    })
65}
66
67pub struct NoteState {
68    buffer_id: lattice_core::BufferId,
69    store: Arc<BufferStoreHandle>,
70    workdir: std::path::PathBuf,
71    /// The commit this note belongs to, read from the buffer name.
72    sha: String,
73}
74
75/// MG.13: service alias for this mode's per-buffer state
76/// (`feedback_servicesregistry_arc_typeid`).
77pub type NoteStatesHandle = Arc<BufferStates<NoteState>>;
78
79fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<NoteState>>> {
80    crate::buffer_state::state_for::<NoteState>(ctx)
81}
82
83impl Mode for MagitNotesMode {
84    type Guard = BufferStateGuard<NoteState>;
85
86    fn id(&self) -> ModeId {
87        Self::mode_id()
88    }
89    fn kind(&self) -> ModeKind {
90        ModeKind::Major
91    }
92    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
93        None
94    }
95
96    /// Editable — unlike every other magit buffer. `ReadOnly` is
97    /// deliberately absent: the whole point is typing in it.
98    fn options(&self) -> OptionOverrideSet {
99        lattice_config::overrides! {
100            lattice_config::NoFile = true,
101        }
102    }
103
104    fn required_capabilities(&self) -> CapabilitySet {
105        CapabilitySet::empty()
106    }
107
108    fn keymap(&self) -> Keymap {
109        Keymap::from_entries(magit_notes_keymap_entries())
110    }
111
112    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
113        vec![
114            ActionHandlerContribution {
115                action_name: "action:magit-note-confirm",
116                handler: Arc::new(|ctx: &ActionContext<'_>| {
117                    let s = state(ctx)?;
118                    let (text, workdir, sha) = {
119                        let g = s.lock().ok()?;
120                        let handle = g.store.handle_for(g.buffer_id)?;
121                        (
122                            handle.snapshot().buffer.as_string(),
123                            g.workdir.clone(),
124                            g.sha.clone(),
125                        )
126                    };
127                    if sha.is_empty() {
128                        return Some(Effect::Echo {
129                            level: EchoLevel::Error,
130                            text: "magit: this note buffer names no commit".to_string(),
131                        });
132                    }
133                    // An empty buffer is not refused the way an empty
134                    // commit message is: clearing the buffer is how you
135                    // say "remove this note", and `Note::set` translates
136                    // it to exactly that.
137                    //
138                    // Off the actor thread, and the buffer closes
139                    // optimistically — the same shape magit-commit's
140                    // confirm uses, including the `error!` for a failure
141                    // that has no synchronous path back to a buffer
142                    // which is already gone.
143                    tokio::task::spawn(tokio::task::spawn_blocking(move || {
144                        let Ok(repo) = Repository::discover(&workdir) else {
145                            tracing::error!(target: "lattice_magit", "note: repo discover failed");
146                            return;
147                        };
148                        if let Err(e) = Note::set(&repo, &sha, &text) {
149                            tracing::error!(target: "lattice_magit", "note save {sha}: {e}");
150                        }
151                    }));
152                    Some(Effect::KillBuffer)
153                }),
154            },
155            ActionHandlerContribution {
156                action_name: "action:magit-note-abort",
157                handler: Arc::new(|ctx: &ActionContext<'_>| {
158                    let _ = state(ctx)?;
159                    // `force`: the buffer is dirty by construction (it
160                    // was seeded, then typed in), and "close without
161                    // saving" is precisely what the chord promises. A
162                    // dirty-buffer prompt here would ask a question the
163                    // user just answered.
164                    Some(Effect::KillBuffer)
165                }),
166            },
167        ]
168    }
169
170    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
171        Box::pin(async move {
172            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
173            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
174            let Some(store) = ctx.service::<BufferStoreHandle>() else {
175                return Ok(orphan());
176            };
177            let Some(handle) = store.handle_for(buffer_id) else {
178                return Ok(orphan());
179            };
180            // MR.3: the repository the trigger resolved for THIS
181            // buffer, not the one the editor was started in.
182            let workdir =
183                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
184            let sha = store
185                .name_for(buffer_id)
186                .as_deref()
187                .and_then(sha_from_name)
188                .unwrap_or_default();
189
190            let (hl, hl_registration) =
191                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
192                    Some((h, reg)) => (Some(h), Some(reg)),
193                    None => (None, None),
194                };
195
196            // MG.13: publish BEFORE the first `.await`, or `C-c C-c`
197            // resolves to a handler with no state on the very next
198            // keystroke. `sha` is known synchronously here (it is in the
199            // buffer name), so unlike magit-rebase there is no
200            // late-resolved field to guard against.
201            let Some(states) = ctx.service::<NoteStatesHandle>() else {
202                return Ok(orphan());
203            };
204            states.publish(
205                buffer_id,
206                NoteState {
207                    buffer_id,
208                    store: store.clone(),
209                    workdir: workdir.clone(),
210                    sha: sha.clone(),
211                },
212            );
213            let guard = BufferStateGuard::new((*states).clone(), buffer_id)
214                .with_headerline(hl_registration);
215
216            let wd = workdir.clone();
217            let sha_for_task = sha.clone();
218            let (text, meta) = tokio::task::spawn_blocking(move || {
219                let existing = Repository::discover(&wd)
220                    .ok()
221                    .and_then(|repo| Note::show(&repo, &sha_for_task))
222                    .unwrap_or_default();
223                let meta = crate::magit_revision_mode::commit_meta(&wd, &sha_for_task);
224                (existing, meta)
225            })
226            .await
227            .unwrap_or_default();
228            headerline::publish(&hl, headerline::note_fields(&meta, !text.trim().is_empty()));
229            crate::buffer_io::replace_buffer_text(&handle, text).await;
230
231            Ok(guard)
232        })
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    /// The buffer name is the only place the commit is recorded, so a
241    /// name that does not round-trip means a note saved against the
242    /// wrong commit — or, with an empty sha, against HEAD, which is a
243    /// commit the user never named.
244    #[test]
245    fn the_name_carries_the_commit_and_round_trips() {
246        let sha = "a1b2c3d4e5f6";
247        let name = crate::workdir::magit_buffer_name_with(NOTE_VIEW, "lattice", sha);
248        assert_eq!(sha_from_name(&name).as_deref(), Some(sha));
249    }
250
251    #[test]
252    fn names_this_mode_does_not_own_resolve_to_no_commit() {
253        for name in [
254            "*magit:note:*",
255            "*magit:notes:abc*",
256            "*magit:commit:abc*",
257            "*magit:note:abc",
258            "a.txt",
259        ] {
260            assert_eq!(
261                sha_from_name(name),
262                None,
263                "{name:?} must not resolve to a commit"
264            );
265        }
266    }
267}