1use 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
42pub(crate) const NOTE_VIEW: &str = "note";
45
46fn 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 sha: String,
73}
74
75pub 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 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 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 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 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 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 #[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}