1use std::sync::{Arc, Mutex, OnceLock};
6
7use lattice_config;
8use lattice_grammar::Effect;
9use lattice_mode::{
10 ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
11 KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
12 keymap_entry,
13};
14use lattice_protocol::position::Position;
15use lattice_vcs::{Branch, Repository};
16
17use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
18use crate::headerline::{self, Field, MagitHeaderlineHandle};
19
20pub struct MagitBranchMode;
21
22impl MagitBranchMode {
23 pub fn mode_id() -> ModeId {
24 ModeId::new("magit-branch-mode")
25 }
26}
27
28fn magit_branch_keymap_entries() -> &'static [KeymapEntry] {
29 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
30 ENTRIES.get_or_init(|| {
31 vec![
32 keymap_entry! { mode: Normal, chord: "<CR>", doc: "Checkout branch", cmd: "action:magit-branch-checkout" },
33 keymap_entry! { mode: Normal, chord: "c", doc: "Create branch", cmd: "action:magit-branch-create" },
34 keymap_entry! { mode: Normal, chord: "d", doc: "Delete branch", cmd: "action:magit-branch-delete" },
35 keymap_entry! { mode: Normal, chord: "m", doc: "Merge branch", cmd: "action:magit-branch-merge" },
36 ]
37 })
38}
39
40pub struct BranchState {
41 buffer_id: lattice_core::BufferId,
42 store: Arc<BufferStoreHandle>,
43 workdir: std::path::PathBuf,
44 pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
45 headerline: Option<MagitHeaderlineHandle>,
49}
50
51pub type BranchStatesHandle = Arc<BufferStates<BranchState>>;
57
58fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<BranchState>>> {
62 crate::buffer_state::state_for::<BranchState>(ctx)
63}
64
65struct BranchView(Arc<Mutex<BranchState>>);
70
71impl MagitView for BranchView {
72 fn refresh(&self) -> Option<Effect> {
73 refresh(self.0.clone())
74 }
75}
76
77impl Mode for MagitBranchMode {
78 type Guard = BufferStateGuard<BranchState>;
79
80 fn id(&self) -> ModeId {
81 Self::mode_id()
82 }
83 fn kind(&self) -> ModeKind {
84 ModeKind::Major
85 }
86 fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
87 None
88 }
89
90 fn options(&self) -> OptionOverrideSet {
91 lattice_config::overrides! {
92 lattice_config::ReadOnly = true,
93 lattice_config::NoFile = true,
94 }
95 }
96
97 fn implies(&self) -> &[lattice_mode::ModeId] {
116 static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
117 IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
118 }
119
120 fn required_capabilities(&self) -> CapabilitySet {
121 CapabilitySet::empty()
122 }
123 fn keymap(&self) -> Keymap {
124 Keymap::from_entries(magit_branch_keymap_entries())
125 }
126
127 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
132 vec![
133 ActionHandlerContribution {
135 action_name: "action:magit-branch-checkout",
136 handler: Arc::new(|ctx: &ActionContext<'_>| {
137 let s = state(ctx)?;
138 let (name, workdir) = {
139 let g = s.lock().ok()?;
140 (branch_name_at_cursor(&g, ctx.cursor)?, g.workdir.clone())
141 };
142 spawn_mutation_and_refresh(s, format!("check out {name}"), move || {
143 let repo = Repository::discover(&workdir)
144 .map_err(|e| format!("not a git repository: {e}"))?;
145 Branch::checkout(&repo, &name)
146 .map(|_| String::new())
147 .map_err(|e| e.to_string())
148 })
149 }),
150 },
151 ActionHandlerContribution {
156 action_name: "action:magit-branch-delete",
157 handler: Arc::new(|ctx: &ActionContext<'_>| {
158 let s = state(ctx)?;
159 let name = {
160 let g = s.lock().ok()?;
161 branch_name_at_cursor(&g, ctx.cursor)?
162 };
163 Some(delete_branch_confirm(&name))
164 }),
165 },
166 ActionHandlerContribution {
173 action_name: "action:magit-branch-delete-execute",
174 handler: Arc::new(|ctx: &ActionContext<'_>| {
175 let s = state(ctx)?;
176 let (name, workdir) = {
181 let g = s.lock().ok()?;
182 let name = match crate::confirm::carried_target(ctx) {
183 Some(carried) => carried,
184 None => branch_name_at_cursor(&g, ctx.cursor)?,
185 };
186 (name, g.workdir.clone())
187 };
188 spawn_mutation_and_refresh(s, format!("delete branch {name}"), move || {
189 let repo = Repository::discover(&workdir)
190 .map_err(|e| format!("not a git repository: {e}"))?;
191 Branch::delete(&repo, &name)
192 .map(|_| String::new())
193 .map_err(|e| e.to_string())
194 })
195 }),
196 },
197 ActionHandlerContribution {
199 action_name: "action:magit-branch-merge",
200 handler: Arc::new(|ctx: &ActionContext<'_>| {
201 let s = state(ctx)?;
202 let (name, workdir) = {
203 let g = s.lock().ok()?;
204 (branch_name_at_cursor(&g, ctx.cursor)?, g.workdir.clone())
205 };
206 spawn_mutation_and_refresh(s, format!("merge {name}"), move || {
207 let repo = Repository::discover(&workdir)
208 .map_err(|e| format!("not a git repository: {e}"))?;
209 repo.run_git(["merge", &name])
210 .map(|_| String::new())
211 .map_err(|e| e.to_string())
212 })
213 }),
214 },
215 ActionHandlerContribution {
227 action_name: "action:magit-branch-create",
228 handler: Arc::new(|ctx: &ActionContext<'_>| {
229 let _ = state(ctx)?;
230 Some(Effect::OpenPicker {
231 source: "magit-branch-pick-base".to_string(),
232 args: Vec::new(),
233 root: None,
234 fill_action: None,
235 query: None,
236 })
237 }),
238 },
239 ]
240 }
241
242 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
243 Box::pin(async move {
244 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
245 let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
249 let Some(store) = ctx.service::<BufferStoreHandle>() else {
250 return Ok(orphan());
251 };
252 let Some(handle) = store.handle_for(buffer_id) else {
253 return Ok(orphan());
254 };
255 let workdir =
258 crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
259 let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
260
261 let (hl, hl_registration) =
265 match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
266 Some((h, reg)) => (Some(h), Some(reg)),
267 None => (None, None),
268 };
269
270 let Some(states) = ctx.service::<BranchStatesHandle>() else {
278 return Ok(orphan());
279 };
280 let state = states.publish(
281 buffer_id,
282 BranchState {
283 buffer_id,
284 store: store.clone(),
285 workdir: workdir.clone(),
286 pending_highlights: pending_highlights.clone(),
287 headerline: hl.clone(),
288 },
289 );
290 let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
291 .with_headerline(hl_registration);
292 if let Some(views) = ctx.service::<MagitViewsHandle>() {
293 views.publish(buffer_id, Arc::new(BranchView(state.clone())));
294 guard = guard.with_views((*views).clone());
295 }
296
297 let wd = workdir.clone();
300 let (text, header) = tokio::task::spawn_blocking(move || build_branch_list(&wd))
301 .await
302 .unwrap();
303 headerline::publish(&hl, header);
304 let spans = crate::highlight::branch_styled_spans(&text);
305 crate::buffer_io::replace_buffer_text(&handle, text).await;
306 if let Some(ref ph) = pending_highlights {
307 ph.store_and_wake(buffer_id, spans);
308 }
309
310 Ok(guard)
311 })
312 }
313}
314
315fn refresh(s: Arc<Mutex<BranchState>>) -> Option<Effect> {
317 let (handle, wd, pending, buffer_id, hl) = {
318 let g = s.lock().ok()?;
319 (
320 g.store.handle_for(g.buffer_id)?,
321 g.workdir.clone(),
322 g.pending_highlights.clone(),
323 g.buffer_id,
324 g.headerline.clone(),
325 )
326 };
327 let busy = headerline::busy(&hl);
331 tokio::task::spawn(async move {
332 let _busy = busy;
333 let (text, header) = tokio::task::spawn_blocking(move || build_branch_list(&wd))
334 .await
335 .unwrap_or_default();
336 headerline::publish(&hl, header);
337 let spans = crate::highlight::branch_styled_spans(&text);
338 crate::buffer_io::replace_buffer_text(&handle, text).await;
339 if let Some(ph) = pending {
340 ph.store_and_wake(buffer_id, spans);
341 }
342 });
343 None
344}
345
346fn spawn_mutation_and_refresh(
357 s: Arc<Mutex<BranchState>>,
358 label: String,
359 mutate: impl FnOnce() -> Result<String, String> + Send + 'static,
360) -> Option<Effect> {
361 let (handle, wd, pending, buffer_id, hl) = {
362 let g = s.lock().ok()?;
363 (
364 g.store.handle_for(g.buffer_id)?,
365 g.workdir.clone(),
366 g.pending_highlights.clone(),
367 g.buffer_id,
368 g.headerline.clone(),
369 )
370 };
371 let busy = headerline::busy(&hl);
375 tokio::task::spawn(async move {
376 let _busy = busy;
377 let result = tokio::task::spawn_blocking(mutate)
378 .await
379 .unwrap_or_else(|e| Err(e.to_string()));
380 crate::magit_global_mode::finish_task(&wd, &label, result);
381 let (text, header) = tokio::task::spawn_blocking(move || build_branch_list(&wd))
382 .await
383 .unwrap_or_default();
384 headerline::publish(&hl, header);
385 let spans = crate::highlight::branch_styled_spans(&text);
386 crate::buffer_io::replace_buffer_text(&handle, text).await;
387 if let Some(ph) = pending {
388 ph.store_and_wake(buffer_id, spans);
389 }
390 });
391 None
392}
393
394fn delete_branch_confirm(name: &str) -> Effect {
397 crate::confirm::ask_target(
398 format!("Delete branch {name}?"),
399 "action:magit-branch-delete-execute",
400 name,
401 )
402}
403
404fn branch_name_at_cursor(state: &BranchState, cursor: Position) -> Option<String> {
405 let handle = state.store.handle_for(state.buffer_id)?;
406 let snap = handle.snapshot();
407 let line = snap.buffer.line(cursor.line)?;
408 let name = line
410 .trim()
411 .trim_start_matches("* ")
412 .split_whitespace()
413 .next()?;
414 Some(name.to_string())
415}
416
417fn build_branch_list(workdir: &std::path::Path) -> (String, Vec<Field>) {
422 let repo = match Repository::discover(workdir) {
423 Ok(r) => r,
424 Err(_) => return ("Not a git repository.\n".to_string(), Vec::new()),
425 };
426 let branches = Branch::list(&repo).unwrap_or_default();
427
428 let current = repo
430 .run_git_str(["rev-parse", "--abbrev-ref", "HEAD"])
431 .map(|s| s.trim().to_string())
432 .unwrap_or_default();
433 let header = headerline::branch_fields(¤t, branches.len());
434
435 if branches.is_empty() {
436 return ("No branches.\n".to_string(), header);
437 }
438
439 let mut out = format!("Branches ({})\n", branches.len());
440 for b in &branches {
441 let marker = if *b == current { "* " } else { " " };
442 out.push_str(&format!("{}{}\n", marker, b));
443 }
444 out.push('\n');
445 (out, header)
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
456 fn delete_asks_before_deleting_and_names_the_branch() {
457 match delete_branch_confirm("feature/foo") {
458 Effect::Confirm {
459 prompt,
460 yes_action,
461 args: _,
462 } => {
463 assert_eq!(prompt, "Delete branch feature/foo?");
464 assert_eq!(yes_action, "action:magit-branch-delete-execute");
465 }
466 other => panic!("expected a confirm before a force delete, got {other:?}"),
467 }
468 }
469
470 #[test]
473 fn delete_prompt_preserves_the_full_branch_name() {
474 match delete_branch_confirm("release/v1.2.3-rc.1") {
475 Effect::Confirm { prompt, .. } => {
476 assert!(
477 prompt.contains("release/v1.2.3-rc.1"),
478 "prompt lost the branch name: {prompt}"
479 );
480 }
481 other => panic!("expected Confirm, got {other:?}"),
482 }
483 }
484}