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:

<details>
  <summary>Click to view showcase</summary>

My super cool demos here

</details>

---

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.
This commit is contained in:
Piotr Osiewicz 2026-06-19 13:07:07 +02:00 committed by GitHub
parent 69b602c797
commit cc3d4d58ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 213 additions and 19 deletions

1
Cargo.lock generated
View file

@ -22451,6 +22451,7 @@ dependencies = [
"theme",
"theme_settings",
"ui",
"ui_input",
"url",
"util",
"uuid",

View file

@ -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<T> Default for InvalidationStack<T> {
fn default() -> Self {

View file

@ -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;

View file

@ -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

View file

@ -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<bool>,
/// 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<InputField>,
}
#[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::<SharedString>, 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<WorktreeStore>,
remote_host: Option<impl Into<RemoteHostLocation>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Option<PathBuf>, 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<Self>) {
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::<HashSet<_>>();
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<Arc<Path>> {
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<Self>) {
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<PathBuf, SharedString> {
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"),
);
}
}

View file

@ -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)
});
}
}