1use std::{ops::Range, time::Duration};
2
3use git::repository::{Remote, RemoteCommandOutput};
4use gpui::{
5 DismissEvent, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveText,
6 StyledText, Task, UnderlineStyle, WeakEntity,
7};
8use itertools::Itertools;
9use linkify::{LinkFinder, LinkKind};
10use ui::{
11 div, h_flex, px, v_flex, vh, Clickable, Color, Context, FluentBuilder, Icon, IconButton,
12 IconName, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
13 Render, SharedString, Styled, StyledExt, Window,
14};
15use workspace::{
16 notifications::{Notification, NotificationId},
17 Workspace,
18};
19
20pub enum RemoteAction {
21 Fetch,
22 Pull,
23 Push(Remote),
24}
25
26struct InfoFromRemote {
27 name: SharedString,
28 remote_text: SharedString,
29 links: Vec<Range<usize>>,
30}
31
32pub struct RemoteOutputToast {
33 _workspace: WeakEntity<Workspace>,
34 _id: NotificationId,
35 message: SharedString,
36 remote_info: Option<InfoFromRemote>,
37 _dismiss_task: Task<()>,
38 focus_handle: FocusHandle,
39}
40
41impl Focusable for RemoteOutputToast {
42 fn focus_handle(&self, _cx: &ui::App) -> FocusHandle {
43 self.focus_handle.clone()
44 }
45}
46
47impl Notification for RemoteOutputToast {}
48
49const REMOTE_OUTPUT_TOAST_SECONDS: u64 = 5;
50
51impl RemoteOutputToast {
52 pub fn new(
53 action: RemoteAction,
54 output: RemoteCommandOutput,
55 id: NotificationId,
56 workspace: WeakEntity<Workspace>,
57 cx: &mut Context<Self>,
58 ) -> Self {
59 let task = cx.spawn({
60 let workspace = workspace.clone();
61 let id = id.clone();
62 |_, mut cx| async move {
63 cx.background_executor()
64 .timer(Duration::from_secs(REMOTE_OUTPUT_TOAST_SECONDS))
65 .await;
66 workspace
67 .update(&mut cx, |workspace, cx| {
68 workspace.dismiss_notification(&id, cx);
69 })
70 .ok();
71 }
72 });
73
74 let message;
75 let remote;
76
77 match action {
78 RemoteAction::Fetch | RemoteAction::Pull => {
79 if output.is_empty() {
80 message = "Up to date".into();
81 } else {
82 message = output.stderr.into();
83 }
84 remote = None;
85 }
86
87 RemoteAction::Push(remote_ref) => {
88 message = output.stdout.trim().to_string().into();
89 let remote_message = get_remote_lines(&output.stderr);
90 let finder = LinkFinder::new();
91 let links = finder
92 .links(&remote_message)
93 .filter(|link| *link.kind() == LinkKind::Url)
94 .map(|link| link.start()..link.end())
95 .collect_vec();
96
97 remote = Some(InfoFromRemote {
98 name: remote_ref.name,
99 remote_text: remote_message.into(),
100 links,
101 });
102 }
103 }
104
105 Self {
106 _workspace: workspace,
107 _id: id,
108 message,
109 remote_info: remote,
110 _dismiss_task: task,
111 focus_handle: cx.focus_handle(),
112 }
113 }
114}
115
116impl Render for RemoteOutputToast {
117 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
118 div()
119 .occlude()
120 .w_full()
121 .max_h(vh(0.8, window))
122 .elevation_3(cx)
123 .child(
124 v_flex()
125 .p_3()
126 .overflow_hidden()
127 .child(
128 h_flex()
129 .justify_between()
130 .items_start()
131 .child(
132 h_flex()
133 .gap_2()
134 .child(Icon::new(IconName::GitBranch).color(Color::Default))
135 .child(Label::new("Git")),
136 )
137 .child(h_flex().child(
138 IconButton::new("close", IconName::Close).on_click(
139 cx.listener(|_, _, _, cx| cx.emit(gpui::DismissEvent)),
140 ),
141 )),
142 )
143 .child(Label::new(self.message.clone()).size(LabelSize::Default))
144 .when_some(self.remote_info.as_ref(), |this, remote_info| {
145 this.child(
146 div()
147 .border_1()
148 .border_color(Color::Muted.color(cx))
149 .rounded_lg()
150 .text_sm()
151 .mt_1()
152 .p_1()
153 .child(
154 h_flex()
155 .gap_2()
156 .child(Icon::new(IconName::Cloud).color(Color::Default))
157 .child(
158 Label::new(remote_info.name.clone())
159 .size(LabelSize::Default),
160 ),
161 )
162 .map(|div| {
163 let styled_text =
164 StyledText::new(remote_info.remote_text.clone())
165 .with_highlights(remote_info.links.iter().map(
166 |link| {
167 (
168 link.clone(),
169 HighlightStyle {
170 underline: Some(UnderlineStyle {
171 thickness: px(1.0),
172 ..Default::default()
173 }),
174 ..Default::default()
175 },
176 )
177 },
178 ));
179 let this = cx.weak_entity();
180 let text = InteractiveText::new("remote-message", styled_text)
181 .on_click(
182 remote_info.links.clone(),
183 move |ix, _window, cx| {
184 this.update(cx, |this, cx| {
185 if let Some(remote_info) = &this.remote_info {
186 cx.open_url(
187 &remote_info.remote_text
188 [remote_info.links[ix].clone()],
189 )
190 }
191 })
192 .ok();
193 },
194 );
195
196 div.child(text)
197 }),
198 )
199 }),
200 )
201 }
202}
203
204impl EventEmitter<DismissEvent> for RemoteOutputToast {}
205
206fn get_remote_lines(output: &str) -> String {
207 output
208 .lines()
209 .filter_map(|line| line.strip_prefix("remote:"))
210 .map(|line| line.trim())
211 .filter(|line| !line.is_empty())
212 .collect::<Vec<_>>()
213 .join("\n")
214}