diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index 67c0f83..b64d25a 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -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 + ?Sized>(value: &S) -> Vec { #[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= diff --git a/nebula_terminal/src/tty/windows/test/bash_input.rs b/nebula_terminal/src/tty/windows/test/bash_input.rs new file mode 100644 index 0000000..6078d76 --- /dev/null +++ b/nebula_terminal/src/tty/windows/test/bash_input.rs @@ -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>>); + +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, + 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) -> 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, line: i32) -> String { + (0..40).map(|column| term.grid()[Line(line)][Column(column)].c).collect() +} + +fn blank_input(term: &Term, 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 +"#, + ); +}