T16: a shell saves only when the running user may run it, as access answers, ACLs too, found at a full path or in one of PATH's full folders, never a relative one; a session runs that same full path, so the check and the start agree
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 12m23s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 21:29:54 -04:00
1 parent 3e1535e921
commit 3c51b01955
4 files changed
+87 -33

No files matched your search

+1 -1
View File
@@ -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: 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: 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: empty for the login shell, a file at a full path, or a name on `PATH`; 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. 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.
+72 -31
View File
@@ -1,7 +1,12 @@
mod store;
pub mod window;
use std::{env, ffi::OsStr, ops::RangeInclusive, path::Path};
use std::{
env,
ffi::OsStr,
ops::RangeInclusive,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
@@ -131,7 +136,7 @@ impl Settings {
}
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
anyhow::ensure!(
self.shell.is_empty() || runs(&self.shell, &env::var_os("PATH").unwrap_or_default()),
self.shell.is_empty() || program_path(&self.shell).is_some(),
"shell is {:?}, which names no program",
self.shell
);
@@ -139,35 +144,44 @@ impl Settings {
}
}
/// Whether `shell` names a program a session starts: a file at a full path, or a name in one of `folders`, as `PATH` lists them.
fn runs(shell: &str, folders: &OsStr) -> bool {
let path = Path::new(shell);
if path.is_absolute() {
return program(path);
}
// A relative path would hang on each session's folder.
if path.components().count() != 1 {
return false;
}
env::split_paths(folders).any(|folder| {
let file = folder.join(path);
program(&file) || (cfg!(windows) && program(&file.with_extension("exe")))
})
/// 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> {
find(shell, &env::var_os("PATH").unwrap_or_default())
}
/// Whether `path` is a file a session can run.
/// `shell`'s program, or the first one named so in `folders`, listed as `PATH` lists them.
fn find(shell: &str, folders: &OsStr) -> Option<PathBuf> {
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.
if path.components().count() != 1 {
return None;
}
let files = env::split_paths(folders)
.filter(|folder| folder.is_absolute())
.flat_map(|folder| {
let file = folder.join(path);
let exe = cfg!(windows).then(|| file.with_extension("exe"));
[Some(file), exe]
});
files.flatten().find(|file| program(file))
}
/// Whether `path` is a file the running user may run, as the system answers, ACLs too.
#[cfg(unix)]
fn program(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let run =
|meta: std::fs::Metadata| meta.is_file() && meta.permissions().mode() & 0o111 != 0;
path.metadata().is_ok_and(run)
}
#[cfg(not(unix))]
{
path.is_file()
}
use std::{ffi::CString, os::unix::ffi::OsStrExt};
let Ok(name) = CString::new(path.as_os_str().as_bytes()) else {
return false;
};
path.is_file() && unsafe { libc::access(name.as_ptr(), libc::X_OK) } == 0
}
#[cfg(not(unix))]
fn program(path: &Path) -> bool {
path.is_file()
}
fn within<T: PartialOrd + std::fmt::Display>(
@@ -227,10 +241,12 @@ mod tests {
assert!(shell("bin/sh").validate().is_err(), "a relative path");
#[cfg(unix)]
{
assert!(runs("sh", OsStr::new("/bin")));
let sh = Some(PathBuf::from("/bin/sh"));
assert_eq!(find("sh", OsStr::new("/bin")), sh);
let root = OsStr::new("/");
assert!(
!runs("bin/sh", root),
assert_eq!(
find("bin/sh", root),
None,
"a relative path, though / holds bin/sh"
);
assert!(shell("/bin/sh").validate().is_ok());
@@ -242,6 +258,31 @@ mod tests {
}
}
#[cfg(unix)]
#[test]
fn a_shell_runs_for_this_user_from_a_full_folder() {
use std::{fs, os::unix::fs::PermissionsExt};
let folder = std::env::temp_dir().join(format!("elyterminal-shell-{}", std::process::id()));
fs::create_dir_all(&folder).expect("a test folder");
let file = folder.join("review-shell");
fs::write(&file, "#!/bin/sh\n").expect("a script");
// Root may run what any execute bit allows.
let root = unsafe { libc::geteuid() } == 0;
for (mode, runs) in [(0o644, false), (0o641, root), (0o755, true)] {
fs::set_permissions(&file, fs::Permissions::from_mode(mode)).expect("a mode");
assert_eq!(program(&file), runs, "mode {mode:o}");
}
assert_eq!(find("review-shell", folder.as_os_str()), Some(file.clone()));
}
#[test]
fn a_relative_folder_counts_for_none() {
// Tests run in the crate, whose scripts folder holds a program.
let scripts = OsStr::new("scripts");
assert!(program(Path::new("scripts/check.sh")));
assert_eq!(find("check.sh", scripts), None);
}
#[test]
fn a_nan_is_out_of_range() {
let settings = Settings {
+6 -1
View File
@@ -77,7 +77,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
let path = if shell.is_empty() {
default_shell()?
} else {
shell.to_string()
// The settings checked this path; the session runs no other.
let found = crate::settings::program_path(shell)
.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)
.file_stem()
+8
View File
@@ -247,6 +247,14 @@ fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() {
run.finish();
}
#[test]
fn a_shell_named_bare_runs_from_its_full_path() {
let started = launch("sh", PathBuf::from("/"), Path::new("/nonexistent")).expect("a launch");
let (program, _) = started.program.expect("a program");
assert!(Path::new(&program).is_absolute(), "{program}");
assert!(launch("no-such-shell", PathBuf::from("/"), Path::new("/")).is_err());
}
#[test]
fn an_unknown_shell_starts_without_integration() {
let dir = PathBuf::from("/nonexistent");