security_modal.rs

  1//! A UI interface for managing the [`TrustedWorktrees`] data.
  2
  3use std::{
  4    borrow::Cow,
  5    path::{Path, PathBuf},
  6    sync::Arc,
  7};
  8
  9use collections::{HashMap, HashSet};
 10use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, WeakEntity};
 11
 12use project::{
 13    WorktreeId,
 14    trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
 15    worktree_store::WorktreeStore,
 16};
 17use smallvec::SmallVec;
 18use theme::ActiveTheme;
 19use ui::{
 20    AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, prelude::*,
 21};
 22
 23use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity};
 24
 25pub struct SecurityModal {
 26    restricted_paths: HashMap<WorktreeId, RestrictedPath>,
 27    home_dir: Option<PathBuf>,
 28    trust_parents: bool,
 29    worktree_store: WeakEntity<WorktreeStore>,
 30    remote_host: Option<RemoteHostLocation>,
 31    focus_handle: FocusHandle,
 32    trusted: Option<bool>,
 33}
 34
 35#[derive(Debug, PartialEq, Eq)]
 36struct RestrictedPath {
 37    abs_path: Arc<Path>,
 38    is_file: bool,
 39    host: Option<RemoteHostLocation>,
 40}
 41
 42impl Focusable for SecurityModal {
 43    fn focus_handle(&self, _: &ui::App) -> FocusHandle {
 44        self.focus_handle.clone()
 45    }
 46}
 47
 48impl EventEmitter<DismissEvent> for SecurityModal {}
 49
 50impl ModalView for SecurityModal {
 51    fn fade_out_background(&self) -> bool {
 52        true
 53    }
 54
 55    fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context<Self>) -> DismissDecision {
 56        match self.trusted {
 57            Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"),
 58            Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"),
 59            None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"),
 60        }
 61        DismissDecision::Dismiss(true)
 62    }
 63}
 64
 65impl Render for SecurityModal {
 66    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 67        if self.restricted_paths.is_empty() {
 68            self.dismiss(cx);
 69            return v_flex().into_any_element();
 70        }
 71
 72        let header_label = if self.restricted_paths.len() == 1 {
 73            "Unrecognized Project"
 74        } else {
 75            "Unrecognized Projects"
 76        };
 77
 78        let trust_label = self.build_trust_label();
 79
 80        AlertModal::new("security-modal")
 81            .width(rems(40.))
 82            .key_context("SecurityModal")
 83            .track_focus(&self.focus_handle(cx))
 84            .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
 85                this.trust_and_dismiss(cx);
 86            }))
 87            .on_action(cx.listener(|security_modal, _: &ToggleWorktreeSecurity, _window, cx| {
 88                security_modal.trusted = Some(false);
 89                security_modal.dismiss(cx);
 90            }))
 91            .header(
 92                v_flex()
 93                    .p_3()
 94                    .gap_1()
 95                    .rounded_t_md()
 96                    .bg(cx.theme().colors().editor_background.opacity(0.5))
 97                    .border_b_1()
 98                    .border_color(cx.theme().colors().border_variant)
 99                    .child(
100                        h_flex()
101                            .gap_2()
102                            .child(Icon::new(IconName::Warning).color(Color::Warning))
103                            .child(Label::new(header_label)),
104                    )
105                    .children(self.restricted_paths.values().filter_map(|restricted_path| {
106                        let abs_path = if restricted_path.is_file {
107                            restricted_path.abs_path.parent()
108                        } else {
109                            Some(restricted_path.abs_path.as_ref())
110                        }?;
111                        let label = match &restricted_path.host {
112                            Some(remote_host) => match &remote_host.user_name {
113                                Some(user_name) => format!(
114                                    "{} ({}@{})",
115                                    self.shorten_path(abs_path).display(),
116                                    user_name,
117                                    remote_host.host_identifier
118                                ),
119                                None => format!(
120                                    "{} ({})",
121                                    self.shorten_path(abs_path).display(),
122                                    remote_host.host_identifier
123                                ),
124                            },
125                            None => self.shorten_path(abs_path).display().to_string(),
126                        };
127                        Some(h_flex()
128                            .pl(IconSize::default().rems() + rems(0.5))
129                            .child(Label::new(label).color(Color::Muted)))
130                    })),
131            )
132            .child(
133                v_flex()
134                    .gap_2()
135                    .child(
136                        v_flex()
137                            .child(
138                                Label::new(
139                                    "Untrusted projects are opened in Restricted Mode to protect your system.",
140                                )
141                                .color(Color::Muted),
142                            )
143                            .child(
144                                Label::new(
145                                    "Review .zed/settings.json for any extensions or commands configured by this project.",
146                                )
147                                .color(Color::Muted),
148                            ),
149                    )
150                    .child(
151                        v_flex()
152                            .child(Label::new("Restricted Mode prevents:").color(Color::Muted))
153                            .child(ListBulletItem::new("Project settings from being applied"))
154                            .child(ListBulletItem::new("Language servers from running"))
155                            .child(ListBulletItem::new("MCP Server integrations from installing")),
156                    )
157                    .map(|this| match trust_label {
158                        Some(trust_label) => this.child(
159                            Checkbox::new("trust-parents", ToggleState::from(self.trust_parents))
160                                .label(trust_label)
161                                .on_click(cx.listener(
162                                    |security_modal, state: &ToggleState, _, cx| {
163                                        security_modal.trust_parents = state.selected();
164                                        cx.notify();
165                                        cx.stop_propagation();
166                                    },
167                                )),
168                        ),
169                        None => this,
170                    }),
171            )
172            .footer(
173                h_flex()
174                    .px_3()
175                    .pb_3()
176                    .gap_1()
177                    .justify_end()
178                    .child(
179                        Button::new("rm", "Stay in Restricted Mode")
180                            .key_binding(
181                                KeyBinding::for_action(
182                                    &ToggleWorktreeSecurity,
183                                    cx,
184                                )
185                                .map(|kb| kb.size(rems_from_px(12.))),
186                            )
187                            .on_click(cx.listener(move |security_modal, _, _, cx| {
188                                security_modal.trusted = Some(false);
189                                security_modal.dismiss(cx);
190                                cx.stop_propagation();
191                            })),
192                    )
193                    .child(
194                        Button::new("tc", "Trust and Continue")
195                            .style(ButtonStyle::Filled)
196                            .layer(ui::ElevationIndex::ModalSurface)
197                            .key_binding(
198                                KeyBinding::for_action(&menu::Confirm, cx)
199                                    .map(|kb| kb.size(rems_from_px(12.))),
200                            )
201                            .on_click(cx.listener(move |security_modal, _, _, cx| {
202                                security_modal.trust_and_dismiss(cx);
203                                cx.stop_propagation();
204                            })),
205                    ),
206            )
207            .into_any_element()
208    }
209}
210
211impl SecurityModal {
212    pub fn new(
213        worktree_store: WeakEntity<WorktreeStore>,
214        remote_host: Option<impl Into<RemoteHostLocation>>,
215        cx: &mut Context<Self>,
216    ) -> Self {
217        let mut this = Self {
218            worktree_store,
219            remote_host: remote_host.map(|host| host.into()),
220            restricted_paths: HashMap::default(),
221            focus_handle: cx.focus_handle(),
222            trust_parents: false,
223            home_dir: std::env::home_dir(),
224            trusted: None,
225        };
226        this.refresh_restricted_paths(cx);
227
228        this
229    }
230
231    fn build_trust_label(&self) -> Option<Cow<'static, str>> {
232        let mut has_restricted_files = false;
233        let available_parents = self
234            .restricted_paths
235            .values()
236            .filter(|restricted_path| {
237                has_restricted_files |= restricted_path.is_file;
238                !restricted_path.is_file
239            })
240            .filter_map(|restricted_path| restricted_path.abs_path.parent())
241            .collect::<SmallVec<[_; 2]>>();
242        match available_parents.len() {
243            0 => {
244                if has_restricted_files {
245                    Some(Cow::Borrowed("Trust all single files"))
246                } else {
247                    None
248                }
249            }
250            1 => Some(Cow::Owned(format!(
251                "Trust all projects in the {:} folder",
252                self.shorten_path(available_parents[0]).display()
253            ))),
254            _ => Some(Cow::Borrowed("Trust all projects in the parent folders")),
255        }
256    }
257
258    fn shorten_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
259        match &self.home_dir {
260            Some(home_dir) => path
261                .strip_prefix(home_dir)
262                .map(|stripped| Path::new("~").join(stripped))
263                .map(Cow::Owned)
264                .unwrap_or(Cow::Borrowed(path)),
265            None => Cow::Borrowed(path),
266        }
267    }
268
269    fn trust_and_dismiss(&mut self, cx: &mut Context<Self>) {
270        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
271            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
272                let mut paths_to_trust = self
273                    .restricted_paths
274                    .keys()
275                    .copied()
276                    .map(PathTrust::Worktree)
277                    .collect::<HashSet<_>>();
278                if self.trust_parents {
279                    paths_to_trust.extend(self.restricted_paths.values().filter_map(
280                        |restricted_paths| {
281                            if restricted_paths.is_file {
282                                None
283                            } else {
284                                let parent_abs_path =
285                                    restricted_paths.abs_path.parent()?.to_owned();
286                                Some(PathTrust::AbsPath(parent_abs_path))
287                            }
288                        },
289                    ));
290                }
291                trusted_worktrees.trust(paths_to_trust, self.remote_host.clone(), cx);
292            });
293        }
294
295        self.trusted = Some(true);
296        self.dismiss(cx);
297    }
298
299    pub fn dismiss(&mut self, cx: &mut Context<Self>) {
300        cx.emit(DismissEvent);
301    }
302
303    pub fn refresh_restricted_paths(&mut self, cx: &mut Context<Self>) {
304        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
305            if let Some(worktree_store) = self.worktree_store.upgrade() {
306                let new_restricted_worktrees = trusted_worktrees
307                    .read(cx)
308                    .restricted_worktrees(worktree_store.read(cx), cx)
309                    .into_iter()
310                    .filter_map(|(worktree_id, abs_path)| {
311                        let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?;
312                        Some((
313                            worktree_id,
314                            RestrictedPath {
315                                abs_path,
316                                is_file: worktree.read(cx).is_single_file(),
317                                host: self.remote_host.clone(),
318                            },
319                        ))
320                    })
321                    .collect::<HashMap<_, _>>();
322
323                if self.restricted_paths != new_restricted_worktrees {
324                    self.trust_parents = false;
325                    self.restricted_paths = new_restricted_worktrees;
326                    cx.notify();
327                }
328            }
329        } else if !self.restricted_paths.is_empty() {
330            self.restricted_paths.clear();
331            cx.notify();
332        }
333    }
334}