Skip to repository content1421 lines · 48.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:29:14.649Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
git_ui.rs
1use anyhow::anyhow;
2use commit_modal::CommitModal;
3use editor::{Editor, actions::DiffClipboardWithSelectionData};
4
5use workspace::{Toast, notifications::NotificationId};
6
7mod blame_ui;
8pub mod clone;
9
10use git::{
11 repository::{Branch, CommitDetails, Upstream, UpstreamTracking, UpstreamTrackingStatus},
12 status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
13};
14use gpui::{
15 App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
16 SharedString, Subscription, Task, TaskExt, WeakEntity, Window,
17};
18use menu::{Cancel, Confirm};
19use project::git_store::Repository;
20use project_diff::ProjectDiff;
21use time::OffsetDateTime;
22use ui::{ButtonLike, ContextMenu, ElevationIndex, PopoverMenuHandle, TintColor, prelude::*};
23use workspace::{
24 ModalView, OpenMode, Workspace,
25 notifications::{DetachAndPromptErr, NotifyTaskExt},
26};
27use zed_actions;
28
29use crate::{
30 commit_view::CommitView,
31 git_panel::{GitPanel, GitStatusEntry, RemoteOperationKind},
32 solo_diff_view::SoloDiffView,
33 text_diff_view::TextDiffView,
34};
35
36mod askpass_modal;
37pub mod branch_diff;
38pub mod branch_picker;
39mod commit_context_menu;
40mod commit_modal;
41pub mod commit_tooltip;
42pub mod commit_view;
43mod conflict_view;
44pub mod created_worktrees;
45mod diff_multibuffer;
46pub mod file_diff_view;
47pub mod git_graph;
48pub mod git_panel;
49mod git_panel_settings;
50pub mod git_picker;
51mod git_runtime_diagnostics;
52pub mod multi_diff_view;
53pub mod picker_prompt;
54pub mod project_diff;
55pub(crate) mod remote_output;
56pub mod repository_selector;
57pub mod solo_diff_view;
58pub mod staged_diff;
59pub mod stash_picker;
60pub mod text_diff_view;
61pub mod unstaged_diff;
62pub mod worktree_names;
63pub mod worktree_picker;
64pub mod worktree_service;
65
66pub use blame_ui::GitBlameStatus;
67pub use conflict_view::MergeConflictIndicator;
68
69pub fn get_provider_icon(name: &str) -> IconName {
70 match name {
71 "Bitbucket" => IconName::Bitbucket,
72 "Chromium" => IconName::Gerrit,
73 "Codeberg" => IconName::Codeberg,
74 "Forgejo Self-Hosted" => IconName::Forgejo,
75 "GitHub" => IconName::Github,
76 "GitLab" => IconName::Gitlab,
77 "Gitea" => IconName::Gitea,
78 "SourceHut" => IconName::Sourcehut,
79 _ => IconName::Link,
80 }
81}
82
83pub fn init(cx: &mut App) {
84 editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
85 commit_view::init(cx);
86 git_graph::init(cx);
87
88 cx.observe_new(|editor: &mut Editor, _, cx| {
89 conflict_view::register_editor(editor, editor.buffer().clone(), cx);
90 })
91 .detach();
92
93 cx.observe_new(|workspace: &mut Workspace, _, cx| {
94 ProjectDiff::register(workspace, cx);
95 staged_diff::StagedDiff::register(workspace, cx);
96 unstaged_diff::UnstagedDiff::register(workspace, cx);
97 branch_diff::BranchDiff::register(workspace, cx);
98 CommitModal::register(workspace);
99 git_panel::register(workspace);
100 repository_selector::register(workspace);
101 git_picker::register(workspace);
102
103 workspace.register_action(
104 |workspace, action: &zed_actions::CreateWorktree, window, cx| {
105 worktree_service::handle_create_worktree(workspace, action, window, None, cx);
106 },
107 );
108 workspace.register_action(
109 |workspace, action: &zed_actions::SwitchWorktree, window, cx| {
110 worktree_service::handle_switch_worktree(workspace, action, window, None, cx);
111 },
112 );
113
114 workspace.register_action(|workspace, _: &zed_actions::git::Worktree, window, cx| {
115 let focused_dock = workspace.focused_dock_position(window, cx);
116 let project = workspace.project().clone();
117 let workspace_handle = workspace.weak_handle();
118 workspace.toggle_modal(window, cx, |window, cx| {
119 worktree_picker::WorktreePicker::new_modal(
120 project,
121 workspace_handle,
122 focused_dock,
123 window,
124 cx,
125 )
126 });
127 });
128
129 workspace.register_action(
130 |workspace, action: &zed_actions::OpenWorktreeInNewWindow, window, cx| {
131 let path = action.path.clone();
132 let is_remote = !workspace.project().read(cx).is_local();
133
134 if is_remote {
135 let connection_options =
136 workspace.project().read(cx).remote_connection_options(cx);
137 let app_state = workspace.app_state().clone();
138 let workspace_handle = workspace.weak_handle();
139 cx.spawn_in(window, async move |_, cx| {
140 if let Some(connection_options) = connection_options {
141 crate::worktree_picker::open_remote_worktree(
142 connection_options,
143 vec![path],
144 app_state,
145 workspace_handle,
146 cx,
147 )
148 .await?;
149 }
150 anyhow::Ok(())
151 })
152 .detach_and_log_err(cx);
153 } else {
154 workspace
155 .open_workspace_for_paths(OpenMode::NewWindow, vec![path], window, cx)
156 .detach_and_log_err(cx);
157 }
158 },
159 );
160
161 let project = workspace.project().read(cx);
162 if project.is_read_only(cx) {
163 return;
164 }
165 if !project.is_via_collab() {
166 workspace.register_action(
167 |workspace, _: &zed_actions::git::CreatePullRequest, window, cx| {
168 if let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) {
169 panel.update(cx, |panel, cx| {
170 panel.create_pull_request(window, cx);
171 });
172 }
173 },
174 );
175 workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
176 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
177 return;
178 };
179 panel.update(cx, |panel, cx| {
180 panel.fetch(true, window, cx);
181 });
182 });
183 workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
184 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
185 return;
186 };
187 panel.update(cx, |panel, cx| {
188 panel.fetch(false, window, cx);
189 });
190 });
191 workspace.register_action(|workspace, _: &git::Push, window, cx| {
192 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
193 return;
194 };
195 panel.update(cx, |panel, cx| {
196 panel.push(false, false, window, cx);
197 });
198 });
199 workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
200 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
201 return;
202 };
203 panel.update(cx, |panel, cx| {
204 panel.push(false, true, window, cx);
205 });
206 });
207 workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
208 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
209 return;
210 };
211 panel.update(cx, |panel, cx| {
212 panel.push(true, false, window, cx);
213 });
214 });
215 workspace.register_action(|workspace, _: &git::Pull, window, cx| {
216 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
217 return;
218 };
219 panel.update(cx, |panel, cx| {
220 panel.pull(false, window, cx);
221 });
222 });
223 workspace.register_action(|workspace, _: &git::PullRebase, window, cx| {
224 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
225 return;
226 };
227 panel.update(cx, |panel, cx| {
228 panel.pull(true, window, cx);
229 });
230 });
231 }
232 workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
233 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
234 return;
235 };
236 panel.update(cx, |panel, cx| {
237 panel.stash_all(action, window, cx);
238 });
239 });
240 workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
241 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
242 return;
243 };
244 panel.update(cx, |panel, cx| {
245 panel.stash_pop(action, window, cx);
246 });
247 });
248 workspace.register_action(|workspace, action: &git::StashApply, window, cx| {
249 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
250 return;
251 };
252 panel.update(cx, |panel, cx| {
253 panel.stash_apply(action, window, cx);
254 });
255 });
256 workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
257 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
258 return;
259 };
260 panel.update(cx, |panel, cx| {
261 panel.stage_all(action, window, cx);
262 });
263 });
264 workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
265 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
266 return;
267 };
268 panel.update(cx, |panel, cx| {
269 panel.unstage_all(action, window, cx);
270 });
271 });
272 workspace.register_action(|workspace, _: &git::Uncommit, window, cx| {
273 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
274 return;
275 };
276 panel.update(cx, |panel, cx| {
277 panel.uncommit(window, cx);
278 })
279 });
280 workspace.register_action(|workspace, _action: &git::Init, window, cx| {
281 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
282 return;
283 };
284 panel.update(cx, |panel, cx| {
285 panel.git_init(window, cx);
286 });
287 });
288 workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
289 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
290 return;
291 };
292
293 workspace.toggle_modal(window, cx, |window, cx| {
294 GitCloneModal::show(panel, window, cx)
295 });
296 });
297 workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
298 open_modified_files(workspace, window, cx);
299 });
300 workspace.register_action_renderer(|div, workspace, _window, cx| {
301 div.when_some(
302 file_diff_entry(workspace, cx),
303 |div, (entry, repository)| {
304 let workspace = workspace.weak_handle();
305 div.on_action(move |_: &git::OpenFileDiff, window, cx| {
306 open_file_diff(
307 entry.clone(),
308 repository.clone(),
309 workspace.clone(),
310 window,
311 cx,
312 );
313 })
314 },
315 )
316 });
317 workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| {
318 rename_current_branch(workspace, window, cx);
319 });
320 workspace.register_action(|workspace, _: &git::CopyBranchName, _, cx| {
321 copy_branch_name(workspace, cx);
322 });
323 workspace.register_action(show_ref_picker);
324 workspace.register_action(
325 |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
326 if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
327 task.detach();
328 };
329 },
330 );
331 })
332 .detach();
333}
334
335fn open_file_diff(
336 entry: GitStatusEntry,
337 repository: Entity<Repository>,
338 workspace: WeakEntity<Workspace>,
339 window: &mut Window,
340 cx: &mut App,
341) {
342 window.defer(cx, move |window, cx| {
343 SoloDiffView::open_or_focus(entry, repository, workspace.clone(), window, cx)
344 .detach_and_notify_err(workspace, window, cx);
345 });
346}
347
348fn file_diff_entry(
349 workspace: &Workspace,
350 cx: &App,
351) -> Option<(GitStatusEntry, Entity<Repository>)> {
352 let project_path = workspace.active_item(cx)?.project_path(cx)?;
353
354 workspace
355 .project()
356 .read(cx)
357 .repositories(cx)
358 .values()
359 .find_map(|repository| {
360 let repo_path = repository
361 .read(cx)
362 .project_path_to_repo_path(&project_path, cx)?;
363 let status_entry = repository.read(cx).status_for_path(&repo_path)?;
364 Some((
365 GitStatusEntry {
366 repo_path,
367 status: status_entry.status,
368 staging: status_entry.status.staging(),
369 diff_stat: status_entry.diff_stat,
370 },
371 repository.clone(),
372 ))
373 })
374}
375
376fn open_modified_files(
377 workspace: &mut Workspace,
378 window: &mut Window,
379 cx: &mut Context<Workspace>,
380) {
381 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
382 return;
383 };
384 let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
385 let Some(repo) = panel.active_repository.as_ref() else {
386 return Vec::new();
387 };
388 let repo = repo.read(cx);
389 repo.cached_status()
390 .filter_map(|entry| {
391 if entry.status.is_modified() {
392 repo.repo_path_to_project_path(&entry.repo_path, cx)
393 } else {
394 None
395 }
396 })
397 .collect()
398 });
399 for path in modified_paths {
400 workspace.open_path(path, None, true, window, cx).detach();
401 }
402}
403
404pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
405 GitStatusIcon::new(status)
406}
407
408struct RenameBranchModal {
409 current_branch: SharedString,
410 editor: Entity<Editor>,
411 repo: Entity<Repository>,
412}
413
414impl RenameBranchModal {
415 fn new(
416 current_branch: String,
417 repo: Entity<Repository>,
418 window: &mut Window,
419 cx: &mut Context<Self>,
420 ) -> Self {
421 let editor = cx.new(|cx| {
422 let mut editor = Editor::single_line(window, cx);
423 editor.set_text(current_branch.clone(), window, cx);
424 editor
425 });
426 Self {
427 current_branch: current_branch.into(),
428 editor,
429 repo,
430 }
431 }
432
433 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
434 cx.emit(DismissEvent);
435 }
436
437 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
438 let new_name = self.editor.read(cx).text(cx);
439 if new_name.is_empty() || new_name == self.current_branch.as_ref() {
440 cx.emit(DismissEvent);
441 return;
442 }
443
444 let repo = self.repo.clone();
445 let current_branch = self.current_branch.to_string();
446 cx.spawn(async move |_, cx| {
447 match repo
448 .update(cx, |repo, _| {
449 repo.rename_branch(current_branch, new_name.clone())
450 })
451 .await
452 {
453 Ok(Ok(_)) => Ok(()),
454 Ok(Err(error)) => Err(error),
455 Err(_) => Err(anyhow!("Operation was canceled")),
456 }
457 })
458 .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None);
459 cx.emit(DismissEvent);
460 }
461}
462
463impl EventEmitter<DismissEvent> for RenameBranchModal {}
464impl ModalView for RenameBranchModal {}
465impl Focusable for RenameBranchModal {
466 fn focus_handle(&self, cx: &App) -> FocusHandle {
467 self.editor.focus_handle(cx)
468 }
469}
470
471impl Render for RenameBranchModal {
472 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
473 v_flex()
474 .key_context("RenameBranchModal")
475 .on_action(cx.listener(Self::cancel))
476 .on_action(cx.listener(Self::confirm))
477 .elevation_2(cx)
478 .w(rems(34.))
479 .child(
480 h_flex()
481 .px_3()
482 .pt_2()
483 .pb_1()
484 .w_full()
485 .gap_1p5()
486 .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
487 .child(
488 Headline::new(format!("Rename Branch ({})", self.current_branch))
489 .size(HeadlineSize::XSmall),
490 ),
491 )
492 .child(div().px_3().pb_3().w_full().child(self.editor.clone()))
493 }
494}
495
496fn rename_current_branch(
497 workspace: &mut Workspace,
498 window: &mut Window,
499 cx: &mut Context<Workspace>,
500) {
501 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
502 return;
503 };
504 let current_branch: Option<String> = panel.update(cx, |panel, cx| {
505 let repo = panel.active_repository.as_ref()?;
506 let repo = repo.read(cx);
507 repo.branch.as_ref().map(|branch| branch.name().to_string())
508 });
509
510 let Some(current_branch_name) = current_branch else {
511 return;
512 };
513
514 let repo = panel.read(cx).active_repository.clone();
515 let Some(repo) = repo else {
516 return;
517 };
518
519 workspace.toggle_modal(window, cx, |window, cx| {
520 RenameBranchModal::new(current_branch_name, repo, window, cx)
521 });
522}
523
524fn copy_branch_name(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
525 let Some(panel) = workspace.panel::<GitPanel>(cx) else {
526 return;
527 };
528 let branch_name = panel.update(cx, |panel, cx| {
529 let repo = panel.active_repository.as_ref()?;
530 let repo = repo.read(cx);
531 repo.branch.as_ref().map(|branch| branch.name().to_string())
532 });
533 if let Some(name) = branch_name {
534 cx.write_to_clipboard(ClipboardItem::new_string(name));
535 }
536}
537
538struct RefPickerModal {
539 editor: Entity<Editor>,
540 repo: Entity<Repository>,
541 workspace: Entity<Workspace>,
542 commit_details: Option<CommitDetails>,
543 lookup_task: Option<Task<()>>,
544 _editor_subscription: Subscription,
545}
546
547impl RefPickerModal {
548 fn new(
549 repo: Entity<Repository>,
550 workspace: Entity<Workspace>,
551 window: &mut Window,
552 cx: &mut Context<Self>,
553 ) -> Self {
554 let editor = cx.new(|cx| {
555 let mut editor = Editor::single_line(window, cx);
556 editor.set_placeholder_text("Enter git ref...", window, cx);
557 editor
558 });
559
560 let _editor_subscription = cx.subscribe_in(
561 &editor,
562 window,
563 |this, _editor, event: &editor::EditorEvent, window, cx| {
564 if let editor::EditorEvent::BufferEdited = event {
565 this.lookup_commit_details(window, cx);
566 }
567 },
568 );
569
570 Self {
571 editor,
572 repo,
573 workspace,
574 commit_details: None,
575 lookup_task: None,
576 _editor_subscription,
577 }
578 }
579
580 fn lookup_commit_details(&mut self, window: &mut Window, cx: &mut Context<Self>) {
581 let git_ref = self.editor.read(cx).text(cx);
582 let git_ref = git_ref.trim().to_string();
583
584 if git_ref.is_empty() {
585 self.commit_details = None;
586 cx.notify();
587 return;
588 }
589
590 let repo = self.repo.clone();
591 self.lookup_task = Some(cx.spawn_in(window, async move |this, cx| {
592 cx.background_executor()
593 .timer(std::time::Duration::from_millis(300))
594 .await;
595
596 let show_result = repo
597 .update(cx, |repo, _| repo.show(git_ref.clone()))
598 .await
599 .ok();
600
601 if let Some(show_future) = show_result {
602 if let Ok(details) = show_future {
603 this.update(cx, |this, cx| {
604 this.commit_details = Some(details);
605 cx.notify();
606 })
607 .ok();
608 } else {
609 this.update(cx, |this, cx| {
610 this.commit_details = None;
611 cx.notify();
612 })
613 .ok();
614 }
615 }
616 }));
617 }
618
619 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
620 cx.emit(DismissEvent);
621 }
622
623 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
624 let git_ref = self.editor.read(cx).text(cx);
625 let git_ref = git_ref.trim();
626
627 if git_ref.is_empty() {
628 cx.emit(DismissEvent);
629 return;
630 }
631
632 let git_ref_string = git_ref.to_string();
633
634 let repo = self.repo.clone();
635 let workspace = self.workspace.clone();
636
637 window
638 .spawn(cx, async move |cx| -> anyhow::Result<()> {
639 let show_future = repo.update(cx, |repo, _| repo.show(git_ref_string.clone()));
640 let show_result = show_future.await;
641
642 match show_result {
643 Ok(Ok(details)) => {
644 workspace.update_in(cx, |workspace, window, cx| {
645 CommitView::open(
646 details.sha.to_string(),
647 repo.downgrade(),
648 workspace.weak_handle(),
649 None,
650 None,
651 window,
652 cx,
653 );
654 })?;
655 }
656 Ok(Err(_)) | Err(_) => {
657 workspace.update(cx, |workspace, cx| {
658 let error = anyhow::anyhow!("View commit failed");
659 Self::show_git_error_toast(&git_ref_string, error, workspace, cx);
660 });
661 }
662 }
663
664 Ok(())
665 })
666 .detach();
667 cx.emit(DismissEvent);
668 }
669
670 fn show_git_error_toast(
671 _git_ref: &str,
672 error: anyhow::Error,
673 workspace: &mut Workspace,
674 cx: &mut Context<Workspace>,
675 ) {
676 let toast = Toast::new(NotificationId::unique::<()>(), error.to_string());
677 workspace.show_toast(toast, cx);
678 }
679}
680
681impl EventEmitter<DismissEvent> for RefPickerModal {}
682impl ModalView for RefPickerModal {}
683impl Focusable for RefPickerModal {
684 fn focus_handle(&self, cx: &App) -> FocusHandle {
685 self.editor.focus_handle(cx)
686 }
687}
688
689impl Render for RefPickerModal {
690 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
691 let has_commit_details = self.commit_details.is_some();
692 let commit_preview = self.commit_details.as_ref().map(|details| {
693 let commit_time = OffsetDateTime::from_unix_timestamp(details.commit_timestamp)
694 .unwrap_or_else(|_| OffsetDateTime::now_utc());
695 let local_offset =
696 time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
697 let formatted_time = time_format::format_localized_timestamp(
698 commit_time,
699 OffsetDateTime::now_utc(),
700 local_offset,
701 time_format::TimestampFormat::Relative,
702 );
703
704 let subject = details.message.lines().next().unwrap_or("").to_string();
705 let author_and_subject = format!("{} • {}", details.author_name, subject);
706
707 h_flex()
708 .w_full()
709 .gap_6()
710 .justify_between()
711 .overflow_x_hidden()
712 .child(
713 div().max_w_96().child(
714 Label::new(author_and_subject)
715 .size(LabelSize::Small)
716 .truncate()
717 .color(Color::Muted),
718 ),
719 )
720 .child(
721 Label::new(formatted_time)
722 .size(LabelSize::Small)
723 .color(Color::Muted),
724 )
725 });
726
727 v_flex()
728 .key_context("RefPickerModal")
729 .on_action(cx.listener(Self::cancel))
730 .on_action(cx.listener(Self::confirm))
731 .elevation_2(cx)
732 .w(rems(34.))
733 .child(
734 h_flex()
735 .px_3()
736 .pt_2()
737 .pb_1()
738 .w_full()
739 .gap_1p5()
740 .child(Icon::new(IconName::Hash).size(IconSize::XSmall))
741 .child(Headline::new("View Commit").size(HeadlineSize::XSmall)),
742 )
743 .child(div().px_3().w_full().child(self.editor.clone()))
744 .when_some(commit_preview, |el, preview| {
745 el.child(div().px_3().pb_3().w_full().child(preview))
746 })
747 .when(!has_commit_details, |el| el.child(div().pb_3()))
748 }
749}
750
751fn show_ref_picker(
752 workspace: &mut Workspace,
753 _: &git::ViewCommit,
754 window: &mut Window,
755 cx: &mut Context<Workspace>,
756) {
757 let Some(repo) = workspace.project().read(cx).active_repository(cx) else {
758 return;
759 };
760
761 let workspace_entity = cx.entity();
762 workspace.toggle_modal(window, cx, |window, cx| {
763 RefPickerModal::new(repo, workspace_entity, window, cx)
764 });
765}
766
767fn render_remote_button(
768 id: impl Into<SharedString>,
769 branch: &Branch,
770 keybinding_target: Option<FocusHandle>,
771 show_fetch_button: bool,
772 in_progress_operation: Option<RemoteOperationKind>,
773 menu_handle: PopoverMenuHandle<ContextMenu>,
774) -> Option<impl IntoElement> {
775 let id = id.into();
776 let upstream = branch.upstream.as_ref();
777 match upstream {
778 Some(Upstream {
779 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
780 ..
781 }) => match (*ahead, *behind) {
782 (0, 0) if show_fetch_button => Some(remote_button::render_fetch_button(
783 keybinding_target,
784 id,
785 in_progress_operation,
786 menu_handle,
787 )),
788 (0, 0) => None,
789 (ahead, 0) => Some(remote_button::render_push_button(
790 keybinding_target,
791 id,
792 ahead,
793 in_progress_operation,
794 menu_handle,
795 )),
796 (ahead, behind) => Some(remote_button::render_pull_button(
797 keybinding_target,
798 id,
799 ahead,
800 behind,
801 in_progress_operation,
802 menu_handle,
803 )),
804 },
805 Some(Upstream {
806 tracking: UpstreamTracking::Gone,
807 ..
808 }) => Some(remote_button::render_republish_button(
809 keybinding_target,
810 id,
811 in_progress_operation,
812 menu_handle,
813 )),
814 None => Some(remote_button::render_publish_button(
815 keybinding_target,
816 id,
817 in_progress_operation,
818 menu_handle,
819 )),
820 }
821}
822
823mod remote_button {
824 use crate::git_panel::RemoteOperationKind;
825 use gpui::{Action, Anchor, AnyView, ClickEvent, FocusHandle};
826 use ui::{
827 ButtonLike, CommonAnimationExt, ContextMenu, ElevationIndex, PopoverMenu,
828 PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
829 };
830
831 pub fn render_fetch_button(
832 keybinding_target: Option<FocusHandle>,
833 id: SharedString,
834 in_progress_operation: Option<RemoteOperationKind>,
835 menu_handle: PopoverMenuHandle<ContextMenu>,
836 ) -> SplitButton {
837 split_button(
838 id,
839 "Fetch",
840 0,
841 0,
842 Some(IconName::ArrowCircle),
843 keybinding_target.clone(),
844 in_progress_operation,
845 menu_handle,
846 move |_, window, cx| {
847 window.dispatch_action(Box::new(git::Fetch), cx);
848 },
849 move |_window, cx| {
850 git_action_tooltip(
851 "Fetch updates from remote",
852 &git::Fetch,
853 "git fetch",
854 keybinding_target.clone(),
855 cx,
856 )
857 },
858 )
859 }
860
861 pub fn render_push_button(
862 keybinding_target: Option<FocusHandle>,
863 id: SharedString,
864 ahead: u32,
865 in_progress_operation: Option<RemoteOperationKind>,
866 menu_handle: PopoverMenuHandle<ContextMenu>,
867 ) -> SplitButton {
868 split_button(
869 id,
870 "Push",
871 ahead as usize,
872 0,
873 None,
874 keybinding_target.clone(),
875 in_progress_operation,
876 menu_handle,
877 move |_, window, cx| {
878 window.dispatch_action(Box::new(git::Push), cx);
879 },
880 move |_window, cx| {
881 git_action_tooltip(
882 "Push committed changes to remote",
883 &git::Push,
884 "git push",
885 keybinding_target.clone(),
886 cx,
887 )
888 },
889 )
890 }
891
892 pub fn render_pull_button(
893 keybinding_target: Option<FocusHandle>,
894 id: SharedString,
895 ahead: u32,
896 behind: u32,
897 in_progress_operation: Option<RemoteOperationKind>,
898 menu_handle: PopoverMenuHandle<ContextMenu>,
899 ) -> SplitButton {
900 split_button(
901 id,
902 "Pull",
903 ahead as usize,
904 behind as usize,
905 None,
906 keybinding_target.clone(),
907 in_progress_operation,
908 menu_handle,
909 move |_, window, cx| {
910 window.dispatch_action(Box::new(git::Pull), cx);
911 },
912 move |_window, cx| {
913 git_action_tooltip(
914 "Pull",
915 &git::Pull,
916 "git pull",
917 keybinding_target.clone(),
918 cx,
919 )
920 },
921 )
922 }
923
924 pub fn render_publish_button(
925 keybinding_target: Option<FocusHandle>,
926 id: SharedString,
927 in_progress_operation: Option<RemoteOperationKind>,
928 menu_handle: PopoverMenuHandle<ContextMenu>,
929 ) -> SplitButton {
930 split_button(
931 id,
932 "Publish",
933 0,
934 0,
935 Some(IconName::ExpandUp),
936 keybinding_target.clone(),
937 in_progress_operation,
938 menu_handle,
939 move |_, window, cx| {
940 window.dispatch_action(Box::new(git::Push), cx);
941 },
942 move |_window, cx| {
943 git_action_tooltip(
944 "Publish branch to remote",
945 &git::Push,
946 "git push --set-upstream",
947 keybinding_target.clone(),
948 cx,
949 )
950 },
951 )
952 }
953
954 pub fn render_republish_button(
955 keybinding_target: Option<FocusHandle>,
956 id: SharedString,
957 in_progress_operation: Option<RemoteOperationKind>,
958 menu_handle: PopoverMenuHandle<ContextMenu>,
959 ) -> SplitButton {
960 split_button(
961 id,
962 "Republish",
963 0,
964 0,
965 Some(IconName::ExpandUp),
966 keybinding_target.clone(),
967 in_progress_operation,
968 menu_handle,
969 move |_, window, cx| {
970 window.dispatch_action(Box::new(git::Push), cx);
971 },
972 move |_window, cx| {
973 git_action_tooltip(
974 "Re-publish branch to remote",
975 &git::Push,
976 "git push --set-upstream",
977 keybinding_target.clone(),
978 cx,
979 )
980 },
981 )
982 }
983
984 fn in_progress_tooltip(operation: RemoteOperationKind) -> &'static str {
985 match operation {
986 RemoteOperationKind::Fetch => "Fetch in Progress…",
987 RemoteOperationKind::Pull => "Pull in Progress…",
988 RemoteOperationKind::Push => "Push in Progress…",
989 }
990 }
991
992 fn git_action_tooltip(
993 label: impl Into<SharedString>,
994 action: &dyn Action,
995 command: impl Into<SharedString>,
996 focus_handle: Option<FocusHandle>,
997 cx: &mut App,
998 ) -> AnyView {
999 let label = label.into();
1000 let command = command.into();
1001
1002 if let Some(handle) = focus_handle {
1003 Tooltip::with_meta_in(label, Some(action), command, &handle, cx)
1004 } else {
1005 Tooltip::with_meta(label, Some(action), command, cx)
1006 }
1007 }
1008
1009 fn render_git_action_menu(
1010 id: impl Into<ElementId>,
1011 keybinding_target: Option<FocusHandle>,
1012 menu_handle: PopoverMenuHandle<ContextMenu>,
1013 ) -> impl IntoElement {
1014 let menu_open = menu_handle.is_deployed();
1015
1016 PopoverMenu::new(id.into())
1017 .trigger(crate::render_split_button_chevron_trigger(
1018 "split-button-right",
1019 menu_open,
1020 ))
1021 .with_handle(menu_handle)
1022 .menu(move |window, cx| {
1023 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
1024 context_menu
1025 .when_some(keybinding_target.clone(), |el, keybinding_target| {
1026 el.context(keybinding_target)
1027 })
1028 .action("Fetch", git::Fetch.boxed_clone())
1029 .action("Fetch From", git::FetchFrom.boxed_clone())
1030 .action("Pull", git::Pull.boxed_clone())
1031 .action("Pull (Rebase)", git::PullRebase.boxed_clone())
1032 .separator()
1033 .action("Push", git::Push.boxed_clone())
1034 .action("Push To", git::PushTo.boxed_clone())
1035 .action("Force Push", git::ForcePush.boxed_clone())
1036 }))
1037 })
1038 .anchor(Anchor::TopRight)
1039 .offset(gpui::Point {
1040 x: px(0.),
1041 y: px(2.),
1042 })
1043 }
1044
1045 #[allow(clippy::too_many_arguments)]
1046 fn split_button(
1047 id: SharedString,
1048 left_label: impl Into<SharedString>,
1049 ahead_count: usize,
1050 behind_count: usize,
1051 left_icon: Option<IconName>,
1052 keybinding_target: Option<FocusHandle>,
1053 in_progress_operation: Option<RemoteOperationKind>,
1054 menu_handle: PopoverMenuHandle<ContextMenu>,
1055 left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
1056 tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
1057 ) -> SplitButton {
1058 fn count(count: usize) -> impl IntoElement {
1059 h_flex()
1060 .ml_neg_px()
1061 .h(rems(0.875))
1062 .overflow_hidden()
1063 .px_0p5()
1064 .child(
1065 Label::new(count.to_string())
1066 .size(LabelSize::XSmall)
1067 .line_height_style(LineHeightStyle::UiLabel),
1068 )
1069 }
1070
1071 let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
1072 let is_in_progress = in_progress_operation.is_some();
1073
1074 let left = ButtonLike::new_rounded_left(format!("split-button-left-{}", id))
1075 .layer(ElevationIndex::ModalSurface)
1076 .size(ButtonSize::Compact)
1077 .disabled(is_in_progress)
1078 .when(should_render_counts, |this| {
1079 this.child(
1080 h_flex()
1081 .ml_neg_0p5()
1082 .when(behind_count > 0, |this| {
1083 this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
1084 .child(count(behind_count))
1085 })
1086 .when(ahead_count > 0, |this| {
1087 this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
1088 .child(count(ahead_count))
1089 }),
1090 )
1091 })
1092 .when_some(left_icon, |this, left_icon| {
1093 this.map(|this| {
1094 if is_in_progress {
1095 this.child(
1096 Icon::new(IconName::LoadCircle)
1097 .size(IconSize::XSmall)
1098 .color(Color::Disabled)
1099 .with_rotate_animation(2),
1100 )
1101 } else {
1102 this.child(Icon::new(left_icon).size(IconSize::XSmall))
1103 }
1104 })
1105 })
1106 .child(
1107 Label::new(left_label)
1108 .size(LabelSize::Small)
1109 .when(is_in_progress, |this| this.color(Color::Disabled))
1110 .mr_0p5(),
1111 )
1112 .on_click(left_on_click)
1113 .tooltip(move |window, cx| {
1114 if let Some(operation) = in_progress_operation {
1115 Tooltip::simple(in_progress_tooltip(operation), cx)
1116 } else {
1117 tooltip(window, cx)
1118 }
1119 });
1120
1121 let right = render_git_action_menu(
1122 format!("split-button-right-{}", id),
1123 keybinding_target,
1124 menu_handle,
1125 )
1126 .into_any_element();
1127
1128 SplitButton::new(left, right)
1129 }
1130}
1131
1132pub(crate) fn render_split_button_chevron_trigger(
1133 id: impl Into<ElementId>,
1134 menu_open: bool,
1135) -> ButtonLike {
1136 let chevron_button_size = rems_from_px(20.);
1137 let chevron_icon = if menu_open {
1138 IconName::ChevronUp
1139 } else {
1140 IconName::ChevronDown
1141 };
1142
1143 ButtonLike::new_rounded_right(id)
1144 .layer(ElevationIndex::ModalSurface)
1145 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1146 .width(chevron_button_size)
1147 .height(chevron_button_size.into())
1148 .child(Icon::new(chevron_icon).size(IconSize::XSmall))
1149}
1150
1151/// A visual representation of a file's Git status.
1152#[derive(IntoElement, RegisterComponent)]
1153pub struct GitStatusIcon {
1154 status: FileStatus,
1155}
1156
1157impl GitStatusIcon {
1158 pub fn new(status: FileStatus) -> Self {
1159 Self { status }
1160 }
1161}
1162
1163impl RenderOnce for GitStatusIcon {
1164 fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
1165 let status = self.status;
1166
1167 let (icon_name, color) = if status.is_conflicted() {
1168 (
1169 IconName::Warning,
1170 cx.theme().colors().version_control_conflict,
1171 )
1172 } else if status.is_deleted() {
1173 (
1174 IconName::SquareMinus,
1175 cx.theme().colors().version_control_deleted,
1176 )
1177 } else if status.is_modified() {
1178 (
1179 IconName::SquareDot,
1180 cx.theme().colors().version_control_modified,
1181 )
1182 } else {
1183 (
1184 IconName::SquarePlus,
1185 cx.theme().colors().version_control_added,
1186 )
1187 };
1188
1189 Icon::new(icon_name).color(Color::Custom(color))
1190 }
1191}
1192
1193// View this component preview using `workspace: open component-preview`
1194impl Component for GitStatusIcon {
1195 fn scope() -> ComponentScope {
1196 ComponentScope::VersionControl
1197 }
1198
1199 fn description() -> &'static str {
1200 "An icon that visually represents the git status of a file, \
1201 using a distinct glyph and color for modified, added, deleted, and conflicted states."
1202 }
1203
1204 fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
1205 fn tracked_file_status(code: StatusCode) -> FileStatus {
1206 FileStatus::Tracked(git::status::TrackedStatus {
1207 index_status: code,
1208 worktree_status: code,
1209 })
1210 }
1211
1212 let modified = tracked_file_status(StatusCode::Modified);
1213 let added = tracked_file_status(StatusCode::Added);
1214 let deleted = tracked_file_status(StatusCode::Deleted);
1215 let conflict = UnmergedStatus {
1216 first_head: UnmergedStatusCode::Updated,
1217 second_head: UnmergedStatusCode::Updated,
1218 }
1219 .into();
1220
1221 v_flex()
1222 .gap_6()
1223 .children(vec![example_group(vec![
1224 single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
1225 single_example("Added", GitStatusIcon::new(added).into_any_element()),
1226 single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
1227 single_example(
1228 "Conflicted",
1229 GitStatusIcon::new(conflict).into_any_element(),
1230 ),
1231 ])])
1232 .into_any_element()
1233 }
1234}
1235
1236struct GitCloneModal {
1237 panel: Entity<GitPanel>,
1238 repo_input: Entity<Editor>,
1239 focus_handle: FocusHandle,
1240}
1241
1242impl GitCloneModal {
1243 pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
1244 let repo_input = cx.new(|cx| {
1245 let mut editor = Editor::single_line(window, cx);
1246 editor.set_placeholder_text("Enter repository URL…", window, cx);
1247 editor
1248 });
1249 let focus_handle = repo_input.focus_handle(cx);
1250
1251 window.focus(&focus_handle, cx);
1252
1253 Self {
1254 panel,
1255 repo_input,
1256 focus_handle,
1257 }
1258 }
1259}
1260
1261impl Focusable for GitCloneModal {
1262 fn focus_handle(&self, _: &App) -> FocusHandle {
1263 self.focus_handle.clone()
1264 }
1265}
1266
1267impl Render for GitCloneModal {
1268 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1269 div()
1270 .elevation_3(cx)
1271 .w(rems(34.))
1272 .flex_1()
1273 .overflow_hidden()
1274 .child(
1275 div()
1276 .w_full()
1277 .p_2()
1278 .border_b_1()
1279 .border_color(cx.theme().colors().border_variant)
1280 .child(self.repo_input.clone()),
1281 )
1282 .child(
1283 h_flex()
1284 .w_full()
1285 .p_2()
1286 .gap_0p5()
1287 .rounded_b_sm()
1288 .bg(cx.theme().colors().editor_background)
1289 .child(
1290 Label::new("Clone a repository from GitHub or other sources.")
1291 .color(Color::Muted)
1292 .size(LabelSize::Small),
1293 )
1294 .child(
1295 Button::new("learn-more", "Learn More")
1296 .label_size(LabelSize::Small)
1297 .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall))
1298 .on_click(|_, _, cx| {
1299 cx.open_url("https://github.com/git-guides/git-clone");
1300 }),
1301 ),
1302 )
1303 .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
1304 cx.emit(DismissEvent);
1305 }))
1306 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1307 let repo = this.repo_input.read(cx).text(cx);
1308 this.panel.update(cx, |panel, cx| {
1309 panel.git_clone(repo, window, cx);
1310 });
1311 cx.emit(DismissEvent);
1312 }))
1313 }
1314}
1315
1316impl EventEmitter<DismissEvent> for GitCloneModal {}
1317
1318impl ModalView for GitCloneModal {}
1319
1320#[cfg(test)]
1321mod view_commit_tests {
1322 use super::*;
1323 use gpui::{TestAppContext, VisualTestContext, WindowHandle};
1324 use language::language_settings::AllLanguageSettings;
1325 use project::project_settings::ProjectSettings;
1326 use project::{FakeFs, Project, WorktreeSettings};
1327 use serde_json::json;
1328 use settings::{Settings as _, SettingsStore};
1329 use std::path::Path;
1330 use std::sync::Arc;
1331 use theme::LoadThemes;
1332 use util::path;
1333 use workspace::WorkspaceSettings;
1334
1335 fn init_test(cx: &mut TestAppContext) {
1336 zlog::init_test();
1337 cx.update(|cx| {
1338 let settings_store = SettingsStore::test(cx);
1339 cx.set_global(settings_store);
1340 theme_settings::init(LoadThemes::JustBase, cx);
1341 AllLanguageSettings::register(cx);
1342 editor::init(cx);
1343 ProjectSettings::register(cx);
1344 WorktreeSettings::register(cx);
1345 WorkspaceSettings::register(cx);
1346 });
1347 }
1348
1349 async fn setup_git_repo(cx: &mut TestAppContext) -> Arc<FakeFs> {
1350 let fs = FakeFs::new(cx.background_executor.clone());
1351 fs.insert_tree(
1352 "/root",
1353 json!({
1354 "project": {
1355 ".git": {},
1356 "src": {
1357 "main.rs": "fn main() {}"
1358 }
1359 }
1360 }),
1361 )
1362 .await;
1363 fs
1364 }
1365
1366 async fn create_test_workspace(
1367 fs: Arc<FakeFs>,
1368 cx: &mut TestAppContext,
1369 ) -> (Entity<Project>, WindowHandle<Workspace>) {
1370 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
1371 let workspace =
1372 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1373 cx.read(|cx| {
1374 project
1375 .read(cx)
1376 .worktrees(cx)
1377 .next()
1378 .unwrap()
1379 .read(cx)
1380 .as_local()
1381 .unwrap()
1382 .scan_complete()
1383 })
1384 .await;
1385 (project, workspace)
1386 }
1387
1388 #[gpui::test]
1389 async fn test_show_ref_picker_with_repository(cx: &mut TestAppContext) {
1390 init_test(cx);
1391 let fs = setup_git_repo(cx).await;
1392
1393 fs.set_status_for_repo(
1394 Path::new("/root/project/.git"),
1395 &[("src/main.rs", git::status::StatusCode::Modified.worktree())],
1396 );
1397
1398 let (_project, workspace) = create_test_workspace(fs, cx).await;
1399 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1400
1401 let initial_modal_state = workspace
1402 .read_with(cx, |workspace, cx| {
1403 workspace.active_modal::<RefPickerModal>(cx).is_some()
1404 })
1405 .unwrap_or(false);
1406
1407 let _ = workspace.update(cx, |workspace, window, cx| {
1408 show_ref_picker(workspace, &git::ViewCommit, window, cx);
1409 });
1410
1411 let final_modal_state = workspace
1412 .read_with(cx, |workspace, cx| {
1413 workspace.active_modal::<RefPickerModal>(cx).is_some()
1414 })
1415 .unwrap_or(false);
1416
1417 assert!(!initial_modal_state);
1418 assert!(final_modal_state);
1419 }
1420}
1421