From cc3d4d58aed840fcdd970efb6c98db80119a7e36 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:07:07 +0200 Subject: [PATCH] workspace: Allow user to pick arbitrary parent path as a trustee (#59562) This allows one to "bless" arbitrary parent path, without making assumptions about the structure of the project storage on users machine. ## Testing - Did you test these changes? If so, how? - Are there any parts that need more testing? - How can other people (reviewers) test your changes? Is there anything specific they need to know? - If relevant, what platforms did you test these changes on, and are there any important ones you can't test? ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase > This section is optional. If this PR does not include a visual change or does not add a new user-facing feature, you can delete this section. - Help others understand the result of this PR by showcasing your awesome work! - If this PR includes a visual change, consider adding a screenshot, GIF, or video - A before/after comparison is very useful for changes to existing features! While a showcase should aim to be brief and digestible, you can use a toggleable section to save space on longer showcases:
Click to view showcase My super cool demos here
--- Release Notes: - When opening a project for the first time, you can now auto-trust arbitrary parent path; previously Zed offered affordance for auto-trusting a parent of a parent only. --- Cargo.lock | 1 + crates/editor/src/editor.rs | 7 + crates/ui_input/src/ui_input.rs | 1 + crates/workspace/Cargo.toml | 1 + crates/workspace/src/security_modal.rs | 218 +++++++++++++++++++++++-- crates/workspace/src/workspace.rs | 4 +- 6 files changed, 213 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8bfbb61cf..ee82f7d975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22451,6 +22451,7 @@ dependencies = [ "theme", "theme_settings", "ui", + "ui_input", "url", "util", "uuid", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e98defa308..32487c5390 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11824,6 +11824,13 @@ impl ui_input::ErasedEditor for ErasedEditorImpl { editor.set_masked(masked, cx); }); } + + fn set_read_only(&self, read_only: bool, cx: &mut App) { + self.0.update(cx, |editor, cx| { + editor.set_read_only(read_only); + cx.notify(); + }); + } } impl Default for InvalidationStack { fn default() -> Self { diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index ab3addc35c..595f16d849 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/crates/ui_input/src/ui_input.rs @@ -20,6 +20,7 @@ pub trait ErasedEditor: 'static { fn set_placeholder_text(&self, text: &str, window: &mut Window, _: &mut App); fn move_selection_to_end(&self, window: &mut Window, _: &mut App); fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App); + fn set_read_only(&self, read_only: bool, cx: &mut App); fn focus_handle(&self, cx: &App) -> FocusHandle; diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index ed91016bca..4fa5c14259 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -66,6 +66,7 @@ telemetry.workspace = true theme.workspace = true theme_settings.workspace = true ui.workspace = true +ui_input.workspace = true url.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs index 378968fd1c..3c8a831fb4 100644 --- a/crates/workspace/src/security_modal.rs +++ b/crates/workspace/src/security_modal.rs @@ -7,7 +7,7 @@ use std::{ }; use collections::{HashMap, HashSet}; -use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, ScrollHandle, WeakEntity}; +use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, WeakEntity}; use project::{ WorktreeId, @@ -20,6 +20,7 @@ use ui::{ AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, WithScrollbar, prelude::*, }; +use ui_input::InputField; use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity}; @@ -32,6 +33,9 @@ pub struct SecurityModal { focus_handle: FocusHandle, project_list_scroll_handle: ScrollHandle, trusted: Option, + /// Editable trust scope shown inline with the checkbox when a single + /// project is being prompted for; read-only until the checkbox is ticked. + trust_path_input: Entity, } #[derive(Debug, PartialEq, Eq)] @@ -86,6 +90,13 @@ impl Render for SecurityModal { let trust_label = self.build_trust_label(); + // The editable trust-scope field is shown only when a single project is + // being prompted for (Delta opens one worktree per thread). + let trust_input = self + .single_trustable_path() + .is_some() + .then(|| self.trust_path_input.clone()); + AlertModal::new("security-modal") .width(rems(40.)) .key_context("SecurityModal") @@ -192,9 +203,55 @@ impl Render for SecurityModal { .child(ListBulletItem::new("Language servers from running")) .child(ListBulletItem::new("MCP Server integrations from installing")), ) - .map(|this| match trust_label { - Some(trust_label) => this.child( - Checkbox::new("trust-parents", ToggleState::from(self.trust_parents)) + .map(|this| { + let Some(trust_label) = trust_label else { + return this; + }; + match trust_input { + // Single project: the editable scope field replaces + // the static folder name, inline with the checkbox. + Some(input) => this.child( + // Top-aligned so the field's validation error + // grows downward without shifting the checkbox; + // the checkbox sits in a fixed-height box matching + // the input row. + h_flex() + .items_start() + .gap_1p5() + .child( + h_flex().h_8().child( + Checkbox::new( + "trust-parents", + ToggleState::from(self.trust_parents), + ) + .label("Trust all projects in") + .on_click(cx.listener( + |security_modal, state: &ToggleState, _, cx| { + let trust_parents = state.selected(); + security_modal.trust_parents = trust_parents; + let input = + security_modal.trust_path_input.clone(); + let editor = input.read(cx).editor().clone(); + editor.set_read_only(!trust_parents, cx); + if !trust_parents { + input.update(cx, |input, cx| { + input.set_error(None::, cx) + }); + } + cx.notify(); + cx.stop_propagation(); + }, + )), + ), + ) + .child(input), + ), + // Zero or several projects: keep the static label. + None => this.child( + Checkbox::new( + "trust-parents", + ToggleState::from(self.trust_parents), + ) .label(trust_label) .on_click(cx.listener( |security_modal, state: &ToggleState, _, cx| { @@ -203,8 +260,8 @@ impl Render for SecurityModal { cx.stop_propagation(); }, )), - ), - None => this, + ), + } }), ) .footer( @@ -250,8 +307,10 @@ impl SecurityModal { pub fn new( worktree_store: WeakEntity, remote_host: Option>, + window: &mut Window, cx: &mut Context, ) -> Self { + let trust_path_input = cx.new(|cx| InputField::new(window, cx, "Folder to trust")); let mut this = Self { worktree_store, remote_host: remote_host.map(|host| host.into()), @@ -261,9 +320,21 @@ impl SecurityModal { trust_parents: false, home_dir: std::env::home_dir(), trusted: None, + trust_path_input, }; this.refresh_restricted_paths(cx); + // Pre-fill with the single project's parent folder (today's static + // scope), read-only until the checkbox is ticked. + if let Some(project) = this.single_trustable_path() { + let default_scope = project.parent().unwrap_or(&project).to_path_buf(); + this.trust_path_input.update(cx, |field, cx| { + field.set_text(&default_scope.to_string_lossy(), window, cx); + }); + } + let editor = this.trust_path_input.read(cx).editor().clone(); + editor.set_read_only(!this.trust_parents, cx); + this } @@ -305,7 +376,31 @@ impl SecurityModal { } } + /// The user-edited trust scope, when an editable field is shown. `Ok(None)` + /// means fall back to the default per-parent behavior; `Err` is a validation + /// message to surface on the field. + fn edited_trust_scope(&self, cx: &App) -> Result, SharedString> { + if !self.trust_parents { + return Ok(None); + } + let Some(project) = self.single_trustable_path() else { + return Ok(None); + }; + let typed = self.trust_path_input.read(cx).text(cx); + validate_trust_scope(&typed, &project, self.home_dir.as_deref()).map(Some) + } + fn trust_and_dismiss(&mut self, cx: &mut Context) { + let scope_override = match self.edited_trust_scope(cx) { + Ok(scope) => scope, + Err(error) => { + // Invalid path: flag the field and keep the modal open. + self.trust_path_input + .update(cx, |input, cx| input.set_error(Some(error), cx)); + return; + } + }; + if let Some((trusted_worktrees, worktree_store)) = TrustedWorktrees::try_get_global(cx).zip(self.worktree_store.upgrade()) { @@ -317,17 +412,21 @@ impl SecurityModal { .map(PathTrust::Worktree) .collect::>(); if self.trust_parents { - paths_to_trust.extend(self.restricted_paths.values().filter_map( - |restricted_paths| { - if restricted_paths.is_file { - None - } else { - let parent_abs_path = - restricted_paths.abs_path.parent()?.to_owned(); - Some(PathTrust::AbsPath(parent_abs_path)) - } - }, - )); + if let Some(scope) = scope_override { + paths_to_trust.insert(PathTrust::AbsPath(scope)); + } else { + paths_to_trust.extend(self.restricted_paths.values().filter_map( + |restricted_paths| { + if restricted_paths.is_file { + None + } else { + let parent_abs_path = + restricted_paths.abs_path.parent()?.to_owned(); + Some(PathTrust::AbsPath(parent_abs_path)) + } + }, + )); + } } trusted_worktrees.trust(&worktree_store, paths_to_trust, cx); }); @@ -341,6 +440,19 @@ impl SecurityModal { cx.emit(DismissEvent); } + /// The sole untrusted, non-file project, when exactly one is being prompted + /// for. This is the case where the trust scope can be edited (Delta opens + /// one worktree per thread); with zero or several we keep the static label. + fn single_trustable_path(&self) -> Option> { + let mut projects = self + .restricted_paths + .values() + .filter(|restricted_path| !restricted_path.is_file) + .map(|restricted_path| restricted_path.abs_path.clone()); + let only = projects.next()?; + projects.next().is_none().then_some(only) + } + pub fn refresh_restricted_paths(&mut self, cx: &mut Context) { if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { if let Some(worktree_store) = self.worktree_store.upgrade() { @@ -378,3 +490,75 @@ impl SecurityModal { } } } + +/// Validates a user-edited trust-scope path. Returns the absolute folder to +/// trust when `typed` is an ancestor of (or equal to) `project`; otherwise an +/// error message suitable for display. A leading `~` is expanded via `home_dir`. +fn validate_trust_scope( + typed: &str, + project: &Path, + home_dir: Option<&Path>, +) -> Result { + let trimmed = typed.trim(); + if trimmed.is_empty() { + return Err("Enter a folder to trust".into()); + } + let expanded = match (trimmed.strip_prefix('~'), home_dir) { + (Some(rest), Some(home_dir)) => { + home_dir.join(rest.strip_prefix(std::path::MAIN_SEPARATOR).unwrap_or(rest)) + } + _ => PathBuf::from(trimmed), + }; + if !expanded.is_absolute() { + return Err("Enter an absolute folder path".into()); + } + if !project.starts_with(&expanded) { + return Err("Must be a parent folder of the project".into()); + } + Ok(expanded) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn accepts_ancestor_or_equal() { + let project = Path::new("/Users/me/dev/delta/wt/t1"); + assert_eq!( + validate_trust_scope("/Users/me/dev/delta/wt", project, None).unwrap(), + PathBuf::from("/Users/me/dev/delta/wt"), + ); + // Equal to the project itself is allowed. + assert_eq!( + validate_trust_scope("/Users/me/dev/delta/wt/t1", project, None).unwrap(), + PathBuf::from("/Users/me/dev/delta/wt/t1"), + ); + // A distant ancestor is allowed. + assert!(validate_trust_scope("/Users/me/dev", project, None).is_ok()); + } + + #[test] + fn rejects_non_ancestor_relative_or_empty() { + let project = Path::new("/Users/me/dev/delta/wt/t1"); + assert!(validate_trust_scope("/Users/other", project, None).is_err()); + assert!(validate_trust_scope("relative/path", project, None).is_err()); + assert!(validate_trust_scope(" ", project, None).is_err()); + // Deeper than the project is not an ancestor. + assert!(validate_trust_scope("/Users/me/dev/delta/wt/t1/sub", project, None).is_err()); + } + + #[test] + fn expands_leading_tilde() { + let home = Path::new("/Users/me"); + let project = Path::new("/Users/me/dev/wt/t1"); + assert_eq!( + validate_trust_scope("~/dev/wt", project, Some(home)).unwrap(), + PathBuf::from("/Users/me/dev/wt"), + ); + assert_eq!( + validate_trust_scope("~", project, Some(home)).unwrap(), + PathBuf::from("/Users/me"), + ); + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e441ef0618..1b50816395 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -8236,8 +8236,8 @@ impl Workspace { .remote_connection_options(cx) .map(RemoteHostLocation::from); let worktree_store = project.worktree_store().downgrade(); - self.toggle_modal(window, cx, |_, cx| { - SecurityModal::new(worktree_store, remote_host, cx) + self.toggle_modal(window, cx, |window, cx| { + SecurityModal::new(worktree_store, remote_host, window, cx) }); } }