T16: on Windows a shell named without an extension means its .exe, and one with an extension keeps it; the resolver hands the session its program as UTF-8 text, so a program whose path is not UTF-8 never saves
This commit is contained in:
4 files changed
+94
-42
No files matched your search
@@ -65,5 +65,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press.
|
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press.
|
||||||
- 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Paths stay git's bytes until shown, so a name not in UTF-8 still names its own file; off Unix git writes UTF-8. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path; a copy is a new file, its source no part of it. A diff is git's own, without external tools or text converters, a conflict against HEAD, our side, so a file we deleted reads as new; it is read into Ely's stretches; a binary file has none, and an untracked folder none either. Without an index git exits 1 for a difference and a failure alike; only a difference prints. What git warns of while it works goes to the log. The log needs a commit at HEAD: git refuses it before the first.
|
- 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Paths stay git's bytes until shown, so a name not in UTF-8 still names its own file; off Unix git writes UTF-8. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path; a copy is a new file, its source no part of it. A diff is git's own, without external tools or text converters, a conflict against HEAD, our side, so a file we deleted reads as new; it is read into Ely's stretches; a binary file has none, and an untracked folder none either. Without an index git exits 1 for a difference and a failure alike; only a difference prints. What git warns of while it works goes to the log. The log needs a commit at HEAD: git refuses it before the first.
|
||||||
- 2026-10-14: the Git panel shows HEAD's branch, or its short commit while detached; Ely's `ChangesList` of conflicted, staged, unstaged and untracked files; Ely's `CommitInput`; Ely's `CommitList` of the last 50 commits. A row's press opens its diff in Ely's fullscreen `Dialog` under the title bar, in Ely's `DiffViewer`, unified or split; a binary file, an untracked folder and a change of no line say so instead. Every control Ely draws works: stage, unstage, stage all, unstage all, amend, and discard, which asks first in Ely's `ConfirmDialog`: an edit goes back to the index (`git restore`); a new file, untracked or only intended to be added, is deleted (`git clean`, `git rm -f`). Git's clean passes over a repository of its own without a word, so a clean that removed nothing is an error. Actions run off the main thread, each after the one asked before it; a failure shows in Ely's `Alert` with git's words until dismissed or the next action, and after any action Files reads its marks again. A folder change drops the open diff, and one still loading, and blanks the view unless the new folder lies inside the shown work tree, so a cd within it does not blink; no action runs until the shown folder has read, so none reaches the repository left behind. A commit empties only the fields that still hold what it committed, and only once the shown folder has read and is in the same repository: a draft typed while it ran, or while a folder read, stays. A path shows on one line and once: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8; a folder drops git's trailing slash, since no file shares its name. A section lists 500 files at most, and a line says how many are listed: Ely's list draws every row, and gpui copies every row's hitboxes and paint each frame, so 3000 rows halved the terminal's frames. The Git body is a cached view, so a terminal's output does not draw it again; it comes back from the Files tab with its lists scrolled to the top, since a view cached while hidden drops its widgets' state. Files stays uncached, so its tree keeps its open folders: Ely's `Tree` draws only the rows in view.
|
- 2026-10-14: the Git panel shows HEAD's branch, or its short commit while detached; Ely's `ChangesList` of conflicted, staged, unstaged and untracked files; Ely's `CommitInput`; Ely's `CommitList` of the last 50 commits. A row's press opens its diff in Ely's fullscreen `Dialog` under the title bar, in Ely's `DiffViewer`, unified or split; a binary file, an untracked folder and a change of no line say so instead. Every control Ely draws works: stage, unstage, stage all, unstage all, amend, and discard, which asks first in Ely's `ConfirmDialog`: an edit goes back to the index (`git restore`); a new file, untracked or only intended to be added, is deleted (`git clean`, `git rm -f`). Git's clean passes over a repository of its own without a word, so a clean that removed nothing is an error. Actions run off the main thread, each after the one asked before it; a failure shows in Ely's `Alert` with git's words until dismissed or the next action, and after any action Files reads its marks again. A folder change drops the open diff, and one still loading, and blanks the view unless the new folder lies inside the shown work tree, so a cd within it does not blink; no action runs until the shown folder has read, so none reaches the repository left behind. A commit empties only the fields that still hold what it committed, and only once the shown folder has read and is in the same repository: a draft typed while it ran, or while a folder read, stays. A path shows on one line and once: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8; a folder drops git's trailing slash, since no file shares its name. A section lists 500 files at most, and a line says how many are listed: Ely's list draws every row, and gpui copies every row's hitboxes and paint each frame, so 3000 rows halved the terminal's frames. The Git body is a cached view, so a terminal's output does not draw it again; it comes back from the Files tab with its lists scrolled to the top, since a view cached while hidden drops its widgets' state. Files stays uncached, so its tree keeps its open folders: Ely's `Tree` draws only the rows in view.
|
||||||
- 2026-10-14: Cmd-, opens the settings window, or brings the open one forward, and Cmd-W closes it; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. Categories sit in Ely's `SettingsLayout`, rows in its `SettingsRow`; a category joins the list with its rows. Each change goes through `SettingsStore::update`, which checks and saves, and every view reads the global, so it applies at once. A shell saves only when it names a program the running user may run, as the system's `access` answers, ACLs too: empty for the login shell, a file at a full path, or a name found in one of `PATH`'s full folders; a relative folder counts for none, since it would hang on each session's own folder. A session runs that same full path, so the check and the start agree; else the row says why and the saved one stays, since a bad shell stops the next start. The window takes focus inside when it opens, so its keys find its context. The app quits with its last terminal window: settings alone hold no terminal.
|
- 2026-10-14: Cmd-, opens the settings window, or brings the open one forward, and Cmd-W closes it; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. Categories sit in Ely's `SettingsLayout`, rows in its `SettingsRow`; a category joins the list with its rows. Each change goes through `SettingsStore::update`, which checks and saves, and every view reads the global, so it applies at once. A shell saves only when it names a program the running user may run, as the system's `access` answers, ACLs too: empty for the login shell, a file at a full path, or a name found in one of `PATH`'s full folders; a relative folder counts for none, since it would hang on each session's own folder. On Windows a name without an extension means its `.exe`, as `CreateProcess` reads it; a name with one keeps it. A program whose path is not UTF-8 counts for none: a session starts its program by text. A session runs that same full path, so the check and the start agree; else the row says why and the saved one stays, since a bad shell stops the next start. The window takes focus inside when it opens, so its keys find its context. The app quits with its last terminal window: settings alone hold no terminal.
|
||||||
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||||
+91
-36
@@ -1,13 +1,9 @@
|
|||||||
mod store;
|
mod store;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
|
|
||||||
use std::{
|
use std::{env, ffi::OsStr, ops::RangeInclusive, path::Path};
|
||||||
env,
|
|
||||||
ffi::OsStr,
|
|
||||||
ops::RangeInclusive,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::themes;
|
use crate::themes;
|
||||||
@@ -135,38 +131,46 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
||||||
anyhow::ensure!(
|
if !self.shell.is_empty() {
|
||||||
self.shell.is_empty() || program_path(&self.shell).is_some(),
|
program_path(&self.shell)?;
|
||||||
"shell is {:?}, which names no program",
|
}
|
||||||
self.shell
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The program `shell` names, as a session starts it: a file at a full path, or the first in `PATH`'s full folders.
|
/// The program `shell` names, as a session starts it: a file at a full path, or the first in `PATH`'s full folders.
|
||||||
pub fn program_path(shell: &str) -> Option<PathBuf> {
|
pub fn program_path(shell: &str) -> anyhow::Result<String> {
|
||||||
find(shell, &env::var_os("PATH").unwrap_or_default())
|
let folders = env::var_os("PATH").unwrap_or_default();
|
||||||
|
find(shell, &folders, cfg!(windows))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `shell`'s program, or the first one named so in `folders`, listed as `PATH` lists them.
|
/// `shell`'s program, or the first one named so in `folders`, listed as `PATH` lists them; with `exe`, as on Windows, a name without an extension means name.exe.
|
||||||
fn find(shell: &str, folders: &OsStr) -> Option<PathBuf> {
|
fn find(shell: &str, folders: &OsStr, exe: bool) -> anyhow::Result<String> {
|
||||||
let path = Path::new(shell);
|
let path = Path::new(shell);
|
||||||
if path.is_absolute() {
|
|
||||||
return program(path).then(|| path.to_path_buf());
|
|
||||||
}
|
|
||||||
// A relative path, or a relative folder, would hang on each session's own folder.
|
// A relative path, or a relative folder, would hang on each session's own folder.
|
||||||
if path.components().count() != 1 {
|
let found = if path.is_absolute() {
|
||||||
return None;
|
program(path).then(|| path.to_path_buf())
|
||||||
}
|
} else if path.components().count() == 1 {
|
||||||
let files = env::split_paths(folders)
|
let name = if exe && !shell.contains('.') {
|
||||||
.filter(|folder| folder.is_absolute())
|
format!("{shell}.exe")
|
||||||
.flat_map(|folder| {
|
} else {
|
||||||
let file = folder.join(path);
|
shell.to_string()
|
||||||
let exe = cfg!(windows).then(|| file.with_extension("exe"));
|
};
|
||||||
[Some(file), exe]
|
env::split_paths(folders)
|
||||||
});
|
.filter(|folder| folder.is_absolute())
|
||||||
files.flatten().find(|file| program(file))
|
.map(|folder| folder.join(&name))
|
||||||
|
.find(|file| program(file))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let found = found.with_context(|| format!("shell is {shell:?}, which names no program"))?;
|
||||||
|
let text = found.to_str().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"shell is {shell:?}, found at {}, which is not UTF-8",
|
||||||
|
found.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(text.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `path` is a file the running user may run, as the system answers, ACLs too.
|
/// Whether `path` is a file the running user may run, as the system answers, ACLs too.
|
||||||
@@ -241,12 +245,11 @@ mod tests {
|
|||||||
assert!(shell("bin/sh").validate().is_err(), "a relative path");
|
assert!(shell("bin/sh").validate().is_err(), "a relative path");
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
let sh = Some(PathBuf::from("/bin/sh"));
|
let sh = find("sh", OsStr::new("/bin"), false).expect("sh");
|
||||||
assert_eq!(find("sh", OsStr::new("/bin")), sh);
|
assert_eq!(sh, "/bin/sh");
|
||||||
let root = OsStr::new("/");
|
let root = OsStr::new("/");
|
||||||
assert_eq!(
|
assert!(
|
||||||
find("bin/sh", root),
|
find("bin/sh", root, false).is_err(),
|
||||||
None,
|
|
||||||
"a relative path, though / holds bin/sh"
|
"a relative path, though / holds bin/sh"
|
||||||
);
|
);
|
||||||
assert!(shell("/bin/sh").validate().is_ok());
|
assert!(shell("/bin/sh").validate().is_ok());
|
||||||
@@ -272,7 +275,59 @@ mod tests {
|
|||||||
fs::set_permissions(&file, fs::Permissions::from_mode(mode)).expect("a mode");
|
fs::set_permissions(&file, fs::Permissions::from_mode(mode)).expect("a mode");
|
||||||
assert_eq!(program(&file), runs, "mode {mode:o}");
|
assert_eq!(program(&file), runs, "mode {mode:o}");
|
||||||
}
|
}
|
||||||
assert_eq!(find("review-shell", folder.as_os_str()), Some(file.clone()));
|
let found = find("review-shell", folder.as_os_str(), false).expect("a program");
|
||||||
|
assert_eq!(Path::new(&found), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exe_joins_only_a_name_without_an_extension() {
|
||||||
|
use std::fs;
|
||||||
|
let folder = env::temp_dir().join(format!("elyterminal-exe-{}", std::process::id()));
|
||||||
|
fs::create_dir_all(&folder).expect("a test folder");
|
||||||
|
let file = folder.join("review.exe");
|
||||||
|
fs::write(&file, "").expect("a program");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).expect("755");
|
||||||
|
}
|
||||||
|
let folders = folder.as_os_str();
|
||||||
|
for name in ["review", "review.exe"] {
|
||||||
|
let found = find(name, folders, true).expect("review.exe");
|
||||||
|
assert_eq!(Path::new(&found), file, "{name}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
find("review.com", folders, true).is_err(),
|
||||||
|
"its own extension"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
find("review", folders, false).is_err(),
|
||||||
|
"no .exe off Windows"
|
||||||
|
);
|
||||||
|
fs::remove_dir_all(&folder).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn a_program_at_a_path_not_in_utf_8_counts_for_none() {
|
||||||
|
use std::{
|
||||||
|
ffi::OsString,
|
||||||
|
fs,
|
||||||
|
os::unix::{ffi::OsStringExt, fs::PermissionsExt},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
let folder = env::temp_dir().join(format!("elyterminal-bytes-{}-", std::process::id()));
|
||||||
|
let mut name = folder.into_os_string().into_vec();
|
||||||
|
name.push(0xff);
|
||||||
|
let folder = PathBuf::from(OsString::from_vec(name));
|
||||||
|
fs::create_dir_all(&folder).expect("a folder named in raw bytes");
|
||||||
|
let file = folder.join("review-shell");
|
||||||
|
fs::write(&file, "#!/bin/sh\n").expect("a script");
|
||||||
|
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).expect("755");
|
||||||
|
assert!(program(&file), "the file runs");
|
||||||
|
let error = find("review-shell", folder.as_os_str(), false).expect_err("not UTF-8");
|
||||||
|
assert!(error.to_string().ends_with("which is not UTF-8"), "{error}");
|
||||||
|
fs::remove_dir_all(&folder).expect("cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -280,7 +335,7 @@ mod tests {
|
|||||||
// Tests run in the crate, whose scripts folder holds a program.
|
// Tests run in the crate, whose scripts folder holds a program.
|
||||||
let scripts = OsStr::new("scripts");
|
let scripts = OsStr::new("scripts");
|
||||||
assert!(program(Path::new("scripts/check.sh")));
|
assert!(program(Path::new("scripts/check.sh")));
|
||||||
assert_eq!(find("check.sh", scripts), None);
|
assert!(find("check.sh", scripts, false).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1
-5
@@ -78,11 +78,7 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
|
|||||||
default_shell()?
|
default_shell()?
|
||||||
} else {
|
} else {
|
||||||
// The settings checked this path; the session runs no other.
|
// The settings checked this path; the session runs no other.
|
||||||
let found = crate::settings::program_path(shell)
|
crate::settings::program_path(shell).context("shell integration")?
|
||||||
.with_context(|| format!("shell integration: {shell:?} names no program"))?;
|
|
||||||
(found.to_str())
|
|
||||||
.with_context(|| format!("shell integration: {} is not UTF-8", found.display()))?
|
|
||||||
.to_string()
|
|
||||||
};
|
};
|
||||||
let name = Path::new(&path)
|
let name = Path::new(&path)
|
||||||
.file_stem()
|
.file_stem()
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() {
|
|||||||
fn a_shell_named_bare_runs_from_its_full_path() {
|
fn a_shell_named_bare_runs_from_its_full_path() {
|
||||||
let started = launch("sh", PathBuf::from("/"), Path::new("/nonexistent")).expect("a launch");
|
let started = launch("sh", PathBuf::from("/"), Path::new("/nonexistent")).expect("a launch");
|
||||||
let (program, _) = started.program.expect("a program");
|
let (program, _) = started.program.expect("a program");
|
||||||
|
assert_eq!(program, crate::settings::program_path("sh").expect("sh"));
|
||||||
assert!(Path::new(&program).is_absolute(), "{program}");
|
assert!(Path::new(&program).is_absolute(), "{program}");
|
||||||
assert!(launch("no-such-shell", PathBuf::from("/"), Path::new("/")).is_err());
|
assert!(launch("no-such-shell", PathBuf::from("/"), Path::new("/")).is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user