fix(file-tree): fold a filename's line breaks where it is drawn

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.
This commit is contained in:
l0ng-ai
2026-08-16 00:36:40 +08:00
parent b3149c31b1
commit 25d1f06a57
2 changed files with 32 additions and 3 deletions
+29 -1
View File
@@ -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};
+3 -2
View File
@@ -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)))
}
}