status_bar: Add encoding indicator (#45476)

Ichimura Tomoo created

## Context / Related PRs This PR is the third part of the encoding
support improvements, following:
- #44819: Introduced initial legacy encoding support (Shift-JIS, etc.).
- #45243: Fixed UTF-16 saving behavior and improved binary detection.

## Summary
This PR implements a status bar item that displays the character
encoding of the active buffer (e.g., `UTF-8`, `Shift_JIS`). It provides
visibility into the file's encoding and indicates the presence of a Byte
Order Mark (BOM).

## Features
- **Encoding Indicator**: Displays the encoding name in the status bar.
- **BOM Support**: Appends `(BOM)` to the encoding name if a BOM is
detected (e.g., `UTF-8 (BOM)`).
- **Configuration**: The active_encoding_button setting in status_bar
accepts "enabled", "disabled", or "non_utf8". The default is "non_utf8",
which displays the indicator for all encodings except standard UTF-8
(without BOM).
- **Settings UI**: Provides a dropdown menu in the Settings UI to
control this behavior.
- **Documentation**: Updated `configuring-zed.md` and
`visual-customization.md`.

## Implementation Details
- Created `ActiveBufferEncoding` component in
`crates/encoding_selector`.
- The click handler for the button is currently a **no-op**.
Implementing the functionality to reopen files with a specific encoding
has potential implications for real-time collaboration (e.g., syncing
buffer interpretation across peers). Therefore, this PR focuses strictly
on the visualization and configuration aspects to keep the scope simple
and focused.
- Updated schema and default settings to include
`active_encoding_button`.

## Screenshots

<img width="487" height="104" alt="image"
src="https://github.com/user-attachments/assets/041f096d-ac69-4bad-ac53-20cdcb41f733"
/>
<img width="454" height="99" alt="image"
src="https://github.com/user-attachments/assets/ed76daa2-2733-484f-bb1f-4688357c035a"
/>


## Configuration
To hide the button, add the following to `settings.json`:
```json
"status_bar": {
  "active_encoding_button": "disabled"
}
```

- **enabled**: Always show the encoding.
- **disabled**: Never show the encoding.
- **non_utf8**: Shows for non-UTF-8 encodings and UTF-8 with BOM. Only
hides for standard UTF-8 (Default).

<img width="1347" height="415" alt="image"
src="https://github.com/user-attachments/assets/7f4f4938-3320-4d21-852c-53ee886d9a44"
/>

## Heuristic Limitations:
The underlying detection logic (implemented in #44819 and #45243)
prioritizes UTF-8 opening performance and does not guarantee perfect
detection for all encodings. We consider this margin of error
acceptable, similar to the behavior seen in VS Code. A future "Reopen
with Encoding" feature would serve as the primary fallback for any
misdetections.

Release Notes:

- Added a status bar item to display the active file's character encoding (e.g. `UTF-16`). This shows for non-utf8 files by default and can be configured with `{"status_bar":{"active_encoding_button":"disabled|enabled|non_utf8"}}`

Change summary

Cargo.lock                                             | 12 +
Cargo.toml                                             |  2 
assets/settings/default.json                           |  2 
crates/encoding_selector/Cargo.toml                    | 20 ++
crates/encoding_selector/LICENSE-GPL                   |  1 
crates/encoding_selector/src/active_buffer_encoding.rs | 91 ++++++++++++
crates/encoding_selector/src/encoding_selector.rs      |  4 
crates/settings/src/settings_content/workspace.rs      | 38 +++++
crates/settings/src/vscode_import.rs                   |  1 
crates/settings_ui/src/page_data.rs                    | 22 ++
crates/settings_ui/src/settings_ui.rs                  |  1 
crates/workspace/src/workspace_settings.rs             |  7 
crates/zed/Cargo.toml                                  |  1 
crates/zed/src/zed.rs                                  |  3 
docs/src/configuring-zed.md                            |  3 
docs/src/visual-customization.md                       |  6 
16 files changed, 210 insertions(+), 4 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5583,6 +5583,17 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "encoding_selector"
+version = "0.1.0"
+dependencies = [
+ "editor",
+ "encoding_rs",
+ "gpui",
+ "ui",
+ "workspace",
+]
+
 [[package]]
 name = "endi"
 version = "1.1.0"
@@ -20710,6 +20721,7 @@ dependencies = [
  "edit_prediction",
  "edit_prediction_ui",
  "editor",
+ "encoding_selector",
  "env_logger 0.11.8",
  "extension",
  "extension_host",

Cargo.toml 🔗

@@ -59,6 +59,7 @@ members = [
     "crates/edit_prediction_ui",
     "crates/edit_prediction_context",
     "crates/editor",
+    "crates/encoding_selector",
     "crates/eval",
     "crates/eval_utils",
     "crates/explorer_command_injector",
@@ -292,6 +293,7 @@ deepseek = { path = "crates/deepseek" }
 derive_refineable = { path = "crates/refineable/derive_refineable" }
 diagnostics = { path = "crates/diagnostics" }
 editor = { path = "crates/editor" }
+encoding_selector = { path = "crates/encoding_selector" }
 eval_utils = { path = "crates/eval_utils" }
 extension = { path = "crates/extension" }
 extension_host = { path = "crates/extension_host" }

assets/settings/default.json 🔗

@@ -1472,6 +1472,8 @@
     "cursor_position_button": true,
     // Whether to show active line endings button in the status bar.
     "line_endings_button": false,
+    // Control when to show the active encoding in the status bar.
+    "active_encoding_button": "non_utf8",
   },
   // Settings specific to the terminal
   "terminal": {

crates/encoding_selector/Cargo.toml 🔗

@@ -0,0 +1,20 @@
+[package]
+name = "encoding_selector"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/encoding_selector.rs"
+doctest = false
+
+[dependencies]
+editor.workspace = true
+encoding_rs.workspace = true
+gpui.workspace = true
+ui.workspace = true
+workspace.workspace = true

crates/encoding_selector/src/active_buffer_encoding.rs 🔗

@@ -0,0 +1,91 @@
+use editor::Editor;
+use encoding_rs::{Encoding, UTF_8};
+use gpui::{
+    Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window, div,
+};
+use ui::{Button, ButtonCommon, Clickable, LabelSize, Tooltip};
+use workspace::{
+    StatusBarSettings, StatusItemView, Workspace,
+    item::{ItemHandle, Settings},
+};
+
+pub struct ActiveBufferEncoding {
+    active_encoding: Option<&'static Encoding>,
+    //workspace: WeakEntity<Workspace>,
+    _observe_active_editor: Option<Subscription>,
+    has_bom: bool,
+}
+
+impl ActiveBufferEncoding {
+    pub fn new(_workspace: &Workspace) -> Self {
+        Self {
+            active_encoding: None,
+            //workspace: workspace.weak_handle(),
+            _observe_active_editor: None,
+            has_bom: false,
+        }
+    }
+
+    fn update_encoding(&mut self, editor: Entity<Editor>, _: &mut Window, cx: &mut Context<Self>) {
+        self.active_encoding = None;
+
+        let editor = editor.read(cx);
+        if let Some((_, buffer, _)) = editor.active_excerpt(cx) {
+            let buffer = buffer.read(cx);
+
+            self.active_encoding = Some(buffer.encoding());
+            self.has_bom = buffer.has_bom();
+        }
+
+        cx.notify();
+    }
+}
+
+impl Render for ActiveBufferEncoding {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let Some(active_encoding) = self.active_encoding else {
+            return div().hidden();
+        };
+
+        let display_option = StatusBarSettings::get_global(cx).active_encoding_button;
+        let is_utf8 = active_encoding == UTF_8;
+        if !display_option.should_show(is_utf8, self.has_bom) {
+            return div().hidden();
+        }
+
+        let mut text = active_encoding.name().to_string();
+        if self.has_bom {
+            text.push_str(" (BOM)");
+        }
+
+        div().child(
+            Button::new("change-encoding", text)
+                .label_size(LabelSize::Small)
+                .on_click(|_, _, _cx| {
+                    // No-op
+                })
+                .tooltip(Tooltip::text("Current Encoding")),
+        )
+    }
+}
+
+impl StatusItemView for ActiveBufferEncoding {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(editor) = active_pane_item.and_then(|item| item.downcast::<Editor>()) {
+            self._observe_active_editor =
+                Some(cx.observe_in(&editor, window, Self::update_encoding));
+            self.update_encoding(editor, window, cx);
+        } else {
+            self.active_encoding = None;
+            self.has_bom = false;
+            self._observe_active_editor = None;
+        }
+
+        cx.notify();
+    }
+}

crates/settings/src/settings_content/workspace.rs 🔗

@@ -435,6 +435,44 @@ pub struct StatusBarSettingsContent {
     ///
     /// Default: false
     pub line_endings_button: Option<bool>,
+    /// Whether to show the active encoding button in the status bar.
+    ///
+    /// Default: non_utf8
+    pub active_encoding_button: Option<EncodingDisplayOptions>,
+}
+
+#[derive(
+    Copy,
+    Clone,
+    Debug,
+    Eq,
+    PartialEq,
+    Default,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    strum::VariantNames,
+    strum::VariantArray,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum EncodingDisplayOptions {
+    Enabled,
+    Disabled,
+    #[default]
+    NonUtf8,
+}
+impl EncodingDisplayOptions {
+    pub fn should_show(&self, is_utf8: bool, has_bom: bool) -> bool {
+        match self {
+            Self::Disabled => false,
+            Self::Enabled => true,
+            Self::NonUtf8 => {
+                let is_standard_utf8 = is_utf8 && !has_bom;
+                !is_standard_utf8
+            }
+        }
+    }
 }
 
 #[derive(

crates/settings/src/vscode_import.rs 🔗

@@ -655,6 +655,7 @@ impl VsCodeSettings {
             active_language_button: None,
             cursor_position_button: None,
             line_endings_button: None,
+            active_encoding_button: None,
         })
     }
 

