From 25d1f06a5780e9979f9e3b7ccd56925f8085e9b0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:36:40 +0800 Subject: [PATCH] fix(file-tree): fold a filename's line breaks where it is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A filename is bytes to the kernel, so `touch $'a\nb'` makes a file the tree has to draw, and gpui breaks text on `\n` whatever the row says. The row grows to two lines, its `text_ellipsis` contract stops holding, and the rows below it slide down — one odd file misaligns the column. The terminal's history rows already met this and already carry the fold (`one_line`), so reuse it rather than spell a second one. What differs here is where it may be applied: a history entry is only ever drawn, but a filename is also handed to `join`, `rename` and `remove`. Folding at ingestion would splice a `↵` into a name that has to match what is on disk, so the fold sits at the two `.child(...)` sites and nothing on the operations path sees it. --- src/terminal/view.rs | 30 +++++++++++++++++++++++++++++- src/ui/file_tree.rs | 5 +++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index d06ff439..19208db8 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -6213,7 +6213,16 @@ fn one_line_char(c: char) -> char { } } -fn one_line(text: &str) -> String { +/// `text` with every line break and control character folded to a visible +/// stand-in, so it draws on exactly one row. +/// +/// The hazard is not this module's alone. Anything that draws a name it did +/// not compose is exposed to it — a filename is bytes to the kernel, so +/// `touch $'a\nb'` is a file the tree has to draw. Fold at the point of +/// drawing, never at the point of reading: the unfolded name is what +/// `join`/`rename`/`remove` are handed, and a `↵` spliced into it names +/// nothing on disk. +pub(crate) fn one_line(text: &str) -> String { text.chars().map(one_line_char).collect() } @@ -6766,6 +6775,25 @@ mod tests { assert_eq!(unescape_mark_text("cargo build"), "cargo build"); } + #[test] + fn a_name_drawn_on_one_row_keeps_its_line_breaks_visible() { + use super::one_line; + // A newline is the whole point: gpui breaks on it whatever the row + // says, so it has to become something that occupies a column instead. + assert_eq!(one_line("a\nb"), "a↵b"); + assert_eq!(one_line("a\r\nb"), "a ↵b"); + // Every other control character folds to a space rather than + // vanishing, so the name's length still reflects what is on disk. + assert_eq!(one_line("a\tb\u{7}c"), "a b c"); + // One char in, one char out — a fuzzy matcher's byte positions are + // handed to this and still have to address the same characters. + for s in ["plain.rs", "a\nb", "a\tb", "日本語\n"] { + assert_eq!(one_line(s).chars().count(), s.chars().count(), "{s:?}"); + } + // Text with nothing to fold comes back untouched. + assert_eq!(one_line("README.md"), "README.md"); + } + #[test] fn a_busy_command_name_stays_short_enough_to_read() { use super::{BUSY_COMMAND_MAX, clamp_command}; diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 6914a308..2eedc38d 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use crate::core::config::RightPanelTab; use crate::core::git::status::{DecoStatus, DirRollup, StatusIndex}; use crate::terminal::git_data::index_of; +use crate::terminal::view::one_line; use crate::ui::app::Tty7App; use crate::ui::file_copy; use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub}; @@ -1815,7 +1816,7 @@ impl Tty7App { .when(deco.strike, |d| d.line_through()) .when(deco.bold, |d| d.font_weight(gpui::FontWeight::SEMIBOLD)) .when(row.is_root, |d| d.font_weight(gpui::FontWeight::MEDIUM)) - .child(SharedString::from(row.entry.name.clone())) + .child(SharedString::from(one_line(&row.entry.name))) .into_any_element() }; @@ -2117,7 +2118,7 @@ impl gpui::Render for DragGhost { .border_color(cx.theme().border) .text_sm() .child(Icon::new(IconName::File).size(px(ROW_GLYPH))) - .child(SharedString::from(self.name.clone())) + .child(SharedString::from(one_line(&self.name))) } }