T29: inside herdr the right column follows herdr's focused pane: herdr keeps its panes' OSC 7, so on macOS and Linux a session that finds herdr's client leading its pty's foreground reads the focused pane's folder from herdr's socket, found as herdr 0.9 finds it
This commit is contained in:
11 files changed
+606
-7
No files matched your search
@@ -33,7 +33,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/macos` gives gpui's NSVisualEffectView a material that blurs; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page, `window/terminal` its Terminal page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser; `herdr` follows herdr's focused pane through herdr's socket while herdr's client leads the pty's foreground. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
|
||||
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
@@ -92,3 +92,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-15: a release's macOS disk image comes from the Release workflow, dispatched once the draft holds the Windows and Linux builds. It runs on `macos-signing`, the Mac mini's runner in the owner's login session, whose keychain holds the Developer ID identity; at the owner's choice that runner moved from AgentCompany alone to the owner's level, so every repository reaches it. The job reads the App Store Connect key from the owner's user-level secrets `APPLE_API_ISSUER`, `APPLE_API_KEY_ID` and `APPLE_API_KEY_P8`, writes the .p8 into the runner's temp folder for notarytool alone, and puts the stapled image on the draft through `scripts/release-asset.sh` with the job's own token. One notarization covers the image and the app inside it; the image carries the ticket, and the app copied out finds its own online on its first open.
|
||||
- 2026-10-15: on Omarchy 4 (Linux) ELYTerminal follows the system's theme. Omarchy renders the current theme's `colors.toml` through its own resolver into `~/.local/state/omarchy/current/theme/ghostty.conf`, under `$HOME` whatever `XDG_STATE_HOME` says, so ELYTerminal reads that file and wears what Ghostty wears there: its color lines go through the strict parser the bundled themes use, and the rest, such as the glass block, pass by. Omarchy writes no `cursor-text`, so the background stands in, as in Ghostty and Omarchy's kitty template. ELYTerminal reads the file twice a second, since Omarchy signals only Alacritty, kitty and Ghostty, by name, and wears it whenever its text changes and parses; a check that fails is logged once while it lasts and tried again, so a file made readable again is worn though nothing else about it changed. The Omarchy theme is the default unless that file is surely absent, so a folder that cannot be read makes a fresh install wear it and the start name the error; another pick stops following. At start a missing file means no Omarchy unless the settings wear it, a file that cannot be read stops the start, and one that does not parse stops it only when worn, else it is logged and no Omarchy card shows. While running, a missing file is a switch in progress, and any other failure keeps the last theme. Omarchy 3's `~/.config/omarchy/current` gets no shim.
|
||||
- 2026-10-16: on Hyprland 0.56 a window blurs only while Hyprland's own `decoration:blur:enabled` is on: its `shouldBlur` reads that option first, then the window's `no_blur`, RGBX and opaque rules, then the blur region a surface sets through `ext_background_effect_v1`, which Hyprland offers with the blur capability whatever the option says. Omarchy 4.0.4's default `looknfeel.lua` turns the option off, so on Omarchy no window blurs until the user turns it on; ELYTerminal leaves the compositor's settings alone. On the owner's Omarchy the log named `ext_background_effect_v1` and the pane's region, and the wallpaper behind the window blurred with the option on and showed sharp with it off.
|
||||
- 2026-10-16: herdr 0.9 keeps its panes' OSC 7 and writes only a title, notifications, OSC 52 and bells to the terminal it runs in, so no terminal hears a folder from inside it; herdr's server knows each pane's `cwd`, the folder its shell reported, else the shell's own. On macOS and Linux each session asks twice a second which process group leads its pty's foreground (`tcgetpgrp` on a copy of the master). When that leader's executable is `herdr` and its arguments start herdr's terminal client (no command, `session attach NAME` or `client`), ELYTerminal reads the client's arguments and variables (`/proc` on Linux; `KERN_PROCARGS2` on macOS, which hides only the variables of Apple's own programs) and finds its socket as herdr 0.9 does: `--session NAME`, then `HERDR_SOCKET_PATH`, then `HERDR_SESSION`, under `$XDG_CONFIG_HOME/herdr`, else `~/.config/herdr`. It asks that socket for `session.snapshot` twice a second, a connection each, since herdr closes one after its reply, and reports the focused pane's `cwd` as the session's folder when it changes and again each time herdr takes the foreground. Focus belongs to herdr's server, so clients of one server follow one focus; a client of another machine's server (`--remote`, `--machine`) is logged, not followed. A failure is logged once while it lasts. Windows has none of this: there herdr is a beta on named pipes.
|
||||
@@ -44,6 +44,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
|
||||
- PTY: alacritty's `tty` and event loop. The pty is wrapped so its reader passes bytes through an OSC 7 scanner. The scanner reports the working directory.
|
||||
- Shell integration for zsh, bash, fish and PowerShell, injected at launch as Ghostty does: each prompt sends OSC 7 and a title. A shell without integration reports nothing, and the panels say so.
|
||||
- Inside herdr, which keeps its panes' OSC 7, the session's folder is that of herdr's focused pane, read from herdr's socket while herdr's client runs in the foreground, on macOS and Linux.
|
||||
- Grid: colors (named, 256, truecolor, OSC 4/10/11/12), bold, dim, italic, underline (single, double, curly, dotted, dashed), strikethrough, inverse, hidden, wide characters, combining marks.
|
||||
- Box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) painted as geometry, so lines and segments join across cells.
|
||||
- Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font.
|
||||
|
||||
@@ -45,7 +45,7 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
||||
- Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title.
|
||||
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound.
|
||||
- A button at each end of the title bar hides or shows its side column; the window opens as it was left. On Windows and Linux a gear beside the right one opens Settings.
|
||||
- Files shows the session's folder as a tree with Git's marks.
|
||||
- Files shows the session's folder as a tree with Git's marks. Inside herdr, on macOS and Linux, the folder is that of herdr's focused pane.
|
||||
- Git shows the branch, stages, unstages, discards, commits, lists recent commits and opens diffs, all through the `git` command line.
|
||||
- Themes: Ely Dark, Ely Light, Catppuccin Mocha, Macchiato, Frappé and Latte, Tokyo Night, Dracula, Nord, Gruvbox Dark, Rosé Pine, Solarized Light, One Light and Kanagawa Wave. A second theme can take over while the system is dark. On Omarchy 4, an Omarchy theme follows the system's theme as it switches, and is the default there.
|
||||
- Background opacity and blur: NSVisualEffectView on macOS, acrylic on Windows, `ext_background_effect_v1` or `org_kde_kwin_blur` on Wayland, `_KDE_NET_WM_BLUR_BEHIND_REGION` on X11, which KWin reads. Blur shows only while the opacity is under 100%. Hyprland blurs only while its own `decoration:blur:enabled` is on, and Omarchy 4.0.4 turns it off; to turn it on there, add `hl.config({ decoration = { blur = { enabled = true } } })` to `~/.config/hypr/looknfeel.lua`.
|
||||
|
||||
@@ -77,3 +77,9 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
|
||||
|
||||
- [x] T27 On Omarchy 4, an Omarchy theme wears the system's current theme and follows each switch, the default there; the v0.1.1 Linux tarball rebuilt with it, in place of the old one on the release.
|
||||
- [x] T28 Blur on Hyprland, which the owner found absent on Omarchy: Hyprland blurs behind a window only while its own `decoration:blur:enabled` is on, which Omarchy 4.0.4 turns off; the PRD, README, AGENTS.md, the acceptance table and the release notes say so, and how to turn it on.
|
||||
|
||||
## 18. herdr
|
||||
|
||||
- [ ] T29 Inside herdr the right column follows herdr's focused pane, which the owner found frozen: herdr keeps its panes' OSC 7, so on macOS and Linux ELYTerminal reads the folder from herdr's socket while herdr's client runs in the session's foreground.
|
||||
- [ ] T30 The main window opens at the size it was left at, on macOS, Windows and Linux, which the owner asked for: the size is kept in the settings.
|
||||
- [ ] T31 Release v0.1.2 with T29 and T30: version 0.1.2; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes say it fixes herdr compatibility and keeps the window's size.
|
||||
@@ -0,0 +1,412 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{OsStr, OsString},
|
||||
io::{self, BufRead, BufReader, Write},
|
||||
os::unix::{ffi::OsStringExt, net::UnixStream},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use gpui::{Context, Task};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::view::{TerminalEvent, TerminalView};
|
||||
|
||||
/// How often the session's foreground program is checked.
|
||||
const CHECK: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long herdr may take to answer.
|
||||
const WAIT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The socket request for herdr's session: its focus and its panes' folders.
|
||||
const SNAPSHOT: &[u8] = b"{\"id\":\"elyterminal\",\"method\":\"session.snapshot\",\"params\":{}}\n";
|
||||
|
||||
/// A program's arguments, and its variables as the kernel holds them.
|
||||
type Invocation = (Vec<String>, HashMap<String, OsString>);
|
||||
|
||||
/// Follows the pane herdr's server has focused while herdr's client runs in the foreground.
|
||||
pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||
cx.spawn(async move |view, cx| {
|
||||
let (mut group, mut socket, mut shown, mut failed) = (None, None, None, String::new());
|
||||
loop {
|
||||
cx.background_executor().timer(CHECK).await;
|
||||
let Ok((pid, now)) = view.update(cx, |view, _| (view.pty.pid, view.pty.foreground()))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let now = match now {
|
||||
Ok(now) => Some(now),
|
||||
Err(error) => {
|
||||
if group.is_some() {
|
||||
log::debug!("terminal {pid}: its foreground does not read: {error}");
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
if now != group {
|
||||
group = now;
|
||||
shown = None;
|
||||
failed.clear();
|
||||
if socket.take().is_some() {
|
||||
log::info!("terminal {pid}: herdr left the foreground");
|
||||
}
|
||||
let found = match now {
|
||||
Some(now) => {
|
||||
cx.background_executor()
|
||||
.spawn(async move { client(now) })
|
||||
.await
|
||||
}
|
||||
None => Ok(None),
|
||||
};
|
||||
socket = found.unwrap_or_else(|error| {
|
||||
failure(&mut failed, pid, &error);
|
||||
None
|
||||
});
|
||||
if let Some(socket) = &socket {
|
||||
log::info!(
|
||||
"terminal {pid}: herdr runs here, its server at {}",
|
||||
socket.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(path) = socket.clone() else {
|
||||
continue;
|
||||
};
|
||||
match cx
|
||||
.background_executor()
|
||||
.spawn(async move { folder(&path) })
|
||||
.await
|
||||
{
|
||||
Ok(Some(folder)) if shown.as_ref() != Some(&folder) => {
|
||||
failed.clear();
|
||||
let report = folder.clone();
|
||||
// herdr may have left while it answered; the shell's own report then stands.
|
||||
let Ok(still) = view.update(cx, |view, cx| {
|
||||
let still = view.pty.foreground().ok() == group;
|
||||
if still {
|
||||
cx.emit(TerminalEvent::Folder(report));
|
||||
}
|
||||
still
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
if still {
|
||||
log::info!(
|
||||
"terminal {pid}: herdr's focused pane is in {}",
|
||||
folder.display()
|
||||
);
|
||||
shown = Some(folder);
|
||||
}
|
||||
}
|
||||
Ok(_) => failed.clear(),
|
||||
Err(error) => failure(&mut failed, pid, &error),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Logs `error` once while it lasts; a server not up yet is no error.
|
||||
fn failure(failed: &mut String, pid: u32, error: &anyhow::Error) {
|
||||
let text = format!("{error:#}");
|
||||
if text != *failed {
|
||||
let level = if starting(error) {
|
||||
log::Level::Info
|
||||
} else {
|
||||
log::Level::Error
|
||||
};
|
||||
log::log!(level, "terminal {pid}: {text}; the panel keeps its folder");
|
||||
*failed = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// herdr's client runs before its server listens: no socket yet, or none answering.
|
||||
fn starting(error: &anyhow::Error) -> bool {
|
||||
error.downcast_ref::<io::Error>().is_some_and(|error| {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The socket of the herdr server whose terminal client leads process group `group`, if one does.
|
||||
fn client(group: i32) -> anyhow::Result<Option<PathBuf>> {
|
||||
let path = match path(group) {
|
||||
Ok(path) => path,
|
||||
// Another user's program, such as sudo, or one already gone.
|
||||
Err(error) => {
|
||||
log::debug!("terminal: process {group} does not read: {error}");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if path.file_name().is_none_or(|name| name != "herdr") {
|
||||
return Ok(None);
|
||||
}
|
||||
let (args, env) =
|
||||
launch(group).with_context(|| format!("herdr: process {group} does not read"))?;
|
||||
follows(&args, &env)
|
||||
}
|
||||
|
||||
/// The socket a herdr process with `args` and `env` shows, if it is herdr's terminal client.
|
||||
pub(super) fn follows(
|
||||
args: &[String],
|
||||
env: &HashMap<String, OsString>,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
if !attaches(args) {
|
||||
return Ok(None);
|
||||
}
|
||||
let remote = |arg: &String| {
|
||||
["--remote", "--machine"]
|
||||
.iter()
|
||||
.any(|flag| arg == flag || arg.starts_with(&format!("{flag}=")))
|
||||
};
|
||||
anyhow::ensure!(
|
||||
!args.iter().any(remote),
|
||||
"herdr: its client shows another machine's server, whose folders cannot show here"
|
||||
);
|
||||
socket(args, env).map(Some)
|
||||
}
|
||||
|
||||
/// Whether `args` start herdr's terminal client, not one of its commands.
|
||||
fn attaches(args: &[String]) -> bool {
|
||||
let mut rest = args.iter().skip(1);
|
||||
while let Some(arg) = rest.next() {
|
||||
match arg.as_str() {
|
||||
"--session" | "--remote" | "--machine" | "--remote-keybindings" => {
|
||||
rest.next();
|
||||
}
|
||||
"--version" | "-V" | "--help" | "-h" | "--default-config" | "--skill" => return false,
|
||||
"client" => return true,
|
||||
"session" => return rest.next().is_some_and(|next| next == "attach"),
|
||||
flag if flag.starts_with('-') => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The socket herdr 0.9's client finds: a named session, `HERDR_SOCKET_PATH`, `HERDR_SESSION`, the default.
|
||||
fn socket(args: &[String], env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||
let dir = match (env.get("XDG_CONFIG_HOME"), env.get("HOME")) {
|
||||
(Some(config), _) => PathBuf::from(config).join("herdr"),
|
||||
(None, Some(home)) => PathBuf::from(home).join(".config/herdr"),
|
||||
(None, None) => anyhow::bail!("herdr: its client has no HOME"),
|
||||
};
|
||||
let named = |name: &OsStr| {
|
||||
if name == "default" {
|
||||
dir.join("herdr.sock")
|
||||
} else {
|
||||
dir.join("sessions").join(name).join("herdr.sock")
|
||||
}
|
||||
};
|
||||
if let Some(name) = session(args) {
|
||||
return Ok(named(OsStr::new(&name)));
|
||||
}
|
||||
if let Some(path) = env.get("HERDR_SOCKET_PATH") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(env
|
||||
.get("HERDR_SESSION")
|
||||
.map_or_else(|| dir.join("herdr.sock"), |name| named(name)))
|
||||
}
|
||||
|
||||
/// The session `session attach NAME`, `--session NAME` or `--session=NAME` names, as herdr reads it.
|
||||
fn session(args: &[String]) -> Option<String> {
|
||||
if let [_, noun, verb, name] = args
|
||||
&& noun == "session"
|
||||
&& verb == "attach"
|
||||
{
|
||||
return Some(name.clone());
|
||||
}
|
||||
let mut named = None;
|
||||
let mut rest = args.iter().skip(1);
|
||||
while let Some(arg) = rest.next() {
|
||||
if arg == "--" {
|
||||
break;
|
||||
}
|
||||
if arg == "--session" {
|
||||
named = rest.next().cloned();
|
||||
} else if let Some(name) = arg.strip_prefix("--session=") {
|
||||
named = Some(name.to_owned());
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// The folder of the pane herdr's server has focused; none while it has none or knows none.
|
||||
fn folder(socket: &Path) -> anyhow::Result<Option<PathBuf>> {
|
||||
let ask = || -> io::Result<String> {
|
||||
let mut stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(WAIT))?;
|
||||
stream.set_write_timeout(Some(WAIT))?;
|
||||
stream.write_all(SNAPSHOT)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(stream).read_line(&mut reply)?;
|
||||
Ok(reply)
|
||||
};
|
||||
let reply = ask().with_context(|| format!("herdr: asking {}", socket.display()))?;
|
||||
focused(&reply).with_context(|| format!("herdr: {}", socket.display()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reply {
|
||||
result: Option<Snapshot>,
|
||||
error: Option<Failure>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Snapshot {
|
||||
snapshot: Session,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Session {
|
||||
focused_pane_id: Option<String>,
|
||||
panes: Vec<Pane>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Pane {
|
||||
pane_id: String,
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Failure {
|
||||
code: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// The focused pane's folder in herdr's `session.snapshot` reply.
|
||||
pub(super) fn focused(reply: &str) -> anyhow::Result<Option<PathBuf>> {
|
||||
let reply: Reply = serde_json::from_str(reply).context("its reply does not parse")?;
|
||||
if let Some(Failure { code, message }) = reply.error {
|
||||
anyhow::bail!("{code}: {message}");
|
||||
}
|
||||
let session = reply.result.context("its reply holds no result")?.snapshot;
|
||||
let Some(id) = session.focused_pane_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let pane = session
|
||||
.panes
|
||||
.into_iter()
|
||||
.find(|pane| pane.pane_id == id)
|
||||
.with_context(|| format!("its focused pane {id} is not among its panes"))?;
|
||||
if let Some(cwd) = &pane.cwd {
|
||||
anyhow::ensure!(
|
||||
cwd.is_absolute(),
|
||||
"pane {id}'s folder {} is relative",
|
||||
cwd.display()
|
||||
);
|
||||
}
|
||||
Ok(pane.cwd)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn path(pid: i32) -> io::Result<PathBuf> {
|
||||
std::fs::read_link(format!("/proc/{pid}/exe"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch(pid: i32) -> io::Result<Invocation> {
|
||||
let read = |name: &str| std::fs::read(format!("/proc/{pid}/{name}"));
|
||||
let (args, env) = (read("cmdline")?, read("environ")?);
|
||||
let args = strings(&args).map(lossy).collect();
|
||||
let env = strings(&env).filter_map(variable).collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
/// Each NUL-ended string in `bytes`.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn strings(bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
bytes
|
||||
.strip_suffix(b"\0")
|
||||
.unwrap_or(bytes)
|
||||
.split(|byte| *byte == 0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn path(pid: i32) -> io::Result<PathBuf> {
|
||||
let mut buffer = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: the buffer holds the size given.
|
||||
let length =
|
||||
unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
|
||||
if length <= 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
buffer.truncate(length as usize);
|
||||
Ok(PathBuf::from(OsString::from_vec(buffer)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch(pid: i32) -> io::Result<Invocation> {
|
||||
let mut name = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
|
||||
let mut ask = |buffer: &mut Vec<u8>| {
|
||||
let mut size = buffer.len();
|
||||
let into = if buffer.is_empty() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
buffer.as_mut_ptr().cast()
|
||||
};
|
||||
// SAFETY: `into` holds `size` bytes, or is null to ask for the size.
|
||||
let done = unsafe {
|
||||
libc::sysctl(
|
||||
name.as_mut_ptr(),
|
||||
3,
|
||||
into,
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if done != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(size)
|
||||
};
|
||||
let mut buffer = Vec::new();
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.resize(size, 0);
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.truncate(size);
|
||||
procargs(&buffer)
|
||||
}
|
||||
|
||||
/// `KERN_PROCARGS2`'s layout: argc, the program's path, NUL padding, the arguments, the variables.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn procargs(buffer: &[u8]) -> io::Result<Invocation> {
|
||||
let invalid =
|
||||
|what: &str| io::Error::new(io::ErrorKind::InvalidData, format!("KERN_PROCARGS2 {what}"));
|
||||
let (count, rest) = buffer
|
||||
.split_first_chunk::<4>()
|
||||
.ok_or_else(|| invalid("holds no argument count"))?;
|
||||
let count = usize::try_from(i32::from_ne_bytes(*count))
|
||||
.map_err(|_| invalid("holds a negative argument count"))?;
|
||||
let mut parts = rest
|
||||
.split(|byte| *byte == 0)
|
||||
.skip(1)
|
||||
.skip_while(|part| part.is_empty());
|
||||
let args = parts.by_ref().take(count).map(lossy).collect();
|
||||
let env = parts
|
||||
.take_while(|part| !part.is_empty())
|
||||
.filter_map(variable)
|
||||
.collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
fn lossy(bytes: &[u8]) -> String {
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
|
||||
/// A `NAME=value` variable; its value keeps its bytes.
|
||||
fn variable(pair: &[u8]) -> Option<(String, OsString)> {
|
||||
let at = pair.iter().position(|byte| *byte == b'=')?;
|
||||
Some((
|
||||
lossy(&pair[..at]),
|
||||
OsString::from_vec(pair[at + 1..].to_vec()),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::{fs, os::unix::net::UnixListener};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(words: &str) -> Vec<String> {
|
||||
words.split(' ').map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> HashMap<String, OsString> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_owned(), OsString::from(value)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn found(words: &str, env: &HashMap<String, OsString>) -> PathBuf {
|
||||
follows(&args(words), env)
|
||||
.expect("a local client")
|
||||
.expect("herdr's client")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_finds_its_server_as_herdr_does() {
|
||||
let home = env(&[("HOME", "/h")]);
|
||||
let default = PathBuf::from("/h/.config/herdr/herdr.sock");
|
||||
let work = PathBuf::from("/h/.config/herdr/sessions/work/herdr.sock");
|
||||
assert_eq!(found("herdr", &home), default);
|
||||
assert_eq!(found("herdr client", &home), default);
|
||||
assert_eq!(found("herdr --session work", &home), work);
|
||||
assert_eq!(found("herdr --session=work", &home), work);
|
||||
assert_eq!(found("herdr session attach work", &home), work);
|
||||
assert_eq!(found("herdr --session default", &home), default);
|
||||
let config = env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]);
|
||||
assert_eq!(
|
||||
found("herdr", &config),
|
||||
PathBuf::from("/x/herdr/herdr.sock")
|
||||
);
|
||||
let socket = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "/s.sock")]);
|
||||
assert_eq!(found("herdr", &socket), PathBuf::from("/s.sock"));
|
||||
assert_eq!(found("herdr --session work", &socket), work);
|
||||
let named = env(&[("HOME", "/h"), ("HERDR_SESSION", "work")]);
|
||||
assert_eq!(found("herdr", &named), work);
|
||||
assert!(follows(&args("herdr"), &env(&[])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_herdr_s_terminal_client_is_followed() {
|
||||
let home = env(&[("HOME", "/h")]);
|
||||
for words in [
|
||||
"herdr pane list",
|
||||
"herdr api snapshot",
|
||||
"herdr server",
|
||||
"herdr session list",
|
||||
"herdr --version",
|
||||
"herdr --session work status",
|
||||
] {
|
||||
assert_eq!(follows(&args(words), &home).expect(words), None, "{words}");
|
||||
}
|
||||
for words in ["herdr --remote box", "herdr --remote=box --session work"] {
|
||||
let error = follows(&args(words), &home).expect_err(words);
|
||||
assert!(error.to_string().contains("another machine"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(focused_pane: &str, cwd: &str) -> String {
|
||||
format!(
|
||||
r#"{{"id":"elyterminal","result":{{"type":"session_snapshot","snapshot":{{"version":"0.9.5","protocol":22,"focused_pane_id":{focused_pane},"workspaces":[],"tabs":[],"panes":[{{"pane_id":"w1:p1","cwd":"/a","focused":false}},{{"pane_id":"w1:p2","cwd":{cwd},"focused":true}}],"layouts":[],"agents":[]}}}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focused_pane_s_folder_comes_from_herdr_s_snapshot() {
|
||||
let read = |focused_pane: &str, cwd: &str| focused(&reply(focused_pane, cwd));
|
||||
assert_eq!(
|
||||
read(r#""w1:p2""#, r#""/srv/b""#).expect("a folder"),
|
||||
Some("/srv/b".into())
|
||||
);
|
||||
assert_eq!(read("null", r#""/srv/b""#).expect("no focus"), None);
|
||||
assert_eq!(read(r#""w1:p2""#, "null").expect("no folder"), None);
|
||||
let error = read(r#""w1:p9""#, r#""/b""#).expect_err("a lost pane");
|
||||
assert!(error.to_string().contains("w1:p9"), "{error}");
|
||||
let error = read(r#""w1:p2""#, r#""b""#).expect_err("a relative folder");
|
||||
assert!(error.to_string().contains("relative"), "{error}");
|
||||
let failed =
|
||||
r#"{"id":"elyterminal","error":{"code":"invalid_request","message":"no such method"}}"#;
|
||||
let error = focused(failed).expect_err("an error");
|
||||
assert_eq!(error.to_string(), "invalid_request: no such method");
|
||||
assert!(focused("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn herdr_answers_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("elyterminal-herdr-{}", std::process::id()));
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir).expect("a stale test folder goes");
|
||||
}
|
||||
fs::create_dir_all(&dir).expect("a test folder");
|
||||
let socket = dir.join("herdr.sock");
|
||||
let listener = UnixListener::bind(&socket).expect("a socket");
|
||||
let answer = reply(r#""w1:p2""#, r#""/srv""#);
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().expect("a client");
|
||||
let mut request = String::new();
|
||||
BufReader::new(&stream)
|
||||
.read_line(&mut request)
|
||||
.expect("a request");
|
||||
(&stream)
|
||||
.write_all(format!("{answer}\n").as_bytes())
|
||||
.expect("a reply");
|
||||
request
|
||||
});
|
||||
assert_eq!(folder(&socket).expect("an answer"), Some("/srv".into()));
|
||||
assert_eq!(server.join().expect("the server").as_bytes(), SNAPSHOT);
|
||||
fs::remove_dir_all(&dir).expect("the test folder goes");
|
||||
let error = folder(&socket).expect_err("no server");
|
||||
assert!(format!("{error:#}").contains("asking"), "{error:#}");
|
||||
assert!(starting(&error), "{error:#}");
|
||||
assert!(!starting(&focused("").expect_err("no reply")));
|
||||
}
|
||||
|
||||
/// macOS hides the variables of its own programs, such as `/bin/sleep`, so the test reads itself.
|
||||
#[test]
|
||||
fn a_process_reads_with_its_path_arguments_and_variables() {
|
||||
let pid = std::process::id() as i32;
|
||||
let read = path(pid).expect("its path");
|
||||
let (args, env) = launch(pid).expect("its arguments");
|
||||
let exe = std::env::current_exe().expect("this test's path");
|
||||
assert_eq!(fs::canonicalize(read).ok(), fs::canonicalize(exe).ok());
|
||||
assert_eq!(args, std::env::args().collect::<Vec<_>>());
|
||||
assert_eq!(env.get("HOME"), std::env::var_os("HOME").as_ref());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_lays_out_the_arguments_after_the_program_s_path() {
|
||||
let mut buffer = 2i32.to_ne_bytes().to_vec();
|
||||
buffer.extend_from_slice(b"/usr/local/bin/herdr\0\0\0\0herdr\0\0HOME=/h\0X=a=b\0\0\0");
|
||||
let (args, env) = procargs(&buffer).expect("it parses");
|
||||
assert_eq!(args, ["herdr", ""]);
|
||||
assert_eq!(env.get("HOME"), Some(&OsString::from("/h")));
|
||||
assert_eq!(env.get("X"), Some(&OsString::from("a=b")));
|
||||
assert!(procargs(&[1, 0]).is_err());
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
mod boxes;
|
||||
mod find;
|
||||
pub mod frame;
|
||||
#[cfg(unix)]
|
||||
mod herdr;
|
||||
pub mod ink;
|
||||
pub mod input;
|
||||
pub mod keys;
|
||||
|
||||
+33
-3
@@ -56,7 +56,7 @@ pub struct Pty {
|
||||
pub term: Arc<FairMutex<Term<Listener>>>,
|
||||
pub pid: u32,
|
||||
notifier: Notifier,
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
master: std::fs::File,
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ pub fn spawn(
|
||||
)
|
||||
})?;
|
||||
let pid = child_id(&raw)?;
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
let master = raw
|
||||
.master()
|
||||
.map_err(|error| anyhow::anyhow!("pty {pid}: no copy of the master: {error}"))?;
|
||||
@@ -141,7 +141,7 @@ pub fn spawn(
|
||||
term,
|
||||
pid,
|
||||
notifier,
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
master,
|
||||
},
|
||||
events,
|
||||
@@ -184,6 +184,15 @@ impl Pty {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Pty {
|
||||
/// The process group the program runs in the foreground.
|
||||
pub fn foreground(&self) -> std::io::Result<i32> {
|
||||
let group = rustix_openpty::rustix::termios::tcgetpgrp(&self.master)?;
|
||||
Ok(group.as_raw_nonzero().get())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
impl Pty {
|
||||
/// Whether the program reads raw keys, canonical mode off.
|
||||
@@ -305,6 +314,27 @@ mod tests {
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_foreground_is_the_program_s_own_group() {
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "exec sleep 30".into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, _events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while pty.foreground().ok() != Some(pty.pid as i32) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{:?}",
|
||||
pty.foreground()
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
|
||||
let ran = run("stty size", (9000, 8));
|
||||
|
||||
+1
-2
@@ -45,8 +45,7 @@ impl Pty {
|
||||
self.child.id()
|
||||
}
|
||||
|
||||
/// A copy of the master; tests read its modes.
|
||||
#[cfg(test)]
|
||||
/// A copy of the master, which reads the foreground and, in tests, the modes.
|
||||
pub fn master(&self) -> io::Result<File> {
|
||||
self.file.try_clone()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ pub struct TerminalView {
|
||||
shown: bool,
|
||||
_events: Task<()>,
|
||||
_blink: Task<()>,
|
||||
#[cfg(unix)]
|
||||
_herdr: Task<()>,
|
||||
_focus: [Subscription; 3],
|
||||
_settings: Subscription,
|
||||
_zoom: Subscription,
|
||||
@@ -117,6 +119,8 @@ impl TerminalView {
|
||||
shown: true,
|
||||
_events: Self::pump(events, cx),
|
||||
_blink: Self::blink(cx),
|
||||
#[cfg(unix)]
|
||||
_herdr: super::herdr::follow(cx),
|
||||
_settings: cx.observe_global::<SettingsStore>(|view, cx| {
|
||||
view.pty
|
||||
.term
|
||||
|
||||
@@ -51,6 +51,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|---|
|
||||
| PTY through alacritty's event loop; OSC 7 scanned on the reader | T04 | `term::pty`, `term::tap` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass |
|
||||
| Shell integration for zsh, bash, fish, PowerShell; a shell without it reports nothing and the panels say so | T05 | `term::shell` tests on macOS and Linux | Pass |
|
||||
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS and Linux | T29 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report; on macOS under `shot`, herdr 0.9.5 as the program, its pane's cd moved the panel | Pass |
|
||||
| Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass |
|
||||
| Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass |
|
||||
| JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |
|
||||
|
||||
Reference in new issue
Block a user