Add copilot.disabled_globs setting

Max Brunsfeld created

Change summary

Cargo.lock                        |  2 
Cargo.toml                        |  1 
assets/settings/default.json      |  7 ++
crates/editor/Cargo.toml          |  1 
crates/editor/src/editor.rs       | 37 +++++++++++-
crates/editor/src/editor_tests.rs | 91 +++++++++++++++++++++++++++++++++
crates/editor/src/multi_buffer.rs | 11 ++-
crates/project/Cargo.toml         |  2 
crates/settings/Cargo.toml        |  1 
crates/settings/src/settings.rs   | 57 +++++++++++---------
10 files changed, 176 insertions(+), 34 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1983,6 +1983,7 @@ dependencies = [
  "futures 0.3.25",
  "fuzzy",
  "git",
+ "glob",
  "gpui",
  "indoc",
  "itertools",
@@ -5964,6 +5965,7 @@ dependencies = [
  "collections",
  "fs",
  "futures 0.3.25",
+ "glob",
  "gpui",
  "json_comments",
  "postage",

Cargo.toml 🔗

@@ -77,6 +77,7 @@ async-trait = { version = "0.1" }
 ctor = { version = "0.1" }
 env_logger = { version = "0.9" }
 futures = { version = "0.3" }
+glob = { version = "0.3.1" }
 lazy_static = { version = "1.4.0" }
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 ordered-float = { version = "2.1.1" }

assets/settings/default.json 🔗

@@ -115,6 +115,13 @@
     //      "git_gutter": "hide"
     "git_gutter": "tracked_files"
   },
+  "copilot": {
+    // The set of glob patterns for which copilot should be disabled
+    // in any matching file.
+    "disabled_globs": [
+      ".env"
+    ]
+  },
   // Settings specific to journaling
   "journal": {
     // The path of the directory where journal entries are stored

crates/editor/Cargo.toml 🔗

@@ -79,6 +79,7 @@ workspace = { path = "../workspace", features = ["test-support"] }
 
 ctor.workspace = true
 env_logger.workspace = true
+glob.workspace = true
 rand.workspace = true
 unindent.workspace = true
 tree-sitter = "0.20"

crates/editor/src/editor.rs 🔗

@@ -2925,11 +2925,7 @@ impl Editor {
 
         let snapshot = self.buffer.read(cx).snapshot(cx);
         let cursor = self.selections.newest_anchor().head();
-        let language_name = snapshot.language_at(cursor).map(|language| language.name());
-        if !cx
-            .global::<Settings>()
-            .show_copilot_suggestions(language_name.as_deref())
-        {
+        if !self.is_copilot_enabled_at(cursor, &snapshot, cx) {
             self.clear_copilot_suggestions(cx);
             return None;
         }
@@ -3080,6 +3076,37 @@ impl Editor {
         }
     }
 
+    fn is_copilot_enabled_at(
+        &self,
+        location: Anchor,
+        snapshot: &MultiBufferSnapshot,
+        cx: &mut ViewContext<Self>,
+    ) -> bool {
+        let settings = cx.global::<Settings>();
+
+        let language_name = snapshot
+            .language_at(location)
+            .map(|language| language.name());
+        if !settings.show_copilot_suggestions(language_name.as_deref()) {
+            return false;
+        }
+
+        let file = snapshot.file_at(location);
+        if let Some(file) = file {
+            let path = file.path();
+            if settings
+                .copilot
+                .disabled_globs
+                .iter()
+                .any(|glob| glob.matches_path(path))
+            {
+                return false;
+            }
+        }
+
+        true
+    }
+
     fn has_active_copilot_suggestion(&self, cx: &AppContext) -> bool {
         self.display_map.read(cx).has_suggestion()
     }

crates/editor/src/editor_tests.rs 🔗

@@ -6387,6 +6387,97 @@ async fn test_copilot_multibuffer(
     });
 }
 
+#[gpui::test]
+async fn test_copilot_disabled_globs(
+    deterministic: Arc<Deterministic>,
+    cx: &mut gpui::TestAppContext,
+) {
+    let (copilot, copilot_lsp) = Copilot::fake(cx);
+    cx.update(|cx| {
+        let mut settings = Settings::test(cx);
+        settings.copilot.disabled_globs = vec![glob::Pattern::new(".env*").unwrap()];
+        cx.set_global(settings);
+        cx.set_global(copilot)
+    });
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree(
+        "/test",
+        json!({
+            ".env": "SECRET=something\n",
+            "README.md": "hello\n"
+        }),
+    )
+    .await;
+    let project = Project::test(fs, ["/test".as_ref()], cx).await;
+
+    let private_buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer("/test/.env", cx)
+        })
+        .await
+        .unwrap();
+    let public_buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer("/test/README.md", cx)
+        })
+        .await
+        .unwrap();
+
+    let multibuffer = cx.add_model(|cx| {
+        let mut multibuffer = MultiBuffer::new(0);
+        multibuffer.push_excerpts(
+            private_buffer.clone(),
+            [ExcerptRange {
+                context: Point::new(0, 0)..Point::new(1, 0),
+                primary: None,
+            }],
+            cx,
+        );
+        multibuffer.push_excerpts(
+            public_buffer.clone(),
+            [ExcerptRange {
+                context: Point::new(0, 0)..Point::new(1, 0),
+                primary: None,
+            }],
+            cx,
+        );
+        multibuffer
+    });
+    let (_, editor) = cx.add_window(|cx| build_editor(multibuffer, cx));
+
+    let mut copilot_requests = copilot_lsp
+        .handle_request::<copilot::request::GetCompletions, _, _>(move |_params, _cx| async move {
+            Ok(copilot::request::GetCompletionsResult {
+                completions: vec![copilot::request::Completion {
+                    text: "next line".into(),
+                    range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 0)),
+                    ..Default::default()
+                }],
+            })
+        });
+
+    editor.update(cx, |editor, cx| {
+        editor.change_selections(None, cx, |selections| {
+            selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)])
+        });
+        editor.next_copilot_suggestion(&Default::default(), cx);
+    });
+
+    deterministic.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
+    assert!(copilot_requests.try_next().is_err());
+
+    editor.update(cx, |editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(2, 0)..Point::new(2, 0)])
+        });
+        editor.next_copilot_suggestion(&Default::default(), cx);
+    });
+
+    deterministic.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
+    assert!(copilot_requests.try_next().is_ok());
+}
+
 fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
     let point = DisplayPoint::new(row as u32, column as u32);
     point..point

