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