1use std::sync::{Arc, Mutex, OnceLock};
23
24use lattice_config;
25use lattice_grammar::{EchoLevel, Effect};
26use lattice_mode::{
27 ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
28 KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
29 keymap_entry,
30};
31use lattice_protocol::position::Position;
32use lattice_vcs::{Remote, RemoteEntry, Repository};
33
34use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
35use crate::headerline::{self, Field, MagitHeaderlineHandle};
36
37pub struct MagitRemoteMode;
38
39impl MagitRemoteMode {
40 pub fn mode_id() -> ModeId {
41 ModeId::new("magit-remote-mode")
42 }
43}
44
45fn magit_remote_keymap_entries() -> &'static [KeymapEntry] {
61 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
62 ENTRIES.get_or_init(|| {
63 vec![
64 keymap_entry! { mode: Normal, chord: "a", doc: "Add a remote", cmd: "action:magit-remote-add" },
65 keymap_entry! { mode: Normal, chord: "r", doc: "Rename the remote at cursor", cmd: "action:magit-remote-rename" },
66 keymap_entry! { mode: Normal, chord: "d", doc: "Remove the remote at cursor", cmd: "action:magit-remote-remove" },
67 keymap_entry! { mode: Normal, chord: "u", doc: "Set the URL of the remote at cursor", cmd: "action:magit-remote-set-url" },
68 keymap_entry! { mode: Normal, chord: "p", doc: "Prune stale branches of the remote at cursor", cmd: "action:magit-remote-prune" },
69 ]
70 })
71}
72
73pub struct RemoteState {
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<RemoteEntry>,
86}
87
88pub type RemoteStatesHandle = Arc<BufferStates<RemoteState>>;
91
92fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<RemoteState>>> {
93 crate::buffer_state::state_for::<RemoteState>(ctx)
94}
95
96struct RemoteView(Arc<Mutex<RemoteState>>);
98
99impl MagitView for RemoteView {
100 fn refresh(&self) -> Option<Effect> {
101 refresh(self.0.clone())
102 }
103}
104
105impl Mode for MagitRemoteMode {
106 type Guard = BufferStateGuard<RemoteState>;
107
108 fn id(&self) -> ModeId {
109 Self::mode_id()
110 }
111 fn kind(&self) -> ModeKind {
112 ModeKind::Major
113 }
114 fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
115 None
116 }
117
118 fn options(&self) -> OptionOverrideSet {
119 lattice_config::overrides! {
120 lattice_config::ReadOnly = true,
121 lattice_config::NoFile = true,
122 }
123 }
124
125 fn implies(&self) -> &[lattice_mode::ModeId] {
144 static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
145 IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
146 }
147
148 fn required_capabilities(&self) -> CapabilitySet {
149 CapabilitySet::empty()
150 }
151 fn keymap(&self) -> Keymap {
152 Keymap::from_entries(magit_remote_keymap_entries())
153 }
154
155 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
162 vec![
163 ActionHandlerContribution {
168 action_name: "action:magit-remote-add",
169 handler: Arc::new(|ctx: &ActionContext<'_>| {
170 let _ = state(ctx)?;
171 Some(Effect::OpenPrompt {
172 prompt: "Remote name: ".to_string(),
173 initial: String::new(),
174 on_submit_action: "action:magit-remote-add-url".to_string(),
175 buffer_name: None,
176 })
177 }),
178 },
179 ActionHandlerContribution {
180 action_name: "action:magit-remote-add-url",
181 handler: Arc::new(|ctx: &ActionContext<'_>| {
182 let name = ctx.prompt_value?.trim().to_string();
183 if name.is_empty() {
186 return None;
187 }
188 Some(Effect::OpenPrompt {
189 prompt: format!("URL for {name}: "),
190 initial: String::new(),
191 on_submit_action: "action:magit-remote-add-finish".to_string(),
192 buffer_name: Some(add_prompt_buffer_name(&name)),
193 })
194 }),
195 },
196 ActionHandlerContribution {
197 action_name: "action:magit-remote-add-finish",
198 handler: Arc::new(|ctx: &ActionContext<'_>| {
199 let url = ctx.prompt_value?.trim().to_string();
200 let name = carried_name(ctx, ADD_PREFIX)?;
201 if url.is_empty() {
202 return None;
203 }
204 let what = format!("add remote {name}");
205 spawn_remote_mutation(ctx, what, move |repo| Remote::add(repo, &name, &url))?;
206 None
207 }),
208 },
209 ActionHandlerContribution {
212 action_name: "action:magit-remote-rename",
213 handler: Arc::new(|ctx: &ActionContext<'_>| {
214 let entry = entry_at_cursor(ctx)?;
215 Some(Effect::OpenPrompt {
216 prompt: format!("Rename {} to: ", entry.name),
217 initial: entry.name.clone(),
218 on_submit_action: "action:magit-remote-rename-finish".to_string(),
219 buffer_name: Some(rename_prompt_buffer_name(&entry.name)),
220 })
221 }),
222 },
223 ActionHandlerContribution {
224 action_name: "action:magit-remote-rename-finish",
225 handler: Arc::new(|ctx: &ActionContext<'_>| {
226 let to = ctx.prompt_value?.trim().to_string();
227 let from = carried_name(ctx, RENAME_PREFIX)?;
228 if to.is_empty() || to == from {
233 return None;
234 }
235 let what = format!("rename remote {from} to {to}");
236 spawn_remote_mutation(ctx, what, move |repo| Remote::rename(repo, &from, &to))?;
237 None
238 }),
239 },
240 ActionHandlerContribution {
242 action_name: "action:magit-remote-set-url",
243 handler: Arc::new(|ctx: &ActionContext<'_>| {
244 let entry = entry_at_cursor(ctx)?;
245 Some(Effect::OpenPrompt {
246 prompt: format!("URL for {}: ", entry.name),
247 initial: entry.fetch_url.clone(),
248 on_submit_action: "action:magit-remote-set-url-finish".to_string(),
249 buffer_name: Some(set_url_prompt_buffer_name(&entry.name)),
250 })
251 }),
252 },
253 ActionHandlerContribution {
254 action_name: "action:magit-remote-set-url-finish",
255 handler: Arc::new(|ctx: &ActionContext<'_>| {
256 let url = ctx.prompt_value?.trim().to_string();
257 let name = carried_name(ctx, SET_URL_PREFIX)?;
258 if url.is_empty() {
259 return None;
260 }
261 let what = format!("change the URL of remote {name}");
262 spawn_remote_mutation(ctx, what, move |repo| {
263 Remote::set_url(repo, &name, &url)
264 })?;
265 None
266 }),
267 },
268 ActionHandlerContribution {
276 action_name: "action:magit-remote-remove",
277 handler: Arc::new(|ctx: &ActionContext<'_>| {
278 let entry = entry_at_cursor(ctx)?;
279 let name = entry.name.clone();
280 let what = format!("remove remote {name}");
281 spawn_remote_mutation(ctx, what, move |repo| Remote::remove(repo, &name))?;
282 None
283 }),
284 },
285 ActionHandlerContribution {
290 action_name: "action:magit-remote-prune",
291 handler: Arc::new(|ctx: &ActionContext<'_>| {
292 let entry = entry_at_cursor(ctx)?;
293 let name = entry.name.clone();
294 let echo = format!("pruning {name}\u{2026}");
295 let what = format!("prune remote {name}");
296 spawn_remote_mutation(ctx, what, move |repo| Remote::prune(repo, &name))?;
297 Some(Effect::Echo {
298 level: EchoLevel::Info,
299 text: echo,
300 })
301 }),
302 },
303 ]
304 }
305
306 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
307 Box::pin(async move {
308 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
309 let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
310 let Some(store) = ctx.service::<BufferStoreHandle>() else {
311 return Ok(orphan());
312 };
313 let Some(handle) = store.handle_for(buffer_id) else {
314 return Ok(orphan());
315 };
316 let workdir =
319 crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
320 let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
321
322 let (hl, hl_registration) =
323 match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
324 Some((h, reg)) => (Some(h), Some(reg)),
325 None => (None, None),
326 };
327
328 let Some(states) = ctx.service::<RemoteStatesHandle>() else {
331 return Ok(orphan());
332 };
333 let state = states.publish(
334 buffer_id,
335 RemoteState {
336 buffer_id,
337 store: store.clone(),
338 workdir: workdir.clone(),
339 pending_highlights: pending_highlights.clone(),
340 headerline: hl.clone(),
341 entries: Vec::new(),
342 },
343 );
344 let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
345 .with_headerline(hl_registration);
346 if let Some(views) = ctx.service::<MagitViewsHandle>() {
347 views.publish(buffer_id, Arc::new(RemoteView(state.clone())));
348 guard = guard.with_views((*views).clone());
349 }
350
351 let wd = workdir.clone();
352 let (text, header, entries) =
353 tokio::task::spawn_blocking(move || build_remote_list(&wd))
354 .await
355 .unwrap_or_else(|_| (String::new(), Vec::new(), Vec::new()));
356 if let Ok(mut g) = state.lock() {
357 g.entries = entries;
358 }
359 headerline::publish(&hl, header);
360 let spans = crate::highlight::remote_styled_spans(&text);
361 crate::buffer_io::replace_buffer_text(&handle, text).await;
362 if let Some(ref ph) = pending_highlights {
363 ph.store_and_wake(buffer_id, spans);
364 }
365
366 Ok(guard)
367 })
368 }
369}
370
371fn refresh(s: Arc<Mutex<RemoteState>>) -> Option<Effect> {
373 let (handle, wd, pending, buffer_id, hl) = {
374 let g = s.lock().ok()?;
375 (
376 g.store.handle_for(g.buffer_id)?,
377 g.workdir.clone(),
378 g.pending_highlights.clone(),
379 g.buffer_id,
380 g.headerline.clone(),
381 )
382 };
383 let busy = headerline::busy(&hl);
387 tokio::task::spawn(async move {
388 let _busy = busy;
389 let (text, header, entries) = tokio::task::spawn_blocking(move || build_remote_list(&wd))
390 .await
391 .unwrap_or_else(|_| (String::new(), Vec::new(), Vec::new()));
392 if let Ok(mut g) = s.lock() {
393 g.entries = entries;
394 }
395 headerline::publish(&hl, header);
396 let spans = crate::highlight::remote_styled_spans(&text);
397 crate::buffer_io::replace_buffer_text(&handle, text).await;
398 if let Some(ph) = pending {
399 ph.store_and_wake(buffer_id, spans);
400 }
401 });
402 None
403}
404
405fn spawn_remote_mutation(
416 ctx: &ActionContext<'_>,
417 what: String,
418 mutate: impl FnOnce(&Repository) -> lattice_vcs::Result<()> + Send + 'static,
419) -> Option<()> {
420 let states = ctx.services.get::<RemoteStatesHandle>()?;
421 let targets = states.all();
422 let workdir = crate::repo_scope::action_workdir(ctx);
423 tokio::task::spawn(async move {
429 let wd = workdir.clone();
430 let outcome = tokio::task::spawn_blocking(move || match Repository::discover(&wd) {
431 Ok(repo) => mutate(&repo),
432 Err(e) => Err(lattice_vcs::VcsError::Remote(format!(
433 "no repository at {}: {e}",
434 wd.display()
435 ))),
436 })
437 .await;
438 crate::magit_global_mode::finish_task(
448 &workdir,
449 &what,
450 match outcome {
451 Ok(Ok(())) => Ok(String::new()),
452 Ok(Err(e)) => Err(e.to_string()),
453 Err(e) => Err(format!("panicked: {e}")),
454 },
455 );
456 for target in targets {
457 refresh(target);
458 }
459 });
460 Some(())
461}
462
463const ADD_PREFIX: &str = "*magit:remote-add:";
464const RENAME_PREFIX: &str = "*magit:remote-rename:";
465const SET_URL_PREFIX: &str = "*magit:remote-set-url:";
466
467pub(crate) fn add_prompt_buffer_name(name: &str) -> String {
468 format!("{ADD_PREFIX}{name}*")
469}
470pub(crate) fn rename_prompt_buffer_name(name: &str) -> String {
471 format!("{RENAME_PREFIX}{name}*")
472}
473pub(crate) fn set_url_prompt_buffer_name(name: &str) -> String {
474 format!("{SET_URL_PREFIX}{name}*")
475}
476
477pub(crate) fn name_from_prompt_buffer_name(buffer_name: &str, prefix: &str) -> Option<String> {
481 let s = buffer_name.strip_prefix(prefix)?;
482 let s = s.strip_suffix('*')?;
483 (!s.is_empty()).then(|| s.to_string())
484}
485
486fn carried_name(ctx: &ActionContext<'_>, prefix: &str) -> Option<String> {
487 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
488 ctx.services
489 .get::<BufferStoreHandle>()?
490 .name_for(buffer_id)
491 .and_then(|n| name_from_prompt_buffer_name(&n, prefix))
492}
493
494fn entry_at_cursor(ctx: &ActionContext<'_>) -> Option<RemoteEntry> {
495 let s = state(ctx)?;
496 let g = s.lock().ok()?;
497 remote_at_line(&g.entries, ctx.cursor)
498}
499
500pub(crate) fn remote_at_line(entries: &[RemoteEntry], cursor: Position) -> Option<RemoteEntry> {
506 let index = (cursor.line as usize).checked_sub(1)?;
507 entries.get(index).cloned()
508}
509
510fn build_remote_list(workdir: &std::path::Path) -> (String, Vec<Field>, Vec<RemoteEntry>) {
513 let repo = match Repository::discover(workdir) {
514 Ok(r) => r,
515 Err(_) => {
516 return (
517 "Not a git repository.\n".to_string(),
518 Vec::new(),
519 Vec::new(),
520 );
521 }
522 };
523 let entries = match Remote::list(&repo) {
524 Ok(e) => e,
525 Err(e) => {
526 tracing::error!("magit-remote: listing remotes failed: {e}");
527 Vec::new()
528 }
529 };
530 let header = headerline::remote_fields(entries.len());
531 (render_remote_list(&entries), header, entries)
532}
533
534pub(crate) fn render_remote_list(entries: &[RemoteEntry]) -> String {
539 if entries.is_empty() {
540 return "No remotes.\n".to_string();
541 }
542 let width = entries
543 .iter()
544 .map(|e| e.name.chars().count())
545 .max()
546 .unwrap_or(0);
547 let mut out = format!("Remotes ({})\n", entries.len());
548 for e in entries {
549 let pad = width.saturating_sub(e.name.chars().count());
550 out.push_str(&format!(" {}{} {}", e.name, " ".repeat(pad), e.fetch_url));
551 if e.push_url != e.fetch_url {
555 out.push_str(&format!(" (push: {})", e.push_url));
556 }
557 out.push('\n');
558 }
559 out.push('\n');
560 out
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 fn entry(name: &str, fetch: &str, push: &str) -> RemoteEntry {
568 RemoteEntry {
569 name: name.into(),
570 fetch_url: fetch.into(),
571 push_url: push.into(),
572 }
573 }
574
575 fn at(line: u32) -> Position {
576 Position { line, byte: 0 }
577 }
578
579 #[test]
580 fn the_heading_row_maps_to_no_remote() {
581 let entries = vec![entry("origin", "u", "u")];
585 assert!(remote_at_line(&entries, at(0)).is_none());
586 }
587
588 #[test]
589 fn each_row_maps_to_its_own_remote() {
590 let entries = vec![entry("origin", "u1", "u1"), entry("upstream", "u2", "u2")];
591 assert_eq!(remote_at_line(&entries, at(1)).unwrap().name, "origin");
592 assert_eq!(remote_at_line(&entries, at(2)).unwrap().name, "upstream");
593 }
594
595 #[test]
596 fn the_trailing_blank_line_maps_to_no_remote() {
597 let entries = vec![entry("origin", "u", "u")];
598 assert!(remote_at_line(&entries, at(2)).is_none());
599 assert!(remote_at_line(&entries, at(99)).is_none());
600 }
601
602 #[test]
603 fn an_empty_list_maps_nothing() {
604 assert!(remote_at_line(&[], at(1)).is_none());
605 }
606
607 #[test]
608 fn rows_line_up_and_only_a_differing_push_url_is_printed() {
609 let text = render_remote_list(&[
610 entry("origin", "https://a.git", "https://a.git"),
611 entry("up", "https://b.git", "git@b.git"),
612 ]);
613 let lines: Vec<&str> = text.lines().collect();
614 assert_eq!(lines[0], "Remotes (2)");
615 assert_eq!(lines[1], " origin https://a.git");
616 assert_eq!(lines[2], " up https://b.git (push: git@b.git)");
617 assert!(
618 !lines[1].contains("push:"),
619 "an identical push URL is not printed twice"
620 );
621 }
622
623 #[test]
624 fn no_remotes_renders_a_sentence_not_an_empty_buffer() {
625 assert_eq!(render_remote_list(&[]), "No remotes.\n");
626 }
627
628 #[test]
631 fn the_rendered_row_index_matches_the_cursor_mapping() {
632 let entries = vec![
633 entry("a", "ua", "ua"),
634 entry("bb", "ub", "ub"),
635 entry("ccc", "uc", "uc"),
636 ];
637 let text = render_remote_list(&entries);
638 for (i, line) in text.lines().enumerate().skip(1).take(entries.len()) {
639 let mapped = remote_at_line(&entries, at(i as u32)).expect("row maps");
640 assert!(
641 line.trim_start().starts_with(&mapped.name),
642 "line {i} ({line:?}) does not start with the mapped remote {:?}",
643 mapped.name
644 );
645 }
646 }
647
648 #[test]
649 fn prompt_buffer_names_round_trip_the_remote_they_carry() {
650 for (built, prefix) in [
651 (add_prompt_buffer_name("origin"), ADD_PREFIX),
652 (rename_prompt_buffer_name("origin"), RENAME_PREFIX),
653 (set_url_prompt_buffer_name("origin"), SET_URL_PREFIX),
654 ] {
655 assert_eq!(
656 name_from_prompt_buffer_name(&built, prefix).as_deref(),
657 Some("origin"),
658 "{built} did not round-trip"
659 );
660 }
661 }
662
663 #[test]
667 fn a_prompt_carrier_does_not_decode_under_another_prefix() {
668 let rename = rename_prompt_buffer_name("origin");
669 assert!(name_from_prompt_buffer_name(&rename, SET_URL_PREFIX).is_none());
670 assert!(name_from_prompt_buffer_name(&rename, ADD_PREFIX).is_none());
671 }
672
673 #[test]
674 fn an_unrelated_buffer_name_carries_nothing() {
675 assert!(name_from_prompt_buffer_name("*magit:status*", RENAME_PREFIX).is_none());
676 assert!(name_from_prompt_buffer_name(RENAME_PREFIX, RENAME_PREFIX).is_none());
677 }
678}