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_protocol::position::Position;
30use lattice_vcs::{Repository, Submodule, SubmoduleEntry};
31
32use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
33use crate::headerline::{self, Field, MagitHeaderlineHandle};
34
35pub struct MagitSubmoduleMode;
36
37impl MagitSubmoduleMode {
38 pub fn mode_id() -> ModeId {
39 ModeId::new("magit-submodule-mode")
40 }
41}
42
43fn magit_submodule_keymap_entries() -> &'static [KeymapEntry] {
62 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
63 ENTRIES.get_or_init(|| {
64 vec![
65 keymap_entry! { mode: Normal, chord: "a", doc: "Add a submodule", cmd: "action:magit-submodule-add" },
66 keymap_entry! { mode: Normal, chord: "u", doc: "Update (init + checkout) the submodule at cursor", cmd: "action:magit-submodule-update" },
67 keymap_entry! { mode: Normal, chord: "s", doc: "Sync the submodule at cursor's URL", cmd: "action:magit-submodule-sync" },
68 keymap_entry! { mode: Normal, chord: "d", doc: "Remove the submodule at cursor (asks first)", cmd: "action:magit-submodule-remove" },
69 ]
70 })
71}
72
73pub struct SubmoduleState {
74 buffer_id: lattice_core::BufferId,
75 store: Arc<BufferStoreHandle>,
76 workdir: std::path::PathBuf,
77 pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
78 headerline: Option<MagitHeaderlineHandle>,
79 entries: Vec<SubmoduleEntry>,
84}
85
86pub type SubmoduleStatesHandle = Arc<BufferStates<SubmoduleState>>;
87
88fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<SubmoduleState>>> {
89 crate::buffer_state::state_for::<SubmoduleState>(ctx)
90}
91
92struct SubmoduleView(Arc<Mutex<SubmoduleState>>);
93
94impl MagitView for SubmoduleView {
95 fn refresh(&self) -> Option<Effect> {
96 refresh(self.0.clone())
97 }
98
99 fn workdir(&self) -> Option<std::path::PathBuf> {
100 self.0.lock().ok().map(|g| g.workdir.clone())
101 }
102}
103
104impl Mode for MagitSubmoduleMode {
105 type Guard = BufferStateGuard<SubmoduleState>;
106
107 fn id(&self) -> ModeId {
108 Self::mode_id()
109 }
110 fn kind(&self) -> ModeKind {
111 ModeKind::Major
112 }
113 fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
114 None
115 }
116
117 fn options(&self) -> OptionOverrideSet {
118 lattice_config::overrides! {
119 lattice_config::ReadOnly = true,
120 lattice_config::NoFile = true,
121 }
122 }
123
124 fn implies(&self) -> &[lattice_mode::ModeId] {
143 static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
144 IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
145 }
146
147 fn required_capabilities(&self) -> CapabilitySet {
148 CapabilitySet::empty()
149 }
150 fn keymap(&self) -> Keymap {
151 Keymap::from_entries(magit_submodule_keymap_entries())
152 }
153
154 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
155 vec![
156 ActionHandlerContribution {
171 action_name: "action:magit-submodule-add",
172 handler: Arc::new(|ctx: &ActionContext<'_>| {
173 let _ = state(ctx)?;
174 Some(Effect::OpenPrompt {
175 prompt: "Submodule URL: ".to_string(),
176 initial: String::new(),
177 on_submit_action: "action:magit-submodule-add-path".to_string(),
178 buffer_name: None,
179 })
180 }),
181 },
182 ActionHandlerContribution {
183 action_name: "action:magit-submodule-add-path",
184 handler: Arc::new(|ctx: &ActionContext<'_>| {
185 let url = ctx.prompt_value?.trim().to_string();
186 if url.is_empty() {
187 return None;
188 }
189 Some(Effect::OpenPrompt {
190 prompt: format!("Path for {url}: "),
191 initial: default_path_for(&url),
194 on_submit_action: "action:magit-submodule-add-finish".to_string(),
195 buffer_name: Some(add_prompt_buffer_name(&url)),
196 })
197 }),
198 },
199 ActionHandlerContribution {
200 action_name: "action:magit-submodule-add-finish",
201 handler: Arc::new(|ctx: &ActionContext<'_>| {
202 let path = ctx.prompt_value?.trim().to_string();
203 let url = carried_url(ctx)?;
204 if path.is_empty() {
205 return None;
206 }
207 let what = format!("add submodule {path}");
208 spawn_submodule_mutation(ctx, what, move |repo| {
209 Submodule::add(repo, &url, &path)
210 })?;
211 Some(Effect::Echo {
212 level: EchoLevel::Info,
213 text: "cloning submodule\u{2026}".to_string(),
214 })
215 }),
216 },
217 ActionHandlerContribution {
220 action_name: "action:magit-submodule-update",
221 handler: Arc::new(|ctx: &ActionContext<'_>| {
222 let entry = entry_at_cursor(ctx)?;
223 let path = entry.path.clone();
224 let echo = format!("updating {path}\u{2026}");
225 let what = format!("update submodule {path}");
226 spawn_submodule_mutation(ctx, what, move |repo| {
227 Submodule::update(repo, Some(&path))
228 })?;
229 Some(Effect::Echo {
230 level: EchoLevel::Info,
231 text: echo,
232 })
233 }),
234 },
235 ActionHandlerContribution {
237 action_name: "action:magit-submodule-sync",
238 handler: Arc::new(|ctx: &ActionContext<'_>| {
239 let entry = entry_at_cursor(ctx)?;
240 let path = entry.path.clone();
241 let what = format!("sync submodule {path}");
242 spawn_submodule_mutation(ctx, what, move |repo| {
243 Submodule::sync(repo, Some(&path))
244 })?;
245 None
246 }),
247 },
248 ActionHandlerContribution {
252 action_name: "action:magit-submodule-remove",
253 handler: Arc::new(|ctx: &ActionContext<'_>| {
254 let entry = entry_at_cursor(ctx)?;
255 Some(remove_confirm(&entry.path))
256 }),
257 },
258 ActionHandlerContribution {
259 action_name: "action:magit-submodule-remove-execute",
260 handler: Arc::new(|ctx: &ActionContext<'_>| {
261 let path = match crate::confirm::carried_target(ctx) {
267 Some(carried) => carried,
268 None => entry_at_cursor(ctx)?.path,
269 };
270 let what = format!("remove submodule {path}");
271 spawn_submodule_mutation(ctx, what, move |repo| {
272 Submodule::remove(repo, &path)
273 })?;
274 None
275 }),
276 },
277 ]
278 }
279
280 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
281 Box::pin(async move {
282 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
283 let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
284 let Some(store) = ctx.service::<BufferStoreHandle>() else {
285 return Ok(orphan());
286 };
287 let Some(handle) = store.handle_for(buffer_id) else {
288 return Ok(orphan());
289 };
290 let workdir =
293 crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
294 let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
295
296 let (hl, hl_registration) =
297 match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
298 Some((h, reg)) => (Some(h), Some(reg)),
299 None => (None, None),
300 };
301
302 let Some(states) = ctx.service::<SubmoduleStatesHandle>() else {
304 return Ok(orphan());
305 };
306 let state = states.publish(
307 buffer_id,
308 SubmoduleState {
309 buffer_id,
310 store: store.clone(),
311 workdir: workdir.clone(),
312 pending_highlights: pending_highlights.clone(),
313 headerline: hl.clone(),
314 entries: Vec::new(),
315 },
316 );
317 let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
318 .with_headerline(hl_registration);
319 if let Some(views) = ctx.service::<MagitViewsHandle>() {
320 views.publish(buffer_id, Arc::new(SubmoduleView(state.clone())));
321 guard = guard.with_views((*views).clone());
322 }
323
324 let wd = workdir.clone();
325 let (text, header, entries) =
326 tokio::task::spawn_blocking(move || build_submodule_list(&wd))
327 .await
328 .unwrap_or_else(|_| (String::new(), Vec::new(), Vec::new()));
329 if let Ok(mut g) = state.lock() {
330 g.entries = entries;
331 }
332 headerline::publish(&hl, header);
333 let spans = crate::highlight::submodule_styled_spans(&text);
334 crate::buffer_io::replace_buffer_text(&handle, text).await;
335 if let Some(ref ph) = pending_highlights {
336 ph.store_and_wake(buffer_id, spans);
337 }
338
339 Ok(guard)
340 })
341 }
342}
343
344fn refresh(s: Arc<Mutex<SubmoduleState>>) -> Option<Effect> {
345 let (handle, wd, pending, buffer_id, hl) = {
346 let g = s.lock().ok()?;
347 (
348 g.store.handle_for(g.buffer_id)?,
349 g.workdir.clone(),
350 g.pending_highlights.clone(),
351 g.buffer_id,
352 g.headerline.clone(),
353 )
354 };
355 let busy = headerline::busy(&hl);
359 tokio::task::spawn(async move {
360 let _busy = busy;
361 let (text, header, entries) =
362 tokio::task::spawn_blocking(move || build_submodule_list(&wd))
363 .await
364 .unwrap_or_else(|_| (String::new(), Vec::new(), Vec::new()));
365 if let Ok(mut g) = s.lock() {
366 g.entries = entries;
367 }
368 headerline::publish(&hl, header);
369 let spans = crate::highlight::submodule_styled_spans(&text);
370 crate::buffer_io::replace_buffer_text(&handle, text).await;
371 if let Some(ph) = pending {
372 ph.store_and_wake(buffer_id, spans);
373 }
374 });
375 None
376}
377
378fn spawn_submodule_mutation(
386 ctx: &ActionContext<'_>,
387 what: String,
388 mutate: impl FnOnce(&Repository) -> lattice_vcs::Result<()> + Send + 'static,
389) -> Option<()> {
390 let states = ctx.services.get::<SubmoduleStatesHandle>()?;
391 let targets = states.all();
392 let workdir = crate::repo_scope::action_workdir(ctx);
393 tokio::task::spawn(async move {
399 let wd = workdir.clone();
400 let outcome = tokio::task::spawn_blocking(move || match Repository::discover(&wd) {
401 Ok(repo) => mutate(&repo),
402 Err(e) => Err(lattice_vcs::VcsError::Submodule(format!(
403 "no repository at {}: {e}",
404 wd.display()
405 ))),
406 })
407 .await;
408 crate::magit_global_mode::finish_task(
413 &workdir,
414 &what,
415 match outcome {
416 Ok(Ok(())) => Ok(String::new()),
417 Ok(Err(e)) => Err(e.to_string()),
418 Err(e) => Err(format!("panicked: {e}")),
419 },
420 );
421 for target in targets {
422 refresh(target);
423 }
424 });
425 Some(())
426}
427
428pub(crate) fn remove_confirm(path: &str) -> Effect {
432 crate::confirm::ask_target(
433 format!("Remove submodule {path}? Its working tree is deleted."),
434 "action:magit-submodule-remove-execute",
435 path,
436 )
437}
438
439const ADD_PREFIX: &str = "*magit:submodule-add:";
440
441pub(crate) fn add_prompt_buffer_name(url: &str) -> String {
442 format!("{ADD_PREFIX}{url}*")
443}
444
445pub(crate) fn url_from_prompt_buffer_name(buffer_name: &str) -> Option<String> {
446 let s = buffer_name.strip_prefix(ADD_PREFIX)?;
447 let s = s.strip_suffix('*')?;
448 (!s.is_empty()).then(|| s.to_string())
449}
450
451fn carried_url(ctx: &ActionContext<'_>) -> Option<String> {
452 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
453 ctx.services
454 .get::<BufferStoreHandle>()?
455 .name_for(buffer_id)
456 .and_then(|n| url_from_prompt_buffer_name(&n))
457}
458
459pub(crate) fn default_path_for(url: &str) -> String {
462 url.trim_end_matches('/')
463 .rsplit(['/', ':'])
464 .next()
465 .unwrap_or("")
466 .trim_end_matches(".git")
467 .to_string()
468}
469
470fn entry_at_cursor(ctx: &ActionContext<'_>) -> Option<SubmoduleEntry> {
471 let s = state(ctx)?;
472 let g = s.lock().ok()?;
473 submodule_at_line(&g.entries, ctx.cursor)
474}
475
476pub(crate) fn submodule_at_line(
479 entries: &[SubmoduleEntry],
480 cursor: Position,
481) -> Option<SubmoduleEntry> {
482 let index = (cursor.line as usize).checked_sub(1)?;
483 entries.get(index).cloned()
484}
485
486fn build_submodule_list(workdir: &std::path::Path) -> (String, Vec<Field>, Vec<SubmoduleEntry>) {
487 let repo = match Repository::discover(workdir) {
488 Ok(r) => r,
489 Err(_) => {
490 return (
491 "Not a git repository.\n".to_string(),
492 Vec::new(),
493 Vec::new(),
494 );
495 }
496 };
497 let entries = match Submodule::list(&repo) {
498 Ok(e) => e,
499 Err(e) => {
500 tracing::error!("magit-submodule: listing submodules failed: {e}");
501 Vec::new()
502 }
503 };
504 let header = headerline::submodule_fields(&entries);
505 (render_submodule_list(&entries), header, entries)
506}
507
508pub(crate) fn render_submodule_list(entries: &[SubmoduleEntry]) -> String {
515 if entries.is_empty() {
516 return "No submodules.\n".to_string();
517 }
518 let width = entries
519 .iter()
520 .map(|e| e.path.chars().count())
521 .max()
522 .unwrap_or(0);
523 let mut out = format!("Submodules ({})\n", entries.len());
524 for e in entries {
525 let short: String = e.sha.chars().take(7).collect();
526 let pad = width.saturating_sub(e.path.chars().count());
527 out.push_str(&format!(
528 " {} {} {}{}",
529 e.state.marker(),
530 short,
531 e.path,
532 " ".repeat(pad)
533 ));
534 if !e.describe.is_empty() {
535 out.push_str(&format!(" ({})", e.describe));
536 }
537 while out.ends_with(' ') {
540 out.pop();
541 }
542 out.push('\n');
543 }
544 out.push('\n');
545 out
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use lattice_vcs::SubmoduleState as St;
552
553 fn entry(state: St, sha: &str, path: &str, describe: &str) -> SubmoduleEntry {
554 SubmoduleEntry {
555 state,
556 sha: sha.into(),
557 path: path.into(),
558 describe: describe.into(),
559 }
560 }
561
562 fn at(line: u32) -> Position {
563 Position { line, byte: 0 }
564 }
565
566 #[test]
567 fn the_heading_row_maps_to_no_submodule() {
568 let entries = vec![entry(St::InSync, "abc1234567", "vendor/x", "")];
569 assert!(submodule_at_line(&entries, at(0)).is_none());
570 }
571
572 #[test]
573 fn each_row_maps_to_its_own_submodule() {
574 let entries = vec![
575 entry(St::InSync, "aaa1111111", "vendor/a", ""),
576 entry(St::Modified, "bbb2222222", "vendor/b", ""),
577 ];
578 assert_eq!(submodule_at_line(&entries, at(1)).unwrap().path, "vendor/a");
579 assert_eq!(submodule_at_line(&entries, at(2)).unwrap().path, "vendor/b");
580 }
581
582 #[test]
583 fn out_of_range_rows_map_to_nothing() {
584 let entries = vec![entry(St::InSync, "abc1234567", "vendor/x", "")];
585 assert!(submodule_at_line(&entries, at(2)).is_none());
586 assert!(submodule_at_line(&[], at(1)).is_none());
587 }
588
589 #[test]
590 fn a_row_carries_gits_marker_the_short_sha_and_the_path() {
591 let text = render_submodule_list(&[
592 entry(St::Uninitialised, "abc1234567890", "vendor/a", ""),
593 entry(St::InSync, "def1234567890", "vendor/bb", "v1.2.3"),
594 ]);
595 let lines: Vec<&str> = text.lines().collect();
596 assert_eq!(lines[0], "Submodules (2)");
597 assert_eq!(lines[1], " - abc1234 vendor/a");
598 assert_eq!(lines[2], " def1234 vendor/bb (v1.2.3)");
599 }
600
601 #[test]
602 fn no_submodules_renders_a_sentence_not_an_empty_buffer() {
603 assert_eq!(render_submodule_list(&[]), "No submodules.\n");
604 }
605
606 #[test]
610 fn the_rendered_row_index_matches_the_cursor_mapping() {
611 let entries = vec![
612 entry(St::InSync, "aaa1111111", "a", ""),
613 entry(St::Modified, "bbb2222222", "bb", "v1"),
614 entry(St::Conflicted, "ccc3333333", "ccc", ""),
615 ];
616 let text = render_submodule_list(&entries);
617 for (i, line) in text.lines().enumerate().skip(1).take(entries.len()) {
618 let mapped = submodule_at_line(&entries, at(i as u32)).expect("row maps");
619 assert!(
620 line.contains(&mapped.path),
621 "line {i} ({line:?}) does not name the mapped submodule {:?}",
622 mapped.path
623 );
624 }
625 }
626
627 #[test]
628 fn remove_asks_before_deleting_and_names_the_submodule() {
629 match remove_confirm("vendor/child") {
630 Effect::Confirm {
631 prompt, yes_action, ..
632 } => {
633 assert!(prompt.contains("vendor/child"), "{prompt}");
634 assert!(
635 prompt.contains("working tree is deleted"),
636 "the question must say what is lost: {prompt}"
637 );
638 assert_eq!(yes_action, "action:magit-submodule-remove-execute");
639 }
640 other => panic!("expected a confirm before a destructive remove, got {other:?}"),
641 }
642 }
643
644 #[test]
645 fn the_add_prompt_seeds_the_path_git_would_have_chosen() {
646 assert_eq!(default_path_for("https://example.com/foo/bar.git"), "bar");
647 assert_eq!(default_path_for("git@example.com:foo/bar.git"), "bar");
648 assert_eq!(default_path_for("https://example.com/foo/bar/"), "bar");
649 assert_eq!(default_path_for("../sibling"), "sibling");
650 }
651
652 #[test]
653 fn the_add_prompt_carries_its_url_and_nothing_elses() {
654 let name = add_prompt_buffer_name("https://example.com/x.git");
655 assert_eq!(
656 url_from_prompt_buffer_name(&name).as_deref(),
657 Some("https://example.com/x.git")
658 );
659 assert!(url_from_prompt_buffer_name("*magit:status*").is_none());
660 assert!(url_from_prompt_buffer_name(ADD_PREFIX).is_none());
661 }
662}