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}