crates/editor/src/multi_buffer.rs 🔗

@@ -10,9 +10,9 @@ use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task};
 pub use language::Completion;
 use language::{
     char_kind, AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk, CursorShape,
-    DiagnosticEntry, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16, Outline,
-    OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _, ToOffsetUtf16 as _,
-    ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
+    DiagnosticEntry, File, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16,
+    Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _,
+    ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
 };
 use std::{
     borrow::Cow,
@@ -2754,6 +2754,11 @@ impl MultiBufferSnapshot {
         self.trailing_excerpt_update_count
     }
 
+    pub fn file_at<'a, T: ToOffset>(&'a self, point: T) -> Option<&'a Arc<dyn File>> {
+        self.point_to_buffer_offset(point)
+            .and_then(|(buffer, _)| buffer.file())
+    }
+
     pub fn language_at<'a, T: ToOffset>(&'a self, point: T) -> Option<&'a Arc<Language>> {
         self.point_to_buffer_offset(point)
             .and_then(|(buffer, offset)| buffer.language_at(offset))

crates/project/Cargo.toml 🔗

@@ -28,7 +28,6 @@ fs = { path = "../fs" }
 fsevent = { path = "../fsevent" }
 fuzzy = { path = "../fuzzy" }
 git = { path = "../git" }
-glob = { version = "0.3.1" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 lsp = { path = "../lsp" }
@@ -43,6 +42,7 @@ anyhow.workspace = true
 async-trait.workspace = true
 backtrace = "0.3"
 futures.workspace = true
+glob.workspace = true
 ignore = "0.4"
 lazy_static.workspace = true
 log.workspace = true

crates/settings/Cargo.toml 🔗

@@ -23,6 +23,7 @@ theme = { path = "../theme" }
 staff_mode = { path = "../staff_mode" }
 util = { path = "../util" }
 
+glob.workspace = true
 json_comments = "0.2"
 postage.workspace = true
 schemars = "0.8"

crates/settings/src/settings.rs 🔗

@@ -47,6 +47,7 @@ pub struct Settings {
     pub editor_overrides: EditorSettings,
     pub git: GitSettings,
     pub git_overrides: GitSettings,
+    pub copilot: CopilotSettings,
     pub journal_defaults: JournalSettings,
     pub journal_overrides: JournalSettings,
     pub terminal_defaults: TerminalSettings,
@@ -61,29 +62,6 @@ pub struct Settings {
     pub base_keymap: BaseKeymap,
 }
 
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
-#[serde(rename_all = "snake_case")]
-pub enum CopilotSettings {
-    #[default]
-    On,
-    Off,
-}
-
-impl From<CopilotSettings> for bool {
-    fn from(value: CopilotSettings) -> Self {
-        match value {
-            CopilotSettings::On => true,
-            CopilotSettings::Off => false,
-        }
-    }
-}
-
-impl CopilotSettings {
-    pub fn is_on(&self) -> bool {
-        <CopilotSettings as Into<bool>>::into(*self)
-    }
-}
-
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
 pub enum BaseKeymap {
     #[default]
@@ -150,6 +128,29 @@ impl TelemetrySettings {
     }
 }
 
+#[derive(Clone, Debug, Default)]
+pub struct CopilotSettings {
+    pub disabled_globs: Vec<glob::Pattern>,
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+pub struct CopilotSettingsContent {
+    #[serde(default)]
+    disabled_globs: Vec<String>,
+}
+
+impl From<CopilotSettingsContent> for CopilotSettings {
+    fn from(value: CopilotSettingsContent) -> Self {
+        Self {
+            disabled_globs: value
+                .disabled_globs
+                .into_iter()
+                .filter_map(|p| glob::Pattern::new(&p).ok())
+                .collect(),
+        }
+    }
+}
+
 #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct GitSettings {
     pub git_gutter: Option<GitGutter>,
@@ -390,6 +391,8 @@ pub struct SettingsFileContent {
     #[serde(default)]
     pub buffer_font_features: Option<fonts::Features>,
     #[serde(default)]
+    pub copilot: Option<CopilotSettingsContent>,
+    #[serde(default)]
     pub active_pane_magnification: Option<f32>,
     #[serde(default)]
     pub cursor_blink: Option<bool>,
@@ -438,8 +441,7 @@ pub struct LspSettings {
     pub initialization_options: Option<Value>,
 }
 
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub struct Features {
     pub copilot: bool,
 }
@@ -506,6 +508,7 @@ impl Settings {
                 show_copilot_suggestions: required(defaults.editor.show_copilot_suggestions),
             },
             editor_overrides: Default::default(),
+            copilot: defaults.copilot.unwrap().into(),
             git: defaults.git.unwrap(),
             git_overrides: Default::default(),
             journal_defaults: defaults.journal,
@@ -576,6 +579,9 @@ impl Settings {
         merge(&mut self.base_keymap, data.base_keymap);
         merge(&mut self.features.copilot, data.features.copilot);
 
+        if let Some(copilot) = data.copilot.map(CopilotSettings::from) {
+            self.copilot = copilot;
+        }
         self.editor_overrides = data.editor;
         self.git_overrides = data.git.unwrap_or_default();
         self.journal_overrides = data.journal;
@@ -751,6 +757,7 @@ impl Settings {
                 show_copilot_suggestions: Some(true),
             },
             editor_overrides: Default::default(),
+            copilot: Default::default(),
             journal_defaults: Default::default(),
             journal_overrides: Default::default(),
             terminal_defaults: Default::default(),