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}