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, worktree_store)) =
271            TrustedWorktrees::try_get_global(cx).zip(self.worktree_store.upgrade())
272        {
273            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
274                let mut paths_to_trust = self
275                    .restricted_paths
276                    .keys()
277                    .copied()
278                    .map(PathTrust::Worktree)
279                    .collect::<HashSet<_>>();
280                if self.trust_parents {
281                    paths_to_trust.extend(self.restricted_paths.values().filter_map(
282                        |restricted_paths| {
283                            if restricted_paths.is_file {
284                                None
285                            } else {
286                                let parent_abs_path =
287                                    restricted_paths.abs_path.parent()?.to_owned();
288                                Some(PathTrust::AbsPath(parent_abs_path))
289                            }
290                        },
291                    ));
292                }
293                trusted_worktrees.trust(&worktree_store, paths_to_trust, cx);
294            });
295        }
296
297        self.trusted = Some(true);
298        self.dismiss(cx);
299    }
300
301    pub fn dismiss(&mut self, cx: &mut Context<Self>) {
302        cx.emit(DismissEvent);
303    }
304
305    pub fn refresh_restricted_paths(&mut self, cx: &mut Context<Self>) {
306        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
307            if let Some(worktree_store) = self.worktree_store.upgrade() {
308                let new_restricted_worktrees = trusted_worktrees
309                    .read(cx)
310                    .restricted_worktrees(&worktree_store, cx)
311                    .into_iter()
312                    .filter_map(|(worktree_id, abs_path)| {
313                        let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?;
314                        Some((
315                            worktree_id,
316                            RestrictedPath {
317                                abs_path,
318                                is_file: worktree.read(cx).is_single_file(),
319                                host: self.remote_host.clone(),
320                            },
321                        ))
322                    })
323                    .collect::<HashMap<_, _>>();
324
325                if self.restricted_paths != new_restricted_worktrees {
326                    self.trust_parents = false;
327                    self.restricted_paths = new_restricted_worktrees;
328                    cx.notify();
329                }
330            }
331        } else if !self.restricted_paths.is_empty() {
332            self.restricted_paths.clear();
333            cx.notify();
334        }
335    }
336}