fix(bash): keep wrapped input aligned in byte-oriented locales

This commit is contained in:
kuddev committed 2026-09-16 19:47:50 +08:00
1 parent 15c18e697c
commit b353104c07
2 files changed
+225 -2

No files matched your search

+11 -2
View File
@@ -957,11 +957,18 @@ __nebula_precmd() {
printf '\033]2;NEBULA|%s|%s\007' "$cwd" "$branch"
if [[ -z ${__nebula_user_ps1-} ]]; then
# Readline counts bytes in non-multibyte locales (including Git Bash's
# unset-locale default). A three-byte arrow would leave two input cells
# behind when redrawing a wrapped line. Keep the user's locale intact.
local prompt_mark='❯'
if (( ${#prompt_mark} != 1 )); then
prompt_mark='>'
fi
if __nebula_bool_on "$(__nebula_setting powerline 1)"; then
# ANSI-16 only: 35=Magenta 提示符(同 PowerShell 侧),主题表决定实际色值。
PS1='\[\033[35m\]❯ \[\033[0m\]'
PS1='\[\033[35m\]'"$prompt_mark"' \[\033[0m\]'
else
PS1='\[\033[90m\]\w \[\033[35m\]❯ \[\033[0m\]'
PS1='\[\033[90m\]\w \[\033[35m\]'"$prompt_mark"' \[\033[0m\]'
fi
fi
@@ -1136,6 +1143,7 @@ pub fn win32_string<S: AsRef<OsStr> + ?Sized>(value: &S) -> Vec<u16> {
#[cfg(test)]
mod test {
mod bash_input;
use std::io::Write;
use std::process::{Command, Stdio};
@@ -1521,6 +1529,7 @@ exit 0
run_bash_integration_case(
r#"
PATH=/usr/bin:/mingw64/bin:$PATH
export LC_ALL=C.UTF-8
NEBULA_BASHRC_SOURCED=1
HOME=/__nebula_test_missing_home__
APPDATA=
@@ -0,0 +1,214 @@
//! Edit real Git Bash input through the product PTY and VT parser.
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use std::{env, fs, thread};
use crate::Term;
use crate::event::{Event, EventListener, WindowSize};
use crate::index::{Column, Line};
use crate::term::{Config, test::TermSize};
use crate::tty::{self, EventedReadWrite};
use vte::ansi;
#[derive(Clone, Default)]
struct Replies(Arc<Mutex<Vec<String>>>);
impl EventListener for Replies {
fn send_event(&self, event: Event) {
if let Event::PtyWrite(data) = event {
self.0.lock().unwrap().push(data);
}
}
}
struct BashSession {
pty: tty::Pty,
term: Term<Replies>,
parser: ansi::Processor,
replies: Replies,
}
impl BashSession {
fn send(&mut self, bytes: &[u8]) {
write_pty(&mut self.pty, bytes);
}
fn wait_for(&mut self, label: &str, expected: impl Fn(&Term<Replies>) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
let mut buffer = [0; 8192];
loop {
match self.pty.reader().read(&mut buffer) {
Ok(count) if count > 0 => {
self.parser.advance(&mut self.term, &buffer[..count]);
for reply in self.replies.0.lock().unwrap().drain(..) {
write_pty(&mut self.pty, reply.as_bytes());
}
if expected(&self.term) {
return;
}
},
Ok(_) => (),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => (),
Err(error) => panic!("{label}: PTY read failed: {error}"),
}
assert!(
Instant::now() < deadline,
"{label}: cursor {:?}, rows {:?} / {:?}",
self.term.grid().cursor.point,
row(&self.term, 0),
row(&self.term, 1)
);
thread::sleep(Duration::from_millis(5));
}
}
}
fn write_pty(pty: &mut tty::Pty, mut bytes: &[u8]) {
let deadline = Instant::now() + Duration::from_secs(10);
while !bytes.is_empty() {
match pty.writer().write(bytes) {
Ok(count) => bytes = &bytes[count..],
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => (),
Err(error) => panic!("PTY write failed: {error}"),
}
assert!(Instant::now() < deadline, "PTY input remained blocked");
if !bytes.is_empty() {
thread::sleep(Duration::from_millis(5));
}
}
}
fn row(term: &Term<Replies>, line: i32) -> String {
(0..40).map(|column| term.grid()[Line(line)][Column(column)].c).collect()
}
fn blank_input(term: &Term<Replies>, mark: char) -> bool {
term.grid().cursor.point.line == Line(0)
&& term.grid().cursor.point.column == Column(2)
&& row(term, 0).trim_end() == mark.to_string()
&& row(term, 1).trim().is_empty()
}
#[test]
fn git_bash_wrapped_input_can_be_fully_erased_in_byte_and_utf8_locales() {
let Some(program) = super::nebula_find_bash() else {
assert!(env::var_os("CI").is_none(), "native Windows CI requires Git Bash");
eprintln!("Git Bash is not installed; native Windows CI runs this regression");
return;
};
let directory = env::temp_dir().join(format!("pebrel-bash-edit-{}", std::process::id()));
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join("pebrel_settings.txt"), "powerline=1\n").unwrap();
let outcome = std::panic::catch_unwind(|| {
for (locale, mark) in [("C", '>'), ("C.UTF-8", '❯')] {
let mut environment = HashMap::from([
("LC_ALL".into(), locale.into()),
("TERM".into(), "xterm-256color".into()),
("NEBULA_BASHRC_SOURCED".into(), "1".into()),
("INPUTRC".into(), "/dev/null".into()),
("HISTFILE".into(), "/dev/null".into()),
]);
for key in ["PEBREL_CONFIG_DIR", "NEBULA_CONFIG_DIR"] {
environment.insert(key.into(), directory.to_string_lossy().into_owned());
}
let options = tty::Options {
shell: Some(tty::bash_with_nebula_integration(program.clone(), Vec::new())),
working_directory: Some(directory.clone()),
env: environment,
..Default::default()
};
let pty = tty::new(
&options,
WindowSize { num_lines: 24, num_cols: 40, cell_width: 8, cell_height: 16 },
0,
)
.expect("create actual Git Bash PTY");
let replies = Replies::default();
// Match the application: OpenConsole's first DA1 was pre-answered
// by the PTY bootstrap, so replying again would type into Bash.
let config = Config {
suppress_bringup_da1: tty::conpty_sideload_enabled(),
conpty_resize: true,
..Default::default()
};
let mut session = BashSession {
pty,
term: Term::new(config, &TermSize::new(40, 24), replies.clone()),
parser: ansi::Processor::new(),
replies,
};
session.wait_for(locale, |term| blank_input(term, mark));
session.send(b"relay/package.json");
session.wait_for("short input", |term| {
row(term, 0).trim_end() == format!("{mark} relay/package.json")
&& term.grid().cursor.point.column == Column(20)
});
session.send(&[127; 18]);
session.wait_for("short backspace", |term| blank_input(term, mark));
let long = b"relay/package.jsonrelay/package.jsonrelay/package.jsonrelay/package.json";
for erase in [vec![127; long.len()], vec![21]] {
session.send(long);
session.wait_for("wrapped input", |term| {
term.grid().cursor.point.line == Line(1)
&& term.grid().cursor.point.column == Column(34)
});
session.send(&[1]);
session.wait_for("wrapped Home", |term| {
term.grid().cursor.point.line == Line(0)
&& term.grid().cursor.point.column == Column(2)
});
session.send(&[5]);
session.wait_for("wrapped End", |term| {
term.grid().cursor.point.line == Line(1)
&& term.grid().cursor.point.column == Column(34)
});
session.send(&erase);
session.wait_for("erase wrapped input", |term| blank_input(term, mark));
}
// No probe command or user history is executed; dropping owns this PTY.
session.send(b"exit\r");
}
});
// The closed host can briefly retain Bash's working directory handle.
let deadline = Instant::now() + Duration::from_secs(5);
while let Err(error) = fs::remove_dir_all(&directory) {
if Instant::now() < deadline
&& (error.kind() == std::io::ErrorKind::PermissionDenied
|| error.raw_os_error() == Some(32))
{
thread::sleep(Duration::from_millis(25));
} else {
panic!("cannot remove probe directory {}: {error}", directory.display());
}
}
if let Err(panic) = outcome {
std::panic::resume_unwind(panic);
}
}
#[test]
fn prompt_width_tracks_locale_changes_without_overriding_user_configuration() {
super::run_bash_integration_case(
"NEBULA_BASHRC_SOURCED=1\nHOME=/__pebrel_missing_home__\nPROMPT_COMMAND=",
r#"
for mode in 0 1; do
__nebula_setting() { printf '%s' "$mode"; }
export LC_ALL=C
__nebula_precmd >/dev/null
[[ $PS1 == *'> '* && $PS1 != *'❯'* && $LC_ALL == C ]] || exit 61
export LC_ALL=C.UTF-8
__nebula_precmd >/dev/null
[[ $PS1 == *'❯ '* && $LC_ALL == C.UTF-8 ]] || exit 62
done
export LC_ALL=C
__nebula_user_ps1=1
PS1='user prompt> '
__nebula_precmd >/dev/null
[[ $PS1 == 'user prompt> ' && $LC_ALL == C ]] || exit 63
"#,
);
}