Files
zed/crates/git_ui/src/multi_diff_view.rs
T
Enzo GAZZOLIandKirill Bulatov e3056061d4 Render control characters in tab titles and project panel entries (#62875)
# Objective

Fixes #62664

File names may contain control characters (`\n`, `\r`, `\t`, …) on most
platforms. Zed rendered them verbatim, so a file named `notes\ndraft.md`
broke the layout of its tab and of its project panel entry instead of
showing a readable name. The same held anywhere else a name reached the
screen without going through `Label::single_line`.

## Solution

The substitution lives in one shared place,
`ui::utils::replace_control_characters`, reached through
`Label::single_line`. `\n` keeps rendering as the `⏎` Zed already used,
so nothing that renders correctly today changes; every other C0 control
character maps onto the Unicode "Control Pictures" block by a single
rule (`U+2400 + code point`), giving `␉` for tab and `␍` for carriage
return. `DEL` maps to `␡`. C1 controls are left alone, having no
equivalent there.

Where that is wired in:

- **`Label::single_line`** — replaces its previous `\n`-only
substitution. The project panel already used it everywhere, including
folded directory components, so the panel is fixed by this alone.
- **Editor tabs** and the **default `Item::tab_content`**, neither of
which called `single_line`.
- **Terminal tab titles**, which render through their own `tab_content`.
Those titles come from OSC escape sequences, so the control characters
in them are attacker-controlled.
- **`HighlightedLabel::single_line`**, which never substituted anything
— not even `\n`. See below.
- **The file finder**, for both the file name and the path.
- **Nine `tab_content` overrides in git_ui**, by deleting them.
- **`HighlightedMatchWithPaths`**, for both the match label and its
paths — it backs the tab switcher and the outline panel, among others.

### `HighlightedLabel` needs its offsets moved, not just its text

Its highlight indices are byte offsets, and every stand-in is wider in
bytes than the character it replaces, so the offsets have to move with
the text. Left alone they index into the middle of a character, and
`highlight_ranges` slices the string at exactly those offsets — which
panics in release builds too. The constructor's `debug_panic!` does not
cover this, since `single_line` runs after construction.

So `replace_control_characters_remapping_offsets` substitutes and remaps
in one pass, over an old-to-new byte offset table built the way
`ensure_uniform_list_compatible_label` does it in `lsp_store`. This also
fixes the call sites that already asked for a single line, including the
branch picker and the tabular column filter values.

### Deleting the git_ui overrides

Nine files replicated the default `Item::tab_content` verbatim except
for the colour, hardcoding `selected ? Default : Muted` instead of
`params.text_color()`. Deleting them lets those tabs inherit the fixed
default, and fixes a second bug along the way: none of them dimmed when
the pane lost focus. Titles and icons are unaffected — they come from
`tab_content_text` and `tab_icon`, which the default calls.
`git_graph.rs` already did it this way.

### Tabular column headers

These never went through a `Label` at all: the raw `SharedString` was a
child of a `div`. They now display stand-ins, while right-click-copy
still yields the real column name.

Two notes for reviewers:

- Both helpers avoid allocating when there is nothing to replace, so the
common path is untouched. The previous `self.label.replace('\n', "⏎")`
allocated a `String` on every render for every label — including every
visible project panel entry, every frame.
- Every stand-in is exactly one character, so character offsets are
preserved and the existing `truncate_and_trailoff` math on tab titles
stays correct.

## Testing

- Unit tests cover both helpers: each control character's substitution,
repeated occurrences, multi-byte characters (accents, emoji), the
borrow-vs-own behaviour, the preserved character count, C1 passthrough,
and — for the remapping — offsets before, at and after a replacement,
offsets at and past the end, and the invariant that every remapped
offset lands on a character boundary.
- Unit tests cover `Label::single_line` and
`HighlightedLabel::single_line`, the entry points every call site uses.
`highlighted_label.rs` had no tests before.
- `cargo test -p ui -p file_finder -p workspace -p project_panel -p
git_ui -p git_ui_core -p terminal_view -p tabular_data_preview` passes
(774 tests), as do `cargo clippy` and `cargo fmt --check`.
- No existing test changes behaviour: tests assert on
`tab_content_text`, which is untouched; only rendering paths changed.
- Verified manually on macOS (aarch64) — see Showcase.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments — none added
- [x] The content adheres to Zed's UI standards
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Showcase

A directory holding `report<TAB>table.csv`, `notes<LF>draft.md`,
`carriage<CR>return.txt` and a normally named `normal.txt`, all four
open as tabs.

**Before**


![before](https://raw.githubusercontent.com/Taqinou/zed/0d5ee2b4f94db7bcb713298c38cf411458b75239/62664/before.png)

**After**


![after](https://raw.githubusercontent.com/Taqinou/zed/0d5ee2b4f94db7bcb713298c38cf411458b75239/62664/after.png)

Three things change:

1. **The `notes<LF>draft.md` tab** is the clearest one. Before, the
literal newline splits the tab across two lines — `notes` above
`draft.md` — distorting the whole tab bar. After, it stays on one line.
2. **`carriage<CR>return.txt` in the project panel** renders as
`carriagereturn.txt` before: the `\r` is completely invisible, so the
name reads as one word and there is no way to tell a character is there.
After, it renders as `carriage␍return.txt`.
3. **`report<TAB>table.csv`** shows a bare gap before, indistinguishable
from a space. After, it renders as `report␉table.csv`.

Note that `notes<LF>draft.md` looks the same in the project panel in
both shots: that entry already called `single_line`, which already
handled `\n`. That is precisely the asymmetry this PR removes.

### File finder

Before, the newline also breaks the list itself — the
`notes<LF>draft.md` row grows to two lines and overlaps the row beneath
it.

**Before**

![file finder
before](https://raw.githubusercontent.com/Taqinou/zed/0d5ee2b4f94db7bcb713298c38cf411458b75239/62664/finder-before.png)

**After**

![file finder
after](https://raw.githubusercontent.com/Taqinou/zed/0d5ee2b4f94db7bcb713298c38cf411458b75239/62664/finder-after.png)

Searching for `table` exercises the offset remapping, the match falling
after the control character:

**Before**

![match
before](https://raw.githubusercontent.com/Taqinou/zed/0d5ee2b4f94db7bcb713298c38cf411458b75239/62664/match-before.png)

**After**

![match
after](https://raw.githubusercontent.com/Taqinou/zed/0d5ee2b4f94db7bcb713298c38cf411458b75239/62664/match-after.png)

One thing this PR does **not** cover: the breadcrumb below the tab bar
still renders the raw tab character, as it goes through a different
path.

## Release Notes:

- Fixed file names containing control characters, such as tabs and
newlines, rendering unreadably in tabs, the project panel, the file
finder, the tab switcher and terminal tab titles.

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2026-08-19 21:10:56 +00:00

341 lines
9.8 KiB
Rust

use anyhow::Result;
use buffer_diff::BufferDiff;
use editor::{
Editor, EditorEvent, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate,
multibuffer_context_lines,
};
use git_ui_core::file_diff_view::build_buffer_diff;
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font,
IntoElement, Render, SharedString, Task, Window,
};
use language::{Buffer, Capability, HighlightedText, OffsetRangeExt};
use multi_buffer::PathKey;
use project::{Project, ProjectPath};
use std::{
any::{Any, TypeId},
path::{Path, PathBuf},
sync::Arc,
};
use ui::{Color, Icon, IconName};
use util::paths::PathStyle;
use util::rel_path::RelPath;
use workspace::{
Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
item::{ItemEvent, SaveOptions},
searchable::SearchableItemHandle,
};
pub struct MultiDiffView {
editor: Entity<Editor>,
file_count: usize,
}
struct Entry {
index: usize,
new_path: PathBuf,
new_buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
}
async fn load_entries(
diff_pairs: Vec<[String; 2]>,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Result<(Vec<Entry>, Option<PathBuf>)> {
let mut entries = Vec::with_capacity(diff_pairs.len());
let mut all_paths = Vec::with_capacity(diff_pairs.len());
for (ix, pair) in diff_pairs.into_iter().enumerate() {
let old_path = PathBuf::from(&pair[0]);
let new_path = PathBuf::from(&pair[1]);
let old_buffer = project
.update(cx, |project, cx| project.open_local_buffer(&old_path, cx))
.await?;
let new_buffer = project
.update(cx, |project, cx| project.open_local_buffer(&new_path, cx))
.await?;
let diff = build_buffer_diff(&old_buffer, &new_buffer, cx).await?;
all_paths.push(new_path.clone());
entries.push(Entry {
index: ix,
new_path,
new_buffer: new_buffer.clone(),
diff,
});
}
let common_root = common_prefix(&all_paths);
Ok((entries, common_root))
}
fn register_entry(
multibuffer: &Entity<MultiBuffer>,
entry: Entry,
common_root: &Option<PathBuf>,
context_lines: u32,
cx: &mut Context<Workspace>,
) {
let snapshot = entry.new_buffer.read(cx).snapshot();
let diff_snapshot = entry.diff.read(cx).snapshot(cx);
let ranges: Vec<std::ops::Range<language::Point>> = diff_snapshot
.hunks(&snapshot)
.map(|hunk| hunk.buffer_range.to_point(&snapshot))
.collect();
let display_rel = common_root
.as_ref()
.and_then(|root| entry.new_path.strip_prefix(root).ok())
.map(|rel| {
RelPath::new(rel, PathStyle::local())
.map(|r| r.into_owned().into())
.unwrap_or_else(|_| {
RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Unix)
.unwrap()
.into_owned()
.into()
})
})
.unwrap_or_else(|| {
entry
.new_path
.file_name()
.and_then(|n| n.to_str())
.and_then(|s| RelPath::new(Path::new(s), PathStyle::Unix).ok())
.map(|r| r.into_owned().into())
.unwrap_or_else(|| {
RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Unix)
.unwrap()
.into_owned()
.into()
})
});
let path_key = PathKey::with_sort_prefix(entry.index as u64, display_rel);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path_key,
entry.new_buffer.clone(),
ranges,
context_lines,
cx,
);
multibuffer.add_diff(entry.diff.clone(), cx);
});
}
fn common_prefix(paths: &[PathBuf]) -> Option<PathBuf> {
let mut iter = paths.iter();
let mut prefix = iter.next()?.clone();
for path in iter {
while !path.starts_with(&prefix) {
if !prefix.pop() {
return Some(PathBuf::new());
}
}
}
Some(prefix)
}
impl MultiDiffView {
pub fn open(
diff_pairs: Vec<[String; 2]>,
workspace: &Workspace,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let project = workspace.project().clone();
let workspace = workspace.weak_handle();
let context_lines = multibuffer_context_lines(cx);
window.spawn(cx, async move |cx| {
let (entries, common_root) = load_entries(diff_pairs, &project, cx).await?;
workspace.update_in(cx, |workspace, window, cx| {
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let file_count = entries.len();
for entry in entries {
register_entry(&multibuffer, entry, &common_root, context_lines, cx);
}
let diff_view = cx.new(|cx| {
Self::new(multibuffer.clone(), project.clone(), file_count, window, cx)
});
let pane = workspace.active_pane();
pane.update(cx, |pane, cx| {
pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
});
// Hide the left dock (file explorer) for a cleaner diff view
workspace.left_dock().update(cx, |dock, cx| {
dock.set_open(false, window, cx);
});
diff_view
})
})
}
fn new(
multibuffer: Entity<MultiBuffer>,
project: Entity<Project>,
file_count: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let editor = cx.new(|cx| {
let mut editor =
Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx);
editor.disable_diagnostics(cx);
editor.set_expand_all_diff_hunks(cx);
editor
});
Self { editor, file_count }
}
fn title(&self) -> SharedString {
let suffix = if self.file_count == 1 {
"1 file".to_string()
} else {
format!("{} files", self.file_count)
};
format!("Diff ({suffix})").into()
}
}
impl EventEmitter<EditorEvent> for MultiDiffView {}
impl Focusable for MultiDiffView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.editor.focus_handle(cx)
}
}
impl Item for MultiDiffView {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Diff).color(Color::Muted))
}
fn tab_tooltip_text(&self, _cx: &App) -> Option<ui::SharedString> {
Some(self.title())
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
self.title()
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Diff View Opened")
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor
.update(cx, |editor, cx| editor.deactivated(window, cx));
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.clone().into())
} else {
None
}
}
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
self.editor.read(cx).active_project_path(cx)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
});
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor
.update(cx, |editor, cx| editor.navigate(data, window, cx))
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
ToolbarItemLocation::PrimaryLeft
}
fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
self.editor.breadcrumbs(cx)
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx)
});
}
fn can_save(&self, cx: &App) -> bool {
self.editor.read(cx).can_save(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Task<Result<()>> {
self.editor
.update(cx, |editor, cx| editor.save(options, project, window, cx))
}
}
impl Render for MultiDiffView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.editor.clone()
}
}