T39: a session's row shows its status, the most pressing first: waiting by its kind, an error, work with Ely's progress ring or a still loader, done, then a failed last command, then idle; hover lists each record's state, app, title and message, disarmed and cut; typing sees done and error, herdr's aside; a hidden session that starts waiting sets its title strong until shown
This commit is contained in:
12 files changed
+449
-22
No files matched your search
@@ -31,7 +31,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `build.rs`: on Windows, the icon into the executable as resource 1, through `assets/icons/elyterminal.rc`. `.cargo/config.toml`: Windows builds carry the C runtime. `rust-toolchain.toml`: the toolchain rustup installs.
|
- `build.rs`: on Windows, the icon into the executable as resource 1, through `assets/icons/elyterminal.rc`. `.cargo/config.toml`: Windows builds carry the C runtime. `rust-toolchain.toml`: the toolchain rustup installs.
|
||||||
- `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, at the size the settings keep, which it keeps as it settles. `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/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, a command's end, the program's exit. `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/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, at the size the settings keep, which it keeps as it settles. `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/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, a command's end, its records, the program's exit. `src/sessions.rs`: a session and its row, with the mark of its status and what hover shows of it. `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/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/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, 9, 99, 133, 777 and 7501, full resets and the mouse modes before the parser; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `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, 9, 99, 133, 777 and 7501, full resets and the mouse modes before the parser; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `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.
|
||||||
@@ -101,3 +101,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
- 2026-10-17: programs say what they are doing through OSC 7501, revision 0.4 of the Program Status Protocol, which the owner asked for, read as the spec reads it: pairs `key=value` joined by `:`, a malformed pair skipped, an unknown key passed by, and every pair of a known key checked, a clear's too, before the last of each key is kept. A sequence over 4096 bytes, counted from OSC through ST with the controls its body skips, a key over 16, an app over 32, an id outside its grammar or limits (128 bytes, 32 a segment, 8 levels), a title or msg over its limits, not base64, not UTF-8 or holding a control character, and a missing or unknown state each drop it whole; kind counts only with blocked, and progress, 0 to 100, only with working and blocked, each absent when unknown. Each terminal keeps its records by id, the root's empty: a report replaces its record whole, clear drops the record and those under it, or all without an id, and past 256 the record updated longest ago goes. A new prompt drops working, blocked and idle records, as kitty does, and keeps done and error; a full reset, which the tap's scanner sees as `ESC c`, through the controls, DEL and high bytes vte passes by in an escape, drops all, and DECSTR nothing. The query `OSC 7501 ; ?` is answered with every state and kind; the view writes the answer when the session hears the query, ahead of the parser's answers to what came later in the same read, so a DA1 sent after the query, as the spec suggests, comes back second, and one sent before it in the same read comes back after it.
|
- 2026-10-17: programs say what they are doing through OSC 7501, revision 0.4 of the Program Status Protocol, which the owner asked for, read as the spec reads it: pairs `key=value` joined by `:`, a malformed pair skipped, an unknown key passed by, and every pair of a known key checked, a clear's too, before the last of each key is kept. A sequence over 4096 bytes, counted from OSC through ST with the controls its body skips, a key over 16, an app over 32, an id outside its grammar or limits (128 bytes, 32 a segment, 8 levels), a title or msg over its limits, not base64, not UTF-8 or holding a control character, and a missing or unknown state each drop it whole; kind counts only with blocked, and progress, 0 to 100, only with working and blocked, each absent when unknown. Each terminal keeps its records by id, the root's empty: a report replaces its record whole, clear drops the record and those under it, or all without an id, and past 256 the record updated longest ago goes. A new prompt drops working, blocked and idle records, as kitty does, and keeps done and error; a full reset, which the tap's scanner sees as `ESC c`, through the controls, DEL and high bytes vte passes by in an escape, drops all, and DECSTR nothing. The query `OSC 7501 ; ?` is answered with every state and kind; the view writes the answer when the session hears the query, ahead of the parser's answers to what came later in the same read, so a DA1 sent after the query, as the spec suggests, comes back second, and one sent before it in the same read comes back after it.
|
||||||
- 2026-10-17: programs ask for notifications through OSC 9, 777 and 99 and report progress through OSC 9;4, which the owner asked for. OSC 9 is ConEmu's command where Ghostty reads one, 9;1 to 9;12 each by its own shape, else iTerm2's notification, its text the title; OSC 777 is rxvt's `notify;title;body`, another extension logged and passed by. OSC 99 is kitty's: `k=v` pairs before the payload, a malformed pair passed by; `i`, of `[A-Za-z0-9_-+.]` alone or the sequence drops, gathers chunks while `d=0`, 32 notifications and 16384 bytes of text at most; a payload holds 2048 bytes plain or 4096 encoded at most; plain text is UTF-8 without a control character; `e=1` reads it as base64 group by group, so chunks split before encoding or after join, and it carries any UTF-8, newlines too, checked once the text is whole; a chunk that breaks a rule drops what its notification gathered; metadata holds 1024 bytes at most, and the tap keeps an OSC 99 body to 5124 bytes, room for metadata and a full payload, where other bodies stop at 4096; `p` takes title, body, close, ? and alive, and other payloads pass by; `o`, `a`, `c` and `w` are kept for showing; a lone body is the title, and one with neither passes by. The query is answered with what is read so far, `p=title,body,?,alive:o=always`, and alive with no id, since none shows yet; an answer names the asker's id, or 0. OSC 9;4's states, remove, set, error, indeterminate and pause, read as Ghostty reads them, the progress clamped to 100, stand as the root record: set and pause working with their progress, indeterminate working without, error an error, remove none; this lasts until a program reports through OSC 7501, and again after a full reset, as the spec asks, so a mapped 9;4 never wipes a real report. Notifications go to the workspace, which logs them.
|
- 2026-10-17: programs ask for notifications through OSC 9, 777 and 99 and report progress through OSC 9;4, which the owner asked for. OSC 9 is ConEmu's command where Ghostty reads one, 9;1 to 9;12 each by its own shape, else iTerm2's notification, its text the title; OSC 777 is rxvt's `notify;title;body`, another extension logged and passed by. OSC 99 is kitty's: `k=v` pairs before the payload, a malformed pair passed by; `i`, of `[A-Za-z0-9_-+.]` alone or the sequence drops, gathers chunks while `d=0`, 32 notifications and 16384 bytes of text at most; a payload holds 2048 bytes plain or 4096 encoded at most; plain text is UTF-8 without a control character; `e=1` reads it as base64 group by group, so chunks split before encoding or after join, and it carries any UTF-8, newlines too, checked once the text is whole; a chunk that breaks a rule drops what its notification gathered; metadata holds 1024 bytes at most, and the tap keeps an OSC 99 body to 5124 bytes, room for metadata and a full payload, where other bodies stop at 4096; `p` takes title, body, close, ? and alive, and other payloads pass by; `o`, `a`, `c` and `w` are kept for showing; a lone body is the title, and one with neither passes by. The query is answered with what is read so far, `p=title,body,?,alive:o=always`, and alive with no id, since none shows yet; an answer names the asker's id, or 0. OSC 9;4's states, remove, set, error, indeterminate and pause, read as Ghostty reads them, the progress clamped to 100, stand as the root record: set and pause working with their progress, indeterminate working without, error an error, remove none; this lasts until a program reports through OSC 7501, and again after a full reset, as the spec asks, so a mapped 9;4 never wipes a real report. Notifications go to the workspace, which logs them.
|
||||||
- 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr:PANE`, whose colon no OSC 7501 id holds, so a program's record of a name like it, `herdr/build` among them, stays its own; unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none.
|
- 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr:PANE`, whose colon no OSC 7501 id holds, so a program's record of a name like it, `herdr/build` among them, stays its own; unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none.
|
||||||
|
- 2026-10-17: a session's row shows its status, which the owner asked for: one mark before its title, the most pressing state first. Blocked shows its kind, a shield for permission, a circled question mark for a question, a key for a login and a hand for none, in the theme's warning color; error a circled cross; working Ely's `ProgressRing` at its progress, 24 pixels wide, since at an icon's 16 its stroke filled it, and every mark takes that box, so the title stays put as the state changes, else Ely's loader icon held still, since a turning spinner would draw the window at every frame for as long as a program works; done a circled check; then a failed last command, a circled alert; then idle, a hollow circle. Among the records in that state, the one reported last speaks. Hover over the mark lists each record, ten at most: its state, its app, its own or its nearest ancestor's as the spec asks, its title and its message, each on one line and cut at 120 characters, with C1 controls, direction controls and invisible formatting taken out, as the spec asks; joiners stay, since scripts and emoji need them. A failed last command's status follows. A key the user types, a paste too, sees the terminal's done and error records, which then go, as the spec suggests; herdr's stay, since herdr marks them seen. A hidden session whose mark turns to blocked sets its title strong until it is shown. The view tells the workspace when its records may have changed, and the workspace logs each change of the state its mark shows.
|
||||||
@@ -43,7 +43,7 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
|||||||
|
|
||||||
- The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; find searches the scrollback.
|
- The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; find searches the scrollback.
|
||||||
- Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title, and OSC 133 marks each prompt, each command and its exit status.
|
- Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title, and OSC 133 marks each prompt, each command and its exit status.
|
||||||
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound.
|
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound. A row's mark shows what its programs report through OSC 7501, OSC 9;4 or herdr: waiting on you, an error, work with its progress, done or idle, else a failed last command; hover over the mark lists each report.
|
||||||
- Cmd-Up and Cmd-Down, Ctrl-Shift-Up and Ctrl-Shift-Down off macOS, jump to the prompt before or after the view's top.
|
- Cmd-Up and Cmd-Down, Ctrl-Shift-Up and Ctrl-Shift-Down off macOS, jump to the prompt before or after the view's top.
|
||||||
- A button at each end of the title bar hides or shows its side column, and a drag on the border between a side column and the terminal resizes it; the window opens as it was left, at the size and widths it was left at. On Windows and Linux a gear beside the right one opens Settings.
|
- A button at each end of the title bar hides or shows its side column, and a drag on the border between a side column and the terminal resizes it; the window opens as it was left, at the size and widths it was left at. 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. Inside herdr the folder is that of herdr's focused pane.
|
- Files shows the session's folder as a tree with Git's marks. Inside herdr the folder is that of herdr's focused pane.
|
||||||
|
|||||||
+265
-11
@@ -3,13 +3,14 @@ use std::path::{MAIN_SEPARATOR, Path, PathBuf};
|
|||||||
use ely_gpui_component::{
|
use ely_gpui_component::{
|
||||||
buttons::{ButtonVariant, IconButton},
|
buttons::{ButtonVariant, IconButton},
|
||||||
lists::ListItem,
|
lists::ListItem,
|
||||||
primitives::{Icon, IconName},
|
motion::ProgressRing,
|
||||||
theme::{ActiveTheme, ControlSize},
|
primitives::{Icon, IconName, Tooltip},
|
||||||
|
theme::{ActiveTheme, ControlSize, IconSize},
|
||||||
};
|
};
|
||||||
use gpui::{App, Entity, Subscription};
|
use gpui::{AnyElement, App, Entity, Hsla, SharedString, Subscription, div, prelude::*};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
term::TerminalView,
|
term::{Kind, Record, Records, State, TerminalView},
|
||||||
workspace::{TITLE, Workspace},
|
workspace::{TITLE, Workspace},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,11 +25,19 @@ pub struct Session {
|
|||||||
pub reports: bool,
|
pub reports: bool,
|
||||||
/// A bell rang while it was hidden.
|
/// A bell rang while it was hidden.
|
||||||
pub bell: bool,
|
pub bell: bool,
|
||||||
|
/// It started waiting on the user while hidden.
|
||||||
|
pub waiting: bool,
|
||||||
/// The last command's exit status, as its shell reported it; none before one, or when it gave none.
|
/// The last command's exit status, as its shell reported it; none before one, or when it gave none.
|
||||||
pub status: Option<i32>,
|
pub status: Option<i32>,
|
||||||
|
/// The state its mark shows, as its records last said.
|
||||||
|
pub state: Option<State>,
|
||||||
pub _events: Subscription,
|
pub _events: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The most records hover lists, and the most characters it shows of each part.
|
||||||
|
const LISTED: usize = 10;
|
||||||
|
const SHORT: usize = 120;
|
||||||
|
|
||||||
/// `folder` as a row shows it: the home folder as `~`, on one line.
|
/// `folder` as a row shows it: the home folder as `~`, on one line.
|
||||||
pub fn shown(folder: &Path, home: Option<&Path>) -> String {
|
pub fn shown(folder: &Path, home: Option<&Path>) -> String {
|
||||||
let shown = match home.and_then(|home| folder.strip_prefix(home).ok()) {
|
let shown = match home.and_then(|home| folder.strip_prefix(home).ok()) {
|
||||||
@@ -59,7 +68,7 @@ pub fn after_close(active: usize, closed: usize, left: usize) -> usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session `index`'s row: its title and folder, a bell while one rang unseen, a button that closes it.
|
/// Session `index`'s row: its title and folder, the mark of its status, a bell while one rang unseen, a button that closes it.
|
||||||
pub fn row(
|
pub fn row(
|
||||||
workspace: &Entity<Workspace>,
|
workspace: &Entity<Workspace>,
|
||||||
index: usize,
|
index: usize,
|
||||||
@@ -68,7 +77,8 @@ pub fn row(
|
|||||||
home: Option<&Path>,
|
home: Option<&Path>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> ListItem {
|
) -> ListItem {
|
||||||
let pid = session.terminal.read(cx).pid() as usize;
|
let terminal = session.terminal.read(cx);
|
||||||
|
let pid = terminal.pid() as usize;
|
||||||
let title = if session.title.is_empty() {
|
let title = if session.title.is_empty() {
|
||||||
TITLE
|
TITLE
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +88,7 @@ pub fn row(
|
|||||||
let item = ListItem::new(("session", pid), title.to_string())
|
let item = ListItem::new(("session", pid), title.to_string())
|
||||||
.description(shown(&session.folder, home))
|
.description(shown(&session.folder, home))
|
||||||
.selected(active)
|
.selected(active)
|
||||||
.strong(session.bell)
|
.strong(session.bell || session.waiting)
|
||||||
.on_click(move |_, window, cx| select.update(cx, |this, cx| this.select(index, window, cx)))
|
.on_click(move |_, window, cx| select.update(cx, |this, cx| this.select(index, window, cx)))
|
||||||
.trailing(
|
.trailing(
|
||||||
IconButton::new(("close-session", pid), IconName::X)
|
IconButton::new(("close-session", pid), IconName::X)
|
||||||
@@ -90,11 +100,178 @@ pub fn row(
|
|||||||
close.update(cx, |this, cx| this.close(index, window, cx))
|
close.update(cx, |this, cx| this.close(index, window, cx))
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if session.bell {
|
let mark = mark(pid, terminal.records(), session.status, cx);
|
||||||
item.leading(Icon::new(IconName::Bell).color(cx.theme().colors.warning))
|
let bell = session
|
||||||
} else {
|
.bell
|
||||||
item
|
.then(|| Icon::new(IconName::Bell).color(cx.theme().colors.warning));
|
||||||
|
if mark.is_none() && bell.is_none() {
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
item.leading(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.children(mark)
|
||||||
|
.children(bell),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark of what the session's records say, else of its last command's failure; hover lists them.
|
||||||
|
fn mark(pid: usize, records: &Records, status: Option<i32>, cx: &App) -> Option<AnyElement> {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = &theme.colors;
|
||||||
|
let icon = |name: IconName, color: Hsla| Icon::new(name).color(color).into_any_element();
|
||||||
|
let failed = status.filter(|&code| code != 0);
|
||||||
|
let mark = match (records.shown(), failed) {
|
||||||
|
(
|
||||||
|
Some(Record {
|
||||||
|
state: State::Blocked,
|
||||||
|
kind,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
_,
|
||||||
|
) => {
|
||||||
|
let name = match kind {
|
||||||
|
Some(Kind::Permission) => IconName::Shield,
|
||||||
|
Some(Kind::Question) => IconName::CircleHelp,
|
||||||
|
Some(Kind::Auth) => IconName::Key,
|
||||||
|
None => IconName::Hand,
|
||||||
|
};
|
||||||
|
icon(name, colors.warning)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Some(Record {
|
||||||
|
state: State::Error,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
_,
|
||||||
|
) => icon(IconName::CircleX, colors.danger),
|
||||||
|
(
|
||||||
|
Some(Record {
|
||||||
|
state: State::Working,
|
||||||
|
progress: Some(value),
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
_,
|
||||||
|
) => ProgressRing::new(("session-progress", pid), f32::from(*value) / 100.0)
|
||||||
|
.size(theme.icon_size(IconSize::Xl))
|
||||||
|
.into_any_element(),
|
||||||
|
// A turning spinner would draw the window at every frame while the program works.
|
||||||
|
(
|
||||||
|
Some(Record {
|
||||||
|
state: State::Working,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
_,
|
||||||
|
) => icon(IconName::LoaderCircle, colors.info),
|
||||||
|
(
|
||||||
|
Some(Record {
|
||||||
|
state: State::Done, ..
|
||||||
|
}),
|
||||||
|
_,
|
||||||
|
) => icon(IconName::CircleCheck, colors.success),
|
||||||
|
(_, Some(_)) => icon(IconName::CircleAlert, colors.danger),
|
||||||
|
(
|
||||||
|
Some(Record {
|
||||||
|
state: State::Idle, ..
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
) => icon(IconName::Circle, colors.fg_muted),
|
||||||
|
(None, None) => return None,
|
||||||
|
};
|
||||||
|
let lines = hover(records, failed);
|
||||||
|
let tip = Tooltip::rich(move |_, _| {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.children(lines.iter().cloned())
|
||||||
|
.into_any_element()
|
||||||
|
});
|
||||||
|
// Every mark takes the ring's box, so the title stays put as the state changes.
|
||||||
|
Some(
|
||||||
|
div()
|
||||||
|
.id(("session-status", pid))
|
||||||
|
.size(theme.icon_size(IconSize::Xl))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.tooltip(tip)
|
||||||
|
.child(mark)
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What hover shows: a line for each record, ten at most, with its state, app, title and message, then a failed command's status.
|
||||||
|
pub fn hover(records: &Records, failed: Option<i32>) -> Vec<SharedString> {
|
||||||
|
let line = |(id, record): (&str, &Record)| {
|
||||||
|
let parts = [
|
||||||
|
records.app(id),
|
||||||
|
record.title.as_deref(),
|
||||||
|
record.msg.as_deref(),
|
||||||
|
];
|
||||||
|
let said: Vec<String> = parts.into_iter().flatten().map(plain).collect();
|
||||||
|
let state = label(record);
|
||||||
|
SharedString::from(if said.is_empty() {
|
||||||
|
state
|
||||||
|
} else {
|
||||||
|
format!("{state}: {}", said.join(" · "))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let mut lines: Vec<SharedString> = records.iter().take(LISTED).map(line).collect();
|
||||||
|
let more = records.iter().count().saturating_sub(LISTED);
|
||||||
|
if more > 0 {
|
||||||
|
lines.push(format!("{more} more").into());
|
||||||
|
}
|
||||||
|
if let Some(code) = failed {
|
||||||
|
lines.push(format!("Last command exited with {code}").into());
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A record's state, as hover names it.
|
||||||
|
fn label(record: &Record) -> String {
|
||||||
|
match (record.state, record.kind, record.progress) {
|
||||||
|
(State::Idle, ..) => "Idle".into(),
|
||||||
|
(State::Working, _, Some(value)) => format!("Working, {value}%"),
|
||||||
|
(State::Working, ..) => "Working".into(),
|
||||||
|
(State::Blocked, Some(Kind::Permission), _) => "Waiting for permission".into(),
|
||||||
|
(State::Blocked, Some(Kind::Question), _) => "Waiting for an answer".into(),
|
||||||
|
(State::Blocked, Some(Kind::Auth), _) => "Waiting for a login".into(),
|
||||||
|
(State::Blocked, None, _) => "Waiting".into(),
|
||||||
|
(State::Done, ..) => "Done".into(),
|
||||||
|
(State::Error, ..) => "Error".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A program's text as hover shows it: on one line, 120 characters at most, without what could disguise it.
|
||||||
|
pub fn plain(text: &str) -> String {
|
||||||
|
let kept: String = text.chars().filter(|&c| !invisible(c)).collect();
|
||||||
|
let line = one_line(&kept);
|
||||||
|
match line.char_indices().nth(SHORT) {
|
||||||
|
Some((at, _)) => format!("{}…", &line[..at]),
|
||||||
|
None => line,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C1 controls, direction controls and invisible formatting; joiners stay, as scripts and emoji need them.
|
||||||
|
fn invisible(c: char) -> bool {
|
||||||
|
matches!(
|
||||||
|
c,
|
||||||
|
'\u{80}'..='\u{9f}'
|
||||||
|
| '\u{ad}'
|
||||||
|
| '\u{61c}'
|
||||||
|
| '\u{180e}'
|
||||||
|
| '\u{200b}'
|
||||||
|
| '\u{200e}'
|
||||||
|
| '\u{200f}'
|
||||||
|
| '\u{202a}'..='\u{202e}'
|
||||||
|
| '\u{2060}'..='\u{2064}'
|
||||||
|
| '\u{2066}'..='\u{2069}'
|
||||||
|
| '\u{feff}'
|
||||||
|
| '\u{fff9}'..='\u{fffb}'
|
||||||
|
| '\u{e0000}'..='\u{e007f}'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -135,4 +312,81 @@ mod tests {
|
|||||||
assert_eq!(after_close(3, 3, 3), 2, "the last went: the one before");
|
assert_eq!(after_close(3, 3, 3), 2, "the last went: the one before");
|
||||||
assert_eq!(after_close(1, 2, 3), 1, "one after it went");
|
assert_eq!(after_close(1, 2, 3), 1, "one after it went");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_shows_a_program_s_text_as_text() {
|
||||||
|
assert_eq!(plain("evil\u{202e}txt.exe"), "eviltxt.exe");
|
||||||
|
assert_eq!(
|
||||||
|
plain("a\u{200b}b\u{feff}c\u{e0041}d\u{85}e\u{2066}f"),
|
||||||
|
"abcdef"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plain("👨\u{200d}💻 two\nlines"),
|
||||||
|
"👨\u{200d}💻 two␊lines",
|
||||||
|
"joiners stay"
|
||||||
|
);
|
||||||
|
assert_eq!(plain(&"x".repeat(SHORT)), "x".repeat(SHORT));
|
||||||
|
assert_eq!(
|
||||||
|
plain(&"é".repeat(SHORT + 1)),
|
||||||
|
format!("{}…", "é".repeat(SHORT))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_lists_each_record_with_its_app_then_a_failed_command() {
|
||||||
|
let record =
|
||||||
|
|state: State, kind: Option<Kind>, progress: Option<u8>, texts: [Option<&str>; 3]| {
|
||||||
|
let [app, title, msg] = texts.map(|text| text.map(String::from));
|
||||||
|
Record {
|
||||||
|
state,
|
||||||
|
kind,
|
||||||
|
progress,
|
||||||
|
app,
|
||||||
|
title,
|
||||||
|
msg,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut records = Records::default();
|
||||||
|
records.report(
|
||||||
|
String::new(),
|
||||||
|
record(
|
||||||
|
State::Working,
|
||||||
|
None,
|
||||||
|
Some(40),
|
||||||
|
[Some("deploy"), None, Some("Deploying")],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
records.report(
|
||||||
|
"eu".into(),
|
||||||
|
record(
|
||||||
|
State::Blocked,
|
||||||
|
Some(Kind::Permission),
|
||||||
|
None,
|
||||||
|
[None, Some("EU West"), Some("Approve?")],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
records.report(
|
||||||
|
"eu/db".into(),
|
||||||
|
record(State::Done, None, None, [Some("db"), None, None]),
|
||||||
|
);
|
||||||
|
let lines: Vec<String> = hover(&records, Some(2))
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
[
|
||||||
|
"Working, 40%: deploy · Deploying",
|
||||||
|
"Waiting for permission: deploy · EU West · Approve?",
|
||||||
|
"Done: db",
|
||||||
|
"Last command exited with 2"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
for at in 0..LISTED {
|
||||||
|
records.report(format!("r{at}"), record(State::Idle, None, None, [None; 3]));
|
||||||
|
}
|
||||||
|
let lines = hover(&records, None);
|
||||||
|
assert_eq!(lines.len(), LISTED + 1);
|
||||||
|
assert_eq!(lines[LISTED].to_string(), "3 more");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+9
-2
@@ -76,7 +76,12 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
|||||||
"terminal {pid}: herdr left the foreground, its agents' records with it"
|
"terminal {pid}: herdr left the foreground, its agents' records with it"
|
||||||
);
|
);
|
||||||
states.clear();
|
states.clear();
|
||||||
if view.update(cx, |view, _| view.records.herdr(&[])).is_err() {
|
let gone = view.update(cx, |view, cx| {
|
||||||
|
if view.records.herdr(&[]) {
|
||||||
|
cx.emit(TerminalEvent::Records);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if gone.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +127,9 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
|||||||
let Ok(still) = view.update(cx, |view, cx| {
|
let Ok(still) = view.update(cx, |view, cx| {
|
||||||
let still = view.reports == reports;
|
let still = view.reports == reports;
|
||||||
if still {
|
if still {
|
||||||
view.records.herdr(&agents);
|
if view.records.herdr(&agents) {
|
||||||
|
cx.emit(TerminalEvent::Records);
|
||||||
|
}
|
||||||
if let Some(folder) = report.clone() {
|
if let Some(folder) = report.clone() {
|
||||||
cx.emit(TerminalEvent::Folder(folder));
|
cx.emit(TerminalEvent::Folder(folder));
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
keys::{self, Action, Key, Kitty},
|
keys::{self, Action, Key, Kitty},
|
||||||
view::TerminalView,
|
view::{TerminalEvent, TerminalView},
|
||||||
};
|
};
|
||||||
|
|
||||||
actions!(elyterminal, [Find, FindNewer, PreviousPrompt, NextPrompt]);
|
actions!(elyterminal, [Find, FindNewer, PreviousPrompt, NextPrompt]);
|
||||||
@@ -68,7 +68,7 @@ pub fn bind_keys(cx: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TerminalView {
|
impl TerminalView {
|
||||||
/// Bytes from the keyboard: back to the newest line, no selection, then to the program.
|
/// Bytes from the keyboard: back to the newest line, no selection, then to the program, which sees done and error records.
|
||||||
pub(super) fn input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
|
pub(super) fn input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
|
||||||
let mut term = self.pty.term.lock();
|
let mut term = self.pty.term.lock();
|
||||||
term.scroll_display(Scroll::Bottom);
|
term.scroll_display(Scroll::Bottom);
|
||||||
@@ -77,6 +77,9 @@ impl TerminalView {
|
|||||||
}
|
}
|
||||||
drop(term);
|
drop(term);
|
||||||
self.pty.write(bytes);
|
self.pty.write(bytes);
|
||||||
|
if self.records.seen() {
|
||||||
|
cx.emit(TerminalEvent::Records);
|
||||||
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ mod view;
|
|||||||
pub mod zoom;
|
pub mod zoom;
|
||||||
|
|
||||||
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
|
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
|
||||||
|
pub use status::{Kind, Record, Records, State};
|
||||||
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView, load_font};
|
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView, load_font};
|
||||||
+55
-3
@@ -232,19 +232,20 @@ impl Records {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// herdr's agents in place of the last ones, unless they are the same.
|
/// herdr's agents in place of the last ones; true when they differ.
|
||||||
pub fn herdr(&mut self, agents: &[(String, Record)]) {
|
pub fn herdr(&mut self, agents: &[(String, Record)]) -> bool {
|
||||||
let held = self.list.iter().filter(|(id, ..)| id.starts_with(HERDR));
|
let held = self.list.iter().filter(|(id, ..)| id.starts_with(HERDR));
|
||||||
if held
|
if held
|
||||||
.map(|(id, record, _)| (id, record))
|
.map(|(id, record, _)| (id, record))
|
||||||
.eq(agents.iter().map(|(id, record)| (id, record)))
|
.eq(agents.iter().map(|(id, record)| (id, record)))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
self.list.retain(|(id, ..)| !id.starts_with(HERDR));
|
self.list.retain(|(id, ..)| !id.starts_with(HERDR));
|
||||||
for (id, record) in agents {
|
for (id, record) in agents {
|
||||||
self.put(id.clone(), record.clone());
|
self.put(id.clone(), record.clone());
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A full reset: every record goes, and OSC 9;4 maps again.
|
/// A full reset: every record goes, and OSC 9;4 maps again.
|
||||||
@@ -287,6 +288,57 @@ impl Records {
|
|||||||
self.list
|
self.list
|
||||||
.retain(|(_, record, _)| matches!(record.state, State::Done | State::Error));
|
.retain(|(_, record, _)| matches!(record.state, State::Done | State::Error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user typed: done and error records are seen and go, but herdr's, which herdr marks seen; true when any went.
|
||||||
|
pub fn seen(&mut self) -> bool {
|
||||||
|
let before = self.list.len();
|
||||||
|
self.list.retain(|(id, record, _)| {
|
||||||
|
id.starts_with(HERDR) || !matches!(record.state, State::Done | State::Error)
|
||||||
|
});
|
||||||
|
self.list.len() != before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The record a row shows: blocked, else error, working, done or idle, the one reported last in its state.
|
||||||
|
pub fn shown(&self) -> Option<&Record> {
|
||||||
|
[
|
||||||
|
State::Blocked,
|
||||||
|
State::Error,
|
||||||
|
State::Working,
|
||||||
|
State::Done,
|
||||||
|
State::Idle,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|state| {
|
||||||
|
self.list
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, record, _)| record.state == state)
|
||||||
|
.max_by_key(|(.., at)| *at)
|
||||||
|
.map(|(_, record, _)| record)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each record with its id, in the order first reported.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&str, &Record)> {
|
||||||
|
self.list
|
||||||
|
.iter()
|
||||||
|
.map(|(id, record, _)| (id.as_str(), record))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app of the record at `id`: its own, else its nearest ancestor's, the root's last; herdr's agents have none.
|
||||||
|
pub fn app(&self, id: &str) -> Option<&str> {
|
||||||
|
let mut at = id;
|
||||||
|
loop {
|
||||||
|
let app = self
|
||||||
|
.list
|
||||||
|
.iter()
|
||||||
|
.find(|(name, ..)| name == at)
|
||||||
|
.and_then(|(_, record, _)| record.app.as_deref());
|
||||||
|
if app.is_some() || at.is_empty() || at.starts_with(HERDR) {
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
at = at.rfind('/').map_or("", |slash| &at[..slash]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -298,3 +298,76 @@ fn osc_9_4_maps_to_the_root_record_until_a_program_reports() {
|
|||||||
"a full reset maps again"
|
"a full reset maps again"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_row_shows_the_most_pressing_record_the_last_reported_in_its_state() {
|
||||||
|
let mut records = Records::default();
|
||||||
|
assert_eq!(records.shown(), None);
|
||||||
|
let shown = |records: &Records| records.shown().map(|record| (record.state, record.kind));
|
||||||
|
records.report("a".into(), record(State::Idle, None));
|
||||||
|
assert_eq!(shown(&records), Some((State::Idle, None)));
|
||||||
|
records.report("b".into(), record(State::Done, None));
|
||||||
|
assert_eq!(shown(&records), Some((State::Done, None)));
|
||||||
|
records.report("c".into(), record(State::Working, None));
|
||||||
|
assert_eq!(shown(&records), Some((State::Working, None)));
|
||||||
|
records.report("d".into(), record(State::Error, None));
|
||||||
|
assert_eq!(shown(&records), Some((State::Error, None)));
|
||||||
|
let asks = |kind| Record {
|
||||||
|
kind: Some(kind),
|
||||||
|
..record(State::Blocked, None)
|
||||||
|
};
|
||||||
|
records.report("e".into(), asks(Kind::Auth));
|
||||||
|
records.report("f".into(), asks(Kind::Question));
|
||||||
|
assert_eq!(
|
||||||
|
shown(&records),
|
||||||
|
Some((State::Blocked, Some(Kind::Question)))
|
||||||
|
);
|
||||||
|
records.report("e".into(), asks(Kind::Auth));
|
||||||
|
assert_eq!(
|
||||||
|
shown(&records),
|
||||||
|
Some((State::Blocked, Some(Kind::Auth))),
|
||||||
|
"the last reported speaks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_without_an_app_takes_its_nearest_ancestor_s() {
|
||||||
|
let mut records = Records::default();
|
||||||
|
records.report("us/east".into(), record(State::Working, None));
|
||||||
|
assert_eq!(records.app("us/east"), None);
|
||||||
|
records.report(String::new(), record(State::Working, Some("deploy")));
|
||||||
|
records.report("us".into(), record(State::Working, Some("push")));
|
||||||
|
records.report("us/east/a".into(), record(State::Working, None));
|
||||||
|
records.report("eu/west".into(), record(State::Working, None));
|
||||||
|
assert_eq!(
|
||||||
|
records.app("us/east/a"),
|
||||||
|
Some("push"),
|
||||||
|
"past a parent without one"
|
||||||
|
);
|
||||||
|
assert_eq!(records.app("eu/west"), Some("deploy"), "the root's last");
|
||||||
|
assert_eq!(records.app(""), Some("deploy"));
|
||||||
|
assert_eq!(
|
||||||
|
records.app("usa"),
|
||||||
|
Some("deploy"),
|
||||||
|
"a sibling's name is no parent"
|
||||||
|
);
|
||||||
|
records.herdr(&[("herdr:w1:p1".into(), record(State::Working, None))]);
|
||||||
|
assert_eq!(
|
||||||
|
records.app("herdr:w1:p1"),
|
||||||
|
None,
|
||||||
|
"herdr's agents are no program's children"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_sees_done_and_error_but_herdr_s() {
|
||||||
|
let mut records = Records::default();
|
||||||
|
records.report("a".into(), record(State::Done, None));
|
||||||
|
records.report("b".into(), record(State::Error, None));
|
||||||
|
records.report("c".into(), record(State::Working, None));
|
||||||
|
assert!(records.herdr(&[("herdr:w1:p1".into(), record(State::Done, None))]));
|
||||||
|
assert!(records.seen());
|
||||||
|
let ids: Vec<&str> = records.iter().map(|(id, _)| id).collect();
|
||||||
|
assert_eq!(ids, ["c", "herdr:w1:p1"]);
|
||||||
|
assert!(!records.seen(), "nothing more to see");
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ pub enum TerminalEvent {
|
|||||||
Finished(Option<i32>),
|
Finished(Option<i32>),
|
||||||
/// A notification to show or close, OSC 9, 777 or 99.
|
/// A notification to show or close, OSC 9, 777 or 99.
|
||||||
Notice(Ask),
|
Notice(Ask),
|
||||||
|
/// Its records may have changed.
|
||||||
|
Records,
|
||||||
Exited(ExitStatus),
|
Exited(ExitStatus),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +150,11 @@ impl TerminalView {
|
|||||||
self.pty.pid
|
self.pty.pid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What programs in this terminal say they are doing.
|
||||||
|
pub fn records(&self) -> &Records {
|
||||||
|
&self.records
|
||||||
|
}
|
||||||
|
|
||||||
fn pump(mut events: Events, cx: &mut Context<Self>) -> Task<()> {
|
fn pump(mut events: Events, cx: &mut Context<Self>) -> Task<()> {
|
||||||
cx.spawn(async move |view, cx| {
|
cx.spawn(async move |view, cx| {
|
||||||
while let Some(first) = events.next().await {
|
while let Some(first) = events.next().await {
|
||||||
@@ -220,6 +227,15 @@ impl TerminalView {
|
|||||||
let drawn = events
|
let drawn = events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| matches!(event, PtyEvent::Term(Event::Wakeup)));
|
.any(|event| matches!(event, PtyEvent::Term(Event::Wakeup)));
|
||||||
|
let restated = events.iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
PtyEvent::Mark(Mark::Prompt)
|
||||||
|
| PtyEvent::Status(Status::Report(..) | Status::Clear(_))
|
||||||
|
| PtyEvent::Ask(Ask::Progress(_))
|
||||||
|
| PtyEvent::Reset
|
||||||
|
)
|
||||||
|
});
|
||||||
for event in events {
|
for event in events {
|
||||||
match event {
|
match event {
|
||||||
PtyEvent::Folder(folder) => {
|
PtyEvent::Folder(folder) => {
|
||||||
@@ -241,6 +257,9 @@ impl TerminalView {
|
|||||||
if drawn && self.finder.is_some() {
|
if drawn && self.finder.is_some() {
|
||||||
self.search(false, cx);
|
self.search(false, cx);
|
||||||
}
|
}
|
||||||
|
if restated {
|
||||||
|
cx.emit(TerminalEvent::Records);
|
||||||
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -154,7 +154,9 @@ impl Workspace {
|
|||||||
folder: launch.cwd,
|
folder: launch.cwd,
|
||||||
reports: launch.reports,
|
reports: launch.reports,
|
||||||
bell: false,
|
bell: false,
|
||||||
|
waiting: false,
|
||||||
status: None,
|
status: None,
|
||||||
|
state: None,
|
||||||
_events: events,
|
_events: events,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,7 +186,7 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows session `index`, clears its bell, and gives its terminal the keys.
|
/// Shows session `index`, clears its bell and its waiting mark, and gives its terminal the keys.
|
||||||
pub fn select(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn select(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if index >= self.sessions.len() {
|
if index >= self.sessions.len() {
|
||||||
return;
|
return;
|
||||||
@@ -196,6 +198,7 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
let session = &mut self.sessions[index];
|
let session = &mut self.sessions[index];
|
||||||
session.bell = false;
|
session.bell = false;
|
||||||
|
session.waiting = false;
|
||||||
window.focus(&session.terminal.focus_handle(cx), cx);
|
window.focus(&session.terminal.focus_handle(cx), cx);
|
||||||
let folder = session.folder.clone();
|
let folder = session.folder.clone();
|
||||||
self.files.update(cx, |files, cx| files.show(&folder, cx));
|
self.files.update(cx, |files, cx| files.show(&folder, cx));
|
||||||
|
|||||||
+14
-1
@@ -1,7 +1,7 @@
|
|||||||
use gpui::{Context, EntityId, Window};
|
use gpui::{Context, EntityId, Window};
|
||||||
|
|
||||||
use super::Workspace;
|
use super::Workspace;
|
||||||
use crate::term::TerminalEvent;
|
use crate::term::{State, TerminalEvent};
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
/// What session `id`'s terminal reported.
|
/// What session `id`'s terminal reported.
|
||||||
@@ -46,6 +46,19 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
session.status = *status;
|
session.status = *status;
|
||||||
}
|
}
|
||||||
|
TerminalEvent::Records => {
|
||||||
|
let terminal = session.terminal.read(cx);
|
||||||
|
let state = terminal.records().shown().map(|record| record.state);
|
||||||
|
if state != session.state {
|
||||||
|
log::info!("app: session {} shows {state:?}", terminal.pid());
|
||||||
|
// A hidden session that starts waiting marks its row until it is shown.
|
||||||
|
if state == Some(State::Blocked) && index != self.active {
|
||||||
|
log::debug!("app: hidden session {} waits", index + 1);
|
||||||
|
session.waiting = true;
|
||||||
|
}
|
||||||
|
session.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
TerminalEvent::Notice(ask) => {
|
TerminalEvent::Notice(ask) => {
|
||||||
let pid = session.terminal.read(cx).pid();
|
let pid = session.terminal.read(cx).pid();
|
||||||
log::info!("app: session {pid} asks {ask:?}");
|
log::info!("app: session {pid} asks {ask:?}");
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
|||||||
| Progress, OSC 9;4, as the root record until an OSC 7501 report; notifications, OSC 9, 777 and 99, read | T37 | `term::notify` tests: OSC 9 reads as Ghostty reads it, ConEmu's commands no notifications, 9;4's progress clamped; OSC 777's notify; OSC 99 gathers chunks by id, base64 split before encoding or after and a character across chunks among them, keeps the newlines base64 carries, reads options, close, query and alive, and drops what breaks its rules with what it gathered; its answers name the asker's id. `term::tap`: an OSC 99 body keeps its metadata and a full payload. `term::status`: 9;4 maps to the root record until a report, and again after a full reset. `term::pty`: OSC 9, 777, chunked 99 and 9;4 reach the session in order, and a full encoded chunk with the lines base64 carries reaches it as one notification. In the app on macOS, a program read OSC 99's query and alive answers before DA1's, and four notifications reached the workspace's log | Pass |
|
| Progress, OSC 9;4, as the root record until an OSC 7501 report; notifications, OSC 9, 777 and 99, read | T37 | `term::notify` tests: OSC 9 reads as Ghostty reads it, ConEmu's commands no notifications, 9;4's progress clamped; OSC 777's notify; OSC 99 gathers chunks by id, base64 split before encoding or after and a character across chunks among them, keeps the newlines base64 carries, reads options, close, query and alive, and drops what breaks its rules with what it gathered; its answers name the asker's id. `term::tap`: an OSC 99 body keeps its metadata and a full payload. `term::status`: 9;4 maps to the root record until a report, and again after a full reset. `term::pty`: OSC 9, 777, chunked 99 and 9;4 reach the session in order, and a full encoded chunk with the lines base64 carries reaches it as one notification. In the app on macOS, a program read OSC 99's query and alive answers before DA1's, and four notifications reached the workspace's log | Pass |
|
||||||
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS, Linux and Windows | T29, T32 | `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, then `exec herdr` from that bash, with `HERDR_SOCKET_PATH` relative to its folder, was followed through the socket there; on macOS under `shot`, herdr 0.9.5 as the program and again by `exec` from `sh` with a relative socket, its pane's cd moved the panel; on Windows, `term::herdr` tests on the CI runner: the folder under `APPDATA` and `USERPROFILE`, the `herdr.exe` nearest under the shell, this test's name, arguments and variables read through the live process list and `sysinfo`, a round trip over a named pipe; no real herdr has run on Windows yet | Partial |
|
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS, Linux and Windows | T29, T32 | `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, then `exec herdr` from that bash, with `HERDR_SOCKET_PATH` relative to its folder, was followed through the socket there; on macOS under `shot`, herdr 0.9.5 as the program and again by `exec` from `sh` with a relative socket, its pane's cd moved the panel; on Windows, `term::herdr` tests on the CI runner: the folder under `APPDATA` and `USERPROFILE`, the `herdr.exe` nearest under the shell, this test's name, arguments and variables read through the live process list and `sysinfo`, a round trip over a named pipe; no real herdr has run on Windows yet | Partial |
|
||||||
| Inside herdr, herdr's agent states are the session's records | T38 | `term::herdr` tests: each agent in a state herdr knows is its pane's record, its app, title and message from herdr's fields, unknown and new states passed by, and a snapshot without agents lists none. `term::status`: herdr's agents replace their own records alone, a program's `herdr/build` kept through an empty snapshot, a full one and herdr's leaving, since a program's id ends at a colon; the same agents change nothing, and the next snapshot puts them back after a full reset | Pass |
|
| Inside herdr, herdr's agent states are the session's records | T38 | `term::herdr` tests: each agent in a state herdr knows is its pane's record, its app, title and message from herdr's fields, unknown and new states passed by, and a snapshot without agents lists none. `term::status`: herdr's agents replace their own records alone, a program's `herdr/build` kept through an empty snapshot, a full one and herdr's leaving, since a program's id ends at a colon; the same agents change nothing, and the next snapshot puts them back after a full reset | Pass |
|
||||||
|
| A row shows its session's status, hover each record; a hidden session that starts waiting marks its row | T39 | `term::status` tests: blocked, then error, working, done and idle show, the record reported last in its state speaks; an app comes from the nearest ancestor that names one; typing sees done and error, herdr's aside. `sessions` tests: hover lists each record's state, app, title and message, ten at most, then a failed command's status; a program's text shows on one line without direction controls or invisible formatting, joiners kept, cut at 120 characters. In the app on macOS, `shot` showed each mark: the ring at 40%, the still loader, a shield over three records whose hover listed each, the root's app inherited, a check, a cross, a hollow circle, an alert after `133;D;1`, a hidden session's question mark with its title strong, and a done record gone after a typed key | Pass |
|
||||||
| Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | 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 |
|
| 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 |
|
| JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |
|
||||||
|
|||||||
Reference in new issue
Block a user