T66: a dropped path stays literal for the session's own shell, as its name says: POSIX shells escape ^ and ~ too, which zsh's extendedglob reads; PowerShell, Windows' default, single-quotes it with its quotes doubled, so $, $( ) and ; stay text; cmd double-quotes it and refuses a %, which it expands inside quotes

This commit is contained in:
ZacharyZhang-NY committed 2026-10-19 16:51:51 -04:00
1 parent 545fb46249
commit 887378ade3
11 files changed
+159 -63

No files matched your search

+51 -56
View File
@@ -7,11 +7,25 @@ use gpui::{Context, ExternalPaths};
use super::view::TerminalView;
/// What a shell reads specially in a path, which Ghostty escapes in one dropped.
const SPECIAL: &str = "\\ ()[]{}<>\"'`!#$&;|*?";
/// What a POSIX shell reads specially in a path: Ghostty's list, and zsh's `extendedglob` marks.
const SPECIAL: &str = "\\ ()[]{}<>\"'`!#$&;|*?^~";
/// The text `paths` type, each followed by a space: escaped for a Unix shell, or quoted when it holds a space, as Windows Terminal quotes it.
pub fn typed(paths: &[PathBuf], windows: bool) -> anyhow::Result<String> {
/// PowerShell's single quotes, each one doubled inside a quoted string.
const SINGLE: [char; 5] = ['\'', '\u{2018}', '\u{2019}', '\u{201a}', '\u{201b}'];
/// How a session's shell reads a path typed into it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Quote {
/// sh, bash, zsh, fish: a backslash before each special character.
Posix,
/// Single quotes, which expand nothing.
PowerShell,
/// Double quotes, past which cmd still expands `%`.
Cmd,
}
/// The text `paths` type, each followed by a space, as `quote` keeps each one literal.
pub fn typed(paths: &[PathBuf], quote: Quote) -> anyhow::Result<String> {
paths
.iter()
.map(|path| {
@@ -21,21 +35,34 @@ pub fn typed(paths: &[PathBuf], windows: bool) -> anyhow::Result<String> {
!text.chars().any(char::is_control),
"{text:?} holds a control character, which would act as typed"
);
Ok(if windows && text.contains(' ') {
format!("\"{text}\" ")
} else if windows {
format!("{text} ")
} else {
let mut escaped = String::with_capacity(text.len() + 1);
for ch in text.chars() {
let mut out = String::with_capacity(text.len() + 3);
match quote {
Quote::Posix => text.chars().for_each(|ch| {
if SPECIAL.contains(ch) {
escaped.push('\\');
out.push('\\');
}
escaped.push(ch);
out.push(ch);
}),
Quote::PowerShell => {
out.push('\'');
text.chars().for_each(|ch| {
if SINGLE.contains(&ch) {
out.push(ch);
}
out.push(ch);
});
out.push('\'');
}
escaped.push(' ');
escaped
})
Quote::Cmd => {
anyhow::ensure!(
!text.contains(['%', '"']),
"{text:?} holds % or \", which cmd reads inside quotes"
);
out = format!("\"{text}\"");
}
}
out.push(' ');
Ok(out)
})
.collect()
}
@@ -43,10 +70,14 @@ pub fn typed(paths: &[PathBuf], windows: bool) -> anyhow::Result<String> {
impl TerminalView {
/// Types the dropped paths at the cursor as a paste; a path it cannot type stops the drop.
pub(super) fn drop_paths(&mut self, dropped: &ExternalPaths, cx: &mut Context<Self>) {
let paths = dropped.paths();
match typed(paths, cfg!(windows)) {
let (paths, quote) = (dropped.paths(), self.pty.quote);
match typed(paths, quote) {
Ok(text) => {
log::info!("terminal {}: {} paths dropped", self.pty.pid, paths.len());
log::info!(
"terminal {}: {} paths dropped, quoted for {quote:?}",
self.pty.pid,
paths.len()
);
self.paste_text(&text, cx);
}
Err(error) => log::error!(
@@ -58,40 +89,4 @@ impl TerminalView {
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(all: &[&str]) -> Vec<PathBuf> {
all.iter().map(PathBuf::from).collect()
}
#[test]
fn a_unix_path_escapes_what_a_shell_reads_specially() {
let typed = |all: &[&str]| typed(&paths(all), false).unwrap();
assert_eq!(typed(&["/tmp/a.txt"]), "/tmp/a.txt ");
assert_eq!(typed(&["/tmp/my file.png"]), "/tmp/my\\ file.png ");
assert_eq!(typed(&["/a/it's (1)&$x"]), "/a/it\\'s\\ \\(1\\)\\&\\$x ");
assert_eq!(typed(&["/a/中文 b"]), "/a/中文\\ b ");
assert_eq!(
typed(&["/a", "/b c"]),
"/a /b\\ c ",
"each followed by a space"
);
}
#[test]
fn a_windows_path_is_quoted_when_it_holds_a_space() {
let typed = |all: &[&str]| typed(&paths(all), true).unwrap();
assert_eq!(typed(&["C:\\a.txt"]), "C:\\a.txt ");
assert_eq!(
typed(&["C:\\My Files\\b.txt", "D:\\c"]),
"\"C:\\My Files\\b.txt\" D:\\c "
);
}
#[test]
fn a_path_with_a_control_character_types_nothing() {
let error = typed(&paths(&["/ok", "/tmp/a\nls"]), false).unwrap_err();
assert!(error.to_string().contains("control character"), "{error}");
}
}
mod tests;
+60
View File
@@ -0,0 +1,60 @@
use super::*;
/// What `paths` type for `quote`.
fn typed_as(quote: Quote, all: &[&str]) -> String {
let paths: Vec<PathBuf> = all.iter().map(PathBuf::from).collect();
typed(&paths, quote).unwrap()
}
#[test]
fn a_posix_path_escapes_what_a_shell_reads_specially() {
let typed = |all: &[&str]| typed_as(Quote::Posix, all);
assert_eq!(typed(&["/tmp/a.txt"]), "/tmp/a.txt ");
assert_eq!(typed(&["/tmp/my file.png"]), "/tmp/my\\ file.png ");
assert_eq!(typed(&["/a/it's (1)&$x"]), "/a/it\\'s\\ \\(1\\)\\&\\$x ");
assert_eq!(
typed(&["/a/a^b~c#d"]),
"/a/a\\^b\\~c\\#d ",
"zsh's extendedglob"
);
assert_eq!(typed(&["/a/中文 b"]), "/a/中文\\ b ");
assert_eq!(
typed(&["/a", "/b c"]),
"/a /b\\ c ",
"each followed by a space"
);
}
#[test]
fn a_powershell_path_is_single_quoted_with_its_quotes_doubled() {
let typed = |all: &[&str]| typed_as(Quote::PowerShell, all);
assert_eq!(typed(&["C:\\a.txt"]), "'C:\\a.txt' ");
assert_eq!(typed(&["C:\\tmp\\it's.txt"]), "'C:\\tmp\\it''s.txt' ");
assert_eq!(typed(&["C:\\$HOME $(1+1);x"]), "'C:\\$HOME $(1+1);x' ");
assert_eq!(
typed(&["C:\\a\u{2019}b"]),
"'C:\\a\u{2019}\u{2019}b' ",
"a curly quote"
);
assert_eq!(typed(&["/a b", "/c"]), "'/a b' '/c' ");
}
#[test]
fn a_cmd_path_is_double_quoted_and_one_with_a_percent_types_nothing() {
let typed = |all: &[&str]| typed_as(Quote::Cmd, all);
assert_eq!(
typed(&["C:\\My Files\\a&b^c.txt", "D:\\c"]),
"\"C:\\My Files\\a&b^c.txt\" \"D:\\c\" "
);
let percent = super::typed(&[PathBuf::from("C:\\%PATH%.txt")], Quote::Cmd);
assert!(percent.is_err(), "{percent:?}");
}
#[test]
fn a_path_with_a_control_character_types_nothing() {
for quote in [Quote::Posix, Quote::PowerShell, Quote::Cmd] {
let paths = [PathBuf::from("/ok"), PathBuf::from("/tmp/a\nls")];
let error = typed(&paths, quote).unwrap_err();
assert!(error.to_string().contains("control character"), "{error}");
}
}
+1
View File
@@ -31,6 +31,7 @@ mod view;
mod win32;
pub mod zoom;
pub use drop::Quote;
pub use fonts::{ICONS, MONO, load_font};
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
pub use status::{Kind, Record, Records, State};
+5 -1
View File
@@ -11,7 +11,7 @@ use alacritty_terminal::{
};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use super::{marks::Mark, mouse::MouseMode, notify::Ask, status::Status, tap::Tapped};
use super::{drop::Quote, marks::Mark, mouse::MouseMode, notify::Ask, status::Status, tap::Tapped};
use crate::settings::{CursorStyle, Settings};
/// What a session hears from its pty.
@@ -61,12 +61,15 @@ pub struct Launch {
pub env: Vec<(String, String)>,
/// Shell integration makes it report its folder.
pub reports: bool,
/// How its shell reads a path dropped on it.
pub quote: Quote,
}
/// A running program on a pseudo-terminal and the grid it draws into.
pub struct Pty {
pub term: Arc<FairMutex<Term<Listener>>>,
pub pid: u32,
pub quote: Quote,
notifier: Notifier,
#[cfg(unix)]
master: std::fs::File,
@@ -152,6 +155,7 @@ pub fn spawn(
Pty {
term,
pid,
quote: launch.quote,
notifier,
#[cfg(unix)]
master,
+6 -1
View File
@@ -5,7 +5,7 @@ use alacritty_terminal::{
use futures::StreamExt;
use super::*;
use crate::term::marks::Click;
use crate::term::{drop::Quote, marks::Click};
use crate::term::{
notify::Ask,
status::{Record, State},
@@ -35,6 +35,7 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
cwd: std::env::temp_dir(),
env: vec![("ELY".into(), "ok".into())],
reports: false,
quote: Quote::Posix,
};
let (pty, mut events) =
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
@@ -241,6 +242,7 @@ fn the_foreground_is_the_program_s_own_group() {
cwd: std::env::temp_dir(),
env: Vec::new(),
reports: false,
quote: Quote::Posix,
};
let (pty, _events) =
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
@@ -270,6 +272,7 @@ fn close_after_ready(script: &str, reaped: &str) {
cwd: std::env::temp_dir(),
env: Vec::new(),
reports: false,
quote: Quote::Posix,
};
let (pty, mut events) =
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
@@ -316,6 +319,7 @@ fn a_missing_folder_stops_the_start() {
cwd: "/nonexistent/ely".into(),
env: Vec::new(),
reports: false,
quote: Quote::Posix,
};
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
.err()
@@ -334,6 +338,7 @@ fn a_folder_that_cannot_be_entered_stops_the_start() {
cwd: folder.clone(),
env: Vec::new(),
reports: false,
quote: Quote::Posix,
};
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
+6 -1
View File
@@ -7,7 +7,7 @@ use std::{
use anyhow::Context as _;
use gpui::{App, Global};
use super::pty::Launch;
use super::{drop::Quote, pty::Launch};
/// Each script, by its path under the integration folder.
const SCRIPTS: [(&str, &str); 4] = [
@@ -89,6 +89,11 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
cwd,
env: Vec::new(),
reports: true,
quote: match name.as_str() {
"pwsh" | "powershell" => Quote::PowerShell,
"cmd" => Quote::Cmd,
_ => Quote::Posix,
},
};
let script = |name: &str| dir.join(name).display().to_string();
match name.as_str() {
+21
View File
@@ -437,3 +437,24 @@ fn an_unknown_shell_starts_without_integration() {
assert_eq!(launch.program, Some(("/bin/sh".into(), Vec::new())));
assert!(launch.env.is_empty());
}
#[test]
fn each_shell_reads_a_dropped_path_its_own_way() {
use std::os::unix::fs::PermissionsExt;
let dir = folder("quote");
for (name, quote) in [
("zsh", Quote::Posix),
("fish", Quote::Posix),
("pwsh", Quote::PowerShell),
("powershell", Quote::PowerShell),
("cmd", Quote::Cmd),
("other", Quote::Posix),
] {
let program = dir.join(name);
fs::write(&program, "").expect("a program");
fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).expect("its mode");
let shell = program.to_str().expect("utf-8");
let launch = launch(shell, PathBuf::from("/"), &dir).expect("a launch");
assert_eq!(launch.quote, quote, "{name}");
}
}