Compare commits

...

12 Commits

Author SHA1 Message Date
Matt Miller
f34d12dd27 SecurityModal uses updated AlertModal 2025-12-04 18:03:27 -06:00
Kirill Bulatov
57b880526a Add initial functioning design of the new security modal 2025-12-04 22:34:53 +02:00
Kirill Bulatov
039fe76a84 Show a rudimental security modal
Rework new tasks
2025-12-04 20:28:48 +02:00
Kirill Bulatov
be7bfa1803 Add restricted mode indicator to title bar 2025-12-04 20:28:48 +02:00
Kirill Bulatov
792641796a Show untrusted worktrees notifications in proper workspaces 2025-12-04 20:28:48 +02:00
Kirill Bulatov
b14b869aa6 Store untrusted worktrees globally 2025-12-04 20:28:48 +02:00
Kirill Bulatov
9473e69fff Apply a review suggestion 2025-12-04 20:28:48 +02:00
Kirill Bulatov
26945eea1c Fix most of the TODOs 2025-12-04 20:28:48 +02:00
Kirill Bulatov
7cf39ed7e5 Only check trusted worktrees on local settings sync 2025-12-04 20:28:48 +02:00
Kirill Bulatov
2ddd3a033f Small fixes 2025-12-04 20:28:48 +02:00
Kirill Bulatov
ddb7eb1747 Show trust notifications 2025-12-04 20:28:48 +02:00
Kirill Bulatov
db75a2c62a Implement initial project settings trust mechanism 2025-12-04 20:28:48 +02:00
16 changed files with 931 additions and 98 deletions

3
Cargo.lock generated
View File

