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 mut message: SharedString;
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 if message.is_empty() {
90 message = output.stderr.trim().to_string().into();
91 if message.is_empty() {
92 message = "Push Successful".into();
93 }
94 remote = None;
95 } else {
96 let remote_message = get_remote_lines(&output.stderr);
97
98 remote = if remote_message.is_empty() {
99 None
100 } else {
101 let finder = LinkFinder::new();
102 let links = finder
103 .links(&remote_message)
104 .filter(|link| *link.kind() == LinkKind::Url)
105 .map(|link| link.start()..link.end())
106 .collect_vec();
107
108 Some(InfoFromRemote {
109 name: remote_ref.name,
110 remote_text: remote_message.into(),
111 links,
112 })
113 }
114 }
115 }
116 }
117
118 Self {
119 _workspace: workspace,
120 _id: id,
121 message,
122 remote_info: remote,
123 _dismiss_task: task,
124 focus_handle: cx.focus_handle(),
125 }
126 }
127}
128
129impl Render for RemoteOutputToast {
130 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
131 div()
132 .occlude()
133 .w_full()
134 .max_h(vh(0.8, window))
135 .elevation_3(cx)
136 .child(
137 v_flex()
138 .p_3()
139 .overflow_hidden()
140 .child(
141 h_flex()
142 .justify_between()
143 .items_start()
144 .child(
145 h_flex()
146 .gap_2()
147 .child(Icon::new(IconName::GitBranch).color(Color::Default))
148 .child(Label::new("Git")),
149 )
150 .child(h_flex().child(
151 IconButton::new("close", IconName::Close).on_click(
152 cx.listener(|_, _, _, cx| cx.emit(gpui::DismissEvent)),
153 ),
154 )),
155 )
156 .child(Label::new(self.message.clone()).size(LabelSize::Default))
157 .when_some(self.remote_info.as_ref(), |this, remote_info| {
158 this.child(
159 div()
160 .border_1()
161 .border_color(Color::Muted.color(cx))
162 .rounded_lg()
163 .text_sm()
164 .mt_1()
165 .p_1()
166 .child(
167 h_flex()
168 .gap_2()
169 .child(Icon::new(IconName::Cloud).color(Color::Default))
170 .child(
171 Label::new(remote_info.name.clone())
172 .size(LabelSize::Default),
173 ),
174 )
175 .map(|div| {
176 let styled_text =
177 StyledText::new(remote_info.remote_text.clone())
178 .with_highlights(remote_info.links.iter().map(
179 |link| {
180 (
181 link.clone(),
182 HighlightStyle {
183 underline: Some(UnderlineStyle {
184 thickness: px(1.0),
185 ..Default::default()
186 }),
187 ..Default::default()
188 },
189 )
190 },
191 ));
192 let this = cx.weak_entity();
193 let text = InteractiveText::new("remote-message", styled_text)
194 .on_click(
195 remote_info.links.clone(),
196 move |ix, _window, cx| {
197 this.update(cx, |this, cx| {
198 if let Some(remote_info) = &this.remote_info {
199 cx.open_url(
200 &remote_info.remote_text
201 [remote_info.links[ix].clone()],
202 )
203 }
204 })
205 .ok();
206 },
207 );
208
209 div.child(text)
210 }),
211 )
212 }),
213 )
214 }
215}
216
217impl EventEmitter<DismissEvent> for RemoteOutputToast {}
218
219fn get_remote_lines(output: &str) -> String {
220 output
221 .lines()
222 .filter_map(|line| line.strip_prefix("remote:"))
223 .map(|line| line.trim())
224 .filter(|line| !line.is_empty())
225 .collect::<Vec<_>>()
226 .join("\n")
227}