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 "Workspace trust ({}@{})",
135 user_name, remote_host.host_identifier
136 ),
137 None => {
138 format!("Workspace trust ({})", remote_host.host_identifier)
139 }
140 },
141 None => "Workspace trust".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}