crates/settings_ui/src/page_data.rs 🔗

@@ -2835,6 +2835,28 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                     metadata: None,
                     files: USER,
                 }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Active Encoding Button",
+                    description: "Control when to show the active encoding in the status bar.",
+                    field: Box::new(SettingField {
+                        json_path: Some("status_bar.active_encoding_button"),
+                        pick: |settings_content| {
+                            settings_content
+                                .status_bar
+                                .as_ref()?
+                                .active_encoding_button
+                                .as_ref()
+                        },
+                        write: |settings_content, value| {
+                            settings_content
+                                .status_bar
+                                .get_or_insert_default()
+                                .active_encoding_button = value;
+                        },
+                    }),
+                    metadata: None,
+                    files: USER,
+                }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Cursor Position Button",
                     description: "Show the cursor position button in the status bar.",

crates/settings_ui/src/settings_ui.rs 🔗

@@ -498,6 +498,7 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::NotifyWhenAgentWaiting>(render_dropdown)
         .add_basic_renderer::<settings::ImageFileSizeUnit>(render_dropdown)
         .add_basic_renderer::<settings::StatusStyle>(render_dropdown)
+        .add_basic_renderer::<settings::EncodingDisplayOptions>(render_dropdown)
         .add_basic_renderer::<settings::PaneSplitDirectionHorizontal>(render_dropdown)
         .add_basic_renderer::<settings::PaneSplitDirectionVertical>(render_dropdown)
         .add_basic_renderer::<settings::PaneSplitDirectionVertical>(render_dropdown)

