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<Option<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: Option<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().map(|restricted_path| {
106                        let abs_path = restricted_path.abs_path.as_ref().and_then(|abs_path| {
107                            if restricted_path.is_file {
108                                abs_path.parent()
109                            } else {
110                                Some(abs_path.as_ref())
111                            }
112                        });
113
114                        let label = match abs_path {
115                            Some(abs_path) => match &restricted_path.host {
116                                Some(remote_host) => match &remote_host.user_name {
117                                    Some(user_name) => format!(
118                                        "{} ({}@{})",
119                                        self.shorten_path(abs_path).display(),
120                                        user_name,
121                                        remote_host.host_identifier
122                                    ),
123                                    None => format!(
124                                        "{} ({})",
125                                        self.shorten_path(abs_path).display(),
126                                        remote_host.host_identifier
127                                    ),
128                                },
129                                None => self.shorten_path(abs_path).display().to_string(),
130                            },
131                            None => match &restricted_path.host {
132                                Some(remote_host) => match &remote_host.user_name {
133                                    Some(user_name) => format!(
134                                        "Empty project ({}@{})",
135                                        user_name, remote_host.host_identifier
136                                    ),
137                                    None => {
138                                        format!("Empty project ({})", remote_host.host_identifier)
139                                    }
140                                },
141                                None => "Empty project".to_string(),
142                            },
143                        };
144                        h_flex()
145                            .pl(IconSize::default().rems() + rems(0.5))
146                            .child(Label::new(label).color(Color::Muted))
147                    })),
148            )
149            .child(
150                v_flex()
151                    .gap_2()
152                    .child(
153                        v_flex()
154                            .child(
155                                Label::new(
156                                    "Untrusted projects are opened in Restricted Mode to protect your system.",
157                                )
158                                .color(Color::Muted),
159                            )
160                            .child(
161                                Label::new(
162                                    "Review .zed/settings.json for any extensions or commands configured by this project.",
163                                )
164                                .color(Color::Muted),
165                            ),
166                    )
167                    .child(
168                        v_flex()
169                            .child(Label::new("Restricted Mode prevents:").color(Color::Muted))
170                            .child(ListBulletItem::new("Project settings from being applied"))
171                            .child(ListBulletItem::new("Language servers from running"))
172                            .child(ListBulletItem::new("MCP Server integrations from installing")),
173                    )
174                    .map(|this| match trust_label {
175                        Some(trust_label) => this.child(
176                            Checkbox::new("trust-parents", ToggleState::from(self.trust_parents))
177                                .label(trust_label)
178                                .on_click(cx.listener(
179                                    |security_modal, state: &ToggleState, _, cx| {
180                                        security_modal.trust_parents = state.selected();
181                                        cx.notify();
182                                        cx.stop_propagation();
183                                    },
184                                )),
185                        ),
186                        None => this,
187                    }),
188            )
189            .footer(
190                h_flex()
191                    .px_3()
192                    .pb_3()
193                    .gap_1()
194                    .justify_end()
195                    .child(
196                        Button::new("rm", "Stay in Restricted Mode")
197                            .key_binding(
198                                KeyBinding::for_action(
199                                    &ToggleWorktreeSecurity,
200                                    cx,
201                                )
202                                .map(|kb| kb.size(rems_from_px(12.))),
203                            )
204                            .on_click(cx.listener(move |security_modal, _, _, cx| {
205                                security_modal.trusted = Some(false);
206                                security_modal.dismiss(cx);
207                                cx.stop_propagation();
208                            })),
209                    )
210                    .child(
211                        Button::new("tc", "Trust and Continue")
212                            .style(ButtonStyle::Filled)
213                            .layer(ui::ElevationIndex::ModalSurface)
214                            .key_binding(
215                                KeyBinding::for_action(&menu::Confirm, cx)
216                                    .map(|kb| kb.size(rems_from_px(12.))),
217                            )
218                            .on_click(cx.listener(move |security_modal, _, _, cx| {
219                                security_modal.trust_and_dismiss(cx);
220                                cx.stop_propagation();
221                            })),
222                    ),
223            )
224            .into_any_element()
225    }
226}
227
228impl SecurityModal {
229    pub fn new(
230        worktree_store: WeakEntity<WorktreeStore>,
231        remote_host: Option<impl Into<RemoteHostLocation>>,
232        cx: &mut Context<Self>,
233    ) -> Self {
234        let mut this = Self {
235            worktree_store,
236            remote_host: remote_host.map(|host| host.into()),
237            restricted_paths: HashMap::default(),
238            focus_handle: cx.focus_handle(),
239            trust_parents: false,
240            home_dir: std::env::home_dir(),
241            trusted: None,
242        };
243        this.refresh_restricted_paths(cx);
244
245        this
246    }
247
248    fn build_trust_label(&self) -> Option<Cow<'static, str>> {
249        let mut has_restricted_files = false;
250        let available_parents = self
251            .restricted_paths
252            .values()
253            .filter(|restricted_path| {
254                has_restricted_files |= restricted_path.is_file;
255                !restricted_path.is_file
256            })
257            .filter_map(|restricted_path| restricted_path.abs_path.as_ref()?.parent())
258            .collect::<SmallVec<[_; 2]>>();
259        match available_parents.len() {
260            0 => {
261                if has_restricted_files {
262                    Some(Cow::Borrowed("Trust all single files"))
263                } else {
264                    None
265                }
266            }
267            1 => Some(Cow::Owned(format!(
268                "Trust all projects in the {:?} folder",
269                self.shorten_path(available_parents[0])
270            ))),
271            _ => Some(Cow::Borrowed("Trust all projects in the parent folders")),
272        }
273    }
274
275    fn shorten_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
276        match &self.home_dir {
277            Some(home_dir) => path
278                .strip_prefix(home_dir)
279                .map(|stripped| Path::new("~").join(stripped))
280                .map(Cow::Owned)
281                .unwrap_or(Cow::Borrowed(path)),
282            None => Cow::Borrowed(path),
283        }
284    }
285
286    fn trust_and_dismiss(&mut self, cx: &mut Context<Self>) {
287        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
288            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
289                let mut paths_to_trust = self
290                    .restricted_paths
291                    .keys()
292                    .map(|worktree_id| match worktree_id {
293                        Some(worktree_id) => PathTrust::Worktree(*worktree_id),
294                        None => PathTrust::Workspace,
295                    })
296                    .collect::<HashSet<_>>();
297                if self.trust_parents {
298                    paths_to_trust.extend(self.restricted_paths.values().filter_map(
299                        |restricted_paths| {
300                            if restricted_paths.is_file {
301                                Some(PathTrust::Workspace)
302                            } else {
303                                let parent_abs_path =
304                                    restricted_paths.abs_path.as_ref()?.parent()?.to_owned();
305                                Some(PathTrust::AbsPath(parent_abs_path))
306                            }
307                        },
308                    ));
309                }
310                trusted_worktrees.trust(paths_to_trust, self.remote_host.clone(), cx);
311            });
312        }
313
314        self.trusted = Some(true);
315        self.dismiss(cx);
316    }
317
318    pub fn dismiss(&mut self, cx: &mut Context<Self>) {
319        cx.emit(DismissEvent);
320    }
321
322    pub fn refresh_restricted_paths(&mut self, cx: &mut Context<Self>) {
323        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
324            if let Some(worktree_store) = self.worktree_store.upgrade() {
325                let mut new_restricted_worktrees = trusted_worktrees
326                    .read(cx)
327                    .restricted_worktrees(worktree_store.read(cx), self.remote_host.clone(), cx)
328                    .into_iter()
329                    .filter_map(|restricted_path| {
330                        let restricted_path = match restricted_path {
331                            Some((worktree_id, abs_path)) => {
332                                let worktree =
333                                    worktree_store.read(cx).worktree_for_id(worktree_id, cx)?;
334                                (
335                                    Some(worktree_id),
336                                    RestrictedPath {
337                                        abs_path: Some(abs_path),
338                                        is_file: worktree.read(cx).is_single_file(),
339                                        host: self.remote_host.clone(),
340                                    },
341                                )
342                            }
343                            None => (
344                                None,
345                                RestrictedPath {
346                                    abs_path: None,
347                                    is_file: false,
348                                    host: self.remote_host.clone(),
349                                },
350                            ),
351                        };
352                        Some(restricted_path)
353                    })
354                    .collect::<HashMap<_, _>>();
355                // Do not clutter the UI:
356                // * trusting regular local worktrees assumes the workspace is trusted either, on the same host.
357                // * trusting a workspace trusts all single-file worktrees on the same host.
358                if new_restricted_worktrees.len() > 1 {
359                    new_restricted_worktrees.remove(&None);
360                }
361
362                if self.restricted_paths != new_restricted_worktrees {
363                    self.trust_parents = false;
364                    self.restricted_paths = new_restricted_worktrees;
365                    cx.notify();
366                }
367            }
368        } else if !self.restricted_paths.is_empty() {
369            self.restricted_paths.clear();
370            cx.notify();
371        }
372    }
373}