T05: fish search path, PowerShell policy and load failure, encoded OSC 7 paths, kept exit status, install read errors
This commit is contained in:
6 files changed
+230
-110
No files matched your search
@@ -13,6 +13,16 @@ else
|
||||
[ -r ~/.bashrc ] && builtin source ~/.bashrc
|
||||
fi
|
||||
_ely_prompt() {
|
||||
builtin printf '\e]7;file://%s\a\e]2;%s\a' "${PWD//\%/%25}" "${PWD##*/}"
|
||||
builtin local ret=$? LC_ALL=C char url= i title
|
||||
for (( i = 0; i < ${#PWD}; i++ )); do
|
||||
char=${PWD:i:1}
|
||||
case $char in
|
||||
[[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;;
|
||||
esac
|
||||
url+=$char
|
||||
done
|
||||
title=${PWD##*/}
|
||||
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${title//[[:cntrl:]]/}"
|
||||
builtin return $ret
|
||||
}
|
||||
PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
|
||||
@@ -7,6 +7,6 @@ end
|
||||
set -e ELY_FISH_XDG_DATA_DIRS
|
||||
if status is-interactive
|
||||
function _ely_prompt --on-event fish_prompt
|
||||
printf '\e]7;file://%s\a' (string replace -a % %25 -- $PWD)
|
||||
printf '\e]7;file://%s\a' (string escape --style=url -- $PWD)
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,15 @@
|
||||
# ELYTerminal: wrap the prompt to report folder and title.
|
||||
$global:ElyPrompt = $function:prompt
|
||||
function global:prompt {
|
||||
$text = & $global:ElyPrompt
|
||||
$folder = $executionContext.SessionState.Path.CurrentLocation
|
||||
if ($folder.Provider.Name -eq 'FileSystem') {
|
||||
$path = $folder.ProviderPath.Replace('\', '/').Replace('%', '%25')
|
||||
$parts = $folder.ProviderPath.Replace('\', '/') -split '/'
|
||||
$path = ($parts | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/'
|
||||
if (-not $path.StartsWith('/')) { $path = "/$path" }
|
||||
$leaf = Split-Path -Leaf $folder.ProviderPath
|
||||
$leaf = (Split-Path -Leaf $folder.ProviderPath) -replace '\p{Cc}', ''
|
||||
$esc, $bel = [char]27, [char]7
|
||||
[Console]::Write("$esc]7;file://$path$bel$esc]2;$leaf$bel")
|
||||
}
|
||||
& $global:ElyPrompt
|
||||
$text
|
||||
}
|
||||
@@ -8,7 +8,15 @@ fi
|
||||
[[ -r "${ZDOTDIR:-$HOME}/.zshenv" ]] && builtin source "${ZDOTDIR:-$HOME}/.zshenv"
|
||||
if [[ -o interactive ]]; then
|
||||
_ely_precmd() {
|
||||
builtin printf '\e]7;file://%s\a\e]2;%s\a' "${PWD//\%/%25}" "${${PWD:t}:-/}"
|
||||
builtin local ret=$? LC_ALL=C char url=
|
||||
for char in ${(s::)PWD}; do
|
||||
case $char in
|
||||
[[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;;
|
||||
esac
|
||||
url+=$char
|
||||
done
|
||||
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${${${PWD:t}:-/}//[[:cntrl:]]/}"
|
||||
builtin return ret
|
||||
}
|
||||
_ely_preexec() {
|
||||
builtin printf '\e]2;%s\a' "${1//[[:cntrl:]]/}"
|
||||
|
||||
+18
-6
@@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
env, fs,
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
@@ -50,8 +51,14 @@ impl ShellIntegration {
|
||||
pub fn install(dir: &Path) -> anyhow::Result<()> {
|
||||
for (name, text) in SCRIPTS {
|
||||
let path = dir.join(name);
|
||||
if fs::read_to_string(&path).is_ok_and(|kept| kept == text) {
|
||||
continue;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(kept) if kept == text => continue,
|
||||
Ok(_) => {}
|
||||
Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::InvalidData) => {}
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("shell integration: cannot read {}", path.display()));
|
||||
}
|
||||
}
|
||||
let folder = path
|
||||
.parent()
|
||||
@@ -98,11 +105,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
|
||||
}
|
||||
"fish" => {
|
||||
let previous = env::var("XDG_DATA_DIRS").unwrap_or_default();
|
||||
let data = if previous.is_empty() {
|
||||
format!("{}:/usr/local/share:/usr/share", script("fish"))
|
||||
let rest = if previous.is_empty() {
|
||||
"/usr/local/share:/usr/share"
|
||||
} else {
|
||||
format!("{}:{previous}", script("fish"))
|
||||
&previous
|
||||
};
|
||||
let data = format!("{}:{rest}", dir.display());
|
||||
launch.env.push(("XDG_DATA_DIRS".into(), data));
|
||||
launch.env.push(("ELY_FISH_XDG_DATA_DIRS".into(), previous));
|
||||
}
|
||||
@@ -113,8 +121,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
|
||||
vec![
|
||||
"-NoLogo".into(),
|
||||
"-NoExit".into(),
|
||||
"-ExecutionPolicy".into(),
|
||||
"Bypass".into(),
|
||||
"-Command".into(),
|
||||
format!(". '{file}'"),
|
||||
format!(
|
||||
"try {{ . '{file}' }} catch {{ [Console]::Error.WriteLine(\"ELYTerminal: shell integration failed: $_\"); exit 1 }}"
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
+186
-98
@@ -1,10 +1,13 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use alacritty_terminal::event::Event;
|
||||
use alacritty_terminal::{event::Event, grid::Dimensions, index::Line};
|
||||
use futures::channel::mpsc::TryRecvError;
|
||||
|
||||
use super::*;
|
||||
use crate::term::{PtyEvent, spawn};
|
||||
use crate::term::{Pty, PtyEvent, spawn};
|
||||
|
||||
/// A folder name with a space, a percent, a bell and a newline.
|
||||
const INNER: &str = "in ner%\u{7}x\ny";
|
||||
|
||||
/// A fresh folder under the temp dir, by its real path.
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
@@ -21,88 +24,135 @@ fn installed(shell: &str) -> String {
|
||||
.args(["-c", &format!("command -v {shell}")])
|
||||
.output()
|
||||
.expect("sh runs");
|
||||
let path = String::from_utf8(found.stdout).expect("utf-8");
|
||||
assert!(found.status.success(), "{shell} is not installed");
|
||||
path.trim().to_string()
|
||||
String::from_utf8(found.stdout)
|
||||
.expect("utf-8")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Starts `shell` with integration in a fresh folder, cds once, and returns folders and titles it reported.
|
||||
fn reports(shell: &str) -> (PathBuf, Vec<PathBuf>, Vec<String>) {
|
||||
crate::testlog::init();
|
||||
let root = folder(&format!(
|
||||
"shell-{}",
|
||||
shell.rsplit('/').next().expect("a name")
|
||||
));
|
||||
let (dir, home, start) = (
|
||||
root.join("integration"),
|
||||
root.join("home"),
|
||||
root.join("start"),
|
||||
);
|
||||
let inner = start.join("in ner%");
|
||||
for made in [&home, &inner] {
|
||||
fs::create_dir_all(made).expect("a folder");
|
||||
}
|
||||
install(&dir).expect("scripts install");
|
||||
let mut launch = launch(shell, start.clone(), &dir).expect("a launch");
|
||||
launch.env.push(("HOME".into(), home.display().to_string()));
|
||||
let mut pty = spawn(&launch, (80, 24), (8, 16), 100).expect("the shell starts");
|
||||
let (mut folders, mut titles) = (Vec::new(), Vec::new());
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let mut asked = false;
|
||||
while !folders.contains(&inner) {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"{shell} reported {folders:?} and {titles:?} on\n{}",
|
||||
screen(&pty)
|
||||
/// A shell started with integration under a fake HOME, and what it reported.
|
||||
struct Run {
|
||||
shell: String,
|
||||
pty: Pty,
|
||||
root: PathBuf,
|
||||
start: PathBuf,
|
||||
folders: Vec<PathBuf>,
|
||||
titles: Vec<String>,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// Starts `shell` in a fresh folder; `files` go under its HOME first.
|
||||
fn start(shell: &str, files: &[(&str, &str)]) -> Self {
|
||||
crate::testlog::init();
|
||||
let root = folder(&format!(
|
||||
"shell-{}",
|
||||
shell.rsplit('/').next().expect("a name")
|
||||
));
|
||||
let (dir, home, start) = (
|
||||
root.join("integration"),
|
||||
root.join("home"),
|
||||
root.join("start"),
|
||||
);
|
||||
match pty.events.try_recv() {
|
||||
Ok(PtyEvent::Folder(folder)) => folders.push(folder),
|
||||
Ok(PtyEvent::Term(Event::Title(title))) => titles.push(title),
|
||||
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => pty.write(reply.into_bytes()),
|
||||
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
|
||||
panic!("{shell} ended, {status}, after {folders:?}")
|
||||
fs::create_dir_all(start.join(INNER)).expect("the inner folder");
|
||||
fs::create_dir_all(&home).expect("home");
|
||||
for (name, text) in files {
|
||||
let path = home.join(name);
|
||||
fs::create_dir_all(path.parent().expect("a folder")).expect("a home folder");
|
||||
fs::write(path, text).expect("a home file");
|
||||
}
|
||||
install(&dir).expect("scripts install");
|
||||
let mut launch = launch(shell, start.clone(), &dir).expect("a launch");
|
||||
launch.env.push(("HOME".into(), home.display().to_string()));
|
||||
let pty = spawn(&launch, (100, 30), (8, 16), 100).expect("the shell starts");
|
||||
Self {
|
||||
shell: shell.into(),
|
||||
pty,
|
||||
root,
|
||||
start,
|
||||
folders: Vec::new(),
|
||||
titles: Vec::new(),
|
||||
deadline: Instant::now() + Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads events, answering the shell's terminal queries, until `done` holds.
|
||||
fn until(&mut self, what: &str, done: impl Fn(&Self) -> bool) {
|
||||
while !done(self) {
|
||||
assert!(
|
||||
Instant::now() < self.deadline,
|
||||
"{}: no {what}; folders {:?}, titles {:?}, screen\n{}",
|
||||
self.shell,
|
||||
self.folders,
|
||||
self.titles,
|
||||
self.screen()
|
||||
);
|
||||
match self.pty.events.try_recv() {
|
||||
Ok(PtyEvent::Folder(folder)) => self.folders.push(folder),
|
||||
Ok(PtyEvent::Term(Event::Title(title))) => self.titles.push(title),
|
||||
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
|
||||
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
|
||||
panic!("{} ended, {status}:\n{}", self.shell, self.screen())
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(TryRecvError::Closed) => panic!("{}'s events ended", self.shell),
|
||||
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(TryRecvError::Closed) => panic!("{shell}'s events ended"),
|
||||
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
if !asked && folders.contains(&start) {
|
||||
pty.write(format!("cd '{}'\r", inner.display()).into_bytes());
|
||||
asked = true;
|
||||
}
|
||||
}
|
||||
pty.write(&b"exit\r"[..]);
|
||||
loop {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"{shell} did not exit:\n{}",
|
||||
screen(&pty)
|
||||
);
|
||||
match pty.events.try_recv() {
|
||||
Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break,
|
||||
Ok(PtyEvent::Term(Event::Title(title))) => titles.push(title),
|
||||
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => pty.write(reply.into_bytes()),
|
||||
Ok(_) => {}
|
||||
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
}
|
||||
drop(pty);
|
||||
fs::remove_dir_all(&root).expect("cleanup");
|
||||
(start, folders, titles)
|
||||
}
|
||||
|
||||
/// The screen's rows, for a failure's message.
|
||||
fn screen(pty: &crate::term::Pty) -> String {
|
||||
use alacritty_terminal::{grid::Dimensions, index::Line};
|
||||
let term = pty.term.lock();
|
||||
(0..term.screen_lines())
|
||||
.map(|line| {
|
||||
let row = &term.grid()[Line(line as i32)];
|
||||
let text: String = row.into_iter().map(|cell| cell.c).collect();
|
||||
text.trim_end().to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
fn send(&self, line: &str) {
|
||||
self.pty.write(format!("{line}\r").into_bytes());
|
||||
}
|
||||
|
||||
fn shows(&self, text: &str) -> bool {
|
||||
self.screen().contains(text)
|
||||
}
|
||||
|
||||
fn screen(&self) -> String {
|
||||
let term = self.pty.term.lock();
|
||||
(0..term.screen_lines())
|
||||
.map(|line| {
|
||||
let row = &term.grid()[Line(line as i32)];
|
||||
let text: String = row.into_iter().map(|cell| cell.c).collect();
|
||||
text.trim_end().to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder.
|
||||
fn walk(&mut self, probe: &str) {
|
||||
let start = self.start.clone();
|
||||
self.until("first folder", |run| run.folders.contains(&start));
|
||||
assert_eq!(self.folders[0], start);
|
||||
self.send(probe);
|
||||
self.until("loaded script", |run| run.shows("ELY-LOADED"));
|
||||
self.send("cd in*");
|
||||
let inner = start.join(INNER);
|
||||
self.until("inner folder", |run| run.folders.contains(&inner));
|
||||
}
|
||||
|
||||
fn finish(mut self) {
|
||||
self.send("exit");
|
||||
loop {
|
||||
assert!(
|
||||
Instant::now() < self.deadline,
|
||||
"{} did not exit",
|
||||
self.shell
|
||||
);
|
||||
match self.pty.events.try_recv() {
|
||||
Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break,
|
||||
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
|
||||
Ok(_) => {}
|
||||
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
}
|
||||
let root = self.root.clone();
|
||||
drop(self);
|
||||
fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -111,46 +161,84 @@ fn the_scripts_install_once_and_mend_a_changed_one() {
|
||||
install(&dir).expect("installs");
|
||||
let zshenv = dir.join("zsh/.zshenv");
|
||||
assert_eq!(fs::read_to_string(&zshenv).expect("written"), SCRIPTS[0].1);
|
||||
fs::write(&zshenv, "changed").expect("write");
|
||||
fs::write(&zshenv, [0xff, 0xfe]).expect("write");
|
||||
install(&dir).expect("installs again");
|
||||
assert_eq!(fs::read_to_string(&zshenv).expect("mended"), SCRIPTS[0].1);
|
||||
fs::remove_dir_all(dir).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zsh_reports_its_folder_and_title() {
|
||||
let (start, folders, titles) = reports(&installed("zsh"));
|
||||
assert_eq!(folders[0], start);
|
||||
assert!(titles.contains(&"start".to_string()), "{titles:?}");
|
||||
fn a_script_it_cannot_read_fails_the_install() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = folder("unreadable");
|
||||
install(&dir).expect("installs");
|
||||
let bash = dir.join("bash/elyterminal.bash");
|
||||
fs::set_permissions(&bash, fs::Permissions::from_mode(0o200)).expect("200");
|
||||
let error = install(&dir).expect_err("an unreadable script fails");
|
||||
fs::set_permissions(&bash, fs::Permissions::from_mode(0o600)).expect("600");
|
||||
assert!(
|
||||
titles.iter().any(|title| title.starts_with("cd ")),
|
||||
"{titles:?}"
|
||||
format!("{error:#}").contains("bash/elyterminal.bash"),
|
||||
"{error:#}"
|
||||
);
|
||||
fs::remove_dir_all(dir).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_reports_its_folder_and_title() {
|
||||
let (start, folders, titles) = reports(&installed("bash"));
|
||||
assert_eq!(folders[0], start);
|
||||
assert!(titles.contains(&"in ner%".to_string()), "{titles:?}");
|
||||
fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
|
||||
let mut run = Run::start(&installed("zsh"), &[]);
|
||||
run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED");
|
||||
run.until("title", |run| {
|
||||
run.titles.iter().any(|title| title == "in ner%xy")
|
||||
});
|
||||
assert!(
|
||||
run.titles.contains(&"cd in*".to_string()),
|
||||
"{:?}",
|
||||
run.titles
|
||||
);
|
||||
run.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fish_reports_its_folder_and_keeps_its_own_title() {
|
||||
let (start, folders, titles) = reports(&installed("fish"));
|
||||
assert_eq!(folders[0], start);
|
||||
assert!(
|
||||
titles.iter().any(|title| title.ends_with("start")),
|
||||
"{titles:?}"
|
||||
);
|
||||
fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() {
|
||||
let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'"#;
|
||||
let files = [(".bashrc", status), (".bash_profile", status)];
|
||||
let mut run = Run::start(&installed("bash"), &files);
|
||||
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
||||
run.until("title", |run| {
|
||||
run.titles.iter().any(|title| title == "in ner%xy")
|
||||
});
|
||||
run.send("false");
|
||||
run.until("kept status", |run| run.shows("USER_STATUS=1"));
|
||||
run.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fish_loads_its_script_and_restores_its_data_dirs() {
|
||||
let mut run = Run::start(&installed("fish"), &[]);
|
||||
run.walk("functions -q _ely_prompt; and echo ELY-LOAD''ED");
|
||||
let previous = env::var("XDG_DATA_DIRS").unwrap_or_default();
|
||||
run.send(r#"echo "DIRS[$ELY_FISH_XDG_DATA_DIRS][$XDG_DATA_DIRS]""#);
|
||||
let restored = format!("DIRS[][{previous}]");
|
||||
run.until("restored dirs", |run| run.shows(&restored));
|
||||
run.finish();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn powershell_reports_its_folder_and_title() {
|
||||
let (start, folders, titles) = reports(&installed("pwsh"));
|
||||
assert_eq!(folders[0], start);
|
||||
assert!(titles.contains(&"in ner%".to_string()), "{titles:?}");
|
||||
fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() {
|
||||
let prompt = r#"function global:prompt { "USER_SUCCESS=$? > " }"#;
|
||||
let files = [(
|
||||
".config/powershell/Microsoft.PowerShell_profile.ps1",
|
||||
prompt,
|
||||
)];
|
||||
let mut run = Run::start(&installed("pwsh"), &files);
|
||||
run.walk("if ($global:ElyPrompt) { 'ELY-LOAD' + 'ED' }");
|
||||
run.until("title", |run| {
|
||||
run.titles.iter().any(|title| title == "in ner%xy")
|
||||
});
|
||||
run.send("/usr/bin/false");
|
||||
run.until("kept status", |run| run.shows("USER_SUCCESS=False"));
|
||||
run.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user