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:
11 files changed
+159
-63
No files matched your search
+51
-56
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user