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