WIP: Add a setting to visually redact enviroment variables (#7124)

Mikayla Maki and Nathan created

Release Notes:

- Added bash syntax highlighting to `.env` files. 
- Added a `private_files` setting for configuring which files should be
considered to contain environment variables or other sensitive
information.
- Added a `redact_private_values` setting to add or remove censor bars
over variable values in files matching the `private_files` patterns.
-(internal) added a new `redactions.scm` query to our language support,
allowing different config file formats to indicate where environment
variable values can be identified in the syntax tree, added this query
to `bash`, `json`, `toml`, and `yaml` files.

---------

Co-authored-by: Nathan <nathan@zed.dev>

Change summary

.zed/settings.json                           |   8 
assets/settings/default.json                 |  11 ++
crates/copilot/src/copilot.rs                |   4 
crates/copilot_ui/src/copilot_button.rs      |   5 
crates/editor/src/editor.rs                  |  25 ++++
crates/editor/src/editor_settings.rs         |   8 +
crates/editor/src/element.rs                 |  40 +++++++
crates/editor/src/items.rs                   |   4 
crates/language/src/buffer.rs                |  40 +++++++
crates/language/src/language.rs              |  29 +++++
crates/language/src/syntax_map.rs            |   2 
crates/multi_buffer/src/multi_buffer.rs      |  52 ++++++++++
crates/project/src/project.rs                |   3 
crates/project/src/project_settings.rs       |   5 
crates/project/src/worktree.rs               | 111 +++++++++++++++++----
crates/project_panel/src/project_panel.rs    |   3 
crates/settings/src/settings_store.rs        |   1 
crates/zed/src/languages.rs                  |   1 
crates/zed/src/languages/bash/config.toml    |   2 
crates/zed/src/languages/bash/redactions.scm |   2 
crates/zed/src/languages/json/redactions.scm |   4 
crates/zed/src/languages/toml/redactions.scm |   1 
crates/zed/src/languages/yaml/redactions.scm |   1 
23 files changed, 330 insertions(+), 32 deletions(-)

Detailed changes

.zed/settings.json 🔗

@@ -1,6 +1,6 @@
 {
-    "JSON": {
-        "tab_size": 4
-    },
-    "formatter": "auto"
+  "JSON": {
+    "tab_size": 4
+  },
+  "formatter": "auto"
 }

assets/settings/default.json 🔗

@@ -72,6 +72,17 @@
   "show_wrap_guides": true,
   // Character counts at which to show wrap guides in the editor.
   "wrap_guides": [],
+  // Hide the values of in variables from visual display in private files
+  "redact_private_values": false,
+  // Globs to match against file paths to determine if a file is private.
+  "private_files": [
+    "**/.env*",
+    "**/*.pem",
+    "**/*.key",
+    "**/*.cert",
+    "**/*.crt",
+    "**/secrets.yml"
+  ],
   // Whether to use additional LSP queries to format (and amend) the code after
   // every "trigger" symbol input, defined by LSP server capabilities.
   "use_on_type_format": true,

crates/copilot/src/copilot.rs 🔗

@@ -1251,6 +1251,10 @@ mod tests {
         fn worktree_id(&self) -> usize {
             0
         }
+
+        fn is_private(&self) -> bool {
+            false
+        }
     }
 
     impl language::LocalFile for File {

crates/copilot_ui/src/copilot_button.rs 🔗

@@ -225,8 +225,9 @@ impl CopilotButton {
         let file = snapshot.file_at(suggestion_anchor).cloned();
 
         self.editor_enabled = Some(
-            all_language_settings(self.file.as_ref(), cx)
-                .copilot_enabled(language, file.as_ref().map(|file| file.path().as_ref())),
+            file.as_ref().map(|file| !file.is_private()).unwrap_or(true)
+                && all_language_settings(self.file.as_ref(), cx)
+                    .copilot_enabled(language, file.as_ref().map(|file| file.path().as_ref())),
         );
         self.language = language.cloned();
         self.file = file;

crates/editor/src/editor.rs 🔗

@@ -8493,6 +8493,31 @@ impl Editor {
         results
     }
 
+    /// Get the text ranges corresponding to the redaction query
+    pub fn redacted_ranges(
+        &self,
+        search_range: Range<Anchor>,
+        display_snapshot: &DisplaySnapshot,
+        cx: &mut ViewContext<Self>,
+    ) -> Vec<Range<DisplayPoint>> {
+        display_snapshot
+            .buffer_snapshot
+            .redacted_ranges(search_range, |file| {
+                if let Some(file) = file {
+                    file.is_private()
+                        && EditorSettings::get(Some((file.worktree_id(), file.path())), cx)
+                            .redact_private_values
+                } else {
+                    false
+                }
+            })
+            .map(|range| {
+                range.start.to_display_point(display_snapshot)
+                    ..range.end.to_display_point(display_snapshot)
+            })
+            .collect()
+    }
+
     pub fn highlight_text<T: 'static>(
         &mut self,
         ranges: Vec<Range<Anchor>>,

crates/editor/src/editor_settings.rs 🔗

@@ -13,6 +13,7 @@ pub struct EditorSettings {
     pub scrollbar: Scrollbar,
     pub relative_line_numbers: bool,
     pub seed_search_query_from_cursor: SeedQuerySetting,
+    pub redact_private_values: bool,
 }
 
 /// When to populate a new search's query based on the text under the cursor.
@@ -93,6 +94,13 @@ pub struct EditorSettingsContent {
     ///
     /// Default: always
     pub seed_search_query_from_cursor: Option<SeedQuerySetting>,
+
+    /// Hide the values of variables in `private` files, as defined by the
+    /// private_files setting. This only changes the visual representation,
+    /// the values are still present in the file and can be selected / copied / pasted
+    ///
+    /// Default: false
+    pub redact_private_values: Option<bool>,
 }
 
 /// Scrollbar related settings

crates/editor/src/element.rs 🔗

@@ -1153,7 +1153,9 @@ impl EditorElement {
                     )
                 }
 
-                cx.with_z_index(0, |cx| {
+                cx.with_z_index(0, |cx| self.paint_redactions(text_bounds, &layout, cx));
+
+                cx.with_z_index(1, |cx| {
                     for cursor in cursors {
                         cursor.paint(content_origin, cx);
                     }
@@ -1162,6 +1164,32 @@ impl EditorElement {
         )
     }
 
+    fn paint_redactions(
+        &mut self,
+        text_bounds: Bounds<Pixels>,
+        layout: &LayoutState,
+        cx: &mut ElementContext,
+    ) {
+        let content_origin = text_bounds.origin + point(layout.gutter_margin, Pixels::ZERO);
+        let line_end_overshoot = layout.line_end_overshoot();
+
+        // A softer than perfect black
+        let redaction_color = gpui::rgb(0x0e1111);
+
+        for range in layout.redacted_ranges.iter() {
+            self.paint_highlighted_range(
+                range.clone(),
+                redaction_color.into(),
+                Pixels::ZERO,
+                line_end_overshoot,
+                layout,
+                content_origin,
+                text_bounds,
+                cx,
+            );
+        }
+    }
+
     fn paint_overlays(
         &mut self,
         text_bounds: Bounds<Pixels>,
@@ -1957,6 +1985,8 @@ impl EditorElement {
                 cx.theme().colors(),
             );
 
+            let redacted_ranges = editor.redacted_ranges(start_anchor..end_anchor, &snapshot.display_snapshot, cx);
+
             let mut newest_selection_head = None;
 
             if editor.show_local_selections {
@@ -2298,6 +2328,7 @@ impl EditorElement {
                 active_rows,
                 highlighted_rows,
                 highlighted_ranges,
+                redacted_ranges,
                 line_numbers,
                 display_hunks,
                 blocks,
@@ -3082,6 +3113,7 @@ pub struct LayoutState {
     display_hunks: Vec<DisplayDiffHunk>,
     blocks: Vec<BlockLayout>,
     highlighted_ranges: Vec<(Range<DisplayPoint>, Hsla)>,
+    redacted_ranges: Vec<Range<DisplayPoint>>,
     selections: Vec<(PlayerColor, Vec<SelectionLayout>)>,
     scrollbar_row_range: Range<f32>,
     show_scrollbars: bool,
@@ -3095,6 +3127,12 @@ pub struct LayoutState {
     space_invisible: ShapedLine,
 }
 
+impl LayoutState {
+    fn line_end_overshoot(&self) -> Pixels {
+        0.15 * self.position_map.line_height
+    }
+}
+
 struct CodeActionsIndicator {
     row: u32,
     button: IconButton,

crates/editor/src/items.rs 🔗

@@ -1364,5 +1364,9 @@ mod tests {
         fn to_proto(&self) -> rpc::proto::File {
             unimplemented!()
         }
+
+        fn is_private(&self) -> bool {
+            false
+        }
     }
 }

crates/language/src/buffer.rs 🔗

@@ -383,6 +383,9 @@ pub trait File: Send + Sync {
 
     /// Converts this file into a protobuf message.
     fn to_proto(&self) -> rpc::proto::File;
+
+    /// Return whether Zed considers this to be a dotenv file.
+    fn is_private(&self) -> bool;
 }
 
 /// The file associated with a buffer, in the case where the file is on the local disk.
@@ -2877,6 +2880,43 @@ impl BufferSnapshot {
         })
     }
 
+    /// Returns anchor ranges for any matches of the redaction query.
+    /// The buffer can be associated with multiple languages, and the redaction query associated with each
+    /// will be run on the relevant section of the buffer.
+    pub fn redacted_ranges<'a, T: ToOffset>(
+        &'a self,
+        range: Range<T>,
+    ) -> impl Iterator<Item = Range<usize>> + 'a {
+        let offset_range = range.start.to_offset(self)..range.end.to_offset(self);
+        let mut syntax_matches = self.syntax.matches(offset_range, self, |grammar| {
+            grammar
+                .redactions_config
+                .as_ref()
+                .map(|config| &config.query)
+        });
+
+        let configs = syntax_matches
+            .grammars()
+            .iter()
+            .map(|grammar| grammar.redactions_config.as_ref())
+            .collect::<Vec<_>>();
+
+        iter::from_fn(move || {
+            let redacted_range = syntax_matches
+                .peek()
+                .and_then(|mat| {
+                    configs[mat.grammar_index].and_then(|config| {
+                        mat.captures
+                            .iter()
+                            .find(|capture| capture.index == config.redaction_capture_ix)
+                    })
+                })
+                .map(|mat| mat.node.byte_range());
+            syntax_matches.advance();
+            redacted_range
+        })
+    }
+
     /// Returns selections for remote peers intersecting the given range.
     #[allow(clippy::type_complexity)]
     pub fn remote_selections_in_range(

crates/language/src/language.rs 🔗

@@ -453,6 +453,7 @@ pub struct LanguageQueries {
     pub embedding: Option<Cow<'static, str>>,
     pub injections: Option<Cow<'static, str>>,
     pub overrides: Option<Cow<'static, str>>,
+    pub redactions: Option<Cow<'static, str>>,
 }
 
 /// Represents a language for the given range. Some languages (e.g. HTML)
@@ -623,6 +624,7 @@ pub struct Grammar {
     pub(crate) error_query: Query,
     pub(crate) highlights_query: Option<Query>,
     pub(crate) brackets_config: Option<BracketConfig>,
+    pub(crate) redactions_config: Option<RedactionConfig>,
     pub(crate) indents_config: Option<IndentConfig>,
     pub outline_config: Option<OutlineConfig>,
     pub embedding_config: Option<EmbeddingConfig>,
@@ -664,6 +666,11 @@ struct InjectionConfig {
     patterns: Vec<InjectionPatternConfig>,
 }
 
+struct RedactionConfig {
+    pub query: Query,
+    pub redaction_capture_ix: u32,
+}
+
 struct OverrideConfig {
     query: Query,
     values: HashMap<u32, (String, LanguageConfigOverride)>,
@@ -1303,6 +1310,7 @@ impl Language {
                     indents_config: None,
                     injection_config: None,
                     override_config: None,
+                    redactions_config: None,
                     error_query: Query::new(&ts_language, "(ERROR) @error").unwrap(),
                     ts_language,
                     highlight_map: Default::default(),
@@ -1359,6 +1367,11 @@ impl Language {
                 .with_override_query(query.as_ref())
                 .context("Error loading override query")?;
         }
+        if let Some(query) = queries.redactions {
+            self = self
+                .with_redaction_query(query.as_ref())
+                .context("Error loading redaction query")?;
+        }
         Ok(self)
     }
 
@@ -1589,6 +1602,22 @@ impl Language {
         Ok(self)
     }
 
+    pub fn with_redaction_query(mut self, source: &str) -> anyhow::Result<Self> {
+        let grammar = self.grammar_mut();
+        let query = Query::new(&grammar.ts_language, source)?;
+        let mut redaction_capture_ix = None;
+        get_capture_indices(&query, &mut [("redact", &mut redaction_capture_ix)]);
+
+        if let Some(redaction_capture_ix) = redaction_capture_ix {
+            grammar.redactions_config = Some(RedactionConfig {
+                query,
+                redaction_capture_ix,
+            });
+        }
+
+        Ok(self)
+    }
+
     fn grammar_mut(&mut self) -> &mut Grammar {
         Arc::get_mut(self.grammar.as_mut().unwrap()).unwrap()
     }

crates/language/src/syntax_map.rs 🔗

@@ -1059,7 +1059,7 @@ impl<'a> SyntaxMapMatches<'a> {
                 .position(|later_layer| key < later_layer.sort_key())
                 .unwrap_or(self.active_layer_count - 1);
             self.layers[0..i].rotate_left(1);
-        } else {
+        } else if self.active_layer_count != 0 {
             self.layers[0..self.active_layer_count].rotate_left(1);
             self.active_layer_count -= 1;
         }

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -36,6 +36,7 @@ use text::{
     BufferId, Edit, TextSummary,
 };
 use theme::SyntaxTheme;
+
 use util::post_inc;
 
 #[cfg(any(test, feature = "test-support"))]
@@ -2784,6 +2785,26 @@ impl MultiBufferSnapshot {
             .map(|excerpt| (excerpt.id, &excerpt.buffer, excerpt.range.clone()))
     }
 
+    fn excerpts_for_range<'a, T: ToOffset>(
+        &'a self,
+        range: Range<T>,
+    ) -> impl Iterator<Item = (&'a Excerpt, usize)> + 'a {
+        let range = range.start.to_offset(self)..range.end.to_offset(self);
+
+        let mut cursor = self.excerpts.cursor::<usize>();
+        cursor.seek(&range.start, Bias::Right, &());
+        cursor.prev(&());
+
+        iter::from_fn(move || {
+            cursor.next(&());
+            if cursor.start() < &range.end {
+                cursor.item().map(|item| (item, *cursor.start()))
+            } else {
+                None
+            }
+        })
+    }
+
     pub fn excerpt_boundaries_in_range<R, T>(
         &self,
         range: R,
@@ -2942,6 +2963,37 @@ impl MultiBufferSnapshot {
         })
     }
 
+    pub fn redacted_ranges<'a, T: ToOffset>(
+        &'a self,
+        range: Range<T>,
+        redaction_enabled: impl Fn(Option<&Arc<dyn File>>) -> bool + 'a,
+    ) -> impl Iterator<Item = Range<usize>> + 'a {
+        let range = range.start.to_offset(self)..range.end.to_offset(self);
+        self.excerpts_for_range(range.clone())
+            .filter_map(move |(excerpt, excerpt_offset)| {
+                redaction_enabled(excerpt.buffer.file()).then(move || {
+                    let excerpt_buffer_start =
+                        excerpt.range.context.start.to_offset(&excerpt.buffer);
+
+                    excerpt
+                        .buffer
+                        .redacted_ranges(excerpt.range.context.clone())
+                        .map(move |mut redacted_range| {
+                            // Re-base onto the excerpts coordinates in the multibuffer
+                            redacted_range.start =
+                                excerpt_offset + (redacted_range.start - excerpt_buffer_start);
+                            redacted_range.end =
+                                excerpt_offset + (redacted_range.end - excerpt_buffer_start);
+
+                            redacted_range
+                        })
+                        .skip_while(move |redacted_range| redacted_range.end < range.start)
+                        .take_while(move |redacted_range| redacted_range.start < range.end)
+                })
+            })
+            .flatten()
+    }
+
     pub fn diagnostics_update_count(&self) -> usize {
         self.diagnostics_update_count
     }

crates/project/src/project.rs 🔗

@@ -6470,6 +6470,7 @@ impl Project {
                             path: entry.path.clone(),
                             worktree: worktree_handle.clone(),
                             is_deleted: false,
+                            is_private: entry.is_private,
                         }
                     } else if let Some(entry) = snapshot.entry_for_path(old_file.path().as_ref()) {
                         File {
@@ -6479,6 +6480,7 @@ impl Project {
                             path: entry.path.clone(),
                             worktree: worktree_handle.clone(),
                             is_deleted: false,
+                            is_private: entry.is_private,
                         }
                     } else {
                         File {
@@ -6488,6 +6490,7 @@ impl Project {
                             mtime: old_file.mtime(),
                             worktree: worktree_handle.clone(),
                             is_deleted: true,
+                            is_private: old_file.is_private,
                         }
                     };
 

crates/project/src/project_settings.rs 🔗

@@ -20,6 +20,7 @@ pub struct ProjectSettings {
     /// Configuration for Git-related features
     #[serde(default)]
     pub git: GitSettings,
+
     /// Completely ignore files matching globs from `file_scan_exclusions`
     ///
     /// Default: [
@@ -34,6 +35,10 @@ pub struct ProjectSettings {
     /// ]
     #[serde(default)]
     pub file_scan_exclusions: Option<Vec<String>>,
+
+    /// Treat the files matching these globs as `.env` files.
+    /// Default: [ "**/.env*" ]
+    pub private_files: Option<Vec<String>>,
 }
 
 #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]

crates/project/src/worktree.rs 🔗

@@ -230,6 +230,7 @@ pub struct LocalSnapshot {
     /// id of their parent directory.
     git_repositories: TreeMap<ProjectEntryId, LocalRepositoryEntry>,
     file_scan_exclusions: Vec<PathMatcher>,
+    private_files: Vec<PathMatcher>,
 }
 
 struct BackgroundScannerState {
@@ -319,16 +320,34 @@ impl Worktree {
         cx.new_model(move |cx: &mut ModelContext<Worktree>| {
             cx.observe_global::<SettingsStore>(move |this, cx| {
                 if let Self::Local(this) = this {
-                    let new_file_scan_exclusions =
-                        file_scan_exclusions(ProjectSettings::get_global(cx));
-                    if new_file_scan_exclusions != this.snapshot.file_scan_exclusions {
+                    let new_file_scan_exclusions = path_matchers(
+                        ProjectSettings::get_global(cx)
+                            .file_scan_exclusions
+                            .as_deref(),
+                        "file_scan_exclusions",
+                    );
+                    let new_private_files = path_matchers(
+                        ProjectSettings::get(Some((cx.handle().entity_id().as_u64() as usize, &Path::new(""))), cx).private_files.as_deref(),
+                        "private_files",
+                    );
+
+                    if new_file_scan_exclusions != this.snapshot.file_scan_exclusions
+                        || new_private_files != this.snapshot.private_files
+                    {
                         this.snapshot.file_scan_exclusions = new_file_scan_exclusions;
+                        this.snapshot.private_files = new_private_files;
+
                         log::info!(
-                            "Re-scanning directories, new scan exclude files: {:?}",
+                            "Re-scanning directories, new scan exclude files: {:?}, new dotenv files: {:?}",
                             this.snapshot
                                 .file_scan_exclusions
                                 .iter()
                                 .map(ToString::to_string)
+                                .collect::<Vec<_>>(),
+                            this.snapshot
+                                .private_files
+                                .iter()
+                                .map(ToString::to_string)
                                 .collect::<Vec<_>>()
                         );
 
@@ -357,7 +376,16 @@ impl Worktree {
                 .map_or(String::new(), |f| f.to_string_lossy().to_string());
 
             let mut snapshot = LocalSnapshot {
-                file_scan_exclusions: file_scan_exclusions(ProjectSettings::get_global(cx)),
+                file_scan_exclusions: path_matchers(
+                    ProjectSettings::get_global(cx)
+                        .file_scan_exclusions
+                        .as_deref(),
+                    "file_scan_exclusions",
+                ),
+                private_files: path_matchers(
+                    ProjectSettings::get(Some((cx.handle().entity_id().as_u64() as usize, &Path::new(""))), cx).private_files.as_deref(),
+                    "private_files",
+                ),
                 ignores_by_parent_abs_path: Default::default(),
                 git_repositories: Default::default(),
                 snapshot: Snapshot {
@@ -650,20 +678,22 @@ fn start_background_scan_tasks(
     vec![background_scanner, scan_state_updater]
 }
 
-fn file_scan_exclusions(project_settings: &ProjectSettings) -> Vec<PathMatcher> {
-    project_settings.file_scan_exclusions.as_deref().unwrap_or(&[]).iter()
-    .sorted()
-    .filter_map(|pattern| {
-        PathMatcher::new(pattern)
-            .map(Some)
-            .unwrap_or_else(|e| {
-                log::error!(
-                    "Skipping pattern {pattern} in `file_scan_exclusions` project settings due to parsing error: {e:#}"
-                );
-                None
-            })
-    })
-    .collect()
+fn path_matchers(values: Option<&[String]>, context: &'static str) -> Vec<PathMatcher> {
+    values
+        .unwrap_or(&[])
+        .iter()
+        .sorted()
+        .filter_map(|pattern| {
+            PathMatcher::new(pattern)
+                .map(Some)
+                .unwrap_or_else(|e| {
+                    log::error!(
+                        "Skipping pattern {pattern} in `{}` project settings due to parsing error: {e:#}", context
+                    );
+                    None
+                })
+        })
+        .collect()
 }
 
 impl LocalWorktree {
@@ -1003,6 +1033,7 @@ impl LocalWorktree {
                         mtime: entry.mtime,
                         is_local: true,
                         is_deleted: false,
+                        is_private: entry.is_private,
                     },
                     text,
                     diff_base,
@@ -1017,6 +1048,7 @@ impl LocalWorktree {
                         .with_context(|| {
                             format!("Excluded file {abs_path:?} got removed during loading")
                         })?;
+                    let is_private = snapshot.is_path_private(path.as_ref());
                     Ok((
                         File {
                             entry_id: None,
@@ -1025,6 +1057,7 @@ impl LocalWorktree {
                             mtime: metadata.mtime,
                             is_local: true,
                             is_deleted: false,
+                            is_private,
                         },
                         text,
                         diff_base,
@@ -1053,14 +1086,15 @@ impl LocalWorktree {
         let save = self.write_file(path.as_ref(), text, buffer.line_ending(), cx);
         let fs = Arc::clone(&self.fs);
         let abs_path = self.absolutize(&path);
+        let is_private = self.snapshot.is_path_private(&path);
 
         cx.spawn(move |this, mut cx| async move {
             let entry = save.await?;
             let abs_path = abs_path?;
             let this = this.upgrade().context("worktree dropped")?;
 
-            let (entry_id, mtime, path) = match entry {
-                Some(entry) => (Some(entry.id), entry.mtime, entry.path),
+            let (entry_id, mtime, path, is_dotenv) = match entry {
+                Some(entry) => (Some(entry.id), entry.mtime, entry.path, entry.is_private),
                 None => {
                     let metadata = fs
                         .metadata(&abs_path)
@@ -1073,7 +1107,7 @@ impl LocalWorktree {
                         .with_context(|| {
                             format!("Excluded buffer {path:?} got removed during saving")
                         })?;
-                    (None, metadata.mtime, path)
+                    (None, metadata.mtime, path, is_private)
                 }
             };
 
@@ -1085,6 +1119,7 @@ impl LocalWorktree {
                     mtime,
                     is_local: true,
                     is_deleted: false,
+                    is_private: is_dotenv,
                 });
 
                 if let Some(project_id) = project_id {
@@ -2295,6 +2330,14 @@ impl LocalSnapshot {
         paths
     }
 
+    pub fn is_path_private(&self, path: &Path) -> bool {
+        path.ancestors().any(|ancestor| {
+            self.private_files
+                .iter()
+                .any(|exclude_matcher| exclude_matcher.is_match(&ancestor))
+        })
+    }
+
     pub fn is_path_excluded(&self, mut path: PathBuf) -> bool {
         loop {
             if self
@@ -2747,6 +2790,7 @@ pub struct File {
     pub(crate) entry_id: Option<ProjectEntryId>,
     pub(crate) is_local: bool,
     pub(crate) is_deleted: bool,
+    pub(crate) is_private: bool,
 }
 
 impl language::File for File {
@@ -2819,6 +2863,10 @@ impl language::File for File {
             is_deleted: self.is_deleted,
         }
     }
+
+    fn is_private(&self) -> bool {
+        self.is_private
+    }
 }
 
 impl language::LocalFile for File {
@@ -2874,6 +2922,7 @@ impl File {
             entry_id: Some(entry.id),
             is_local: true,
             is_deleted: false,
+            is_private: entry.is_private,
         })
     }
 
@@ -2899,6 +2948,7 @@ impl File {
             entry_id: proto.entry_id.map(ProjectEntryId::from_proto),
             is_local: false,
             is_deleted: proto.is_deleted,
+            is_private: false,
         })
     }
 
@@ -2943,6 +2993,8 @@ pub struct Entry {
     /// entries in that they are not included in searches.
     pub is_external: bool,
     pub git_status: Option<GitFileStatus>,
+    /// Whether this entry is considered to be a `.env` file.
+    pub is_private: bool,
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -2997,6 +3049,7 @@ impl Entry {
             is_symlink: metadata.is_symlink,
             is_ignored: false,
             is_external: false,
+            is_private: false,
             git_status: None,
         }
     }
@@ -3732,7 +3785,7 @@ impl BackgroundScanner {
                     ancestor_inodes.insert(child_entry.inode);
 
                     new_jobs.push(Some(ScanJob {
-                        abs_path: child_abs_path,
+                        abs_path: child_abs_path.clone(),
                         path: child_path,
                         is_external: child_entry.is_external,
                         ignore_stack: if child_entry.is_ignored {
@@ -3766,6 +3819,16 @@ impl BackgroundScanner {
                 }
             }
 
+            {
+                let relative_path = job.path.join(child_name);
+                let state = self.state.lock();
+                if state.snapshot.is_path_private(&relative_path) {
+                    log::debug!("detected private file: {relative_path:?}");
+                    child_entry.is_private = true;
+                }
+                drop(state)
+            }
+
             new_entries.push(child_entry);
         }
 
@@ -3866,6 +3929,7 @@ impl BackgroundScanner {
                     let is_dir = fs_entry.is_dir();
                     fs_entry.is_ignored = ignore_stack.is_abs_path_ignored(&abs_path, is_dir);
                     fs_entry.is_external = !canonical_path.starts_with(&root_canonical_path);
+                    fs_entry.is_private = state.snapshot.is_path_private(path);
 
                     if !is_dir && !fs_entry.is_ignored && !fs_entry.is_external {
                         if let Some((work_dir, repo)) = state.snapshot.local_repo_for_path(path) {
@@ -4548,6 +4612,7 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry {
                 is_ignored: entry.is_ignored,
                 is_external: entry.is_external,
                 git_status: git_status_from_proto(entry.git_status),
+                is_private: false,
             })
         } else {
             Err(anyhow!(

crates/project_panel/src/project_panel.rs 🔗

@@ -101,6 +101,7 @@ pub struct EntryDetails {
     is_processing: bool,
     is_cut: bool,
     git_status: Option<GitFileStatus>,
+    is_dotenv: bool,
 }
 
 actions!(
@@ -1137,6 +1138,7 @@ impl ProjectPanel {
                         is_symlink: false,
                         is_ignored: false,
                         is_external: false,
+                        is_private: false,
                         git_status: entry.git_status,
                     });
                 }
@@ -1298,6 +1300,7 @@ impl ProjectPanel {
                             .clipboard_entry
                             .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
                         git_status: status,
+                        is_dotenv: entry.is_private,
                     };
 
                     if let Some(edit_state) = &self.edit_state {

crates/settings/src/settings_store.rs 🔗

@@ -86,6 +86,7 @@ pub trait Settings: 'static + Send + Sync {
         });
     }
 
+    /// path is a (worktree ID, Path)
     #[track_caller]
     fn get<'a>(path: Option<(usize, &Path)>, cx: &'a AppContext) -> &'a Self
     where

crates/zed/src/languages.rs 🔗

@@ -357,6 +357,7 @@ const QUERY_FILENAME_PREFIXES: &[(
     ("embedding", |q| &mut q.embedding),
     ("injections", |q| &mut q.injections),
     ("overrides", |q| &mut q.overrides),
+    ("redactions", |q| &mut q.redactions),
 ];
 
 fn load_queries(name: &str) -> LanguageQueries {

crates/zed/src/languages/bash/config.toml 🔗

@@ -1,5 +1,5 @@
 name = "Shell Script"
-path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile"]
+path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env"]
 line_comments = ["# "]
 first_line_pattern = "^#!.*\\b(?:ba|z)?sh\\b"
 brackets = [