Support external .editorconfig (#46332)

Closes #41832

Extends https://github.com/zed-industries/zed/pull/19455

When an internal `.editorconfig` is detected in the worktree, we
traverse parent directories up to the filesystem root looking for
additional `.editorconfig` files. All discovered external configs are
loaded and cached (shared when multiple worktrees reference the same
parent directories). When computing settings for a file, external
configs are applied first (from furthest to closest), then internal
configs.

For local projects, file watchers are set up for each external config so
changes are applied immediately. When a project is shared via collab,
external configs are sent to guests through the existing
`UpdateWorktreeSettings` proto message (with a new `outside_worktree`
field). SSH remoting works similarly.

Limitations: We don't currently take creation of new external editor
config files into account since they are loaded once on worktree add.

Release Notes:

- Added support for `.editorconfig` files outside the project directory.
Zed now traverses parent directories to find and apply EditorConfig
settings. Use `root = true` in any `.editorconfig` to stop inheriting
settings from parent directories.
This commit is contained in:
Smit Barmase authored and GitHub committed 2026-01-19 20:32:32 +05:30
1 parent 25264b2cc3
commit 2f7dbbdac7
15 files changed
+1334 -209

No files matched your search

@@ -146,6 +146,7 @@ CREATE TABLE "worktree_settings_files" (
"path" VARCHAR NOT NULL,
"content" TEXT,
"kind" VARCHAR,
"outside_worktree" BOOL NOT NULL DEFAULT FALSE,
PRIMARY KEY (project_id, worktree_id, path),
FOREIGN KEY (project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE
);
@@ -503,7 +503,8 @@ CREATE TABLE public.worktree_settings_files (
worktree_id bigint NOT NULL,
path character varying NOT NULL,
content text NOT NULL,
kind character varying
kind character varying,
outside_worktree boolean DEFAULT false NOT NULL
);
CREATE TABLE public.worktrees (
+1
View File
@@ -649,6 +649,7 @@ pub struct WorktreeSettingsFile {
pub path: String,
pub content: String,
pub kind: LocalSettingsKind,
pub outside_worktree: bool,
}
pub struct NewExtensionVersion {
+6 -1
View File
@@ -760,6 +760,7 @@ impl Database {
path: ActiveValue::Set(update.path.clone()),
content: ActiveValue::Set(content.clone()),
kind: ActiveValue::Set(kind),
outside_worktree: ActiveValue::Set(update.outside_worktree.unwrap_or(false)),
})
.on_conflict(
OnConflict::columns([
@@ -767,7 +768,10 @@ impl Database {
worktree_settings_file::Column::WorktreeId,
worktree_settings_file::Column::Path,
])
.update_column(worktree_settings_file::Column::Content)
.update_columns([
worktree_settings_file::Column::Content,
worktree_settings_file::Column::OutsideWorktree,
])
.to_owned(),
)
.exec(&*tx)
@@ -1050,6 +1054,7 @@ impl Database {
path: db_settings_file.path,
content: db_settings_file.content,
kind: db_settings_file.kind,
outside_worktree: db_settings_file.outside_worktree,
});
}
}
+1
View File
@@ -834,6 +834,7 @@ impl Database {
path: db_settings_file.path,
content: db_settings_file.content,
kind: db_settings_file.kind,
outside_worktree: db_settings_file.outside_worktree,
});
}
}
@@ -12,6 +12,7 @@ pub struct Model {
pub path: String,
pub content: String,
pub kind: LocalSettingsKind,
pub outside_worktree: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+2
View File
@@ -1555,6 +1555,7 @@ fn notify_rejoined_projects(
path: settings_file.path,
content: Some(settings_file.content),
kind: Some(settings_file.kind.to_proto().into()),
outside_worktree: Some(settings_file.outside_worktree),
},
)?;
}
@@ -1987,6 +1988,7 @@ async fn join_project(
path: settings_file.path,
content: Some(settings_file.content),
kind: Some(settings_file.kind.to_proto() as i32),
outside_worktree: Some(settings_file.outside_worktree),
},
)?;
}
+106 -1
View File
@@ -21,7 +21,7 @@ use gpui::{
App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext,
};
use indoc::indoc;
use language::{FakeLspAdapter, rust_lang};
use language::{FakeLspAdapter, language_settings::language_settings, rust_lang};
use lsp::LSP_REQUEST_TIMEOUT;
use pretty_assertions::assert_eq;
use project::{
@@ -35,6 +35,7 @@ use serde_json::json;
use settings::{InlayHintSettingsContent, InlineBlameSettings, SettingsStore};
use std::{
collections::BTreeSet,
num::NonZeroU32,
ops::{Deref as _, Range},
path::{Path, PathBuf},
sync::{
@@ -3978,6 +3979,110 @@ fn main() { let foo = other::foo(); }"};
);
}
#[gpui::test(iterations = 10)]
async fn test_collaborating_with_external_editorconfig(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
client_a.language_registry().add(rust_lang());
client_b.language_registry().add(rust_lang());
// Set up external .editorconfig in parent directory
client_a
.fs()
.insert_tree(
path!("/parent"),
json!({
".editorconfig": "[*]\nindent_size = 5\n",
"worktree": {
".editorconfig": "[*]\n",
"src": {
"main.rs": "fn main() {}",
},
},
}),
)
.await;
let (project_a, worktree_id) = client_a
.build_local_project(path!("/parent/worktree"), cx_a)
.await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
// Open buffer on client A
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/main.rs")), cx)
})
.await
.unwrap();
cx_a.run_until_parked();
// Verify client A sees external editorconfig settings
cx_a.read(|cx| {
let file = buffer_a.read(cx).file();
let settings = language_settings(Some("Rust".into()), file, cx);
assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
});
// Client B joins the project
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/main.rs")), cx)
})
.await
.unwrap();
cx_b.run_until_parked();
// Verify client B also sees external editorconfig settings
cx_b.read(|cx| {
let file = buffer_b.read(cx).file();
let settings = language_settings(Some("Rust".into()), file, cx);
assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
});
// Client A modifies the external .editorconfig
client_a
.fs()
.atomic_write(
PathBuf::from(path!("/parent/.editorconfig")),
"[*]\nindent_size = 9\n".to_owned(),
)
.await
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
// Verify client A sees updated settings
cx_a.read(|cx| {
let file = buffer_a.read(cx).file();
let settings = language_settings(Some("Rust".into()), file, cx);
assert_eq!(Some(settings.tab_size), NonZeroU32::new(9));
});
// Verify client B also sees updated settings
cx_b.read(|cx| {
let file = buffer_b.read(cx).file();
let settings = language_settings(Some("Rust".into()), file, cx);
assert_eq!(Some(settings.tab_size), NonZeroU32::new(9));
});
}
#[gpui::test]
async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let executor = cx_a.executor();
+3 -1
View File
@@ -451,7 +451,9 @@ impl AllLanguageSettings {
let editorconfig_properties = location.and_then(|location| {
cx.global::<SettingsStore>()
.editorconfig_properties(location.worktree_id, location.path)
.editorconfig_store
.read(cx)
.properties(location.worktree_id, location.path)
});
if let Some(editorconfig_properties) = editorconfig_properties {
let mut settings = settings.clone();
+119 -30
View File
@@ -20,8 +20,9 @@ use serde::{Deserialize, Serialize};
pub use settings::DirenvSettings;
pub use settings::LspSettings;
use settings::{
DapSettingsContent, InvalidSettingsError, LocalSettingsKind, RegisterSetting, Settings,
SettingsLocation, SettingsStore, parse_json_with_comments, watch_config_file,
DapSettingsContent, EditorconfigEvent, InvalidSettingsError, LocalSettingsKind,
LocalSettingsPath, RegisterSetting, Settings, SettingsLocation, SettingsStore,
parse_json_with_comments, watch_config_file,
};
use std::{cell::OnceCell, collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile};
@@ -670,6 +671,7 @@ pub struct SettingsObserver {
HashMap<PathTrust, BTreeMap<(WorktreeId, Arc<RelPath>), Option<String>>>,
_trusted_worktrees_watcher: Option<Subscription>,
_user_settings_watcher: Option<Subscription>,
_editorconfig_watcher: Option<Subscription>,
_global_task_config_watcher: Task<()>,
_global_debug_config_watcher: Task<()>,
}
@@ -708,9 +710,11 @@ impl SettingsObserver {
for ((worktree_id, directory_path), settings_contents) in
pending_local_settings
{
let path =
LocalSettingsPath::InWorktree(directory_path.clone());
apply_local_settings(
worktree_id,
&directory_path,
path.clone(),
LocalSettingsKind::Settings,
&settings_contents,
cx,
@@ -722,7 +726,7 @@ impl SettingsObserver {
.send(proto::UpdateWorktreeSettings {
project_id: settings_observer.project_id,
worktree_id: worktree_id.to_proto(),
path: directory_path.to_proto(),
path: path.to_proto(),
content: settings_contents,
kind: Some(
local_settings_kind_to_proto(
@@ -730,6 +734,7 @@ impl SettingsObserver {
)
.into(),
),
outside_worktree: Some(false),
})
.log_err();
}
@@ -742,6 +747,36 @@ impl SettingsObserver {
)
});
let editorconfig_store = cx.global::<SettingsStore>().editorconfig_store.clone();
let _editorconfig_watcher = cx.subscribe(
&editorconfig_store,
|this, _, event: &EditorconfigEvent, cx| {
let EditorconfigEvent::ExternalConfigChanged {
path,
content,
affected_worktree_ids,
} = event;
for worktree_id in affected_worktree_ids {
if let Some(worktree) = this
.worktree_store
.read(cx)
.worktree_for_id(*worktree_id, cx)
{
this.update_settings(
worktree,
[(
path.clone(),
LocalSettingsKind::Editorconfig,
content.clone(),
)],
false,
cx,
);
}
}
},
);
Self {
worktree_store,
task_store,
@@ -750,6 +785,7 @@ impl SettingsObserver {
_trusted_worktrees_watcher,
pending_local_settings: HashMap::default(),
_user_settings_watcher: None,
_editorconfig_watcher: Some(_editorconfig_watcher),
project_id: REMOTE_SERVER_PROJECT_ID,
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
fs.clone(),
@@ -805,6 +841,7 @@ impl SettingsObserver {
_trusted_worktrees_watcher: None,
pending_local_settings: HashMap::default(),
_user_settings_watcher: user_settings_watcher,
_editorconfig_watcher: None,
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
fs.clone(),
paths::tasks_file().clone(),
@@ -841,19 +878,25 @@ impl SettingsObserver {
kind: Some(
local_settings_kind_to_proto(LocalSettingsKind::Settings).into(),
),
outside_worktree: Some(false),
})
.log_err();
}
for (path, content, _) in store.local_editorconfig_settings(worktree.read(cx).id()) {
for (path, content, _) in store
.editorconfig_store
.read(cx)
.local_editorconfig_settings(worktree.read(cx).id())
{
downstream_client
.send(proto::UpdateWorktreeSettings {
project_id,
worktree_id,
path: path.to_proto(),
content: Some(content),
content: Some(content.to_owned()),
kind: Some(
local_settings_kind_to_proto(LocalSettingsKind::Editorconfig).into(),
),
outside_worktree: Some(path.is_outside_worktree()),
})
.log_err();
}
@@ -874,7 +917,12 @@ impl SettingsObserver {
.with_context(|| format!("unknown kind {kind}"))?,
None => proto::LocalSettingsKind::Settings,
};
let path = RelPath::from_proto(&envelope.payload.path)?;
let path = LocalSettingsPath::from_proto(
&envelope.payload.path,
envelope.payload.outside_worktree.unwrap_or(false),
)?;
this.update(&mut cx, |this, cx| {
let is_via_collab = match &this.mode {
SettingsObserverMode::Local(..) => false,
@@ -1012,6 +1060,23 @@ impl SettingsObserver {
let Some(settings_dir) = path.parent().map(Arc::from) else {
continue;
};
if matches!(change, PathChange::Loaded) || matches!(change, PathChange::Added) {
let worktree_id = worktree.read(cx).id();
let worktree_path = worktree.read(cx).abs_path();
let fs = fs.clone();
cx.update_global::<SettingsStore, _>(|store, cx| {
store
.editorconfig_store
.update(cx, |editorconfig_store, cx| {
editorconfig_store.discover_local_external_configs_chain(
worktree_id,
worktree_path,
fs,
cx,
);
});
});
}
(settings_dir, LocalSettingsKind::Editorconfig)
} else {
continue;
@@ -1088,7 +1153,11 @@ impl SettingsObserver {
this.update_settings(
worktree,
settings_contents.into_iter().map(|(path, kind, content)| {
(path, kind, content.and_then(|c| c.log_err()))
(
LocalSettingsPath::InWorktree(path),
kind,
content.and_then(|c| c.log_err()),
)
}),
false,
cx,
@@ -1102,7 +1171,9 @@ impl SettingsObserver {
fn update_settings(
&mut self,
worktree: Entity<Worktree>,
settings_contents: impl IntoIterator<Item = (Arc<RelPath>, LocalSettingsKind, Option<String>)>,
settings_contents: impl IntoIterator<
Item = (LocalSettingsPath, LocalSettingsKind, Option<String>),
>,
is_via_collab: bool,
cx: &mut Context<Self>,
) {
@@ -1114,10 +1185,10 @@ impl SettingsObserver {
} else {
OnceCell::new()
};
for (directory, kind, file_content) in settings_contents {
for (directory_path, kind, file_content) in settings_contents {
let mut applied = true;
match kind {
LocalSettingsKind::Settings => {
match (&directory_path, kind) {
(LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Settings) => {
if *can_trust_worktree.get_or_init(|| {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
@@ -1127,7 +1198,13 @@ impl SettingsObserver {
true
}
}) {
apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
apply_local_settings(
worktree_id,
LocalSettingsPath::InWorktree(directory.clone()),
kind,
&file_content,
cx,
)
} else {
applied = false;
self.pending_local_settings
@@ -1136,10 +1213,7 @@ impl SettingsObserver {
.insert((worktree_id, directory.clone()), file_content.clone());
}
}
LocalSettingsKind::Editorconfig => {
apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
}
LocalSettingsKind::Tasks => {
(LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Tasks) => {
let result = task_store.update(cx, |task_store, cx| {
task_store.update_user_tasks(
TaskSettingsLocation::Worktree(SettingsLocation {
@@ -1168,7 +1242,7 @@ impl SettingsObserver {
}
}
}
LocalSettingsKind::Debug => {
(LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Debug) => {
let result = task_store.update(cx, |task_store, cx| {
task_store.update_user_debug_scenarios(
TaskSettingsLocation::Worktree(SettingsLocation {
@@ -1199,6 +1273,17 @@ impl SettingsObserver {
}
}
}
(directory, LocalSettingsKind::Editorconfig) => {
apply_local_settings(worktree_id, directory.clone(), kind, &file_content, cx);
}
(LocalSettingsPath::OutsideWorktree(path), kind) => {
log::error!(
"OutsideWorktree path {:?} with kind {:?} is only supported by editorconfig",
path,
kind
);
continue;
}
};
if applied {
@@ -1207,9 +1292,10 @@ impl SettingsObserver {
.send(proto::UpdateWorktreeSettings {
project_id: self.project_id,
worktree_id: remote_worktree_id.to_proto(),
path: directory.to_proto(),
path: directory_path.to_proto(),
content: file_content.clone(),
kind: Some(local_settings_kind_to_proto(kind).into()),
outside_worktree: Some(directory_path.is_outside_worktree()),
})
.log_err();
}
@@ -1323,19 +1409,14 @@ impl SettingsObserver {
fn apply_local_settings(
worktree_id: WorktreeId,
directory: &Arc<RelPath>,
path: LocalSettingsPath,
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,
);
let result =
store.set_local_settings(worktree_id, path.clone(), kind, file_content.as_deref(), cx);
match result {
Err(InvalidSettingsError::LocalSettings { path, message }) => {
@@ -1345,9 +1426,17 @@ fn apply_local_settings(
)));
}
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())))),
Ok(()) => {
let settings_path = match &path {
LocalSettingsPath::InWorktree(rel_path) => rel_path
.as_std_path()
.join(local_settings_file_relative_path().as_std_path()),
LocalSettingsPath::OutsideWorktree(abs_path) => abs_path.to_path_buf(),
};
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(
settings_path,
)))
}
}
})
}
+574 -1
View File
@@ -27,7 +27,7 @@ use language::{
ManifestName, ManifestProvider, ManifestQuery, OffsetRangeExt, Point, ToPoint, ToolchainList,
ToolchainLister,
language_settings::{LanguageSettingsContent, language_settings},
rust_lang, tree_sitter_typescript,
markdown_lang, rust_lang, tree_sitter_typescript,
};
use lsp::{
DiagnosticSeverity, DocumentChanges, FileOperationFilter, NumberOrString, TextDocumentEdit,
@@ -244,6 +244,579 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) {
});
}
#[gpui::test]
async fn test_external_editorconfig_support(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/grandparent"),
json!({
".editorconfig": "[*]\nindent_size = 4\n",
"parent": {
".editorconfig": "[*.rs]\nindent_size = 2\n",
"worktree": {
".editorconfig": "[*.md]\nindent_size = 3\n",
"main.rs": "fn main() {}",
"README.md": "# README",
"other.txt": "other content",
}
}
}),
)
.await;
let project = Project::test(fs, [path!("/grandparent/parent/worktree").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
language_registry.add(markdown_lang());
let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
cx.executor().run_until_parked();
cx.update(|cx| {
let tree = worktree.read(cx);
let settings_for = |path: &str| {
let file_entry = tree.entry_for_path(rel_path(path)).unwrap().clone();
let file = File::for_entry(file_entry, worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
language_settings(Some(file_language.name()), Some(&file), cx).into_owned()
};
let settings_rs = settings_for("main.rs");
let settings_md = settings_for("README.md");
let settings_txt = settings_for("other.txt");
// main.rs gets indent_size = 2 from parent's external .editorconfig
assert_eq!(Some(settings_rs.tab_size), NonZeroU32::new(2));
// README.md gets indent_size = 3 from internal worktree .editorconfig
assert_eq!(Some(settings_md.tab_size), NonZeroU32::new(3));
// other.txt gets indent_size = 4 from grandparent's external .editorconfig
assert_eq!(Some(settings_txt.tab_size), NonZeroU32::new(4));
});
}
#[gpui::test]
async fn test_external_editorconfig_root_stops_traversal(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/parent"),
json!({
".editorconfig": "[*]\nindent_size = 99\n",
"worktree": {
".editorconfig": "root = true\n[*]\nindent_size = 2\n",
"file.rs": "fn main() {}",
}
}),
)
.await;
let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
cx.executor().run_until_parked();
cx.update(|cx| {
let tree = worktree.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// file.rs gets indent_size = 2 from worktree's root config, NOT 99 from parent
assert_eq!(Some(settings.tab_size), NonZeroU32::new(2));
});
}
#[gpui::test]
async fn test_external_editorconfig_root_in_parent_stops_traversal(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/grandparent"),
json!({
".editorconfig": "[*]\nindent_size = 99\n",
"parent": {
".editorconfig": "root = true\n[*]\nindent_size = 4\n",
"worktree": {
"file.rs": "fn main() {}",
}
}
}),
)
.await;
let project = Project::test(fs, [path!("/grandparent/parent/worktree").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
cx.executor().run_until_parked();
cx.update(|cx| {
let tree = worktree.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// file.rs gets indent_size = 4 from parent's root config, NOT 99 from grandparent
assert_eq!(Some(settings.tab_size), NonZeroU32::new(4));
});
}
#[gpui::test]
async fn test_external_editorconfig_shared_across_worktrees(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/parent"),
json!({
".editorconfig": "root = true\n[*]\nindent_size = 5\n",
"worktree_a": {
"file.rs": "fn a() {}",
".editorconfig": "[*]\ninsert_final_newline = true\n",
},
"worktree_b": {
"file.rs": "fn b() {}",
".editorconfig": "[*]\ninsert_final_newline = false\n",
}
}),
)
.await;
let project = Project::test(
fs,
[
path!("/parent/worktree_a").as_ref(),
path!("/parent/worktree_b").as_ref(),
],
cx,
)
.await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
cx.executor().run_until_parked();
cx.update(|cx| {
let worktrees: Vec<_> = project.read(cx).worktrees(cx).collect();
assert_eq!(worktrees.len(), 2);
for worktree in worktrees {
let tree = worktree.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings =
language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// Both worktrees should get indent_size = 5 from shared parent .editorconfig
assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
}
});
}
#[gpui::test]
async fn test_external_editorconfig_not_loaded_without_internal_config(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/parent"),
json!({
".editorconfig": "[*]\nindent_size = 99\n",
"worktree": {
"file.rs": "fn main() {}",
}
}),
)
.await;
let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
cx.executor().run_until_parked();
cx.update(|cx| {
let tree = worktree.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// file.rs should have default tab_size = 4, NOT 99 from parent's external .editorconfig
// because without an internal .editorconfig, external configs are not loaded
assert_eq!(Some(settings.tab_size), NonZeroU32::new(4));
});
}
#[gpui::test]
async fn test_external_editorconfig_modification_triggers_refresh(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/parent"),
json!({
".editorconfig": "[*]\nindent_size = 4\n",
"worktree": {
".editorconfig": "[*]\n",
"file.rs": "fn main() {}",
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/parent/worktree").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
cx.executor().run_until_parked();
cx.update(|cx| {
let tree = worktree.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// Test initial settings: tab_size = 4 from parent's external .editorconfig
assert_eq!(Some(settings.tab_size), NonZeroU32::new(4));
});
fs.atomic_write(
PathBuf::from(path!("/parent/.editorconfig")),
"[*]\nindent_size = 8\n".to_owned(),
)
.await
.unwrap();
cx.executor().run_until_parked();
cx.update(|cx| {
let tree = worktree.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// Test settings updated: tab_size = 8
assert_eq!(Some(settings.tab_size), NonZeroU32::new(8));
});
}
#[gpui::test]
async fn test_adding_worktree_discovers_external_editorconfigs(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/parent"),
json!({
".editorconfig": "root = true\n[*]\nindent_size = 7\n",
"existing_worktree": {
".editorconfig": "[*]\n",
"file.rs": "fn a() {}",
},
"new_worktree": {
".editorconfig": "[*]\n",
"file.rs": "fn b() {}",
}
}),
)
.await;
let project = Project::test(fs, [path!("/parent/existing_worktree").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
cx.executor().run_until_parked();
cx.update(|cx| {
let worktree = project.read(cx).worktrees(cx).next().unwrap();
let tree = worktree.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// Test existing worktree has tab_size = 7
assert_eq!(Some(settings.tab_size), NonZeroU32::new(7));
});
let (new_worktree, _) = project
.update(cx, |project, cx| {
project.find_or_create_worktree(path!("/parent/new_worktree"), true, cx)
})
.await
.unwrap();
cx.executor().run_until_parked();
cx.update(|cx| {
let tree = new_worktree.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, new_worktree.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// Verify new worktree also has tab_size = 7 from shared parent editorconfig
assert_eq!(Some(settings.tab_size), NonZeroU32::new(7));
});
}
#[gpui::test]
async fn test_removing_worktree_cleans_up_external_editorconfig(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/parent"),
json!({
".editorconfig": "[*]\nindent_size = 6\n",
"worktree": {
".editorconfig": "[*]\n",
"file.rs": "fn main() {}",
}
}),
)
.await;
let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
let worktree_id = worktree.read_with(cx, |tree, _| tree.id());
cx.executor().run_until_parked();
cx.update(|cx| {
let store = cx.global::<SettingsStore>();
let (worktree_ids, external_paths, watcher_paths) =
store.editorconfig_store.read(cx).test_state();
// Test external config is loaded
assert!(worktree_ids.contains(&worktree_id));
assert!(!external_paths.is_empty());
assert!(!watcher_paths.is_empty());
});
project.update(cx, |project, cx| {
project.remove_worktree(worktree_id, cx);
});
cx.executor().run_until_parked();
cx.update(|cx| {
let store = cx.global::<SettingsStore>();
let (worktree_ids, external_paths, watcher_paths) =
store.editorconfig_store.read(cx).test_state();
// Test worktree state, external configs, and watchers all removed
assert!(!worktree_ids.contains(&worktree_id));
assert!(external_paths.is_empty());
assert!(watcher_paths.is_empty());
});
}
#[gpui::test]
async fn test_shared_external_editorconfig_cleanup_with_multiple_worktrees(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/parent"),
json!({
".editorconfig": "root = true\n[*]\nindent_size = 5\n",
"worktree_a": {
".editorconfig": "[*]\n",
"file.rs": "fn a() {}",
},
"worktree_b": {
".editorconfig": "[*]\n",
"file.rs": "fn b() {}",
}
}),
)
.await;
let project = Project::test(
fs,
[
path!("/parent/worktree_a").as_ref(),
path!("/parent/worktree_b").as_ref(),
],
cx,
)
.await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
cx.executor().run_until_parked();
let (worktree_a_id, worktree_b, worktree_b_id) = cx.update(|cx| {
let worktrees: Vec<_> = project.read(cx).worktrees(cx).collect();
assert_eq!(worktrees.len(), 2);
let worktree_a = &worktrees[0];
let worktree_b = &worktrees[1];
let worktree_a_id = worktree_a.read(cx).id();
let worktree_b_id = worktree_b.read(cx).id();
(worktree_a_id, worktree_b.clone(), worktree_b_id)
});
cx.update(|cx| {
let store = cx.global::<SettingsStore>();
let (worktree_ids, external_paths, _) = store.editorconfig_store.read(cx).test_state();
// Test both worktrees have settings and share external config
assert!(worktree_ids.contains(&worktree_a_id));
assert!(worktree_ids.contains(&worktree_b_id));
assert_eq!(external_paths.len(), 1); // single shared external config
});
project.update(cx, |project, cx| {
project.remove_worktree(worktree_a_id, cx);
});
cx.executor().run_until_parked();
cx.update(|cx| {
let store = cx.global::<SettingsStore>();
let (worktree_ids, external_paths, watcher_paths) =
store.editorconfig_store.read(cx).test_state();
// Test worktree_a is gone but external config remains for worktree_b
assert!(!worktree_ids.contains(&worktree_a_id));
assert!(worktree_ids.contains(&worktree_b_id));
// External config should still exist because worktree_b uses it
assert_eq!(external_paths.len(), 1);
assert_eq!(watcher_paths.len(), 1);
});
cx.update(|cx| {
let tree = worktree_b.read(cx);
let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
let file = File::for_entry(file_entry, worktree_b.clone());
let file_language = project
.read(cx)
.languages()
.load_language_for_file_path(file.path.as_std_path());
let file_language = cx
.foreground_executor()
.block_on(file_language)
.expect("Failed to get file language");
let file = file as _;
let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
// Test worktree_b still has correct settings
assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
});
}
#[gpui::test]
async fn test_git_provider_project_setting(cx: &mut gpui::TestAppContext) {
init_test(cx);
+1
View File
@@ -146,6 +146,7 @@ message UpdateWorktreeSettings {
string path = 3;
optional string content = 4;
optional LocalSettingsKind kind = 5;
optional bool outside_worktree = 6;
}
enum LocalSettingsKind {
+385
View File
@@ -0,0 +1,385 @@
use anyhow::{Context as _, Result};
use collections::{BTreeMap, BTreeSet, HashSet};
use ec4rs::{ConfigParser, PropertiesSource, Section};
use fs::Fs;
use futures::StreamExt;
use gpui::{Context, EventEmitter, Task};
use paths::EDITORCONFIG_NAME;
use smallvec::SmallVec;
use std::{path::Path, str::FromStr, sync::Arc};
use util::{ResultExt as _, rel_path::RelPath};
use crate::{InvalidSettingsError, LocalSettingsPath, WorktreeId, watch_config_file};
pub type EditorconfigProperties = ec4rs::Properties;
#[derive(Clone)]
pub struct Editorconfig {
pub is_root: bool,
pub sections: SmallVec<[Section; 5]>,
}
impl FromStr for Editorconfig {
type Err = anyhow::Error;
fn from_str(contents: &str) -> Result<Self, Self::Err> {
let parser = ConfigParser::new_buffered(contents.as_bytes())
.context("creating editorconfig parser")?;
let is_root = parser.is_root;
let sections = parser
.collect::<Result<SmallVec<_>, _>>()
.context("parsing editorconfig sections")?;
Ok(Self { is_root, sections })
}
}
#[derive(Clone, Debug)]
pub enum EditorconfigEvent {
ExternalConfigChanged {
path: LocalSettingsPath,
content: Option<String>,
affected_worktree_ids: Vec<WorktreeId>,
},
}
impl EventEmitter<EditorconfigEvent> for EditorconfigStore {}
#[derive(Default)]
pub struct EditorconfigStore {
external_configs: BTreeMap<Arc<Path>, (String, Option<Editorconfig>)>,
worktree_state: BTreeMap<WorktreeId, EditorconfigWorktreeState>,
local_external_config_watchers: BTreeMap<Arc<Path>, Task<()>>,
local_external_config_discovery_tasks: BTreeMap<WorktreeId, Task<()>>,
}
#[derive(Default)]
struct EditorconfigWorktreeState {
internal_configs: BTreeMap<Arc<RelPath>, (String, Option<Editorconfig>)>,
external_config_paths: BTreeSet<Arc<Path>>,
}
impl EditorconfigStore {
pub(crate) fn set_configs(
&mut self,
worktree_id: WorktreeId,
path: LocalSettingsPath,
content: Option<&str>,
) -> std::result::Result<(), InvalidSettingsError> {
match (&path, content) {
(LocalSettingsPath::InWorktree(rel_path), None) => {
if let Some(state) = self.worktree_state.get_mut(&worktree_id) {
state.internal_configs.remove(rel_path);
}
}
(LocalSettingsPath::OutsideWorktree(abs_path), None) => {
if let Some(state) = self.worktree_state.get_mut(&worktree_id) {
state.external_config_paths.remove(abs_path);
}
let still_in_use = self
.worktree_state
.values()
.any(|state| state.external_config_paths.contains(abs_path));
if !still_in_use {
self.external_configs.remove(abs_path);
self.local_external_config_watchers.remove(abs_path);
}
}
(LocalSettingsPath::InWorktree(rel_path), Some(content)) => {
let state = self.worktree_state.entry(worktree_id).or_default();
let should_update = state
.internal_configs
.get(rel_path)
.map_or(true, |entry| entry.0 != content);
if should_update {
let parsed = match content.parse::<Editorconfig>() {
Ok(parsed) => Some(parsed),
Err(e) => {
state
.internal_configs
.insert(rel_path.clone(), (content.to_owned(), None));
return Err(InvalidSettingsError::Editorconfig {
message: e.to_string(),
path: LocalSettingsPath::InWorktree(
rel_path.join(RelPath::unix(EDITORCONFIG_NAME).unwrap()),
),
});
}
};
state
.internal_configs
.insert(rel_path.clone(), (content.to_owned(), parsed));
}
}
(LocalSettingsPath::OutsideWorktree(abs_path), Some(content)) => {
let state = self.worktree_state.entry(worktree_id).or_default();
state.external_config_paths.insert(abs_path.clone());
let should_update = self
.external_configs
.get(abs_path)
.map_or(true, |entry| entry.0 != content);
if should_update {
let parsed = match content.parse::<Editorconfig>() {
Ok(parsed) => Some(parsed),
Err(e) => {
self.external_configs
.insert(abs_path.clone(), (content.to_owned(), None));
return Err(InvalidSettingsError::Editorconfig {
message: e.to_string(),
path: LocalSettingsPath::OutsideWorktree(
abs_path.join(EDITORCONFIG_NAME).into(),
),
});
}
};
self.external_configs
.insert(abs_path.clone(), (content.to_owned(), parsed));
}
}
}
Ok(())
}
pub(crate) fn remove_for_worktree(&mut self, root_id: WorktreeId) {
self.local_external_config_discovery_tasks.remove(&root_id);
let Some(removed) = self.worktree_state.remove(&root_id) else {
return;
};
let paths_in_use: HashSet<_> = self
.worktree_state
.values()
.flat_map(|w| w.external_config_paths.iter())
.collect();
for path in removed.external_config_paths.iter() {
if !paths_in_use.contains(path) {
self.external_configs.remove(path);
self.local_external_config_watchers.remove(path);
}
}
}
fn internal_configs(
&self,
root_id: WorktreeId,
) -> impl '_ + Iterator<Item = (&RelPath, &str, Option<&Editorconfig>)> {
self.worktree_state
.get(&root_id)
.into_iter()
.flat_map(|state| {
state
.internal_configs
.iter()
.map(|(path, data)| (path.as_ref(), data.0.as_str(), data.1.as_ref()))
})
}
fn external_configs(
&self,
worktree_id: WorktreeId,
) -> impl '_ + Iterator<Item = (&Path, &str, Option<&Editorconfig>)> {
self.worktree_state
.get(&worktree_id)
.into_iter()
.flat_map(|state| {
state.external_config_paths.iter().filter_map(|path| {
self.external_configs
.get(path)
.map(|entry| (path.as_ref(), entry.0.as_str(), entry.1.as_ref()))
})
})
}
pub fn local_editorconfig_settings(
&self,
worktree_id: WorktreeId,
) -> impl '_ + Iterator<Item = (LocalSettingsPath, &str, Option<&Editorconfig>)> {
let external = self
.external_configs(worktree_id)
.map(|(path, content, parsed)| {
(
LocalSettingsPath::OutsideWorktree(path.into()),
content,
parsed,
)
});
let internal = self
.internal_configs(worktree_id)
.map(|(path, content, parsed)| {
(LocalSettingsPath::InWorktree(path.into()), content, parsed)
});
external.chain(internal)
}
pub fn discover_local_external_configs_chain(
&mut self,
worktree_id: WorktreeId,
worktree_path: Arc<Path>,
fs: Arc<dyn Fs>,
cx: &mut Context<Self>,
) {
// We should only have one discovery task per worktree.
if self
.local_external_config_discovery_tasks
.contains_key(&worktree_id)
{
return;
}
let task = cx.spawn({
let fs = fs.clone();
async move |this, cx| {
let discovered_paths = {
let mut paths = Vec::new();
let mut current = worktree_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
let dir_path: Arc<Path> = Arc::from(dir.as_path());
let path = dir.join(EDITORCONFIG_NAME);
if fs.load(&path).await.is_ok() {
paths.push(dir_path);
}
current = dir.parent().map(|p| p.to_path_buf());
}
paths
};
this.update(cx, |this, cx| {
for dir_path in discovered_paths {
// We insert it here so that watchers can send events to appropriate worktrees.
// external_config_paths gets populated again in set_configs.
this.worktree_state
.entry(worktree_id)
.or_default()
.external_config_paths
.insert(dir_path.clone());
match this.local_external_config_watchers.entry(dir_path.clone()) {
std::collections::btree_map::Entry::Occupied(_) => {
if let Some(existing_config) = this.external_configs.get(&dir_path)
{
cx.emit(EditorconfigEvent::ExternalConfigChanged {
path: LocalSettingsPath::OutsideWorktree(dir_path),
content: Some(existing_config.0.clone()),
affected_worktree_ids: vec![worktree_id],
});
} else {
log::error!("Watcher exists for {dir_path:?} but no config found in external_configs");
}
}
std::collections::btree_map::Entry::Vacant(entry) => {
let watcher =
Self::watch_local_external_config(fs.clone(), dir_path, cx);
entry.insert(watcher);
}
}
}
})
.ok();
}
});
self.local_external_config_discovery_tasks
.insert(worktree_id, task);
}
fn watch_local_external_config(
fs: Arc<dyn Fs>,
dir_path: Arc<Path>,
cx: &mut Context<Self>,
) -> Task<()> {
let config_path = dir_path.join(EDITORCONFIG_NAME);
let mut config_rx = watch_config_file(cx.background_executor(), fs, config_path);
cx.spawn(async move |this, cx| {
while let Some(content) = config_rx.next().await {
let content = Some(content).filter(|c| !c.is_empty());
let dir_path = dir_path.clone();
this.update(cx, |this, cx| {
let affected_worktree_ids: Vec<WorktreeId> = this
.worktree_state
.iter()
.filter_map(|(id, state)| {
state
.external_config_paths
.contains(&dir_path)
.then_some(*id)
})
.collect();
cx.emit(EditorconfigEvent::ExternalConfigChanged {
path: LocalSettingsPath::OutsideWorktree(dir_path),
content,
affected_worktree_ids,
});
})
.ok();
}
})
}
pub fn properties(
&self,
for_worktree: WorktreeId,
for_path: &RelPath,
) -> Option<EditorconfigProperties> {
let mut properties = EditorconfigProperties::new();
let state = self.worktree_state.get(&for_worktree);
let empty_path: Arc<RelPath> = RelPath::empty().into();
let internal_root_config_is_root = state
.and_then(|state| state.internal_configs.get(&empty_path))
.and_then(|data| data.1.as_ref())
.is_some_and(|ec| ec.is_root);
if !internal_root_config_is_root {
for (_, _, parsed_editorconfig) in self.external_configs(for_worktree) {
if let Some(parsed_editorconfig) = parsed_editorconfig {
if parsed_editorconfig.is_root {
properties = EditorconfigProperties::new();
}
for section in &parsed_editorconfig.sections {
section
.apply_to(&mut properties, for_path.as_std_path())
.log_err()?;
}
}
}
}
for (directory_with_config, _, parsed_editorconfig) in self.internal_configs(for_worktree) {
if !for_path.starts_with(directory_with_config) {
properties.use_fallbacks();
return Some(properties);
}
let parsed_editorconfig = parsed_editorconfig?;
if parsed_editorconfig.is_root {
properties = EditorconfigProperties::new();
}
for section in &parsed_editorconfig.sections {
section
.apply_to(&mut properties, for_path.as_std_path())
.log_err()?;
}
}
properties.use_fallbacks();
Some(properties)
}
}
#[cfg(any(test, feature = "test-support"))]
impl EditorconfigStore {
pub fn test_state(&self) -> (Vec<WorktreeId>, Vec<Arc<Path>>, Vec<Arc<Path>>) {
let worktree_ids: Vec<_> = self.worktree_state.keys().copied().collect();
let external_paths: Vec<_> = self.external_configs.keys().cloned().collect();
let watcher_paths: Vec<_> = self
.local_external_config_watchers
.keys()
.cloned()
.collect();
(worktree_ids, external_paths, watcher_paths)
}
pub fn external_config_paths_for_worktree(&self, worktree_id: WorktreeId) -> Vec<Arc<Path>> {
self.worktree_state
.get(&worktree_id)
.map(|state| state.external_config_paths.iter().cloned().collect())
.unwrap_or_default()
}
}
+7 -3
View File
@@ -1,6 +1,7 @@
mod base_keymap_setting;
mod content_into_gpui;
mod editable_setting_control;
mod editorconfig_store;
mod keymap_file;
mod settings_file;
mod settings_store;
@@ -33,6 +34,9 @@ pub use ::settings_content::*;
pub use base_keymap_setting::*;
pub use content_into_gpui::IntoGpui;
pub use editable_setting_control::*;
pub use editorconfig_store::{
Editorconfig, EditorconfigEvent, EditorconfigProperties, EditorconfigStore,
};
pub use keymap_file::{
KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation,
KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult,
@@ -40,9 +44,9 @@ pub use keymap_file::{
pub use settings_file::*;
pub use settings_json::*;
pub use settings_store::{
InvalidSettingsError, LSP_SETTINGS_SCHEMA_URL_PREFIX, LocalSettingsKind, MigrationStatus,
Settings, SettingsFile, SettingsJsonSchemaParams, SettingsKey, SettingsLocation,
SettingsParseResult, SettingsStore,
InvalidSettingsError, LSP_SETTINGS_SCHEMA_URL_PREFIX, LocalSettingsKind, LocalSettingsPath,
MigrationStatus, Settings, SettingsFile, SettingsJsonSchemaParams, SettingsKey,
SettingsLocation, SettingsParseResult, SettingsStore,
};
pub use vscode_import::{VsCodeSettings, VsCodeSettingsSource};
+125 -171
View File
@@ -1,25 +1,26 @@
use anyhow::{Context as _, Result};
use collections::{BTreeMap, HashMap, btree_map, hash_map};
use ec4rs::{ConfigParser, PropertiesSource, Section};
use fs::Fs;
use futures::{
FutureExt, StreamExt,
channel::{mpsc, oneshot},
future::LocalBoxFuture,
};
use gpui::{App, AsyncApp, BorrowAppContext, Global, SharedString, Task, UpdateGlobal};
use gpui::{
App, AppContext, AsyncApp, BorrowAppContext, Entity, Global, SharedString, Task, UpdateGlobal,
};
use paths::{EDITORCONFIG_NAME, local_settings_file_relative_path, task_file_name};
use paths::{local_settings_file_relative_path, task_file_name};
use schemars::{JsonSchema, json_schema};
use serde_json::Value;
use smallvec::SmallVec;
use settings_content::ParseStatus;
use std::{
any::{Any, TypeId, type_name},
fmt::Debug,
ops::Range,
path::PathBuf,
path::{Path, PathBuf},
rc::Rc,
str::{self, FromStr},
str,
sync::Arc,
};
use util::{
@@ -28,17 +29,17 @@ use util::{
schemars::{AllowTrailingCommas, DefaultDenyUnknownFields, replace_subschema},
};
pub type EditorconfigProperties = ec4rs::Properties;
use crate::editorconfig_store::EditorconfigStore;
use crate::settings_content::{
ExtensionsSettingsContent, FontFamilyName, IconThemeName, LanguageSettingsContent,
LanguageToSettingsMap, LspSettings, LspSettingsMap, ProjectSettingsContent, SettingsContent,
ThemeName, UserSettingsContent,
};
use crate::{
ActiveSettingsProfileName, ParseStatus, UserSettingsContentExt, VsCodeSettings, WorktreeId,
ActiveSettingsProfileName, FontFamilyName, IconThemeName, LanguageSettingsContent,
LanguageToSettingsMap, LspSettings, LspSettingsMap, ThemeName, UserSettingsContentExt,
VsCodeSettings, WorktreeId,
settings_content::{
ExtensionsSettingsContent, ProjectSettingsContent, RootUserSettings, SettingsContent,
UserSettingsContent, merge_from::MergeFrom,
},
};
use settings_content::{RootUserSettings, merge_from::MergeFrom};
use settings_json::{infer_json_indent_size, update_value_in_json_text};
@@ -153,7 +154,7 @@ pub struct SettingsStore {
merged_settings: Rc<SettingsContent>,
local_settings: BTreeMap<(WorktreeId, Arc<RelPath>), SettingsContent>,
raw_editorconfig_settings: BTreeMap<(WorktreeId, Arc<RelPath>), (String, Option<Editorconfig>)>,
pub editorconfig_store: Entity<EditorconfigStore>,
_setting_file_updates: Task<()>,
setting_file_updates_tx:
@@ -201,26 +202,6 @@ impl Ord for SettingsFile {
}
}
#[derive(Clone)]
pub struct Editorconfig {
pub is_root: bool,
pub sections: SmallVec<[Section; 5]>,
}
impl FromStr for Editorconfig {
type Err = anyhow::Error;
fn from_str(contents: &str) -> Result<Self, Self::Err> {
let parser = ConfigParser::new_buffered(contents.as_bytes())
.context("creating editorconfig parser")?;
let is_root = parser.is_root;
let sections = parser
.collect::<Result<SmallVec<_>, _>>()
.context("parsing editorconfig sections")?;
Ok(Self { is_root, sections })
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum LocalSettingsKind {
Settings,
@@ -229,6 +210,33 @@ pub enum LocalSettingsKind {
Debug,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum LocalSettingsPath {
InWorktree(Arc<RelPath>),
OutsideWorktree(Arc<Path>),
}
impl LocalSettingsPath {
pub fn is_outside_worktree(&self) -> bool {
matches!(self, Self::OutsideWorktree(_))
}
pub fn to_proto(&self) -> String {
match self {
Self::InWorktree(path) => path.to_proto(),
Self::OutsideWorktree(path) => path.to_string_lossy().to_string(),
}
}
pub fn from_proto(path: &str, is_outside_worktree: bool) -> anyhow::Result<Self> {
if is_outside_worktree {
Ok(Self::OutsideWorktree(PathBuf::from(path).into()))
} else {
Ok(Self::InWorktree(RelPath::from_proto(path)?))
}
}
}
impl Global for SettingsStore {}
#[doc(hidden)]
@@ -263,7 +271,7 @@ pub struct SettingsJsonSchemaParams<'a> {
}
impl SettingsStore {
pub fn new(cx: &App, default_settings: &str) -> Self {
pub fn new(cx: &mut App, default_settings: &str) -> Self {
let (setting_file_updates_tx, mut setting_file_updates_rx) = mpsc::unbounded();
let default_settings: Rc<SettingsContent> =
SettingsContent::parse_json_with_comments(default_settings)
@@ -279,7 +287,7 @@ impl SettingsStore {
merged_settings: default_settings,
local_settings: BTreeMap::default(),
raw_editorconfig_settings: BTreeMap::default(),
editorconfig_store: cx.new(|_| EditorconfigStore::default()),
setting_file_updates_tx,
_setting_file_updates: cx.spawn(async move |cx| {
while let Some(setting_file_update) = setting_file_updates_rx.next().await {
@@ -835,19 +843,17 @@ impl SettingsStore {
pub fn set_local_settings(
&mut self,
root_id: WorktreeId,
directory_path: Arc<RelPath>,
path: LocalSettingsPath,
kind: LocalSettingsKind,
settings_content: Option<&str>,
cx: &mut App,
) -> std::result::Result<(), InvalidSettingsError> {
let content = settings_content
.map(|content| content.trim())
.filter(|content| !content.is_empty());
let mut zed_settings_changed = false;
match (
kind,
settings_content
.map(|content| content.trim())
.filter(|content| !content.is_empty()),
) {
(LocalSettingsKind::Tasks, _) => {
match (path.clone(), kind, content) {
(LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Tasks, _) => {
return Err(InvalidSettingsError::Tasks {
message: "Attempted to submit tasks into the settings store".to_string(),
path: directory_path
@@ -856,7 +862,7 @@ impl SettingsStore {
.to_path_buf(),
});
}
(LocalSettingsKind::Debug, _) => {
(LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Debug, _) => {
return Err(InvalidSettingsError::Debug {
message: "Attempted to submit debugger config into the settings store"
.to_string(),
@@ -866,19 +872,19 @@ impl SettingsStore {
.to_path_buf(),
});
}
(LocalSettingsKind::Settings, None) => {
(LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Settings, None) => {
zed_settings_changed = self
.local_settings
.remove(&(root_id, directory_path.clone()))
.is_some();
self.file_errors
.remove(&SettingsFile::Project((root_id, directory_path.clone())));
.remove(&SettingsFile::Project((root_id, directory_path)));
}
(LocalSettingsKind::Editorconfig, None) => {
self.raw_editorconfig_settings
.remove(&(root_id, directory_path.clone()));
}
(LocalSettingsKind::Settings, Some(settings_contents)) => {
(
LocalSettingsPath::InWorktree(directory_path),
LocalSettingsKind::Settings,
Some(settings_contents),
) => {
let (new_settings, parse_result) = self
.parse_and_migrate_zed_settings::<ProjectSettingsContent>(
settings_contents,
@@ -892,7 +898,7 @@ impl SettingsStore {
}),
}?;
if let Some(new_settings) = new_settings {
match self.local_settings.entry((root_id, directory_path.clone())) {
match self.local_settings.entry((root_id, directory_path)) {
btree_map::Entry::Vacant(v) => {
v.insert(SettingsContent {
project: new_settings,
@@ -912,50 +918,24 @@ impl SettingsStore {
}
}
}
(LocalSettingsKind::Editorconfig, Some(editorconfig_contents)) => {
match self
.raw_editorconfig_settings
.entry((root_id, directory_path.clone()))
{
btree_map::Entry::Vacant(v) => match editorconfig_contents.parse() {
Ok(new_contents) => {
v.insert((editorconfig_contents.to_owned(), Some(new_contents)));
}
Err(e) => {
v.insert((editorconfig_contents.to_owned(), None));
return Err(InvalidSettingsError::Editorconfig {
message: e.to_string(),
path: directory_path
.join(RelPath::unix(EDITORCONFIG_NAME).unwrap()),
});
}
},
btree_map::Entry::Occupied(mut o) => {
if o.get().0 != editorconfig_contents {
match editorconfig_contents.parse() {
Ok(new_contents) => {
o.insert((
editorconfig_contents.to_owned(),
Some(new_contents),
));
}
Err(e) => {
o.insert((editorconfig_contents.to_owned(), None));
return Err(InvalidSettingsError::Editorconfig {
message: e.to_string(),
path: directory_path
.join(RelPath::unix(EDITORCONFIG_NAME).unwrap()),
});
}
}
}
}
}
(directory_path, LocalSettingsKind::Editorconfig, editorconfig_contents) => {
self.editorconfig_store.update(cx, |store, _| {
store.set_configs(root_id, directory_path, editorconfig_contents)
})?;
}
(LocalSettingsPath::OutsideWorktree(path), kind, _) => {
log::error!(
"OutsideWorktree path {:?} with kind {:?} is only supported by editorconfig",
path,
kind
);
return Ok(());
}
}
if let LocalSettingsPath::InWorktree(directory_path) = &path {
if zed_settings_changed {
self.recompute_values(Some((root_id, &directory_path)), cx);
}
};
if zed_settings_changed {
self.recompute_values(Some((root_id, &directory_path)), cx);
}
Ok(())
}
@@ -980,8 +960,10 @@ impl SettingsStore {
pub fn clear_local_settings(&mut self, root_id: WorktreeId, cx: &mut App) -> Result<()> {
self.local_settings
.retain(|(worktree_id, _), _| worktree_id != &root_id);
self.raw_editorconfig_settings
.retain(|(worktree_id, _), _| worktree_id != &root_id);
self.editorconfig_store
.update(cx, |store, _cx| store.remove_for_worktree(root_id));
for setting_value in self.setting_values.values_mut() {
setting_value.clear_local_values(root_id);
}
@@ -1004,23 +986,6 @@ impl SettingsStore {
.map(|((_, path), content)| (path.clone(), &content.project))
}
pub fn local_editorconfig_settings(
&self,
root_id: WorktreeId,
) -> impl '_ + Iterator<Item = (Arc<RelPath>, String, Option<Editorconfig>)> {
self.raw_editorconfig_settings
.range(
(root_id, RelPath::empty().into())
..(
WorktreeId::from_usize(root_id.to_usize() + 1),
RelPath::empty().into(),
),
)
.map(|((_, path), (content, parsed_content))| {
(path.clone(), content.clone(), parsed_content.clone())
})
}
pub fn json_schema(&self, params: &SettingsJsonSchemaParams) -> Value {
let mut generator = schemars::generate::SchemaSettings::draft2019_09()
.with_transform(DefaultDenyUnknownFields)
@@ -1181,35 +1146,6 @@ impl SettingsStore {
}
}
}
pub fn editorconfig_properties(
&self,
for_worktree: WorktreeId,
for_path: &RelPath,
) -> Option<EditorconfigProperties> {
let mut properties = EditorconfigProperties::new();
for (directory_with_config, _, parsed_editorconfig) in
self.local_editorconfig_settings(for_worktree)
{
if !for_path.starts_with(&directory_with_config) {
properties.use_fallbacks();
return Some(properties);
}
let parsed_editorconfig = parsed_editorconfig?;
if parsed_editorconfig.is_root {
properties = EditorconfigProperties::new();
}
for section in parsed_editorconfig.sections {
section
.apply_to(&mut properties, for_path.as_std_path())
.log_err()?;
}
}
properties.use_fallbacks();
Some(properties)
}
}
/// The result of parsing settings, including any migration attempts
@@ -1296,13 +1232,31 @@ impl SettingsParseResult {
#[derive(Debug, Clone, PartialEq)]
pub enum InvalidSettingsError {
LocalSettings { path: Arc<RelPath>, message: String },
UserSettings { message: String },
ServerSettings { message: String },
DefaultSettings { message: String },
Editorconfig { path: Arc<RelPath>, message: String },
Tasks { path: PathBuf, message: String },
Debug { path: PathBuf, message: String },
LocalSettings {
path: Arc<RelPath>,
message: String,
},
UserSettings {
message: String,
},
ServerSettings {
message: String,
},
DefaultSettings {
message: String,
},
Editorconfig {
path: LocalSettingsPath,
message: String,
},
Tasks {
path: PathBuf,
message: String,
},
Debug {
path: PathBuf,
message: String,
},
}
impl std::fmt::Display for InvalidSettingsError {
@@ -1505,7 +1459,7 @@ mod tests {
store
.set_local_settings(
WorktreeId::from_usize(1),
rel_path("root1").into(),
LocalSettingsPath::InWorktree(rel_path("root1").into()),
LocalSettingsKind::Settings,
Some(r#"{ "tab_size": 5 }"#),
cx,
@@ -1514,7 +1468,7 @@ mod tests {
store
.set_local_settings(
WorktreeId::from_usize(1),
rel_path("root1/subdir").into(),
LocalSettingsPath::InWorktree(rel_path("root1/subdir").into()),
LocalSettingsKind::Settings,
Some(r#"{ "preferred_line_length": 50 }"#),
cx,
@@ -1524,7 +1478,7 @@ mod tests {
store
.set_local_settings(
WorktreeId::from_usize(1),
rel_path("root2").into(),
LocalSettingsPath::InWorktree(rel_path("root2").into()),
LocalSettingsKind::Settings,
Some(r#"{ "tab_size": 9, "auto_update": true}"#),
cx,
@@ -1995,7 +1949,7 @@ mod tests {
store
.set_local_settings(
local.0,
local.1.clone(),
LocalSettingsPath::InWorktree(local.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{}"#),
cx,
@@ -2029,7 +1983,7 @@ mod tests {
store
.set_local_settings(
local.0,
local.1.clone(),
LocalSettingsPath::InWorktree(local.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 80}"#),
cx,
@@ -2086,7 +2040,7 @@ mod tests {
store
.set_local_settings(
local_1.0,
local_1.1.clone(),
LocalSettingsPath::InWorktree(local_1.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 1}"#),
cx,
@@ -2095,7 +2049,7 @@ mod tests {
store
.set_local_settings(
local_1_child.0,
local_1_child.1.clone(),
LocalSettingsPath::InWorktree(local_1_child.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{}"#),
cx,
@@ -2104,7 +2058,7 @@ mod tests {
store
.set_local_settings(
local_2.0,
local_2.1.clone(),
LocalSettingsPath::InWorktree(local_2.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 2}"#),
cx,
@@ -2113,7 +2067,7 @@ mod tests {
store
.set_local_settings(
local_2_child.0,
local_2_child.1.clone(),
LocalSettingsPath::InWorktree(local_2_child.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{}"#),
cx,
@@ -2135,7 +2089,7 @@ mod tests {
store
.set_local_settings(
local_1_adjacent_child.0,
local_1_adjacent_child.1.clone(),
LocalSettingsPath::InWorktree(local_1_adjacent_child.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{}"#),
cx,
@@ -2144,7 +2098,7 @@ mod tests {
store
.set_local_settings(
local_1_child.0,
local_1_child.1.clone(),
LocalSettingsPath::InWorktree(local_1_child.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 3}"#),
cx,
@@ -2158,7 +2112,7 @@ mod tests {
store
.set_local_settings(
local_1_adjacent_child.0,
local_1_adjacent_child.1,
LocalSettingsPath::InWorktree(local_1_adjacent_child.1),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 3}"#),
cx,
@@ -2167,7 +2121,7 @@ mod tests {
store
.set_local_settings(
local_1_child.0,
local_1_child.1.clone(),
LocalSettingsPath::InWorktree(local_1_child.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{}"#),
cx,
@@ -2202,7 +2156,7 @@ mod tests {
store
.set_local_settings(
wt0_root.0,
wt0_root.1.clone(),
LocalSettingsPath::InWorktree(wt0_root.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 80}"#),
cx,
@@ -2211,7 +2165,7 @@ mod tests {
store
.set_local_settings(
wt0_child1.0,
wt0_child1.1.clone(),
LocalSettingsPath::InWorktree(wt0_child1.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 120}"#),
cx,
@@ -2220,7 +2174,7 @@ mod tests {
store
.set_local_settings(
wt0_child2.0,
wt0_child2.1.clone(),
LocalSettingsPath::InWorktree(wt0_child2.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{}"#),
cx,
@@ -2230,7 +2184,7 @@ mod tests {
store
.set_local_settings(
wt1_root.0,
wt1_root.1.clone(),
LocalSettingsPath::InWorktree(wt1_root.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 90}"#),
cx,
@@ -2239,7 +2193,7 @@ mod tests {
store
.set_local_settings(
wt1_subdir.0,
wt1_subdir.1.clone(),
LocalSettingsPath::InWorktree(wt1_subdir.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{}"#),
cx,
@@ -2290,7 +2244,7 @@ mod tests {
store
.set_local_settings(
wt0_deep_child.0,
wt0_deep_child.1.clone(),
LocalSettingsPath::InWorktree(wt0_deep_child.1.clone()),
LocalSettingsKind::Settings,
Some(r#"{"preferred_line_length": 140}"#),
cx,