crates/workspace/src/workspace_settings.rs 🔗

@@ -4,8 +4,9 @@ use crate::DockPosition;
 use collections::HashMap;
 use serde::Deserialize;
 pub use settings::{
-    AutosaveSetting, BottomDockLayout, InactiveOpacity, PaneSplitDirectionHorizontal,
-    PaneSplitDirectionVertical, RegisterSetting, RestoreOnStartupBehavior, Settings,
+    AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity,
+    PaneSplitDirectionHorizontal, PaneSplitDirectionVertical, RegisterSetting,
+    RestoreOnStartupBehavior, Settings,
 };
 
 #[derive(RegisterSetting)]
@@ -130,6 +131,7 @@ pub struct StatusBarSettings {
     pub active_language_button: bool,
     pub cursor_position_button: bool,
     pub line_endings_button: bool,
+    pub active_encoding_button: EncodingDisplayOptions,
 }
 
 impl Settings for StatusBarSettings {
@@ -140,6 +142,7 @@ impl Settings for StatusBarSettings {
             active_language_button: status_bar.active_language_button.unwrap(),
             cursor_position_button: status_bar.cursor_position_button.unwrap(),
             line_endings_button: status_bar.line_endings_button.unwrap(),
+            active_encoding_button: status_bar.active_encoding_button.unwrap(),
         }
     }
 }

crates/zed/Cargo.toml 🔗

@@ -92,6 +92,7 @@ debugger_tools.workspace = true
 debugger_ui.workspace = true
 diagnostics.workspace = true
 editor.workspace = true
+encoding_selector.workspace = true
 env_logger.workspace = true
 extension.workspace = true
 extension_host.workspace = true

crates/zed/src/zed.rs 🔗

@@ -433,6 +433,8 @@ pub fn initialize_workspace(
             window,
             cx,
         );
+        let active_buffer_encoding =
+            cx.new(|_| encoding_selector::ActiveBufferEncoding::new(workspace));
         let active_buffer_language =
             cx.new(|_| language_selector::ActiveBufferLanguage::new(workspace));
         let active_toolchain_language =
@@ -459,6 +461,7 @@ pub fn initialize_workspace(
             status_bar.add_left_item(diagnostic_summary, window, cx);
             status_bar.add_left_item(activity_indicator, window, cx);
             status_bar.add_right_item(edit_prediction_ui, window, cx);
+            status_bar.add_right_item(active_buffer_encoding, window, cx);
             status_bar.add_right_item(active_buffer_language, window, cx);
             status_bar.add_right_item(active_toolchain_language, window, cx);
             status_bar.add_right_item(line_ending_indicator, window, cx);

docs/src/configuring-zed.md 🔗

@@ -1615,7 +1615,8 @@ Positive `integer` value between 1 and 32. Values outside of this range will be
 "status_bar": {
   "active_language_button": true,
   "cursor_position_button": true,
-  "line_endings_button": false
+  "line_endings_button": false,
+  "active_encoding_button": "non_utf8"
 },
 ```
 

docs/src/visual-customization.md 🔗

@@ -332,7 +332,11 @@ TBD: Centered layout related settings
     // Show/hide a button that displays the buffer's line-ending mode.
     // Clicking the button brings up the line-ending selector.
     // Defaults to false.
-    "line_endings_button": false
+    "line_endings_button": false,
+    // Show/hide a button that displays the buffer's character encoding.
+    // If set to "non_utf8", the button is hidden only for UTF-8 without BOM.
+    // Defaults to "non_utf8".
+    "active_encoding_button": "non_utf8"
   },
   "global_lsp_settings": {
     // Show/hide the LSP button in the status bar.