@@ -13076,6 +13076,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"session",
"settings",
"sha2",
"shellexpand 2.1.2",
@@ -15383,6 +15384,7 @@ dependencies = [
name = "session"
version = "0.1.0"
dependencies = [
"collections",
"db",
"gpui",
"serde_json",
@@ -17509,6 +17511,7 @@ dependencies = [
"rpc",
"schemars",
"serde",
"session",
"settings",
"smallvec",
"story",

View File

@@ -2040,7 +2040,11 @@
// dirty files when closing the application.
//
// Default: true
"restore_unsaved_buffers": true
"restore_unsaved_buffers": true,
// Whether or not to skip project trust checks and synchronize project settings from any worktree automatically.
//
// Default: false
"trust_all_worktrees": false
},
// Zed's Prettier integration settings.
// Allows to enable/disable formatting with Prettier

View File

@@ -70,6 +70,7 @@ schemars.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
session.workspace = true
settings.workspace = true
sha2.workspace = true
shellexpand.workspace = true

View File

@@ -17,13 +17,14 @@ use rpc::{
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use session::TrustedWorktreesStorage;
pub use settings::DirenvSettings;
pub use settings::LspSettings;
use settings::{
DapSettingsContent, InvalidSettingsError, LocalSettingsKind, RegisterSetting, Settings,
SettingsLocation, SettingsStore, parse_json_with_comments, watch_config_file,
};
use std::{path::PathBuf, sync::Arc, time::Duration};
use std::{cell::OnceCell, collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile};
use util::{ResultExt, rel_path::RelPath, serde::default_true};
use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId};
@@ -83,6 +84,10 @@ pub struct SessionSettings {
///
/// Default: true
pub restore_unsaved_buffers: bool,
/// Whether or not to skip project trust checks and synchronize project settings from any worktree automatically.
///
/// Default: false
pub trust_all_worktrees: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -570,6 +575,7 @@ impl Settings for ProjectSettings {
load_direnv: project.load_direnv.clone().unwrap(),
session: SessionSettings {
restore_unsaved_buffers: content.session.unwrap().restore_unsaved_buffers.unwrap(),
trust_all_worktrees: content.session.unwrap().trust_all_worktrees.unwrap(),
},
}
}
@@ -595,6 +601,8 @@ pub struct SettingsObserver {
worktree_store: Entity<WorktreeStore>,
project_id: u64,
task_store: Entity<TaskStore>,
pending_local_settings: HashMap<PathBuf, BTreeMap<(WorktreeId, Arc<RelPath>), Option<String>>>,
_trusted_worktrees_watcher: Option<Subscription>,
_user_settings_watcher: Option<Subscription>,
_global_task_config_watcher: Task<()>,
_global_debug_config_watcher: Task<()>,
@@ -620,11 +628,65 @@ impl SettingsObserver {
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
let weak_settings_observer = cx.weak_entity();
let _trusted_worktrees_watcher = if cx.has_global::<TrustedWorktreesStorage>() {
cx.update_global::<TrustedWorktreesStorage, _>(|trusted_worktrees, cx| {
let watcher = trusted_worktrees.subscribe(cx, move |_, e, cx| match e {
session::Event::TrustedWorktree(trusted_path) => {
weak_settings_observer
.update(cx, |settings_observer, cx| {
if let Some(pending_local_settings) = settings_observer
.pending_local_settings
.remove(trusted_path)
{
for ((worktree_id, directory_path), settings_contents) in
pending_local_settings
{
apply_local_settings(
worktree_id,
&directory_path,
LocalSettingsKind::Settings,
&settings_contents,
cx,
);
if let Some(downstream_client) =
&settings_observer.downstream_client
{
downstream_client
.send(proto::UpdateWorktreeSettings {
project_id: settings_observer.project_id,
worktree_id: worktree_id.to_proto(),
path: directory_path.to_proto(),
content: settings_contents,
kind: Some(
local_settings_kind_to_proto(
LocalSettingsKind::Settings,
)
.into(),
),
})
.log_err();
}
}
}
})
.ok();
}
session::Event::UntrustedWorktree(_) => {}
});
Some(watcher)
})
} else {
None
};
Self {
worktree_store,
task_store,
mode: SettingsObserverMode::Local(fs.clone()),
downstream_client: None,
_trusted_worktrees_watcher,
pending_local_settings: HashMap::default(),
_user_settings_watcher: None,
project_id: REMOTE_SERVER_PROJECT_ID,
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
@@ -677,6 +739,8 @@ impl SettingsObserver {
mode: SettingsObserverMode::Remote,
downstream_client: None,
project_id: REMOTE_SERVER_PROJECT_ID,
_trusted_worktrees_watcher: None,
pending_local_settings: HashMap::default(),
_user_settings_watcher: user_settings_watcher,
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
fs.clone(),
@@ -968,36 +1032,36 @@ impl SettingsObserver {
let worktree_id = worktree.read(cx).id();
let remote_worktree_id = worktree.read(cx).id();
let task_store = self.task_store.clone();
let worktree_abs_path = worktree.read(cx).abs_path();
let can_trust_worktree = OnceCell::new();
for (directory, kind, file_content) in settings_contents {
let mut applied = true;
match kind {
LocalSettingsKind::Settings | LocalSettingsKind::Editorconfig => cx
.update_global::<SettingsStore, _>(|store, cx| {
let result = store.set_local_settings(
worktree_id,
directory.clone(),
kind,
file_content.as_deref(),
cx,
);
match result {
Err(InvalidSettingsError::LocalSettings { path, message }) => {
log::error!("Failed to set local settings in {path:?}: {message}");
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
InvalidSettingsError::LocalSettings { path, message },
)));
}
Err(e) => {
log::error!("Failed to set local settings: {e}");
}
Ok(()) => {
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
.as_std_path()
.join(local_settings_file_relative_path().as_std_path()))));
}
LocalSettingsKind::Settings => {
if *can_trust_worktree.get_or_init(|| {
if cx.has_global::<TrustedWorktreesStorage>() {
cx.update_global::<TrustedWorktreesStorage, _>(
|trusted_worktrees_storage, cx| {
trusted_worktrees_storage
.can_trust_path(worktree_abs_path.as_ref(), cx)
},
)
} else {
true
}
}),
}) {
apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
} else {
applied = false;
self.pending_local_settings
.entry(worktree_abs_path.to_path_buf())
.or_default()
.insert((worktree_id, directory.clone()), file_content.clone());
}
}
LocalSettingsKind::Editorconfig => {
apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
}
LocalSettingsKind::Tasks => {
let result = task_store.update(cx, |task_store, cx| {
task_store.update_user_tasks(
@@ -1060,16 +1124,18 @@ impl SettingsObserver {
}
};
if let Some(downstream_client) = &self.downstream_client {
downstream_client
.send(proto::UpdateWorktreeSettings {
project_id: self.project_id,
worktree_id: remote_worktree_id.to_proto(),
path: directory.to_proto(),
content: file_content.clone(),
kind: Some(local_settings_kind_to_proto(kind).into()),
})
.log_err();
if applied {
if let Some(downstream_client) = &self.downstream_client {
downstream_client
.send(proto::UpdateWorktreeSettings {
project_id: self.project_id,
worktree_id: remote_worktree_id.to_proto(),
path: directory.to_proto(),
content: file_content.clone(),
kind: Some(local_settings_kind_to_proto(kind).into()),
})
.log_err();
}
}
}
}
@@ -1186,6 +1252,41 @@ impl SettingsObserver {
}
}
fn apply_local_settings(
worktree_id: WorktreeId,
directory: &Arc<RelPath>,
kind: LocalSettingsKind,
file_content: &Option<String>,
cx: &mut Context<'_, SettingsObserver>,
) {
cx.update_global::<SettingsStore, _>(|store, cx| {
let result = store.set_local_settings(
worktree_id,
directory.clone(),
kind,
file_content.as_deref(),
cx,
);
match result {
Err(InvalidSettingsError::LocalSettings { path, message }) => {
log::error!("Failed to set local settings in {path:?}: {message}");
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
InvalidSettingsError::LocalSettings { path, message },
)));
}
Err(e) => {
log::error!("Failed to set local settings: {e}");
}
Ok(()) => {
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
.as_std_path()
.join(local_settings_file_relative_path().as_std_path()))));
}
}
})
}
pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSettingsKind {
match kind {
proto::LocalSettingsKind::Settings => LocalSettingsKind::Settings,

View File

@@ -18,6 +18,7 @@ test-support = [
]
[dependencies]
collections.workspace = true
db.workspace = true
gpui.workspace = true
uuid.workspace = true

View File

@@ -1,9 +1,31 @@
use std::time::Duration;
use std::{
path::{Path, PathBuf},
time::Duration,
};
use collections::HashSet;
use db::kvp::KEY_VALUE_STORE;
use gpui::{App, AppContext as _, Context, Subscription, Task, WindowId};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, Global, Subscription, Task, Window,
WindowId,
};
use util::ResultExt;
pub fn init(cx: &mut App) {
cx.spawn(async move |cx| {
let trusted_worktrees = TrustedWorktrees::new().await;
cx.update(|cx| {
let trusted_worktees_storage = TrustedWorktreesStorage {
trusted: cx.new(|_| trusted_worktrees),
untrusted: HashSet::default(),
};
cx.set_global(trusted_worktees_storage);
})
.log_err();
})
.detach();
}
pub struct Session {
session_id: String,
old_session_id: Option<String>,
@@ -12,6 +34,8 @@ pub struct Session {
const SESSION_ID_KEY: &str = "session_id";
const SESSION_WINDOW_STACK_KEY: &str = "session_window_stack";
const TRUSTED_WORKSPACES_KEY: &str = "trusted_workspaces";
const TRUSTED_WORKSPACES_SEPARATOR: &str = "<|>";
impl Session {
pub async fn new(session_id: String) -> Self {
@@ -108,6 +132,177 @@ impl AppSession {
}
}
// TODO kb move the whole trusted shebang into `project` crate.
/// A collection of worktree absolute paths that are considered trusted.
/// This can be used when checking for this criteria before enabling certain features.
#[derive(Clone)]
pub struct TrustedWorktreesStorage {
trusted: Entity<TrustedWorktrees>,
untrusted: HashSet<PathBuf>,
}
#[derive(Debug)]
pub enum Event {
TrustedWorktree(PathBuf),
UntrustedWorktree(PathBuf),
}
/// A collection of absolute paths for trusted worktrees.
/// Such worktrees' local settings will be processed and applied.
///
/// Emits an event each time the worktree path checked and found not trusted,
/// or a certain worktree path had been trusted.
struct TrustedWorktrees {
worktree_roots: HashSet<PathBuf>,
serialization_task: Task<()>,
}
impl EventEmitter<Event> for TrustedWorktrees {}
impl TrustedWorktrees {
async fn new() -> Self {
Self {
worktree_roots: KEY_VALUE_STORE
.read_kvp(TRUSTED_WORKSPACES_KEY)
.ok()
.flatten()
.map(|workspaces| {
workspaces
.split(TRUSTED_WORKSPACES_SEPARATOR)
.map(|workspace_path| PathBuf::from(workspace_path))
.collect()
})
.unwrap_or_default(),
serialization_task: Task::ready(()),
}
}
fn trust_path(&mut self, abs_path: PathBuf, cx: &mut Context<'_, Self>) {
debug_assert!(
abs_path.is_absolute(),
"Cannot trust non-absolute path {abs_path:?}"
);
let updated = self.worktree_roots.insert(abs_path.clone());
if updated {
let new_worktree_roots =
self.worktree_roots
.iter()
.fold(String::new(), |mut acc, path| {
if !acc.is_empty() {
acc.push_str(TRUSTED_WORKSPACES_SEPARATOR);
}
acc.push_str(&path.to_string_lossy());
acc
});
self.serialization_task = cx.background_spawn(async move {
KEY_VALUE_STORE
.write_kvp(TRUSTED_WORKSPACES_KEY.to_string(), new_worktree_roots)
.await
.log_err();
});
// TODO kb wrong: need to emut multiple worktrees, as we can trust some high-level directory
cx.emit(Event::TrustedWorktree(abs_path));
}
}
fn clear(&mut self, cx: &App) {
self.worktree_roots.clear();
self.serialization_task = cx.background_spawn(async move {
KEY_VALUE_STORE
.write_kvp(TRUSTED_WORKSPACES_KEY.to_string(), String::new())
.await
.log_err();
});
}
}
impl Global for TrustedWorktreesStorage {}
impl TrustedWorktreesStorage {
pub fn subscribe<T: 'static>(
&self,
cx: &mut Context<T>,
mut on_event: impl FnMut(&mut T, &Event, &mut Context<T>) + 'static,
) -> Subscription {
cx.subscribe(&self.trusted, move |t, _, e, cx| on_event(t, e, cx))
}
pub fn subscribe_in<T: 'static>(
&self,
window: &mut Window,
cx: &mut Context<T>,
mut on_event: impl FnMut(&mut T, &Event, &mut Window, &mut Context<T>) + 'static,
) -> Subscription {
cx.subscribe_in(&self.trusted, window, move |t, _, e, window, cx| {
on_event(t, e, window, cx)
})
}
/// Adds a worktree absolute path to the trusted list.
/// This will emit [`Event::TrustedWorktree`] event.
pub fn trust_path(&mut self, abs_path: PathBuf, cx: &mut App) {
self.untrusted.remove(&abs_path);
self.trusted.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.trust_path(abs_path, cx)
});
}
/// Checks whether a certain worktree absolute path is trusted.
/// If not, emits [`Event::UntrustedWorktree`] event.
pub fn can_trust_path(&mut self, abs_path: &Path, cx: &mut App) -> bool {
debug_assert!(
abs_path.is_absolute(),
"Cannot check if trusting non-absolute path {abs_path:?}"
);
self.trusted.update(cx, |trusted_worktrees, cx| {
let trusted_worktree_roots = &trusted_worktrees.worktree_roots;
let mut can_trust = !self.untrusted.contains(abs_path);
if can_trust {
can_trust = if trusted_worktree_roots.len() > 100 {
let mut path = Some(abs_path);
while let Some(path_to_check) = path {
if trusted_worktree_roots.contains(path_to_check) {
return true;
}
path = path_to_check.parent();
}
false
} else {
trusted_worktree_roots
.iter()
.any(|trusted_root| abs_path.starts_with(&trusted_root))
};
}
if !can_trust {
if self.untrusted.insert(abs_path.to_owned()) {
cx.emit(Event::UntrustedWorktree(abs_path.to_owned()));
}
}
can_trust
})
}
pub fn untrusted_worktrees(&self) -> &HashSet<PathBuf> {
&self.untrusted
}
pub fn trust_all(&mut self, cx: &mut App) {
for untrusted_path in std::mem::take(&mut self.untrusted) {
self.trust_path(untrusted_path, cx);
}
}
pub fn clear_trusted_paths(&self, cx: &mut App) {
self.trusted.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.clear(cx);
});
}
}
fn window_stack(cx: &App) -> Option<Vec<u64>> {
Some(
cx.window_stack()?

View File

@@ -187,6 +187,10 @@ pub struct SessionSettingsContent {
///
/// Default: true
pub restore_unsaved_buffers: Option<bool>,
/// Whether or not to skip project trust checks and synchronize project settings from any worktree automatically.
///
/// Default: false
pub trust_all_worktrees: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)]

View File

@@ -138,6 +138,28 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
SettingsPageItem::SectionHeader("Security"),
SettingsPageItem::SettingItem(SettingItem {
title: "Trust all worktrees by default",
description: r#"When opening a directory in Zed, whether to require confirmation to read and apply project settings"#,
field: Box::new(SettingField {
json_path: Some("session.trust_all_worktrees"),
pick: |settings_content| {
settings_content
.session
.as_ref()
.and_then(|session| session.trust_all_worktrees.as_ref())
},
write: |settings_content, value| {
settings_content
.session
.get_or_insert_default()
.trust_all_worktrees = value;
},
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SectionHeader("Workspace Restoration"),
SettingsPageItem::SettingItem(SettingItem {
title: "Restore Unsaved Buffers",

View File

@@ -34,6 +34,7 @@ channel.workspace = true
chrono.workspace = true
client.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
db.workspace = true
gpui = { workspace = true, features = ["screen-capture"] }
notifications.workspace = true
@@ -42,6 +43,7 @@ remote.workspace = true
rpc.workspace = true
schemars.workspace = true
serde.workspace = true
session.workspace = true
settings.workspace = true
smallvec.workspace = true
story = { workspace = true, optional = true }

View File

@@ -24,6 +24,7 @@ use auto_update::AutoUpdateStatus;
use call::ActiveCall;
use client::{Client, UserStore, zed_urls};
use cloud_llm_client::{Plan, PlanV1, PlanV2};
use collections::HashSet;
use gpui::{
Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement,
IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled,
@@ -32,8 +33,9 @@ use gpui::{
use onboarding_banner::OnboardingBanner;
use project::{Project, WorktreeSettings, git_store::GitStoreEvent};
use remote::RemoteConnectionOptions;
use session::TrustedWorktreesStorage;
use settings::{Settings, SettingsLocation};
use std::sync::Arc;
use std::{path::PathBuf, sync::Arc};
use theme::ActiveTheme;
use title_bar_settings::TitleBarSettings;
use ui::{
@@ -134,6 +136,7 @@ pub struct TitleBar {
_subscriptions: Vec<Subscription>,
banner: Entity<OnboardingBanner>,
screen_share_popover_handle: PopoverMenuHandle<ContextMenu>,
untrusted_worktrees: HashSet<PathBuf>,
}
impl Render for TitleBar {
@@ -163,6 +166,8 @@ impl Render for TitleBar {
title_bar
.when(title_bar_settings.show_project_items, |title_bar| {
title_bar
.pl_2()
.children(self.render_restricted_mode(cx))
.children(self.render_project_host(cx))
.child(self.render_project_name(cx))
})
@@ -290,6 +295,43 @@ impl TitleBar {
}),
);
subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
let mut untrusted_worktrees = if cx.has_global::<TrustedWorktreesStorage>() {
cx.update_global::<TrustedWorktreesStorage, _>(|trusted_worktrees_storage, cx| {
subscriptions.push(trusted_worktrees_storage.subscribe(
cx,
move |title_bar, e, cx| match e {
session::Event::TrustedWorktree(abs_path) => {
title_bar.untrusted_worktrees.remove(abs_path);
}
session::Event::UntrustedWorktree(abs_path) => {
title_bar
.workspace
.update(cx, |workspace, cx| {
if workspace
.project()
.read(cx)
.find_worktree(abs_path, cx)
.is_some()
{
title_bar.untrusted_worktrees.insert(abs_path.clone());
};
})
.ok();
}
},
));
trusted_worktrees_storage.untrusted_worktrees().clone()
})
} else {
HashSet::default()
};
untrusted_worktrees.retain(|untrusted_path| {
workspace
.project()
.read(cx)
.find_worktree(untrusted_path, cx)
.is_some()
});
let banner = cx.new(|cx| {
OnboardingBanner::new(
@@ -315,7 +357,8 @@ impl TitleBar {
client,
_subscriptions: subscriptions,
banner,
screen_share_popover_handle: Default::default(),
untrusted_worktrees,
screen_share_popover_handle: PopoverMenuHandle::default(),
}
}
@@ -398,6 +441,31 @@ impl TitleBar {
)
}
pub fn render_restricted_mode(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.untrusted_worktrees.is_empty()
|| !cx.has_global::<session::TrustedWorktreesStorage>()
{
return None;
}
Some(
IconButton::new("restricted_mode_trigger", IconName::Warning)
.icon_color(Color::Warning)
.tooltip(Tooltip::text("Restricted Mode".to_string()))
.on_click({
cx.listener(move |title_bar, _, window, cx| {
title_bar
.workspace
.update(cx, |workspace, cx| {
workspace.show_worktree_security_modal(window, cx)
})
.log_err();
})
})
.into_any_element(),
)
}
pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.project.read(cx).is_via_remote_server() {
return self.render_remote_project_connection(cx);

View File

@@ -1,73 +1,121 @@
use crate::component_prelude::*;
use crate::prelude::*;
use crate::{Checkbox, ListBulletItem, ToggleState};
use gpui::IntoElement;
use smallvec::{SmallVec, smallvec};
use theme::ActiveTheme;
#[derive(IntoElement, RegisterComponent)]
pub struct AlertModal {
id: ElementId,
header: Option<AnyElement>,
children: SmallVec<[AnyElement; 2]>,
title: SharedString,
primary_action: SharedString,
dismiss_label: SharedString,
footer: Option<AnyElement>,
title: Option<SharedString>,
primary_action: Option<SharedString>,
dismiss_label: Option<SharedString>,
width: Option<DefiniteLength>,
}
impl AlertModal {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
header: None,
children: smallvec![],
title: title.into(),
primary_action: "Ok".into(),
dismiss_label: "Cancel".into(),
footer: None,
title: None,
primary_action: None,
dismiss_label: None,
width: None,
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn header(mut self, header: impl IntoElement) -> Self {
self.header = Some(header.into_any_element());
self
}
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
pub fn primary_action(mut self, primary_action: impl Into<SharedString>) -> Self {
self.primary_action = primary_action.into();
self.primary_action = Some(primary_action.into());
self
}
pub fn dismiss_label(mut self, dismiss_label: impl Into<SharedString>) -> Self {
self.dismiss_label = dismiss_label.into();
self.dismiss_label = Some(dismiss_label.into());
self
}
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.width = Some(width.into());
self
}
}
impl RenderOnce for AlertModal {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
let width = self.width.unwrap_or_else(|| px(440.).into());
let has_default_footer = self.primary_action.is_some() || self.dismiss_label.is_some();
let mut modal = v_flex()
.id(self.id)
.elevation_3(cx)
.w(px(440.))
.p_5()
.child(
.bg(cx.theme().colors().elevated_surface_background)
.w(width)
.overflow_hidden();
if let Some(header) = self.header {
modal = modal.child(header);
} else if let Some(title) = self.title {
modal = modal.child(
v_flex()
.pt_3()
.pr_3()
.pl_3()
.pb_1()
.child(Headline::new(title).size(HeadlineSize::Small)),
);
}
if !self.children.is_empty() {
modal = modal.child(
v_flex()
.p_3()
.text_ui(cx)
.text_color(Color::Muted.color(cx))
.gap_1()
.child(Headline::new(self.title).size(HeadlineSize::Small))
.children(self.children),
)
.child(
);
}
if let Some(footer) = self.footer {
modal = modal.child(footer);
} else if has_default_footer {
let primary_action = self.primary_action.unwrap_or_else(|| "Ok".into());
let dismiss_label = self.dismiss_label.unwrap_or_else(|| "Cancel".into());
modal = modal.child(
h_flex()
.h(rems(1.75))
.p_3()
.items_center()
.child(div().flex_1())
.child(
h_flex()
.items_center()
.gap_1()
.child(
Button::new(self.dismiss_label.clone(), self.dismiss_label.clone())
.color(Color::Muted),
)
.child(Button::new(
self.primary_action.clone(),
self.primary_action,
)),
),
)
.justify_end()
.gap_1()
.child(Button::new(dismiss_label.clone(), dismiss_label).color(Color::Muted))
.child(Button::new(primary_action.clone(), primary_action)),
);
}
modal
}
}
@@ -90,24 +138,75 @@ impl Component for AlertModal {
Some("A modal dialog that presents an alert message with primary and dismiss actions.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.p_4()
.children(vec![example_group(
vec![
single_example(
"Basic Alert",
AlertModal::new("simple-modal", "Do you want to leave the current call?")
.child("The current window will be closed, and connections to any shared projects will be terminated."
)
.primary_action("Leave Call")
.into_any_element(),
)
],
)])
.into_any_element()
.children(vec![
example_group(vec![single_example(
"Basic Alert",
AlertModal::new("simple-modal")
.title("Do you want to leave the current call?")
.child(
"The current window will be closed, and connections to any shared projects will be terminated."
)
.primary_action("Leave Call")
.dismiss_label("Cancel")
.into_any_element(),
)]),
example_group(vec![single_example(
"Custom Header",
AlertModal::new("custom-header-modal")
.header(
v_flex()
.p_3()
.bg(cx.theme().colors().background)
.gap_1()
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(Headline::new("Unrecognized Workspace").size(HeadlineSize::Small))
)
.child(
h_flex()
.pl(IconSize::default().rems() + rems(0.5))
.child(Label::new("~/projects/my-project").color(Color::Muted))
)
)
.child(
"Untrusted workspaces are opened in Restricted Mode to protect your system.
Review .zed/settings.json for any extensions or commands configured by this project.",
)
.child(
v_flex()
.mt_1()
.child(Label::new("Restricted mode prevents:").color(Color::Muted))
.child(ListBulletItem::new("Project settings from being applied"))
.child(ListBulletItem::new("Language servers from running"))
.child(ListBulletItem::new("MCP integrations from installing"))
)
.footer(
h_flex()
.p_3()
.justify_between()
.child(
Checkbox::new("trust-parent", ToggleState::Unselected)
.label("Trust all projects in parent directory")
)
.child(
h_flex()
.gap_1()
.child(Button::new("restricted", "Open in Restricted Mode").color(Color::Muted))
.child(Button::new("trust", "Trust and Continue").style(ButtonStyle::Filled))
)
)
.width(rems(40.))
.into_any_element(),
)]),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,186 @@
use std::{
borrow::Cow,
path::{Path, PathBuf},
};
use collections::HashSet;
use gpui::BorrowAppContext;
use gpui::{DismissEvent, EventEmitter, Focusable};
use session::TrustedWorktreesStorage;
use theme::ActiveTheme;
use ui::{
AlertModal, Button, ButtonCommon as _, ButtonStyle, Checkbox, Clickable as _, Color, Context,
Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, Label, LabelCommon as _,
ListBulletItem, ParentElement as _, Render, Styled, ToggleState, Window, h_flex, rems, v_flex,
};
use crate::{DismissDecision, ModalView};
pub struct SecurityModal {
pub paths: HashSet<PathBuf>,
home_dir: Option<PathBuf>,
dismissed: bool,
trust_parents: bool,
}
impl Focusable for SecurityModal {
fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle {
cx.focus_handle()
}
}
impl EventEmitter<DismissEvent> for SecurityModal {}
impl ModalView for SecurityModal {
fn on_before_dismiss(
&mut self,
_window: &mut Window,
_: &mut Context<Self>,
) -> DismissDecision {
DismissDecision::Dismiss(self.dismissed)
}
fn fade_out_background(&self) -> bool {
true
}
}
impl Render for SecurityModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.paths.is_empty() {
self.dismiss(cx);
return v_flex().into_any_element();
}
let header_label = if self.paths.len() == 1 {
"Unrecognized Workspace"
} else {
"Unrecognized Workspaces"
};
let trust_label = self.build_trust_label();
AlertModal::new("security-modal")
.header(
v_flex()
.p_3()
.bg(cx.theme().colors().background)
.gap_1()
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(Headline::new(header_label).size(HeadlineSize::Small)),
)
.children(self.paths.iter().map(|path| {
h_flex()
.pl(IconSize::default().rems() + rems(0.5))
.child(Label::new(path.display().to_string()).color(Color::Muted))
})),
)
.child(
"Untrusted workspaces are opened in Restricted Mode to protect your system.
Review .zed/settings.json for any extensions or commands configured by this project.",
)
.child(
v_flex()
.mt_2()
.child(Label::new("Restricted mode prevents:").color(Color::Muted))
.child(ListBulletItem::new("Project settings from being applied"))
.child(ListBulletItem::new("Language servers from running"))
.child(ListBulletItem::new("MCP integrations from installing")),
)
.footer(
h_flex()
.p_3()
.justify_between()
.child(
Checkbox::new("trust-parents", ToggleState::from(self.trust_parents))
.label(trust_label)
.on_click(cx.listener(|security_modal, state: &ToggleState, _, cx| {
security_modal.trust_parents = state.selected();
cx.notify();
})),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("open-in-restricted-mode", "Restricted Mode")
.color(Color::Muted)
.on_click(cx.listener(move |security_modal, _, _, cx| {
security_modal.dismiss(cx);
cx.stop_propagation();
})),
)
.child(
Button::new("trust-and-continue", "Trust and Continue")
.style(ButtonStyle::Filled)
.on_click(cx.listener(move |security_modal, _, _, cx| {
security_modal.trust_and_dismiss(cx);
})),
),
),
)
.width(rems(40.))
.into_any_element()
}
}
impl SecurityModal {
pub fn new(paths: HashSet<PathBuf>) -> Self {
Self {
paths,
dismissed: false,
trust_parents: false,
home_dir: std::env::home_dir(),
}
}
fn build_trust_label(&self) -> Cow<'static, str> {
if self.paths.len() == 1 {
let Some(single_path) = self.paths.iter().next() else {
return Cow::Borrowed("Trust all projects in the parent folders");
};
match single_path.parent().map(|path| match &self.home_dir {
Some(home_dir) => path
.strip_prefix(home_dir)
.map(|stripped| Path::new("~").join(stripped))
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(path)),
None => Cow::Borrowed(path),
}) {
Some(parent) => Cow::Owned(format!("Trust all projects in the {parent:?} folder")),
None => Cow::Borrowed("Trust all projects in the parent folders"),
}
} else {
Cow::Borrowed("Trust all projects in the parent folders")
}
}
fn trust_and_dismiss(&mut self, cx: &mut Context<Self>) {
if cx.has_global::<TrustedWorktreesStorage>() {
cx.update_global::<TrustedWorktreesStorage, _>(|trusted_wortrees_storage, cx| {
let mut paths_to_trust = self.paths.clone();
if self.trust_parents {
paths_to_trust.extend(
self.paths
.iter()
.filter_map(|path| Some(path.parent()?.to_owned())),
);
}
for path_to_trust in paths_to_trust {
trusted_wortrees_storage.trust_path(path_to_trust, cx);
}
});
}
self.dismiss(cx);
}
fn dismiss(&mut self, cx: &mut Context<Self>) {
self.dismissed = true;
cx.emit(DismissEvent);
}
}

View File

@@ -9,6 +9,7 @@ pub mod pane_group;
mod path_list;
mod persistence;
pub mod searchable;
mod security_modal;
pub mod shared_screen;
mod status_bar;
pub mod tasks;
@@ -74,6 +75,7 @@ use project::{
DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
WorktreeSettings,
debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
project_settings::ProjectSettings,
toolchain_store::ToolchainStoreEvent,
};
use remote::{
@@ -82,8 +84,10 @@ use remote::{
};
use schemars::JsonSchema;
use serde::Deserialize;
use session::AppSession;
use settings::{CenteredPaddingSettings, Settings, SettingsLocation, update_settings_file};
use session::{AppSession, TrustedWorktreesStorage};
use settings::{
CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file,
};
use shared_screen::SharedScreen;
use sqlez::{
bindable::{Bind, Column, StaticColumnCount},
@@ -126,11 +130,14 @@ pub use workspace_settings::{
};
use zed_actions::{Spawn, feedback::FileBugReport};
use crate::persistence::{
SerializedAxis,
model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
};
use crate::{item::ItemBufferKind, notifications::NotificationId};
use crate::{
persistence::{
SerializedAxis,
model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
},
security_modal::SecurityModal,
};
pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200);
@@ -265,6 +272,13 @@ actions!(
ToggleRightDock,
/// Toggles zoom on the active pane.
ToggleZoom,
/// If any worktrees are in restricted mode, shows a modal with possible actions.
/// TODO kb docs
ShowWorktreeSecurity,
/// Clears all trusted worktrees, placing them in restricted mode on next open.
/// Requires restart to take effect on already opened projects.
/// TODO kb docs
ClearTrustedWorktrees,
/// Stops following a collaborator.
Unfollow,
/// Restores the banner.
@@ -1204,6 +1218,46 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
if cx.has_global::<TrustedWorktreesStorage>() {
cx.update_global::<TrustedWorktreesStorage, _>(|trusted_worktrees_storage, cx| {
// TODO kb Is it ok that remote projects' worktrees are identified by abs path only?
// Need to join with remote_hosts DB table data
trusted_worktrees_storage
.subscribe_in(window, cx, move |workspace, e, window, cx| match e {
session::Event::TrustedWorktree(trusted_path) => {
if let Some(security_modal) =
workspace.active_modal::<SecurityModal>(cx)
{
let remove = security_modal.update(cx, |security_modal, _| {
security_modal.paths.remove(trusted_path);
security_modal.paths.is_empty()
});
if remove {
workspace.hide_modal(window, cx);
}
}
}
session::Event::UntrustedWorktree(_) => {
workspace.show_worktree_security_modal(window, cx)
}
})
.detach();
})
};
cx.observe_global::<SettingsStore>(|_, cx| {
if ProjectSettings::get_global(cx).session.trust_all_worktrees {
if cx.has_global::<TrustedWorktreesStorage>() {
cx.update_global::<TrustedWorktreesStorage, _>(
|trusted_worktrees_storage, cx| {
trusted_worktrees_storage.trust_all(cx);
},
)
}
}
})
.detach();
cx.subscribe_in(&project, window, move |this, _, event, window, cx| {
match event {
project::Event::RemoteIdChanged(_) => {
@@ -1461,9 +1515,10 @@ impl Workspace {
}),
];
cx.defer_in(window, |this, window, cx| {
this.update_window_title(window, cx);
this.show_initial_notifications(cx);
cx.defer_in(window, move |workspace, window, cx| {
workspace.update_window_title(window, cx);
workspace.show_initial_notifications(cx);
workspace.show_worktree_security_modal(window, cx);
});
Workspace {
weak_self: weak_handle.clone(),
@@ -1517,6 +1572,7 @@ impl Workspace {
scheduled_tasks: Vec::new(),
last_open_dock_positions: Vec::new(),
removing: false,
}
}
@@ -5912,6 +5968,22 @@ impl Workspace {
}
},
))
.on_action(cx.listener(
|workspace: &mut Workspace, _: &ShowWorktreeSecurity, window, cx| {
workspace.show_worktree_security_modal(window, cx);
},
))
.on_action(
cx.listener(|_: &mut Workspace, _: &ClearTrustedWorktrees, _, cx| {
if cx.has_global::<TrustedWorktreesStorage>() {
cx.update_global::<TrustedWorktreesStorage, _>(
|trusted_worktrees_storage, cx| {
trusted_worktrees_storage.clear_trusted_paths(cx);
},
);
}
}),
)
.on_action(cx.listener(
|workspace: &mut Workspace, _: &ReopenClosedItem, window, cx| {
workspace.reopen_closed_item(window, cx).detach();
@@ -6349,6 +6421,40 @@ impl Workspace {
file.project.all_languages.defaults.show_edit_predictions = Some(!show_edit_predictions)
});
}
pub fn show_worktree_security_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let untrusted_worktrees = if cx.has_global::<TrustedWorktreesStorage>() {
cx.update_global::<TrustedWorktreesStorage, _>(|trusted_worktrees_storage, cx| {
trusted_worktrees_storage
.untrusted_worktrees()
.iter()
.filter(|untrusted_path| {
self.project()
.read(cx)
.find_worktree(untrusted_path, cx)
.is_some()
})
.cloned()
.collect()
})
} else {
HashSet::default()
};
if let Some(security_modal) = self.active_modal::<SecurityModal>(cx) {
let remove = security_modal.update(cx, |security_modal, cx| {
security_modal.paths.extend(untrusted_worktrees);
let remove = security_modal.paths.is_empty();
cx.notify();
remove
});
if remove {
self.hide_modal(window, cx);
}
} else if !untrusted_worktrees.is_empty() {
self.toggle_modal(window, cx, |_, _| SecurityModal::new(untrusted_worktrees));
}
}
}
fn leader_border_for_pane(

View File

@@ -105,6 +105,7 @@ impl Settings for WorkspaceSettings {
.collect(),
close_on_file_delete: workspace.close_on_file_delete.unwrap(),
use_system_window_tabs: workspace.use_system_window_tabs.unwrap(),
zoomed_padding: workspace.zoomed_padding.unwrap(),
window_decorations: workspace.window_decorations.unwrap(),
}

View File

@@ -404,6 +404,7 @@ pub fn main() {
});
app.run(move |cx| {
session::init(cx);
menu::init();
zed_actions::init();

View File

@@ -1451,6 +1451,45 @@ or
`boolean` values
### Session
- Description: Controls Zed lifecycle-related behavior.
- Setting: `session`
- Default:
```json
{
"session": {
"restore_unsaved_buffers": true,
"trust_all_worktrees": false
}
}
```
**Options**
1. Whether or not to restore unsaved buffers on restart:
```json [settings]
{
"session": {
"restore_unsaved_buffers": true
}
}
```
If this is true, user won't be prompted whether to save/discard dirty files when closing the application.
2. Whether or not to skip project trust checks and synchronize project settings from any worktree automatically:
```json [settings]
{
"session": {
"trust_all_worktrees": false
}
}
```
### Drag And Drop Selection
- Description: Whether to allow drag and drop text selection in buffer. `delay` is the milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created.