Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb9610988e
|
||
|
|
1e08cae4c4
|
||
|
|
7569f0011f
|
||
|
|
336c05beb7
|
||
|
|
c62c17fa7c
|
||
|
|
140c2a5741
|
||
|
|
22752a28db
|
||
|
|
52576bb8ee
|
||
|
|
efda50f142
|
||
|
|
3aaeb36bd1
|
||
|
|
31de5e44ef
|
||
|
|
53b8978dff
|
||
|
|
85bcbf466f
|
||
|
|
b504bfb9b8
|
||
|
|
7d56063df9
|
||
|
|
a175b18743
|
||
|
|
72acbe4bb0
|
||
|
|
628ed6b8c5
|
||
|
|
ef33a0d34e
|
||
|
|
30e65e3435
|
||
|
|
4a269cb57a
|
||
|
|
42b272ece6
|
||
|
|
99db56bab1
|
||
|
|
11ec0effa0
|
||
|
|
e2bb615a19
|
||
|
|
310c219386
|
||
|
|
15e83f5b7e
|
||
|
|
d3700f5278
|
||
|
|
2c25116703
|
||
|
|
d4bc6e69fa
|
||
|
|
0a22200725
|
||
|
|
c53b540f42
|
||
|
|
cccfb2c82b
|
||
|
|
3ea8b616f5
|
||
|
|
2173c74fc5
|
||
|
|
da55942050
|
||
|
|
6336c05528
|
||
|
|
f85e4c1169
|
||
|
|
3b5bf46093
|
||
|
|
d7dc1af6b9
|
||
|
|
7cb2dffc45
|
||
|
|
9a0c0d9dbb
|
||
|
|
2a1060ce48
|
||
|
|
436480248a
|
||
|
|
4068784f66
|
||
|
|
9729d24605
|
||
|
|
2f58a0670e
|
||
|
|
1bf420ce41
|
||
|
|
a5dbfbcbae
|
||
|
|
2af45cbfb9
|
||
|
|
3e30f95543
|
||
|
|
6c610fdc6e
|
||
|
|
8bc94edf8d
|
||
|
|
741e48bc4a
|
||
|
|
c25ca92921
|
||
|
|
1faf8ca0be
|
||
|
|
aae7fba6a9
|
||
|
|
d8ebadea2d
|
||
|
|
340c7a92ea
|
||
|
|
2e9ed4de75
|
||
|
|
194c7bb9d5
|
||
|
|
43ea5b1a07
|
||
|
|
7bfb462ffa
|
||
|
|
90a46257a3
|
||
|
|
c316d271a2
|
||
|
|
0f87676ae2
|
||
|
|
6f7d3a9aeb
|
||
|
|
2d8fa64622
|
||
|
|
59e41ea9bb
|
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Dispatched once the version's draft release holds the Windows and Linux builds.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos:
|
||||||
|
# The owner's login session, whose keychain holds the Developer ID identity.
|
||||||
|
runs-on: macos-signing
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
# The runner's PATH lacks the host's rustup.
|
||||||
|
- run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- run: rustup toolchain install --no-self-update
|
||||||
|
- name: Build, sign and notarize the disk image
|
||||||
|
env:
|
||||||
|
SIGN_IDENTITY: "Developer ID Application: Yang Zhang (9PDD8M2SMS)"
|
||||||
|
NOTARY_KEY: ${{ runner.temp }}/notary.p8
|
||||||
|
NOTARY_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
NOTARY_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
NOTARY_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||||
|
run: |
|
||||||
|
trap 'rm -f "$NOTARY_KEY"' EXIT
|
||||||
|
(umask 077 && printf '%s' "$NOTARY_KEY_P8" > "$NOTARY_KEY")
|
||||||
|
scripts/mac-app.sh
|
||||||
|
- name: Put the disk image on the release
|
||||||
|
env:
|
||||||
|
RELEASE_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
version=$(cargo pkgid)
|
||||||
|
scripts/release-asset.sh "target/release/ELYTerminal-${version##*[#@]}.dmg"
|
||||||
@@ -7,8 +7,9 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
- Rust 1.95.0, as `rust-toolchain.toml` pins it, edition 2024. One binary crate, `elyterminal`.
|
- Rust 1.95.0, as `rust-toolchain.toml` pins it, edition 2024. One binary crate, `elyterminal`.
|
||||||
- Ely (`ely-gpui-component`) from git.zacharyzhang.com, pinned by rev. gpui and gpui_platform from crates.io's `gpui-pre`, the exact version Ely pins, under their crate names through `package`.
|
- Ely (`ely-gpui-component`) from git.zacharyzhang.com, pinned by rev. gpui and gpui_platform from crates.io's `gpui-pre`, the exact version Ely pins, under their crate names through `package`.
|
||||||
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
|
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
|
||||||
- `serde`, `serde_json`: settings. `dirs`: the config folder. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
|
- `serde`, `serde_json`: settings. `dirs`: the config folder. `base64`, the version alacritty pulls in for OSC 52: OSC 7501's text. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
|
||||||
- On Linux, `wayland-client`, `wayland-backend` and `wayland-protocols` (staging) ask the compositor for `ext_background_effect_v1` on gpui's own connection; `wayland-server` hosts a compositor in the tests. `x11rb` sets `_KDE_NET_WM_BLUR_BEHIND_REGION`; its test runs Xvfb.
|
- On Linux, `wayland-client`, `wayland-backend` and `wayland-protocols` (staging) ask the compositor for `ext_background_effect_v1` on gpui's own connection; `wayland-server` hosts a compositor in the tests. `x11rb` sets `_KDE_NET_WM_BLUR_BEHIND_REGION`; its test runs Xvfb.
|
||||||
|
- On Windows, `sysinfo` reads herdr's client's arguments and variables; `windows-sys`, the version the Windows build already holds, lists processes and waits on herdr's pipe.
|
||||||
- `resvg`, a dev dependency at the version gpui uses, renders the icon's SVG for the macOS app and the Windows `.ico`. `embed-resource`, a build dependency at the version gpui uses, puts the icon into the Windows executable.
|
- `resvg`, a dev dependency at the version gpui uses, renders the icon's SVG for the macOS app and the Windows `.ico`. `embed-resource`, a build dependency at the version gpui uses, puts the icon into the Windows executable.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -22,18 +23,19 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
- mouse gestures handed to gpui's view, at points from the content's top-left, modifiers as for keys: `click@x,y`, `click2@`, `click3@`, `rclick@`, `mclick@`, `drag@x,y@x,y`, `move@x,y`, the steps `ldown@`, `lup@`, `ldrag@`, `rdown@`, `rup@`, `mdown@`, `mup@`, and `scrollup@x,y@lines`, `scrolldown@x,y@lines`.
|
- mouse gestures handed to gpui's view, at points from the content's top-left, modifiers as for keys: `click@x,y`, `click2@`, `click3@`, `rclick@`, `mclick@`, `drag@x,y@x,y`, `move@x,y`, the steps `ldown@`, `lup@`, `ldrag@`, `rdown@`, `rup@`, `mdown@`, `mup@`, and `scrollup@x,y@lines`, `scrolldown@x,y@lines`.
|
||||||
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
|
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
|
||||||
- `scripts/linux.sh ARGS` runs `cargo ARGS` on the working tree in a Linux container, the build kept in Docker volumes; `scripts/linux.sh test --locked` runs the tests as a user, not root, as CI does. The tree goes in as a tar stream, every file dated now: Colima's shared folder can show the VM a file as it was before an edit, and cargo judges by date, so a tree older than the last build would reuse that build. It needs a Docker daemon: on macOS, Colima (`colima start`).
|
- `scripts/linux.sh ARGS` runs `cargo ARGS` on the working tree in a Linux container, the build kept in Docker volumes; `scripts/linux.sh test --locked` runs the tests as a user, not root, as CI does. The tree goes in as a tar stream, every file dated now: Colima's shared folder can show the VM a file as it was before an edit, and cargo judges by date, so a tree older than the last build would reuse that build. It needs a Docker daemon: on macOS, Colima (`colima start`).
|
||||||
- `scripts/mac-app.sh` builds `target/release/ELYTerminal.app` from a release build, signed ad hoc, and `ELYTerminal-VERSION.dmg` beside it, the app next to a link to Applications. Its icon: `examples/icons.rs` renders `assets/icons/elyterminal-macos.svg` into an iconset, which `iconutil` packs.
|
- `scripts/mac-app.sh` builds `target/release/ELYTerminal.app` from a release build, signed ad hoc, and `ELYTerminal-VERSION.dmg` beside it, the app next to a link to Applications. Given `SIGN_IDENTITY` and the App Store Connect key, `NOTARY_KEY` (its .p8 file), `NOTARY_KEY_ID` and `NOTARY_ISSUER`, it signs with that identity under the hardened runtime, then notarizes and staples the image, and it stops before building when one is missing or no keychain holds the identity. Its icon: `examples/icons.rs` renders `assets/icons/elyterminal-macos.svg` into an iconset, which `iconutil` packs.
|
||||||
- `scripts/windows-zip.ps1`, on Windows with Visual Studio's C++ tools, builds `elyterminal.exe` with its icon and zips it as `ELYTerminal-VERSION-windows-ARCH.zip` in `target/release`. After an edit to the figure, `cargo run --example icons -- ico assets/icons/elyterminal.svg assets/icons/elyterminal.ico` renders the icon again.
|
- `scripts/windows-zip.ps1`, on Windows with Visual Studio's C++ tools, builds `elyterminal.exe` with its icon and zips it as `ELYTerminal-VERSION-windows-ARCH.zip` in `target/release`. After an edit to the figure, `cargo run --example icons -- ico assets/icons/elyterminal.svg assets/icons/elyterminal.ico` renders the icon again.
|
||||||
- `scripts/linux-tar.sh` builds the release in Docker on `rust:1.95.0-bookworm` and packs it with `com.zacharyzhang.elyterminal.desktop` and the icon as `ELYTerminal-VERSION-linux-ARCH.tar.gz` in `target/release`.
|
- `scripts/linux-tar.sh` builds the release in Docker on `rust:1.95.0-bookworm` and packs it with `com.zacharyzhang.elyterminal.desktop` and the icon as `ELYTerminal-VERSION-linux-ARCH.tar.gz` in `target/release`.
|
||||||
|
- `scripts/release-asset.sh FILE` puts FILE on the Gitea release of this version, a draft too, in place of a file of its name; `RELEASE_API` and `RELEASE_TOKEN` say where and as whom. The Release workflow, `.github/workflows/release.yml`, dispatched by hand, builds the signed and notarized disk image on the `macos-signing` runner and puts it on the release.
|
||||||
|
|
||||||
## 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. `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/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, the state its row shows, the program's exit; and a press on a notification a session posted. `src/sessions.rs`: a session and its row, with the mark of its status and what hover shows of it; `sessions/orb` draws the mark, 3 by 3 dots on one clock. `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.
|
- `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 0, 2, 7, 9, 99, 133, 777 and 7501, the title stack's CSI 22 t and 23 t, full resets and the mouse modes before the parser; `titles` keeps the title a session shows, read in the output's order; `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; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `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.
|
||||||
- `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/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; each marks prompts and commands with OSC 133 where the shell does not.
|
||||||
- `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/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).
|
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||||
- `assets/icons`: the icon, an original figure, Ely, holding a terminal: `elyterminal-macos.svg` on macOS's rounded square, `elyterminal.svg` the figure alone, for Windows and Linux; the macOS file holds a copy of the figure's markup, so an edit to the figure is made in both. `elyterminal.ico` is the figure at 16 to 256 px for Windows, and `elyterminal.rc` names it resource 1.
|
- `assets/icons`: the icon, an original figure, Ely, holding a terminal: `elyterminal-macos.svg` on macOS's rounded square, `elyterminal.svg` the figure alone, for Windows and Linux; the macOS file holds a copy of the figure's markup, so an edit to the figure is made in both. `elyterminal.ico` is the figure at 16 to 256 px for Windows, and `elyterminal.rc` names it resource 1.
|
||||||
@@ -62,28 +64,42 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
- 2026-10-14: an input method's commit goes to the program as UTF-8 under every kitty flag, as kitty (`keys.c`, `GLFW_IME_COMMIT_TEXT`) and Ghostty (test `kitty: composed text with report all`) send it.
|
- 2026-10-14: an input method's commit goes to the program as UTF-8 under every kitty flag, as kitty (`keys.c`, `GLFW_IME_COMMIT_TEXT`) and Ghostty (test `kitty: composed text with report all`) send it.
|
||||||
- 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it), and shifted punctuation reports the character it types as its key, since gpui clears Shift for it.
|
- 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it), and shifted punctuation reports the character it types as its key, since gpui clears Shift for it.
|
||||||
- 2026-10-14: on macOS, which Option key is down and whether a key repeats come from AppKit's current event: gpui rebuilds keys AppKit routes through its commands as fresh presses. As in Ghostty, `ApplePressAndHoldEnabled` is registered off, so held keys repeat instead of opening the accent menu, and a local event monitor hands Command key-ups to the focused view, since AppKit never routes them to the window.
|
- 2026-10-14: on macOS, which Option key is down and whether a key repeats come from AppKit's current event: gpui rebuilds keys AppKit routes through its commands as fresh presses. As in Ghostty, `ApplePressAndHoldEnabled` is registered off, so held keys repeat instead of opening the accent menu, and a local event monitor hands Command key-ups to the focused view, since AppKit never routes them to the window.
|
||||||
- 2026-10-14: mouse reports follow Ghostty's rules: X10 tracking sends presses alone without modifiers, SGR names the released button, motion inside one cell sends nothing, Shift takes the mouse back for selecting. The tap follows modes 9, 1000, 1002, 1003, 1005 and 1006 as xterm holds them: one tracking and one format at a time, the last set wins, any reset ends it, and RIS clears both. alacritty drops 9 and keeps each mode apart, so its mouse bits go unread. The tap's scanner reads controls inside a sequence as vte does: C0 runs, DEL and high bytes are skipped, CAN and SUB cancel, ESC restarts, subparameters are skipped. Motion reports the first held button: left, right, middle.
|
- 2026-10-14: mouse reports follow Ghostty's rules: X10 tracking sends presses alone without modifiers, SGR names the released button, motion inside one cell sends nothing, Shift takes the mouse back for selecting. The tap follows modes 9, 1000, 1002, 1003, 1005 and 1006 as xterm holds them: one tracking and one format at a time, the last set wins, any reset ends it, and RIS clears both. alacritty drops 9 and keeps each mode apart, so its mouse bits go unread. The tap's scanner reads controls inside a sequence as vte does: C0 runs, DEL and high bytes are skipped, CAN and SUB cancel, ESC restarts, subparameters are skipped. Motion reports the first held button: left, right, middle. The pointer over the grid is the arrow, as the owner asked, not Ghostty's I-beam.
|
||||||
- 2026-10-14: a wheel notch scrolls 3 lines, as Ghostty's discrete wheel; a trackpad scrolls its pixels. The wheel reports to a program that tracks the mouse, sends arrows on the alternate screen under mode 1007, and scrolls history otherwise.
|
- 2026-10-14: a wheel notch scrolls 3 lines, as Ghostty's discrete wheel; a trackpad scrolls its pixels. The wheel reports to a program that tracks the mouse, sends arrows on the alternate screen under mode 1007, and scrolls history otherwise.
|
||||||
- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection. Copy on select and OSC 52 writes to `p` or `s` go to the primary selection on Linux, as Ghostty's do; macOS and Windows have none, so they go to the clipboard.
|
- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; on Windows Ctrl-V pastes too, as in Windows Terminal, since Windows shells take it so, while on Linux it stays the program's, as in GNOME Terminal and Ghostty; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection. Copy on select and OSC 52 writes to `p` or `s` go to the primary selection on Linux, as Ghostty's do; macOS and Windows have none, so they go to the clipboard.
|
||||||
- 2026-10-14: a key's release goes out only when the terminal saw its press, so a key a binding takes sends no release, as in kitty.
|
- 2026-10-14: a key's release goes out only when the terminal saw its press, so a key a binding takes sends no release, as in kitty.
|
||||||
- 2026-10-14: find opens on Cmd-F (Ctrl-Shift-F off macOS) beside the grid, outside the `Terminal` key context, so its keys never reach the program. Matches wash in Ely's `warning` color, the current one stronger; the newest is current first, Enter steps older and Shift-Enter newer. Shift-Enter binds under `TerminalFind > ElyInput`, the context of Ely's text input, whose own Shift-Enter binding takes the key. Find reads the grid as it shows: combining marks ride their cells, hidden cells are blank, wrapped rows join, words are Unicode; alacritty's own search reads no marks and knows only ASCII word edges. New output, a resize and a settings change search again; the current match stays the same occurrence: its line kept, grown or cut at its end, held under the same line above or in its place, before such a line elsewhere, nearest where scrolling moved it; else the nearest by line, then column. Searching and following take one pass over the text and the matches.
|
- 2026-10-14: find opens on Cmd-F (Ctrl-Shift-F off macOS) beside the grid, outside the `Terminal` key context, so its keys never reach the program. Matches wash in Ely's `warning` color, the current one stronger; the newest is current first, Enter steps older and Shift-Enter newer. Shift-Enter binds under `TerminalFind > ElyInput`, the context of Ely's text input, whose own Shift-Enter binding takes the key. Find reads the grid as it shows: combining marks ride their cells, hidden cells are blank, wrapped rows join, words are Unicode; alacritty's own search reads no marks and knows only ASCII word edges. New output, a resize and a settings change search again; the current match stays the same occurrence: its line kept, grown or cut at its end, held under the same line above or in its place, before such a line elsewhere, nearest where scrolling moved it; else the nearest by line, then column. Searching and following take one pass over the text and the matches.
|
||||||
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. A full reset, `ESC c`, empties alacritty's title and sends only `CursorBlinkingChange`, in the parser's order, synchronized updates too; on that event the view replays the title through `set_options`, which sends it.
|
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. The title comes from the tap, in the output's order, read as vte and alacritty read it: OSC 0 and 2 set it, 15 parts past the number at most, since vte keeps 16 parameters, and 65536 bytes at most, past which it drops, logged; CSI 22 t and 23 t save it and bring it back; a full reset, `ESC c`, empties it. alacritty's own title events come after the tap's, and send nothing at a full reset, so they pass by.
|
||||||
- 2026-10-14: the title bar is transparent, as Ghostty's: the content runs under it, Ely's column headers drag the window, and the terminal's header shows the program's title. macOS draws its traffic lights at Ely's origin; elsewhere Ely draws the controls, so windows ask for client decorations, which only Linux reads, and Ely's `ResizeBorder` gives the edges. Cmd-B and Cmd-Alt-B (Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS) toggle the sessions and right columns, kept in the settings. A button at each end of the title bar does the same: `PanelLeft` leads the first column, right after the traffic lights, and `PanelRight` ends the last, so neither moves when a column hides; its tooltip says Hide or Show.
|
- 2026-10-14: the title bar is transparent, as Ghostty's: the content runs under it, Ely's column headers drag the window, and the terminal's header shows the program's title. macOS draws its traffic lights at Ely's origin; elsewhere Ely draws the controls, so windows ask for client decorations, which only Linux reads, and Ely's `ResizeBorder` gives the edges. Cmd-B and Cmd-Alt-B (Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS) toggle the sessions and right columns, kept in the settings. A button at each end of the title bar does the same: `PanelLeft` leads the first column, right after the traffic lights, and `PanelRight` ends the last, before the window buttons Ely draws on Windows and Linux, so neither moves when a column hides; its tooltip says Hide or Show. On Windows and Linux, which have no menu bar, a gear just inside `PanelRight` opens Settings, as Ctrl-, does. It sends `OpenSettings` through `Window::dispatch_action`, which waits for the click to finish: `App::dispatch_action` updates the active window at once, which fails inside that window's own handler, and only gpui's log says so. The Git and Files switch tops the right column, centered below the title bar: on Windows the caption buttons left the last header no room for it.
|
||||||
- 2026-10-14: session keys are Ghostty's: Cmd-T, Cmd-W, Cmd-1 to Cmd-8, Cmd-9 for the last, Ctrl-Tab and Ctrl-Shift-Tab; off macOS Ctrl-Shift-T, Ctrl-Shift-W and Alt with the digits. A digit past the last session goes on to the program. A new session runs the Shell setting in home or in the shown session's folder, as Start in says. A session whose program ends closes its row; the last row closes the window, and with no window left the app quits. A bell in a hidden session marks its row until it is shown. A hidden terminal hears no mouse release, so hiding it ends its mouse gesture as releases would: the selection drag ends, and the program hears each held button come up. A row's title is the program's, or the app's name while it names none.
|
- 2026-10-14: session keys are Ghostty's: Cmd-T, Cmd-W, Cmd-1 to Cmd-8, Cmd-9 for the last, Ctrl-Tab and Ctrl-Shift-Tab; off macOS Ctrl-Shift-T, Ctrl-Shift-W and Alt with the digits. A digit past the last session goes on to the program. A new session runs the Shell setting in home or in the shown session's folder, as Start in says. A session whose program ends closes its row; the last row closes the window, and with no window left the app quits. A bell in a hidden session marks its row until it is shown. A hidden terminal hears no mouse release, so hiding it ends its mouse gesture as releases would: the selection drag ends, and the program hears each held button come up. A row's title is the program's, or the app's name while it names none.
|
||||||
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press.
|
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder.
|
||||||
- 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Paths stay git's bytes until shown, so a name not in UTF-8 still names its own file; off Unix git writes UTF-8. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path; a copy is a new file, its source no part of it. A diff is git's own, without external tools or text converters, a conflict against HEAD, our side, so a file we deleted reads as new; it is read into Ely's stretches; a binary file has none, and an untracked folder none either. Without an index git exits 1 for a difference and a failure alike; only a difference prints. What git warns of while it works goes to the log. The log needs a commit at HEAD: git refuses it before the first.
|
- 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Paths stay git's bytes until shown, so a name not in UTF-8 still names its own file; off Unix git writes UTF-8. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path; a copy is a new file, its source no part of it. A diff is git's own, without external tools or text converters, a conflict against HEAD, our side, so a file we deleted reads as new; it is read into Ely's stretches; a binary file has none, and an untracked folder none either. Without an index git exits 1 for a difference and a failure alike; only a difference prints. What git warns of while it works goes to the log. The log needs a commit at HEAD: git refuses it before the first.
|
||||||
- 2026-10-14: the Git panel shows HEAD's branch, or its short commit while detached; Ely's `ChangesList` of conflicted, staged, unstaged and untracked files; Ely's `CommitInput`; Ely's `CommitList` of the last 50 commits. A row's press opens its diff in Ely's fullscreen `Dialog` under the title bar, in Ely's `DiffViewer`, unified or split; a binary file, an untracked folder and a change of no line say so instead. Every control Ely draws works: stage, unstage, stage all, unstage all, amend, and discard, which asks first in Ely's `ConfirmDialog`: an edit goes back to the index (`git restore`); a new file, untracked or only intended to be added, is deleted (`git clean`, `git rm -f`). Git's clean passes over a repository of its own without a word, so a clean that removed nothing is an error. Actions run off the main thread, each after the one asked before it; a failure shows in Ely's `Alert` with git's words until dismissed or the next action, and after any action Files reads its marks again. A folder change drops the open diff, and one still loading, and blanks the view unless the new folder lies inside the shown work tree, so a cd within it does not blink; no action runs until the shown folder has read, so none reaches the repository left behind. A commit empties only the fields that still hold what it committed, and only once the shown folder has read and is in the same repository: a draft typed while it ran, or while a folder read, stays. A path shows on one line and once: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8; a folder drops git's trailing slash, since no file shares its name. A section lists 500 files at most, and a line says how many are listed: Ely's list draws every row, and gpui copies every row's hitboxes and paint each frame, so 3000 rows halved the terminal's frames. The Git body is a cached view, so a terminal's output does not draw it again; it comes back from the Files tab with its lists scrolled to the top, since a view cached while hidden drops its widgets' state. Files stays uncached, so its tree keeps its open folders: Ely's `Tree` draws only the rows in view.
|
- 2026-10-14: the Git panel shows HEAD's branch, or its short commit while detached; Ely's `ChangesList` of conflicted, staged, unstaged and untracked files; Ely's `CommitInput`; Ely's `CommitList` of the last 50 commits. A row's press opens its diff in Ely's fullscreen `Dialog` under the title bar, in Ely's `DiffViewer`, unified or split; a binary file, an untracked folder and a change of no line say so instead. Every control Ely draws works: stage, unstage, stage all, unstage all, amend, and discard, which asks first in Ely's `ConfirmDialog`: an edit goes back to the index (`git restore`); a new file, untracked or only intended to be added, is deleted (`git clean`, `git rm -f`). Git's clean passes over a repository of its own without a word, so a clean that removed nothing is an error. Actions run off the main thread, each after the one asked before it; a failure shows in Ely's `Alert` with git's words until dismissed or the next action, and after any action Files reads its marks again. A folder change drops the open diff, and one still loading, and blanks the view unless the new folder lies inside the shown work tree, so a cd within it does not blink; no action runs until the shown folder has read, so none reaches the repository left behind. A commit empties only the fields that still hold what it committed, and only once the shown folder has read and is in the same repository: a draft typed while it ran, or while a folder read, stays. A path shows on one line and once: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8; a folder drops git's trailing slash, since no file shares its name. A section lists 500 files at most, and a line says how many are listed: Ely's list draws every row, and gpui copies every row's hitboxes and paint each frame, so 3000 rows halved the terminal's frames. The Git body is a cached view, so a terminal's output does not draw it again; it comes back from the Files tab with its lists scrolled to the top, since a view cached while hidden drops its widgets' state. Files stays uncached, so its tree keeps its open folders: Ely's `Tree` draws only the rows in view.
|
||||||
- 2026-10-14: Cmd-, opens the settings window, or brings the open one forward, and Cmd-W closes it; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. Categories sit in Ely's `SettingsLayout`, rows in its `SettingsRow`; a category joins the list with its rows. Each change goes through `SettingsStore::update`, which checks and saves, and every view reads the global, so it applies at once. A shell saves only when it names a program the running user may run, as the system's `access` answers, ACLs too: empty for the login shell, a file at a full path, or a name found in one of `PATH`'s full folders; a relative folder counts for none, since it would hang on each session's own folder. On Windows a name without an extension means its `.exe`, as `CreateProcess` reads it; a name with one keeps it. A program whose path is not UTF-8 counts for none: a session starts its program by text. A session runs that same full path, so the check and the start agree; else the row says why and the saved one stays, since a bad shell stops the next start. The window takes focus inside when it opens, so its keys find its context. The app quits with its last terminal window: settings alone hold no terminal.
|
- 2026-10-14: Cmd-, opens the settings window, or brings the open one forward, and Cmd-W closes it; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. Categories sit in the first of two columns of Ely's `ColumnShell`, an Ely `SelectableList` as `SettingsLayout` draws its own, and the open one's rows, Ely's `SettingsRow`, in the second; each column scrolls on its own, which `SettingsLayout`, one box for both, cannot. A category joins the list with its rows. Each change goes through `SettingsStore::update`, which checks and saves, and every view reads the global, so it applies at once. A shell saves only when it names a program the running user may run, as the system's `access` answers, ACLs too: empty for the login shell, a file at a full path, or a name found in one of `PATH`'s full folders; a relative folder counts for none, since it would hang on each session's own folder. On Windows a name without an extension means its `.exe`, as `CreateProcess` reads it; a name with one keeps it. A program whose path is not UTF-8 counts for none: a session starts its program by text. A session runs that same full path, so the check and the start agree; else the row says why and the saved one stays, since a bad shell stops the next start. The window takes focus inside when it opens, so its keys find its context. The app quits with its last terminal window: settings alone hold no terminal.
|
||||||
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||||
- 2026-10-14: Appearance shows every built-in theme as Ely's `RadioCard` in a `SimpleGrid`, each over a small window in the theme's colors. Separate dark theme copies the theme into `dark_theme`, so turning it on changes nothing until a dark card is picked. A theme change re-dresses the chrome at once. Font family comes from Ely's `FontPicker`; size, line height and padding from its `NumberInput`, which saves a number on Enter, on leaving the field and on each step. A font saves, and a settings file starts, only when gpui loads it as the terminal draws it: gpui falls back to another font without a word and loads none without an `m`, so the terminal draws with no font but its own. The cursor preview is a prompt in the terminal's font and theme, its cursor drawn as the terminal draws it, blinking on the terminal's beat. Opacity moves by whole percents, so the saved fraction carries no float noise. A change the store refuses leaves the setting as it was, and the page says why at its top; a number field drops its draft, and the font picker shows the saved family again. The settings window opens at 900 by 640, room for three theme columns.
|
- 2026-10-14: Appearance shows every built-in theme as Ely's `RadioCard` in a `SimpleGrid`, each over a small window in the theme's colors. Separate dark theme copies the theme into `dark_theme`, so turning it on changes nothing until a dark card is picked. A theme change re-dresses the chrome at once. Font family comes from Ely's `FontPicker`; size, line height and padding from its `NumberInput`, which saves a number on Enter, on leaving the field and on each step. A font saves, and a settings file starts, only when gpui loads it as the terminal draws it: gpui falls back to another font without a word and loads none without an `m`, so the terminal draws with no font but its own. The cursor preview is a prompt in the terminal's font and theme, its cursor drawn as the terminal draws it, blinking on the terminal's beat. Opacity moves by whole percents, so the saved fraction carries no float noise. A change the store refuses leaves the setting as it was, and the page says why at its top; a number field drops its draft, and the font picker shows the saved family again. The settings window opens at 900 by 640, room for three theme columns.
|
||||||
- 2026-10-14: Terminal shows scrollback lines, Option as Alt, on macOS only, since elsewhere Alt is Alt, and copy on select. The engine reads each when it needs it, so a change applies at once to every session; a smaller scrollback drops the history past it.
|
- 2026-10-14: Terminal shows the shell and where sessions start, which a General page held until the owner dropped it, then scrollback lines, Option as Alt, on macOS only, since elsewhere Alt is Alt, and copy on select, then every shortcut in Ely's `KeyboardShortcutsList`, read from the keymap: the bindings of each listed command that apply in the main window's terminal, matched at any depth of its context stack as gpui matches them, so the list cannot drift from the keys. The engine reads each when it needs it, so a change applies at once to every session; a smaller scrollback drops the history past it.
|
||||||
- 2026-10-14: the main window lets the desktop through while Background opacity is under 1, blurred or clear as Background blur says, through gpui's `Blurred` (macOS's NSVisualEffectView, Windows' acrylic) or `Transparent`; at 1 it is `Opaque`. The workspace sets it as the settings change. Ely's `ColumnShell::glass`, added to Ely for this, grounds the columns in the palette's glass, which the opacity sets, so the title bar and every column let the desktop through alike; the palette's `bg` stays solid, so a dialog, the Git diff among them, hides what lies under it. The terminal paints no ground over the glass, or the two would stack; a program's own background (OSC 11) paints over it. Ely's own themes pass their palette too, Ely's colors with the glass set. The settings window stays opaque.
|
- 2026-10-14: the main window lets the desktop through while Background opacity is under 1, blurred or clear as Background blur says, through gpui's `Blurred` (macOS's NSVisualEffectView, Windows' acrylic) or `Transparent`; at 1 it is `Opaque`. The workspace sets it as the settings change. Ely's `ColumnShell::glass`, added to Ely for this, grounds the columns in the palette's glass, which the opacity sets, so the title bar and every column let the desktop through alike; the palette's `bg` stays solid, so a dialog, the Git diff among them, hides what lies under it. The terminal paints no ground over the glass, or the two would stack; a program's own background (OSC 11) paints over it. Ely's own themes pass their palette too, Ely's colors with the glass set. The settings window stays opaque.
|
||||||
- 2026-10-15: on Wayland the window asks for `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur`. A guest connection on gpui's own display binds it, takes the effect for the window's surface once, and blurs the window's pane: the surface less Ely's resize margin on each free edge of a client-drawn window, worked out each frame and sent only when it changes; it goes and comes back with Blurred and lands at the surface's next commit, which every frame makes. The guest connection reads the display only while the window opens, before gpui's loop runs, and is never dropped: a drop destroys its proxies through the display, which gpui may close first. The log names what the compositor offers, so which way blur goes: `ext_background_effect_v1`, gpui's `org_kde_kwin_blur`, or none, the desktop clear. Its test runs a `wayland-server` compositor on a socket pair.
|
- 2026-10-15: on Wayland the window asks for `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur`. A guest connection on gpui's own display binds it, takes the effect for the window's surface once, and blurs the window's pane: the surface less Ely's resize margin on each free edge of a client-drawn window, worked out each frame and sent only when it changes; it goes and comes back with Blurred and lands at the surface's next commit, which every frame makes. The guest connection reads the display only while the window opens, before gpui's loop runs, and is never dropped: a drop destroys its proxies through the display, which gpui may close first. The log names what the compositor offers, so which way blur goes: `ext_background_effect_v1`, gpui's `org_kde_kwin_blur`, or none, the desktop clear. Its test runs a `wayland-server` compositor on a socket pair.
|
||||||
- 2026-10-15: on a client-drawn window, so on Linux, Ely's `ResizeBorder` paints its pane solid and shadowed under the content, which kept the desktop from showing at all. `ResizeBorder::glass`, added to Ely for this, leaves the pane to the content while the window is see-through: no ground, nor the shadow gpui draws under the box, which would tint it. So the blur covers the pane alone and the resize margin stays clear. On X11 the window names its pane in `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32: x, y, width, height in the window's pixels), as Ghostty names its own, and drops the property when Blurred goes; KWin reads it, other compositors pass it by. It goes over a connection of ELYTerminal's own to `$DISPLAY`, which gpui opens too: a property belongs to the window, whoever sets it. gpui's own `org_kde_kwin_blur` on KDE's Wayland still covers the whole surface, margin included, beyond ELYTerminal's reach.
|
- 2026-10-15: on a client-drawn window, so on Linux, Ely's `ResizeBorder` paints its pane solid and shadowed under the content, which kept the desktop from showing at all. `ResizeBorder::glass`, added to Ely for this, leaves the pane to the content while the window is see-through: no ground, nor the shadow gpui draws under the box, which would tint it. So the blur covers the pane alone and the resize margin stays clear. On X11 the window names its pane in `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32: x, y, width, height in the window's pixels), as Ghostty names its own, and drops the property when Blurred goes; KWin reads it, other compositors pass it by. It goes over a connection of ELYTerminal's own to `$DISPLAY`, which gpui opens too: a property belongs to the window, whoever sets it. gpui's own `org_kde_kwin_blur` on KDE's Wayland still covers the whole surface, margin included, beyond ELYTerminal's reach.
|
||||||
- 2026-10-15: the macOS menu bar is the PRD's: ELYTerminal (Settings…, Quit), Shell (New Session, Close Session), Edit (Copy, Paste, Select All, Find), View (Toggle Sessions, Toggle Right Panel, Bigger, Smaller, Reset Size), Window (Minimize, Zoom, then the windows macOS lists). An item shows the key bound to its action, so the menus are set after the bindings; Copy, Paste and Select All go through AppKit's `copy:`, `paste:` and `selectAll:`, as gpui's `OsAction` maps them. They are Ely's text input actions, which `forms` exports for this, and the terminal handles them too: gpui sends a menu's action to what holds focus and enables the item only where a handler waits, so one action serves the terminal, find and every field. The new keys are Ghostty's: Cmd-Q, Cmd-A, Cmd-= and Cmd-+, Cmd-minus, Cmd-0, Cmd-M. Off macOS Quit and Select All take Ctrl-Shift, since Ctrl with a letter types a control byte; the font's size takes plain Ctrl with =, +, minus and 0, which type none, as in Ghostty, Alacritty and Windows Terminal; Minimize has no key there. Bigger and Smaller move every session's font a point, inside the Size range, and Reset Size goes back to the setting; nothing saves the change, as in Ghostty. Select All runs from the first character in the history to the last on the screen, the blanks around them left out, as Ghostty's, though a space under a combining mark shows a character and counts as one; copy on select takes it, and a blank grid selects nothing.
|
- 2026-10-15: the macOS menu bar is the PRD's: ELYTerminal (Settings…, Quit), Shell (New Session, Close Session), Edit (Copy, Paste, Select All, Find), View (Toggle Sessions, Toggle Right Panel, Bigger, Smaller, Reset Size), Window (Minimize, Zoom, then the windows macOS lists). An item shows the key bound to its action, so the menus are set after the bindings; Copy, Paste and Select All go through AppKit's `copy:`, `paste:` and `selectAll:`, as gpui's `OsAction` maps them. They are Ely's text input actions, which `forms` exports for this, and the terminal handles them too: gpui sends a menu's action to what holds focus and enables the item only where a handler waits, so one action serves the terminal, find and every field. The new keys are Ghostty's: Cmd-Q, Cmd-A, Cmd-= and Cmd-+, Cmd-minus, Cmd-0, Cmd-M. Off macOS Quit and Select All take Ctrl-Shift, since Ctrl with a letter types a control byte; the font's size takes plain Ctrl with =, +, minus and 0, which type none, as in Ghostty, Alacritty and Windows Terminal; Minimize has no key there. Bigger and Smaller move every session's font a point, inside the Size range, and Reset Size goes back to the setting; nothing saves the change, as in Ghostty. Select All runs from the first character in the history to the last on the screen, the blanks around them left out, as Ghostty's, though a space under a combining mark shows a character and counts as one; copy on select takes it, and a blank grid selects nothing.
|
||||||
- 2026-10-15: CI runs on self-hosted Gitea runners, each job with what its runner must hold. macOS runs on the host, with fish and PowerShell installed, or the two shell tests fail as they should. Windows runs on the host, with rustup's `cargo` and `rustc` proxies beside `rustup` and Visual Studio's C++ tools. Ubuntu (`ubuntu-24.04`) runs as root in `docker.gitea.com/runner-images:ubuntu-24.04`, installs the libraries gpui links, and runs the tests as a user it makes, since root ignores the file modes they check; its runner serves this repository alone, in a VM of its own, so a build cannot starve other containers. The Linux and Windows jobs build without debuginfo, as `scripts/linux.sh` does: with it, linking gpui's test binaries outgrew that VM's 8 GB. The macOS and Windows jobs run on the owner's own machines, so they skip `Swatinem/rust-cache`: its post step cleans `$CARGO_HOME`, which there is the owner's own, and after every job it deleted `rustup` from `~/.cargo/bin`, leaving its proxies pointing at nothing, and pruned the shared registry; the Linux job keeps it, inside its container. A run that waits on a missing runner stays queued: cancel stale ones before a new runner registers, or it runs every one. The shell tests type a line only once the line editor reads raw keys, canonical mode off: on CI, a line typed before PowerShell's first read was echoed by the terminal and never run. Every job installs the toolchain `rust-toolchain.toml` names with `rustup toolchain install --no-self-update` and never runs `rustup default`, so the owner's machines keep their own default toolchain and rustup; the macOS job puts the host's `~/.cargo/bin` on the runner's PATH, and the Linux container installs rustup first. A toolchain file's components install only with the toolchain: one already there without them makes each cargo call try to download them, so `scripts/linux.sh` and `scripts/linux-tar.sh` add them to their images.
|
- 2026-10-15: CI runs on self-hosted Gitea runners, each job with what its runner must hold. macOS runs on the host, with fish and PowerShell installed, or the two shell tests fail as they should. Windows runs on the host, with rustup's `cargo` and `rustc` proxies beside `rustup` and Visual Studio's C++ tools. Ubuntu (`ubuntu-24.04`) runs as root in `docker.gitea.com/runner-images:ubuntu-24.04`, installs the libraries gpui links, and runs the tests as a user it makes, since root ignores the file modes they check; its runner serves this repository alone, in a VM of its own, so a build cannot starve other containers. The Linux and Windows jobs build without debuginfo, as `scripts/linux.sh` does: with it, linking gpui's test binaries outgrew that VM's 8 GB. The macOS and Windows jobs run on the owner's own machines, so they skip `Swatinem/rust-cache`: its post step cleans `$CARGO_HOME`, which there is the owner's own, and after every job it deleted `rustup` from `~/.cargo/bin`, leaving its proxies pointing at nothing, and pruned the shared registry; the Linux job keeps it, inside its container. A run that waits on a missing runner stays queued: cancel stale ones before a new runner registers, or it runs every one. The shell tests type a line only once the line editor reads raw keys, canonical mode off: on CI, a line typed before PowerShell's first read was echoed by the terminal and never run. Every job installs the toolchain `rust-toolchain.toml` names with `rustup toolchain install --no-self-update` and never runs `rustup default`, so the owner's machines keep their own default toolchain and rustup; the macOS job puts the host's `~/.cargo/bin` on the runner's PATH, and the Linux container installs rustup first. A toolchain file's components install only with the toolchain: one already there without them makes each cargo call try to download them, so `scripts/linux.sh` and `scripts/linux-tar.sh` add them to their images.
|
||||||
- 2026-10-15: on macOS 27 gpui's `Blurred` gives its NSVisualEffectView the `Selection` material, which builds no backdrop layer, so the desktop showed through sharp. `blur/macos` sets the view to `underWindowBackground` whenever the window turns Blurred: a gaussian blur of radius 30, the radius the owner's Ghostty uses; gpui's `updateLayer` still strips the material's tint and saturation. The view's presence proved nothing, so `shot` reads the radius off its backdrop layer.
|
- 2026-10-15: on macOS 27 gpui's `Blurred` gives its NSVisualEffectView the `Selection` material, which builds no backdrop layer, so the desktop showed through sharp. `blur/macos` sets the view to `underWindowBackground` whenever the window turns Blurred: a gaussian blur of radius 30, the radius the owner's Ghostty uses; gpui's `updateLayer` still strips the material's tint and saturation. The view's presence proved nothing, so `shot` reads the radius off its backdrop layer.
|
||||||
- 2026-10-15: the icon is drawn by hand in SVG, flat, so one figure serves both forms: on macOS it stands on a night-purple rounded square on Apple's grid (824 of 1024, radius 185) with sparkles, the gradient grained so it does not band; on Windows and Linux it stands alone, as Papirus draws app icons. The app is `com.zacharyzhang.elyterminal`, after the owner's domain, signed ad hoc: it opens on the Mac that built it, and Gatekeeper asks once on any other.
|
- 2026-10-15: the icon is drawn by hand in SVG, flat, so one figure serves both forms: on macOS it stands on a night-purple rounded square on Apple's grid (824 of 1024, radius 185) with sparkles, the gradient grained so it does not band; on Windows and Linux it stands alone, as Papirus draws app icons. The app is `com.zacharyzhang.elyterminal`, after the owner's domain. A local build is signed ad hoc: it opens on the Mac that built it, and Gatekeeper asks once on any other; a release's is signed with the owner's Developer ID and notarized, so it opens on any Mac.
|
||||||
- 2026-10-15: a release is a Gitea release tagged `vVERSION` with three builds, each from its script: the macOS disk image for Apple silicon, a Windows zip and a Linux tarball for x86_64. Each window names itself `com.zacharyzhang.elyterminal` through `Window::set_app_id` as it opens: on Linux that is Wayland's app id and X11's `WM_CLASS`, which the desktop entry's name and `StartupWMClass` match, so a dock pairs the window with the icon; gpui's X11 windows read no app id from `WindowOptions`.
|
- 2026-10-15: a release is a Gitea release tagged `vVERSION` with three builds, each from its script: the macOS disk image for Apple silicon, a Windows zip and a Linux tarball for x86_64. Each window names itself `com.zacharyzhang.elyterminal` through `Window::set_app_id` as it opens: on Linux that is Wayland's app id and X11's `WM_CLASS`, which the desktop entry's name and `StartupWMClass` match, so a dock pairs the window with the icon; gpui's X11 windows read no app id from `WindowOptions`.
|
||||||
- 2026-10-15: on Windows a release build is a GUI program, so no console window opens beside it; a debug build keeps its console for the log. git then starts with `CREATE_NO_WINDOW`, or each call would open a console. Windows builds link the C runtime statically, so the executable needs no Visual C++ redistributable. gpui's build script embeds its manifest, PerMonitorV2 DPI awareness among it; `build.rs` adds the icon as resource 1, which gpui loads for each window, and stops the build when no resource compiler is found.
|
- 2026-10-15: on Windows a release build is a GUI program, so no console window opens beside it; a debug build keeps its console for the log. git then starts with `CREATE_NO_WINDOW`, or each call would open a console. Windows builds link the C runtime statically, so the executable needs no Visual C++ redistributable. gpui's build script embeds its manifest, PerMonitorV2 DPI awareness among it; `build.rs` adds the icon as resource 1, which gpui loads for each window, and stops the build when no resource compiler is found.
|
||||||
- 2026-10-15: the Linux tarball builds on Debian 12, in `rust:1.95.0-bookworm`: a binary asks for the glibc symbol versions it was linked against, so it is linked against an old glibc, not the builder's. `scripts/linux-tar.sh` prints the glibc its build asks for, and the README names it: 2.35 for v0.1.0, which runs on Ubuntu 22.04.
|
- 2026-10-15: the Linux tarball builds on Debian 12, in `rust:1.95.0-bookworm`: a binary asks for the glibc symbol versions it was linked against, so it is linked against an old glibc, not the builder's. `scripts/linux-tar.sh` prints the glibc its build asks for, and the README names it: 2.35 for v0.1.0, which runs on Ubuntu 22.04.
|
||||||
|
- 2026-10-15: on Windows gpui answers the system's hit test from the last frame: under the pointer, a `WindowControlArea::Drag` hitbox that nothing occludes makes it the caption, and Windows keeps the press, so no click reaches the app. In v0.1.0 every title bar control was dead so. Ely now occludes the drag region under its window buttons, which then answer as close, maximize and minimize, and under each column header's item row, whose controls get plain presses; the row stays the scroll container, and its title is a drag region of its own.
|
||||||
|
- 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) and what executable that group's leader runs: an exec keeps the group and swaps the executable, so `exec herdr` from a shell is a new program, and one that did not read is tried again; on Linux the ` (deleted)` mark an update leaves on a running executable is set aside. When that 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`; a relative socket lies in the client's folder, as herdr reads it (`/proc/PID/cwd`; `proc_pidinfo` on macOS). 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; an answer that lands after the shell reported a folder is dropped, since the shell reports only once herdr has left. 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. On Windows, which has no foreground group, a Toolhelp32 snapshot lists the `herdr.exe` processes at or under the session's shell, none under another herdr, since what a herdr starts, its server too, is its own; when that list changes, `sysinfo` reads their arguments and variables, nearest first, names in upper case, as Windows matches them, and the first terminal client is followed, a server or a command beside it passed by; its folder is found as herdr 0.9 finds it there, `XDG_CONFIG_HOME`, then `APPDATA`, then `USERPROFILE\AppData\Roaming`, then `HOME\.config`; and its server answers on the pipe herdr names `\\.\pipe\` and the socket's path as written, relative or not. A busy pipe is waited for, as herdr's client waits, and the pipe's server may identify ELYTerminal but never act as it. The pipe has no timeout: a herdr that never answers holds that session's follow until it answers or exits.
|
||||||
|
- 2026-10-16: the main window opens at the size it was left at, which the owner asked for on macOS, Windows and Linux: `window_width` and `window_height` in the settings hold the size gpui's `window_bounds` gives 0.5 s after the bounds settle, saved through `SettingsStore::update`, which writes only a change, so a drag saves once and a move saves nothing. A maximized, full-screen or hidden window keeps the size from before; its state and size are read once they settle, since X11 marks a window maximized after its new size, gpui's X11 window then gives a maximized window's own size, and it calls a maximized window unmaximized while minimized. A window that shows again, uncovered, restored or back on its screen, is read again, so a size it took just before it hid is kept; a size it takes in the half second before it closes, or before it hides until the app quits, is lost. The window is no smaller than 100 by 100, the least the settings keep, or a smaller size the platform allows could not be kept. The window opens centered at whole points, since macOS grows a window by a point on a side whose edge falls on a half point; its place is not kept, and a kept side past the primary display's visible area shrinks to it, logged. A size outside 100 to 16384 stops the start, as any value out of range does.
|
||||||
|
- 2026-10-16: the sessions and right columns resize by a drag on their border toward the terminal, which the owner asked for on macOS, Windows and Linux. Ely's `ShellColumn::resizable`, added to Ely for this, puts Ely's `resize_handle` on the border toward the filling column, the sessions column's right and the right column's left: the handle draws that border, a filled hairline, since gpui paints a border no wider than half its box, and the line it drew at first showed half a pixel on macOS and none on X11; it occludes the header under it, so a press there drags the border, not the window, on Windows too, where an unoccluded drag region takes the press as the caption. A drag sets off from the wider of the width drawn and the one kept: a header's parts may hold a column wider, and a window too narrow for both widths squeezes them, where a drag from the squeezed width jumped back against the pointer; a drag that has not moved sideways changes nothing, and one that comes back where it set off gives the width it had. A set width in Ely became a flex basis, so a column holds its header's own parts: at a set width under them, Windows' caption buttons ran out of the window. The widths, 160 to 960 pixels, show at once and are saved in `sessions_width` and `panel_width` through `SettingsStore::update` 0.5 s after a drag rests, as the window's size is; a drag in the half second before the window closes is lost, and a save the store refuses puts the columns back. A window too narrow for both yields the terminal its header's width, as Ely's set widths give way.
|
||||||
|
- 2026-10-16: prompts and commands carry OSC 133 marks, which the owner asked for, read as Ghostty reads them: A or N starts a prompt, B or I ends it, C starts a command's output, and D ends the command with its status, D's first option, none when that is no number. The tap reads any program's marks before the parser, so it never sees the cursor: A's fresh line and L, which need it, are not done, and P adds nothing to A. Each session keeps the status its last command ended with. The integration marks what the shell leaves unmarked, and the user's prompt still sees the command's status. zsh: precmd reads `$?` and sends D after a command ran, preexec sends C, and a last precmd hook wraps PS1 in A and B under `prompt_percent`, which the first prompt says while it is off; it moves to the end of `precmd_functions` at each prompt, since a theme that sets PS1 in a hook of its own loads after it, and at the first prompt `sched +0` wraps it once more, since zsh runs due events after every precmd hook; that call returns the command's status, which the prompt shows, but it would reset `$pipestatus`, so it serves the first prompt alone. bash: PROMPT_COMMAND's first entry reads `$?`, which each entry resets, and its last wraps PS1. From bash 4.4 PS0 sends C and its subscript notes that a command ran, since bash prints no PS0 for an empty line, and the first entry sends D for it; both need `promptvars`, on by default. macOS's bash 3.2 has no PS0 and nothing else between reading a line and running it: its DEBUG trap runs for a key binding's command (`bind -x`) as for a typed one, with nothing to tell the two apart, runs for no line that is only a subshell, and under `extdebug` its status decides whether a command runs. So bash 3.2 sends no C, and Ghostty's automatic integration skips bash 3.2. Its D comes from PS1 once `\#`, the command number, has moved, which neither a key binding nor an empty line does; readline sends a prompt's escapes again at each redraw, so that D goes again with it, the same status. Every mark of a command rides an expansion, which `promptvars` turns on, so while it is off at start the script says so rather than mark nothing without a word. fish 4.3 marks its own prompts and commands, fish 4.0 all but B, older ones none: the script adds what the running fish lacks, around the prompt shown, and wraps it again when the user's changes. PowerShell: the prompt reads `$?` first and sets it back for the user's prompt through `Write-Error '' -ErrorAction Ignore`, which adds nothing to `$Error`. A failure ends with `$LASTEXITCODE` when a program set it during the line, since each run stores a new object, equal or not, which `[object]::ReferenceEquals` tells apart, unless the line surely ends in PowerShell: its syntax tree, read as PSReadLine hands the line over, ends in a cmdlet, as `Get-Command` resolves the name, aliases through, in a name not found, or in no command; such a line ends with 1, a cmdlet's failure under `-ErrorAction Ignore` too. A line that ends in a block, a function, a script or a call through a variable takes the code of a program it ran: only a hook on every command lookup would name the last command run, and it slowed a loop of commands by half. `$LASTEXITCODE` is read through `Get-Variable`, since strict mode refuses an unset variable and a prompt that fails falls back to `PS>`. C goes out when PSReadLine hands over a line that is not blank; without PSReadLine the script says it marks no command. herdr passes no OSC 133 on, so a session running herdr keeps the status from before it.
|
||||||
|
- 2026-10-16: the view jumps between prompts, which the owner asked for, as Ghostty jumps: Cmd-Up and Cmd-Down, with Shift or without, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt that starts before or after the view's top; a prompt on the screen's last page means the bottom, which the view cannot pass, and with none there nothing moves. alacritty's rows hold no marks, and the tap, which reads before the parser, never sees the cursor, so the tap rewrites what goes to the parser: an OSC that may be a mark is held whole until it ends and read as `marks` reads it, and a prompt's start becomes an OSC 8 link of the terminal's own, its target `\x7f`, which no program names, its id `\x7f` and the prompt's number; the prompt's end closes it. The parser stamps each cell the prompt prints with the link, SGR 0 keeps it, and it rides the cells through scroll, reflow and history; a prompt starts on the first row that shows its number, blank rows within it passed. A program's own link keeps its target and takes the prompt's number in its id: one opened inside a prompt carries it, and a prompt that starts inside one opens it again with the number and gives back its own id at the prompt's end, its target kept whole as the scanner keeps it; its end inside a prompt takes the prompt's link up again. A cursor restore that brings back a link passes the rewrite by. An OSC held, a body of 4096 bytes at most as the scanner keeps it and twice that with the controls vte skips, shows nothing until it ends; controls vte runs inside an escape go on before an ESC that waits, and only an ESC from ground waits: one that ends an OSC goes on with it, so what follows runs where the parser stands. A rewrite turns a mark of 8 bytes inside a program's link into that link again, its target up to 4096 bytes, so the tap reads about 2 KB of the buffer's room at a time, which moved 3 MB through a pty as fast as larger reads. Links, Cmd-press among them, pass over the prompt's link. The tap's OSC scanner ends a body as vte does, at BEL, ESC, CAN or SUB, and skips the controls vte skips, so a mark and its link agree.
|
||||||
|
- 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; `a`, `c` and `w` are kept for posting, and `o` passes by; a lone body is the title, and one with neither passes by. The query and alive are answered as the notifications below say; 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. The terminal posts the notifications, as below.
|
||||||
|
- 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: 3 by 3 dots in the state's color, which the owner asked for, moving, after the S1 orb of aicss.dev; the dots and their motion are ELYTerminal's own. Working ripples out from the centre to the edges, then the corners, once every 1.2 s; with progress the outer ring fills clockwise from the top, as Ely's `ProgressRing` did, and the next dot pulses, the centre last. Blocked, in the theme's warning color, stays lit and dips twice every 1.6 s, whatever its kind, which hover names. Error, a program's or a failed last command's, draws an X in the danger color; done a check in the success color; idle a dimmer ring in the muted color. A settled shape lights dot by dot, then holds still: one that kept moving would draw the window for as long as it stands. The dots outside a shape stay faint. Every mark steps on one clock, every 80 ms, so the marks move together, and the window draws again at each step only while a mark it shows moves: a working program costs 12.5 frames a second, not one at every refresh of the display, which kept the loader still before. Every mark takes a 24-pixel box, so the title stays put as the state changes; the dots fill 16 of it, an icon's room. 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. Hover reads the records as they stand at each frame, so an open one follows a change; it stays while the pointer is over it and scrolls within the window's height, so ten long records all show. 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. The view notes the state its row shows after each change it hears, not once an output, and tells the workspace of each, and a hidden session whose mark turns to blocked sets its title strong until it is shown, though it went on within the same output, so one output marks as two would. A change of herdr's records draws the view again, so an open hover follows them though the state holds. The view logs each change of that state, and keeps its last command's status.
|
||||||
|
- 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session, which the tap reads in the output's order, so a title set before a notification in the same output names it. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities.
|
||||||
Generated
+30
-3
@@ -1701,7 +1701,7 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-gpui-component"
|
name = "ely-gpui-component"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git?rev=a2cb531e06e15f9e15d5b44a83f82beddb4888f9#a2cb531e06e15f9e15d5b44a83f82beddb4888f9"
|
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git?rev=da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721#da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1747,10 +1747,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elyterminal"
|
name = "elyterminal"
|
||||||
version = "0.1.0"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"block",
|
"block",
|
||||||
"core-graphics 0.24.0",
|
"core-graphics 0.24.0",
|
||||||
"dirs 7.0.0",
|
"dirs 7.0.0",
|
||||||
@@ -1774,11 +1775,13 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
|
"sysinfo 0.39.6",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
"wayland-protocols",
|
"wayland-protocols",
|
||||||
"wayland-server",
|
"wayland-server",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4463,6 +4466,16 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-surface"
|
name = "objc2-io-surface"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -6509,6 +6522,20 @@ dependencies = [
|
|||||||
"windows 0.57.0",
|
"windows 0.57.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sysinfo"
|
||||||
|
version = "0.39.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"ntapi",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-kit",
|
||||||
|
"windows 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-fonts"
|
name = "system-fonts"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -8555,7 +8582,7 @@ dependencies = [
|
|||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"screencapturekit",
|
"screencapturekit",
|
||||||
"screencapturekit-sys",
|
"screencapturekit-sys",
|
||||||
"sysinfo",
|
"sysinfo 0.31.4",
|
||||||
"tao-core-video-sys",
|
"tao-core-video-sys",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"windows-capture",
|
"windows-capture",
|
||||||
|
|||||||
+10
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "elyterminal"
|
name = "elyterminal"
|
||||||
version = "0.1.0"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -11,8 +11,9 @@ publish = false
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
alacritty_terminal = { version = "=0.26.0", default-features = false }
|
alacritty_terminal = { version = "=0.26.0", default-features = false }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
base64 = "0.22"
|
||||||
dirs = "7"
|
dirs = "7"
|
||||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "a2cb531e06e15f9e15d5b44a83f82beddb4888f9" }
|
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721" }
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
@@ -57,6 +58,13 @@ core-graphics = { version = "0.24", features = ["highsierra"] }
|
|||||||
foreign-types = "0.5"
|
foreign-types = "0.5"
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
sysinfo = { version = "0.39", default-features = false, features = ["system"] }
|
||||||
|
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Pipes"] }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dev-dependencies]
|
||||||
|
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_System_IO"] }
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Chapters run in build order. `TASKS.md` follows them line by line.
|
|||||||
|
|
||||||
- One window. Sessions on the left, the terminal in the middle, Git or Files on the right.
|
- One window. Sessions on the left, the terminal in the middle, Git or Files on the right.
|
||||||
- Every setting has a control in the settings window. No setting lives only in a file.
|
- Every setting has a control in the settings window. No setting lives only in a file.
|
||||||
- Silent: no sound, no bell, no notifications, no update checks, no telemetry, no tips, no onboarding.
|
- Silent: no sound, no bell, no notifications but a session's, below, no update checks, no telemetry, no tips, no onboarding.
|
||||||
- Clean: no subtitle, no tagline, no logging options, no status bar, no badges that do not carry state.
|
- Clean: no subtitle, no tagline, no logging options, no status bar, no badges that do not carry state.
|
||||||
- Built-in themes, each shown as a small preview, Catppuccin among them. A theme colors the terminal and the chrome.
|
- Built-in themes, each shown as a small preview, Catppuccin among them. A theme colors the terminal and the chrome.
|
||||||
- The window background blurs on macOS, Windows, Wayland and X11.
|
- The window background blurs on macOS, Windows, Wayland and X11.
|
||||||
@@ -20,7 +20,7 @@ Chapters run in build order. `TASKS.md` follows them line by line.
|
|||||||
- `alacritty_terminal` 0.26 for the grid, the parser and the pseudo-terminal, as Ely uses it.
|
- `alacritty_terminal` 0.26 for the grid, the parser and the pseudo-terminal, as Ely uses it.
|
||||||
- Logs through `log` to stderr, `RUST_LOG` sets the level. No log setting in the app.
|
- Logs through `log` to stderr, `RUST_LOG` sets the level. No log setting in the app.
|
||||||
- CI on Gitea Actions: macOS builds, tests and lints; Ubuntu and Windows build and test.
|
- CI on Gitea Actions: macOS builds, tests and lints; Ubuntu and Windows build and test.
|
||||||
- Private repo `ELYTerminal` on git.zacharyzhang.com. No credential in any file or commit.
|
- Repo `ELYTerminal` on git.zacharyzhang.com, private until v0.1.1, public since. No credential in any file or commit.
|
||||||
|
|
||||||
## 3. Settings model and storage
|
## 3. Settings model and storage
|
||||||
|
|
||||||
@@ -36,13 +36,20 @@ Chapters run in build order. `TASKS.md` follows them line by line.
|
|||||||
- A theme holds background, foreground, cursor, cursor text, selection background and foreground, and the 16 ANSI colors. Values come from the theme's upstream files (iTerm2-Color-Schemes' Ghostty export, or Ely's palette).
|
- A theme holds background, foreground, cursor, cursor text, selection background and foreground, and the 16 ANSI colors. Values come from the theme's upstream files (iTerm2-Color-Schemes' Ghostty export, or Ely's palette).
|
||||||
- A theme maps onto Ely's `Palette` through `Theme::set_palette`, so panels, inputs and lists wear the theme.
|
- A theme maps onto Ely's `Palette` through `Theme::set_palette`, so panels, inputs and lists wear the theme.
|
||||||
- Dark mode: one theme, or a second theme for when the system is dark.
|
- Dark mode: one theme, or a second theme for when the system is dark.
|
||||||
|
- On Omarchy 4 (Linux), an Omarchy theme wears Omarchy's current theme and changes with each switch. It is the default where Omarchy runs; picking another theme stops following.
|
||||||
|
|
||||||
## 5. Terminal engine
|
## 5. Terminal engine
|
||||||
|
|
||||||
Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `alacritty_terminal`; Ely's `Terminal` is a gallery widget without mouse reporting, IME, kitty keys or style settings.
|
Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `alacritty_terminal`; Ely's `Terminal` is a gallery widget without mouse reporting, IME, kitty keys or style settings.
|
||||||
|
|
||||||
- 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.
|
- PTY: alacritty's `tty` and event loop. The pty is wrapped so its reader passes bytes through an OSC scanner. The scanner reports the working directory and prompt marks.
|
||||||
- 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.
|
- 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.
|
||||||
|
- Prompt marks, OSC 133: the shell integration marks where each prompt starts (A) and ends (B), where a command starts (C), and where it ends with its exit status (D), all but C on macOS's bash 3.2, which has no hook for it; the terminal reads any program's OSC 133. Cmd-Up and Cmd-Down, and Cmd-Shift-Up and Cmd-Shift-Down, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt before or after the view's top, as in Ghostty.
|
||||||
|
- Program status, OSC 7501 revision 0.4: each terminal keeps the records programs report (state, id, kind, progress, app, title, msg), checked as the spec checks them; a new prompt drops working, blocked and idle records, a full reset drops them all; the terminal answers the support query with the states and kinds it takes.
|
||||||
|
- Progress, OSC 9;4: set, error, indeterminate and pause stand as the root record until a program reports OSC 7501, until a full reset.
|
||||||
|
- Notifications: OSC 9 (iTerm2), OSC 777 notify (rxvt) and OSC 99 (kitty: title and body in chunks, base64, ids, focus and report on activation, close, expiry, which ones live, and its query; `o` passes by, since a session posts only while unseen, below); ConEmu's other OSC 9 commands are no notifications.
|
||||||
|
- 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 session, on macOS, Linux and Windows.
|
||||||
|
- Inside herdr, which passes no OSC 7501 on, herdr's agent states (idle, working, blocked, done) are the session's status records.
|
||||||
- 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.
|
- 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.
|
- 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.
|
- Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font.
|
||||||
@@ -59,8 +66,12 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
|||||||
|
|
||||||
- Ely's `ColumnShell`: sessions column, terminal column, right column. The window opens with blur when the setting is on.
|
- Ely's `ColumnShell`: sessions column, terminal column, right column. The window opens with blur when the setting is on.
|
||||||
- The sessions column and the right column toggle, each by a button at its end of the title bar or by key, and keep their state in settings, so the window opens as it was left.
|
- The sessions column and the right column toggle, each by a button at its end of the title bar or by key, and keep their state in settings, so the window opens as it was left.
|
||||||
|
- The sessions column and the right column resize by a drag on their border toward the terminal, on macOS, Windows and Linux, from 160 to 960 pixels; the widths are kept in settings once a drag rests, so the window opens with them.
|
||||||
|
- The window opens at the size it was left at, on macOS, Windows and Linux: the size is kept in settings once it settles; a maximized or full-screen window keeps the size from before; a kept size past the screen shrinks to it; the window is no smaller than 100 by 100, the least the settings keep.
|
||||||
- The title shows the active session's title, and no other text.
|
- The title shows the active session's title, and no other text.
|
||||||
|
- The pointer over the terminal is the arrow.
|
||||||
- The title bar is transparent, as Ghostty's: the window's background, opacity and blur run under it, so it wears the terminal's color.
|
- The title bar is transparent, as Ghostty's: the window's background, opacity and blur run under it, so it wears the terminal's color.
|
||||||
|
- Every control in the title bar takes its own press on every platform, the window buttons Ely draws on Windows and Linux too.
|
||||||
|
|
||||||
## 7. Sessions panel
|
## 7. Sessions panel
|
||||||
|
|
||||||
@@ -68,10 +79,13 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
|||||||
- New session (+ and Cmd-T), close (row's ×, Cmd-W), select (press, Cmd-1…9, Ctrl-Tab and Ctrl-Shift-Tab).
|
- New session (+ and Cmd-T), close (row's ×, Cmd-W), select (press, Cmd-1…9, Ctrl-Tab and Ctrl-Shift-Tab).
|
||||||
- A session whose program ends closes its row. The last row closing closes the window.
|
- A session whose program ends closes its row. The last row closing closes the window.
|
||||||
- A bell in a hidden session marks its row.
|
- A bell in a hidden session marks its row.
|
||||||
|
- A row shows its session's status: working, with its progress where known; waiting on the user, for a permission, a question or a login; done; error; and a mark when the last command failed. Hover shows each record's program, title and message. A hidden session that starts waiting marks its row until it is shown.
|
||||||
|
- The mark is 3 by 3 dots that move: work ripples, or fills by its progress; waiting dips; a settled state draws its shape, then holds still.
|
||||||
|
- System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and a program's OSC 9, 777 or 99 notification, post one while the window is not active or the session is hidden, one a session every 3 seconds at most; it names the session, and a press on it shows the window and the session. On macOS only the app bundle posts them.
|
||||||
|
|
||||||
## 8. Right panel: Files
|
## 8. Right panel: Files
|
||||||
|
|
||||||
- A switch at the top: Git, Files.
|
- A switch at the top, centered below the title bar: Git, Files.
|
||||||
- Files shows the active session's working directory as a tree, folders first, Git status colors on names.
|
- Files shows the active session's working directory as a tree, folders first, Git status colors on names.
|
||||||
- Double press: a folder opens, a file opens in the system's default app.
|
- Double press: a folder opens, a file opens in the system's default app.
|
||||||
- It reloads when the session reports a new directory and at each prompt.
|
- It reloads when the session reports a new directory and at each prompt.
|
||||||
@@ -87,35 +101,35 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
|||||||
|
|
||||||
## 10. Settings window
|
## 10. Settings window
|
||||||
|
|
||||||
Cmd-, opens it. Categories on the left, rows on the right, Ely's settings components. Changes apply at once to every session and save.
|
Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in the title bar. Categories on the left, rows on the right, each side scrolling on its own, Ely's components. Changes apply at once to every session and save.
|
||||||
|
|
||||||
- General
|
|
||||||
- Shell: a path; empty runs the login shell.
|
|
||||||
- Start in: Home, or the active session's folder.
|
|
||||||
- Appearance
|
- Appearance
|
||||||
- Theme: a grid of previews.
|
- Theme: a grid of previews; on Omarchy, the Omarchy theme first.
|
||||||
- Separate dark theme: a switch; when on, a second grid for dark mode.
|
- Separate dark theme: a switch; when on, a second grid for dark mode.
|
||||||
- Font family: a list of installed fonts. Font size. Line height. Ligatures.
|
- Font family: a list of installed fonts. Font size. Line height. Ligatures.
|
||||||
- Cursor: style (block, bar, underline, hollow), blink, with a live preview.
|
- Cursor: style (block, bar, underline, hollow), blink, with a live preview.
|
||||||
- Background opacity: a slider. Background blur: a switch.
|
- Background opacity: a slider. Background blur: a switch.
|
||||||
- Padding: horizontal and vertical.
|
- Padding: horizontal and vertical.
|
||||||
- Terminal
|
- Terminal
|
||||||
|
- Shell: a path; empty runs the login shell.
|
||||||
|
- Start in: Home, or the active session's folder.
|
||||||
- Scrollback lines.
|
- Scrollback lines.
|
||||||
- Option as Alt: off, left, right, both (macOS only).
|
- Option as Alt: off, left, right, both (macOS only).
|
||||||
- Copy on select.
|
- Copy on select.
|
||||||
|
- Shortcuts: every shortcut, as this platform's keys, with a search.
|
||||||
|
|
||||||
## 11. Background blur
|
## 11. Background blur
|
||||||
|
|
||||||
- macOS: gpui's `WindowBackgroundAppearance::Blurred` (NSVisualEffectView).
|
- macOS: gpui's `WindowBackgroundAppearance::Blurred` (NSVisualEffectView).
|
||||||
- Windows: gpui's `Blurred` (acrylic accent).
|
- Windows: gpui's `Blurred` (acrylic accent).
|
||||||
- Wayland: gpui's `Blurred` speaks `org_kde_kwin_blur`; ELYTerminal adds `ext_background_effect_v1` on the window's surface, which GNOME 51 and Plasma 6.7 support.
|
- Wayland: gpui's `Blurred` speaks `org_kde_kwin_blur`; ELYTerminal adds `ext_background_effect_v1` on the window's surface, which GNOME 51, Plasma 6.7 and Hyprland support. Hyprland honors it only while its own blur is on, which Omarchy 4.0.4 turns off.
|
||||||
- X11: ELYTerminal sets `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32) on the window, as Ghostty does; removes it when blur turns off.
|
- X11: ELYTerminal sets `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32) on the window, as Ghostty does; removes it when blur turns off.
|
||||||
- A compositor with neither protocol shows transparency without blur. The app logs which path it took.
|
- A compositor with neither protocol shows transparency without blur. The app logs which path it took.
|
||||||
|
|
||||||
## 12. Menus and shortcuts
|
## 12. Menus and shortcuts
|
||||||
|
|
||||||
- macOS menu bar: ELYTerminal (Settings, Quit), Shell (New Session, Close Session), Edit (Copy, Paste, Select All, Find), View (Toggle Sessions, Toggle Right Panel, Bigger, Smaller, Reset Size), Window.
|
- macOS menu bar: ELYTerminal (Settings, Quit), Shell (New Session, Close Session), Edit (Copy, Paste, Select All, Find), View (Toggle Sessions, Toggle Right Panel, Bigger, Smaller, Reset Size), Window.
|
||||||
- The same shortcuts on Windows and Linux with Ctrl-Shift for terminal-colliding keys.
|
- The same shortcuts on Windows and Linux with Ctrl-Shift for terminal-colliding keys. On Windows Ctrl-V pastes too, as in Windows Terminal.
|
||||||
|
|
||||||
## 13. Acceptance
|
## 13. Acceptance
|
||||||
|
|
||||||
@@ -127,14 +141,14 @@ Cmd-, opens it. Categories on the left, rows on the right, Ely's settings compon
|
|||||||
|
|
||||||
- The icon is Ely, a girl with long pink hair, holding a terminal in front of her.
|
- The icon is Ely, a girl with long pink hair, holding a terminal in front of her.
|
||||||
- macOS: the icon on the rounded square macOS gives app icons. Windows and Linux: the figure alone, no ground, flat, as Papirus draws.
|
- macOS: the icon on the rounded square macOS gives app icons. Windows and Linux: the figure alone, no ground, flat, as Papirus draws.
|
||||||
- `scripts/mac-app.sh` builds `ELYTerminal.app` from a release build with its icon, signed ad hoc, and a disk image holding it beside a link to Applications.
|
- `scripts/mac-app.sh` builds `ELYTerminal.app` from a release build with its icon, signed ad hoc, or for a release with Developer ID and notarized, and a disk image holding it beside a link to Applications.
|
||||||
|
|
||||||
## 15. Release
|
## 15. Release
|
||||||
|
|
||||||
- A release on Gitea, tagged `vVERSION`, carries three builds: the macOS disk image (Apple silicon), a Windows zip (x86_64) and a Linux tarball (x86_64).
|
- A release on Gitea, tagged `vVERSION`, carries three builds: the macOS disk image (Apple silicon, signed with Developer ID and notarized), a Windows zip (x86_64) and a Linux tarball (x86_64).
|
||||||
- The Windows executable carries the icon. The Linux tarball carries the icon and a desktop entry, and the window names itself after it, `com.zacharyzhang.elyterminal`, as the macOS bundle does.
|
- The Windows executable carries the icon. The Linux tarball carries the icon and a desktop entry, and the window names itself after it, `com.zacharyzhang.elyterminal`, as the macOS bundle does.
|
||||||
- Each build comes from its platform's script: `scripts/mac-app.sh`, `scripts/windows-zip.ps1`, `scripts/linux-tar.sh`.
|
- Each build comes from its platform's script: `scripts/mac-app.sh`, `scripts/windows-zip.ps1`, `scripts/linux-tar.sh`. The macOS one runs on the signing runner, through the Release workflow.
|
||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
|
|
||||||
Splits, tabs inside a session, profiles, key rebinding, kitty graphics, sixel, OSC 133 prompt marks, session restore, push and pull, quick terminal.
|
Splits, tabs inside a session, profiles, key rebinding, kitty graphics, sixel, session restore, push and pull, quick terminal.
|
||||||
@@ -6,7 +6,7 @@ A terminal for macOS, Windows and Linux, in Rust on GPUI and Ely components. Ses
|
|||||||
|
|
||||||
A release on Gitea carries three builds.
|
A release on Gitea carries three builds.
|
||||||
|
|
||||||
- macOS 11 or later, Apple silicon: open `ELYTerminal-VERSION.dmg` and drag ELYTerminal into Applications. The app is signed ad hoc, not notarized, so macOS stops its first open: in System Settings, Privacy & Security, press Open Anyway.
|
- macOS 11 or later, Apple silicon: open `ELYTerminal-VERSION.dmg` and drag ELYTerminal into Applications. The app is signed with Developer ID and notarized.
|
||||||
- Windows 10 or later, x86_64: unzip `ELYTerminal-VERSION-windows-x86_64.zip` and run `elyterminal.exe`. It is unsigned; if SmartScreen stops it, press More info, then Run anyway.
|
- Windows 10 or later, x86_64: unzip `ELYTerminal-VERSION-windows-x86_64.zip` and run `elyterminal.exe`. It is unsigned; if SmartScreen stops it, press More info, then Run anyway.
|
||||||
- Linux, x86_64, glibc 2.35 or later, with libxkbcommon, libxkbcommon-x11 and libxcb:
|
- Linux, x86_64, glibc 2.35 or later, with libxkbcommon, libxkbcommon-x11 and libxcb:
|
||||||
|
|
||||||
@@ -42,16 +42,20 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
|||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- 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.
|
- 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. The mark is 3 by 3 dots: work ripples and waiting dips while they last. Hover over the mark lists each report.
|
||||||
- A button at each end of the title bar hides or shows its side column; the window opens as it was left.
|
- Notifications: while the window is in the background or the session hidden, a session that starts waiting, finishes or fails, and a program's OSC 9, 777 or 99, post a system notification, one a session every 3 seconds at most; a press shows the session. On macOS only the app bundle posts them, not `cargo run`.
|
||||||
- Files shows the session's folder as a tree with Git's marks.
|
- 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.
|
||||||
|
- Files shows the session's folder as a tree with Git's marks. Inside herdr 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.
|
- 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.
|
- 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.
|
- 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`.
|
||||||
|
|
||||||
## Keys
|
## Keys
|
||||||
|
|
||||||
|
Settings › Terminal lists them all, as this platform binds them.
|
||||||
|
|
||||||
| | macOS | Windows and Linux |
|
| | macOS | Windows and Linux |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| New session | Cmd-T | Ctrl-Shift-T |
|
| New session | Cmd-T | Ctrl-Shift-T |
|
||||||
@@ -60,7 +64,7 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
|||||||
| Next, previous session | Ctrl-Tab, Ctrl-Shift-Tab | Ctrl-Tab, Ctrl-Shift-Tab |
|
| Next, previous session | Ctrl-Tab, Ctrl-Shift-Tab | Ctrl-Tab, Ctrl-Shift-Tab |
|
||||||
| Sessions column | Cmd-B | Ctrl-Shift-B |
|
| Sessions column | Cmd-B | Ctrl-Shift-B |
|
||||||
| Right column | Cmd-Alt-B | Ctrl-Shift-Alt-B |
|
| Right column | Cmd-Alt-B | Ctrl-Shift-Alt-B |
|
||||||
| Copy, paste | Cmd-C, Cmd-V | Ctrl-Shift-C, Ctrl-Shift-V |
|
| Copy, paste | Cmd-C, Cmd-V | Ctrl-Shift-C, Ctrl-Shift-V; on Windows Ctrl-V pastes too |
|
||||||
| Select all | Cmd-A | Ctrl-Shift-A |
|
| Select all | Cmd-A | Ctrl-Shift-A |
|
||||||
| Find | Cmd-F | Ctrl-Shift-F |
|
| Find | Cmd-F | Ctrl-Shift-F |
|
||||||
| Bigger, smaller, reset size | Cmd-=, Cmd-minus, Cmd-0 | Ctrl-=, Ctrl-minus, Ctrl-0 |
|
| Bigger, smaller, reset size | Cmd-=, Cmd-minus, Cmd-0 | Ctrl-=, Ctrl-minus, Ctrl-0 |
|
||||||
|
|||||||
@@ -67,4 +67,46 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
|
|||||||
|
|
||||||
## 15. Release
|
## 15. Release
|
||||||
|
|
||||||
- [ ] T25 Windows zip with the icon in the executable, Linux tarball with the icon and a desktop entry, the app id on Linux windows, their scripts; release v0.1.0 on Gitea with the three builds.
|
- [x] T25 Windows zip with the icon in the executable, Linux tarball with the icon and a desktop entry, the app id on Linux windows, their scripts; release v0.1.0 on Gitea with the three builds.
|
||||||
|
|
||||||
|
## 16. Fixes from the owner's first run
|
||||||
|
|
||||||
|
- [x] T26 On Windows every title bar control takes its press, Ely's window buttons too: in Ely, window buttons and header items occlude the drag region; the Git and Files switch moves below the title bar, centered; on Windows and Linux a gear in the title bar opens Settings; on Windows Ctrl-V pastes, as in Windows Terminal; the pointer over the terminal is the arrow; the settings window drops General, whose Shell and Start in lead Terminal, which lists every shortcut, and its categories and page scroll apart; the release's macOS disk image signed with Developer ID and notarized on the signing runner; release v0.1.1 with the three builds.
|
||||||
|
|
||||||
|
## 17. Omarchy
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [x] 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.
|
||||||
|
- [x] 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.
|
||||||
|
- [x] 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.
|
||||||
|
- [x] T32 On Windows the right column follows herdr's focused pane too, which the owner found frozen there: ELYTerminal finds herdr's client under the session's shell, reads its arguments and variables, and asks herdr's named pipe.
|
||||||
|
|
||||||
|
## 19. Column widths
|
||||||
|
|
||||||
|
- [x] T33 The sessions and right columns resize by a drag on their border toward the terminal, on macOS, Windows and Linux, which the owner asked for: in Ely, a `ShellColumn` resizable between a least and a greatest width; the two widths kept in the settings.
|
||||||
|
|
||||||
|
## 20. Prompts, program status and notifications
|
||||||
|
|
||||||
|
- [x] T34 OSC 133 from the shell integration, which the owner asked for with everything below: zsh, bash, fish and PowerShell mark each prompt's start (A) and end (B), a command's start (C), and its end with the exit status (D); the tap reads any program's OSC 133; each session keeps its last command's status.
|
||||||
|
- [x] T35 Jump to prompt: a prompt's first row is marked once the prompt is drawn; Cmd-Up and Cmd-Down, and Cmd-Shift-Up and Cmd-Shift-Down, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt before or after the view's top, as in Ghostty.
|
||||||
|
- [x] T36 OSC 7501 program status, revision 0.4: reports checked as the spec checks them; records per terminal, by id, with clear and a cap of 256; a new prompt drops working, blocked and idle records, a full reset all; the support query answered with the states and kinds.
|
||||||
|
- [x] T37 OSC 9;4 progress as the root record until an OSC 7501 report; OSC 9, 777 and 99 notifications read, OSC 99's chunks, base64, ids, occasions, actions, close and query included; ConEmu's other OSC 9 commands are no notifications.
|
||||||
|
- [x] T38 Inside herdr, herdr's agent states from its snapshot are the session's records.
|
||||||
|
- [x] T39 Session rows show the status: working with its progress, waiting by kind, done, error, a failed last command; hover shows each record's program, title and message; a hidden session that starts waiting marks its row.
|
||||||
|
- [x] T40 System notifications on macOS, Windows and Linux through gpui's: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99 requests, while the window is inactive or the session hidden, rate-limited; a press shows the window and the session.
|
||||||
|
|
||||||
|
## 21. Release
|
||||||
|
|
||||||
|
- [x] T41 Release v0.1.3 with T32 to T40: version 0.1.3; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes list what changed.
|
||||||
|
|
||||||
|
## 22. Status marks that move
|
||||||
|
|
||||||
|
- [x] T42 The status marks move, which the owner asked for after aicss.dev's orbs: 3 by 3 dots in the state's color; work ripples, or fills by its progress, waiting dips, a settled state draws its shape, then holds; the window draws again only at the marks' steps while one moves.
|
||||||
|
|
||||||
|
## 23. Release
|
||||||
|
|
||||||
|
- [ ] T43 v0.1.3 rebuilt with T42, which the owner asked for in place of a v0.1.4: the version stays 0.1.3; the macOS disk image, the Windows zip and the Linux tarball, each from its script, replace the old ones on the release; the tag moves to their commit; the notes say what changed.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ELYTerminal: load the usual startup files, report folder and title.
|
# ELYTerminal: load the usual startup files, report folder and title, mark prompts and commands.
|
||||||
if [ -n "${ELY_BASH_LOGIN-}" ]; then
|
if [ -n "${ELY_BASH_LOGIN-}" ]; then
|
||||||
builtin unset ELY_BASH_LOGIN
|
builtin unset ELY_BASH_LOGIN
|
||||||
[ -r /etc/profile ] && builtin source /etc/profile
|
[ -r /etc/profile ] && builtin source /etc/profile
|
||||||
@@ -12,8 +12,20 @@ if [ -n "${ELY_BASH_LOGIN-}" ]; then
|
|||||||
else
|
else
|
||||||
[ -r ~/.bashrc ] && builtin source ~/.bashrc
|
[ -r ~/.bashrc ] && builtin source ~/.bashrc
|
||||||
fi
|
fi
|
||||||
|
_ely_ran=
|
||||||
|
_ely_n=0
|
||||||
|
_ely_ps0=
|
||||||
|
if (( BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 404 )); then
|
||||||
|
# PS0 runs in the shell, so its subscript can note that a command ran.
|
||||||
|
_ely_ps0='${_ely_note[_ely_ran=1]-}\e]133;C\a'
|
||||||
|
fi
|
||||||
_ely_prompt() {
|
_ely_prompt() {
|
||||||
builtin local ret=$? LC_ALL=C char url= i title
|
builtin local ret=$? LC_ALL=C char url= i title
|
||||||
|
if [ -n "$_ely_ran" ]; then
|
||||||
|
builtin printf '\e]133;D;%s\a' "$ret"
|
||||||
|
_ely_ran=
|
||||||
|
fi
|
||||||
|
[ -n "$_ely_ps0" ] || _ely_d=([1]=$'\e]133;D;'"$ret"$'\a')
|
||||||
for (( i = 0; i < ${#PWD}; i++ )); do
|
for (( i = 0; i < ${#PWD}; i++ )); do
|
||||||
char=${PWD:i:1}
|
char=${PWD:i:1}
|
||||||
case $char in
|
case $char in
|
||||||
@@ -25,4 +37,20 @@ _ely_prompt() {
|
|||||||
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${title//[[:cntrl:]]/}"
|
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${title//[[:cntrl:]]/}"
|
||||||
builtin return $ret
|
builtin return $ret
|
||||||
}
|
}
|
||||||
PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
|
# Last, after anything that sets PS1 or PS0.
|
||||||
|
_ely_mark() {
|
||||||
|
if [[ $PS1 != *'\e]133;A'* ]]; then
|
||||||
|
if [ -z "$_ely_ps0" ] && builtin shopt -q promptvars; then
|
||||||
|
# bash 3.2 has no PS0; \#, the command number, moves once a command ran.
|
||||||
|
PS1='\[${_ely_d[_ely_n && \# != _ely_n]-}${_ely_note[_ely_n = \#]-}\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]'
|
||||||
|
else
|
||||||
|
PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "$_ely_ps0" ] && builtin shopt -q promptvars && [[ ${PS0-} != *"$_ely_ps0"* ]]; then
|
||||||
|
PS0=$_ely_ps0${PS0-}
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND};_ely_mark"
|
||||||
|
# PS0 and the prompt mark a command only through expansions, which promptvars turns on.
|
||||||
|
builtin shopt -q promptvars || builtin printf 'ELYTerminal: bash marks no command while promptvars is off (shopt -s promptvars)\n' >&2
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ELYTerminal: restore XDG_DATA_DIRS, report the folder; fish_title keeps the title.
|
# ELYTerminal: restore XDG_DATA_DIRS, report the folder, mark what this fish leaves unmarked; fish_title keeps the title.
|
||||||
if test -n "$ELY_FISH_XDG_DATA_DIRS"
|
if test -n "$ELY_FISH_XDG_DATA_DIRS"
|
||||||
set -gx XDG_DATA_DIRS $ELY_FISH_XDG_DATA_DIRS
|
set -gx XDG_DATA_DIRS $ELY_FISH_XDG_DATA_DIRS
|
||||||
else
|
else
|
||||||
@@ -9,4 +9,33 @@ if status is-interactive
|
|||||||
function _ely_prompt --on-event fish_prompt
|
function _ely_prompt --on-event fish_prompt
|
||||||
printf '\e]7;file://%s\a' (string escape --style=url -- $PWD)
|
printf '\e]7;file://%s\a' (string escape --style=url -- $PWD)
|
||||||
end
|
end
|
||||||
|
# fish 4.3 marks its own prompts and commands; 4.0 all but the prompt's end; older ones none.
|
||||||
|
set -l v (string split ' ' -- (string replace -r '^(\d+)\.(\d+).*' '$1 $2' -- $version))
|
||||||
|
if test $v[1] -lt 4
|
||||||
|
set -g _ely_marks A B
|
||||||
|
function _ely_preexec --on-event fish_preexec
|
||||||
|
printf '\e]133;C\a'
|
||||||
|
end
|
||||||
|
function _ely_postexec --on-event fish_postexec
|
||||||
|
printf '\e]133;D;%s\a' $status
|
||||||
|
end
|
||||||
|
else if test $v[1] -eq 4 -a $v[2] -lt 3
|
||||||
|
set -g _ely_marks B
|
||||||
|
end
|
||||||
|
if set -q _ely_marks
|
||||||
|
# The user's prompt loads late and may change; the marks go around the one shown.
|
||||||
|
function _ely_wrap --on-event fish_prompt
|
||||||
|
if functions -q _ely_user_prompt; and string match -q '*_ely_user_prompt*' -- (functions fish_prompt)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
functions -q _ely_user_prompt; and functions -e _ely_user_prompt
|
||||||
|
functions -c fish_prompt _ely_user_prompt
|
||||||
|
function fish_prompt
|
||||||
|
# First, so the user's prompt sees the command's status.
|
||||||
|
set -l text (_ely_user_prompt | string collect -N)
|
||||||
|
contains A $_ely_marks; and printf '\e]133;A\a'
|
||||||
|
printf '%s\e]133;B\a' "$text"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
# ELYTerminal: wrap the prompt to report folder and title.
|
# ELYTerminal: wrap the prompt to report folder and title and to mark prompts and commands.
|
||||||
$global:ElyPrompt = $function:prompt
|
$global:ElyPrompt = $function:prompt
|
||||||
|
$global:ElyRan = $false
|
||||||
function global:prompt {
|
function global:prompt {
|
||||||
|
# First: every statement sets $? again.
|
||||||
|
$ok = $?
|
||||||
|
$esc, $bel = [char]27, [char]7
|
||||||
|
if ($global:ElyRan) {
|
||||||
|
$code = 0
|
||||||
|
if (-not $ok) {
|
||||||
|
# Each native run sets a new $LASTEXITCODE, equal or not; strict mode refuses an unset one.
|
||||||
|
$exit = Get-Variable LASTEXITCODE -Scope Global -ValueOnly -ErrorAction Ignore
|
||||||
|
$ran = $null -ne $exit -and -not [object]::ReferenceEquals($exit, $global:ElyExit)
|
||||||
|
$code = if (-not $global:ElyShell -and $ran -and $exit -ne 0) { $exit } else { 1 }
|
||||||
|
}
|
||||||
|
[Console]::Write("$esc]133;D;$code$bel")
|
||||||
|
$global:ElyRan = $false
|
||||||
|
}
|
||||||
|
[Console]::Write("$esc]133;A$bel")
|
||||||
|
# The user's prompt sees a failed command's $? still.
|
||||||
|
if (-not $ok) { Write-Error '' -ErrorAction Ignore }
|
||||||
$text = & $global:ElyPrompt
|
$text = & $global:ElyPrompt
|
||||||
$folder = $executionContext.SessionState.Path.CurrentLocation
|
$folder = $executionContext.SessionState.Path.CurrentLocation
|
||||||
if ($folder.Provider.Name -eq 'FileSystem') {
|
if ($folder.Provider.Name -eq 'FileSystem') {
|
||||||
@@ -8,8 +26,40 @@ function global:prompt {
|
|||||||
$path = ($parts | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/'
|
$path = ($parts | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/'
|
||||||
if (-not $path.StartsWith('/')) { $path = "/$path" }
|
if (-not $path.StartsWith('/')) { $path = "/$path" }
|
||||||
$leaf = (Split-Path -Leaf $folder.ProviderPath) -replace '\p{Cc}', ''
|
$leaf = (Split-Path -Leaf $folder.ProviderPath) -replace '\p{Cc}', ''
|
||||||
$esc, $bel = [char]27, [char]7
|
|
||||||
[Console]::Write("$esc]7;file://$path$bel$esc]2;$leaf$bel")
|
[Console]::Write("$esc]7;file://$path$bel$esc]2;$leaf$bel")
|
||||||
}
|
}
|
||||||
$text
|
"$text$esc]133;B$bel"
|
||||||
|
}
|
||||||
|
# A command starts once PSReadLine hands over a line that is not blank.
|
||||||
|
if (Test-Path Function:\PSConsoleHostReadLine) {
|
||||||
|
$global:ElyReadLine = $function:PSConsoleHostReadLine
|
||||||
|
function global:PSConsoleHostReadLine {
|
||||||
|
$line = & $global:ElyReadLine
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($line)) {
|
||||||
|
$global:ElyRan = $true
|
||||||
|
$global:ElyExit = Get-Variable LASTEXITCODE -Scope Global -ValueOnly -ErrorAction Ignore
|
||||||
|
# A program's code is the line's, unless the line surely ends in PowerShell: a cmdlet, a name not found, or no command.
|
||||||
|
$ast = [System.Management.Automation.Language.Parser]::ParseInput($line, [ref]$null, [ref]$null)
|
||||||
|
# Strict mode refuses .Count on one statement alone, so the statements stay a list.
|
||||||
|
$statements = @(if ($ast.EndBlock) { $ast.EndBlock.Statements })
|
||||||
|
$last = if ($statements.Count) { $statements[$statements.Count - 1] }
|
||||||
|
if ($last -is [System.Management.Automation.Language.PipelineAst]) {
|
||||||
|
$elements = $last.PipelineElements
|
||||||
|
$last = $elements[$elements.Count - 1]
|
||||||
|
}
|
||||||
|
$name = if ($last -is [System.Management.Automation.Language.CommandAst]) { $last.GetCommandName() }
|
||||||
|
$global:ElyShell = if ($name) {
|
||||||
|
$pattern = [System.Management.Automation.WildcardPattern]::Escape($name)
|
||||||
|
$info = Get-Command -Name $pattern -ErrorAction Ignore | Select-Object -First 1
|
||||||
|
while ($info -and $info.CommandType -eq 'Alias') { $info = $info.ResolvedCommand }
|
||||||
|
$null -eq $info -or $info.CommandType -eq 'Cmdlet'
|
||||||
|
} else {
|
||||||
|
$null -eq $last -or -not $last.Find({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)
|
||||||
|
}
|
||||||
|
[Console]::Write("$([char]27)]133;C$([char]7)")
|
||||||
|
}
|
||||||
|
$line
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[Console]::Error.WriteLine('ELYTerminal: PowerShell marks no command without PSReadLine')
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ELYTerminal: restore ZDOTDIR, load the user's .zshenv, report folder and title.
|
# ELYTerminal: restore ZDOTDIR, load the user's .zshenv, report folder and title, mark prompts and commands.
|
||||||
if [[ -n "${ELY_ZSH_ZDOTDIR+set}" ]]; then
|
if [[ -n "${ELY_ZSH_ZDOTDIR+set}" ]]; then
|
||||||
ZDOTDIR=$ELY_ZSH_ZDOTDIR
|
ZDOTDIR=$ELY_ZSH_ZDOTDIR
|
||||||
builtin unset ELY_ZSH_ZDOTDIR
|
builtin unset ELY_ZSH_ZDOTDIR
|
||||||
@@ -7,8 +7,14 @@ else
|
|||||||
fi
|
fi
|
||||||
[[ -r "${ZDOTDIR:-$HOME}/.zshenv" ]] && builtin source "${ZDOTDIR:-$HOME}/.zshenv"
|
[[ -r "${ZDOTDIR:-$HOME}/.zshenv" ]] && builtin source "${ZDOTDIR:-$HOME}/.zshenv"
|
||||||
if [[ -o interactive ]]; then
|
if [[ -o interactive ]]; then
|
||||||
|
_ely_ran=
|
||||||
|
_ely_first=1
|
||||||
_ely_precmd() {
|
_ely_precmd() {
|
||||||
builtin local ret=$? LC_ALL=C char url=
|
builtin local ret=$? LC_ALL=C char url=
|
||||||
|
if [[ -n $_ely_ran ]]; then
|
||||||
|
builtin printf '\e]133;D;%s\a' $ret
|
||||||
|
_ely_ran=
|
||||||
|
fi
|
||||||
for char in ${(s::)PWD}; do
|
for char in ${(s::)PWD}; do
|
||||||
case $char in
|
case $char in
|
||||||
[[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;;
|
[[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;;
|
||||||
@@ -16,12 +22,28 @@ if [[ -o interactive ]]; then
|
|||||||
url+=$char
|
url+=$char
|
||||||
done
|
done
|
||||||
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${${${PWD:t}:-/}//[[:cntrl:]]/}"
|
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${${${PWD:t}:-/}//[[:cntrl:]]/}"
|
||||||
|
# Themes set PS1 in later hooks: the marks go on after them, at the first prompt through sched.
|
||||||
|
precmd_functions=(${precmd_functions:#_ely_mark} _ely_mark)
|
||||||
|
if (( _ely_first )); then
|
||||||
|
_ely_first=0
|
||||||
|
[[ -o prompt_percent ]] || builtin print -ru2 'ELYTerminal: zsh marks no prompt while prompt_percent is off (setopt prompt_percent)'
|
||||||
|
builtin zmodload zsh/sched && builtin sched +0 _ely_mark $ret
|
||||||
|
fi
|
||||||
builtin return ret
|
builtin return ret
|
||||||
}
|
}
|
||||||
|
_ely_mark() {
|
||||||
|
if [[ -o prompt_percent && $PS1 != *$'\e]133;A'* ]]; then
|
||||||
|
PS1=$'%{\e]133;A\a%}'$PS1$'%{\e]133;B\a%}'
|
||||||
|
fi
|
||||||
|
# sched's command leaves its status for the prompt.
|
||||||
|
builtin return ${1:-0}
|
||||||
|
}
|
||||||
_ely_preexec() {
|
_ely_preexec() {
|
||||||
builtin printf '\e]2;%s\a' "${1//[[:cntrl:]]/}"
|
_ely_ran=1
|
||||||
|
builtin printf '\e]133;C\a\e]2;%s\a' "${1//[[:cntrl:]]/}"
|
||||||
}
|
}
|
||||||
builtin autoload -Uz add-zsh-hook
|
builtin autoload -Uz add-zsh-hook
|
||||||
add-zsh-hook precmd _ely_precmd
|
add-zsh-hook precmd _ely_precmd
|
||||||
|
add-zsh-hook precmd _ely_mark
|
||||||
add-zsh-hook preexec _ely_preexec
|
add-zsh-hook preexec _ely_preexec
|
||||||
fi
|
fi
|
||||||
+31
-2
@@ -1,7 +1,18 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# mac-app.sh: ELYTerminal.app in target/release, signed ad hoc, and its disk image.
|
# mac-app.sh: ELYTerminal.app in target/release and its disk image, signed ad hoc, or by SIGN_IDENTITY and notarized.
|
||||||
set -eu
|
set -eu
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
sign=${SIGN_IDENTITY:-}
|
||||||
|
if [ -n "$sign" ]; then
|
||||||
|
[ -s "${NOTARY_KEY:-}" ] && [ -n "${NOTARY_KEY_ID:-}" ] && [ -n "${NOTARY_ISSUER:-}" ] || {
|
||||||
|
echo "mac-app: SIGN_IDENTITY needs NOTARY_KEY (a .p8 file), NOTARY_KEY_ID and NOTARY_ISSUER" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
security find-identity -v -p codesigning | grep -q -F "\"$sign\"" || {
|
||||||
|
echo "mac-app: no valid identity \"$sign\" in the keychains" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
version=$(cargo pkgid)
|
version=$(cargo pkgid)
|
||||||
version=${version##*[#@]}
|
version=${version##*[#@]}
|
||||||
@@ -38,11 +49,29 @@ cat > "$app/Contents/Info.plist" <<EOF
|
|||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
plutil -lint -s "$app/Contents/Info.plist"
|
plutil -lint -s "$app/Contents/Info.plist"
|
||||||
codesign --force --sign - "$app"
|
if [ -n "$sign" ]; then
|
||||||
|
codesign --force --options runtime --timestamp --sign "$sign" "$app"
|
||||||
|
else
|
||||||
|
codesign --force --sign - "$app"
|
||||||
|
fi
|
||||||
mkdir "$work/disk"
|
mkdir "$work/disk"
|
||||||
ditto "$app" "$work/disk/ELYTerminal.app"
|
ditto "$app" "$work/disk/ELYTerminal.app"
|
||||||
ln -s /Applications "$work/disk/Applications"
|
ln -s /Applications "$work/disk/Applications"
|
||||||
dmg=$out/ELYTerminal-$version.dmg
|
dmg=$out/ELYTerminal-$version.dmg
|
||||||
hdiutil create -quiet -volname ELYTerminal -srcfolder "$work/disk" -ov -format UDZO "$dmg"
|
hdiutil create -quiet -volname ELYTerminal -srcfolder "$work/disk" -ov -format UDZO "$dmg"
|
||||||
|
if [ -n "$sign" ]; then
|
||||||
|
codesign --force --timestamp --sign "$sign" "$dmg"
|
||||||
|
notary() { xcrun notarytool "$@" --key "$NOTARY_KEY" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER"; }
|
||||||
|
notary submit "$dmg" --wait --output-format json > "$work/notary.json" || true
|
||||||
|
status=$(plutil -extract status raw -o - "$work/notary.json" 2>/dev/null || echo none)
|
||||||
|
echo "mac-app: notarization $status"
|
||||||
|
if [ "$status" != Accepted ]; then
|
||||||
|
cat "$work/notary.json" >&2
|
||||||
|
id=$(plutil -extract id raw -o - "$work/notary.json" 2>/dev/null) && notary log "$id" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
xcrun stapler staple "$dmg"
|
||||||
|
spctl --assess --type open --context context:primary-signature --verbose "$dmg"
|
||||||
|
fi
|
||||||
echo "mac-app: $app"
|
echo "mac-app: $app"
|
||||||
echo "mac-app: $dmg"
|
echo "mac-app: $dmg"
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# release-asset.sh FILE: puts FILE on the Gitea release of this version, a draft too, in place of one of its name.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
file=$1
|
||||||
|
[ -f "$file" ] || { echo "release-asset: no file $file" >&2; exit 1; }
|
||||||
|
: "${RELEASE_API:?release-asset: RELEASE_API names the repository API, https://HOST/api/v1/repos/OWNER/REPO}"
|
||||||
|
: "${RELEASE_TOKEN:?release-asset: RELEASE_TOKEN is a token that may edit the releases}"
|
||||||
|
version=$(cargo pkgid)
|
||||||
|
version=${version##*[#@]}
|
||||||
|
name=$(basename "$file")
|
||||||
|
auth="Authorization: token $RELEASE_TOKEN"
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
curl -fsS -H "$auth" "$RELEASE_API/releases?limit=50" > "$work/releases.json"
|
||||||
|
ids=$(python3 -c '
|
||||||
|
import json, sys
|
||||||
|
tag, name, path = sys.argv[1:]
|
||||||
|
found = [r for r in json.load(open(path)) if r["tag_name"] == tag]
|
||||||
|
if len(found) != 1:
|
||||||
|
sys.exit(f"release-asset: {len(found)} releases tagged {tag}")
|
||||||
|
print(found[0]["id"], *(a["id"] for a in found[0]["assets"] if a["name"] == name))
|
||||||
|
' "v$version" "$name" "$work/releases.json")
|
||||||
|
set -- $ids
|
||||||
|
release=$1
|
||||||
|
shift
|
||||||
|
for old in "$@"; do
|
||||||
|
curl -fsS -X DELETE -H "$auth" "$RELEASE_API/releases/$release/assets/$old"
|
||||||
|
echo "release-asset: removed the old $name"
|
||||||
|
done
|
||||||
|
curl -fsS -H "$auth" -F "attachment=@$file" "$RELEASE_API/releases/$release/assets?name=$name" > /dev/null
|
||||||
|
echo "release-asset: $name on release $release, v$version"
|
||||||
+107
-6
@@ -1,10 +1,10 @@
|
|||||||
use std::borrow::Cow;
|
use std::{borrow::Cow, cell::RefCell, rc::Rc, time::Duration};
|
||||||
|
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use ely_gpui_component::theme::ActiveTheme;
|
use ely_gpui_component::theme::ActiveTheme;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Bounds, TitlebarOptions, WindowBounds, WindowDecorations, WindowHandle,
|
App, AppContext, Bounds, Context, Pixels, Size, Task, TitlebarOptions, Window, WindowBounds,
|
||||||
WindowOptions, px, size,
|
WindowDecorations, WindowHandle, WindowOptions, px, size,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -17,6 +17,9 @@ use crate::{
|
|||||||
workspace::{self, TITLE, Workspace},
|
workspace::{self, TITLE, Workspace},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// How long the window's size holds before it is kept, so a drag saves once.
|
||||||
|
const SETTLE: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
/// The id the macOS bundle and Linux desktop entry share.
|
/// The id the macOS bundle and Linux desktop entry share.
|
||||||
pub const APP_ID: &str = "com.zacharyzhang.elyterminal";
|
pub const APP_ID: &str = "com.zacharyzhang.elyterminal";
|
||||||
|
|
||||||
@@ -47,6 +50,23 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
|||||||
workspace::bind_keys(cx);
|
workspace::bind_keys(cx);
|
||||||
settings::window::bind_keys(cx);
|
settings::window::bind_keys(cx);
|
||||||
menus::init(cx);
|
menus::init(cx);
|
||||||
|
cx.set_app_identity(APP_ID, TITLE);
|
||||||
|
// A press on a notification goes to the window whose session posted it.
|
||||||
|
cx.on_system_notification_response(|response, cx| {
|
||||||
|
let tag = response.tag;
|
||||||
|
let pressed = cx
|
||||||
|
.windows()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|window| window.downcast::<Workspace>())
|
||||||
|
.any(|workspace| {
|
||||||
|
workspace
|
||||||
|
.update(cx, |this, window, cx| this.pressed(&tag, window, cx))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
if !pressed {
|
||||||
|
log::info!("app: notification {tag} pressed, but no session holds it");
|
||||||
|
}
|
||||||
|
});
|
||||||
// Settings alone hold no terminal, so they go with the last one.
|
// Settings alone hold no terminal, so they go with the last one.
|
||||||
cx.on_window_closed(|cx, _| {
|
cx.on_window_closed(|cx, _| {
|
||||||
let windows = cx.windows();
|
let windows = cx.windows();
|
||||||
@@ -64,6 +84,8 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
|||||||
mac::repeat_held_keys();
|
mac::repeat_held_keys();
|
||||||
mac::deliver_command_key_ups()?;
|
mac::deliver_command_key_ups()?;
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
themes::omarchy::start(cx)?;
|
||||||
themes::apply(cx);
|
themes::apply(cx);
|
||||||
let chosen = |settings: &settings::Settings| {
|
let chosen = |settings: &settings::Settings| {
|
||||||
let theme = (settings.theme.clone(), settings.dark_theme.clone());
|
let theme = (settings.theme.clone(), settings.dark_theme.clone());
|
||||||
@@ -83,10 +105,16 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
/// Opens the main window running `launch`.
|
/// Opens the main window running `launch`.
|
||||||
pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspace>> {
|
pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspace>> {
|
||||||
let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx);
|
let kept = opening_size(cx);
|
||||||
|
// macOS grows a window by a point on a side whose edge falls on a half point.
|
||||||
|
let centered = Bounds::centered(None, kept, cx);
|
||||||
|
let bounds = Bounds::new(centered.origin.map(|at| at.round()), kept);
|
||||||
let background = blur::appearance(SettingsStore::get(cx));
|
let background = blur::appearance(SettingsStore::get(cx));
|
||||||
|
let least = px(*settings::WINDOW.start());
|
||||||
let options = WindowOptions {
|
let options = WindowOptions {
|
||||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||||
|
// No smaller than the settings keep.
|
||||||
|
window_min_size: Some(size(least, least)),
|
||||||
titlebar: Some(TitlebarOptions {
|
titlebar: Some(TitlebarOptions {
|
||||||
title: Some(TITLE.into()),
|
title: Some(TITLE.into()),
|
||||||
appears_transparent: true,
|
appears_transparent: true,
|
||||||
@@ -103,11 +131,84 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
|
|||||||
window
|
window
|
||||||
.observe_window_appearance(|_, cx| themes::apply(cx))
|
.observe_window_appearance(|_, cx| themes::apply(cx))
|
||||||
.detach();
|
.detach();
|
||||||
cx.new(|cx| Workspace::new(pty, events, launch, window, cx))
|
cx.new(|cx| {
|
||||||
|
keep_size(window, cx);
|
||||||
|
Workspace::new(pty, events, launch, window, cx)
|
||||||
|
})
|
||||||
})?;
|
})?;
|
||||||
log::info!(
|
log::info!(
|
||||||
"app: main window open, blur: {background:?} on {}",
|
"app: main window open at {} by {}, blur: {background:?} on {}",
|
||||||
|
f32::from(kept.width),
|
||||||
|
f32::from(kept.height),
|
||||||
std::env::consts::OS
|
std::env::consts::OS
|
||||||
);
|
);
|
||||||
Ok(window)
|
Ok(window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kept size, within the primary display's visible area where that is known.
|
||||||
|
fn opening_size(cx: &App) -> Size<Pixels> {
|
||||||
|
let settings = SettingsStore::get(cx);
|
||||||
|
let room = cx
|
||||||
|
.primary_display()
|
||||||
|
.map(|display| display.visible_bounds().size);
|
||||||
|
let fit = |want: f32, side: Option<Pixels>| match side.map(f32::from) {
|
||||||
|
Some(side) if side > 0.0 && want > side => {
|
||||||
|
log::info!(
|
||||||
|
"app: a kept side of {want} px passes the screen's {side}; the window takes {side}"
|
||||||
|
);
|
||||||
|
px(side)
|
||||||
|
}
|
||||||
|
_ => px(want),
|
||||||
|
};
|
||||||
|
size(
|
||||||
|
fit(settings.window_width, room.map(|area| area.width)),
|
||||||
|
fit(settings.window_height, room.map(|area| area.height)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the window's size in the settings once it settles; maximized, full screen or hidden, the size before stays.
|
||||||
|
fn keep_size(window: &mut Window, cx: &mut Context<Workspace>) {
|
||||||
|
let settling: Rc<RefCell<Option<Task<()>>>> = Rc::default();
|
||||||
|
let settle = move |window: &mut Window, cx: &mut Context<Workspace>| {
|
||||||
|
let task = cx.spawn_in(window, async move |_, cx| {
|
||||||
|
cx.background_executor().timer(SETTLE).await;
|
||||||
|
// Read once settled: X11 marks a window maximized late, and unmaximized while hidden.
|
||||||
|
let kept = cx.update(|window, cx| {
|
||||||
|
if !window.is_visible() || window.is_maximized() || window.is_fullscreen() {
|
||||||
|
log::debug!(
|
||||||
|
"app: the window is hidden, maximized or full screen; its kept size stays"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let area = window.window_bounds().get_bounds().size;
|
||||||
|
let (width, height) = (
|
||||||
|
f32::from(area.width).round(),
|
||||||
|
f32::from(area.height).round(),
|
||||||
|
);
|
||||||
|
SettingsStore::update(cx, |settings| {
|
||||||
|
settings.window_width = width;
|
||||||
|
settings.window_height = height;
|
||||||
|
})
|
||||||
|
.with_context(|| {
|
||||||
|
format!("app: the window's size, {width} by {height}, is not kept")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match kept {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(error)) => log::error!("{error:#}"),
|
||||||
|
Err(_) => log::debug!("app: the window closed before its size settled"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
settling.replace(Some(task));
|
||||||
|
};
|
||||||
|
let shown = settle.clone();
|
||||||
|
cx.observe_window_bounds(window, move |_, window, cx| settle(window, cx))
|
||||||
|
.detach();
|
||||||
|
// A size skipped while the window hid is read again once it shows.
|
||||||
|
cx.observe_window_visibility(window, move |_, visibility, window, cx| {
|
||||||
|
if visibility.is_visible() {
|
||||||
|
shown(window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
+303
-12
@@ -1,18 +1,25 @@
|
|||||||
use std::path::{MAIN_SEPARATOR, Path, PathBuf};
|
use std::{
|
||||||
|
path::{MAIN_SEPARATOR, Path, PathBuf},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
use ely_gpui_component::{
|
use ely_gpui_component::{
|
||||||
buttons::{ButtonVariant, IconButton},
|
buttons::{ButtonVariant, IconButton},
|
||||||
lists::ListItem,
|
lists::ListItem,
|
||||||
primitives::{Icon, IconName},
|
primitives::{Icon, IconName, Tooltip},
|
||||||
theme::{ActiveTheme, ControlSize},
|
theme::{ActiveTheme, ControlSize, IconSize},
|
||||||
};
|
};
|
||||||
use gpui::{App, Entity, Subscription};
|
use gpui::{AnyElement, App, Entity, SharedString, Subscription, div, prelude::*, rems};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
term::TerminalView,
|
term::{Kind, Record, Records, State, TerminalView},
|
||||||
workspace::{TITLE, Workspace},
|
workspace::{TITLE, Workspace},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod orb;
|
||||||
|
|
||||||
|
use orb::Look;
|
||||||
|
|
||||||
/// A program on its own terminal, as the sessions column lists it.
|
/// A program on its own terminal, as the sessions column lists it.
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub terminal: Entity<TerminalView>,
|
pub terminal: Entity<TerminalView>,
|
||||||
@@ -24,9 +31,31 @@ 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 look its mark shows, and since when.
|
||||||
|
pub look: Option<(Look, Instant)>,
|
||||||
pub _events: Subscription,
|
pub _events: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Notes the look its mark takes `now`; true while the mark moves.
|
||||||
|
pub fn note(&mut self, now: Instant, cx: &App) -> bool {
|
||||||
|
let view = self.terminal.read(cx);
|
||||||
|
let failed = view.ended().is_some_and(|code| code != 0);
|
||||||
|
let shows = look(view.records().shown().map(|(_, record)| record), failed);
|
||||||
|
if self.look.map(|(shown, _)| shown) != shows {
|
||||||
|
self.look = shows.map(|shows| (shows, now));
|
||||||
|
}
|
||||||
|
self.look
|
||||||
|
.is_some_and(|(look, since)| orb::moves(look, now.saturating_duration_since(since)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()) {
|
||||||
@@ -57,16 +86,18 @@ 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,
|
||||||
session: &Session,
|
session: &Session,
|
||||||
active: bool,
|
active: bool,
|
||||||
home: Option<&Path>,
|
home: Option<&Path>,
|
||||||
|
now: Instant,
|
||||||
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 {
|
||||||
@@ -76,7 +107,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)
|
||||||
@@ -88,11 +119,158 @@ 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(session, now, 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a mark shows: the most pressing record's state, else a failed last command.
|
||||||
|
pub fn look(record: Option<&Record>, failed: bool) -> Option<Look> {
|
||||||
|
Some(
|
||||||
|
match (record.map(|record| (record.state, record.progress)), failed) {
|
||||||
|
(Some((State::Blocked, _)), _) => Look::Waiting,
|
||||||
|
(Some((State::Error, _)), _) => Look::Error,
|
||||||
|
(Some((State::Working, progress)), _) => Look::Working(progress),
|
||||||
|
(Some((State::Done, _)), _) => Look::Done,
|
||||||
|
(_, true) => Look::Error,
|
||||||
|
(Some((State::Idle, _)), false) => Look::Idle,
|
||||||
|
(None, false) => return None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session's mark: its look's dots in its state's color; hover lists the records.
|
||||||
|
fn mark(session: &Session, now: Instant, cx: &App) -> Option<AnyElement> {
|
||||||
|
let (look, since) = session.look?;
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = &theme.colors;
|
||||||
|
let color = match look {
|
||||||
|
Look::Working(_) => colors.info,
|
||||||
|
Look::Waiting => colors.warning,
|
||||||
|
Look::Error => colors.danger,
|
||||||
|
Look::Done => colors.success,
|
||||||
|
Look::Idle => colors.fg_muted,
|
||||||
|
};
|
||||||
|
let pid = session.terminal.read(cx).pid() as usize;
|
||||||
|
// Hover reads the records as they stand, so an open one follows them, and scrolls within the window.
|
||||||
|
let watched = session.terminal.downgrade();
|
||||||
|
let tip = Tooltip::rich(move |window, cx| {
|
||||||
|
let lines = watched.upgrade().map_or_else(Vec::new, |terminal| {
|
||||||
|
let view = terminal.read(cx);
|
||||||
|
hover(view.records(), view.ended().filter(|&code| code != 0))
|
||||||
|
});
|
||||||
|
let room = window.viewport_size().height - rems(3.).to_pixels(window.rem_size());
|
||||||
|
div()
|
||||||
|
.id("session-hover")
|
||||||
|
.max_h(room)
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.children(lines)
|
||||||
|
.into_any_element()
|
||||||
|
});
|
||||||
|
// Every mark takes a 24-pixel 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()
|
||||||
|
.hoverable_tooltip(tip)
|
||||||
|
.child(orb::lattice(look, color, now, since))
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What hover shows: a line for each record, ten at most, then a failed command's status.
|
||||||
|
pub fn hover(records: &Records, failed: Option<i32>) -> Vec<SharedString> {
|
||||||
|
let mut lines: Vec<SharedString> = records
|
||||||
|
.iter()
|
||||||
|
.take(LISTED)
|
||||||
|
.map(|(id, record)| line(records, id, record).into())
|
||||||
|
.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 as hover and notifications say it: its state, then its app, title and message.
|
||||||
|
pub fn line(records: &Records, id: &str, record: &Record) -> String {
|
||||||
|
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);
|
||||||
|
if said.is_empty() {
|
||||||
|
state
|
||||||
|
} else {
|
||||||
|
format!("{state}: {}", said.join(" · "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
@@ -133,4 +311,117 @@ 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 the_mark_shows_the_most_pressing_look() {
|
||||||
|
let record = |state, progress| Record {
|
||||||
|
state,
|
||||||
|
kind: None,
|
||||||
|
progress,
|
||||||
|
app: None,
|
||||||
|
title: None,
|
||||||
|
msg: None,
|
||||||
|
};
|
||||||
|
let waiting = look(Some(&record(State::Blocked, None)), true);
|
||||||
|
assert_eq!(
|
||||||
|
waiting,
|
||||||
|
Some(Look::Waiting),
|
||||||
|
"waiting outranks a failed command"
|
||||||
|
);
|
||||||
|
let working = look(Some(&record(State::Working, Some(40))), false);
|
||||||
|
assert_eq!(working, Some(Look::Working(Some(40))));
|
||||||
|
let idle = record(State::Idle, None);
|
||||||
|
assert_eq!(
|
||||||
|
look(Some(&idle), true),
|
||||||
|
Some(Look::Error),
|
||||||
|
"a failed command outranks idle"
|
||||||
|
);
|
||||||
|
assert_eq!(look(Some(&idle), false), Some(Look::Idle));
|
||||||
|
assert_eq!(
|
||||||
|
look(Some(&record(State::Done, None)), true),
|
||||||
|
Some(Look::Done)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
look(Some(&record(State::Error, None)), false),
|
||||||
|
Some(Look::Error)
|
||||||
|
);
|
||||||
|
assert_eq!(look(None, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! A session's status mark: 3 by 3 dots in its state's color, every mark stepping on one clock.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
f64::consts::TAU,
|
||||||
|
sync::OnceLock,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use gpui::{Div, Hsla, ParentElement, Styled, div};
|
||||||
|
|
||||||
|
/// The dots' beat: while a mark moves, the window draws at each step, not at every frame.
|
||||||
|
pub const STEP: Duration = Duration::from_millis(80);
|
||||||
|
|
||||||
|
/// What a mark shows.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Look {
|
||||||
|
/// A ripple from the centre; with progress, the outer ring fills clockwise from the top.
|
||||||
|
Working(Option<u8>),
|
||||||
|
/// Lit, with two quick dips.
|
||||||
|
Waiting,
|
||||||
|
/// A program's error, or a failed last command.
|
||||||
|
Error,
|
||||||
|
Done,
|
||||||
|
Idle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One ripple, in seconds, and how far a dot one dot out from the centre lags it.
|
||||||
|
const RIPPLE: f64 = 1.2;
|
||||||
|
const LAG: f64 = 0.3;
|
||||||
|
/// Steps in one round of a waiting mark's dips.
|
||||||
|
const BLINK: u64 = 20;
|
||||||
|
/// A working dot at its lowest, a waiting mark in a dip, and a dot outside a settled shape.
|
||||||
|
const DIM: f32 = 0.35;
|
||||||
|
const DIP: f32 = 0.25;
|
||||||
|
const FAINT: f32 = 0.12;
|
||||||
|
/// Steps a settled shape's dot takes to light, and between one dot and the next.
|
||||||
|
const FADE: u64 = 2;
|
||||||
|
const STAGGER: u64 = 1;
|
||||||
|
/// The outer ring clockwise from the top, then the centre: the order progress fills.
|
||||||
|
const FILL: [usize; 9] = [1, 2, 5, 8, 7, 6, 3, 0, 4];
|
||||||
|
|
||||||
|
/// A settled look's dots in the order they light, and how bright they end; none for a look that keeps moving.
|
||||||
|
fn shape(look: Look) -> Option<(&'static [usize], f32)> {
|
||||||
|
match look {
|
||||||
|
Look::Working(_) | Look::Waiting => None,
|
||||||
|
Look::Error => Some((&[0, 4, 8, 2, 6], 1.0)),
|
||||||
|
Look::Done => Some((&[3, 7, 5, 2], 1.0)),
|
||||||
|
Look::Idle => Some((&FILL[..8], 0.6)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn steps(time: Duration) -> u64 {
|
||||||
|
(time.as_millis() / STEP.as_millis()) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dot's brightness `t` seconds into its ripple.
|
||||||
|
fn crest(t: f64) -> f32 {
|
||||||
|
let wave = 0.5 + 0.5 * (TAU * t / RIPPLE).cos();
|
||||||
|
DIM + (1.0 - DIM) * wave as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each dot's opacity, row by row, `clock` on the marks' clock and `held` after the look began.
|
||||||
|
pub fn dots(look: Look, clock: Duration, held: Duration) -> [f32; 9] {
|
||||||
|
let t = steps(clock) as f64 * STEP.as_secs_f64();
|
||||||
|
let mut levels = [DIM; 9];
|
||||||
|
match look {
|
||||||
|
Look::Working(None) => {
|
||||||
|
for (dot, level) in levels.iter_mut().enumerate() {
|
||||||
|
let (x, y) = ((dot % 3) as f64 - 1.0, (dot / 3) as f64 - 1.0);
|
||||||
|
*level = crest(t - x.hypot(y) * LAG);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Look::Working(Some(progress)) => {
|
||||||
|
let filled = (usize::from(progress) * FILL.len() / 100).min(FILL.len() - 1);
|
||||||
|
for (rank, &dot) in FILL.iter().enumerate() {
|
||||||
|
levels[dot] = match rank.cmp(&filled) {
|
||||||
|
Ordering::Less => 1.0,
|
||||||
|
Ordering::Equal => crest(t),
|
||||||
|
Ordering::Greater => DIM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Look::Waiting => {
|
||||||
|
let dip = matches!(steps(clock) % BLINK, 0 | 1 | 4 | 5);
|
||||||
|
levels = [if dip { DIP } else { 1.0 }; 9];
|
||||||
|
}
|
||||||
|
settled => {
|
||||||
|
let (shape, bright) = shape(settled).expect("a settled look has a shape");
|
||||||
|
levels = [FAINT; 9];
|
||||||
|
let held = steps(held);
|
||||||
|
for (rank, &dot) in shape.iter().enumerate() {
|
||||||
|
let lit = held.saturating_sub(rank as u64 * STAGGER).min(FADE) as f32 / FADE as f32;
|
||||||
|
levels[dot] = bright * lit + FAINT * (1.0 - lit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
levels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a mark still changes `held` after its look began: work and waiting always, a settled shape until it is drawn.
|
||||||
|
pub fn moves(look: Look, held: Duration) -> bool {
|
||||||
|
shape(look).is_none_or(|(shape, _)| steps(held) < (shape.len() as u64 - 1) * STAGGER + FADE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time on the clock every mark steps to, so marks move together.
|
||||||
|
fn clock(now: Instant) -> Duration {
|
||||||
|
static START: OnceLock<Instant> = OnceLock::new();
|
||||||
|
now.saturating_duration_since(*START.get_or_init(|| now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long until the clock's next step, a little past it.
|
||||||
|
pub fn wait(now: Instant) -> Duration {
|
||||||
|
let into = clock(now).as_nanos() % STEP.as_nanos();
|
||||||
|
STEP - Duration::from_nanos(into as u64) + Duration::from_millis(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark's dots in `color`, `now`, its look shown since `since`.
|
||||||
|
pub fn lattice(look: Look, color: Hsla, now: Instant, since: Instant) -> Div {
|
||||||
|
let levels = dots(look, clock(now), now.saturating_duration_since(since));
|
||||||
|
div()
|
||||||
|
.grid()
|
||||||
|
.grid_cols(3)
|
||||||
|
.gap_0p5()
|
||||||
|
.children(levels.map(|level| div().size_1().rounded_full().bg(color.opacity(level))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn at(steps: u32) -> Duration {
|
||||||
|
STEP * steps
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn work_ripples_from_the_centre_to_the_edges_then_the_corners() {
|
||||||
|
let levels = |steps| dots(Look::Working(None), at(steps), Duration::ZERO);
|
||||||
|
let peak = |dot: usize| {
|
||||||
|
(0..15)
|
||||||
|
.max_by(|&a, &b| levels(a)[dot].total_cmp(&levels(b)[dot]))
|
||||||
|
.expect("steps")
|
||||||
|
};
|
||||||
|
let (centre, edge, corner) = (peak(4), peak(1), peak(0));
|
||||||
|
assert_eq!(centre, 0);
|
||||||
|
assert!(centre < edge && edge < corner, "{centre} {edge} {corner}");
|
||||||
|
assert_eq!(peak(3), edge, "every edge together");
|
||||||
|
assert_eq!(peak(8), corner, "every corner together");
|
||||||
|
let again = levels(15);
|
||||||
|
assert!(
|
||||||
|
levels(0)
|
||||||
|
.iter()
|
||||||
|
.zip(again)
|
||||||
|
.all(|(a, b)| (a - b).abs() < 1e-4),
|
||||||
|
"one ripple every 15 steps"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_fills_the_ring_clockwise_from_the_top_then_the_centre() {
|
||||||
|
let lit = |progress, steps| dots(Look::Working(Some(progress)), at(steps), Duration::ZERO);
|
||||||
|
let half = lit(50, 9);
|
||||||
|
assert!([1, 2, 5, 8].iter().all(|&dot| half[dot] == 1.0), "{half:?}");
|
||||||
|
assert!([6, 3, 0, 4].iter().all(|&dot| half[dot] == DIM), "{half:?}");
|
||||||
|
assert!(lit(50, 0)[7] > half[7], "the next dot pulses");
|
||||||
|
let full = lit(100, 9);
|
||||||
|
assert!(FILL[..8].iter().all(|&dot| full[dot] == 1.0), "{full:?}");
|
||||||
|
assert!(lit(100, 0)[4] > full[4], "then the centre pulses");
|
||||||
|
assert!(lit(0, 0)[1] > lit(0, 9)[1], "at 0% the top pulses");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waiting_stays_lit_and_dips_twice() {
|
||||||
|
let blink = |steps| dots(Look::Waiting, at(steps), Duration::ZERO);
|
||||||
|
let dips: Vec<u32> = (0..20).filter(|&steps| blink(steps)[0] == DIP).collect();
|
||||||
|
assert_eq!(dips, [0, 1, 4, 5]);
|
||||||
|
assert!((0..20).all(|steps| blink(steps).iter().all(|&level| level == blink(steps)[0])));
|
||||||
|
assert_eq!(blink(2), [1.0; 9]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_settled_look_draws_its_shape_dot_by_dot_then_holds() {
|
||||||
|
let done = |steps| dots(Look::Done, at(1000), at(steps));
|
||||||
|
assert_eq!(done(0), [FAINT; 9], "nothing lit at first");
|
||||||
|
assert!(
|
||||||
|
done(1)[3] > FAINT && done(1)[2] == FAINT,
|
||||||
|
"the check draws from its left end"
|
||||||
|
);
|
||||||
|
let check = [3, 7, 5, 2];
|
||||||
|
let drawn = done(5);
|
||||||
|
assert!((0..9).all(|dot| drawn[dot] == if check.contains(&dot) { 1.0 } else { FAINT }));
|
||||||
|
assert_eq!(done(500), drawn, "then it holds still");
|
||||||
|
assert!(moves(Look::Done, at(4)) && !moves(Look::Done, at(5)));
|
||||||
|
let idle = dots(Look::Idle, Duration::ZERO, at(9));
|
||||||
|
assert_eq!(idle[4], FAINT, "idle is a ring");
|
||||||
|
assert!(FILL[..8].iter().all(|&dot| idle[dot] == 0.6));
|
||||||
|
assert!(!moves(Look::Idle, at(9)) && moves(Look::Idle, at(8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn work_and_waiting_never_stop() {
|
||||||
|
for look in [Look::Working(None), Look::Working(Some(40)), Look::Waiting] {
|
||||||
|
assert!(moves(look, at(1_000_000)), "{look:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-5
@@ -75,6 +75,12 @@ pub struct Settings {
|
|||||||
pub show_sessions: bool,
|
pub show_sessions: bool,
|
||||||
pub show_panel: bool,
|
pub show_panel: bool,
|
||||||
pub panel: PanelTab,
|
pub panel: PanelTab,
|
||||||
|
/// The sessions and right columns' widths, in pixels, as their borders were dragged.
|
||||||
|
pub sessions_width: f32,
|
||||||
|
pub panel_width: f32,
|
||||||
|
/// The main window's size, unmaximized, in pixels.
|
||||||
|
pub window_width: f32,
|
||||||
|
pub window_height: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@@ -82,7 +88,7 @@ impl Default for Settings {
|
|||||||
Self {
|
Self {
|
||||||
shell: String::new(),
|
shell: String::new(),
|
||||||
start_in: StartIn::Home,
|
start_in: StartIn::Home,
|
||||||
theme: "catppuccin-mocha".into(),
|
theme: themes::default_id().into(),
|
||||||
dark_theme: None,
|
dark_theme: None,
|
||||||
font_family: "JetBrains Mono".into(),
|
font_family: "JetBrains Mono".into(),
|
||||||
font_size: 13.0,
|
font_size: 13.0,
|
||||||
@@ -100,6 +106,10 @@ impl Default for Settings {
|
|||||||
show_sessions: true,
|
show_sessions: true,
|
||||||
show_panel: true,
|
show_panel: true,
|
||||||
panel: PanelTab::Git,
|
panel: PanelTab::Git,
|
||||||
|
sessions_width: 240.0,
|
||||||
|
panel_width: 240.0,
|
||||||
|
window_width: 1200.0,
|
||||||
|
window_height: 760.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +119,8 @@ pub const LINE_HEIGHT: RangeInclusive<f32> = 1.0..=2.0;
|
|||||||
pub const OPACITY: RangeInclusive<f32> = 0.3..=1.0;
|
pub const OPACITY: RangeInclusive<f32> = 0.3..=1.0;
|
||||||
pub const PADDING: RangeInclusive<f32> = 0.0..=64.0;
|
pub const PADDING: RangeInclusive<f32> = 0.0..=64.0;
|
||||||
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
|
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
|
||||||
|
pub const COLUMN: RangeInclusive<f32> = 160.0..=960.0;
|
||||||
|
pub const WINDOW: RangeInclusive<f32> = 100.0..=16384.0;
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Every value inside its range, every theme built in; the error names the field.
|
/// Every value inside its range, every theme built in; the error names the field.
|
||||||
@@ -119,15 +131,16 @@ impl Settings {
|
|||||||
within("padding_x", self.padding_x, &PADDING)?;
|
within("padding_x", self.padding_x, &PADDING)?;
|
||||||
within("padding_y", self.padding_y, &PADDING)?;
|
within("padding_y", self.padding_y, &PADDING)?;
|
||||||
within("scrollback", self.scrollback, &SCROLLBACK)?;
|
within("scrollback", self.scrollback, &SCROLLBACK)?;
|
||||||
|
within("sessions_width", self.sessions_width, &COLUMN)?;
|
||||||
|
within("panel_width", self.panel_width, &COLUMN)?;
|
||||||
|
within("window_width", self.window_width, &WINDOW)?;
|
||||||
|
within("window_height", self.window_height, &WINDOW)?;
|
||||||
for (field, id) in [
|
for (field, id) in [
|
||||||
("theme", Some(&self.theme)),
|
("theme", Some(&self.theme)),
|
||||||
("dark_theme", self.dark_theme.as_ref()),
|
("dark_theme", self.dark_theme.as_ref()),
|
||||||
] {
|
] {
|
||||||
if let Some(id) = id {
|
if let Some(id) = id {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(themes::known(id), "{field} is {id:?}, no built-in theme");
|
||||||
themes::find(id).is_some(),
|
|
||||||
"{field} is {id:?}, no built-in theme"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
||||||
@@ -228,6 +241,21 @@ mod tests {
|
|||||||
error.to_string(),
|
error.to_string(),
|
||||||
"scrollback is 100001, outside 0 to 100000"
|
"scrollback is 100001, outside 0 to 100000"
|
||||||
);
|
);
|
||||||
|
let settings = Settings {
|
||||||
|
window_width: 50.0,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
let error = settings.validate().expect_err("too narrow");
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"window_width is 50, outside 100 to 16384"
|
||||||
|
);
|
||||||
|
let settings = Settings {
|
||||||
|
panel_width: 1000.0,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
let error = settings.validate().expect_err("too wide");
|
||||||
|
assert_eq!(error.to_string(), "panel_width is 1000, outside 160 to 960");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+58
-62
@@ -3,11 +3,11 @@ use std::fmt::Debug;
|
|||||||
use ely_gpui_component::{
|
use ely_gpui_component::{
|
||||||
buttons::SegmentedControl,
|
buttons::SegmentedControl,
|
||||||
feedback::InlineMessage,
|
feedback::InlineMessage,
|
||||||
forms::{InputEvent, PathInput, Switch, TextInput},
|
forms::{InputEvent, Switch, TextInput},
|
||||||
primitives::{FocusScope, IconName, Severity},
|
lists::{ListItem, SelectableList},
|
||||||
settings::{SettingsLayout, SettingsRow, SettingsSection},
|
primitives::{FocusScope, Icon, IconName, Severity},
|
||||||
shell::{ColumnShell, ResizeBorder, ShellColumn},
|
shell::{ColumnShell, ResizeBorder, ShellColumn},
|
||||||
theme::{ActiveTheme, ControlSize},
|
theme::{ActiveTheme, ControlSize, IconSize},
|
||||||
};
|
};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Bounds, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
|
App, AppContext, Bounds, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
|
||||||
@@ -16,7 +16,7 @@ use gpui::{
|
|||||||
actions, div, prelude::FluentBuilder, px, size,
|
actions, div, prelude::FluentBuilder, px, size,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{Settings, SettingsStore, StartIn};
|
use super::{Settings, SettingsStore};
|
||||||
use crate::{app::APP_ID, menus, term::BLINK};
|
use crate::{app::APP_ID, menus, term::BLINK};
|
||||||
|
|
||||||
mod appearance;
|
mod appearance;
|
||||||
@@ -28,8 +28,7 @@ actions!(elyterminal, [OpenSettings, CloseSettings]);
|
|||||||
const CONTEXT: &str = "Settings";
|
const CONTEXT: &str = "Settings";
|
||||||
|
|
||||||
/// Each category's key, name and icon.
|
/// Each category's key, name and icon.
|
||||||
const SECTIONS: [(&str, &str, IconName); 3] = [
|
const SECTIONS: [(&str, &str, IconName); 2] = [
|
||||||
("general", "General", IconName::Settings),
|
|
||||||
("appearance", "Appearance", IconName::Palette),
|
("appearance", "Appearance", IconName::Palette),
|
||||||
("terminal", "Terminal", IconName::Terminal),
|
("terminal", "Terminal", IconName::Terminal),
|
||||||
];
|
];
|
||||||
@@ -83,11 +82,13 @@ fn open_window(cx: &mut App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings window: categories down the side, the open one's rows beside them.
|
/// The settings window: categories in one column, the open one's rows in the next, each scrolling on its own.
|
||||||
pub struct SettingsWindow {
|
pub struct SettingsWindow {
|
||||||
focus: FocusHandle,
|
focus: FocusHandle,
|
||||||
section: SharedString,
|
section: SharedString,
|
||||||
shell: Entity<TextInput>,
|
shell: Entity<TextInput>,
|
||||||
|
/// The shortcut list's search.
|
||||||
|
search: Entity<TextInput>,
|
||||||
/// Why the typed shell is not saved.
|
/// Why the typed shell is not saved.
|
||||||
refused: Option<SharedString>,
|
refused: Option<SharedString>,
|
||||||
/// Why the last change did not save.
|
/// Why the last change did not save.
|
||||||
@@ -110,6 +111,7 @@ impl SettingsWindow {
|
|||||||
input
|
input
|
||||||
});
|
});
|
||||||
let font = cx.new(|cx| TextInput::new(window, cx));
|
let font = cx.new(|cx| TextInput::new(window, cx));
|
||||||
|
let search = cx.new(|cx| TextInput::new(window, cx).placeholder("Search shortcuts"));
|
||||||
let typed = cx.subscribe(&shell, |this, input, event: &InputEvent, cx| {
|
let typed = cx.subscribe(&shell, |this, input, event: &InputEvent, cx| {
|
||||||
if let InputEvent::Changed = event {
|
if let InputEvent::Changed = event {
|
||||||
let shell = input.read(cx).text().to_string();
|
let shell = input.read(cx).text().to_string();
|
||||||
@@ -123,6 +125,7 @@ impl SettingsWindow {
|
|||||||
focus,
|
focus,
|
||||||
section: SECTIONS[0].0.into(),
|
section: SECTIONS[0].0.into(),
|
||||||
shell,
|
shell,
|
||||||
|
search,
|
||||||
refused: None,
|
refused: None,
|
||||||
failed: None,
|
failed: None,
|
||||||
drafts: 0,
|
drafts: 0,
|
||||||
@@ -167,46 +170,36 @@ impl SettingsWindow {
|
|||||||
};
|
};
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn general(&self, cx: &Context<Self>) -> SettingsSection {
|
|
||||||
let start = match SettingsStore::get(cx).start_in {
|
|
||||||
StartIn::Home => "home",
|
|
||||||
StartIn::Session => "session",
|
|
||||||
};
|
|
||||||
let shell = div()
|
|
||||||
.w(cx.theme().prose_width())
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.child(PathInput::new("settings-shell", &self.shell))
|
|
||||||
.children((self.refused.clone()).map(|why| InlineMessage::new(Severity::Danger, why)));
|
|
||||||
let start = SegmentedControl::new("settings-start-in", start)
|
|
||||||
.segment("home", "Home", None)
|
|
||||||
.segment("session", "Session's folder", None)
|
|
||||||
.size(ControlSize::Sm)
|
|
||||||
.on_change({
|
|
||||||
let this = cx.entity();
|
|
||||||
move |value, _, cx| start_in(&this, value, cx)
|
|
||||||
});
|
|
||||||
SettingsSection::new("General")
|
|
||||||
.row(SettingsRow::new("Shell").control(shell))
|
|
||||||
.row(SettingsRow::new("Start in").control(start))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where new sessions start, as the settings keep it.
|
/// The categories; a press, Up and Down, or Enter opens one.
|
||||||
fn start_in(this: &Entity<SettingsWindow>, value: &SharedString, cx: &mut App) {
|
fn sections(open: &SharedString, cx: &Context<SettingsWindow>) -> SelectableList {
|
||||||
let start = match value.as_ref() {
|
let muted = cx.theme().colors.fg_muted;
|
||||||
"home" => StartIn::Home,
|
let list = SECTIONS.iter().fold(
|
||||||
"session" => StartIn::Session,
|
SelectableList::new("settings-section-list").selected([open.clone()]),
|
||||||
other => unreachable!("settings: no start {other}"),
|
|list, (key, name, icon)| {
|
||||||
};
|
let item = ListItem::new(SharedString::from(format!("settings-section-{key}")), *name)
|
||||||
set(
|
.leading(Icon::new(*icon).size(IconSize::Sm).color(muted));
|
||||||
this,
|
list.row(*key, item)
|
||||||
format!("new sessions start in {start:?}"),
|
},
|
||||||
cx,
|
|
||||||
|settings| settings.start_in = start,
|
|
||||||
);
|
);
|
||||||
|
let show = {
|
||||||
|
let this = cx.entity();
|
||||||
|
move |key: &SharedString, cx: &mut App| {
|
||||||
|
log::info!("settings: open {key}");
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.section = key.clone();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let enter = show.clone();
|
||||||
|
list.on_change(move |keys, _, cx| {
|
||||||
|
if let Some(key) = keys.first() {
|
||||||
|
show(key, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_activate(move |key, _, cx| enter(key, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A switch that saves through `put`.
|
/// A switch that saves through `put`.
|
||||||
@@ -289,9 +282,7 @@ fn set(
|
|||||||
|
|
||||||
impl Render for SettingsWindow {
|
impl Render for SettingsWindow {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let pick = cx.entity();
|
|
||||||
let page = match self.section.as_ref() {
|
let page = match self.section.as_ref() {
|
||||||
"general" => self.general(cx).into_any_element(),
|
|
||||||
"appearance" => self.appearance(window, cx).into_any_element(),
|
"appearance" => self.appearance(window, cx).into_any_element(),
|
||||||
"terminal" => self.terminal(cx).into_any_element(),
|
"terminal" => self.terminal(cx).into_any_element(),
|
||||||
other => unreachable!("settings: no section {other}"),
|
other => unreachable!("settings: no section {other}"),
|
||||||
@@ -302,26 +293,31 @@ impl Render for SettingsWindow {
|
|||||||
.gap_4()
|
.gap_4()
|
||||||
.children((self.failed.clone()).map(|why| InlineMessage::new(Severity::Danger, why)))
|
.children((self.failed.clone()).map(|why| InlineMessage::new(Severity::Danger, why)))
|
||||||
.child(page);
|
.child(page);
|
||||||
let layout = SettingsLayout::new("settings", SECTIONS, self.section.clone(), {
|
let (_, name, _) = SECTIONS
|
||||||
move |key, _, cx| {
|
.iter()
|
||||||
pick.update(cx, |this, cx| {
|
.find(|(key, ..)| *key == self.section.as_ref())
|
||||||
this.section = key.clone();
|
.expect("the open section is listed");
|
||||||
cx.notify();
|
let list = div()
|
||||||
})
|
.id("settings-list")
|
||||||
}
|
.size_full()
|
||||||
})
|
.overflow_y_scroll()
|
||||||
.page(page);
|
.p_2()
|
||||||
|
.child(sections(&self.section, cx));
|
||||||
let body = div()
|
let body = div()
|
||||||
.id("settings-body")
|
.id("settings-body")
|
||||||
.size_full()
|
.size_full()
|
||||||
.overflow_y_scroll()
|
.overflow_y_scroll()
|
||||||
.p_6()
|
.p_6()
|
||||||
.child(layout);
|
.child(page);
|
||||||
let shell = ColumnShell::new("settings").column(
|
// The categories and the page scroll apart.
|
||||||
ShellColumn::new("settings-column")
|
let shell = ColumnShell::new("settings")
|
||||||
.title("Settings")
|
.column(
|
||||||
.child(body),
|
ShellColumn::new("settings-sections")
|
||||||
);
|
.width(cx.theme().label_width() * 1.25)
|
||||||
|
.title("Settings")
|
||||||
|
.child(list),
|
||||||
|
)
|
||||||
|
.column(ShellColumn::new("settings-page").title(*name).child(body));
|
||||||
// The context sits above the focus root, so its keys work wherever focus is.
|
// The context sits above the focus root, so its keys work wherever focus is.
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ impl SettingsWindow {
|
|||||||
settings.cursor_style,
|
settings.cursor_style,
|
||||||
|settings, style| settings.cursor_style = style,
|
|settings, style| settings.cursor_style = style,
|
||||||
);
|
);
|
||||||
let active = themes::active(settings, window.appearance());
|
let active = themes::active(settings, window.appearance(), cx);
|
||||||
let lit = self.lit || !settings.cursor_blink;
|
let lit = self.lit || !settings.cursor_blink;
|
||||||
let cursor = SettingsSection::new("Cursor")
|
let cursor = SettingsSection::new("Cursor")
|
||||||
.row(SettingsRow::new("Style").control(style))
|
.row(SettingsRow::new("Style").control(style))
|
||||||
@@ -113,7 +113,7 @@ impl SettingsWindow {
|
|||||||
settings.cursor_blink,
|
settings.cursor_blink,
|
||||||
|settings, on| settings.cursor_blink = on,
|
|settings, on| settings.cursor_blink = on,
|
||||||
)))
|
)))
|
||||||
.row(SettingsRow::new("Preview").control(prompt(settings, active, lit, cx)));
|
.row(SettingsRow::new("Preview").control(prompt(settings, &active, lit, cx)));
|
||||||
let opacity = percent(settings.background_opacity);
|
let opacity = percent(settings.background_opacity);
|
||||||
let slider = Slider::new("background_opacity", opacity)
|
let slider = Slider::new("background_opacity", opacity)
|
||||||
.range(percent(*OPACITY.start()), percent(*OPACITY.end()))
|
.range(percent(*OPACITY.start()), percent(*OPACITY.end()))
|
||||||
@@ -207,7 +207,7 @@ fn fraction(percent: f64) -> f32 {
|
|||||||
(percent / 100.) as f32
|
(percent / 100.) as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every built-in theme as a card over its preview; `dark` picks the one a dark system wears.
|
/// Omarchy's theme where followed, then every built-in, as cards; `dark` picks the dark system's.
|
||||||
fn grid(
|
fn grid(
|
||||||
this: &Entity<SettingsWindow>,
|
this: &Entity<SettingsWindow>,
|
||||||
title: Option<&'static str>,
|
title: Option<&'static str>,
|
||||||
@@ -222,7 +222,8 @@ fn grid(
|
|||||||
} else {
|
} else {
|
||||||
("theme", Some(settings.theme.as_str()))
|
("theme", Some(settings.theme.as_str()))
|
||||||
};
|
};
|
||||||
let cards = themes::all().iter().map(|each| {
|
let omarchy = cx.try_global::<themes::Omarchy>().map(|omarchy| &omarchy.0);
|
||||||
|
let cards = omarchy.into_iter().chain(themes::all()).map(|each| {
|
||||||
let (id, this) = (each.id, this.clone());
|
let (id, this) = (each.id, this.clone());
|
||||||
let key = SharedString::from(format!("{field}-{id}"));
|
let key = SharedString::from(format!("{field}-{id}"));
|
||||||
RadioCard::new(key, chosen == Some(id), each.name)
|
RadioCard::new(key, chosen == Some(id), each.name)
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
use ely_gpui_component::{
|
use ely_gpui_component::{
|
||||||
forms::NumberInput,
|
buttons::SegmentedControl,
|
||||||
settings::{SettingsRow, SettingsSection},
|
feedback::InlineMessage,
|
||||||
theme::ControlSize,
|
forms::{Copy, NumberInput, Paste, PathInput, SelectAll},
|
||||||
|
primitives::Severity,
|
||||||
|
settings::{KeyboardShortcutsList, SettingsRow, SettingsSection, Shortcut},
|
||||||
|
theme::{ActiveTheme, ControlSize},
|
||||||
|
};
|
||||||
|
use gpui::{
|
||||||
|
Action, App, Context, Div, Entity, KeyContext, ParentElement, SharedString, Styled, div,
|
||||||
};
|
};
|
||||||
use gpui::Context;
|
|
||||||
|
|
||||||
use super::{SettingsWindow, choice, set, switch};
|
use super::{OpenSettings, SettingsWindow, choice, set, switch};
|
||||||
use crate::settings::{OptionAsAlt, SCROLLBACK, SettingsStore};
|
use crate::{
|
||||||
|
menus::{Bigger, Minimize, Quit, ResetSize, Smaller},
|
||||||
|
settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn},
|
||||||
|
term::input::{self, Find, NextPrompt, PreviousPrompt},
|
||||||
|
workspace::{
|
||||||
|
self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession,
|
||||||
|
TogglePanel, ToggleSessions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/// Each way Option types Alt, its key and its name.
|
/// Each way Option types Alt, its key and its name.
|
||||||
const OPTIONS: [(OptionAsAlt, &str, &str); 4] = [
|
const OPTIONS: [(OptionAsAlt, &str, &str); 4] = [
|
||||||
@@ -16,11 +29,42 @@ const OPTIONS: [(OptionAsAlt, &str, &str); 4] = [
|
|||||||
(OptionAsAlt::Both, "both", "Both"),
|
(OptionAsAlt::Both, "both", "Both"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// The names of the sessions a digit shows.
|
||||||
|
const PLACES: [&str; 8] = [
|
||||||
|
"Session 1",
|
||||||
|
"Session 2",
|
||||||
|
"Session 3",
|
||||||
|
"Session 4",
|
||||||
|
"Session 5",
|
||||||
|
"Session 6",
|
||||||
|
"Session 7",
|
||||||
|
"Session 8",
|
||||||
|
];
|
||||||
|
|
||||||
impl SettingsWindow {
|
impl SettingsWindow {
|
||||||
/// Scrollback, Option as Alt on macOS, and copy on select.
|
/// Shell, start in, scrollback, Option as Alt on macOS and copy on select, then the shortcuts.
|
||||||
pub(super) fn terminal(&self, cx: &Context<Self>) -> SettingsSection {
|
pub(super) fn terminal(&self, cx: &Context<Self>) -> Div {
|
||||||
let settings = SettingsStore::get(cx);
|
let settings = SettingsStore::get(cx);
|
||||||
let this = cx.entity();
|
let this = cx.entity();
|
||||||
|
let shell = div()
|
||||||
|
.w(cx.theme().prose_width())
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.child(PathInput::new("settings-shell", &self.shell))
|
||||||
|
.children((self.refused.clone()).map(|why| InlineMessage::new(Severity::Danger, why)));
|
||||||
|
let start = match settings.start_in {
|
||||||
|
StartIn::Home => "home",
|
||||||
|
StartIn::Session => "session",
|
||||||
|
};
|
||||||
|
let start = SegmentedControl::new("settings-start-in", start)
|
||||||
|
.segment("home", "Home", None)
|
||||||
|
.segment("session", "Session's folder", None)
|
||||||
|
.size(ControlSize::Sm)
|
||||||
|
.on_change({
|
||||||
|
let this = this.clone();
|
||||||
|
move |value, _, cx| start_in(&this, value, cx)
|
||||||
|
});
|
||||||
let lines = NumberInput::new(("scrollback", self.drafts), f64::from(settings.scrollback))
|
let lines = NumberInput::new(("scrollback", self.drafts), f64::from(settings.scrollback))
|
||||||
.range(f64::from(*SCROLLBACK.start()), f64::from(*SCROLLBACK.end()))
|
.range(f64::from(*SCROLLBACK.start()), f64::from(*SCROLLBACK.end()))
|
||||||
.step(1000.)
|
.step(1000.)
|
||||||
@@ -35,6 +79,8 @@ impl SettingsWindow {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let mut section = SettingsSection::new("Terminal")
|
let mut section = SettingsSection::new("Terminal")
|
||||||
|
.row(SettingsRow::new("Shell").control(shell))
|
||||||
|
.row(SettingsRow::new("Start in").control(start))
|
||||||
.row(SettingsRow::new("Scrollback lines").control(lines));
|
.row(SettingsRow::new("Scrollback lines").control(lines));
|
||||||
// Off macOS, Alt is always Alt.
|
// Off macOS, Alt is always Alt.
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
@@ -53,6 +99,88 @@ impl SettingsWindow {
|
|||||||
settings.copy_on_select,
|
settings.copy_on_select,
|
||||||
|settings, on| settings.copy_on_select = on,
|
|settings, on| settings.copy_on_select = on,
|
||||||
);
|
);
|
||||||
section.row(SettingsRow::new("Copy on select").control(copy))
|
let section = section.row(SettingsRow::new("Copy on select").control(copy));
|
||||||
|
let keys = SettingsSection::new("Shortcuts")
|
||||||
|
.row(KeyboardShortcutsList::new(shortcuts(cx), &self.search));
|
||||||
|
div().flex().flex_col().gap_6().child(section).child(keys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where new sessions start, as the settings keep it.
|
||||||
|
fn start_in(this: &Entity<SettingsWindow>, value: &SharedString, cx: &mut App) {
|
||||||
|
let start = match value.as_ref() {
|
||||||
|
"home" => StartIn::Home,
|
||||||
|
"session" => StartIn::Session,
|
||||||
|
other => unreachable!("settings: no start {other}"),
|
||||||
|
};
|
||||||
|
set(
|
||||||
|
this,
|
||||||
|
format!("new sessions start in {start:?}"),
|
||||||
|
cx,
|
||||||
|
|settings| settings.start_in = start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listed commands, in order: group, name and action.
|
||||||
|
fn listed() -> Vec<(&'static str, &'static str, Box<dyn Action>)> {
|
||||||
|
let mut listed = vec![
|
||||||
|
("Sessions", "New session", NewSession.boxed_clone()),
|
||||||
|
("Sessions", "Close session", CloseSession.boxed_clone()),
|
||||||
|
("Sessions", "Next session", NextSession.boxed_clone()),
|
||||||
|
(
|
||||||
|
"Sessions",
|
||||||
|
"Previous session",
|
||||||
|
PreviousSession.boxed_clone(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let places = PLACES.iter().enumerate();
|
||||||
|
listed.extend(places.map(|(ix, name)| ("Sessions", *name, SelectSession(ix).boxed_clone())));
|
||||||
|
listed.extend([
|
||||||
|
("Sessions", "Last session", LastSession.boxed_clone()),
|
||||||
|
("Edit", "Copy", Copy.boxed_clone()),
|
||||||
|
("Edit", "Paste", Paste.boxed_clone()),
|
||||||
|
("Edit", "Select all", SelectAll.boxed_clone()),
|
||||||
|
("Edit", "Find", Find.boxed_clone()),
|
||||||
|
("View", "Previous prompt", PreviousPrompt.boxed_clone()),
|
||||||
|
("View", "Next prompt", NextPrompt.boxed_clone()),
|
||||||
|
("View", "Toggle sessions", ToggleSessions.boxed_clone()),
|
||||||
|
("View", "Toggle right panel", TogglePanel.boxed_clone()),
|
||||||
|
("View", "Bigger", Bigger.boxed_clone()),
|
||||||
|
("View", "Smaller", Smaller.boxed_clone()),
|
||||||
|
("View", "Reset size", ResetSize.boxed_clone()),
|
||||||
|
("App", "Settings", OpenSettings.boxed_clone()),
|
||||||
|
("App", "Minimize", Minimize.boxed_clone()),
|
||||||
|
("App", "Quit", Quit.boxed_clone()),
|
||||||
|
]);
|
||||||
|
listed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each listed command's keys in the main window's terminal, a line per binding, from the keymap.
|
||||||
|
fn shortcuts(cx: &App) -> Vec<Shortcut> {
|
||||||
|
let stack = [workspace::CONTEXT, input::CONTEXT]
|
||||||
|
.map(|name| KeyContext::parse(name).expect("a context name parses"));
|
||||||
|
let keymap = cx.key_bindings();
|
||||||
|
let keymap = keymap.borrow();
|
||||||
|
let mut shortcuts = Vec::new();
|
||||||
|
for (group, name, action) in listed() {
|
||||||
|
for binding in keymap.bindings_for_action(action.as_ref()) {
|
||||||
|
// gpui matches a binding at any depth of the context stack.
|
||||||
|
let applies = binding.predicate().is_none_or(|predicate| {
|
||||||
|
(1..=stack.len()).any(|depth| predicate.eval(&stack[..depth]))
|
||||||
|
});
|
||||||
|
if applies {
|
||||||
|
let strokes: Vec<String> = binding
|
||||||
|
.keystrokes()
|
||||||
|
.iter()
|
||||||
|
.map(|key| key.unparse())
|
||||||
|
.collect();
|
||||||
|
shortcuts.push(Shortcut {
|
||||||
|
group: group.into(),
|
||||||
|
name: name.into(),
|
||||||
|
keys: strokes.join(" ").into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcuts
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
ffi::{OsStr, OsString},
|
||||||
|
io::{self, BufRead, BufReader, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use gpui::{Context, Task};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
status::{HERDR, Record, State},
|
||||||
|
view::{TerminalEvent, TerminalView},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod unix;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use unix::{Program, client, connect, home, leader, running};
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::{Program, client, connect, home, leader, running};
|
||||||
|
|
||||||
|
/// 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, its panes' folders and its agents.
|
||||||
|
const SNAPSHOT: &[u8] = b"{\"id\":\"elyterminal\",\"method\":\"session.snapshot\",\"params\":{}}\n";
|
||||||
|
|
||||||
|
/// A program's arguments, and its variables as the system holds them.
|
||||||
|
type Invocation = (Vec<String>, HashMap<String, OsString>);
|
||||||
|
|
||||||
|
/// Follows the pane herdr's server has focused while herdr's client runs in the session.
|
||||||
|
pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||||
|
cx.spawn(async move |view, cx| {
|
||||||
|
let mut seen: Option<Program> = None;
|
||||||
|
let (mut socket, mut shown, mut retry, mut failed) = (None, None, false, String::new());
|
||||||
|
let mut states: Vec<(String, State)> = Vec::new();
|
||||||
|
loop {
|
||||||
|
cx.background_executor().timer(CHECK).await;
|
||||||
|
let Ok((pid, lead, reports)) = view.update(cx, |view, _| {
|
||||||
|
(view.pty.pid, leader(&view.pty), view.reports)
|
||||||
|
}) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let program = match lead {
|
||||||
|
Ok(lead) => {
|
||||||
|
cx.background_executor()
|
||||||
|
.spawn(async move { running(lead) })
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
};
|
||||||
|
let program = match program {
|
||||||
|
Ok(program) => Some(program),
|
||||||
|
Err(error) => {
|
||||||
|
if seen.is_some() {
|
||||||
|
log::debug!("terminal {pid}: its foreground does not read: {error}");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if program != seen {
|
||||||
|
seen = program.clone();
|
||||||
|
shown = None;
|
||||||
|
retry = true;
|
||||||
|
failed.clear();
|
||||||
|
if socket.take().is_some() {
|
||||||
|
log::info!(
|
||||||
|
"terminal {pid}: herdr left the foreground, its agents' records with it"
|
||||||
|
);
|
||||||
|
states.clear();
|
||||||
|
if view.update(cx, |view, cx| view.agents(&[], cx)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if retry {
|
||||||
|
let found = match program {
|
||||||
|
Some(program) => {
|
||||||
|
cx.background_executor()
|
||||||
|
.spawn(async move { client(program) })
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
};
|
||||||
|
retry = found.is_err();
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
let (folder, agents) = match cx
|
||||||
|
.background_executor()
|
||||||
|
.spawn(async move { snapshot(&path) })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(shows) => shows,
|
||||||
|
Err(error) => {
|
||||||
|
failure(&mut failed, pid, &error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
failed.clear();
|
||||||
|
let report = folder.filter(|folder| shown.as_ref() != Some(folder));
|
||||||
|
// The shell reports only once herdr has left; its folder then stands.
|
||||||
|
let Ok(still) = view.update(cx, |view, cx| {
|
||||||
|
let still = view.reports == reports;
|
||||||
|
if still {
|
||||||
|
view.agents(&agents, cx);
|
||||||
|
if let Some(folder) = report.clone() {
|
||||||
|
cx.emit(TerminalEvent::Folder(folder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
still
|
||||||
|
}) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !still {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let now: Vec<(String, State)> = agents
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, record)| (id, record.state))
|
||||||
|
.collect();
|
||||||
|
if now != states {
|
||||||
|
log::info!("terminal {pid}: herdr's agents are {now:?}");
|
||||||
|
states = now;
|
||||||
|
}
|
||||||
|
if let Some(folder) = report {
|
||||||
|
log::info!(
|
||||||
|
"terminal {pid}: herdr's focused pane is in {}",
|
||||||
|
folder.display()
|
||||||
|
);
|
||||||
|
shown = Some(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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") {
|
||||||
|
Some(config) => PathBuf::from(config).join("herdr"),
|
||||||
|
None => home(env)?,
|
||||||
|
};
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What herdr's server shows: the folder of the pane it has focused, none while it has none or knows none, and its agents.
|
||||||
|
fn snapshot(socket: &Path) -> anyhow::Result<Shows> {
|
||||||
|
let ask = || -> io::Result<String> {
|
||||||
|
let mut stream = connect(socket)?;
|
||||||
|
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()))?;
|
||||||
|
shows(&reply).with_context(|| format!("herdr: {}", socket.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The focused pane's folder, and herdr's agents as records.
|
||||||
|
type Shows = (Option<PathBuf>, Vec<(String, Record)>);
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
/// A herdr before agents lists none.
|
||||||
|
#[serde(default)]
|
||||||
|
agents: Vec<Agent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Pane {
|
||||||
|
pane_id: String,
|
||||||
|
cwd: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Agent {
|
||||||
|
pane_id: String,
|
||||||
|
agent_status: AgentStatus,
|
||||||
|
agent: Option<String>,
|
||||||
|
display_agent: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
terminal_title_stripped: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An agent's state, as herdr names it.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum AgentStatus {
|
||||||
|
Idle,
|
||||||
|
Working,
|
||||||
|
Blocked,
|
||||||
|
Done,
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Failure {
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What herdr's `session.snapshot` reply shows.
|
||||||
|
pub(super) fn shows(reply: &str) -> anyhow::Result<Shows> {
|
||||||
|
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 agents = records(session.agents);
|
||||||
|
let Some(id) = session.focused_pane_id else {
|
||||||
|
return Ok((None, agents));
|
||||||
|
};
|
||||||
|
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, agents))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each agent whose state herdr knows, as the record of its pane: herdr names no kind and no progress.
|
||||||
|
fn records(agents: Vec<Agent>) -> Vec<(String, Record)> {
|
||||||
|
let record = |agent: Agent| {
|
||||||
|
let state = match agent.agent_status {
|
||||||
|
AgentStatus::Idle => State::Idle,
|
||||||
|
AgentStatus::Working => State::Working,
|
||||||
|
AgentStatus::Blocked => State::Blocked,
|
||||||
|
AgentStatus::Done => State::Done,
|
||||||
|
AgentStatus::Unknown => return None,
|
||||||
|
};
|
||||||
|
let record = Record {
|
||||||
|
state,
|
||||||
|
kind: None,
|
||||||
|
progress: None,
|
||||||
|
app: agent.agent,
|
||||||
|
title: agent.name.or(agent.display_agent),
|
||||||
|
msg: agent.terminal_title_stripped.or(agent.title),
|
||||||
|
};
|
||||||
|
Some((format!("{HERDR}{}", agent.pane_id), record))
|
||||||
|
};
|
||||||
|
agents.into_iter().filter_map(record).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A root that makes a path absolute here.
|
||||||
|
const ROOT: &str = if cfg!(windows) { "C:/" } else { "/" };
|
||||||
|
|
||||||
|
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 relative = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "herdr.sock")]);
|
||||||
|
assert_eq!(found("herdr", &relative), PathBuf::from("herdr.sock"));
|
||||||
|
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| shows(&reply(focused_pane, cwd)).map(|(folder, _)| folder);
|
||||||
|
let folder = format!("{ROOT}srv/b");
|
||||||
|
assert_eq!(
|
||||||
|
read(r#""w1:p2""#, &format!("\"{folder}\"")).expect("a folder"),
|
||||||
|
Some(folder.clone().into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read("null", &format!("\"{folder}\"")).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 = shows(failed).expect_err("an error");
|
||||||
|
assert_eq!(error.to_string(), "invalid_request: no such method");
|
||||||
|
assert!(shows("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn herdr_s_agents_whose_state_it_knows_are_records_by_pane() {
|
||||||
|
let agent = |pane: &str, status: &str, more: &str| {
|
||||||
|
format!(
|
||||||
|
r#"{{"terminal_id":"t","agent_status":"{status}","workspace_id":"w1","tab_id":"w1:t1","pane_id":"{pane}","focused":false,"revision":1{more}}}"#
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let agents = [
|
||||||
|
agent(
|
||||||
|
"w1:p1",
|
||||||
|
"working",
|
||||||
|
r#","agent":"codex","display_agent":"Codex","name":"review","title":"codex","terminal_title_stripped":"Reading files""#,
|
||||||
|
),
|
||||||
|
agent(
|
||||||
|
"w1:p2",
|
||||||
|
"blocked",
|
||||||
|
r#","agent":"claude","display_agent":"Claude Code","title":"claude""#,
|
||||||
|
),
|
||||||
|
agent("w1:p3", "unknown", ""),
|
||||||
|
agent("w1:p4", "napping", ""),
|
||||||
|
agent("w1:p5", "done", ""),
|
||||||
|
agent("w1:p6", "idle", ""),
|
||||||
|
];
|
||||||
|
let reply = reply("null", "null").replace(
|
||||||
|
r#""agents":[]"#,
|
||||||
|
&format!(r#""agents":[{}]"#, agents.join(",")),
|
||||||
|
);
|
||||||
|
let (folder, records) = shows(&reply).expect("a snapshot");
|
||||||
|
assert_eq!(folder, None);
|
||||||
|
let said: Vec<_> = records
|
||||||
|
.iter()
|
||||||
|
.map(|(id, record)| {
|
||||||
|
(
|
||||||
|
id.as_str(),
|
||||||
|
record.state,
|
||||||
|
record.app.as_deref(),
|
||||||
|
record.title.as_deref(),
|
||||||
|
record.msg.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
said,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"herdr:w1:p1",
|
||||||
|
State::Working,
|
||||||
|
Some("codex"),
|
||||||
|
Some("review"),
|
||||||
|
Some("Reading files")
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"herdr:w1:p2",
|
||||||
|
State::Blocked,
|
||||||
|
Some("claude"),
|
||||||
|
Some("Claude Code"),
|
||||||
|
Some("claude")
|
||||||
|
),
|
||||||
|
("herdr:w1:p5", State::Done, None, None, None),
|
||||||
|
("herdr:w1:p6", State::Idle, None, None, None),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.all(|(_, record)| record.kind.is_none() && record.progress.is_none())
|
||||||
|
);
|
||||||
|
let older = reply.replace(&format!(r#","agents":[{}]"#, agents.join(",")), "");
|
||||||
|
assert_eq!(shows(&older).expect("a herdr before agents").1, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn herdr_answers_over_its_socket() {
|
||||||
|
use std::{fs, os::unix::net::UnixListener};
|
||||||
|
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!(snapshot(&socket).expect("an answer").0, 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 = snapshot(&socket).expect_err("no server");
|
||||||
|
assert!(format!("{error:#}").contains("asking"), "{error:#}");
|
||||||
|
assert!(starting(&error), "{error:#}");
|
||||||
|
assert!(!starting(&shows("").expect_err("no reply")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS hides the variables of its own programs, such as `/bin/sleep`, so the test reads itself.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn a_process_reads_with_its_path_arguments_variables_and_folder() {
|
||||||
|
use super::unix::{cwd, launch, path};
|
||||||
|
use std::fs;
|
||||||
|
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());
|
||||||
|
let folder = std::env::current_dir().expect("this test's folder");
|
||||||
|
assert_eq!(
|
||||||
|
fs::canonicalize(cwd(pid).expect("its folder")).ok(),
|
||||||
|
fs::canonicalize(folder).ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn only_herdr_s_program_is_read() {
|
||||||
|
let pid = std::process::id() as i32;
|
||||||
|
assert_eq!(
|
||||||
|
client((pid, None)).expect("a program that does not read"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
let shell = client((pid, Some("/bin/sh".into())));
|
||||||
|
assert_eq!(shell.expect("another program"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn an_updated_herdr_still_names_itself() {
|
||||||
|
use super::unix::installed;
|
||||||
|
let deleted = PathBuf::from("/home/u/.local/bin/herdr (deleted)");
|
||||||
|
assert_eq!(
|
||||||
|
installed(deleted),
|
||||||
|
PathBuf::from("/home/u/.local/bin/herdr")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
installed("/usr/bin/herdr".into()),
|
||||||
|
PathBuf::from("/usr/bin/herdr")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn macos_lays_out_the_arguments_after_the_program_s_path() {
|
||||||
|
use super::unix::procargs;
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn a_windows_client_finds_its_server_in_app_data() {
|
||||||
|
let roaming = PathBuf::from(r"C:\Users\u\AppData\Roaming\herdr");
|
||||||
|
let data = env(&[
|
||||||
|
("APPDATA", r"C:\Users\u\AppData\Roaming"),
|
||||||
|
("USERPROFILE", r"C:\D"),
|
||||||
|
]);
|
||||||
|
assert_eq!(found("herdr", &data), roaming.join("herdr.sock"));
|
||||||
|
let profile = env(&[("USERPROFILE", r"C:\Users\u")]);
|
||||||
|
assert_eq!(
|
||||||
|
found("herdr --session work", &profile),
|
||||||
|
roaming.join(r"sessions\work\herdr.sock")
|
||||||
|
);
|
||||||
|
let config = env(&[("XDG_CONFIG_HOME", r"C:\x"), ("APPDATA", r"C:\D")]);
|
||||||
|
assert_eq!(
|
||||||
|
found("herdr", &config),
|
||||||
|
PathBuf::from(r"C:\x\herdr\herdr.sock")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn herdr_is_sought_at_and_under_the_shell_not_under_herdr() {
|
||||||
|
use super::windows::herdrs;
|
||||||
|
let processes = [
|
||||||
|
(100, 50, "pwsh.exe"),
|
||||||
|
(104, 100, "cmd.exe"),
|
||||||
|
(108, 104, "HERDR.EXE"),
|
||||||
|
(112, 100, "herdr.exe"),
|
||||||
|
(116, 112, "herdr.exe"),
|
||||||
|
(120, 100, "herdr.exe"),
|
||||||
|
(200, 300, "a.exe"),
|
||||||
|
(300, 200, "b.exe"),
|
||||||
|
]
|
||||||
|
.map(|(pid, parent, name)| (pid, parent, name.to_owned()));
|
||||||
|
// 116 is the server 112 started; 120 one started beside it.
|
||||||
|
assert_eq!(herdrs(&processes, 100), [112, 120, 108]);
|
||||||
|
assert_eq!(herdrs(&processes, 112), [112]);
|
||||||
|
assert!(herdrs(&processes, 200).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn a_server_or_a_command_beside_herdr_s_client_is_passed_by() {
|
||||||
|
use super::windows::pick;
|
||||||
|
let data = env(&[("APPDATA", r"C:\D")]);
|
||||||
|
let read = |words: &str| -> io::Result<Invocation> { Ok((args(words), data.clone())) };
|
||||||
|
let gone = || -> io::Result<Invocation> { Err(io::ErrorKind::NotFound.into()) };
|
||||||
|
let socket = Some(PathBuf::from(r"C:\D\herdr\herdr.sock"));
|
||||||
|
let server = "herdr.exe server";
|
||||||
|
let client = [(200, read(server)), (100, read("herdr.exe"))];
|
||||||
|
assert_eq!(pick(client).expect("a client"), socket);
|
||||||
|
let command = [(200, read(server)), (300, read("herdr.exe api snapshot"))];
|
||||||
|
assert_eq!(pick(command).expect("no client"), None);
|
||||||
|
let unread = [(300, gone()), (100, read("herdr.exe"))];
|
||||||
|
assert_eq!(pick(unread).expect("a client"), socket);
|
||||||
|
let error = pick([(300, gone()), (200, read(server))]).expect_err("an unread process");
|
||||||
|
assert!(format!("{error:#}").contains("process 300"), "{error:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn a_windows_process_reads_with_its_name_arguments_and_variables() {
|
||||||
|
use super::windows::{launch, processes};
|
||||||
|
let pid = std::process::id();
|
||||||
|
let exe = std::env::current_exe().expect("this test's path");
|
||||||
|
let name = exe.file_name().expect("this test's name");
|
||||||
|
let listed = processes().expect("the process list");
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.iter()
|
||||||
|
.any(|(id, _, read)| *id == pid && name.eq_ignore_ascii_case(read)),
|
||||||
|
"process {pid} is not listed under its name"
|
||||||
|
);
|
||||||
|
assert!(running(pid).expect("the process list").is_empty());
|
||||||
|
let (args, env) = launch(pid).expect("its arguments");
|
||||||
|
assert_eq!(args, std::env::args().collect::<Vec<_>>());
|
||||||
|
// Windows names it Path; the reader folds names to upper case.
|
||||||
|
let path = std::env::var_os("PATH");
|
||||||
|
assert!(env.get("PATH") == path.as_ref(), "PATH reads otherwise");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn herdr_answers_over_its_pipe() {
|
||||||
|
use std::os::windows::{
|
||||||
|
ffi::OsStrExt,
|
||||||
|
io::{AsRawHandle, FromRawHandle, OwnedHandle},
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::{ERROR_PIPE_CONNECTED, INVALID_HANDLE_VALUE},
|
||||||
|
Storage::FileSystem::PIPE_ACCESS_DUPLEX,
|
||||||
|
System::Pipes::{ConnectNamedPipe, CreateNamedPipeW, PIPE_TYPE_BYTE},
|
||||||
|
};
|
||||||
|
let socket = PathBuf::from(format!(
|
||||||
|
r"C:\elyterminal-herdr-{}\herdr.sock",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let name = format!(r"\\.\pipe\{}", socket.display());
|
||||||
|
let name: Vec<u16> = OsStr::new(&name).encode_wide().chain([0]).collect();
|
||||||
|
// SAFETY: `name` ends with a NUL; the handle is owned below.
|
||||||
|
let pipe = unsafe {
|
||||||
|
CreateNamedPipeW(
|
||||||
|
name.as_ptr(),
|
||||||
|
PIPE_ACCESS_DUPLEX,
|
||||||
|
PIPE_TYPE_BYTE,
|
||||||
|
1,
|
||||||
|
4096,
|
||||||
|
4096,
|
||||||
|
0,
|
||||||
|
std::ptr::null(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_ne!(pipe, INVALID_HANDLE_VALUE, "{}", io::Error::last_os_error());
|
||||||
|
// SAFETY: the handle is new and ours.
|
||||||
|
let pipe = unsafe { OwnedHandle::from_raw_handle(pipe) };
|
||||||
|
let answer = reply(r#""w1:p2""#, r#""C:/srv""#);
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
// SAFETY: the pipe is open.
|
||||||
|
let joined = unsafe { ConnectNamedPipe(pipe.as_raw_handle(), std::ptr::null_mut()) };
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
assert!(
|
||||||
|
joined != 0 || error.raw_os_error() == Some(ERROR_PIPE_CONNECTED as i32),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
let stream = std::fs::File::from(pipe);
|
||||||
|
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");
|
||||||
|
// The pipe goes only once the client has read the reply.
|
||||||
|
stream.sync_all().expect("the client reads the reply");
|
||||||
|
request
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
snapshot(&socket).expect("an answer").0,
|
||||||
|
Some("C:/srv".into())
|
||||||
|
);
|
||||||
|
assert_eq!(server.join().expect("the server").as_bytes(), SNAPSHOT);
|
||||||
|
let error = snapshot(&socket).expect_err("no server");
|
||||||
|
assert!(format!("{error:#}").contains("asking"), "{error:#}");
|
||||||
|
assert!(starting(&error), "{error:#}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
ffi::OsString,
|
||||||
|
io,
|
||||||
|
os::unix::{ffi::OsStringExt, net::UnixStream},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
|
||||||
|
use super::{Invocation, WAIT, follows};
|
||||||
|
use crate::term::Pty;
|
||||||
|
|
||||||
|
pub(super) type Pid = i32;
|
||||||
|
|
||||||
|
/// The foreground's group and its leader's executable: an exec keeps the group and swaps the program.
|
||||||
|
pub(super) type Program = (Pid, Option<PathBuf>);
|
||||||
|
|
||||||
|
/// herdr's executable.
|
||||||
|
const HERDR: &str = "herdr";
|
||||||
|
|
||||||
|
/// The process group leading the session's foreground.
|
||||||
|
pub(super) fn leader(pty: &Pty) -> io::Result<Pid> {
|
||||||
|
pty.foreground()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn running(group: Pid) -> io::Result<Program> {
|
||||||
|
Ok((group, path(group).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The socket of the herdr server whose terminal client leads process group `group`, if it is one.
|
||||||
|
pub(super) fn client((group, exe): Program) -> anyhow::Result<Option<PathBuf>> {
|
||||||
|
let Some(exe) = exe else {
|
||||||
|
log::debug!("herdr: process {group}, another user's or gone, does not read");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !exe
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case(HERDR))
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (args, env) =
|
||||||
|
launch(group).with_context(|| format!("herdr: process {group} does not read"))?;
|
||||||
|
let Some(socket) = follows(&args, &env)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if socket.is_absolute() {
|
||||||
|
return Ok(Some(socket));
|
||||||
|
}
|
||||||
|
// herdr reads a relative socket from its client's folder.
|
||||||
|
let folder =
|
||||||
|
cwd(group).with_context(|| format!("herdr: process {group}'s folder does not read"))?;
|
||||||
|
Ok(Some(folder.join(socket)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// herdr's folder without `XDG_CONFIG_HOME`.
|
||||||
|
pub(super) fn home(env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||||
|
let home = env.get("HOME").context("herdr: its client has no HOME")?;
|
||||||
|
Ok(PathBuf::from(home).join(".config/herdr"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// herdr's socket, which must answer within `WAIT`.
|
||||||
|
pub(super) fn connect(socket: &Path) -> io::Result<UnixStream> {
|
||||||
|
let stream = UnixStream::connect(socket)?;
|
||||||
|
stream.set_read_timeout(Some(WAIT))?;
|
||||||
|
stream.set_write_timeout(Some(WAIT))?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(super) fn path(pid: Pid) -> io::Result<PathBuf> {
|
||||||
|
std::fs::read_link(format!("/proc/{pid}/exe")).map(installed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path of an executable the kernel marks ` (deleted)`, as an update that replaced it leaves it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(super) fn installed(path: PathBuf) -> PathBuf {
|
||||||
|
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
|
||||||
|
match path.as_os_str().as_bytes().strip_suffix(b" (deleted)") {
|
||||||
|
Some(kept) => PathBuf::from(OsStr::from_bytes(kept)),
|
||||||
|
None => path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(super) fn launch(pid: Pid) -> 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(super) fn cwd(pid: Pid) -> io::Result<PathBuf> {
|
||||||
|
std::fs::read_link(format!("/proc/{pid}/cwd"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")]
|
||||||
|
pub(super) fn path(pid: Pid) -> 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")]
|
||||||
|
pub(super) fn launch(pid: Pid) -> 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(super) fn cwd(pid: Pid) -> io::Result<PathBuf> {
|
||||||
|
// SAFETY: the struct is plain C data, valid zeroed.
|
||||||
|
let mut info: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() };
|
||||||
|
let size = std::mem::size_of::<libc::proc_vnodepathinfo>() as libc::c_int;
|
||||||
|
// SAFETY: `info` holds the size given.
|
||||||
|
let read = unsafe {
|
||||||
|
libc::proc_pidinfo(
|
||||||
|
pid,
|
||||||
|
libc::PROC_PIDVNODEPATHINFO,
|
||||||
|
0,
|
||||||
|
(&raw mut info).cast(),
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if read <= 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
if read != size {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"proc_pidinfo read {read} of {size} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let path = info
|
||||||
|
.pvi_cdir
|
||||||
|
.vip_path
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|byte| *byte as u8);
|
||||||
|
Ok(PathBuf::from(OsString::from_vec(
|
||||||
|
path.take_while(|byte| *byte != 0).collect(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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()),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
ffi::{OsStr, OsString},
|
||||||
|
fs::{File, OpenOptions},
|
||||||
|
io,
|
||||||
|
os::windows::{
|
||||||
|
ffi::{OsStrExt, OsStringExt},
|
||||||
|
fs::OpenOptionsExt,
|
||||||
|
io::{AsRawHandle, FromRawHandle, OwnedHandle},
|
||||||
|
},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::{ERROR_NO_MORE_FILES, ERROR_PIPE_BUSY, INVALID_HANDLE_VALUE},
|
||||||
|
Storage::FileSystem::SECURITY_IDENTIFICATION,
|
||||||
|
System::{
|
||||||
|
Diagnostics::ToolHelp::{
|
||||||
|
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||||
|
TH32CS_SNAPPROCESS,
|
||||||
|
},
|
||||||
|
Pipes::WaitNamedPipeW,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Invocation, WAIT, follows};
|
||||||
|
use crate::term::Pty;
|
||||||
|
|
||||||
|
pub(super) type Pid = u32;
|
||||||
|
|
||||||
|
/// The herdr processes at or under the session's shell, nearest first.
|
||||||
|
pub(super) type Program = Vec<Pid>;
|
||||||
|
|
||||||
|
/// herdr's executable.
|
||||||
|
const HERDR: &str = "herdr.exe";
|
||||||
|
|
||||||
|
/// The session's shell: Windows has no foreground group, so herdr is sought under it.
|
||||||
|
pub(super) fn leader(pty: &Pty) -> io::Result<Pid> {
|
||||||
|
Ok(pty.pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn running(shell: Pid) -> io::Result<Program> {
|
||||||
|
Ok(herdrs(&processes()?, shell))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The herdr processes at or under `root`, breadth first; what a herdr starts, its server among them, is its own.
|
||||||
|
pub(super) fn herdrs(processes: &[(Pid, Pid, String)], root: Pid) -> Vec<Pid> {
|
||||||
|
let herdr = |pid: Pid| {
|
||||||
|
processes
|
||||||
|
.iter()
|
||||||
|
.any(|(id, _, name)| *id == pid && name.eq_ignore_ascii_case(HERDR))
|
||||||
|
};
|
||||||
|
let (mut under, mut found, mut at) = (vec![root], Vec::new(), 0);
|
||||||
|
while let Some(&pid) = under.get(at) {
|
||||||
|
at += 1;
|
||||||
|
if herdr(pid) {
|
||||||
|
found.push(pid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (child, ..) in processes.iter().filter(|(_, of, _)| *of == pid) {
|
||||||
|
// A parent id outlives its process, so a stale one may lead back up.
|
||||||
|
if !under.contains(child) {
|
||||||
|
under.push(*child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every process: its id, its parent's, and its executable's name.
|
||||||
|
pub(super) fn processes() -> io::Result<Vec<(Pid, Pid, String)>> {
|
||||||
|
// SAFETY: a plain call; its handle is owned below.
|
||||||
|
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||||
|
if snapshot == INVALID_HANDLE_VALUE {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: the handle is new and ours.
|
||||||
|
let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) };
|
||||||
|
let mut entry = PROCESSENTRY32W {
|
||||||
|
dwSize: size_of::<PROCESSENTRY32W>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut processes = Vec::new();
|
||||||
|
// SAFETY: `entry` holds the size it names.
|
||||||
|
let mut read = unsafe { Process32FirstW(snapshot.as_raw_handle(), &mut entry) };
|
||||||
|
while read != 0 {
|
||||||
|
let name = entry.szExeFile.split(|unit| *unit == 0).next();
|
||||||
|
processes.push((
|
||||||
|
entry.th32ProcessID,
|
||||||
|
entry.th32ParentProcessID,
|
||||||
|
String::from_utf16_lossy(name.unwrap_or_default()),
|
||||||
|
));
|
||||||
|
// SAFETY: as above.
|
||||||
|
read = unsafe { Process32NextW(snapshot.as_raw_handle(), &mut entry) };
|
||||||
|
}
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
// The list ends with ERROR_NO_MORE_FILES.
|
||||||
|
if error.raw_os_error() == Some(ERROR_NO_MORE_FILES as i32) {
|
||||||
|
Ok(processes)
|
||||||
|
} else {
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The socket of the herdr server whose terminal client is among `herdrs`; a server or a command is passed by.
|
||||||
|
pub(super) fn client(herdrs: Program) -> anyhow::Result<Option<PathBuf>> {
|
||||||
|
pick(herdrs.into_iter().map(|pid| (pid, launch(pid))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first terminal client's socket in `reads`; with none, a failed read is the error, so it is tried again.
|
||||||
|
pub(super) fn pick(
|
||||||
|
reads: impl IntoIterator<Item = (Pid, io::Result<Invocation>)>,
|
||||||
|
) -> anyhow::Result<Option<PathBuf>> {
|
||||||
|
let mut unread = None;
|
||||||
|
for (pid, read) in reads {
|
||||||
|
match read.with_context(|| format!("herdr: process {pid} does not read")) {
|
||||||
|
// herdr names its pipe after the socket's path as written, relative or not.
|
||||||
|
Ok((args, env)) => {
|
||||||
|
if let Some(socket) = follows(&args, &env)? {
|
||||||
|
return Ok(Some(socket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
unread.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unread.map_or(Ok(None), Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The arguments and variables of process `pid`; names fold to upper case, as Windows matches them.
|
||||||
|
pub(super) fn launch(pid: Pid) -> io::Result<Invocation> {
|
||||||
|
let id = sysinfo::Pid::from_u32(pid);
|
||||||
|
let mut system = System::new();
|
||||||
|
let read = ProcessRefreshKind::nothing()
|
||||||
|
.with_cmd(UpdateKind::Always)
|
||||||
|
.with_environ(UpdateKind::Always);
|
||||||
|
system.refresh_processes_specifics(ProcessesToUpdate::Some(&[id]), true, read);
|
||||||
|
let process = system
|
||||||
|
.process(id)
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "the process is gone"))?;
|
||||||
|
// sysinfo leaves a list empty when it does not read; every process has both.
|
||||||
|
if process.cmd().is_empty() || process.environ().is_empty() {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"its command line or variables do not read",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let args = process
|
||||||
|
.cmd()
|
||||||
|
.iter()
|
||||||
|
.map(|arg| arg.to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
let env = process
|
||||||
|
.environ()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|pair| variable(pair))
|
||||||
|
.collect();
|
||||||
|
Ok((args, env))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `NAME=value` variable, its name in upper case; its value keeps its units.
|
||||||
|
fn variable(pair: &OsStr) -> Option<(String, OsString)> {
|
||||||
|
let pair: Vec<u16> = pair.encode_wide().collect();
|
||||||
|
let at = pair.iter().position(|unit| *unit == u16::from(b'='))?;
|
||||||
|
Some((
|
||||||
|
String::from_utf16_lossy(&pair[..at]).to_ascii_uppercase(),
|
||||||
|
OsString::from_wide(&pair[at + 1..]),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// herdr's folder without `XDG_CONFIG_HOME`, as herdr finds it on Windows.
|
||||||
|
pub(super) fn home(env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||||
|
if let Some(data) = env.get("APPDATA") {
|
||||||
|
return Ok(PathBuf::from(data).join("herdr"));
|
||||||
|
}
|
||||||
|
if let Some(profile) = env.get("USERPROFILE") {
|
||||||
|
return Ok(PathBuf::from(profile).join(r"AppData\Roaming\herdr"));
|
||||||
|
}
|
||||||
|
let home = env
|
||||||
|
.get("HOME")
|
||||||
|
.context("herdr: its client has no APPDATA, USERPROFILE or HOME")?;
|
||||||
|
Ok(PathBuf::from(home).join(".config/herdr"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pipe herdr names after its socket's path; a busy one is waited for, as herdr's client waits.
|
||||||
|
pub(super) fn connect(socket: &Path) -> io::Result<File> {
|
||||||
|
let pipe = format!(r"\\.\pipe\{}", socket.to_string_lossy());
|
||||||
|
// Identification only: the pipe's server may not act as this user.
|
||||||
|
let open = || {
|
||||||
|
OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.security_qos_flags(SECURITY_IDENTIFICATION)
|
||||||
|
.open(&pipe)
|
||||||
|
};
|
||||||
|
match open() {
|
||||||
|
Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => {
|
||||||
|
let name: Vec<u16> = OsStr::new(&pipe).encode_wide().chain([0]).collect();
|
||||||
|
// SAFETY: `name` ends with a NUL.
|
||||||
|
if unsafe { WaitNamedPipeW(name.as_ptr(), WAIT.as_millis() as u32) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
opened => opened,
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-2
@@ -12,7 +12,7 @@ use super::{
|
|||||||
view::TerminalView,
|
view::TerminalView,
|
||||||
};
|
};
|
||||||
|
|
||||||
actions!(elyterminal, [Find, FindNewer]);
|
actions!(elyterminal, [Find, FindNewer, PreviousPrompt, NextPrompt]);
|
||||||
|
|
||||||
/// The key context a terminal's root sets.
|
/// The key context a terminal's root sets.
|
||||||
pub const CONTEXT: &str = "Terminal";
|
pub const CONTEXT: &str = "Terminal";
|
||||||
@@ -32,6 +32,20 @@ pub fn bind_keys(cx: &mut App) {
|
|||||||
"ctrl-shift-f",
|
"ctrl-shift-f",
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
// Ghostty's jumps: Cmd with or without Shift on macOS, Ctrl-Shift elsewhere.
|
||||||
|
let (up, down): (&[&str], &[&str]) = if cfg!(target_os = "macos") {
|
||||||
|
(&["cmd-up", "cmd-shift-up"], &["cmd-down", "cmd-shift-down"])
|
||||||
|
} else {
|
||||||
|
(&["ctrl-shift-up"], &["ctrl-shift-down"])
|
||||||
|
};
|
||||||
|
cx.bind_keys(
|
||||||
|
up.iter()
|
||||||
|
.map(|key| KeyBinding::new(key, PreviousPrompt, Some(CONTEXT))),
|
||||||
|
);
|
||||||
|
cx.bind_keys(
|
||||||
|
down.iter()
|
||||||
|
.map(|key| KeyBinding::new(key, NextPrompt, Some(CONTEXT))),
|
||||||
|
);
|
||||||
cx.bind_keys([
|
cx.bind_keys([
|
||||||
KeyBinding::new(copy, Copy, Some(CONTEXT)),
|
KeyBinding::new(copy, Copy, Some(CONTEXT)),
|
||||||
KeyBinding::new(paste, Paste, Some(CONTEXT)),
|
KeyBinding::new(paste, Paste, Some(CONTEXT)),
|
||||||
@@ -47,10 +61,14 @@ pub fn bind_keys(cx: &mut App) {
|
|||||||
["tab", "shift-tab", "f6", "shift-f6"]
|
["tab", "shift-tab", "f6", "shift-f6"]
|
||||||
.map(|key| KeyBinding::new(key, NoAction, Some(CONTEXT))),
|
.map(|key| KeyBinding::new(key, NoAction, Some(CONTEXT))),
|
||||||
);
|
);
|
||||||
|
// Windows pastes on Ctrl-V too, as Windows Terminal does.
|
||||||
|
if cfg!(windows) {
|
||||||
|
cx.bind_keys([KeyBinding::new("ctrl-v", Paste, Some(CONTEXT))]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -59,6 +77,9 @@ impl TerminalView {
|
|||||||
}
|
}
|
||||||
drop(term);
|
drop(term);
|
||||||
self.pty.write(bytes);
|
self.pty.write(bytes);
|
||||||
|
if self.records.seen() {
|
||||||
|
self.restate(cx);
|
||||||
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -4,13 +4,18 @@ use alacritty_terminal::{
|
|||||||
};
|
};
|
||||||
use ely_gpui_component::terminal::{Target, links};
|
use ely_gpui_component::terminal::{Target, links};
|
||||||
|
|
||||||
/// The OSC 8 target at `point`, else a URL in its wrapped line.
|
use super::prompts;
|
||||||
|
|
||||||
|
/// The OSC 8 target at `point`, else a URL in its wrapped line; a prompt's own link is no target.
|
||||||
pub fn link_at<T>(term: &Term<T>, mut point: Point) -> Option<String> {
|
pub fn link_at<T>(term: &Term<T>, mut point: Point) -> Option<String> {
|
||||||
let grid = term.grid();
|
let grid = term.grid();
|
||||||
if grid[point].flags.contains(Flags::WIDE_CHAR_SPACER) {
|
if grid[point].flags.contains(Flags::WIDE_CHAR_SPACER) {
|
||||||
point.column -= 1;
|
point.column -= 1;
|
||||||
}
|
}
|
||||||
if let Some(link) = grid[point].hyperlink() {
|
if let Some(link) = grid[point]
|
||||||
|
.hyperlink()
|
||||||
|
.filter(|link| link.uri() != prompts::LINK)
|
||||||
|
{
|
||||||
return Some(link.uri().to_string());
|
return Some(link.uri().to_string());
|
||||||
}
|
}
|
||||||
let end = term.line_search_right(point);
|
let end = term.line_search_right(point);
|
||||||
@@ -63,6 +68,13 @@ mod tests {
|
|||||||
assert_eq!(link(text, 0, 0), None);
|
assert_eq!(link(text, 0, 0), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prompt_s_own_link_is_no_target() {
|
||||||
|
let text = "\x1b]8;;\x7f\x1b\\$ https://ely.dev\x1b]8;;\x1b\\";
|
||||||
|
assert_eq!(link(text, 0, 0), None);
|
||||||
|
assert_eq!(link(text, 0, 4).as_deref(), Some("https://ely.dev"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_url_ends_before_its_sentence_does() {
|
fn a_url_ends_before_its_sentence_does() {
|
||||||
assert_eq!(link("(a.io/x).", 0, 2), None, "no scheme, no link");
|
assert_eq!(link("(a.io/x).", 0, 2), None, "no scheme, no link");
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/// A semantic prompt mark a program sends, OSC 133.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Mark {
|
||||||
|
/// A prompt starts: A, or N.
|
||||||
|
Prompt,
|
||||||
|
/// The prompt ends and input starts: B, or I.
|
||||||
|
Input,
|
||||||
|
/// Input ends and the command's output starts: C.
|
||||||
|
Output,
|
||||||
|
/// The command ended, with the status it gave: D.
|
||||||
|
Done(Option<i32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark in an OSC 133 body past `133;`; none for one the terminal takes no action on.
|
||||||
|
pub(crate) fn read(body: &[u8]) -> Option<Mark> {
|
||||||
|
let shown = || String::from_utf8_lossy(body);
|
||||||
|
let Some((&letter, rest)) = body.split_first() else {
|
||||||
|
log::warn!("pty: OSC 133 without a mark");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let options = match rest {
|
||||||
|
[] => rest,
|
||||||
|
[b';', options @ ..] => options,
|
||||||
|
_ => {
|
||||||
|
log::warn!("pty: OSC 133;{} is no mark", shown());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match letter {
|
||||||
|
b'A' | b'N' => Some(Mark::Prompt),
|
||||||
|
b'B' | b'I' => Some(Mark::Input),
|
||||||
|
b'C' => Some(Mark::Output),
|
||||||
|
b'D' => Some(Mark::Done(status(options))),
|
||||||
|
// L's fresh line needs the cursor, unseen before the parser; P adds nothing to A.
|
||||||
|
b'L' | b'P' => {
|
||||||
|
log::debug!("pty: OSC 133;{} takes no action here", shown());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::warn!("pty: OSC 133;{} is no mark", shown());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// D's status, its first option; none when the program gave none or no number.
|
||||||
|
fn status(options: &[u8]) -> Option<i32> {
|
||||||
|
let first = options.split(|&byte| byte == b';').next()?;
|
||||||
|
if first.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let status = std::str::from_utf8(first)
|
||||||
|
.ok()
|
||||||
|
.and_then(|text| text.parse().ok());
|
||||||
|
if status.is_none() {
|
||||||
|
log::warn!(
|
||||||
|
"pty: OSC 133;D with a status that is no number ({})",
|
||||||
|
String::from_utf8_lossy(first)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_mark_reads_with_its_options_as_ghostty_reads_it() {
|
||||||
|
assert_eq!(read(b"A"), Some(Mark::Prompt));
|
||||||
|
assert_eq!(read(b"A;click_events=1"), Some(Mark::Prompt));
|
||||||
|
assert_eq!(read(b"N;aid=7"), Some(Mark::Prompt));
|
||||||
|
assert_eq!(read(b"B"), Some(Mark::Input));
|
||||||
|
assert_eq!(read(b"I"), Some(Mark::Input));
|
||||||
|
assert_eq!(read(b"C;cmdline_url=false"), Some(Mark::Output));
|
||||||
|
assert_eq!(read(b"D;1"), Some(Mark::Done(Some(1))));
|
||||||
|
assert_eq!(read(b"D;127;aid=foo"), Some(Mark::Done(Some(127))));
|
||||||
|
assert_eq!(read(b"D;-1"), Some(Mark::Done(Some(-1))));
|
||||||
|
assert_eq!(read(b"D"), Some(Mark::Done(None)));
|
||||||
|
assert_eq!(read(b"D;"), Some(Mark::Done(None)));
|
||||||
|
assert_eq!(read(b"D;abc"), Some(Mark::Done(None)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mark_the_terminal_takes_no_action_on_reads_as_none() {
|
||||||
|
for body in [&b""[..], b"L", b"P;k=s", b"AB", b"Q", b"a"] {
|
||||||
|
assert_eq!(read(body), None, "{}", String::from_utf8_lossy(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
mod boxes;
|
mod boxes;
|
||||||
mod find;
|
mod find;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
mod herdr;
|
||||||
pub mod ink;
|
pub mod ink;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
mod links;
|
mod links;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub mod mac;
|
pub mod mac;
|
||||||
|
mod marks;
|
||||||
pub mod mouse;
|
pub mod mouse;
|
||||||
|
mod notify;
|
||||||
mod paint;
|
mod paint;
|
||||||
mod pointer;
|
mod pointer;
|
||||||
|
mod prompts;
|
||||||
mod pty;
|
mod pty;
|
||||||
|
mod reports;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
mod status;
|
||||||
mod tap;
|
mod tap;
|
||||||
|
mod titles;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod unix;
|
mod unix;
|
||||||
mod view;
|
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};
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
use super::status::BASE64;
|
||||||
|
|
||||||
|
/// A notification a program asks for: OSC 9, 777 or 99.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Notice {
|
||||||
|
/// OSC 99's identifier, which updates, closes and answers name; none for OSC 9, 777 and OSC 99 without one.
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
/// A press shows the window and session (`a=focus`, the default), and tells the program (`a=report`).
|
||||||
|
pub focus: bool,
|
||||||
|
pub report: bool,
|
||||||
|
/// The program hears of its close: `c=1`.
|
||||||
|
pub close: bool,
|
||||||
|
/// Milliseconds it lasts: `w` over 0.
|
||||||
|
pub expire: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a program's work stands, OSC 9;4.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Progress {
|
||||||
|
Remove,
|
||||||
|
Set(Option<u8>),
|
||||||
|
Error(Option<u8>),
|
||||||
|
Indeterminate,
|
||||||
|
Pause(Option<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a program asks of notifications and progress.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Ask {
|
||||||
|
Show(Notice),
|
||||||
|
/// Closes the notification this id names: OSC 99's `p=close`.
|
||||||
|
Close(String),
|
||||||
|
/// Which notifications are alive; the answer names this id: `p=alive`.
|
||||||
|
Alive(String),
|
||||||
|
/// What the terminal does with OSC 99; the answer names this id: `p=?`.
|
||||||
|
Query(String),
|
||||||
|
Progress(Progress),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ask in an OSC 9 body past `9;`: ConEmu's commands, 9;4 among them, else iTerm2's notification, as Ghostty reads them.
|
||||||
|
pub fn nine(body: &[u8]) -> Option<Ask> {
|
||||||
|
let semi = |at: usize| body.get(at) == Some(&b';');
|
||||||
|
let conemu = match body {
|
||||||
|
[] => false,
|
||||||
|
[b'1', b';', ..] | [b'1', b'0'] => true,
|
||||||
|
[b'1', b'0', b';', b'0'..=b'3', ..] | [b'1', b'1', b';', ..] | [b'1', b'2', ..] => true,
|
||||||
|
[b'4', b';', state @ b'0'..=b'4', ..] => {
|
||||||
|
return Some(Ask::Progress(progress(*state, &body[3..])));
|
||||||
|
}
|
||||||
|
[b'5', ..] => true,
|
||||||
|
[b'2' | b'3' | b'6' | b'7' | b'8' | b'9', ..] => semi(1),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if conemu {
|
||||||
|
log::debug!(
|
||||||
|
"pty: OSC 9;{} is ConEmu's, no notification",
|
||||||
|
String::from_utf8_lossy(body)
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = safe(body, "OSC 9")?;
|
||||||
|
(!text.is_empty()).then(|| Ask::Show(plain(text, String::new())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSC 9;4's state and the progress after it, clamped to 100.
|
||||||
|
fn progress(state: u8, rest: &[u8]) -> Progress {
|
||||||
|
let value = rest
|
||||||
|
.strip_prefix(b";")
|
||||||
|
.and_then(|digits| std::str::from_utf8(digits).ok())
|
||||||
|
.filter(|digits| !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
|
||||||
|
.and_then(|digits| digits.parse::<u64>().ok())
|
||||||
|
.map(|value| value.min(100) as u8);
|
||||||
|
match state {
|
||||||
|
b'0' => Progress::Remove,
|
||||||
|
// Set alone means 0, as in Ghostty; a value that does not read means none.
|
||||||
|
b'1' if rest.is_empty() => Progress::Set(Some(0)),
|
||||||
|
b'1' => Progress::Set(value),
|
||||||
|
b'2' => Progress::Error(value),
|
||||||
|
b'3' => Progress::Indeterminate,
|
||||||
|
_ => Progress::Pause(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The notification in an OSC 777 body past `777;`: rxvt's `notify;title;body`.
|
||||||
|
pub fn rxvt(body: &[u8]) -> Option<Ask> {
|
||||||
|
let Some(rest) = body.strip_prefix(b"notify;") else {
|
||||||
|
log::warn!(
|
||||||
|
"pty: OSC 777;{} is no rxvt notify",
|
||||||
|
String::from_utf8_lossy(body)
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let Some(at) = rest.iter().position(|&byte| byte == b';') else {
|
||||||
|
log::warn!("pty: OSC 777 notify without a title");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let (title, body) = (
|
||||||
|
safe(&rest[..at], "OSC 777")?,
|
||||||
|
safe(&rest[at + 1..], "OSC 777")?,
|
||||||
|
);
|
||||||
|
(!title.is_empty() || !body.is_empty()).then(|| Ask::Show(plain(title, body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification with no options, the body standing in for a missing title.
|
||||||
|
fn plain(title: String, body: String) -> Notice {
|
||||||
|
Notice {
|
||||||
|
id: None,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
focus: true,
|
||||||
|
report: false,
|
||||||
|
close: false,
|
||||||
|
expire: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text with no control character, else none, logged with `what` sent it.
|
||||||
|
fn safe(bytes: &[u8], what: &str) -> Option<String> {
|
||||||
|
let text = String::from_utf8(bytes.to_vec())
|
||||||
|
.inspect_err(|_| log::warn!("pty: {what} text is not UTF-8"))
|
||||||
|
.ok()?;
|
||||||
|
if text.chars().any(char::is_control) {
|
||||||
|
log::warn!("pty: {what} text holds a control character");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gpui closes no notification on Linux, nor replaces one, so close and expiry hold elsewhere alone.
|
||||||
|
pub(super) const CLOSES: bool = !cfg!(target_os = "linux");
|
||||||
|
|
||||||
|
/// The answer to OSC 99's query, naming `id`: a press focuses and reports, a notification posts while unseen, its payloads, and close and expiry where gpui closes one.
|
||||||
|
pub fn answer(id: &str) -> String {
|
||||||
|
let (close, expire) = if CLOSES { ("close,", ":w=1") } else { ("", "") };
|
||||||
|
format!(
|
||||||
|
"\x1b]99;i={id}:p=?;a=focus,report:o=invisible:p=title,body,{close}?,alive{expire}\x1b\\"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The answer to OSC 99's `p=alive`, naming `id`: the ids of notifications still alive.
|
||||||
|
pub fn alive(id: &str, alive: &[&str]) -> String {
|
||||||
|
format!("\x1b]99;i={id}:p=alive;{}\x1b\\", alive.join(","))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `c=1` hears of notification `id`: its close goes untracked, as gpui hears of none.
|
||||||
|
pub fn untracked(id: &str) -> String {
|
||||||
|
format!("\x1b]99;i={id}:p=close;untracked\x1b\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `a=report` hears when notification `id` is pressed.
|
||||||
|
pub fn activated(id: &str) -> String {
|
||||||
|
format!("\x1b]99;i={id};\x1b\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OSC 99 payload's most bytes: plain, and base64; and its metadata's.
|
||||||
|
const PLAIN: usize = 2048;
|
||||||
|
const ENCODED: usize = 4096;
|
||||||
|
const METADATA: usize = 1024;
|
||||||
|
|
||||||
|
/// The longest OSC 99 body the tap's scanner keeps: `99;`, metadata, `;` and a payload.
|
||||||
|
pub(super) const BODY: usize = 3 + METADATA + 1 + ENCODED;
|
||||||
|
|
||||||
|
/// The most text one notification gathers from its chunks, and how many gather at once.
|
||||||
|
const GATHERED: usize = 16384;
|
||||||
|
const PENDING: usize = 32;
|
||||||
|
|
||||||
|
/// A payload gathering its chunks: its bytes so far, and base64 short of a whole group.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Text {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
rest: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Text {
|
||||||
|
/// Adds a chunk: plain text as it is, base64 by whole groups, so a program may chunk before encoding or after.
|
||||||
|
fn add(&mut self, payload: &[u8], encoded: bool) -> Result<(), String> {
|
||||||
|
// Plain text is UTF-8 without a control character; base64 holds any UTF-8, checked once whole.
|
||||||
|
if !encoded {
|
||||||
|
let text = std::str::from_utf8(payload).map_err(|_| "payload is not UTF-8")?;
|
||||||
|
if text.chars().any(char::is_control) {
|
||||||
|
return Err("payload holds a control character".into());
|
||||||
|
}
|
||||||
|
self.finish()?;
|
||||||
|
self.bytes.extend_from_slice(payload);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.rest.extend_from_slice(payload);
|
||||||
|
let whole = self.rest.len() / 4 * 4;
|
||||||
|
for group in self.rest[..whole].chunks(4) {
|
||||||
|
self.bytes.extend(decode(group)?);
|
||||||
|
}
|
||||||
|
self.rest.drain(..whole);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the base64 left, padded or not, as the last chunk may leave it.
|
||||||
|
fn finish(&mut self) -> Result<(), String> {
|
||||||
|
if !self.rest.is_empty() {
|
||||||
|
self.bytes.extend(decode(&self.rest)?);
|
||||||
|
self.rest.clear();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.bytes.len() + self.rest.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(base64: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
BASE64
|
||||||
|
.decode(base64)
|
||||||
|
.map_err(|error| format!("payload is no base64: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification gathering its chunks.
|
||||||
|
struct Gathering {
|
||||||
|
notice: Notice,
|
||||||
|
title: Text,
|
||||||
|
body: Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSC 99 notifications gathered from their chunks, by id.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Kitty {
|
||||||
|
pending: Vec<(String, Gathering)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kitty {
|
||||||
|
/// The ask an OSC 99 body past `99;` completes, logged with why when there is none.
|
||||||
|
pub fn read(&mut self, body: &[u8]) -> Option<Ask> {
|
||||||
|
self.parse(body)
|
||||||
|
.inspect_err(|why| log::warn!("pty: OSC 99 dropped: {why}"))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(&mut self, body: &[u8]) -> Result<Option<Ask>, String> {
|
||||||
|
let at = body
|
||||||
|
.iter()
|
||||||
|
.position(|&byte| byte == b';')
|
||||||
|
.ok_or("no payload")?;
|
||||||
|
let (metadata, payload) = (&body[..at], &body[at + 1..]);
|
||||||
|
if metadata.len() > METADATA {
|
||||||
|
return Err(format!("metadata over {METADATA} bytes"));
|
||||||
|
}
|
||||||
|
let mut options: Vec<(u8, &[u8])> = Vec::new();
|
||||||
|
for pair in metadata
|
||||||
|
.split(|&byte| byte == b':')
|
||||||
|
.filter(|pair| !pair.is_empty())
|
||||||
|
{
|
||||||
|
match pair {
|
||||||
|
[key, b'=', value @ ..] if key.is_ascii_alphabetic() => options.push((*key, value)),
|
||||||
|
_ => log::debug!("pty: OSC 99 passes {} by", String::from_utf8_lossy(pair)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let get = |key: u8| {
|
||||||
|
options
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|(name, _)| *name == key)
|
||||||
|
.map(|(_, value)| *value)
|
||||||
|
};
|
||||||
|
let id = match get(b'i') {
|
||||||
|
Some(id)
|
||||||
|
if id
|
||||||
|
.iter()
|
||||||
|
.all(|&byte| byte.is_ascii_alphanumeric() || b"_-+.".contains(&byte)) =>
|
||||||
|
{
|
||||||
|
Some(String::from_utf8(id.to_vec()).expect("ASCII"))
|
||||||
|
}
|
||||||
|
Some(id) => {
|
||||||
|
return Err(format!(
|
||||||
|
"id {} holds a byte ids may not",
|
||||||
|
String::from_utf8_lossy(id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let encoded = get(b'e') == Some(b"1");
|
||||||
|
let limit = if encoded { ENCODED } else { PLAIN };
|
||||||
|
if payload.len() > limit {
|
||||||
|
return Err(format!("a payload over {limit} bytes"));
|
||||||
|
}
|
||||||
|
// An answer names the asker's id, or 0 without one, as kitty's spec says.
|
||||||
|
let named = || id.clone().unwrap_or_else(|| "0".into());
|
||||||
|
match get(b'p').unwrap_or(b"title") {
|
||||||
|
b"?" => return Ok(Some(Ask::Query(named()))),
|
||||||
|
b"alive" => return Ok(Some(Ask::Alive(named()))),
|
||||||
|
b"close" => return Ok(id.map(Ask::Close)),
|
||||||
|
b"title" | b"body" => {}
|
||||||
|
other => {
|
||||||
|
log::debug!(
|
||||||
|
"pty: OSC 99 payload {} passed by",
|
||||||
|
String::from_utf8_lossy(other)
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let key = id.clone().unwrap_or_default();
|
||||||
|
let at = match self.pending.iter().position(|(name, _)| *name == key) {
|
||||||
|
Some(at) => at,
|
||||||
|
None if self.pending.len() == PENDING => {
|
||||||
|
return Err(format!("{PENDING} notifications gathering already"));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let notice = Notice {
|
||||||
|
id: id.clone(),
|
||||||
|
..plain(String::new(), String::new())
|
||||||
|
};
|
||||||
|
let (title, body) = (Text::default(), Text::default());
|
||||||
|
self.pending.push((
|
||||||
|
key,
|
||||||
|
Gathering {
|
||||||
|
notice,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
self.pending.len() - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let gathering = &mut self.pending[at].1;
|
||||||
|
let text = if get(b'p') == Some(b"body") {
|
||||||
|
&mut gathering.body
|
||||||
|
} else {
|
||||||
|
&mut gathering.title
|
||||||
|
};
|
||||||
|
// A chunk that breaks the rules drops what its notification gathered.
|
||||||
|
if let Err(why) = text.add(payload, encoded) {
|
||||||
|
self.pending.remove(at);
|
||||||
|
return Err(why);
|
||||||
|
}
|
||||||
|
if gathering.title.len() + gathering.body.len() > GATHERED {
|
||||||
|
self.pending.remove(at);
|
||||||
|
return Err(format!("a notification over {GATHERED} bytes"));
|
||||||
|
}
|
||||||
|
let notice = &mut gathering.notice;
|
||||||
|
if let Some(actions) = get(b'a') {
|
||||||
|
for action in actions.split(|&byte| byte == b',') {
|
||||||
|
let (on, name) = match action.strip_prefix(b"-") {
|
||||||
|
Some(name) => (false, name),
|
||||||
|
None => (true, action),
|
||||||
|
};
|
||||||
|
match name {
|
||||||
|
b"focus" => notice.focus = on,
|
||||||
|
b"report" => notice.report = on,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(close) = get(b'c') {
|
||||||
|
notice.close = close == b"1";
|
||||||
|
}
|
||||||
|
if let Some(expire) = get(b'w') {
|
||||||
|
notice.expire = std::str::from_utf8(expire)
|
||||||
|
.ok()
|
||||||
|
.and_then(|ms| ms.parse::<u32>().ok())
|
||||||
|
.filter(|&ms| ms > 0);
|
||||||
|
}
|
||||||
|
if get(b'd') == Some(b"0") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (_, gathering) = self.pending.remove(at);
|
||||||
|
let Gathering {
|
||||||
|
mut notice,
|
||||||
|
mut title,
|
||||||
|
mut body,
|
||||||
|
} = gathering;
|
||||||
|
title.finish()?;
|
||||||
|
body.finish()?;
|
||||||
|
notice.title = String::from_utf8(title.bytes).map_err(|_| "title is not UTF-8")?;
|
||||||
|
notice.body = String::from_utf8(body.bytes).map_err(|_| "body is not UTF-8")?;
|
||||||
|
if notice.title.is_empty() && notice.body.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if notice.title.is_empty() {
|
||||||
|
notice.title = std::mem::take(&mut notice.body);
|
||||||
|
}
|
||||||
|
Ok(Some(Ask::Show(notice)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn shown(ask: Option<Ask>) -> Notice {
|
||||||
|
match ask {
|
||||||
|
Some(Ask::Show(notice)) => notice,
|
||||||
|
other => panic!("no notification: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_9_is_iterm2_s_notification_unless_it_is_conemu_s_command() {
|
||||||
|
assert_eq!(shown(nine(b"Hello world")).title, "Hello world");
|
||||||
|
for command in [
|
||||||
|
&b"1;100"[..],
|
||||||
|
b"10",
|
||||||
|
b"10;2",
|
||||||
|
b"11;note",
|
||||||
|
b"12",
|
||||||
|
b"2;box",
|
||||||
|
b"3;",
|
||||||
|
b"3;title",
|
||||||
|
b"5",
|
||||||
|
b"5x",
|
||||||
|
b"6;macro",
|
||||||
|
b"7;cmd",
|
||||||
|
b"8;VAR",
|
||||||
|
b"9;/path",
|
||||||
|
] {
|
||||||
|
assert_eq!(nine(command), None, "{}", String::from_utf8_lossy(command));
|
||||||
|
}
|
||||||
|
for text in [
|
||||||
|
"1", "1a", "10x", "10;5", "13", "2", "2a", "4", "4;", "4;9", "x;y",
|
||||||
|
] {
|
||||||
|
assert_eq!(shown(nine(text.as_bytes())).title, text);
|
||||||
|
}
|
||||||
|
assert_eq!(nine(b""), None);
|
||||||
|
assert_eq!(nine(b"a\xffb"), None, "not UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_9_4_reads_progress_as_ghostty_does() {
|
||||||
|
let progress = |body: &[u8]| match nine(body) {
|
||||||
|
Some(Ask::Progress(progress)) => progress,
|
||||||
|
other => panic!("no progress: {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(progress(b"4;0"), Progress::Remove);
|
||||||
|
assert_eq!(progress(b"4;1"), Progress::Set(Some(0)));
|
||||||
|
assert_eq!(progress(b"4;1;40"), Progress::Set(Some(40)));
|
||||||
|
assert_eq!(progress(b"4;1;900"), Progress::Set(Some(100)));
|
||||||
|
assert_eq!(progress(b"4;1;x"), Progress::Set(None));
|
||||||
|
assert_eq!(
|
||||||
|
progress(b"4;1;99999999999999999999999"),
|
||||||
|
Progress::Set(None)
|
||||||
|
);
|
||||||
|
assert_eq!(progress(b"4;1;1_0"), Progress::Set(None));
|
||||||
|
assert_eq!(progress(b"4;2"), Progress::Error(None));
|
||||||
|
assert_eq!(progress(b"4;2;50"), Progress::Error(Some(50)));
|
||||||
|
assert_eq!(progress(b"4;3;7"), Progress::Indeterminate);
|
||||||
|
assert_eq!(progress(b"4;4;10"), Progress::Pause(Some(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_777_is_rxvt_s_notify() {
|
||||||
|
let notice = shown(rxvt(b"notify;Title;Body;more"));
|
||||||
|
assert_eq!(
|
||||||
|
(notice.title.as_str(), notice.body.as_str()),
|
||||||
|
("Title", "Body;more")
|
||||||
|
);
|
||||||
|
assert_eq!(rxvt(b"other;x;y"), None);
|
||||||
|
assert_eq!(rxvt(b"notify;Title"), None, "no body");
|
||||||
|
assert_eq!(rxvt(b"notify;;"), None, "nothing to say");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_99_gathers_chunks_by_id_and_reads_its_options() {
|
||||||
|
let mut kitty = Kitty::default();
|
||||||
|
assert_eq!(kitty.read(b"i=a:d=0;Hel"), None);
|
||||||
|
assert_eq!(kitty.read(b"i=b:d=0:p=body;other"), None);
|
||||||
|
assert_eq!(kitty.read(b"i=a:d=0:p=body;Wor"), None);
|
||||||
|
let notice = shown(kitty.read(b"i=a:p=body:o=unfocused:a=report,-focus:c=1:w=5000;ld"));
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
notice.id.as_deref(),
|
||||||
|
notice.title.as_str(),
|
||||||
|
notice.body.as_str()
|
||||||
|
),
|
||||||
|
(Some("a"), "Hel", "World")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(notice.focus, notice.report, notice.close, notice.expire),
|
||||||
|
(false, true, true, Some(5000)),
|
||||||
|
"o passes by"
|
||||||
|
);
|
||||||
|
let other = shown(kitty.read(b"i=b:p=body;!"));
|
||||||
|
assert_eq!(
|
||||||
|
(other.title.as_str(), other.body.as_str()),
|
||||||
|
("other!", ""),
|
||||||
|
"a lone body is the title"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_99_decodes_base64_and_takes_plain_text() {
|
||||||
|
let mut kitty = Kitty::default();
|
||||||
|
let notice = shown(kitty.read(b"e=1:p=body;SGVsbG8gd29ybGQ="));
|
||||||
|
assert_eq!((notice.title.as_str(), notice.id), ("Hello world", None));
|
||||||
|
let plain = shown(kitty.read(b";Hello world"));
|
||||||
|
assert_eq!(
|
||||||
|
(plain.title.as_str(), plain.focus, plain.report),
|
||||||
|
("Hello world", true, false)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kitty.read(b"bad:i=c;x").map(|_| ()),
|
||||||
|
Some(()),
|
||||||
|
"a malformed pair passes by"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_99_gathers_base64_chunked_before_or_after_encoding() {
|
||||||
|
let title = |chunks: &[&str]| {
|
||||||
|
let mut kitty = Kitty::default();
|
||||||
|
let (last, first) = chunks.split_last().expect("a chunk");
|
||||||
|
for chunk in first {
|
||||||
|
assert_eq!(kitty.read(format!("i=a:d=0:e=1;{chunk}").as_bytes()), None);
|
||||||
|
}
|
||||||
|
shown(kitty.read(format!("i=a:e=1;{last}").as_bytes())).title
|
||||||
|
};
|
||||||
|
assert_eq!(title(&["SGVsb", "G8="]), "Hello", "split inside a group");
|
||||||
|
assert_eq!(title(&["8J+Y", "gA=="]), "😀", "split inside a character");
|
||||||
|
assert_eq!(
|
||||||
|
title(&["SGVsbG8=", "IHdvcmxk"]),
|
||||||
|
"Hello world",
|
||||||
|
"each chunk padded"
|
||||||
|
);
|
||||||
|
assert_eq!(title(&["SGVsbG", "8"]), "Hello", "the last chunk unpadded");
|
||||||
|
let mut kitty = Kitty::default();
|
||||||
|
assert_eq!(kitty.read(b"i=n:d=0;Title"), None);
|
||||||
|
let notice = shown(kitty.read(b"i=n:p=body:e=1;TGluZSAxCkxpbmUgMg=="));
|
||||||
|
assert_eq!(
|
||||||
|
(notice.title.as_str(), notice.body.as_str()),
|
||||||
|
("Title", "Line 1\nLine 2"),
|
||||||
|
"base64 carries any UTF-8"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_99_asks_to_close_and_what_is_alive_and_supported() {
|
||||||
|
let mut kitty = Kitty::default();
|
||||||
|
assert_eq!(kitty.read(b"i=x:p=close;"), Some(Ask::Close("x".into())));
|
||||||
|
assert_eq!(kitty.read(b"p=close;"), None, "no id, no close");
|
||||||
|
assert_eq!(kitty.read(b"i=q:p=?;"), Some(Ask::Query("q".into())));
|
||||||
|
assert_eq!(kitty.read(b"p=?;"), Some(Ask::Query("0".into())));
|
||||||
|
assert_eq!(kitty.read(b"i=y:p=alive;"), Some(Ask::Alive("y".into())));
|
||||||
|
assert_eq!(
|
||||||
|
kitty.read(b"p=icon;x"),
|
||||||
|
None,
|
||||||
|
"an unknown payload passes by"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_99_drops_what_breaks_its_rules() {
|
||||||
|
let mut kitty = Kitty::default();
|
||||||
|
let long = |length: usize, metadata: &str| {
|
||||||
|
let mut body = format!("{metadata};").into_bytes();
|
||||||
|
body.extend(std::iter::repeat_n(b'a', length));
|
||||||
|
body
|
||||||
|
};
|
||||||
|
for body in [
|
||||||
|
b"no payload".to_vec(),
|
||||||
|
b"i=a b;x".to_vec(),
|
||||||
|
b"i=a\"b;x".to_vec(),
|
||||||
|
b"e=1;!!!".to_vec(),
|
||||||
|
b"e=1;/w==".to_vec(),
|
||||||
|
"x\u{85}y"
|
||||||
|
.as_bytes()
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.fold(b";".to_vec(), |mut body, byte| {
|
||||||
|
body.push(byte);
|
||||||
|
body
|
||||||
|
}),
|
||||||
|
b";a\tb".to_vec(),
|
||||||
|
b"e=1;SGVsbG8gS".to_vec(),
|
||||||
|
long(PLAIN + 1, ""),
|
||||||
|
long(ENCODED + 4, "e=1"),
|
||||||
|
long(1, &"x".repeat(METADATA + 1)),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
kitty.read(&body),
|
||||||
|
None,
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&body)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(kitty.read(b"i=k:d=0;Hel"), None);
|
||||||
|
assert_eq!(kitty.read(b"i=k:d=0:e=1;!!!!"), None);
|
||||||
|
assert!(kitty.pending.is_empty(), "a bad chunk drops what gathered");
|
||||||
|
for at in 0..PENDING {
|
||||||
|
assert_eq!(kitty.read(format!("i=p{at}:d=0;x").as_bytes()), None);
|
||||||
|
}
|
||||||
|
assert_eq!(kitty.read(b"i=over:d=0;x"), None);
|
||||||
|
assert!(
|
||||||
|
kitty.pending.iter().all(|(id, _)| id != "over"),
|
||||||
|
"past the cap nothing gathers"
|
||||||
|
);
|
||||||
|
let mut big = Kitty::default();
|
||||||
|
for _ in 0..GATHERED / PLAIN {
|
||||||
|
assert_eq!(big.read(&long(PLAIN, "i=g:d=0")), None);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
big.read(&long(PLAIN, "i=g")),
|
||||||
|
None,
|
||||||
|
"over the gathered bound"
|
||||||
|
);
|
||||||
|
assert!(big.pending.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_answers_name_the_asker_s_id() {
|
||||||
|
let offered = if CLOSES {
|
||||||
|
"p=title,body,close,?,alive:w=1"
|
||||||
|
} else {
|
||||||
|
"p=title,body,?,alive"
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
answer("q"),
|
||||||
|
format!("\x1b]99;i=q:p=?;a=focus,report:o=invisible:{offered}\x1b\\")
|
||||||
|
);
|
||||||
|
assert_eq!(alive("0", &[]), "\x1b]99;i=0:p=alive;\x1b\\");
|
||||||
|
assert_eq!(alive("z", &["a", "b"]), "\x1b]99;i=z:p=alive;a,b\x1b\\");
|
||||||
|
assert_eq!(untracked("n1"), "\x1b]99;i=n1:p=close;untracked\x1b\\");
|
||||||
|
assert_eq!(activated("0"), "\x1b]99;i=0;\x1b\\");
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
use alacritty_terminal::{
|
||||||
|
grid::{Dimensions, Row, Scroll},
|
||||||
|
index::Line,
|
||||||
|
term::{Term, cell::Cell},
|
||||||
|
};
|
||||||
|
use gpui::Context;
|
||||||
|
|
||||||
|
use super::{tap::MOST, view::TerminalView};
|
||||||
|
|
||||||
|
/// The target of the OSC 8 link a prompt becomes; no program names a link so.
|
||||||
|
pub(crate) const LINK: &str = "\u{7f}";
|
||||||
|
|
||||||
|
/// What a link's id starts with when it carries a prompt's number: the prompt's own link, or a program's inside it.
|
||||||
|
const NUMBERED: char = '\u{7f}';
|
||||||
|
|
||||||
|
/// What ends a prompt's link.
|
||||||
|
const CLOSE: &[u8] = b"\x1b]8;;\x07";
|
||||||
|
|
||||||
|
/// What a held sequence's rewrite comes to at most: a link with the longest target the scanner keeps.
|
||||||
|
pub(super) const SLACK: usize = MOST + 32;
|
||||||
|
|
||||||
|
/// How many times its size a rewrite makes a sequence at most: a mark of 8 bytes inside a program's link becomes that link again.
|
||||||
|
pub(super) const GROWTH: usize = SLACK / 8 + 1;
|
||||||
|
|
||||||
|
const ESC: u8 = 0x1b;
|
||||||
|
|
||||||
|
/// Controls vte skips inside an OSC.
|
||||||
|
fn ignored(byte: u8) -> bool {
|
||||||
|
matches!(byte, 0x00..=0x06 | 0x08..=0x17 | 0x19 | 0x1c..=0x1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
enum State {
|
||||||
|
#[default]
|
||||||
|
Ground,
|
||||||
|
/// After an ESC, which waits while the parser stands where it stood before it, else has gone on.
|
||||||
|
Escape { sent: bool },
|
||||||
|
/// An OSC held whole, since it may be a mark or a link to rewrite.
|
||||||
|
Held,
|
||||||
|
/// An OSC passed on as it comes.
|
||||||
|
Passing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites what goes to the parser so it stamps each prompt's cells with a link of its own, numbered per prompt.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Prompts {
|
||||||
|
state: State,
|
||||||
|
/// The escape or OSC held, from its ESC.
|
||||||
|
held: Vec<u8>,
|
||||||
|
/// The OSC's body as vte reads it, the controls it skips left out.
|
||||||
|
body: Vec<u8>,
|
||||||
|
/// The body passed the scanner's bound, so it counts for nothing.
|
||||||
|
overflow: bool,
|
||||||
|
/// The held OSC's ESC has gone on: it ended the OSC before.
|
||||||
|
sent: bool,
|
||||||
|
/// The prompt open now, from its start mark to its end.
|
||||||
|
prompt: Option<u32>,
|
||||||
|
/// The target of a program's link open now, to open it again with a prompt's number and without.
|
||||||
|
target: Option<Vec<u8>>,
|
||||||
|
/// The last prompt's number.
|
||||||
|
count: u32,
|
||||||
|
/// What goes to the parser from the last read.
|
||||||
|
out: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Prompts {
|
||||||
|
/// The bytes held from earlier reads.
|
||||||
|
pub fn held(&self) -> usize {
|
||||||
|
self.held.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What goes to the parser for `read`: marks become links, and a held start waits for its end.
|
||||||
|
pub fn pass(&mut self, read: &[u8]) -> &[u8] {
|
||||||
|
self.out.clear();
|
||||||
|
for &byte in read {
|
||||||
|
self.step(byte);
|
||||||
|
}
|
||||||
|
&self.out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step(&mut self, byte: u8) {
|
||||||
|
self.state = match (self.state, byte) {
|
||||||
|
(State::Ground, ESC) => {
|
||||||
|
self.held.push(ESC);
|
||||||
|
State::Escape { sent: false }
|
||||||
|
}
|
||||||
|
(State::Ground, _) => {
|
||||||
|
self.out.push(byte);
|
||||||
|
State::Ground
|
||||||
|
}
|
||||||
|
(State::Escape { sent }, b']') => {
|
||||||
|
self.held.push(byte);
|
||||||
|
self.sent = sent;
|
||||||
|
self.body.clear();
|
||||||
|
self.overflow = false;
|
||||||
|
State::Held
|
||||||
|
}
|
||||||
|
(State::Escape { .. }, ESC) => {
|
||||||
|
self.release();
|
||||||
|
self.held.push(ESC);
|
||||||
|
State::Escape { sent: false }
|
||||||
|
}
|
||||||
|
// vte runs these and stays in the escape; one that waits lets them on first, where the parser stands the same.
|
||||||
|
(State::Escape { sent }, _) if byte == 0x07 || ignored(byte) => {
|
||||||
|
self.out.push(byte);
|
||||||
|
State::Escape { sent }
|
||||||
|
}
|
||||||
|
// vte passes DEL and high bytes by in the escape: the ESC goes on, then they.
|
||||||
|
(State::Escape { sent }, 0x7f..=0xff) => {
|
||||||
|
if !sent {
|
||||||
|
self.release();
|
||||||
|
}
|
||||||
|
self.out.push(byte);
|
||||||
|
State::Escape { sent: true }
|
||||||
|
}
|
||||||
|
(State::Escape { .. }, _) => {
|
||||||
|
// A full reset clears the link the parser writes with.
|
||||||
|
if byte == b'c' {
|
||||||
|
(self.prompt, self.target) = (None, None);
|
||||||
|
}
|
||||||
|
self.held.push(byte);
|
||||||
|
self.release();
|
||||||
|
State::Ground
|
||||||
|
}
|
||||||
|
(State::Held | State::Passing, 0x07 | 0x18 | 0x1a) => {
|
||||||
|
self.end(Some(byte));
|
||||||
|
State::Ground
|
||||||
|
}
|
||||||
|
// An ESC ends the OSC: after a rewrite the parser stands in ground, else inside the OSC, so the ESC goes on.
|
||||||
|
(State::Held | State::Passing, ESC) => {
|
||||||
|
if self.end(None) {
|
||||||
|
self.held.push(ESC);
|
||||||
|
State::Escape { sent: false }
|
||||||
|
} else {
|
||||||
|
self.out.push(ESC);
|
||||||
|
State::Escape { sent: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(State::Held, _) => {
|
||||||
|
self.held.push(byte);
|
||||||
|
self.read(byte);
|
||||||
|
if self.wanted() {
|
||||||
|
State::Held
|
||||||
|
} else {
|
||||||
|
self.release();
|
||||||
|
State::Passing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(State::Passing, _) => {
|
||||||
|
self.out.push(byte);
|
||||||
|
self.read(byte);
|
||||||
|
State::Passing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds `byte` to the body as the tap's scanner does; skipped controls bound the bytes held too.
|
||||||
|
fn read(&mut self, byte: u8) {
|
||||||
|
if self.held.len() > 2 * MOST {
|
||||||
|
self.overflow = true;
|
||||||
|
}
|
||||||
|
if ignored(byte) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.body.len() < MOST {
|
||||||
|
self.body.push(byte);
|
||||||
|
} else {
|
||||||
|
self.overflow = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the OSC held may be a mark, or a link that ends inside a prompt, within the scanner's bounds.
|
||||||
|
fn wanted(&self) -> bool {
|
||||||
|
let fits = |prefix: &[u8]| {
|
||||||
|
let length = self.body.len().min(prefix.len());
|
||||||
|
self.body[..length] == prefix[..length]
|
||||||
|
};
|
||||||
|
!self.overflow && (fits(b"133;") || (self.prompt.is_some() && fits(b"8;")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(&mut self) {
|
||||||
|
self.out.append(&mut self.held);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OSC ends at `end`, BEL, CAN or SUB, or at an ESC when none; true when it was rewritten.
|
||||||
|
fn end(&mut self, end: Option<u8>) -> bool {
|
||||||
|
let rewrite = if self.overflow {
|
||||||
|
None
|
||||||
|
} else if let Some(opens) = self.body.strip_prefix(b"133;").and_then(mark) {
|
||||||
|
if opens {
|
||||||
|
self.count = self.count.wrapping_add(1);
|
||||||
|
self.prompt = Some(self.count);
|
||||||
|
// A prompt inside a program's link carries its number on that link.
|
||||||
|
Some(match &self.target {
|
||||||
|
Some(target) => link(self.count, target),
|
||||||
|
None => link(self.count, LINK.as_bytes()),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
match (self.prompt.take(), &self.target) {
|
||||||
|
(Some(_), Some(target)) => Some(plain(target)),
|
||||||
|
(Some(_), None) => Some(CLOSE.to_vec()),
|
||||||
|
(None, _) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((opens, target)) = user(&self.body) {
|
||||||
|
self.target = opens.then(|| target.to_vec());
|
||||||
|
// Inside a prompt a program's link carries the prompt's number; its end takes the prompt's link up again.
|
||||||
|
let target = if opens { target } else { LINK.as_bytes() };
|
||||||
|
self.prompt.map(|number| link(number, target))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
match (rewrite, self.state) {
|
||||||
|
(Some(bytes), State::Held) => {
|
||||||
|
self.held.clear();
|
||||||
|
// An ESC that went on already starts the link.
|
||||||
|
self.out.extend_from_slice(&bytes[usize::from(self.sent)..]);
|
||||||
|
// CAN and SUB end the OSC and run as well.
|
||||||
|
self.out.extend(end.filter(|&byte| byte != 0x07));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
(_, State::Held) => {
|
||||||
|
self.held.extend(end);
|
||||||
|
self.release();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.out.extend(end);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a mark's body opens a prompt or ends one, as `marks::read` reads its letter; none for neither.
|
||||||
|
fn mark(body: &[u8]) -> Option<bool> {
|
||||||
|
let (&letter, rest) = body.split_first()?;
|
||||||
|
if !rest.is_empty() && rest[0] != b';' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match letter {
|
||||||
|
b'A' | b'N' => Some(true),
|
||||||
|
b'B' | b'I' | b'C' | b'D' => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an OSC 8 body opens a program's link, as alacritty reads its target, and that target; none for no link at all.
|
||||||
|
fn user(body: &[u8]) -> Option<(bool, &[u8])> {
|
||||||
|
let rest = body.strip_prefix(b"8;")?;
|
||||||
|
let target = &rest[rest.iter().position(|&byte| byte == b';')? + 1..];
|
||||||
|
// alacritty joins the target's parts with `;`, each one empty when it is not UTF-8.
|
||||||
|
let opens =
|
||||||
|
target.contains(&b';') || std::str::from_utf8(target).is_ok_and(|text| !text.is_empty());
|
||||||
|
Some((opens, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link to `target` whose id carries prompt `number`.
|
||||||
|
fn link(number: u32, target: &[u8]) -> Vec<u8> {
|
||||||
|
let mut link = format!("\x1b]8;id={NUMBERED}{number};").into_bytes();
|
||||||
|
link.extend_from_slice(target);
|
||||||
|
link.push(0x07);
|
||||||
|
link
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A program's link to `target`, its id its own again.
|
||||||
|
fn plain(target: &[u8]) -> Vec<u8> {
|
||||||
|
let mut link = b"\x1b]8;;".to_vec();
|
||||||
|
link.extend_from_slice(target);
|
||||||
|
link.push(0x07);
|
||||||
|
link
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of the prompt `row` shows, as a link's id carries it.
|
||||||
|
fn prompt(row: &Row<Cell>) -> Option<u32> {
|
||||||
|
row.into_iter().find_map(|cell| {
|
||||||
|
let link = cell.hyperlink()?;
|
||||||
|
link.id().strip_prefix(NUMBERED)?.parse().ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The display offset that puts the prompt before or after the view's top at the top, as Ghostty jumps.
|
||||||
|
pub fn jump<T>(term: &Term<T>, up: bool) -> Option<usize> {
|
||||||
|
let top = -(term.grid().display_offset() as i32);
|
||||||
|
let (first, last) = (term.topmost_line().0, term.bottommost_line().0);
|
||||||
|
let number = |line: i32| prompt(&term.grid()[Line(line)]);
|
||||||
|
// ponytail: a scan of the rows on the way; index prompt rows if long histories lag.
|
||||||
|
let found = if up {
|
||||||
|
// The nearest prompt above the view's top, then its first row, blank rows within it passed.
|
||||||
|
let (line, own) = (first..top)
|
||||||
|
.rev()
|
||||||
|
.find_map(|line| number(line).map(|own| (line, own)))?;
|
||||||
|
let mut start = line;
|
||||||
|
for above in (first..line).rev() {
|
||||||
|
match number(above) {
|
||||||
|
Some(other) if other != own => break,
|
||||||
|
Some(_) => start = above,
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start
|
||||||
|
} else {
|
||||||
|
// The prompt at or above the view's top may go on below it; the next one starts.
|
||||||
|
let here = (first..=top).rev().find_map(number);
|
||||||
|
(top + 1..=last).find(|&line| number(line).is_some_and(|other| Some(other) != here))?
|
||||||
|
};
|
||||||
|
// A prompt on the screen means the bottom, which the view cannot pass.
|
||||||
|
Some((-found).max(0) as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalView {
|
||||||
|
/// Scrolls to the prompt before or after the view's top; with none there, nothing moves.
|
||||||
|
pub(super) fn scroll_to_prompt(&mut self, up: bool, cx: &mut Context<Self>) {
|
||||||
|
let mut term = self.pty.term.lock();
|
||||||
|
let Some(offset) = jump(&term, up) else {
|
||||||
|
log::debug!(
|
||||||
|
"terminal {}: no prompt {}",
|
||||||
|
self.pty.pid,
|
||||||
|
if up { "above" } else { "below" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let delta = offset as i32 - term.grid().display_offset() as i32;
|
||||||
|
term.scroll_display(Scroll::Delta(delta));
|
||||||
|
drop(term);
|
||||||
|
log::debug!("terminal {}: to the prompt {offset} lines up", self.pty.pid);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
use alacritty_terminal::{
|
||||||
|
event::VoidListener,
|
||||||
|
index::Column,
|
||||||
|
term::{Config, test::TermSize},
|
||||||
|
vte::ansi::Processor,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::term::marks::{self, Mark};
|
||||||
|
|
||||||
|
/// What the parser gets from `chunks`, each one read.
|
||||||
|
fn passed(chunks: &[&[u8]]) -> Vec<u8> {
|
||||||
|
let mut prompts = Prompts::default();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for chunk in chunks {
|
||||||
|
out.extend_from_slice(prompts.pass(chunk));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terminal `columns` wide and `lines` high after `bytes` passed through the rewrite.
|
||||||
|
fn parsed(bytes: &[u8], columns: usize, lines: usize) -> Term<VoidListener> {
|
||||||
|
let mut term = Term::new(
|
||||||
|
Config::default(),
|
||||||
|
&TermSize::new(columns, lines),
|
||||||
|
VoidListener,
|
||||||
|
);
|
||||||
|
let mut parser: Processor = Processor::new();
|
||||||
|
parser.advance(&mut term, &passed(&[bytes]));
|
||||||
|
term
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target of the link at a cell.
|
||||||
|
fn target_of(term: &Term<VoidListener>, line: i32, column: usize) -> Option<String> {
|
||||||
|
let cell = &term.grid()[Line(line)][Column(column)];
|
||||||
|
cell.hyperlink().map(|link| link.uri().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mark_becomes_its_prompt_s_numbered_link() {
|
||||||
|
assert_eq!(
|
||||||
|
passed(&[b"\x1b]133;A\x07$ \x1b]133;B\x1b\\x"]),
|
||||||
|
b"\x1b]8;id=\x7f1;\x7f\x07$ \x1b]8;;\x07\x1b\\x"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
passed(&[b"\x1b]133;A;k=i\x07\x1b]133;D;1\x18"]),
|
||||||
|
b"\x1b]8;id=\x7f1;\x7f\x07\x1b]8;;\x07\x18"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
passed(&[b"\x1b]133;N\x07\x1b]133;C\x07\x1b]133;A\x07"]),
|
||||||
|
b"\x1b]8;id=\x7f1;\x7f\x07\x1b]8;;\x07\x1b]8;id=\x7f2;\x7f\x07"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
passed(&[b"\x1b]133;D;0\x07"]),
|
||||||
|
b"\x1b]133;D;0\x07",
|
||||||
|
"an end with no prompt open passes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_rewrite_reads_a_mark_as_the_session_hears_it() {
|
||||||
|
let bodies = [
|
||||||
|
"A", "A;k=i", "N", "B", "I", "C;x", "D;1", "D;abc", "D", "", "AB", "L", "P;k=i", "Q", "a",
|
||||||
|
];
|
||||||
|
for body in bodies {
|
||||||
|
let heard = marks::read(body.as_bytes()).map(|mark| mark == Mark::Prompt);
|
||||||
|
assert_eq!(mark(body.as_bytes()), heard, "{body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mark_split_across_reads_comes_out_whole() {
|
||||||
|
let whole: &[u8] =
|
||||||
|
b"a\x1b]133;A;k=i\x07\x1b]8;;file:///w\x1b\\w\x1b]8;;\x1b\\$ \x1b]133;B\x1b\\b";
|
||||||
|
let once = passed(&[whole]);
|
||||||
|
for cut in 0..=whole.len() {
|
||||||
|
let (first, second) = whole.split_at(cut);
|
||||||
|
assert_eq!(passed(&[first, second]), once, "cut at {cut}");
|
||||||
|
}
|
||||||
|
let bytes: Vec<&[u8]> = whole.chunks(1).collect();
|
||||||
|
assert_eq!(passed(&bytes), once);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_sequences_pass_as_they_came() {
|
||||||
|
for bytes in [
|
||||||
|
"\x1b]133;AB\x07x",
|
||||||
|
"\x1b]1337;A\x07",
|
||||||
|
"\x1b]133;P;k=i\x07",
|
||||||
|
"\x1b]133;\x07",
|
||||||
|
"\x1b]0;133;A\x07",
|
||||||
|
"\x1b[31m\x1bc",
|
||||||
|
"\x1b]8;;file:///x\x07x\x1b]8;;\x07",
|
||||||
|
"text",
|
||||||
|
] {
|
||||||
|
assert_eq!(passed(&[bytes.as_bytes()]), bytes.as_bytes(), "{bytes:?}");
|
||||||
|
}
|
||||||
|
let mut long = b"\x1b]133;A;".to_vec();
|
||||||
|
long.extend([b'x'; MOST]);
|
||||||
|
long.push(0x07);
|
||||||
|
assert_eq!(passed(&[&long]), long, "past the scanner's bound");
|
||||||
|
let mut skipped = b"\x1b]133;A".to_vec();
|
||||||
|
skipped.extend([0; 3 * MOST]);
|
||||||
|
skipped.push(0x07);
|
||||||
|
assert_eq!(
|
||||||
|
passed(&[&skipped]),
|
||||||
|
skipped,
|
||||||
|
"skipped controls bound the bytes held"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_escape_of_controls_goes_on_as_it_comes() {
|
||||||
|
let mut whole = vec![ESC];
|
||||||
|
whole.extend(vec![0; 3 << 20]);
|
||||||
|
whole.extend_from_slice(b"[0mREADY");
|
||||||
|
let mut prompts = Prompts::default();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for chunk in whole.chunks(64 << 10) {
|
||||||
|
out.extend_from_slice(prompts.pass(chunk));
|
||||||
|
assert!(prompts.held() <= 1, "only the ESC waits");
|
||||||
|
}
|
||||||
|
assert_eq!(out.len(), whole.len());
|
||||||
|
assert!(out.ends_with(b"\x1b[0mREADY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_program_s_link_inside_a_prompt_carries_its_number() {
|
||||||
|
let whole = parsed(
|
||||||
|
b"\x1b]133;A\x07\x1b]8;;file:///work\x1b\\/work $ \x1b]8;;\x1b\\\x1b]133;B\x07",
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt(&whole.grid()[Line(0)]),
|
||||||
|
Some(1),
|
||||||
|
"a prompt all in a link"
|
||||||
|
);
|
||||||
|
assert_eq!(target_of(&whole, 0, 6).as_deref(), Some("file:///work"));
|
||||||
|
let first = b"\x1b]133;A\x07\x1b]8;;file:///w\x1b\\/work\r\n\x1b]8;;\x1b\\$ \x1b]133;B\x07\r\no\r\no\r\no";
|
||||||
|
let term = parsed(first, 8, 3);
|
||||||
|
assert_eq!(jump(&term, true), Some(2), "its linked first row starts it");
|
||||||
|
let open = parsed(
|
||||||
|
b"\x1b]8;;file:///x\x1b\\x\x1b]133;A\x07y\x1b]133;B\x07z",
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt(&open.grid()[Line(0)]),
|
||||||
|
Some(1),
|
||||||
|
"a prompt inside a link left open"
|
||||||
|
);
|
||||||
|
let (y, z) = (
|
||||||
|
&open.grid()[Line(0)][Column(1)],
|
||||||
|
&open.grid()[Line(0)][Column(2)],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
z.hyperlink()
|
||||||
|
.is_some_and(|link| !link.id().starts_with(NUMBERED))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target_of(&open, 0, 2).as_deref(),
|
||||||
|
Some("file:///x"),
|
||||||
|
"its own again past the prompt"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
y.hyperlink()
|
||||||
|
.is_some_and(|link| link.id().starts_with(NUMBERED))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_target_numbers_a_prompt_in_either_order() {
|
||||||
|
let target = format!("https://e.test/{}", "a".repeat(249));
|
||||||
|
let open = format!("\x1b]8;;{target}\x1b\\");
|
||||||
|
let before = format!("{open}x\x1b]133;A\x07y\x1b]133;B\x07z\x1b]8;;\x1b\\");
|
||||||
|
let inside = format!("x\x1b]133;A\x07{open}y\x1b]133;B\x07z\x1b]8;;\x1b\\");
|
||||||
|
for bytes in [before, inside] {
|
||||||
|
let term = parsed(bytes.as_bytes(), 8, 2);
|
||||||
|
let row = &term.grid()[Line(0)];
|
||||||
|
let numbered = |column: usize| {
|
||||||
|
row[Column(column)]
|
||||||
|
.hyperlink()
|
||||||
|
.is_some_and(|link| link.id().starts_with(NUMBERED))
|
||||||
|
};
|
||||||
|
assert_eq!(prompt(row), Some(1), "{bytes:?}");
|
||||||
|
assert!(
|
||||||
|
numbered(1) && !numbered(2),
|
||||||
|
"the prompt's cell alone: {bytes:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(target_of(&term, 0, 2).as_deref(), Some(target.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controls_after_an_osc_s_closing_escape_run_where_vte_runs_them() {
|
||||||
|
let whole: &[u8] = b"LEFT\x1b]0;title\x1b\r\n\\RIGHT";
|
||||||
|
for cut in 0..=whole.len() {
|
||||||
|
let (first, second) = whole.split_at(cut);
|
||||||
|
assert_eq!(passed(&[first, second]), whole, "cut at {cut}");
|
||||||
|
}
|
||||||
|
let term = parsed(whole, 8, 2);
|
||||||
|
let line = |at: i32| -> String {
|
||||||
|
term.grid()[Line(at)]
|
||||||
|
.into_iter()
|
||||||
|
.map(|cell| cell.c)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert_eq!((line(0).trim_end(), line(1).trim_end()), ("LEFT", "RIGHT"));
|
||||||
|
let marked = parsed(b"\x1b]133;A\x1b\r\n\\$", 8, 2);
|
||||||
|
assert_eq!(prompt(&marked.grid()[Line(1)]), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn del_and_high_bytes_in_an_escape_go_on_after_it() {
|
||||||
|
assert_eq!(
|
||||||
|
passed(&[b"\x1b\x7f]133;A\x07$\x1b\xff", b"]133;B\x07"]),
|
||||||
|
b"\x1b\x7f]8;id=\x7f1;\x7f\x07$\x1b\xff]8;;\x07"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controls_vte_runs_inside_an_escape_go_on_before_the_link() {
|
||||||
|
assert_eq!(
|
||||||
|
passed(&[b"\x1b\n\x07]133;A\x07$"]),
|
||||||
|
b"\n\x07\x1b]8;id=\x7f1;\x7f\x07$"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prompt_s_cells_carry_its_link_through_sgr_0_and_its_input_none() {
|
||||||
|
let term = parsed(
|
||||||
|
b"x\r\n\x1b]133;A\x07\x1b[31m$\x1b[0m \x1b]133;B\x07ls",
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert_eq!(prompt(&term.grid()[Line(0)]), None);
|
||||||
|
assert_eq!(prompt(&term.grid()[Line(1)]), Some(1));
|
||||||
|
assert_eq!(target_of(&term, 1, 1).as_deref(), Some(LINK));
|
||||||
|
assert_eq!(target_of(&term, 1, 2), None, "input");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_program_s_own_link_and_the_prompt_s_keep_each_other() {
|
||||||
|
let term = parsed(
|
||||||
|
b"\x1b]133;A\x07\x1b]8;;file:///work\x1b\\/work\x1b]8;;\x1b\\ $ \x1b]133;B\x07",
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert_eq!(target_of(&term, 0, 0).as_deref(), Some("file:///work"));
|
||||||
|
assert_eq!(target_of(&term, 0, 6).as_deref(), Some(LINK));
|
||||||
|
assert_eq!(prompt(&term.grid()[Line(0)]), Some(1));
|
||||||
|
let term = parsed(
|
||||||
|
b"\x1b]8;;file:///x\x1b\\x\x1b]133;A\x07y\x1b]8;;\x1b\\z\x1b]133;B\x07",
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert_eq!(target_of(&term, 0, 0).as_deref(), Some("file:///x"));
|
||||||
|
assert_eq!(
|
||||||
|
target_of(&term, 0, 1).as_deref(),
|
||||||
|
Some("file:///x"),
|
||||||
|
"a prompt inside a link keeps it"
|
||||||
|
);
|
||||||
|
assert_eq!(target_of(&term, 0, 2).as_deref(), Some(LINK));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompts on lines 0, 3 and 6 of a history four lines high, the last on the screen.
|
||||||
|
fn history() -> Term<VoidListener> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
for _ in 0..3 {
|
||||||
|
bytes.extend_from_slice(
|
||||||
|
b"\x1b]133;A\x07$ \x1b]133;B\x07c\r\n\x1b]133;C\x07o\r\no\r\n\x1b]133;D;0\x07",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(b"\x1b]133;A\x07$ \x1b]133;B\x07");
|
||||||
|
parsed(&bytes, 8, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_and_down_go_to_the_prompts_beside_the_view_s_top() {
|
||||||
|
let mut term = history();
|
||||||
|
assert_eq!(term.grid().history_size(), 6);
|
||||||
|
// From the bottom: the prompt at the view's top is passed, the one before it found.
|
||||||
|
assert_eq!(jump(&term, true), Some(3));
|
||||||
|
term.scroll_display(Scroll::Delta(3));
|
||||||
|
assert_eq!(jump(&term, true), Some(6));
|
||||||
|
term.scroll_display(Scroll::Delta(3));
|
||||||
|
assert_eq!(jump(&term, true), None, "no prompt above the first");
|
||||||
|
assert_eq!(jump(&term, false), Some(3));
|
||||||
|
term.scroll_display(Scroll::Delta(-3));
|
||||||
|
assert_eq!(
|
||||||
|
jump(&term, false),
|
||||||
|
Some(0),
|
||||||
|
"a prompt on the screen means the bottom"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prompt_over_rows_starts_at_its_first_blank_rows_within_it_too() {
|
||||||
|
let bytes = b"\x1b]133;A\x07FIRST\r\n\r\nLAST $ \x1b]133;B\x07\r\nout1\r\nout2\r\nout3\r\nout4";
|
||||||
|
let mut term = parsed(bytes, 8, 3);
|
||||||
|
assert_eq!(term.grid().history_size(), 4);
|
||||||
|
assert_eq!(jump(&term, true), Some(4), "FIRST, not LAST");
|
||||||
|
term.scroll_display(Scroll::Delta(4));
|
||||||
|
assert_eq!(jump(&term, false), None, "LAST is the same prompt");
|
||||||
|
let next = parsed(b"\x1b]133;A\x07$ \x1b]133;B\x07\r\n\x1b]133;A\x07$ ", 8, 2);
|
||||||
|
assert_eq!(
|
||||||
|
(prompt(&next.grid()[Line(0)]), prompt(&next.grid()[Line(1)])),
|
||||||
|
(Some(1), Some(2)),
|
||||||
|
"an empty line's next prompt is its own"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prompt_keeps_its_start_through_a_resize() {
|
||||||
|
let bytes =
|
||||||
|
b"\x1b]133;A\x07~/folder $ \x1b]133;B\x07ls\r\nout\r\nout\r\nout\r\n\x1b]133;A\x07$ ";
|
||||||
|
let mut term = parsed(bytes, 12, 3);
|
||||||
|
term.resize(TermSize::new(6, 3));
|
||||||
|
let rows = term.topmost_line().0..=term.bottommost_line().0;
|
||||||
|
let numbers: Vec<Option<u32>> = rows.map(|line| prompt(&term.grid()[Line(line)])).collect();
|
||||||
|
let first = numbers.iter().position(|number| *number == Some(1));
|
||||||
|
// The first prompt wraps over two rows; the view jumps to the first of them.
|
||||||
|
assert_eq!(
|
||||||
|
numbers.iter().filter(|number| **number == Some(1)).count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
let offset = jump(&term, true).expect("a prompt above");
|
||||||
|
let top = term.topmost_line().0 + first.expect("prompt 1") as i32;
|
||||||
|
assert_eq!(offset as i32, -top);
|
||||||
|
}
|
||||||
+23
-170
@@ -11,7 +11,7 @@ use alacritty_terminal::{
|
|||||||
};
|
};
|
||||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||||
|
|
||||||
use super::{mouse::MouseMode, tap::Tapped};
|
use super::{marks::Mark, mouse::MouseMode, notify::Ask, status::Status, tap::Tapped};
|
||||||
use crate::settings::{CursorStyle, Settings};
|
use crate::settings::{CursorStyle, Settings};
|
||||||
|
|
||||||
/// What a session hears from its pty.
|
/// What a session hears from its pty.
|
||||||
@@ -20,6 +20,15 @@ pub enum PtyEvent {
|
|||||||
Term(Event),
|
Term(Event),
|
||||||
Folder(PathBuf),
|
Folder(PathBuf),
|
||||||
Mouse(MouseMode),
|
Mouse(MouseMode),
|
||||||
|
Mark(Mark),
|
||||||
|
/// A program's status, OSC 7501.
|
||||||
|
Status(Status),
|
||||||
|
/// A notification or progress, OSC 9, 777 or 99.
|
||||||
|
Ask(Ask),
|
||||||
|
/// A title a program set, in the output's order: OSC 0 or 2, or the title stack's pop.
|
||||||
|
Title(String),
|
||||||
|
/// A full reset, `ESC c`.
|
||||||
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hands the grid's events to the session; `pid` names the program in logs.
|
/// Hands the grid's events to the session; `pid` names the program in logs.
|
||||||
@@ -56,7 +65,7 @@ pub struct Pty {
|
|||||||
pub term: Arc<FairMutex<Term<Listener>>>,
|
pub term: Arc<FairMutex<Term<Listener>>>,
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
notifier: Notifier,
|
notifier: Notifier,
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(unix)]
|
||||||
master: std::fs::File,
|
master: std::fs::File,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +120,7 @@ pub fn spawn(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let pid = child_id(&raw)?;
|
let pid = child_id(&raw)?;
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(unix)]
|
||||||
let master = raw
|
let master = raw
|
||||||
.master()
|
.master()
|
||||||
.map_err(|error| anyhow::anyhow!("pty {pid}: no copy of the master: {error}"))?;
|
.map_err(|error| anyhow::anyhow!("pty {pid}: no copy of the master: {error}"))?;
|
||||||
@@ -141,7 +150,7 @@ pub fn spawn(
|
|||||||
term,
|
term,
|
||||||
pid,
|
pid,
|
||||||
notifier,
|
notifier,
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(unix)]
|
||||||
master,
|
master,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
@@ -184,6 +193,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))]
|
#[cfg(all(test, unix))]
|
||||||
impl Pty {
|
impl Pty {
|
||||||
/// Whether the program reads raw keys, canonical mode off.
|
/// Whether the program reads raw keys, canonical mode off.
|
||||||
@@ -236,172 +254,7 @@ pub fn fit(columns: usize, lines: usize, cell: (u16, u16)) -> WindowSize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests;
|
||||||
use alacritty_terminal::{
|
|
||||||
grid::Dimensions,
|
|
||||||
index::{Column, Line},
|
|
||||||
};
|
|
||||||
use futures::StreamExt;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
struct Ran {
|
|
||||||
folders: Vec<PathBuf>,
|
|
||||||
text: String,
|
|
||||||
success: bool,
|
|
||||||
pid: u32,
|
|
||||||
columns: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(script: &str, size: (usize, usize)) -> Ran {
|
|
||||||
crate::testlog::init();
|
|
||||||
let launch = Launch {
|
|
||||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
|
||||||
cwd: std::env::temp_dir(),
|
|
||||||
env: vec![("ELY".into(), "ok".into())],
|
|
||||||
reports: false,
|
|
||||||
};
|
|
||||||
let (pty, mut events) =
|
|
||||||
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
|
|
||||||
let (folders, success) = futures::executor::block_on(async {
|
|
||||||
let (mut folders, mut success) = (Vec::new(), false);
|
|
||||||
while let Some(event) = events.next().await {
|
|
||||||
match event {
|
|
||||||
PtyEvent::Folder(folder) => folders.push(folder),
|
|
||||||
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
|
||||||
PtyEvent::Term(Event::Exit) => break,
|
|
||||||
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(folders, success)
|
|
||||||
});
|
|
||||||
let term = pty.term.lock();
|
|
||||||
let row = &term.grid()[Line(0)];
|
|
||||||
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
|
|
||||||
Ran {
|
|
||||||
folders,
|
|
||||||
text: text.trim_end().to_string(),
|
|
||||||
success,
|
|
||||||
pid: pty.pid,
|
|
||||||
columns: term.columns(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
|
|
||||||
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
|
|
||||||
assert!(ran.folders.is_empty());
|
|
||||||
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
|
|
||||||
assert!(ran.success);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn osc_7_reaches_the_session_as_a_folder() {
|
|
||||||
let ran = run(
|
|
||||||
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
|
|
||||||
(40, 4),
|
|
||||||
);
|
|
||||||
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
|
|
||||||
assert_eq!(ran.text, "x");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
|
|
||||||
let ran = run("stty size", (9000, 8));
|
|
||||||
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
|
|
||||||
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
|
|
||||||
fn close_after_ready(script: &str, reaped: &str) {
|
|
||||||
crate::testlog::init();
|
|
||||||
let launch = Launch {
|
|
||||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
|
||||||
cwd: std::env::temp_dir(),
|
|
||||||
env: Vec::new(),
|
|
||||||
reports: false,
|
|
||||||
};
|
|
||||||
let (pty, mut events) =
|
|
||||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
|
||||||
futures::executor::block_on(async {
|
|
||||||
while let Some(event) = events.next().await {
|
|
||||||
let term = pty.term.lock();
|
|
||||||
let text: String = (0..5)
|
|
||||||
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
|
||||||
.collect();
|
|
||||||
drop(term);
|
|
||||||
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let pid = pty.pid;
|
|
||||||
drop(pty);
|
|
||||||
let hung_up = format!("pty {pid}: hung up, {reaped}");
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
||||||
while !crate::testlog::logged(&hung_up) {
|
|
||||||
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
|
|
||||||
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
|
|
||||||
assert!(!alive, "the program was reaped");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn closing_a_session_hangs_up_its_program() {
|
|
||||||
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
|
|
||||||
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
|
|
||||||
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_missing_folder_stops_the_start() {
|
|
||||||
let launch = Launch {
|
|
||||||
program: None,
|
|
||||||
cwd: "/nonexistent/ely".into(),
|
|
||||||
env: Vec::new(),
|
|
||||||
reports: false,
|
|
||||||
};
|
|
||||||
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
|
||||||
.err()
|
|
||||||
.expect("a missing folder is an error");
|
|
||||||
assert!(error.to_string().contains("/nonexistent/ely"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_folder_that_cannot_be_entered_stops_the_start() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
|
|
||||||
std::fs::create_dir_all(&folder).expect("a temp folder");
|
|
||||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
|
|
||||||
let launch = Launch {
|
|
||||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
|
||||||
cwd: folder.clone(),
|
|
||||||
env: Vec::new(),
|
|
||||||
reports: false,
|
|
||||||
};
|
|
||||||
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
|
||||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
|
||||||
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
|
||||||
let error = started.err().expect("a folder it cannot enter is an error");
|
|
||||||
let error = error.to_string();
|
|
||||||
assert!(error.contains("Permission denied"), "{error}");
|
|
||||||
assert!(error.contains(&folder.display().to_string()), "{error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
|
|
||||||
let size = fit(70_000, 24, (8, 16));
|
|
||||||
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
|
|
||||||
let size = fit(0, 0, (8, 16));
|
|
||||||
assert_eq!((size.num_cols, size.num_lines), (2, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod scrollback {
|
mod scrollback {
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
use alacritty_terminal::{
|
||||||
|
grid::Dimensions,
|
||||||
|
index::{Column, Line},
|
||||||
|
};
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::term::{
|
||||||
|
notify::Ask,
|
||||||
|
status::{Record, State},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Ran {
|
||||||
|
folders: Vec<PathBuf>,
|
||||||
|
marks: Vec<Mark>,
|
||||||
|
/// What programs said of their status, a full reset as none.
|
||||||
|
statuses: Vec<Option<Status>>,
|
||||||
|
/// Notifications and progress programs asked for.
|
||||||
|
asks: Vec<Ask>,
|
||||||
|
/// Titles, notifications and full resets, in the output's order.
|
||||||
|
said: Vec<String>,
|
||||||
|
text: String,
|
||||||
|
/// The first line's cells that carry a prompt's link.
|
||||||
|
prompt: Vec<usize>,
|
||||||
|
success: bool,
|
||||||
|
pid: u32,
|
||||||
|
columns: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(script: &str, size: (usize, usize)) -> Ran {
|
||||||
|
crate::testlog::init();
|
||||||
|
let launch = Launch {
|
||||||
|
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||||
|
cwd: std::env::temp_dir(),
|
||||||
|
env: vec![("ELY".into(), "ok".into())],
|
||||||
|
reports: false,
|
||||||
|
};
|
||||||
|
let (pty, mut events) =
|
||||||
|
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
|
||||||
|
let (folders, marks, statuses, asks, said, success) = futures::executor::block_on(async {
|
||||||
|
let (mut folders, mut marks, mut statuses, mut asks, mut said, mut success) = (
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
while let Some(event) = events.next().await {
|
||||||
|
match event {
|
||||||
|
PtyEvent::Folder(folder) => folders.push(folder),
|
||||||
|
PtyEvent::Mark(mark) => marks.push(mark),
|
||||||
|
PtyEvent::Status(status) => statuses.push(Some(status)),
|
||||||
|
PtyEvent::Reset => {
|
||||||
|
said.push("reset".to_string());
|
||||||
|
statuses.push(None);
|
||||||
|
}
|
||||||
|
PtyEvent::Title(title) => said.push(format!("title {title}")),
|
||||||
|
PtyEvent::Ask(ask) => {
|
||||||
|
if let Ask::Show(notice) = &ask {
|
||||||
|
said.push(format!("show {}", notice.title));
|
||||||
|
}
|
||||||
|
asks.push(ask);
|
||||||
|
}
|
||||||
|
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
||||||
|
PtyEvent::Term(Event::Exit) => break,
|
||||||
|
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(folders, marks, statuses, asks, said, success)
|
||||||
|
});
|
||||||
|
let term = pty.term.lock();
|
||||||
|
let row = &term.grid()[Line(0)];
|
||||||
|
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
|
||||||
|
let prompt = (0..24).filter(|&column| {
|
||||||
|
row[Column(column)]
|
||||||
|
.hyperlink()
|
||||||
|
.is_some_and(|link| link.uri() == crate::term::prompts::LINK)
|
||||||
|
});
|
||||||
|
Ran {
|
||||||
|
folders,
|
||||||
|
marks,
|
||||||
|
statuses,
|
||||||
|
asks,
|
||||||
|
said,
|
||||||
|
text: text.trim_end().to_string(),
|
||||||
|
prompt: prompt.collect(),
|
||||||
|
success,
|
||||||
|
pid: pty.pid,
|
||||||
|
columns: term.columns(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
|
||||||
|
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
|
||||||
|
assert!(ran.folders.is_empty());
|
||||||
|
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
|
||||||
|
assert!(ran.success);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_7_reaches_the_session_as_a_folder() {
|
||||||
|
let ran = run(
|
||||||
|
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
|
||||||
|
(40, 4),
|
||||||
|
);
|
||||||
|
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
|
||||||
|
assert_eq!(ran.text, "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_133_reaches_the_session_as_marks() {
|
||||||
|
let ran = run(
|
||||||
|
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
|
||||||
|
(40, 4),
|
||||||
|
);
|
||||||
|
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
|
||||||
|
assert_eq!(ran.marks, marks);
|
||||||
|
assert_eq!(ran.text, ">x");
|
||||||
|
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_7501_and_a_full_reset_reach_the_session_in_order() {
|
||||||
|
let ran = run(
|
||||||
|
"printf '\\033]7501;state=working:progress=40\\033\\\\\\033]7501;?\\007\\033cx'",
|
||||||
|
(40, 4),
|
||||||
|
);
|
||||||
|
let working = Record {
|
||||||
|
state: State::Working,
|
||||||
|
kind: None,
|
||||||
|
progress: Some(40),
|
||||||
|
app: None,
|
||||||
|
title: None,
|
||||||
|
msg: None,
|
||||||
|
};
|
||||||
|
let said = [
|
||||||
|
Some(Status::Report(String::new(), working)),
|
||||||
|
Some(Status::Query),
|
||||||
|
None,
|
||||||
|
];
|
||||||
|
assert_eq!(ran.statuses, said);
|
||||||
|
assert_eq!(ran.text, "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notifications_and_progress_reach_the_session_in_order() {
|
||||||
|
let ran = run(
|
||||||
|
"printf '\\033]9;Hello\\007\\033]777;notify;T;B\\007\\033]99;i=a:d=0;Hel\\033\\\\\\033]99;i=a;lo\\033\\\\\\033]9;4;1;40\\007x'",
|
||||||
|
(40, 4),
|
||||||
|
);
|
||||||
|
let titles: Vec<String> = ran
|
||||||
|
.asks
|
||||||
|
.iter()
|
||||||
|
.map(|ask| match ask {
|
||||||
|
Ask::Show(notice) => format!("{}/{}", notice.title, notice.body),
|
||||||
|
other => format!("{other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
titles,
|
||||||
|
["Hello/", "T/B", "Hello/", "Progress(Set(Some(40)))"]
|
||||||
|
);
|
||||||
|
assert_eq!(ran.text, "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_99_gathers_a_full_encoded_chunk_and_the_lines_base64_carries() {
|
||||||
|
let ran = run(
|
||||||
|
"printf '\\033]99;i=m:d=0:e=1;'; yes YWFh | head -n 1024 | tr -d '\\n'; printf '\\033\\\\\\033]99;i=m:d=0:e=1;RU5E\\033\\\\\\033]99;i=m:p=body:e=1;TGluZSAxCkxpbmUgMg==\\007x'",
|
||||||
|
(40, 4),
|
||||||
|
);
|
||||||
|
let [Ask::Show(notice)] = ran.asks.as_slice() else {
|
||||||
|
panic!("one notification: {:?}", ran.asks);
|
||||||
|
};
|
||||||
|
assert_eq!(notice.title, format!("{}END", "a".repeat(3072)));
|
||||||
|
assert_eq!(notice.body, "Line 1\nLine 2");
|
||||||
|
assert_eq!(ran.text, "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn titles_reach_the_session_in_their_place_among_notifications() {
|
||||||
|
let long = "y".repeat(5000);
|
||||||
|
let many: Vec<String> = (1..=17).map(|at| at.to_string()).collect();
|
||||||
|
let parts = [
|
||||||
|
"\\033]2;Old\\007".to_string(),
|
||||||
|
"\\033[22t".into(),
|
||||||
|
"\\033]2;New\\007".into(),
|
||||||
|
"\\033]9;a\\007".into(),
|
||||||
|
"\\033[23t".into(),
|
||||||
|
"\\033]9;b\\007".into(),
|
||||||
|
"\\033c".into(),
|
||||||
|
"\\033]9;c\\007".into(),
|
||||||
|
format!("\\033]2;{long}\\007"),
|
||||||
|
format!("\\033]2;{}\\007", many.join(";")),
|
||||||
|
"\\033]9;d\\007".into(),
|
||||||
|
];
|
||||||
|
let whole = run(&format!("printf '{}x'", parts.concat()), (40, 4));
|
||||||
|
let split: Vec<String> = parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| format!("printf '{part}'; sleep 0.1"))
|
||||||
|
.collect();
|
||||||
|
let split = run(&format!("{}; printf x", split.join("; ")), (40, 4));
|
||||||
|
let said = [
|
||||||
|
"title Old".to_string(),
|
||||||
|
"title New".into(),
|
||||||
|
"show a".into(),
|
||||||
|
"title Old".into(),
|
||||||
|
"show b".into(),
|
||||||
|
"reset".into(),
|
||||||
|
"show c".into(),
|
||||||
|
format!("title {long}"),
|
||||||
|
format!("title {}", many[..15].join(";")),
|
||||||
|
"show d".into(),
|
||||||
|
];
|
||||||
|
assert_eq!(whole.said, said, "one write");
|
||||||
|
assert_eq!(split.said, said, "a write a sequence");
|
||||||
|
assert_eq!((whole.text.as_str(), split.text.as_str()), ("x", "x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_escape_of_controls_keeps_the_pty_reading() {
|
||||||
|
let ran = run(
|
||||||
|
"printf '\\033'; head -c 3000000 /dev/zero; printf '[0mREADY'",
|
||||||
|
(40, 4),
|
||||||
|
);
|
||||||
|
assert_eq!(ran.text, "READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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));
|
||||||
|
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
|
||||||
|
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
|
||||||
|
fn close_after_ready(script: &str, reaped: &str) {
|
||||||
|
crate::testlog::init();
|
||||||
|
let launch = Launch {
|
||||||
|
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||||
|
cwd: std::env::temp_dir(),
|
||||||
|
env: Vec::new(),
|
||||||
|
reports: false,
|
||||||
|
};
|
||||||
|
let (pty, mut events) =
|
||||||
|
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||||
|
futures::executor::block_on(async {
|
||||||
|
while let Some(event) = events.next().await {
|
||||||
|
let term = pty.term.lock();
|
||||||
|
let text: String = (0..5)
|
||||||
|
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
||||||
|
.collect();
|
||||||
|
drop(term);
|
||||||
|
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let pid = pty.pid;
|
||||||
|
drop(pty);
|
||||||
|
let hung_up = format!("pty {pid}: hung up, {reaped}");
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
while !crate::testlog::logged(&hung_up) {
|
||||||
|
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
|
||||||
|
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
|
||||||
|
assert!(!alive, "the program was reaped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_a_session_hangs_up_its_program() {
|
||||||
|
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
|
||||||
|
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
|
||||||
|
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_folder_stops_the_start() {
|
||||||
|
let launch = Launch {
|
||||||
|
program: None,
|
||||||
|
cwd: "/nonexistent/ely".into(),
|
||||||
|
env: Vec::new(),
|
||||||
|
reports: false,
|
||||||
|
};
|
||||||
|
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
||||||
|
.err()
|
||||||
|
.expect("a missing folder is an error");
|
||||||
|
assert!(error.to_string().contains("/nonexistent/ely"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_that_cannot_be_entered_stops_the_start() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&folder).expect("a temp folder");
|
||||||
|
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
|
||||||
|
let launch = Launch {
|
||||||
|
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
||||||
|
cwd: folder.clone(),
|
||||||
|
env: Vec::new(),
|
||||||
|
reports: false,
|
||||||
|
};
|
||||||
|
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
||||||
|
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
||||||
|
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
||||||
|
let error = started.err().expect("a folder it cannot enter is an error");
|
||||||
|
let error = error.to_string();
|
||||||
|
assert!(error.contains("Permission denied"), "{error}");
|
||||||
|
assert!(error.contains(&folder.display().to_string()), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
|
||||||
|
let size = fit(70_000, 24, (8, 16));
|
||||||
|
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
|
||||||
|
let size = fit(0, 0, (8, 16));
|
||||||
|
assert_eq!((size.num_cols, size.num_lines), (2, 1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use gpui::{Context, SharedString, SystemNotification};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
notify::{self, Ask, CLOSES, Notice},
|
||||||
|
status::{self, Record, State, Status},
|
||||||
|
view::{TerminalEvent, TerminalView},
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
sessions::{line, plain},
|
||||||
|
workspace::TITLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One notification a session every 3 seconds at most.
|
||||||
|
const GAP: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// The notifications a session keeps for presses and answers; past them the oldest goes.
|
||||||
|
const POSTED: usize = 64;
|
||||||
|
|
||||||
|
/// The system notifications a session posted and has not seen go.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Notices {
|
||||||
|
posted: Vec<Posted>,
|
||||||
|
/// When it last posted.
|
||||||
|
last: Option<Instant>,
|
||||||
|
/// Posts so far: they tell apart notifications without an id, and an expiry finds its own post.
|
||||||
|
count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification posted.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
struct Posted {
|
||||||
|
tag: SharedString,
|
||||||
|
/// The OSC 99 id its program gave; none for the session's own and for OSC 9 and 777.
|
||||||
|
id: Option<String>,
|
||||||
|
focus: bool,
|
||||||
|
report: bool,
|
||||||
|
count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notices {
|
||||||
|
/// Program `pid`'s turn to post at `now`, kept as posted; none within 3 seconds of its last.
|
||||||
|
fn turn(&mut self, pid: u32, asked: Option<&Notice>, now: Instant) -> Option<Posted> {
|
||||||
|
if self.last.is_some_and(|last| now.duration_since(last) < GAP) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.last = Some(now);
|
||||||
|
self.count += 1;
|
||||||
|
let id = asked.and_then(|notice| notice.id.clone());
|
||||||
|
// The session's own share a tag, so the last takes its place; an OSC 99 id shares its own.
|
||||||
|
let tag = match (asked, &id) {
|
||||||
|
(None, _) => format!("{pid}"),
|
||||||
|
(Some(_), Some(id)) => format!("{pid}:{id}"),
|
||||||
|
(Some(_), None) => format!("{pid}#{}", self.count),
|
||||||
|
};
|
||||||
|
let posted = Posted {
|
||||||
|
tag: tag.into(),
|
||||||
|
id,
|
||||||
|
focus: asked.is_none_or(|notice| notice.focus),
|
||||||
|
report: asked.is_some_and(|notice| notice.report),
|
||||||
|
count: self.count,
|
||||||
|
};
|
||||||
|
self.posted.retain(|old| old.tag != posted.tag);
|
||||||
|
if self.posted.len() == POSTED {
|
||||||
|
let gone = self.posted.remove(0);
|
||||||
|
log::debug!(
|
||||||
|
"pty {pid}: notification {} goes from the {POSTED} kept",
|
||||||
|
gone.tag
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.posted.push(posted.clone());
|
||||||
|
Some(posted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes notification `tag` out; with `count`, only that post of it.
|
||||||
|
fn take(&mut self, tag: &str, count: Option<u64>) -> Option<Posted> {
|
||||||
|
let at = self.posted.iter().position(|posted| {
|
||||||
|
posted.tag == tag && count.is_none_or(|count| posted.count == count)
|
||||||
|
})?;
|
||||||
|
Some(self.posted.remove(at))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a post after post `count` holds `tag`.
|
||||||
|
fn replaced(&self, tag: &str, count: u64) -> bool {
|
||||||
|
self.posted
|
||||||
|
.iter()
|
||||||
|
.any(|posted| posted.tag == tag && posted.count > count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ids of the notifications still alive: posted, and not pressed, closed or expired.
|
||||||
|
fn alive(&self) -> Vec<&str> {
|
||||||
|
self.posted
|
||||||
|
.iter()
|
||||||
|
.filter_map(|posted| posted.id.as_deref())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalView {
|
||||||
|
/// Keeps a program's report, drops what it clears, and answers its query.
|
||||||
|
pub(super) fn status(&mut self, status: Status) {
|
||||||
|
match status {
|
||||||
|
Status::Report(id, record) => self.records.report(id, record),
|
||||||
|
Status::Clear(id) => self.records.clear(id.as_deref()),
|
||||||
|
Status::Query => self.pty.write(status::REPLY.as_bytes().to_vec()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps progress, answers the query and alive in order, and posts and closes notifications.
|
||||||
|
pub(super) fn ask(&mut self, ask: Ask, cx: &mut Context<Self>) {
|
||||||
|
match ask {
|
||||||
|
Ask::Progress(progress) => self.records.progress(progress),
|
||||||
|
Ask::Query(id) => self.pty.write(notify::answer(&id).into_bytes()),
|
||||||
|
Ask::Alive(id) => {
|
||||||
|
let alive = self.notices.alive();
|
||||||
|
log::debug!("pty {}: notifications alive: {alive:?}", self.pty.pid);
|
||||||
|
self.pty.write(notify::alive(&id, &alive).into_bytes());
|
||||||
|
}
|
||||||
|
Ask::Show(notice) => self.post(said(¬ice), Some(¬ice), cx),
|
||||||
|
Ask::Close(id) => self.close(&id, cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes the state its row shows; a change goes to the owner, and one to waiting, done or error posts.
|
||||||
|
pub(super) fn restate(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.records.restate() {
|
||||||
|
let state = self.records.shows();
|
||||||
|
log::info!("terminal {}: its row shows {state:?}", self.pty.pid);
|
||||||
|
cx.emit(TerminalEvent::Shows(state));
|
||||||
|
self.tell_state(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// herdr's agents in place of the last ones; an open hover reads them, so the view draws again.
|
||||||
|
pub(super) fn agents(&mut self, agents: &[(String, Record)], cx: &mut Context<Self>) {
|
||||||
|
if self.records.herdr(agents) {
|
||||||
|
self.restate(cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts the state its row shows when it turned to waiting, done or error.
|
||||||
|
fn tell_state(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let body = match self.records.shown() {
|
||||||
|
Some((id, record)) if tells(record.state) => line(&self.records, id, record),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
self.post(body, None, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command the shell ran ended with `status`, which a notification tells.
|
||||||
|
pub(super) fn finished(&mut self, status: Option<i32>, cx: &mut Context<Self>) {
|
||||||
|
if self.ended != status {
|
||||||
|
let ended = status.map_or("with no status".into(), |code| format!("with {code}"));
|
||||||
|
log::info!("terminal {}: its last command ended {ended}", self.pty.pid);
|
||||||
|
}
|
||||||
|
self.ended = status;
|
||||||
|
// Only a command C started posts its end, so a prompt bash 3.2 draws again posts nothing.
|
||||||
|
if std::mem::take(&mut self.ran) {
|
||||||
|
self.post(finished(status), None, cx);
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"terminal {}: an end without its start posts nothing",
|
||||||
|
self.pty.pid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts `body` while the window is not active or the session hidden, one every 3 seconds at most.
|
||||||
|
fn post(&mut self, body: String, asked: Option<&Notice>, cx: &mut Context<Self>) {
|
||||||
|
let pid = self.pty.pid;
|
||||||
|
if !unseen(self.front, self.active) {
|
||||||
|
log::debug!("terminal {pid}: in view, so it posts no {body:?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(posted) = self.notices.turn(pid, asked, Instant::now()) else {
|
||||||
|
log::info!("terminal {pid}: posted within 3 s, so it posts no {body:?}");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Its title names the session, so no program speaks as another.
|
||||||
|
let title = plain(if self.title.is_empty() {
|
||||||
|
TITLE
|
||||||
|
} else {
|
||||||
|
&self.title
|
||||||
|
});
|
||||||
|
log::info!("terminal {pid}: posts {}: {title:?}, {body:?}", posted.tag);
|
||||||
|
// Linux's notification daemons read the body as markup.
|
||||||
|
let body = if cfg!(target_os = "linux") {
|
||||||
|
escape(&body)
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
};
|
||||||
|
cx.show_system_notification(SystemNotification {
|
||||||
|
tag: posted.tag.clone(),
|
||||||
|
title: title.into(),
|
||||||
|
body: body.into(),
|
||||||
|
actions: Vec::new(),
|
||||||
|
});
|
||||||
|
let Some(notice) = asked else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// gpui hears of no close, so a program that asks hears that none is tracked.
|
||||||
|
if notice.close {
|
||||||
|
let id = posted.id.as_deref().unwrap_or("0");
|
||||||
|
self.pty.write(notify::untracked(id).into_bytes());
|
||||||
|
}
|
||||||
|
match notice.expire {
|
||||||
|
Some(ms) if CLOSES => self.expire(posted, ms, cx),
|
||||||
|
Some(_) => log::debug!(
|
||||||
|
"terminal {pid}: gpui closes none on Linux, so {} stays",
|
||||||
|
posted.tag
|
||||||
|
),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes `posted` back once `ms` run out, though its session be gone, unless a later post holds its tag.
|
||||||
|
fn expire(&mut self, posted: Posted, ms: u32, cx: &mut Context<Self>) {
|
||||||
|
let (tag, count) = (posted.tag, posted.count);
|
||||||
|
cx.spawn(async move |view, cx| {
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_millis(u64::from(ms)))
|
||||||
|
.await;
|
||||||
|
let held = view.update(cx, |view, cx| {
|
||||||
|
if view.notices.replaced(&tag, count) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if view.notices.take(&tag, Some(count)).is_some() {
|
||||||
|
log::info!("terminal {}: notification {tag} expires", view.pty.pid);
|
||||||
|
}
|
||||||
|
cx.dismiss_system_notification(&tag);
|
||||||
|
});
|
||||||
|
// The system keeps what a closed session posted, so its expiry still takes it.
|
||||||
|
if held.is_err() {
|
||||||
|
log::info!("app: notification {tag} expires after its session");
|
||||||
|
cx.update(|cx| cx.dismiss_system_notification(&tag));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A program's close of notification `id`, where gpui can close one.
|
||||||
|
fn close(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||||
|
let tag = format!("{}:{id}", self.pty.pid);
|
||||||
|
if !CLOSES {
|
||||||
|
log::debug!(
|
||||||
|
"terminal {}: gpui closes none on Linux, so {tag} stays",
|
||||||
|
self.pty.pid
|
||||||
|
);
|
||||||
|
} else if self.notices.take(&tag, None).is_some() {
|
||||||
|
log::info!("terminal {}: closes notification {tag}", self.pty.pid);
|
||||||
|
cx.dismiss_system_notification(&tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A press on notification `tag`, if this session posted it: a program that asked hears of it; whether the session shows.
|
||||||
|
pub fn pressed(&mut self, tag: &str) -> Option<bool> {
|
||||||
|
let pid = self.pty.pid;
|
||||||
|
let Some(posted) = self.notices.take(tag, None) else {
|
||||||
|
// A twin Linux stacked, pressed before, or one past the 64 kept, still shows its session.
|
||||||
|
let own = owns(pid, tag);
|
||||||
|
if own {
|
||||||
|
log::info!(
|
||||||
|
"terminal {pid}: notification {tag} pressed, its record gone, so it tells no program"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return own.then_some(true);
|
||||||
|
};
|
||||||
|
log::info!("terminal {pid}: notification {tag} pressed");
|
||||||
|
if posted.report {
|
||||||
|
let id = posted.id.as_deref().unwrap_or("0");
|
||||||
|
self.pty.write(notify::activated(id).into_bytes());
|
||||||
|
}
|
||||||
|
Some(posted.focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a session posts: not while it is the session shown in an active window.
|
||||||
|
fn unseen(front: bool, active: bool) -> bool {
|
||||||
|
!(front && active)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the row's turn to `state` posts: waiting, done and error do.
|
||||||
|
fn tells(state: State) -> bool {
|
||||||
|
matches!(state, State::Blocked | State::Done | State::Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether program `pid`'s session posted notification `tag`: its own, `PID`, else `PID:ID` or `PID#N`.
|
||||||
|
fn owns(pid: u32, tag: &str) -> bool {
|
||||||
|
tag.strip_prefix(&pid.to_string())
|
||||||
|
.is_some_and(|rest| rest.is_empty() || rest.starts_with([':', '#']))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` as markup reads it as text.
|
||||||
|
fn escape(text: &str) -> String {
|
||||||
|
text.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a command's end says: it finished, or the status it failed with.
|
||||||
|
fn finished(status: Option<i32>) -> String {
|
||||||
|
match status {
|
||||||
|
Some(code) if code != 0 => format!("Command exited with {code}"),
|
||||||
|
_ => "Command finished".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A program's notification as posted: its title, then its body, each line shown as text.
|
||||||
|
fn said(notice: &Notice) -> String {
|
||||||
|
let lines = notice.title.lines().chain(notice.body.lines());
|
||||||
|
let shown: Vec<String> = lines.map(plain).filter(|line| !line.is_empty()).collect();
|
||||||
|
shown.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn notice(id: Option<&str>, title: &str, body: &str) -> Notice {
|
||||||
|
Notice {
|
||||||
|
id: id.map(String::from),
|
||||||
|
title: title.into(),
|
||||||
|
body: body.into(),
|
||||||
|
focus: true,
|
||||||
|
report: false,
|
||||||
|
close: false,
|
||||||
|
expire: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_session_posts_once_in_3_seconds_its_own_under_one_tag() {
|
||||||
|
let mut notices = Notices::default();
|
||||||
|
let start = Instant::now();
|
||||||
|
let first = notices.turn(7, None, start).expect("the first post");
|
||||||
|
assert_eq!((&*first.tag, first.focus, first.report), ("7", true, false));
|
||||||
|
assert_eq!(notices.turn(7, None, start + Duration::from_secs(2)), None);
|
||||||
|
let again = notices.turn(7, None, start + GAP).expect("past the gap");
|
||||||
|
assert_eq!(&*again.tag, "7");
|
||||||
|
assert_eq!(notices.posted.len(), 1, "the last takes its place");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_program_s_notifications_go_by_their_id_and_live_until_taken() {
|
||||||
|
let mut notices = Notices::default();
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut asked = notice(Some("build"), "T", "");
|
||||||
|
asked.report = true;
|
||||||
|
asked.focus = false;
|
||||||
|
let posted = notices.turn(7, Some(&asked), start).expect("a post");
|
||||||
|
assert_eq!(
|
||||||
|
(&*posted.tag, posted.focus, posted.report),
|
||||||
|
("7:build", false, true)
|
||||||
|
);
|
||||||
|
let untagged = notices
|
||||||
|
.turn(7, Some(¬ice(None, "T", "")), start + GAP)
|
||||||
|
.expect("a post");
|
||||||
|
assert!(untagged.tag.starts_with("7#"), "{}", untagged.tag);
|
||||||
|
assert_eq!(notices.alive(), ["build"]);
|
||||||
|
let update = notices
|
||||||
|
.turn(7, Some(&asked), start + GAP * 2)
|
||||||
|
.expect("an update");
|
||||||
|
assert!(
|
||||||
|
notices.replaced(&posted.tag, posted.count),
|
||||||
|
"a later post holds the tag"
|
||||||
|
);
|
||||||
|
assert!(!notices.replaced(&update.tag, update.count));
|
||||||
|
assert_eq!(
|
||||||
|
notices.take("7:build", Some(posted.count)),
|
||||||
|
None,
|
||||||
|
"an expiry takes its own post only"
|
||||||
|
);
|
||||||
|
assert_eq!(notices.take("7:build", Some(update.count)), Some(update));
|
||||||
|
assert!(notices.alive().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_session_keeps_its_last_64_notifications() {
|
||||||
|
let mut notices = Notices::default();
|
||||||
|
let start = Instant::now();
|
||||||
|
for at in 0..=POSTED as u32 {
|
||||||
|
let asked = notice(Some(format!("n{at}").as_str()), "T", "");
|
||||||
|
notices
|
||||||
|
.turn(7, Some(&asked), start + GAP * at)
|
||||||
|
.expect("a post");
|
||||||
|
}
|
||||||
|
assert_eq!(notices.posted.len(), POSTED);
|
||||||
|
assert!(!notices.alive().contains(&"n0"), "the oldest went");
|
||||||
|
assert!(notices.alive().contains(&"n64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_notification_says_its_text_as_text() {
|
||||||
|
let asked = notice(None, "Build\u{202e} done", "Line 1\n\nLine 2\u{200b}");
|
||||||
|
assert_eq!(said(&asked), "Build done\nLine 1\nLine 2");
|
||||||
|
assert_eq!(finished(Some(0)), "Command finished");
|
||||||
|
assert_eq!(finished(None), "Command finished");
|
||||||
|
assert_eq!(finished(Some(2)), "Command exited with 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_session_posts_unless_it_is_the_one_shown_in_an_active_window() {
|
||||||
|
assert!(!unseen(true, true));
|
||||||
|
assert!(unseen(true, false), "the window inactive");
|
||||||
|
assert!(unseen(false, true), "the session hidden");
|
||||||
|
assert!(unseen(false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waiting_done_and_error_post_and_work_and_rest_do_not() {
|
||||||
|
for (state, posts) in [
|
||||||
|
(State::Blocked, true),
|
||||||
|
(State::Done, true),
|
||||||
|
(State::Error, true),
|
||||||
|
(State::Working, false),
|
||||||
|
(State::Idle, false),
|
||||||
|
] {
|
||||||
|
assert_eq!(tells(state), posts, "{state:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tag_names_the_session_that_posted_it() {
|
||||||
|
for tag in ["12", "12:build", "12#3"] {
|
||||||
|
assert!(owns(12, tag), "{tag}");
|
||||||
|
}
|
||||||
|
for tag in ["123", "1", "123:build", "13#3", ""] {
|
||||||
|
assert!(!owns(12, tag), "{tag}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_body_goes_as_markup_s_text() {
|
||||||
|
assert_eq!(
|
||||||
|
escape("<b>a</b> & <a href=x>"),
|
||||||
|
"<b>a</b> & <a href=x>"
|
||||||
|
);
|
||||||
|
assert_eq!(escape("plain"), "plain");
|
||||||
|
}
|
||||||
+167
-11
@@ -1,4 +1,7 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::{
|
||||||
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use alacritty_terminal::{event::Event, grid::Dimensions, index::Line};
|
use alacritty_terminal::{event::Event, grid::Dimensions, index::Line};
|
||||||
use futures::channel::mpsc::TryRecvError;
|
use futures::channel::mpsc::TryRecvError;
|
||||||
@@ -6,7 +9,7 @@ use futures::channel::mpsc::TryRecvError;
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
term::{Events, Pty, PtyEvent, config, spawn},
|
term::{Events, Pty, PtyEvent, config, marks::Mark, spawn},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A folder name with a space, a percent, a bell and a newline.
|
/// A folder name with a space, a percent, a bell and a newline.
|
||||||
@@ -43,6 +46,9 @@ struct Run {
|
|||||||
start: PathBuf,
|
start: PathBuf,
|
||||||
folders: Vec<PathBuf>,
|
folders: Vec<PathBuf>,
|
||||||
titles: Vec<String>,
|
titles: Vec<String>,
|
||||||
|
marks: Vec<Mark>,
|
||||||
|
/// Lines typed, each followed by a prompt of its own.
|
||||||
|
lines: usize,
|
||||||
deadline: Instant,
|
deadline: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +56,12 @@ impl Run {
|
|||||||
/// Starts `shell` in a fresh folder; `files` go under its HOME first.
|
/// Starts `shell` in a fresh folder; `files` go under its HOME first.
|
||||||
fn start(shell: &str, files: &[(&str, &str)]) -> Self {
|
fn start(shell: &str, files: &[(&str, &str)]) -> Self {
|
||||||
crate::testlog::init();
|
crate::testlog::init();
|
||||||
|
// Two tests may run one shell at once.
|
||||||
|
static RUNS: AtomicUsize = AtomicUsize::new(0);
|
||||||
let root = folder(&format!(
|
let root = folder(&format!(
|
||||||
"shell-{}",
|
"shell-{}-{}",
|
||||||
shell.rsplit('/').next().expect("a name")
|
shell.rsplit('/').next().expect("a name"),
|
||||||
|
RUNS.fetch_add(1, Ordering::Relaxed)
|
||||||
));
|
));
|
||||||
let (dir, home, start) = (
|
let (dir, home, start) = (
|
||||||
root.join("integration"),
|
root.join("integration"),
|
||||||
@@ -79,6 +88,8 @@ impl Run {
|
|||||||
start,
|
start,
|
||||||
folders: Vec::new(),
|
folders: Vec::new(),
|
||||||
titles: Vec::new(),
|
titles: Vec::new(),
|
||||||
|
marks: Vec::new(),
|
||||||
|
lines: 0,
|
||||||
deadline: Instant::now() + Duration::from_secs(30),
|
deadline: Instant::now() + Duration::from_secs(30),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,15 +99,17 @@ impl Run {
|
|||||||
while !done(self) {
|
while !done(self) {
|
||||||
assert!(
|
assert!(
|
||||||
Instant::now() < self.deadline,
|
Instant::now() < self.deadline,
|
||||||
"{}: no {what}; folders {:?}, titles {:?}, screen\n{}",
|
"{}: no {what}; folders {:?}, titles {:?}, marks {:?}, screen\n{}",
|
||||||
self.shell,
|
self.shell,
|
||||||
self.folders,
|
self.folders,
|
||||||
self.titles,
|
self.titles,
|
||||||
|
self.marks,
|
||||||
self.screen()
|
self.screen()
|
||||||
);
|
);
|
||||||
match self.events.try_recv() {
|
match self.events.try_recv() {
|
||||||
Ok(PtyEvent::Folder(folder)) => self.folders.push(folder),
|
Ok(PtyEvent::Folder(folder)) => self.folders.push(folder),
|
||||||
Ok(PtyEvent::Term(Event::Title(title))) => self.titles.push(title),
|
Ok(PtyEvent::Title(title)) => self.titles.push(title),
|
||||||
|
Ok(PtyEvent::Mark(mark)) => self.marks.push(mark),
|
||||||
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
|
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
|
||||||
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
|
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
|
||||||
panic!("{} ended, {status}:\n{}", self.shell, self.screen())
|
panic!("{} ended, {status}:\n{}", self.shell, self.screen())
|
||||||
@@ -112,6 +125,7 @@ impl Run {
|
|||||||
fn send(&mut self, line: &str) {
|
fn send(&mut self, line: &str) {
|
||||||
self.until("line editor", |run| run.pty.reads_keys());
|
self.until("line editor", |run| run.pty.reads_keys());
|
||||||
self.pty.write(format!("{line}\r").into_bytes());
|
self.pty.write(format!("{line}\r").into_bytes());
|
||||||
|
self.lines += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shows(&self, text: &str) -> bool {
|
fn shows(&self, text: &str) -> bool {
|
||||||
@@ -132,6 +146,10 @@ impl Run {
|
|||||||
|
|
||||||
/// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder.
|
/// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder.
|
||||||
fn walk(&mut self, probe: &str) {
|
fn walk(&mut self, probe: &str) {
|
||||||
|
let first = [Mark::Prompt, Mark::Input];
|
||||||
|
self.until("the first prompt's marks", |run| {
|
||||||
|
run.marks.starts_with(&first)
|
||||||
|
});
|
||||||
let start = self.start.clone();
|
let start = self.start.clone();
|
||||||
self.send(probe);
|
self.send(probe);
|
||||||
self.until("first folder", |run| run.folders.contains(&start));
|
self.until("first folder", |run| run.folders.contains(&start));
|
||||||
@@ -142,6 +160,23 @@ impl Run {
|
|||||||
self.until("inner folder", |run| run.folders.contains(&inner));
|
self.until("inner folder", |run| run.folders.contains(&inner));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Types `line` once the prompt before it is marked, then waits for the marks it ends with.
|
||||||
|
fn ends(&mut self, line: &str, marks: &[Mark]) {
|
||||||
|
let prompts = self.lines + 1;
|
||||||
|
self.until("the prompt's marks", |run| {
|
||||||
|
run.marks
|
||||||
|
.iter()
|
||||||
|
.filter(|mark| **mark == Mark::Input)
|
||||||
|
.count()
|
||||||
|
>= prompts
|
||||||
|
});
|
||||||
|
let from = self.marks.len();
|
||||||
|
self.send(line);
|
||||||
|
self.until(&format!("the marks of {line}"), |run| {
|
||||||
|
run.marks[from..].ends_with(marks)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn finish(mut self) {
|
fn finish(mut self) {
|
||||||
self.send("exit");
|
self.send("exit");
|
||||||
loop {
|
loop {
|
||||||
@@ -163,6 +198,35 @@ impl Run {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this bash has PS0, 4.4 on, so it marks a command's start.
|
||||||
|
fn bash_ps0(shell: &str) -> bool {
|
||||||
|
let version = std::process::Command::new(shell)
|
||||||
|
.args(["-c", "echo $((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"])
|
||||||
|
.output()
|
||||||
|
.expect("bash runs");
|
||||||
|
let version: u32 = String::from_utf8(version.stdout)
|
||||||
|
.expect("utf-8")
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.expect("a version");
|
||||||
|
version >= 404
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marks a bash command that ends with `status` leaves; bash 3.2 marks no start.
|
||||||
|
fn bash_ended(ps0: bool, status: i32) -> Vec<Mark> {
|
||||||
|
ended(status)[usize::from(!ps0)..].to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marks a command that ends with `status` leaves: its output, its end, the next prompt.
|
||||||
|
fn ended(status: i32) -> [Mark; 4] {
|
||||||
|
[
|
||||||
|
Mark::Output,
|
||||||
|
Mark::Done(Some(status)),
|
||||||
|
Mark::Prompt,
|
||||||
|
Mark::Input,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_scripts_install_once_and_mend_a_changed_one() {
|
fn the_scripts_install_once_and_mend_a_changed_one() {
|
||||||
let dir = folder("install");
|
let dir = folder("install");
|
||||||
@@ -193,7 +257,10 @@ fn a_script_it_cannot_read_fails_the_install() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
|
fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
|
||||||
let mut run = Run::start(&installed("zsh"), &[]);
|
// A theme that sets PS1 in its own hook, which runs after ours.
|
||||||
|
let theme =
|
||||||
|
"autoload -Uz add-zsh-hook\n_theme() { PS1='THEME %? > ' }\nadd-zsh-hook precmd _theme";
|
||||||
|
let mut run = Run::start(&installed("zsh"), &[(".zshrc", theme)]);
|
||||||
run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED");
|
run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED");
|
||||||
run.until("title", |run| {
|
run.until("title", |run| {
|
||||||
run.titles.iter().any(|title| title == "in ner%xy")
|
run.titles.iter().any(|title| title == "in ner%xy")
|
||||||
@@ -203,20 +270,86 @@ fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
|
|||||||
"{:?}",
|
"{:?}",
|
||||||
run.titles
|
run.titles
|
||||||
);
|
);
|
||||||
|
run.ends("false", &ended(1));
|
||||||
|
run.until("the theme's prompt", |run| {
|
||||||
|
run.shows("THEME 0 > false") && run.shows("THEME 1 >")
|
||||||
|
});
|
||||||
|
run.send("false | true");
|
||||||
|
run.send("echo PIPE=$pipestatus");
|
||||||
|
run.until("the pipeline's statuses", |run| run.shows("PIPE=1 0"));
|
||||||
|
run.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zsh_says_why_it_marks_no_prompt_without_prompt_percent() {
|
||||||
|
let mut run = Run::start(&installed("zsh"), &[(".zshrc", "unsetopt prompt_percent")]);
|
||||||
|
run.until("the warning", |run| {
|
||||||
|
run.shows("ELYTerminal: zsh marks no prompt while prompt_percent is off")
|
||||||
|
});
|
||||||
|
run.send("false");
|
||||||
|
let ended = [Mark::Output, Mark::Done(Some(1))];
|
||||||
|
run.until("the command's marks", |run| run.marks.ends_with(&ended));
|
||||||
|
assert!(!run.marks.contains(&Mark::Prompt), "{:?}", run.marks);
|
||||||
run.finish();
|
run.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() {
|
fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() {
|
||||||
let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'"#;
|
let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'; shopt -s extdebug"#;
|
||||||
let files = [(".bashrc", status), (".bash_profile", status)];
|
let files = [(".bashrc", status), (".bash_profile", status)];
|
||||||
let mut run = Run::start(&installed("bash"), &files);
|
let shell = installed("bash");
|
||||||
|
let ps0 = bash_ps0(&shell);
|
||||||
|
let mut run = Run::start(&shell, &files);
|
||||||
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
||||||
run.until("title", |run| {
|
run.until("title", |run| {
|
||||||
run.titles.iter().any(|title| title == "in ner%xy")
|
run.titles.iter().any(|title| title == "in ner%xy")
|
||||||
});
|
});
|
||||||
run.send("false");
|
run.ends("false", &bash_ended(ps0, 1));
|
||||||
run.until("kept status", |run| run.shows("USER_STATUS=1"));
|
run.until("kept status", |run| run.shows("USER_STATUS=1"));
|
||||||
|
assert!(run.shows("$ false"), "{}", run.screen());
|
||||||
|
// Under extdebug a DEBUG trap that fails skips its command.
|
||||||
|
run.ends("touch ran", &bash_ended(ps0, 0));
|
||||||
|
assert!(run.start.join(INNER).join("ran").exists());
|
||||||
|
run.ends("(exit 7)", &bash_ended(ps0, 7));
|
||||||
|
run.send(r#"ls / >/dev/null; echo "LAST=$_""#);
|
||||||
|
run.until("the last argument", |run| run.shows("LAST=/\n"));
|
||||||
|
run.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bash_leaves_the_user_s_trap_and_keys_alone() {
|
||||||
|
let rc = r#"_user_trap() { USER_TRAP=yes; }; trap '_user_trap "$_"' DEBUG
|
||||||
|
bind -x '"\C-t": :'"#;
|
||||||
|
let files = [(".bashrc", rc), (".bash_profile", rc)];
|
||||||
|
let shell = installed("bash");
|
||||||
|
let ps0 = bash_ps0(&shell);
|
||||||
|
let mut run = Run::start(&shell, &files);
|
||||||
|
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
||||||
|
run.ends("false", &bash_ended(ps0, 1));
|
||||||
|
// A key binding runs no command, so it marks none.
|
||||||
|
let from = run.marks.len();
|
||||||
|
run.pty.write(b"\x14".to_vec());
|
||||||
|
run.ends(r#"echo "TRAP=$USER_TRAP""#, &bash_ended(ps0, 0));
|
||||||
|
assert!(run.shows("TRAP=yes"), "{}", run.screen());
|
||||||
|
let starts = run.marks[from..]
|
||||||
|
.iter()
|
||||||
|
.filter(|mark| **mark == Mark::Output);
|
||||||
|
assert_eq!(starts.count(), usize::from(ps0), "{:?}", &run.marks[from..]);
|
||||||
|
run.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bash_says_why_it_marks_no_command_without_promptvars() {
|
||||||
|
let files = [
|
||||||
|
(".bashrc", "shopt -u promptvars"),
|
||||||
|
(".bash_profile", "shopt -u promptvars"),
|
||||||
|
];
|
||||||
|
let mut run = Run::start(&installed("bash"), &files);
|
||||||
|
run.until("the warning", |run| {
|
||||||
|
run.shows("ELYTerminal: bash marks no command while promptvars is off")
|
||||||
|
});
|
||||||
|
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
||||||
|
run.ends("false", &[Mark::Prompt, Mark::Input]);
|
||||||
run.finish();
|
run.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +361,10 @@ fn fish_loads_its_script_and_restores_its_data_dirs() {
|
|||||||
run.send(r#"echo "DIRS[$ELY_FISH_XDG_DATA_DIRS][$XDG_DATA_DIRS]""#);
|
run.send(r#"echo "DIRS[$ELY_FISH_XDG_DATA_DIRS][$XDG_DATA_DIRS]""#);
|
||||||
let restored = format!("DIRS[][{previous}]");
|
let restored = format!("DIRS[][{previous}]");
|
||||||
run.until("restored dirs", |run| run.shows(&restored));
|
run.until("restored dirs", |run| run.shows(&restored));
|
||||||
|
// The prompt hears each command's own status, none failed so far.
|
||||||
|
assert!(!run.shows("[1]"), "{}", run.screen());
|
||||||
|
run.ends("false", &ended(1));
|
||||||
|
run.until("the failed status", |run| run.shows("[1]>"));
|
||||||
run.finish();
|
run.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,8 +381,27 @@ fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() {
|
|||||||
run.until("title", |run| {
|
run.until("title", |run| {
|
||||||
run.titles.iter().any(|title| title == "in ner%xy")
|
run.titles.iter().any(|title| title == "in ner%xy")
|
||||||
});
|
});
|
||||||
run.send("/usr/bin/false");
|
run.ends("/usr/bin/false", &ended(1));
|
||||||
run.until("kept status", |run| run.shows("USER_SUCCESS=False"));
|
run.until("kept status", |run| run.shows("USER_SUCCESS=False"));
|
||||||
|
assert!(run.shows("> /usr/bin/false"), "{}", run.screen());
|
||||||
|
// A cmdlet's failure leaves no error behind, a native code may repeat, and the line's last pipeline counts.
|
||||||
|
run.ends("/usr/bin/true", &ended(0));
|
||||||
|
run.ends("Get-Item ./missing -ErrorAction Ignore", &ended(1));
|
||||||
|
run.ends("sh -c 'exit 3'", &ended(3));
|
||||||
|
run.ends("sh -c 'exit 3'", &ended(3));
|
||||||
|
run.ends("Get-Item ./missing", &ended(1));
|
||||||
|
run.ends("sh -c 'exit 7'; Get-Item ./missing", &ended(1));
|
||||||
|
run.ends(
|
||||||
|
"sh -c 'exit 7'; Get-Item ./missing -ErrorAction Ignore",
|
||||||
|
&ended(1),
|
||||||
|
);
|
||||||
|
run.ends("Get-Item ./missing; sh -c 'exit 7'", &ended(7));
|
||||||
|
// A call through a variable runs a program the line does not name.
|
||||||
|
run.send("$exe = 'sh'");
|
||||||
|
run.ends("& $exe -c 'exit 7'", &ended(7));
|
||||||
|
run.ends("& \"$exe\" -c 'exit 9'", &ended(9));
|
||||||
|
let native = "$PSNativeCommandUseErrorActionPreference = $true; sh -c 'exit 7'";
|
||||||
|
run.ends(native, &ended(7));
|
||||||
run.finish();
|
run.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
use base64::{
|
||||||
|
Engine,
|
||||||
|
alphabet::STANDARD,
|
||||||
|
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::notify::Progress;
|
||||||
|
|
||||||
|
/// What a program says it is doing, OSC 7501.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum State {
|
||||||
|
Idle,
|
||||||
|
Working,
|
||||||
|
Done,
|
||||||
|
Blocked,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a blocked program waits for.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Kind {
|
||||||
|
Permission,
|
||||||
|
Question,
|
||||||
|
Auth,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A record, as the last report for its id left it.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Record {
|
||||||
|
pub state: State,
|
||||||
|
pub kind: Option<Kind>,
|
||||||
|
pub progress: Option<u8>,
|
||||||
|
pub app: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OSC 7501 sequence, checked whole; an id is empty for the root record.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Status {
|
||||||
|
Report(String, Record),
|
||||||
|
/// Drops the record at the id and those under it; with no id, all.
|
||||||
|
Clear(Option<String>),
|
||||||
|
/// Asks which states and kinds the terminal takes.
|
||||||
|
Query,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The answer to the query: every state and kind this terminal takes.
|
||||||
|
pub const REPLY: &str =
|
||||||
|
"\x1b]7501;?:states=idle,working,done,blocked,error:kinds=permission,question,auth\x1b\\";
|
||||||
|
|
||||||
|
/// The spec's limits: a whole sequence, a key, an app, an id, its segments and levels.
|
||||||
|
const SEQUENCE: usize = 4096;
|
||||||
|
const KEY: usize = 16;
|
||||||
|
const APP: usize = 32;
|
||||||
|
const ID: usize = 128;
|
||||||
|
const SEGMENT: usize = 32;
|
||||||
|
const LEVELS: usize = 8;
|
||||||
|
|
||||||
|
/// The ids herdr's agents take, one by pane: no OSC 7501 id holds a colon, so none is taken for theirs.
|
||||||
|
pub(super) const HERDR: &str = "herdr:";
|
||||||
|
|
||||||
|
/// Records a terminal keeps; past them, the one updated longest ago goes.
|
||||||
|
pub const RECORDS: usize = 256;
|
||||||
|
|
||||||
|
/// Standard base64, its padding optional.
|
||||||
|
pub(super) const BASE64: GeneralPurpose = GeneralPurpose::new(
|
||||||
|
&STANDARD,
|
||||||
|
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The sequence in an OSC 7501 body past `7501;`, `length` bytes from OSC through ST; none for one the spec drops, logged with why.
|
||||||
|
pub fn read(body: &[u8], length: usize) -> Option<Status> {
|
||||||
|
parse(body, length)
|
||||||
|
.inspect_err(|why| log::warn!("pty: OSC 7501 dropped: {why}"))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A value's bytes, as the spec allows them.
|
||||||
|
fn valued(byte: u8) -> bool {
|
||||||
|
byte.is_ascii_alphanumeric() || b"_.,+/=-".contains(&byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id's or an app's bytes.
|
||||||
|
fn named(byte: u8) -> bool {
|
||||||
|
byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(body: &[u8], length: usize) -> Result<Status, String> {
|
||||||
|
if length > SEQUENCE {
|
||||||
|
return Err(format!("{length} bytes, over {SEQUENCE}"));
|
||||||
|
}
|
||||||
|
if body.trim_ascii() == b"?" {
|
||||||
|
return Ok(Status::Query);
|
||||||
|
}
|
||||||
|
// Every pair is checked before the last of each key wins.
|
||||||
|
let (mut state, mut id, mut kind, mut progress, mut app, mut title, mut msg) =
|
||||||
|
(None, None, None, None, None, None, None);
|
||||||
|
for pair in body.split(|&byte| byte == b':') {
|
||||||
|
// A malformed pair is skipped: no `=`, no key, or a byte outside the set.
|
||||||
|
let Some(at) = pair.iter().position(|&byte| byte == b'=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (key, value) = (pair[..at].trim_ascii(), pair[at + 1..].trim_ascii());
|
||||||
|
if key.is_empty()
|
||||||
|
|| !key.iter().all(u8::is_ascii_lowercase)
|
||||||
|
|| !value.iter().all(|&byte| valued(byte))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if key.len() > KEY {
|
||||||
|
return Err(format!("a key over {KEY} bytes"));
|
||||||
|
}
|
||||||
|
let value = String::from_utf8(value.to_vec()).expect("ASCII");
|
||||||
|
match key {
|
||||||
|
b"state" => state = Some(value),
|
||||||
|
b"id" => id = Some(self::id(&value)?),
|
||||||
|
b"kind" => kind = Some(value),
|
||||||
|
b"progress" => progress = Some(value),
|
||||||
|
b"app" if value.len() > APP => return Err(format!("an app over {APP} bytes")),
|
||||||
|
b"app" => app = Some(value),
|
||||||
|
b"title" => title = Some(text(&value, "title", 256, 192)?),
|
||||||
|
b"msg" => msg = Some(text(&value, "msg", 2732, 2048)?),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let state = match state.as_deref().ok_or("no state")? {
|
||||||
|
"idle" => State::Idle,
|
||||||
|
"working" => State::Working,
|
||||||
|
"done" => State::Done,
|
||||||
|
"blocked" => State::Blocked,
|
||||||
|
"error" => State::Error,
|
||||||
|
"clear" => return Ok(Status::Clear(id)),
|
||||||
|
other => return Err(format!("state {other} is not the spec's")),
|
||||||
|
};
|
||||||
|
let kind = match (state, kind.as_deref()) {
|
||||||
|
(State::Blocked, Some("permission")) => Some(Kind::Permission),
|
||||||
|
(State::Blocked, Some("question")) => Some(Kind::Question),
|
||||||
|
(State::Blocked, Some("auth")) => Some(Kind::Auth),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let progress = match state {
|
||||||
|
State::Working | State::Blocked => progress
|
||||||
|
.filter(|value| value.bytes().all(|byte| byte.is_ascii_digit()))
|
||||||
|
.and_then(|value| value.parse::<u8>().ok())
|
||||||
|
.filter(|&value| value <= 100),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let app = app.filter(|app| !app.is_empty() && app.bytes().all(named));
|
||||||
|
let record = Record {
|
||||||
|
state,
|
||||||
|
kind,
|
||||||
|
progress,
|
||||||
|
app,
|
||||||
|
title,
|
||||||
|
msg,
|
||||||
|
};
|
||||||
|
Ok(Status::Report(id.unwrap_or_default(), record))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id that keeps to the spec's grammar and limits.
|
||||||
|
fn id(value: &str) -> Result<String, String> {
|
||||||
|
let segments: Vec<&str> = value.split('/').collect();
|
||||||
|
let fits = value.len() <= ID
|
||||||
|
&& segments.len() <= LEVELS
|
||||||
|
&& segments
|
||||||
|
.iter()
|
||||||
|
.all(|segment| (1..=SEGMENT).contains(&segment.len()) && segment.bytes().all(named));
|
||||||
|
if fits {
|
||||||
|
Ok(value.to_string())
|
||||||
|
} else {
|
||||||
|
Err(format!("id {value} is not the spec's"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text a base64 value names, one line, within `encoded` and `decoded` bytes.
|
||||||
|
fn text(value: &str, key: &str, encoded: usize, decoded: usize) -> Result<String, String> {
|
||||||
|
if value.len() > encoded {
|
||||||
|
return Err(format!("a {key} over {encoded} bytes"));
|
||||||
|
}
|
||||||
|
let bytes = BASE64
|
||||||
|
.decode(value)
|
||||||
|
.map_err(|error| format!("{key} is no base64: {error}"))?;
|
||||||
|
if bytes.len() > decoded {
|
||||||
|
return Err(format!("a {key} over {decoded} bytes decoded"));
|
||||||
|
}
|
||||||
|
let text = String::from_utf8(bytes).map_err(|_| format!("{key} is not UTF-8"))?;
|
||||||
|
if text.chars().any(char::is_control) {
|
||||||
|
return Err(format!("{key} holds a control character"));
|
||||||
|
}
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The records one terminal keeps, by id, each with when it was last reported.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Records {
|
||||||
|
list: Vec<(String, Record, u64)>,
|
||||||
|
clock: u64,
|
||||||
|
/// A program has reported through OSC 7501 since the last full reset, so OSC 9;4 maps no more.
|
||||||
|
reported: bool,
|
||||||
|
/// The state a row showed at the last note.
|
||||||
|
shows: Option<State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Records {
|
||||||
|
/// A program's report: replaces the record at `id` whole.
|
||||||
|
pub fn report(&mut self, id: String, record: Record) {
|
||||||
|
self.reported = true;
|
||||||
|
self.put(id, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSC 9;4 as the root record, until a program reports through OSC 7501.
|
||||||
|
pub fn progress(&mut self, progress: Progress) {
|
||||||
|
if self.reported {
|
||||||
|
log::debug!("status: OSC 9;4 passed by, a program reports through OSC 7501");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let root = |state, progress| Record {
|
||||||
|
state,
|
||||||
|
kind: None,
|
||||||
|
progress,
|
||||||
|
app: None,
|
||||||
|
title: None,
|
||||||
|
msg: None,
|
||||||
|
};
|
||||||
|
match progress {
|
||||||
|
Progress::Remove => self.list.retain(|(id, ..)| !id.is_empty()),
|
||||||
|
// Paused work is work still, its progress kept.
|
||||||
|
Progress::Set(value) | Progress::Pause(value) => {
|
||||||
|
self.put(String::new(), root(State::Working, value))
|
||||||
|
}
|
||||||
|
Progress::Indeterminate => self.put(String::new(), root(State::Working, None)),
|
||||||
|
Progress::Error(_) => self.put(String::new(), root(State::Error, None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// herdr's agents in place of the last ones; true when they differ.
|
||||||
|
pub fn herdr(&mut self, agents: &[(String, Record)]) -> bool {
|
||||||
|
let held = self.list.iter().filter(|(id, ..)| id.starts_with(HERDR));
|
||||||
|
if held
|
||||||
|
.map(|(id, record, _)| (id, record))
|
||||||
|
.eq(agents.iter().map(|(id, record)| (id, record)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.list.retain(|(id, ..)| !id.starts_with(HERDR));
|
||||||
|
for (id, record) in agents {
|
||||||
|
self.put(id.clone(), record.clone());
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full reset: every record goes, and OSC 9;4 maps again.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.list.clear();
|
||||||
|
self.reported = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(&mut self, id: String, record: Record) {
|
||||||
|
self.clock += 1;
|
||||||
|
if let Some(entry) = self.list.iter_mut().find(|(at, ..)| *at == id) {
|
||||||
|
*entry = (id, record, self.clock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.list.len() == RECORDS {
|
||||||
|
let oldest = (0..self.list.len())
|
||||||
|
.min_by_key(|&at| self.list[at].2)
|
||||||
|
.expect("records");
|
||||||
|
let (gone, ..) = self.list.remove(oldest);
|
||||||
|
log::debug!("status: record {gone:?} goes, the one updated longest ago");
|
||||||
|
}
|
||||||
|
self.list.push((id, record, self.clock));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A program's clear: drops the record at `id` and every one under it; with none, all.
|
||||||
|
pub fn clear(&mut self, id: Option<&str>) {
|
||||||
|
self.reported = true;
|
||||||
|
match id {
|
||||||
|
None => self.list.clear(),
|
||||||
|
Some(id) => {
|
||||||
|
let under = format!("{id}/");
|
||||||
|
self.list
|
||||||
|
.retain(|(at, ..)| at != id && !at.starts_with(&under));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new prompt: working, blocked and idle records go, done and error stay.
|
||||||
|
pub fn prompt(&mut self) {
|
||||||
|
self.list
|
||||||
|
.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, with its id: blocked, else error, working, done or idle, the one reported last in its state.
|
||||||
|
pub fn shown(&self) -> Option<(&str, &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(|(id, record, _)| (id.as_str(), record))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes the state a row shows now; true when it changed since the last note.
|
||||||
|
pub fn restate(&mut self) -> bool {
|
||||||
|
let now = self.shown().map(|(_, record)| record.state);
|
||||||
|
let changed = now != self.shows;
|
||||||
|
self.shows = now;
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state a row showed at the last note.
|
||||||
|
pub fn shows(&self) -> Option<State> {
|
||||||
|
self.shows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::term::notify::Progress;
|
||||||
|
|
||||||
|
fn encoded(text: &[u8]) -> String {
|
||||||
|
BASE64.encode(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `body` says when its sequence is as long as its frame makes it.
|
||||||
|
fn said(body: &[u8]) -> Option<Status> {
|
||||||
|
read(body, body.len() + 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(state: State, app: Option<&str>) -> Record {
|
||||||
|
Record {
|
||||||
|
state,
|
||||||
|
kind: None,
|
||||||
|
progress: None,
|
||||||
|
app: app.map(str::to_string),
|
||||||
|
title: None,
|
||||||
|
msg: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reported(body: &str) -> Record {
|
||||||
|
match said(body.as_bytes()) {
|
||||||
|
Some(Status::Report(_, record)) => record,
|
||||||
|
other => panic!("{body}: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_spec_s_examples_read_as_it_means_them() {
|
||||||
|
let body = b"state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQsIDEgdG8gY2hhbmdlLCAwIHRvIGRlc3Ryb3k/";
|
||||||
|
let blocked = Record {
|
||||||
|
kind: Some(Kind::Permission),
|
||||||
|
msg: Some("Apply 3 to add, 1 to change, 0 to destroy?".into()),
|
||||||
|
..record(State::Blocked, Some("terraform"))
|
||||||
|
};
|
||||||
|
assert_eq!(said(body), Some(Status::Report(String::new(), blocked)));
|
||||||
|
let body = b"state=working:id=us-east:title=VVMgRWFzdA==:progress=40:msg=UHVzaGluZyBpbWFnZQ==";
|
||||||
|
let working = Record {
|
||||||
|
progress: Some(40),
|
||||||
|
title: Some("US East".into()),
|
||||||
|
msg: Some("Pushing image".into()),
|
||||||
|
..record(State::Working, None)
|
||||||
|
};
|
||||||
|
assert_eq!(said(body), Some(Status::Report("us-east".into(), working)));
|
||||||
|
assert_eq!(
|
||||||
|
said(b"state=clear:id=us-east"),
|
||||||
|
Some(Status::Clear(Some("us-east".into())))
|
||||||
|
);
|
||||||
|
assert_eq!(said(b"state=clear"), Some(Status::Clear(None)));
|
||||||
|
assert_eq!(said(b"?"), Some(Status::Query));
|
||||||
|
assert_eq!(
|
||||||
|
reported("state=done:msg=VXBncmFkZWQgMTIgcGFja2FnZXM")
|
||||||
|
.msg
|
||||||
|
.as_deref(),
|
||||||
|
Some("Upgraded 12 packages"),
|
||||||
|
"padding is optional"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_pair_is_skipped_the_last_of_a_key_wins_and_unknown_keys_pass() {
|
||||||
|
let record = reported(" junk : =x : state = idle :Key=v:app=a b:color=red:state=done:app=ok ");
|
||||||
|
assert_eq!(record, self::record(State::Done, Some("ok")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sequence_that_breaks_a_rule_drops_whole() {
|
||||||
|
let long = |key: &str, length: usize| format!("state=done:{key}={}", "a".repeat(length));
|
||||||
|
let drops = [
|
||||||
|
"app=x".to_string(),
|
||||||
|
"state=sleeping".into(),
|
||||||
|
"state=done:id=".into(),
|
||||||
|
"state=done:id=a//b".into(),
|
||||||
|
"state=done:id=a/b/c/d/e/f/g/h/i".into(),
|
||||||
|
format!("state=done:id={}", "a".repeat(33)),
|
||||||
|
format!("state=done:{}=1", "k".repeat(17)),
|
||||||
|
long("app", 33),
|
||||||
|
long("title", 257),
|
||||||
|
long("msg", 2733),
|
||||||
|
"state=done:msg=A".into(),
|
||||||
|
format!("state=done:msg={}", encoded(b"a\nb")),
|
||||||
|
format!("state=done:msg={}", encoded("a\u{85}b".as_bytes())),
|
||||||
|
format!("state=done:msg={}", encoded(&[0xff])),
|
||||||
|
format!("state=done:title={}", encoded(&[b'a'; 193])),
|
||||||
|
format!("state=done:x={}", "a".repeat(4096 - 9 - 12)),
|
||||||
|
];
|
||||||
|
for body in drops {
|
||||||
|
assert_eq!(said(body.as_bytes()), None, "{body}");
|
||||||
|
}
|
||||||
|
assert!(said(format!("state=done:x={}", "a".repeat(4096 - 9 - 14)).as_bytes()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_progress_and_app_count_only_where_the_spec_has_them() {
|
||||||
|
let done = reported("state=done:kind=auth:progress=5");
|
||||||
|
assert_eq!((done.kind, done.progress), (None, None));
|
||||||
|
let blocked = reported("state=blocked:kind=nope:progress=101");
|
||||||
|
assert_eq!((blocked.kind, blocked.progress), (None, None));
|
||||||
|
assert_eq!(reported("state=working:progress=007").progress, Some(7));
|
||||||
|
assert_eq!(reported("state=working:progress=+5").progress, None);
|
||||||
|
assert_eq!(reported("state=blocked:kind=auth").kind, Some(Kind::Auth));
|
||||||
|
assert_eq!(
|
||||||
|
reported("state=working:app=a.b+c_d-e").app.as_deref(),
|
||||||
|
Some("a.b+c_d-e")
|
||||||
|
);
|
||||||
|
assert_eq!(reported("state=working:app=a,b").app, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_replace_and_clear_as_the_spec_says() {
|
||||||
|
let mut records = Records::default();
|
||||||
|
records.report(String::new(), record(State::Working, Some("deploy")));
|
||||||
|
records.report("us-east".into(), record(State::Working, None));
|
||||||
|
records.report("us-east/push".into(), record(State::Blocked, None));
|
||||||
|
records.report("us-easter".into(), record(State::Done, Some("other")));
|
||||||
|
records.report("us-east".into(), record(State::Done, None));
|
||||||
|
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||||
|
assert_eq!(ids, ["", "us-east", "us-east/push", "us-easter"]);
|
||||||
|
let east = records
|
||||||
|
.list
|
||||||
|
.iter()
|
||||||
|
.find(|(id, ..)| id == "us-east")
|
||||||
|
.map(|(_, record, _)| record.clone());
|
||||||
|
assert_eq!(
|
||||||
|
east,
|
||||||
|
Some(record(State::Done, None)),
|
||||||
|
"a report replaces its record whole"
|
||||||
|
);
|
||||||
|
records.clear(Some("us-east"));
|
||||||
|
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
["", "us-easter"],
|
||||||
|
"clear takes the record and those under it"
|
||||||
|
);
|
||||||
|
records.clear(None);
|
||||||
|
assert!(records.list.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn herdr_s_agents_replace_their_own_records_alone() {
|
||||||
|
let mut records = Records::default();
|
||||||
|
records.report("build".into(), record(State::Working, None));
|
||||||
|
// A program's id ends at a colon, so `herdr:w1:p1` names its record `herdr`.
|
||||||
|
for body in [
|
||||||
|
&b"state=done:id=herdr/build"[..],
|
||||||
|
b"state=done:id=herdr:w1:p1",
|
||||||
|
] {
|
||||||
|
let Some(Status::Report(id, done)) = said(body) else {
|
||||||
|
panic!("a program's report");
|
||||||
|
};
|
||||||
|
records.report(id, done);
|
||||||
|
}
|
||||||
|
let ids = |records: &Records| -> Vec<String> {
|
||||||
|
records.list.iter().map(|(id, ..)| id.clone()).collect()
|
||||||
|
};
|
||||||
|
records.herdr(&[]);
|
||||||
|
assert_eq!(
|
||||||
|
ids(&records),
|
||||||
|
["build", "herdr/build", "herdr"],
|
||||||
|
"an empty snapshot takes no program's record"
|
||||||
|
);
|
||||||
|
let codex = [(
|
||||||
|
"herdr:w1:p1".to_string(),
|
||||||
|
record(State::Working, Some("codex")),
|
||||||
|
)];
|
||||||
|
records.herdr(&codex);
|
||||||
|
let clock = records.clock;
|
||||||
|
records.herdr(&codex);
|
||||||
|
assert_eq!(records.clock, clock, "the same agents change nothing");
|
||||||
|
let claude = [(
|
||||||
|
"herdr:w1:p2".to_string(),
|
||||||
|
record(State::Blocked, Some("claude")),
|
||||||
|
)];
|
||||||
|
records.herdr(&claude);
|
||||||
|
assert_eq!(
|
||||||
|
ids(&records),
|
||||||
|
["build", "herdr/build", "herdr", "herdr:w1:p2"]
|
||||||
|
);
|
||||||
|
records.herdr(&[]);
|
||||||
|
assert_eq!(
|
||||||
|
ids(&records),
|
||||||
|
["build", "herdr/build", "herdr"],
|
||||||
|
"herdr's leaving takes its own alone"
|
||||||
|
);
|
||||||
|
records.reset();
|
||||||
|
records.herdr(&claude);
|
||||||
|
assert_eq!(
|
||||||
|
ids(&records),
|
||||||
|
["herdr:w1:p2"],
|
||||||
|
"the next snapshot puts them back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_prompt_leaves_done_and_error() {
|
||||||
|
let mut records = Records::default();
|
||||||
|
for (id, state) in [
|
||||||
|
("a", State::Idle),
|
||||||
|
("b", State::Working),
|
||||||
|
("c", State::Done),
|
||||||
|
("d", State::Blocked),
|
||||||
|
("e", State::Error),
|
||||||
|
] {
|
||||||
|
records.report(id.into(), record(state, None));
|
||||||
|
}
|
||||||
|
records.prompt();
|
||||||
|
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||||
|
assert_eq!(ids, ["c", "e"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn past_the_cap_the_record_updated_longest_ago_goes() {
|
||||||
|
let mut records = Records::default();
|
||||||
|
for at in 0..RECORDS {
|
||||||
|
records.report(format!("r{at}"), record(State::Working, None));
|
||||||
|
}
|
||||||
|
records.report("r0".into(), record(State::Done, None));
|
||||||
|
records.report("new".into(), record(State::Working, None));
|
||||||
|
assert_eq!(records.list.len(), RECORDS);
|
||||||
|
assert!(
|
||||||
|
records.list.iter().all(|(id, ..)| id != "r1"),
|
||||||
|
"r1 was updated longest ago"
|
||||||
|
);
|
||||||
|
assert!(records.list.iter().any(|(id, ..)| id == "r0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clear_that_breaks_a_rule_drops_whole() {
|
||||||
|
let drops = [
|
||||||
|
"state=clear:msg=Cg==".to_string(),
|
||||||
|
"state=clear:msg=A".into(),
|
||||||
|
format!("state=clear:app={}", "a".repeat(33)),
|
||||||
|
format!("state=clear:title={}", "a".repeat(257)),
|
||||||
|
"state=clear:id=a//b".into(),
|
||||||
|
];
|
||||||
|
for body in drops {
|
||||||
|
assert_eq!(said(body.as_bytes()), None, "{body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_pair_counts_though_the_last_of_a_key_wins() {
|
||||||
|
let drops = [
|
||||||
|
format!("state=done:app={}:app=ok", "a".repeat(33)),
|
||||||
|
"state=done:title=A:title=VVMgRWFzdA==".into(),
|
||||||
|
"state=done:id=a//b:id=ok".into(),
|
||||||
|
format!("state=done:msg={}:msg=b2s=", encoded(b"a\nb")),
|
||||||
|
];
|
||||||
|
for body in drops {
|
||||||
|
assert_eq!(said(body.as_bytes()), None, "{body}");
|
||||||
|
}
|
||||||
|
assert_eq!(reported("state=done:app=x:app=y").app.as_deref(), Some("y"));
|
||||||
|
assert_eq!(
|
||||||
|
reported("state=done:app=a,b:app=ok").app.as_deref(),
|
||||||
|
Some("ok")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sequence_counts_from_osc_through_st() {
|
||||||
|
assert_eq!(read(b"state=done", 4097), None);
|
||||||
|
assert!(read(b"state=done", 4096).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_9_4_maps_to_the_root_record_until_a_program_reports() {
|
||||||
|
let root = |records: &Records| {
|
||||||
|
records
|
||||||
|
.list
|
||||||
|
.iter()
|
||||||
|
.find(|(id, ..)| id.is_empty())
|
||||||
|
.map(|(_, record, _)| (record.state, record.progress))
|
||||||
|
};
|
||||||
|
let mut records = Records::default();
|
||||||
|
records.progress(Progress::Set(Some(40)));
|
||||||
|
assert_eq!(root(&records), Some((State::Working, Some(40))));
|
||||||
|
records.progress(Progress::Pause(Some(5)));
|
||||||
|
assert_eq!(root(&records), Some((State::Working, Some(5))));
|
||||||
|
records.progress(Progress::Indeterminate);
|
||||||
|
assert_eq!(root(&records), Some((State::Working, None)));
|
||||||
|
records.progress(Progress::Error(Some(9)));
|
||||||
|
assert_eq!(root(&records), Some((State::Error, None)));
|
||||||
|
records.progress(Progress::Remove);
|
||||||
|
assert_eq!(root(&records), None);
|
||||||
|
records.report("a".into(), record(State::Idle, None));
|
||||||
|
records.progress(Progress::Set(Some(1)));
|
||||||
|
assert_eq!(root(&records), None, "a program reports through OSC 7501");
|
||||||
|
records.reset();
|
||||||
|
assert!(records.list.is_empty());
|
||||||
|
records.progress(Progress::Set(Some(2)));
|
||||||
|
assert_eq!(
|
||||||
|
root(&records),
|
||||||
|
Some((State::Working, Some(2))),
|
||||||
|
"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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_change_of_the_state_a_row_shows_is_noted() {
|
||||||
|
let mut records = Records::default();
|
||||||
|
assert!(!records.restate(), "none shows yet");
|
||||||
|
records.report(String::new(), record(State::Blocked, None));
|
||||||
|
assert!(records.restate());
|
||||||
|
records.report(String::new(), record(State::Working, None));
|
||||||
|
assert!(records.restate());
|
||||||
|
records.report(String::new(), record(State::Blocked, None));
|
||||||
|
assert!(records.restate(), "waiting again after work in one output");
|
||||||
|
assert_eq!(records.shows(), Some(State::Blocked));
|
||||||
|
records.report("b".into(), record(State::Working, None));
|
||||||
|
assert!(!records.restate(), "the row shows what it showed");
|
||||||
|
records.reset();
|
||||||
|
assert!(records.restate());
|
||||||
|
assert_eq!(records.shows(), None);
|
||||||
|
}
|
||||||
+194
-41
@@ -7,16 +7,27 @@ use alacritty_terminal::{
|
|||||||
use futures::channel::mpsc::UnboundedSender;
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
use polling::{Event, PollMode, Poller};
|
use polling::{Event, PollMode, Poller};
|
||||||
|
|
||||||
use super::{mouse::MouseMode, pty::PtyEvent};
|
use super::{
|
||||||
|
marks,
|
||||||
|
mouse::MouseMode,
|
||||||
|
notify::{self, Kitty},
|
||||||
|
prompts::{self, Prompts},
|
||||||
|
pty::PtyEvent,
|
||||||
|
status,
|
||||||
|
titles::{self, Titles},
|
||||||
|
};
|
||||||
|
|
||||||
/// The longest OSC body kept; longer ones are dropped whole.
|
/// The longest OSC body kept; longer ones are dropped whole.
|
||||||
const MOST: usize = 4096;
|
pub(super) const MOST: usize = 4096;
|
||||||
|
|
||||||
/// A pty whose reader shows each byte to its scanners before the parser.
|
/// A pty whose reader shows each byte to its scanners before the parser.
|
||||||
pub(crate) struct Tapped<P> {
|
pub(crate) struct Tapped<P> {
|
||||||
pub inner: P,
|
pub inner: P,
|
||||||
scanner: Osc7,
|
scanner: Osc,
|
||||||
mouse: MouseModes,
|
mouse: MouseModes,
|
||||||
|
prompts: Prompts,
|
||||||
|
kitty: Kitty,
|
||||||
|
titles: Titles,
|
||||||
events: UnboundedSender<PtyEvent>,
|
events: UnboundedSender<PtyEvent>,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
last: Option<PathBuf>,
|
last: Option<PathBuf>,
|
||||||
@@ -26,8 +37,11 @@ impl<P> Tapped<P> {
|
|||||||
pub fn new(inner: P, events: UnboundedSender<PtyEvent>, pid: u32) -> Self {
|
pub fn new(inner: P, events: UnboundedSender<PtyEvent>, pid: u32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner,
|
inner,
|
||||||
scanner: Osc7::default(),
|
scanner: Osc::default(),
|
||||||
mouse: MouseModes::default(),
|
mouse: MouseModes::default(),
|
||||||
|
prompts: Prompts::default(),
|
||||||
|
kitty: Kitty::default(),
|
||||||
|
titles: Titles::default(),
|
||||||
events,
|
events,
|
||||||
pid,
|
pid,
|
||||||
last: None,
|
last: None,
|
||||||
@@ -37,29 +51,100 @@ impl<P> Tapped<P> {
|
|||||||
|
|
||||||
impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
||||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
let count = self.inner.reader().read(buf)?;
|
// What a rewrite adds stays inside the buffer, with room for the held sequence's own.
|
||||||
|
let room = buf
|
||||||
|
.len()
|
||||||
|
.saturating_sub(self.prompts.held() + prompts::SLACK);
|
||||||
|
let take = room / prompts::GROWTH;
|
||||||
|
if take == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let count = self.inner.reader().read(&mut buf[..take])?;
|
||||||
|
let read = &buf[..count];
|
||||||
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
|
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
|
||||||
self.scanner.feed(&buf[..count], &mut |body| {
|
let (kitty, titles) = (&mut self.kitty, &mut self.titles);
|
||||||
let Some(folder) = folder(body) else {
|
self.scanner.feed(read, &mut |found| {
|
||||||
return;
|
report(found, events, pid, last, kitty, titles)
|
||||||
};
|
|
||||||
if last.as_ref() == Some(&folder) {
|
|
||||||
log::debug!("pty {pid}: folder {} again", folder.display());
|
|
||||||
} else {
|
|
||||||
log::info!("pty {pid}: folder {}", folder.display());
|
|
||||||
*last = Some(folder.clone());
|
|
||||||
}
|
|
||||||
if events.unbounded_send(PtyEvent::Folder(folder)).is_err() {
|
|
||||||
log::debug!("pty {pid}: a folder report after its session closed");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if let Some(mode) = self.mouse.feed(&buf[..count]) {
|
if let Some(mode) = self.mouse.feed(read) {
|
||||||
log::info!("pty {pid}: mouse {mode:?}");
|
log::info!("pty {pid}: mouse {mode:?}");
|
||||||
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
||||||
log::debug!("pty {pid}: a mouse mode after its session closed");
|
log::debug!("pty {pid}: a mouse mode after its session closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(count)
|
let out = self.prompts.pass(&buf[..count]);
|
||||||
|
buf[..out.len()].copy_from_slice(out);
|
||||||
|
Ok(out.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the session what the scanner found: a full reset, a title, a mark, a program's status or ask, or a folder.
|
||||||
|
fn report(
|
||||||
|
found: Found,
|
||||||
|
events: &UnboundedSender<PtyEvent>,
|
||||||
|
pid: u32,
|
||||||
|
last: &mut Option<PathBuf>,
|
||||||
|
kitty: &mut Kitty,
|
||||||
|
titles: &mut Titles,
|
||||||
|
) {
|
||||||
|
let event = match found {
|
||||||
|
Found::Reset => {
|
||||||
|
log::debug!("pty {pid}: a full reset");
|
||||||
|
titles.reset();
|
||||||
|
PtyEvent::Reset
|
||||||
|
}
|
||||||
|
Found::Push => {
|
||||||
|
titles.push();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Found::Pop => match titles.pop() {
|
||||||
|
Some(title) => PtyEvent::Title(title),
|
||||||
|
None => return,
|
||||||
|
},
|
||||||
|
Found::Osc(body, length) => {
|
||||||
|
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
|
||||||
|
log::debug!("pty {pid}: {mark:?}");
|
||||||
|
PtyEvent::Mark(mark)
|
||||||
|
} else if let Some(status) = body
|
||||||
|
.strip_prefix(b"7501;")
|
||||||
|
.and_then(|rest| status::read(rest, length))
|
||||||
|
{
|
||||||
|
log::debug!("pty {pid}: {status:?}");
|
||||||
|
PtyEvent::Status(status)
|
||||||
|
} else if let Some(ask) = ask(body, kitty) {
|
||||||
|
log::debug!("pty {pid}: {ask:?}");
|
||||||
|
PtyEvent::Ask(ask)
|
||||||
|
} else if let Some(folder) = body.strip_prefix(b"7;").and_then(folder) {
|
||||||
|
if last.as_ref() == Some(&folder) {
|
||||||
|
log::debug!("pty {pid}: folder {} again", folder.display());
|
||||||
|
} else {
|
||||||
|
log::info!("pty {pid}: folder {}", folder.display());
|
||||||
|
*last = Some(folder.clone());
|
||||||
|
}
|
||||||
|
PtyEvent::Folder(folder)
|
||||||
|
} else if let Some(rest) = body
|
||||||
|
.strip_prefix(b"0;")
|
||||||
|
.or_else(|| body.strip_prefix(b"2;"))
|
||||||
|
{
|
||||||
|
PtyEvent::Title(titles.set(rest))
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if events.unbounded_send(event).is_err() {
|
||||||
|
log::debug!("pty {pid}: an event after its session closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification or progress an OSC 99, 777 or 9 body asks for.
|
||||||
|
fn ask(body: &[u8], kitty: &mut Kitty) -> Option<notify::Ask> {
|
||||||
|
if let Some(rest) = body.strip_prefix(b"99;") {
|
||||||
|
kitty.read(rest)
|
||||||
|
} else if let Some(rest) = body.strip_prefix(b"777;") {
|
||||||
|
notify::rxvt(rest)
|
||||||
|
} else {
|
||||||
|
body.strip_prefix(b"9;").and_then(notify::nine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,19 +196,32 @@ enum State {
|
|||||||
Ground,
|
Ground,
|
||||||
Escape,
|
Escape,
|
||||||
Osc,
|
Osc,
|
||||||
OscEscape,
|
Csi,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finds `ESC ] 7 ; body` ended by BEL or `ESC \`, across reads.
|
/// What the scanner finds: an OSC's body with the sequence's length from OSC through ST, a full reset, or the title stack's push and pop.
|
||||||
|
pub(crate) enum Found<'a> {
|
||||||
|
Osc(&'a [u8], usize),
|
||||||
|
Reset,
|
||||||
|
Push,
|
||||||
|
Pop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB, each full reset, and CSI 22 t and 23 t.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(crate) struct Osc7 {
|
pub(crate) struct Osc {
|
||||||
state: State,
|
state: State,
|
||||||
body: Vec<u8>,
|
body: Vec<u8>,
|
||||||
|
/// The sequence's bytes so far, the controls the body leaves out too.
|
||||||
|
length: usize,
|
||||||
overflow: bool,
|
overflow: bool,
|
||||||
|
/// A CSI's first parameter while it reads as a plain number, and its fields so far.
|
||||||
|
first: Option<u32>,
|
||||||
|
fields: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Osc7 {
|
impl Osc {
|
||||||
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(&[u8])) {
|
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(Found)) {
|
||||||
let mut rest = bytes;
|
let mut rest = bytes;
|
||||||
while !rest.is_empty() {
|
while !rest.is_empty() {
|
||||||
if self.state == State::Ground {
|
if self.state == State::Ground {
|
||||||
@@ -141,42 +239,97 @@ impl Osc7 {
|
|||||||
self.state = match (self.state, byte) {
|
self.state = match (self.state, byte) {
|
||||||
(State::Escape, b']') => {
|
(State::Escape, b']') => {
|
||||||
self.body.clear();
|
self.body.clear();
|
||||||
|
self.length = 2;
|
||||||
self.overflow = false;
|
self.overflow = false;
|
||||||
State::Osc
|
State::Osc
|
||||||
}
|
}
|
||||||
(State::Escape, 0x1b) => State::Escape,
|
(State::Escape, b'c') => {
|
||||||
(State::Escape, _) => State::Ground,
|
found(Found::Reset);
|
||||||
(State::Osc, 0x07) | (State::OscEscape, b'\\') => {
|
|
||||||
self.finish(found);
|
|
||||||
State::Ground
|
State::Ground
|
||||||
}
|
}
|
||||||
(State::Osc | State::OscEscape, 0x18 | 0x1a) => State::Ground,
|
(State::Escape, b'[') => {
|
||||||
(State::Osc, 0x1b) => State::OscEscape,
|
self.first = Some(0);
|
||||||
(State::OscEscape, b']') => {
|
self.fields = 1;
|
||||||
self.body.clear();
|
State::Csi
|
||||||
self.overflow = false;
|
}
|
||||||
|
// vte runs controls in an escape, passes DEL and high bytes by, and stays there.
|
||||||
|
(State::Escape, 0x00..=0x17 | 0x19 | 0x1b..=0x1f | 0x7f..=0xff) => State::Escape,
|
||||||
|
(State::Escape, _) => State::Ground,
|
||||||
|
(State::Osc, 0x07 | 0x18 | 0x1a) => {
|
||||||
|
self.finish(1, found);
|
||||||
|
State::Ground
|
||||||
|
}
|
||||||
|
// ESC ends the body, then reads as an escape: `\` makes ST, counted so.
|
||||||
|
(State::Osc, 0x1b) => {
|
||||||
|
self.finish(2, found);
|
||||||
|
State::Escape
|
||||||
|
}
|
||||||
|
(State::Osc, 0x00..=0x06 | 0x08..=0x17 | 0x19 | 0x1c..=0x1f) => {
|
||||||
|
self.length += 1;
|
||||||
State::Osc
|
State::Osc
|
||||||
}
|
}
|
||||||
(State::OscEscape, 0x1b) => State::Escape,
|
|
||||||
(State::OscEscape, _) => State::Ground,
|
|
||||||
(State::Osc, byte) => {
|
(State::Osc, byte) => {
|
||||||
if self.body.len() < MOST {
|
self.length += 1;
|
||||||
|
if self.body.len() < self.most() {
|
||||||
self.body.push(byte);
|
self.body.push(byte);
|
||||||
} else {
|
} else {
|
||||||
self.overflow = true;
|
self.overflow = true;
|
||||||
}
|
}
|
||||||
State::Osc
|
State::Osc
|
||||||
}
|
}
|
||||||
|
// In a CSI, as in vte: CAN and SUB cancel, ESC restarts, controls run, DEL and high bytes pass by.
|
||||||
|
(State::Csi, 0x18 | 0x1a) => State::Ground,
|
||||||
|
(State::Csi, 0x1b) => State::Escape,
|
||||||
|
(State::Csi, 0x00..=0x1f | 0x7f..=0xff) => State::Csi,
|
||||||
|
(State::Csi, b'0'..=b'9') => {
|
||||||
|
if self.fields == 1 {
|
||||||
|
self.first = self.first.map(|first| {
|
||||||
|
first
|
||||||
|
.saturating_mul(10)
|
||||||
|
.saturating_add(u32::from(byte - b'0'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
State::Csi
|
||||||
|
}
|
||||||
|
(State::Csi, b';' | b':') => {
|
||||||
|
self.fields += 1;
|
||||||
|
State::Csi
|
||||||
|
}
|
||||||
|
(State::Csi, b't') => {
|
||||||
|
match self.first.filter(|_| self.fields <= PARAMS) {
|
||||||
|
Some(22) => found(Found::Push),
|
||||||
|
Some(23) => found(Found::Pop),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
State::Ground
|
||||||
|
}
|
||||||
|
(State::Csi, 0x40..=0x7e) => State::Ground,
|
||||||
|
// A private marker or an intermediate makes another sequence.
|
||||||
|
(State::Csi, _) => {
|
||||||
|
self.first = None;
|
||||||
|
State::Csi
|
||||||
|
}
|
||||||
(State::Ground, _) => unreachable!("ground scans by position"),
|
(State::Ground, _) => unreachable!("ground scans by position"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish(&mut self, found: &mut impl FnMut(&[u8])) {
|
/// The longest body kept: an OSC 99's holds its payload past its metadata, a title's more.
|
||||||
|
fn most(&self) -> usize {
|
||||||
|
if self.body.starts_with(b"99;") {
|
||||||
|
notify::BODY
|
||||||
|
} else if self.body.starts_with(b"0;") || self.body.starts_with(b"2;") {
|
||||||
|
titles::LONGEST
|
||||||
|
} else {
|
||||||
|
MOST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, end: usize, found: &mut impl FnMut(Found)) {
|
||||||
if self.overflow {
|
if self.overflow {
|
||||||
log::warn!("pty: an OSC over {MOST} bytes dropped");
|
log::warn!("pty: an OSC over {} bytes dropped", self.most());
|
||||||
} else if let Some(body) = self.body.strip_prefix(b"7;") {
|
} else {
|
||||||
found(body);
|
found(Found::Osc(&self.body, self.length + end));
|
||||||
}
|
}
|
||||||
self.body.clear();
|
self.body.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-12
@@ -1,28 +1,70 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// What the scanner finds in `chunks`, each one read: a body, or `RESET`.
|
||||||
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
||||||
let mut scanner = Osc7::default();
|
let mut scanner = Osc::default();
|
||||||
let mut found = Vec::new();
|
let mut found = Vec::new();
|
||||||
for chunk in chunks {
|
for chunk in chunks {
|
||||||
scanner.feed(chunk, &mut |body| {
|
scanner.feed(chunk, &mut |thing| {
|
||||||
found.push(String::from_utf8(body.to_vec()).expect("utf-8"))
|
found.push(match thing {
|
||||||
|
Found::Osc(body, _) => String::from_utf8(body.to_vec()).expect("utf-8"),
|
||||||
|
Found::Reset => "RESET".into(),
|
||||||
|
Found::Push => "PUSH".into(),
|
||||||
|
Found::Pop => "POP".into(),
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finds_osc_7_ended_by_bel_or_st_across_reads() {
|
fn finds_a_full_reset_between_bodies() {
|
||||||
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["file:///a"]);
|
assert_eq!(
|
||||||
assert_eq!(scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), ["file:///b"]);
|
scan(&[b"\x1b]0;a\x07\x1bc\x1b", b"c\x1b]0;b\x1b\\"]),
|
||||||
assert_eq!(scan(&[b"\x1b", b"]7;x\x07\x1b]7;y\x1b\\"]), ["x", "y"]);
|
["0;a", "RESET", "RESET", "0;b"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
scan(&[b"\x1b[c\x1b]0;c"]).is_empty(),
|
||||||
|
"DA1 and an unfinished body"
|
||||||
|
);
|
||||||
|
// vte passes DEL and high bytes by in an escape, and the reset still comes.
|
||||||
|
assert_eq!(scan(&[b"\x1b\x7fc\x1b\xff", b"c"]), ["RESET", "RESET"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn other_sequences_and_cancels_pass() {
|
fn a_sequence_counts_from_osc_through_st_skipped_controls_too() {
|
||||||
assert!(scan(&[b"\x1b]0;title\x07\x1b[31m"]).is_empty());
|
let mut lengths = Vec::new();
|
||||||
assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty());
|
let mut osc = b"\x1b]7501;".to_vec();
|
||||||
assert_eq!(scan(&[b"\x1b]7;lost\x1b]7;kept\x07"]), ["kept"]);
|
osc.extend([0; 5000]);
|
||||||
|
osc.extend_from_slice(b"state=done\x07\x1b]0;t\x1b\\");
|
||||||
|
Osc::default().feed(&osc, &mut |found| {
|
||||||
|
if let Found::Osc(body, length) = found {
|
||||||
|
lengths.push((body.len(), length));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(lengths, [(15, 5018), (3, 7)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_each_osc_body_across_reads_ended_as_vte_ends_it() {
|
||||||
|
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["7;file:///a"]);
|
||||||
|
assert_eq!(
|
||||||
|
scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]),
|
||||||
|
["7;file:///b"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scan(&[b"\x1b", b"]133;A\x07\x1b]0;t\x1b\\"]),
|
||||||
|
["133;A", "0;t"]
|
||||||
|
);
|
||||||
|
assert_eq!(scan(&[b"\x1b]7;x\x18\x1b]7;y\x1a"]), ["7;x", "7;y"]);
|
||||||
|
assert_eq!(scan(&[b"\x1b]7;cut\x1b]133;D;1\x07"]), ["7;cut", "133;D;1"]);
|
||||||
|
assert_eq!(scan(&[b"\x1b]0;t\x1b[31m"]), ["0;t"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controls_vte_skips_stay_out_of_a_body() {
|
||||||
|
assert_eq!(scan(&[b"\x1b\x00]1\x0033;\x1fA\x07"]), ["133;A"]);
|
||||||
|
assert_eq!(scan(&[b"\x1b[31m\x1bc"]), ["RESET"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -31,7 +73,14 @@ fn an_overlong_body_is_dropped() {
|
|||||||
long.extend(std::iter::repeat_n(b'a', MOST + 1));
|
long.extend(std::iter::repeat_n(b'a', MOST + 1));
|
||||||
long.push(0x07);
|
long.push(0x07);
|
||||||
assert!(scan(&[&long]).is_empty());
|
assert!(scan(&[&long]).is_empty());
|
||||||
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
|
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["7;b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_osc_99_body_holds_its_metadata_and_a_full_payload() {
|
||||||
|
let osc = |length: usize| [&b"\x1b]99;"[..], &vec![b'a'; length - 3], b"\x07"].concat();
|
||||||
|
assert_eq!(scan(&[&osc(notify::BODY)]).concat().len(), notify::BODY);
|
||||||
|
assert!(scan(&[&osc(notify::BODY + 1)]).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -178,3 +227,54 @@ fn the_tap_reads_mouse_modes_as_vte_does_byte_by_byte() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_the_title_stack_s_push_and_pop_as_vte_reads_them() {
|
||||||
|
assert_eq!(scan(&[b"\x1b[22;0t\x1b[23;2t"]), ["PUSH", "POP"]);
|
||||||
|
assert_eq!(
|
||||||
|
scan(&[b"\x1b[2", b"2:1\x07t\x1b[23t"]),
|
||||||
|
["PUSH", "POP"],
|
||||||
|
"across reads, a control run"
|
||||||
|
);
|
||||||
|
for other in [
|
||||||
|
&b"\x1b[?22t"[..],
|
||||||
|
b"\x1b[22$t",
|
||||||
|
b"\x1b[1;22t",
|
||||||
|
b"\x1b[t",
|
||||||
|
b"\x1b[22\x18t",
|
||||||
|
b"\x1b[24t",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
scan(&[other]).is_empty(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(other)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut many = b"\x1b[22".to_vec();
|
||||||
|
many.extend(std::iter::repeat_n(&b";1"[..], 32).flatten());
|
||||||
|
many.push(b't');
|
||||||
|
assert!(scan(&[&many]).is_empty(), "vte drops one past 32 fields");
|
||||||
|
assert_eq!(
|
||||||
|
scan(&[b"\x1b[22t\x1b]2;t\x07\x1b[23t"]),
|
||||||
|
["PUSH", "2;t", "POP"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_s_body_holds_65536_bytes_and_another_s_4096() {
|
||||||
|
let osc = |number: &str, length: usize| {
|
||||||
|
[
|
||||||
|
format!("\x1b]{number};").as_bytes(),
|
||||||
|
&vec![b'a'; length],
|
||||||
|
b"\x07",
|
||||||
|
]
|
||||||
|
.concat()
|
||||||
|
};
|
||||||
|
let longest = scan(&[&osc("2", titles::LONGEST - 2)]);
|
||||||
|
assert_eq!(longest.concat().len(), titles::LONGEST);
|
||||||
|
assert!(scan(&[&osc("0", titles::LONGEST - 1)]).is_empty());
|
||||||
|
assert!(
|
||||||
|
scan(&[&osc("7", MOST - 1)]).is_empty(),
|
||||||
|
"another stays at 4096"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/// The titles alacritty saves before it drops the oldest.
|
||||||
|
const SAVED: usize = 4096;
|
||||||
|
|
||||||
|
/// vte keeps 16 parameters of an OSC, its number among them.
|
||||||
|
const PARTS: usize = 15;
|
||||||
|
|
||||||
|
/// The longest OSC 0 or 2 body the tap keeps; a longer one drops, logged.
|
||||||
|
pub(super) const LONGEST: usize = 65536;
|
||||||
|
|
||||||
|
/// The title a session shows, read in the output's order as vte and alacritty read it.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Titles {
|
||||||
|
/// Empty while the program names none.
|
||||||
|
title: String,
|
||||||
|
saved: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Titles {
|
||||||
|
/// Sets the title from an OSC 0 or 2 body past its number, as vte splits it and alacritty joins it.
|
||||||
|
pub fn set(&mut self, rest: &[u8]) -> String {
|
||||||
|
let parts: Vec<&str> = rest
|
||||||
|
.split(|&byte| byte == b';')
|
||||||
|
.take(PARTS)
|
||||||
|
.filter_map(|part| std::str::from_utf8(part).ok())
|
||||||
|
.collect();
|
||||||
|
self.title = parts.join(";").trim().to_owned();
|
||||||
|
self.title.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the title: CSI 22 t.
|
||||||
|
pub fn push(&mut self) {
|
||||||
|
if self.saved.len() == SAVED {
|
||||||
|
self.saved.remove(0);
|
||||||
|
}
|
||||||
|
self.saved.push(self.title.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brings the last title saved back: CSI 23 t; none when none was saved.
|
||||||
|
pub fn pop(&mut self) -> Option<String> {
|
||||||
|
let title = self.saved.pop()?;
|
||||||
|
self.title.clone_from(&title);
|
||||||
|
Some(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full reset empties the title and those saved.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.title.clear();
|
||||||
|
self.saved.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_reads_as_alacritty_joins_it() {
|
||||||
|
let mut titles = Titles::default();
|
||||||
|
assert_eq!(titles.set(b" a;b "), "a;b");
|
||||||
|
assert_eq!(
|
||||||
|
titles.set(b"a;\xff;b"),
|
||||||
|
"a;b",
|
||||||
|
"a part not in UTF-8 passes by"
|
||||||
|
);
|
||||||
|
assert_eq!(titles.set(b""), "");
|
||||||
|
let parts: Vec<String> = (1..=17).map(|at| at.to_string()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
titles.set(parts.join(";").as_bytes()),
|
||||||
|
parts[..PARTS].join(";"),
|
||||||
|
"vte keeps 16 parameters, the number among them"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_saved_comes_back_and_a_reset_empties_both() {
|
||||||
|
let mut titles = Titles::default();
|
||||||
|
assert_eq!(titles.pop(), None, "none saved");
|
||||||
|
titles.set(b"shell");
|
||||||
|
titles.push();
|
||||||
|
titles.set(b"vim");
|
||||||
|
assert_eq!(titles.pop().as_deref(), Some("shell"));
|
||||||
|
assert_eq!(titles.pop(), None);
|
||||||
|
titles.push();
|
||||||
|
titles.reset();
|
||||||
|
assert_eq!(titles.pop(), None, "a reset empties those saved");
|
||||||
|
for at in 0..=SAVED {
|
||||||
|
titles.set(at.to_string().as_bytes());
|
||||||
|
titles.push();
|
||||||
|
}
|
||||||
|
assert_eq!(titles.saved.len(), SAVED);
|
||||||
|
assert_eq!(titles.saved[0], "1", "the oldest went");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -45,8 +45,7 @@ impl Pty {
|
|||||||
self.child.id()
|
self.child.id()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A copy of the master; tests read its modes.
|
/// A copy of the master, which reads the foreground and, in tests, the modes.
|
||||||
#[cfg(test)]
|
|
||||||
pub fn master(&self) -> io::Result<File> {
|
pub fn master(&self) -> io::Result<File> {
|
||||||
self.file.try_clone()
|
self.file.try_clone()
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-15
@@ -21,11 +21,14 @@ use super::{
|
|||||||
find::Finder,
|
find::Finder,
|
||||||
frame::frame,
|
frame::frame,
|
||||||
ink::Ink,
|
ink::Ink,
|
||||||
input::{self, Find},
|
input::{self, Find, NextPrompt, PreviousPrompt},
|
||||||
|
marks::Mark,
|
||||||
mouse::MouseMode,
|
mouse::MouseMode,
|
||||||
paint::{self, Look},
|
paint::{self, Look},
|
||||||
pointer::Pointer,
|
pointer::Pointer,
|
||||||
pty::{Events, Pty, PtyEvent, config, fit},
|
pty::{Events, Pty, PtyEvent, config, fit},
|
||||||
|
reports::Notices,
|
||||||
|
status::{Records, State},
|
||||||
zoom,
|
zoom,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -48,6 +51,8 @@ pub enum TerminalEvent {
|
|||||||
Title(String),
|
Title(String),
|
||||||
Folder(PathBuf),
|
Folder(PathBuf),
|
||||||
Bell,
|
Bell,
|
||||||
|
/// The state its row shows changed, to this one.
|
||||||
|
Shows(Option<State>),
|
||||||
Exited(ExitStatus),
|
Exited(ExitStatus),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +71,22 @@ pub struct TerminalView {
|
|||||||
pub(super) held: HashSet<String>,
|
pub(super) held: HashSet<String>,
|
||||||
pub(super) finder: Option<Finder>,
|
pub(super) finder: Option<Finder>,
|
||||||
pub(super) size: (usize, usize),
|
pub(super) size: (usize, usize),
|
||||||
|
/// How many folders the program has reported.
|
||||||
|
pub(super) reports: u64,
|
||||||
|
/// What programs say they are doing, OSC 7501.
|
||||||
|
pub(super) records: Records,
|
||||||
|
/// The status its shell's last command ended with; none before one, or when it gave none.
|
||||||
|
pub(super) ended: Option<i32>,
|
||||||
|
/// A command started, as OSC 133 C says, since the last one ended.
|
||||||
|
pub(super) ran: bool,
|
||||||
|
/// The system notifications it posted.
|
||||||
|
pub(super) notices: Notices,
|
||||||
|
/// The program's title; empty while it names none.
|
||||||
|
pub(super) title: String,
|
||||||
|
/// It is the session shown.
|
||||||
|
pub(super) front: bool,
|
||||||
|
/// Its window is active.
|
||||||
|
pub(super) active: bool,
|
||||||
cell: (u16, u16),
|
cell: (u16, u16),
|
||||||
/// Focused in an active window, as the program last heard.
|
/// Focused in an active window, as the program last heard.
|
||||||
focused: bool,
|
focused: bool,
|
||||||
@@ -73,6 +94,7 @@ pub struct TerminalView {
|
|||||||
shown: bool,
|
shown: bool,
|
||||||
_events: Task<()>,
|
_events: Task<()>,
|
||||||
_blink: Task<()>,
|
_blink: Task<()>,
|
||||||
|
_herdr: Task<()>,
|
||||||
_focus: [Subscription; 3],
|
_focus: [Subscription; 3],
|
||||||
_settings: Subscription,
|
_settings: Subscription,
|
||||||
_zoom: Subscription,
|
_zoom: Subscription,
|
||||||
@@ -111,12 +133,21 @@ impl TerminalView {
|
|||||||
held: HashSet::new(),
|
held: HashSet::new(),
|
||||||
finder: None,
|
finder: None,
|
||||||
size,
|
size,
|
||||||
|
reports: 0,
|
||||||
|
records: Records::default(),
|
||||||
|
ended: None,
|
||||||
|
ran: false,
|
||||||
|
notices: Notices::default(),
|
||||||
|
title: String::new(),
|
||||||
|
front: true,
|
||||||
|
active: false,
|
||||||
cell: (8, 16),
|
cell: (8, 16),
|
||||||
focused: false,
|
focused: false,
|
||||||
blinking: false,
|
blinking: false,
|
||||||
shown: true,
|
shown: true,
|
||||||
_events: Self::pump(events, cx),
|
_events: Self::pump(events, cx),
|
||||||
_blink: Self::blink(cx),
|
_blink: Self::blink(cx),
|
||||||
|
_herdr: super::herdr::follow(cx),
|
||||||
_settings: cx.observe_global::<SettingsStore>(|view, cx| {
|
_settings: cx.observe_global::<SettingsStore>(|view, cx| {
|
||||||
view.pty
|
view.pty
|
||||||
.term
|
.term
|
||||||
@@ -133,6 +164,21 @@ impl TerminalView {
|
|||||||
self.pty.pid
|
self.pty.pid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What programs in this terminal say they are doing.
|
||||||
|
pub fn records(&self) -> &Records {
|
||||||
|
&self.records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status its shell's last command ended with.
|
||||||
|
pub fn ended(&self) -> Option<i32> {
|
||||||
|
self.ended
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether it is the session shown: one shown in an active window posts no notification.
|
||||||
|
pub fn set_front(&mut self, front: bool) {
|
||||||
|
self.front = front;
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -180,6 +226,7 @@ impl TerminalView {
|
|||||||
|
|
||||||
/// Tells a program that asked, by mode 1004, when the terminal gains or loses focus.
|
/// Tells a program that asked, by mode 1004, when the terminal gains or loses focus.
|
||||||
fn refocus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn refocus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.active = window.is_window_active();
|
||||||
let focused = self.focus.is_focused(window) && window.is_window_active();
|
let focused = self.focus.is_focused(window) && window.is_window_active();
|
||||||
if focused != self.focused {
|
if focused != self.focused {
|
||||||
self.focused = focused;
|
self.focused = focused;
|
||||||
@@ -207,9 +254,38 @@ impl TerminalView {
|
|||||||
.any(|event| matches!(event, PtyEvent::Term(Event::Wakeup)));
|
.any(|event| matches!(event, PtyEvent::Term(Event::Wakeup)));
|
||||||
for event in events {
|
for event in events {
|
||||||
match event {
|
match event {
|
||||||
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
|
PtyEvent::Folder(folder) => {
|
||||||
|
self.reports += 1;
|
||||||
|
cx.emit(TerminalEvent::Folder(folder));
|
||||||
|
}
|
||||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||||
PtyEvent::Mouse(mode) => self.mouse = mode,
|
PtyEvent::Mouse(mode) => self.mouse = mode,
|
||||||
|
PtyEvent::Mark(Mark::Done(status)) => self.finished(status, cx),
|
||||||
|
// A new prompt ends what was working, waiting or idle; done and error stay.
|
||||||
|
PtyEvent::Mark(Mark::Prompt) => {
|
||||||
|
self.records.prompt();
|
||||||
|
self.restate(cx);
|
||||||
|
}
|
||||||
|
PtyEvent::Mark(Mark::Output) => self.ran = true,
|
||||||
|
PtyEvent::Mark(_) => {}
|
||||||
|
PtyEvent::Status(status) => {
|
||||||
|
self.status(status);
|
||||||
|
self.restate(cx);
|
||||||
|
}
|
||||||
|
PtyEvent::Ask(ask) => {
|
||||||
|
self.ask(ask, cx);
|
||||||
|
self.restate(cx);
|
||||||
|
}
|
||||||
|
PtyEvent::Title(title) => {
|
||||||
|
self.title.clone_from(&title);
|
||||||
|
cx.emit(TerminalEvent::Title(title));
|
||||||
|
}
|
||||||
|
PtyEvent::Reset => {
|
||||||
|
self.records.reset();
|
||||||
|
self.title.clear();
|
||||||
|
cx.emit(TerminalEvent::Title(String::new()));
|
||||||
|
self.restate(cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
|
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
|
||||||
@@ -222,14 +298,11 @@ impl TerminalView {
|
|||||||
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
match event {
|
match event {
|
||||||
Event::Wakeup => self.shown = true,
|
Event::Wakeup => self.shown = true,
|
||||||
Event::MouseCursorDirty => {}
|
// The tap reads the title in the output's order; alacritty's title events come after it.
|
||||||
// A reset empties the title and sends only this; setting the options sends the title.
|
Event::MouseCursorDirty
|
||||||
Event::CursorBlinkingChange => {
|
| Event::CursorBlinkingChange
|
||||||
let settings = config(SettingsStore::get(cx));
|
| Event::Title(_)
|
||||||
self.pty.term.lock().set_options(settings);
|
| Event::ResetTitle => {}
|
||||||
}
|
|
||||||
Event::Title(title) => cx.emit(TerminalEvent::Title(title)),
|
|
||||||
Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())),
|
|
||||||
Event::Bell => {
|
Event::Bell => {
|
||||||
log::debug!("terminal {}: a bell, kept silent", self.pty.pid);
|
log::debug!("terminal {}: a bell, kept silent", self.pty.pid);
|
||||||
cx.emit(TerminalEvent::Bell);
|
cx.emit(TerminalEvent::Bell);
|
||||||
@@ -243,9 +316,9 @@ impl TerminalView {
|
|||||||
self.pty.pid
|
self.pty.pid
|
||||||
),
|
),
|
||||||
Event::ColorRequest(index, reply) => {
|
Event::ColorRequest(index, reply) => {
|
||||||
let theme = themes::active(SettingsStore::get(cx), cx.window_appearance());
|
let theme = themes::active(SettingsStore::get(cx), cx.window_appearance(), cx);
|
||||||
let term = self.pty.term.lock();
|
let term = self.pty.term.lock();
|
||||||
let rgb = Ink::new(theme, term.colors()).query(index, term.colors());
|
let rgb = Ink::new(&theme, term.colors()).query(index, term.colors());
|
||||||
drop(term);
|
drop(term);
|
||||||
self.pty.write(reply(rgb).into_bytes());
|
self.pty.write(reply(rgb).into_bytes());
|
||||||
}
|
}
|
||||||
@@ -322,9 +395,9 @@ fn look(settings: &Settings, points: f32, ink: &Ink, window: &Window) -> anyhow:
|
|||||||
impl Render for TerminalView {
|
impl Render for TerminalView {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let settings = SettingsStore::get(cx).clone();
|
let settings = SettingsStore::get(cx).clone();
|
||||||
let theme = themes::active(&settings, window.appearance());
|
let theme = themes::active(&settings, window.appearance(), cx);
|
||||||
let term = self.pty.term.lock();
|
let term = self.pty.term.lock();
|
||||||
let ink = Ink::new(theme, term.colors());
|
let ink = Ink::new(&theme, term.colors());
|
||||||
let lit = self.finder.as_ref().map_or_else(Vec::new, |finder| {
|
let lit = self.finder.as_ref().map_or_else(Vec::new, |finder| {
|
||||||
finder.lit(&term, cx.theme().colors.warning)
|
finder.lit(&term, cx.theme().colors.warning)
|
||||||
});
|
});
|
||||||
@@ -357,10 +430,13 @@ impl Render for TerminalView {
|
|||||||
.on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx)))
|
.on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx)))
|
||||||
.on_action(cx.listener(|view, _: &SelectAll, _, cx| view.select_all(cx)))
|
.on_action(cx.listener(|view, _: &SelectAll, _, cx| view.select_all(cx)))
|
||||||
.on_action(cx.listener(|view, _: &Find, window, cx| view.find(window, cx)))
|
.on_action(cx.listener(|view, _: &Find, window, cx| view.find(window, cx)))
|
||||||
|
.on_action(
|
||||||
|
cx.listener(|view, _: &PreviousPrompt, _, cx| view.scroll_to_prompt(true, cx)),
|
||||||
|
)
|
||||||
|
.on_action(cx.listener(|view, _: &NextPrompt, _, cx| view.scroll_to_prompt(false, cx)))
|
||||||
.on_any_mouse_down(cx.listener(Self::mouse_down))
|
.on_any_mouse_down(cx.listener(Self::mouse_down))
|
||||||
.on_mouse_move(cx.listener(Self::mouse_move))
|
.on_mouse_move(cx.listener(Self::mouse_move))
|
||||||
.on_scroll_wheel(cx.listener(Self::wheel))
|
.on_scroll_wheel(cx.listener(Self::wheel))
|
||||||
.cursor_text()
|
|
||||||
.size_full()
|
.size_full()
|
||||||
.px(px(settings.padding_x))
|
.px(px(settings.padding_x))
|
||||||
.py(px(settings.padding_y))
|
.py(px(settings.padding_y))
|
||||||
|
|||||||
@@ -76,14 +76,14 @@ pub fn palette(theme: &TerminalTheme, opacity: f32) -> Palette {
|
|||||||
/// Dresses the app in the theme the settings choose for the system's appearance.
|
/// Dresses the app in the theme the settings choose for the system's appearance.
|
||||||
pub fn apply(cx: &mut App) {
|
pub fn apply(cx: &mut App) {
|
||||||
let settings = SettingsStore::get(cx);
|
let settings = SettingsStore::get(cx);
|
||||||
let theme = active(settings, cx.window_appearance());
|
let theme = active(settings, cx.window_appearance(), cx);
|
||||||
let opacity = settings.background_opacity;
|
let opacity = settings.background_opacity;
|
||||||
let mode = if theme.is_dark() {
|
let mode = if theme.is_dark() {
|
||||||
Mode::Dark
|
Mode::Dark
|
||||||
} else {
|
} else {
|
||||||
Mode::Light
|
Mode::Light
|
||||||
};
|
};
|
||||||
Theme::set_palette(mode, Some(palette(theme, opacity)), cx);
|
Theme::set_palette(mode, Some(palette(&theme, opacity)), cx);
|
||||||
Theme::set_mode_now(mode, cx);
|
Theme::set_mode_now(mode, cx);
|
||||||
log::info!("theme: {} in {mode:?}, glass at {opacity}", theme.id);
|
log::info!("theme: {} in {mode:?}, glass at {opacity}", theme.id);
|
||||||
}
|
}
|
||||||
+39
-5
@@ -1,10 +1,12 @@
|
|||||||
mod chrome;
|
mod chrome;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod omarchy;
|
||||||
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use ely_gpui_component::theme::Palette;
|
use ely_gpui_component::theme::Palette;
|
||||||
use gpui::{Hsla, Rgba, WindowAppearance};
|
use gpui::{App, Global, Hsla, Rgba, WindowAppearance};
|
||||||
|
|
||||||
pub use chrome::{apply, palette};
|
pub use chrome::{apply, palette};
|
||||||
|
|
||||||
@@ -32,6 +34,14 @@ impl TerminalTheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The settings' name for Omarchy's theme, on Linux.
|
||||||
|
pub const OMARCHY: &str = "omarchy";
|
||||||
|
|
||||||
|
/// Omarchy's current theme, while ELYTerminal follows it.
|
||||||
|
pub struct Omarchy(pub TerminalTheme);
|
||||||
|
|
||||||
|
impl Global for Omarchy {}
|
||||||
|
|
||||||
/// Upstream Ghostty files from iTerm2-Color-Schemes: id, shown name, text.
|
/// Upstream Ghostty files from iTerm2-Color-Schemes: id, shown name, text.
|
||||||
const FILES: [(&str, &str, &str); 12] = [
|
const FILES: [(&str, &str, &str); 12] = [
|
||||||
(
|
(
|
||||||
@@ -112,17 +122,41 @@ pub fn find(id: &str) -> Option<&'static TerminalTheme> {
|
|||||||
all().iter().find(|theme| theme.id == id)
|
all().iter().find(|theme| theme.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A theme the settings may name: built in, or Omarchy's on Linux.
|
||||||
|
pub fn known(id: &str) -> bool {
|
||||||
|
find(id).is_some() || (cfg!(target_os = "linux") && id == OMARCHY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh install wears Omarchy's theme unless its file is surely absent.
|
||||||
|
pub fn default_id() -> &'static str {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if omarchy::path().is_some_and(|path| !matches!(path.try_exists(), Ok(false))) {
|
||||||
|
return OMARCHY;
|
||||||
|
}
|
||||||
|
"catppuccin-mocha"
|
||||||
|
}
|
||||||
|
|
||||||
/// The theme the settings choose under the system's appearance.
|
/// The theme the settings choose under the system's appearance.
|
||||||
pub fn active(settings: &Settings, appearance: WindowAppearance) -> &'static TerminalTheme {
|
pub fn chosen(settings: &Settings, appearance: WindowAppearance) -> &str {
|
||||||
let dark = matches!(
|
let dark = matches!(
|
||||||
appearance,
|
appearance,
|
||||||
WindowAppearance::Dark | WindowAppearance::VibrantDark
|
WindowAppearance::Dark | WindowAppearance::VibrantDark
|
||||||
);
|
);
|
||||||
let id = match (&settings.dark_theme, dark) {
|
match (&settings.dark_theme, dark) {
|
||||||
(Some(id), true) => id,
|
(Some(id), true) => id,
|
||||||
_ => &settings.theme,
|
_ => &settings.theme,
|
||||||
};
|
}
|
||||||
find(id).unwrap_or_else(|| panic!("settings name theme {id}, which validation admits"))
|
}
|
||||||
|
|
||||||
|
/// The chosen theme's colors; Omarchy's come from its global.
|
||||||
|
pub fn active(settings: &Settings, appearance: WindowAppearance, cx: &App) -> TerminalTheme {
|
||||||
|
let id = chosen(settings, appearance);
|
||||||
|
if id == OMARCHY {
|
||||||
|
return cx.global::<Omarchy>().0.clone();
|
||||||
|
}
|
||||||
|
find(id)
|
||||||
|
.unwrap_or_else(|| panic!("settings name theme {id}, which validation admits"))
|
||||||
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ely(id: &'static str, name: &'static str, palette: Palette) -> TerminalTheme {
|
fn ely(id: &'static str, name: &'static str, palette: Palette) -> TerminalTheme {
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::{self, ErrorKind},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use gpui::App;
|
||||||
|
|
||||||
|
use super::{OMARCHY, Omarchy, TerminalTheme, apply, parse};
|
||||||
|
use crate::settings::SettingsStore;
|
||||||
|
|
||||||
|
/// How often Omarchy's theme file is read for a switch.
|
||||||
|
const CHECK: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// A Ghostty theme file's keys; Omarchy's other lines pass by.
|
||||||
|
const COLORS: [&str; 7] = [
|
||||||
|
"palette",
|
||||||
|
"background",
|
||||||
|
"foreground",
|
||||||
|
"cursor-color",
|
||||||
|
"cursor-text",
|
||||||
|
"selection-background",
|
||||||
|
"selection-foreground",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Omarchy 4's current theme for Ghostty, under $HOME whatever XDG_STATE_HOME says.
|
||||||
|
pub fn path() -> Option<PathBuf> {
|
||||||
|
dirs::home_dir().map(|home| home.join(".local/state/omarchy/current/theme/ghostty.conf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows Omarchy's theme; an unreadable file, or a bad one worn, stops the start.
|
||||||
|
pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||||
|
let settings = SettingsStore::get(cx);
|
||||||
|
let worn = settings.theme == OMARCHY || settings.dark_theme.as_deref() == Some(OMARCHY);
|
||||||
|
let path = path().context("omarchy: no home folder")?;
|
||||||
|
match read(&path) {
|
||||||
|
Ok((text, theme)) => {
|
||||||
|
log::info!("omarchy: the theme follows {}", path.display());
|
||||||
|
cx.set_global(Omarchy(theme));
|
||||||
|
watch(path, text, cx);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) if worn => Err(error.context("settings wear the Omarchy theme")),
|
||||||
|
Err(error) if missing(&error) => {
|
||||||
|
log::info!("omarchy: none at {}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) if error.is::<io::Error>() => Err(error),
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("{error:#}; no Omarchy theme to follow");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Omarchy signals only Alacritty, kitty and Ghostty, so this reads the file.
|
||||||
|
fn watch(path: PathBuf, mut shown: String, cx: &mut App) {
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
let mut failed = String::new();
|
||||||
|
loop {
|
||||||
|
cx.background_executor().timer(CHECK).await;
|
||||||
|
match check(&path, &shown) {
|
||||||
|
Ok(None) => failed.clear(),
|
||||||
|
Ok(Some((text, theme))) => {
|
||||||
|
shown = text;
|
||||||
|
failed.clear();
|
||||||
|
cx.update(|cx| {
|
||||||
|
log::info!("omarchy: the theme changed");
|
||||||
|
cx.set_global(Omarchy(theme));
|
||||||
|
apply(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let error = format!("{error:#}");
|
||||||
|
if error != failed {
|
||||||
|
log::error!("{error}; the last Omarchy theme stays");
|
||||||
|
failed = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's theme when its text is not `shown`'s; none while unchanged.
|
||||||
|
pub(super) fn check(path: &Path, shown: &str) -> anyhow::Result<Option<(String, TerminalTheme)>> {
|
||||||
|
match read(path) {
|
||||||
|
Ok((text, _)) if text == shown => Ok(None),
|
||||||
|
// Gone mid-switch: the next check finds the new folder.
|
||||||
|
Err(error) if missing(&error) => Ok(None),
|
||||||
|
next => next.map(Some),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(path: &Path) -> anyhow::Result<(String, TerminalTheme)> {
|
||||||
|
let text =
|
||||||
|
fs::read_to_string(path).with_context(|| format!("omarchy: reading {}", path.display()))?;
|
||||||
|
let theme = colors(&text).with_context(|| format!("omarchy: {}", path.display()))?;
|
||||||
|
Ok((text, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing(error: &anyhow::Error) -> bool {
|
||||||
|
error
|
||||||
|
.downcast_ref::<io::Error>()
|
||||||
|
.is_some_and(|error| error.kind() == ErrorKind::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Omarchy writes no cursor-text; the background stands in, as Ghostty's default.
|
||||||
|
pub(super) fn colors(text: &str) -> anyhow::Result<TerminalTheme> {
|
||||||
|
let key = |line: &str| {
|
||||||
|
line.split_once('=')
|
||||||
|
.map_or("", |(key, _)| key.trim())
|
||||||
|
.to_owned()
|
||||||
|
};
|
||||||
|
let mut lines: Vec<&str> = text
|
||||||
|
.lines()
|
||||||
|
.filter(|line| COLORS.contains(&key(line).as_str()))
|
||||||
|
.collect();
|
||||||
|
let background = lines
|
||||||
|
.iter()
|
||||||
|
.find(|line| key(line) == "background")
|
||||||
|
.and_then(|line| line.split_once('='))
|
||||||
|
.map(|(_, value)| format!("cursor-text = {}", value.trim()));
|
||||||
|
if !lines.iter().any(|line| key(line) == "cursor-text") {
|
||||||
|
lines.extend(background.as_deref());
|
||||||
|
}
|
||||||
|
parse(OMARCHY, "Omarchy", &lines.join("\n"))
|
||||||
|
}
|
||||||
+87
-6
@@ -86,6 +86,90 @@ fn a_missing_key_or_bad_color_names_it() {
|
|||||||
assert!(parse("x", "X", "palette = 16=#000000").is_err());
|
assert!(parse("x", "X", "palette = 16=#000000").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Omarchy 4 renders Tokyo Night so; glass mode appends the last four lines.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn tokyo_night() -> String {
|
||||||
|
[
|
||||||
|
"background = #1a1b26",
|
||||||
|
"foreground = #a9b1d6",
|
||||||
|
"cursor-color = #c0caf5",
|
||||||
|
"selection-background = #292e42",
|
||||||
|
"selection-foreground = #c0caf5",
|
||||||
|
"",
|
||||||
|
"palette = 0=#1a1b26",
|
||||||
|
"palette = 1=#f7768e",
|
||||||
|
"palette = 2=#9ece6a",
|
||||||
|
"palette = 3=#e0af68",
|
||||||
|
"palette = 4=#7aa2f7",
|
||||||
|
"palette = 5=#ad8ee6",
|
||||||
|
"palette = 6=#449dab",
|
||||||
|
"palette = 7=#a9b1d6",
|
||||||
|
"palette = 8=#414868",
|
||||||
|
"palette = 9=#ff7a93",
|
||||||
|
"palette = 10=#b9f27c",
|
||||||
|
"palette = 11=#ff9e64",
|
||||||
|
"palette = 12=#7da6ff",
|
||||||
|
"palette = 13=#bb9af7",
|
||||||
|
"palette = 14=#0db9d7",
|
||||||
|
"palette = 15=#c0caf5",
|
||||||
|
"# omarchy-glass begin",
|
||||||
|
"background-opacity = 0.65",
|
||||||
|
"gtk-custom-css = /usr/share/omarchy/default/gtk/glass.css",
|
||||||
|
"# omarchy-glass end",
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn omarchys_ghostty_conf_reads_without_its_glass() {
|
||||||
|
let rendered = tokyo_night();
|
||||||
|
let theme = omarchy::colors(&rendered).expect("Omarchy's file reads");
|
||||||
|
assert_eq!(theme.id, OMARCHY);
|
||||||
|
assert_eq!(theme.background, rgb(0x1a1b26));
|
||||||
|
assert_eq!(theme.cursor, rgb(0xc0caf5));
|
||||||
|
assert_eq!(theme.cursor_text, theme.background, "Ghostty's default");
|
||||||
|
assert_eq!(theme.ansi[8], rgb(0x414868));
|
||||||
|
let cut = rendered.replace("palette = 7=#a9b1d6\n", "");
|
||||||
|
assert_eq!(
|
||||||
|
omarchy::colors(&cut).expect_err("no 7").to_string(),
|
||||||
|
"no palette 7"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn omarchys_file_is_worn_again_once_it_reads() {
|
||||||
|
use std::{fs, os::unix::fs::PermissionsExt};
|
||||||
|
let folder = std::env::temp_dir().join(format!("elyterminal-omarchy-{}", std::process::id()));
|
||||||
|
if folder.exists() {
|
||||||
|
fs::remove_dir_all(&folder).expect("a stale test folder goes");
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&folder).expect("a test folder");
|
||||||
|
let path = folder.join("ghostty.conf");
|
||||||
|
let text = tokyo_night();
|
||||||
|
fs::write(&path, &text).expect("a theme");
|
||||||
|
assert!(omarchy::check(&path, &text).expect("reads").is_none());
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).expect("000");
|
||||||
|
// Root reads any mode.
|
||||||
|
if unsafe { libc::geteuid() } != 0 {
|
||||||
|
let error = omarchy::check(&path, "").expect_err("unreadable");
|
||||||
|
assert!(
|
||||||
|
format!("{error:#}").contains("Permission denied"),
|
||||||
|
"{error:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("644");
|
||||||
|
let (shown, theme) = omarchy::check(&path, "").expect("reads").expect("a theme");
|
||||||
|
assert_eq!((shown, theme.background), (text, rgb(0x1a1b26)));
|
||||||
|
fs::remove_dir_all(&folder).expect("the test folder goes");
|
||||||
|
assert!(
|
||||||
|
omarchy::check(&path, "")
|
||||||
|
.expect("gone mid-switch")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_dark_theme_wins_only_in_dark_mode() {
|
fn the_dark_theme_wins_only_in_dark_mode() {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -94,21 +178,18 @@ fn the_dark_theme_wins_only_in_dark_mode() {
|
|||||||
..Settings::default()
|
..Settings::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
active(&settings, WindowAppearance::Light).id,
|
chosen(&settings, WindowAppearance::Light),
|
||||||
"catppuccin-latte"
|
"catppuccin-latte"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
active(&settings, WindowAppearance::VibrantDark).id,
|
chosen(&settings, WindowAppearance::VibrantDark),
|
||||||
"catppuccin-mocha"
|
"catppuccin-mocha"
|
||||||
);
|
);
|
||||||
let single = Settings {
|
let single = Settings {
|
||||||
dark_theme: None,
|
dark_theme: None,
|
||||||
..settings
|
..settings
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(chosen(&single, WindowAppearance::Dark), "catppuccin-latte");
|
||||||
active(&single, WindowAppearance::Dark).id,
|
|
||||||
"catppuccin-latte"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+78
-73
@@ -1,15 +1,17 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use ely_gpui_component::{
|
use ely_gpui_component::{
|
||||||
buttons::{ButtonVariant, IconButton, SegmentedControl},
|
buttons::{ButtonVariant, IconButton, SegmentedControl},
|
||||||
lists::List,
|
lists::List,
|
||||||
primitives::{FocusScope, IconName},
|
primitives::{FocusScope, IconName},
|
||||||
shell::{ColumnShell, ResizeBorder, ShellColumn},
|
shell::{ColumnShell, ResizeBorder, ShellColumn},
|
||||||
theme::{ActiveTheme, ControlSize},
|
theme::ControlSize,
|
||||||
typography::Caption,
|
typography::Caption,
|
||||||
};
|
};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Context, Entity, EntityId, FocusHandle, Focusable, InteractiveElement,
|
Action, App, AppContext, Context, Entity, FocusHandle, Focusable, InteractiveElement,
|
||||||
IntoElement, KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement,
|
IntoElement, KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement,
|
||||||
StyleRefinement, Styled, Subscription, Window, actions, div, prelude::FluentBuilder,
|
StyleRefinement, Styled, Subscription, Task, Window, actions, div, prelude::FluentBuilder,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -17,15 +19,20 @@ use crate::{
|
|||||||
files::Files,
|
files::Files,
|
||||||
git::panel::{Acted, GitPanel},
|
git::panel::{Acted, GitPanel},
|
||||||
menus,
|
menus,
|
||||||
sessions::{self, Session},
|
sessions::{self, Session, orb},
|
||||||
settings::{PanelTab, Settings, SettingsStore, StartIn},
|
settings::{PanelTab, Settings, SettingsStore, StartIn, window::OpenSettings},
|
||||||
term::{
|
term::{
|
||||||
Events, Launch, Pty, TerminalEvent, TerminalView, config,
|
Events, Launch, Pty, TerminalView, config,
|
||||||
shell::{ShellIntegration, launch},
|
shell::{ShellIntegration, launch},
|
||||||
spawn,
|
spawn,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod events;
|
||||||
|
mod widths;
|
||||||
|
|
||||||
|
use widths::{Dragged, Side};
|
||||||
|
|
||||||
actions!(
|
actions!(
|
||||||
elyterminal,
|
elyterminal,
|
||||||
[
|
[
|
||||||
@@ -45,7 +52,7 @@ actions!(
|
|||||||
pub struct SelectSession(pub usize);
|
pub struct SelectSession(pub usize);
|
||||||
|
|
||||||
/// The key context of the main window's content.
|
/// The key context of the main window's content.
|
||||||
const CONTEXT: &str = "Workspace";
|
pub const CONTEXT: &str = "Workspace";
|
||||||
|
|
||||||
/// The window's title while the program names none.
|
/// The window's title while the program names none.
|
||||||
pub const TITLE: &str = "ELYTerminal";
|
pub const TITLE: &str = "ELYTerminal";
|
||||||
@@ -88,6 +95,10 @@ pub struct Workspace {
|
|||||||
git: Entity<GitPanel>,
|
git: Entity<GitPanel>,
|
||||||
/// What the window shows of the desktop, and the blur behind it.
|
/// What the window shows of the desktop, and the blur behind it.
|
||||||
background: Blur,
|
background: Blur,
|
||||||
|
/// Column widths a drag shows before the settings hold them.
|
||||||
|
dragged: Dragged,
|
||||||
|
/// Draws the window at each step of the status marks while one shown moves.
|
||||||
|
ticking: Option<Task<()>>,
|
||||||
_settings: Subscription,
|
_settings: Subscription,
|
||||||
_acted: Subscription,
|
_acted: Subscription,
|
||||||
}
|
}
|
||||||
@@ -114,6 +125,8 @@ impl Workspace {
|
|||||||
files: cx.new(|_| Files::new(launch.cwd.clone())),
|
files: cx.new(|_| Files::new(launch.cwd.clone())),
|
||||||
git,
|
git,
|
||||||
background: Blur::new(window, SettingsStore::get(cx)),
|
background: Blur::new(window, SettingsStore::get(cx)),
|
||||||
|
dragged: Dragged::default(),
|
||||||
|
ticking: None,
|
||||||
_settings: cx.observe_global_in::<SettingsStore>(window, |this, window, cx| {
|
_settings: cx.observe_global_in::<SettingsStore>(window, |this, window, cx| {
|
||||||
this.background.follow(window, SettingsStore::get(cx));
|
this.background.follow(window, SettingsStore::get(cx));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -146,10 +159,37 @@ impl Workspace {
|
|||||||
folder: launch.cwd,
|
folder: launch.cwd,
|
||||||
reports: launch.reports,
|
reports: launch.reports,
|
||||||
bell: false,
|
bell: false,
|
||||||
|
waiting: false,
|
||||||
|
look: None,
|
||||||
_events: events,
|
_events: events,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Notes each session's mark `now`, and draws the window at each step while one shown moves.
|
||||||
|
fn tick(&mut self, now: Instant, cx: &mut Context<Self>) {
|
||||||
|
let mut moving = false;
|
||||||
|
for session in &mut self.sessions {
|
||||||
|
moving |= session.note(now, cx);
|
||||||
|
}
|
||||||
|
let moving = moving && SettingsStore::get(cx).show_sessions;
|
||||||
|
if moving == self.ticking.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::debug!("app: status marks {}", if moving { "move" } else { "rest" });
|
||||||
|
self.ticking = moving.then(|| {
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
loop {
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(orb::wait(Instant::now()))
|
||||||
|
.await;
|
||||||
|
if this.update(cx, |_, cx| cx.notify()).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// A session of the shell the settings name, in home or the shown session's folder.
|
/// A session of the shell the settings name, in home or the shown session's folder.
|
||||||
fn new_session(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn new_session(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(home) = dirs::home_dir() else {
|
let Some(home) = dirs::home_dir() else {
|
||||||
@@ -175,7 +215,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;
|
||||||
@@ -183,10 +223,15 @@ impl Workspace {
|
|||||||
if index != self.active
|
if index != self.active
|
||||||
&& let Some(hidden) = self.sessions.get(self.active)
|
&& let Some(hidden) = self.sessions.get(self.active)
|
||||||
{
|
{
|
||||||
hidden.terminal.update(cx, |view, cx| view.let_go(cx));
|
hidden.terminal.update(cx, |view, cx| {
|
||||||
|
view.let_go(cx);
|
||||||
|
view.set_front(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let session = &mut self.sessions[index];
|
let session = &mut self.sessions[index];
|
||||||
session.bell = false;
|
session.bell = false;
|
||||||
|
session.waiting = false;
|
||||||
|
session.terminal.update(cx, |view, _| view.set_front(true));
|
||||||
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));
|
||||||
@@ -235,51 +280,6 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_event(
|
|
||||||
&mut self,
|
|
||||||
id: EntityId,
|
|
||||||
event: &TerminalEvent,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let Some(index) = self
|
|
||||||
.sessions
|
|
||||||
.iter()
|
|
||||||
.position(|session| session.terminal.entity_id() == id)
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let session = &mut self.sessions[index];
|
|
||||||
match event {
|
|
||||||
TerminalEvent::Title(title) => session.title.clone_from(title),
|
|
||||||
TerminalEvent::Folder(folder) => {
|
|
||||||
session.folder.clone_from(folder);
|
|
||||||
session.reports = true;
|
|
||||||
if index == self.active {
|
|
||||||
self.files.update(cx, |files, cx| files.show(folder, cx));
|
|
||||||
self.git.update(cx, |git, cx| git.show(folder, cx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TerminalEvent::Bell if index != self.active => {
|
|
||||||
if !session.bell {
|
|
||||||
log::debug!("app: a bell in hidden session {}", index + 1);
|
|
||||||
}
|
|
||||||
session.bell = true;
|
|
||||||
}
|
|
||||||
TerminalEvent::Bell => {}
|
|
||||||
TerminalEvent::Exited(status) => {
|
|
||||||
let pid = session.terminal.read(cx).pid();
|
|
||||||
log::info!("app: session {pid}'s program ended, {status}");
|
|
||||||
self.close(index, window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if index == self.active {
|
|
||||||
self.retitle(window);
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows or hides a side column, as the settings keep it.
|
/// Shows or hides a side column, as the settings keep it.
|
||||||
fn toggle(&mut self, flip: fn(&mut Settings), cx: &mut Context<Self>) {
|
fn toggle(&mut self, flip: fn(&mut Settings), cx: &mut Context<Self>) {
|
||||||
match SettingsStore::update(cx, flip) {
|
match SettingsStore::update(cx, flip) {
|
||||||
@@ -332,17 +332,15 @@ fn column_toggle(
|
|||||||
.variant(ButtonVariant::Ghost)
|
.variant(ButtonVariant::Ghost)
|
||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.tooltip(tip)
|
.tooltip(tip)
|
||||||
.on_click(move |_, _, cx| {
|
.on_click(move |_, _, cx| workspace.update(cx, |this, cx| this.toggle(flip, cx)))
|
||||||
cx.stop_propagation();
|
|
||||||
workspace.update(cx, |this, cx| this.toggle(flip, cx));
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Workspace {
|
impl Render for Workspace {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
self.background.fit(window);
|
self.background.fit(window);
|
||||||
|
let now = Instant::now();
|
||||||
|
self.tick(now, cx);
|
||||||
let settings = SettingsStore::get(cx);
|
let settings = SettingsStore::get(cx);
|
||||||
let width = cx.theme().sidebar_width(false);
|
|
||||||
let workspace = cx.entity();
|
let workspace = cx.entity();
|
||||||
let tip =
|
let tip =
|
||||||
|shown: bool, what: &str| format!("{} {what}", if shown { "Hide" } else { "Show" });
|
|shown: bool, what: &str| format!("{} {what}", if shown { "Hide" } else { "Show" });
|
||||||
@@ -359,6 +357,20 @@ impl Render for Workspace {
|
|||||||
tip(settings.show_panel, "Git and Files"),
|
tip(settings.show_panel, "Git and Files"),
|
||||||
|settings| settings.show_panel = !settings.show_panel,
|
|settings| settings.show_panel = !settings.show_panel,
|
||||||
);
|
);
|
||||||
|
// No menu bar off macOS: a gear opens Settings.
|
||||||
|
let gear = (!cfg!(target_os = "macos")).then(|| {
|
||||||
|
IconButton::new("open-settings", IconName::Settings)
|
||||||
|
.variant(ButtonVariant::Ghost)
|
||||||
|
.size(ControlSize::Sm)
|
||||||
|
.tooltip("Settings")
|
||||||
|
.on_click(|_, window, cx| window.dispatch_action(Box::new(OpenSettings), cx))
|
||||||
|
});
|
||||||
|
let end = div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.children(gear)
|
||||||
|
.child(right);
|
||||||
let mut shell = ColumnShell::new("workspace").glass();
|
let mut shell = ColumnShell::new("workspace").glass();
|
||||||
let shown = self
|
let shown = self
|
||||||
.sessions
|
.sessions
|
||||||
@@ -371,7 +383,7 @@ impl Render for Workspace {
|
|||||||
let home = dirs::home_dir();
|
let home = dirs::home_dir();
|
||||||
let rows = self.sessions.iter().enumerate().map(|(index, session)| {
|
let rows = self.sessions.iter().enumerate().map(|(index, session)| {
|
||||||
let active = index == self.active;
|
let active = index == self.active;
|
||||||
sessions::row(&workspace, index, session, active, home.as_deref(), cx)
|
sessions::row(&workspace, index, session, active, home.as_deref(), now, cx)
|
||||||
});
|
});
|
||||||
let list = List::new().children(rows);
|
let list = List::new().children(rows);
|
||||||
let new = IconButton::new("new-session", IconName::Plus)
|
let new = IconButton::new("new-session", IconName::Plus)
|
||||||
@@ -379,12 +391,10 @@ impl Render for Workspace {
|
|||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.tooltip("New session")
|
.tooltip("New session")
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
cx.stop_propagation();
|
|
||||||
workspace.update(cx, |this, cx| this.new_session(window, cx))
|
workspace.update(cx, |this, cx| this.new_session(window, cx))
|
||||||
});
|
});
|
||||||
shell = shell.column(
|
shell = shell.column(
|
||||||
ShellColumn::new("sessions")
|
self.side_column(Side::Sessions, cx)
|
||||||
.width(width)
|
|
||||||
.leading(left)
|
.leading(left)
|
||||||
.action(new)
|
.action(new)
|
||||||
.child(
|
.child(
|
||||||
@@ -410,11 +420,6 @@ impl Render for Workspace {
|
|||||||
.segment("files", "Files", None)
|
.segment("files", "Files", None)
|
||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.on_change(|value, _, cx| show_tab(value, cx));
|
.on_change(|value, _, cx| show_tab(value, cx));
|
||||||
// Its presses stay off the header, whose double press zooms.
|
|
||||||
let switch = div()
|
|
||||||
.id("panel-switch")
|
|
||||||
.on_click(|_, _, cx| cx.stop_propagation())
|
|
||||||
.child(switch);
|
|
||||||
let quiet = self
|
let quiet = self
|
||||||
.sessions
|
.sessions
|
||||||
.get(self.active)
|
.get(self.active)
|
||||||
@@ -431,16 +436,16 @@ impl Render for Workspace {
|
|||||||
// Git's list draws every row, so terminal output must not redraw it.
|
// Git's list draws every row, so terminal output must not redraw it.
|
||||||
let fill = || StyleRefinement::default().size_full();
|
let fill = || StyleRefinement::default().size_full();
|
||||||
shell = shell.column(terminal).column(
|
shell = shell.column(terminal).column(
|
||||||
ShellColumn::new("panel")
|
self.side_column(Side::Panel, cx)
|
||||||
.width(width)
|
.action(end)
|
||||||
.leading(switch)
|
// Below the title bar, clear of Windows' caption buttons.
|
||||||
.action(right)
|
.child(div().flex().justify_center().px_2().pt_2().child(switch))
|
||||||
.children(quiet.then(caption))
|
.children(quiet.then(caption))
|
||||||
.child(body(tab == PanelTab::Git).child(self.git.clone().cached(fill())))
|
.child(body(tab == PanelTab::Git).child(self.git.clone().cached(fill())))
|
||||||
.child(body(tab == PanelTab::Files).child(self.files.clone())),
|
.child(body(tab == PanelTab::Files).child(self.files.clone())),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
shell = shell.column(terminal.action(right));
|
shell = shell.column(terminal.action(end));
|
||||||
}
|
}
|
||||||
// The context sits above the focus root, so its keys work wherever focus is.
|
// The context sits above the focus root, so its keys work wherever focus is.
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use gpui::{Context, EntityId, Window};
|
||||||
|
|
||||||
|
use super::Workspace;
|
||||||
|
use crate::term::{State, TerminalEvent};
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
/// What session `id`'s terminal reported.
|
||||||
|
pub(super) fn session_event(
|
||||||
|
&mut self,
|
||||||
|
id: EntityId,
|
||||||
|
event: &TerminalEvent,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Some(index) = self
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.position(|session| session.terminal.entity_id() == id)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let session = &mut self.sessions[index];
|
||||||
|
match event {
|
||||||
|
TerminalEvent::Title(title) => session.title.clone_from(title),
|
||||||
|
TerminalEvent::Folder(folder) => {
|
||||||
|
session.folder.clone_from(folder);
|
||||||
|
session.reports = true;
|
||||||
|
if index == self.active {
|
||||||
|
self.files.update(cx, |files, cx| files.show(folder, cx));
|
||||||
|
self.git.update(cx, |git, cx| git.show(folder, cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TerminalEvent::Bell if index != self.active => {
|
||||||
|
if !session.bell {
|
||||||
|
log::debug!("app: a bell in hidden session {}", index + 1);
|
||||||
|
}
|
||||||
|
session.bell = true;
|
||||||
|
}
|
||||||
|
TerminalEvent::Bell => {}
|
||||||
|
// A hidden session that starts waiting marks its row until it is shown.
|
||||||
|
TerminalEvent::Shows(Some(State::Blocked)) if index != self.active => {
|
||||||
|
log::debug!("app: hidden session {} waits", index + 1);
|
||||||
|
session.waiting = true;
|
||||||
|
}
|
||||||
|
TerminalEvent::Shows(_) => {}
|
||||||
|
TerminalEvent::Exited(status) => {
|
||||||
|
let pid = session.terminal.read(cx).pid();
|
||||||
|
log::info!("app: session {pid}'s program ended, {status}");
|
||||||
|
self.close(index, window, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == self.active {
|
||||||
|
self.retitle(window);
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A press on notification `tag`: its session hears of it and shows, unless it asked not to; false when no session here posted it.
|
||||||
|
pub fn pressed(&mut self, tag: &str, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||||
|
let found = self
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find_map(|(index, session)| {
|
||||||
|
let focus = session.terminal.update(cx, |view, _| view.pressed(tag))?;
|
||||||
|
Some((index, focus))
|
||||||
|
});
|
||||||
|
let Some((index, focus)) = found else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if focus {
|
||||||
|
cx.activate(true);
|
||||||
|
window.activate_window();
|
||||||
|
self.select(index, window, cx);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ely_gpui_component::shell::ShellColumn;
|
||||||
|
use gpui::{Context, Pixels, Task, px};
|
||||||
|
|
||||||
|
use super::Workspace;
|
||||||
|
use crate::settings::{COLUMN, SettingsStore};
|
||||||
|
|
||||||
|
/// How long a dragged border rests before its width is saved.
|
||||||
|
const SETTLE: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// A side column, whose border toward the terminal drags its width.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(super) enum Side {
|
||||||
|
Sessions,
|
||||||
|
Panel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widths a drag shows that the settings do not hold yet.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Dragged {
|
||||||
|
sessions: Option<f32>,
|
||||||
|
panel: Option<f32>,
|
||||||
|
saving: Option<Task<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
/// Column `side`, as wide as it was dragged or saved.
|
||||||
|
pub(super) fn side_column(&self, side: Side, cx: &Context<Self>) -> ShellColumn {
|
||||||
|
let settings = SettingsStore::get(cx);
|
||||||
|
let (id, dragged, saved) = match side {
|
||||||
|
Side::Sessions => ("sessions", self.dragged.sessions, settings.sessions_width),
|
||||||
|
Side::Panel => ("panel", self.dragged.panel, settings.panel_width),
|
||||||
|
};
|
||||||
|
let workspace = cx.entity();
|
||||||
|
ShellColumn::new(id).resizable(
|
||||||
|
px(dragged.unwrap_or(saved)),
|
||||||
|
px(*COLUMN.start()),
|
||||||
|
px(*COLUMN.end()),
|
||||||
|
move |width, _, cx| workspace.update(cx, |this, cx| this.drag(side, width, cx)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows `width` at once and saves it once the drag rests.
|
||||||
|
fn drag(&mut self, side: Side, width: Pixels, cx: &mut Context<Self>) {
|
||||||
|
let width = f32::from(width).round();
|
||||||
|
match side {
|
||||||
|
Side::Sessions => self.dragged.sessions = Some(width),
|
||||||
|
Side::Panel => self.dragged.panel = Some(width),
|
||||||
|
}
|
||||||
|
self.dragged.saving = Some(cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(SETTLE).await;
|
||||||
|
if let Err(error) = this.update(cx, |this, cx| this.save(cx)) {
|
||||||
|
log::error!("app: the columns' widths were not saved: {error:#}");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let (sessions, panel) = (self.dragged.sessions.take(), self.dragged.panel.take());
|
||||||
|
let saved = SettingsStore::update(cx, |settings| {
|
||||||
|
settings.sessions_width = sessions.unwrap_or(settings.sessions_width);
|
||||||
|
settings.panel_width = panel.unwrap_or(settings.panel_width);
|
||||||
|
});
|
||||||
|
match saved {
|
||||||
|
Ok(()) => {
|
||||||
|
let settings = SettingsStore::get(cx);
|
||||||
|
log::info!(
|
||||||
|
"app: the sessions column is {} px wide, the right column {} px",
|
||||||
|
settings.sessions_width,
|
||||||
|
settings.panel_width
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The columns go back to the saved widths.
|
||||||
|
Err(error) => log::error!("app: the columns' widths are not kept: {error:#}"),
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-17
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
`PRD.md` line by line, as of T23. Status: **Pass** when a test, a run or a review shows it; **Partial** when part of it was shown; **Open** when nothing has shown it yet. Reviews are in `tasks/reviews.md`; tests are named by module (`cargo test -- MODULE`).
|
`PRD.md` line by line, as of T23. Status: **Pass** when a test, a run or a review shows it; **Partial** when part of it was shown; **Open** when nothing has shown it yet. Reviews are in `tasks/reviews.md`; tests are named by module (`cargo test -- MODULE`).
|
||||||
|
|
||||||
What no check reached: no one has looked at the window on Windows, which CI builds and tests; no real KWin or Wayland desktop drew the blur. On 2026-10-15 macOS gave `shot` the window's ground alone, for old and new builds alike, while the terminal it ran under lacked Screen Recording (`CGPreflightScreenCaptureAccess` false); that day's macOS checks read the app's log.
|
What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyprland, on the owner's Omarchy, has drawn it. On 2026-10-15 macOS gave `shot` the window's ground alone, for old and new builds alike, while the terminal it ran under lacked Screen Recording (`CGPreflightScreenCaptureAccess` false); that day's macOS checks read the app's log.
|
||||||
|
|
||||||
## 1. Product
|
## 1. Product
|
||||||
|
|
||||||
| PRD line | Task | Evidence | Status |
|
| PRD line | Task | Evidence | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| One window: sessions left, terminal middle, Git or Files right | T11 | T11 review; a real X11 window shows the terminal and the right column | Pass |
|
| One window: sessions left, terminal middle, Git or Files right | T11 | T11 review; a real X11 window shows the terminal and the right column | Pass |
|
||||||
| Every setting has a control in the settings window | T16–T18 | 17 of the 20 fields have a row; `show_sessions`, `show_panel` and `panel` are the main window's toggles and switch, which §6 keeps in the file | Pass |
|
| Every setting has a control in the settings window | T16–T18, T30, T33 | 17 of the 24 fields have a row; `show_sessions`, `show_panel` and `panel` are the main window's toggles and switch, `sessions_width` and `panel_width` its columns' borders, and `window_width` and `window_height` its size, which §6 keeps in the file | Pass |
|
||||||
| Silent: no sound, bell, notification, update check, telemetry, tip or onboarding | T10, T12 | a bell is logged and marks a hidden row; the code holds no network call or notification | Pass |
|
| Silent: no sound, bell, notification but a session's, update check, telemetry, tip or onboarding | T10, T12, T40 | a bell is logged and marks a hidden row; the code holds no network call, and its one notification is a session's, through gpui's, which sets no sound on macOS and a silent toast on Windows; on Linux the notification daemon decides | Pass |
|
||||||
| Clean: no subtitle, tagline, logging option, status bar or badge without state | T11–T18 | reviews of the layout and settings tasks | Pass |
|
| Clean: no subtitle, tagline, logging option, status bar or badge without state | T11–T18 | reviews of the layout and settings tasks | Pass |
|
||||||
| Built-in themes, each with a preview, Catppuccin among them; a theme colors terminal and chrome | T03, T17 | `themes::tests`; T17 review | Pass |
|
| Built-in themes, each with a preview, Catppuccin among them; a theme colors terminal and chrome | T03, T17 | `themes::tests`; T17 review | Pass |
|
||||||
| The background blurs on macOS, Windows, Wayland and X11 | T19–T21 | see §11 | Partial |
|
| The background blurs on macOS, Windows, Wayland and X11 | T19–T21 | see §11 | Partial |
|
||||||
@@ -24,7 +24,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
|||||||
| `alacritty_terminal` 0.26 | T04 | `=0.26.0` | Pass |
|
| `alacritty_terminal` 0.26 | T04 | `=0.26.0` | Pass |
|
||||||
| Logs through `log` to stderr, `RUST_LOG` sets the level, no log setting | T01 | `src/main.rs`; no such field in `Settings` | Pass |
|
| Logs through `log` to stderr, `RUST_LOG` sets the level, no log setting | T01 | `src/main.rs`; no such field in `Settings` | Pass |
|
||||||
| CI on Gitea Actions: macOS builds, tests and lints; Ubuntu and Windows build and test | T01, T23 | `.github/workflows/ci.yml`; run 926 on `65bb4d3`, see the CI notes | Pass |
|
| CI on Gitea Actions: macOS builds, tests and lints; Ubuntu and Windows build and test | T01, T23 | `.github/workflows/ci.yml`; run 926 on `65bb4d3`, see the CI notes | Pass |
|
||||||
| Private repo; no credential in any file or commit | T01 | T22's review scanned the tree and its 398 reachable objects: 0 hits; T23's scans for the Gitea token and the runners' SSH details: 0 hits | Pass |
|
| The repo, private until v0.1.1 and public since; no credential in any file or commit | T01, T26 | T22's review scanned the tree and its 398 reachable objects: 0 hits; T23's scans for the Gitea token and the runners' SSH details: 0 hits; before the owner made it public, a scan of all 91 commits' contents and messages found no token, SSH password, gateway, port, SSH user or private key | Pass |
|
||||||
|
|
||||||
## 3. Settings model and storage
|
## 3. Settings model and storage
|
||||||
|
|
||||||
@@ -43,13 +43,23 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
|||||||
| Background, foreground, cursor, cursor text, selection, 16 ANSI from upstream | T03 | `themes::tests` | Pass |
|
| Background, foreground, cursor, cursor text, selection, 16 ANSI from upstream | T03 | `themes::tests` | Pass |
|
||||||
| A theme maps onto Ely's `Palette` through `Theme::set_palette` | T03, T19 | `the_chrome_wears_the_theme` | Pass |
|
| A theme maps onto Ely's `Palette` through `Theme::set_palette` | T03, T19 | `the_chrome_wears_the_theme` | Pass |
|
||||||
| One theme, or a second for dark mode | T17 | T17 review | Pass |
|
| One theme, or a second for dark mode | T17 | T17 review | Pass |
|
||||||
|
| On Omarchy 4, an Omarchy theme wears the current theme and follows each switch; the default there; another pick stops following | T27 | `omarchys_ghostty_conf_reads_without_its_glass`, `omarchys_file_is_worn_again_once_it_reads`; on X11, as a user, with Omarchy v4.0.4's own renderer staging and swapping the folder as `omarchy-theme-set` does: with no theme saved the window wore Omarchy's, and through all 22 of its themes the terminal's ground matched each background within 1.5 s; Omarchy's glass lines changed nothing; a file without palette 7 was logged and the last theme stayed; a file swapped in unreadable was worn once made readable, its stamp unchanged; a folder unreadable for 2 s kept the theme; each failure was logged once while it lasted; a picked Catppuccin Mocha stayed through a switch, and with it worn a bad Omarchy file was logged and the start went on; with Omarchy's folder unreadable the start stopped, a theme saved or not, and with settings wearing it and no Omarchy too, each naming the file and the error; the first release build, on the owner's Omarchy 4.0.4 under Hyprland 0.56.2, wore the live theme, sepia-night, which is none of Omarchy's 22, and the release build from `aae7fba`, under Xvfb on Debian 13 with that theme's folder, painted its background | Pass |
|
||||||
|
|
||||||
## 5. Terminal engine
|
## 5. Terminal engine
|
||||||
|
|
||||||
| PRD line | Task | Evidence | Status |
|
| PRD line | Task | Evidence | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 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 |
|
| PTY through alacritty's event loop; OSC 7 and OSC 133 scanned on the reader | T04, T34 | `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 |
|
| 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 |
|
||||||
|
| Prompt marks, OSC 133, from the shell integration and any program; each session keeps its last command's status | T34 | `term::marks` tests: each letter and option as Ghostty reads them; `term::tap` and `term::pty` tests: marks split across reads reach the session in order; `term::shell` tests: zsh, bash, fish and PowerShell send C, D;1, A and B around a failing command, and the user's prompt still shows the failure, a zsh theme's that sets PS1 in its own hook too, whose first prompt is marked before any command, and `$pipestatus` stays; bash's lone subshell ends with D;7, `$_` stays, a command runs under `extdebug` after a failure, a key binding marks nothing, the user's DEBUG trap runs, bash 3.2 sends no C, and bash says at start why it marks nothing while `promptvars` is off; PowerShell reads 1 for a cmdlet's failure under `-ErrorAction Ignore` after a success, a native code twice, a native failure's code under `$PSNativeCommandUseErrorActionPreference`, on a line that mixes a program and a cmdlet the cmdlet's 1, and through a call by variable the program's code; on macOS fish 4.9 marks itself and bash 3.2 marks no C, in the Linux container fish 4.0 gets B and bash 5.2 marks C through PS0, CI's Ubuntu runs fish 3.7, which the script marks whole; by hand in a pty, PowerShell under strict mode, and a cmdlet's failure after a native one's reads 1 | Pass |
|
||||||
|
| Jump to the prompt before or after the view's top, as Ghostty jumps | T35 | `term::prompts` tests: a mark becomes its prompt's numbered link, read as the session reads marks, whole when split across reads at every byte, and other sequences, `133;AB` among them, pass as they came; a prompt's cells carry its link through SGR 0 and a resize, its input none; a program's own link and the prompt's keep each other, a program's link inside a prompt carries the prompt's number, a prompt inside a link left open too, a target of 264 bytes in either order; controls after an OSC's closing ESC keep their line; a long escape of controls keeps the pty reading; a prompt over rows starts at its first, blank rows within it too; up and down land as Ghostty's `scrollPrompt`; `term::links`: a prompt's link is no target; `term::pty`: through the event loop the prompt's cell alone carries it; in the app on macOS, Cmd-Up once and twice put PROMPT-3 and PROMPT-2 at the top, and Cmd-Shift-Down and Cmd-Down came back to the screen's bottom; the review's fixtures of a prompt that starts with a link and of a prompt with a blank row jumped to PROMPT-3-FIRST, and its fixture of `133;AB` marks found no prompt; the second review's fixtures of a prompt all in a link and of a linked first row jumped to PROMPT-3-FIRST. The keys off macOS are bound, not run | Pass |
|
||||||
|
| Program status, OSC 7501 revision 0.4: reports checked as the spec checks them, records per terminal, a new prompt and a full reset, the support query | T36 | `term::status` tests: the spec's examples read as it means them; a malformed pair is skipped, the last of a key wins, unknown keys pass; a sequence over any limit, with bad base64, UTF-8 or a control character, or without a known state drops whole, a clear too, and an earlier pair of a key counts though the last wins; the sequence's length counts the controls its body skips; kind and progress count only with their states; records replace whole, clear with those under them, and past 256 the one updated longest ago goes; a new prompt keeps done and error alone. `term::tap`: a full reset found between bodies, and through DEL and high bytes in its escape. `term::pty`: a report, a query and a full reset reach the session in order through the event loop. In the app on macOS, a program that sent the query and then DA1 read the answer with every state and kind, then DA1's | 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, 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; each change of the state a row shows is noted, a wait after work in one output among them. `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; after review, a hidden session that waited, worked and waited again in one output set its title strong, an open hover followed a record replaced under it and work from 20% to 80%, ten long records scrolled to the last within the window, and a selected row at 0% in Ely Dark showed its loader; after round 2, herdr's records replaced in the same state showed at once in an open hover, and a hidden session that waited and worked again in one output set its title strong | Pass |
|
||||||
|
| A row's mark moves: work ripples from the centre, progress fills the outer ring clockwise from the top, waiting stays lit and dips, a settled state draws its shape and holds; the window draws at the marks' steps only while one moves | T42 | `sessions::orb` tests: the ripple peaks at the centre, then every edge, then every corner, once every 15 steps; at 50% the first four ring dots are lit, the next pulses and the rest stay dim, and at 100% the centre pulses; waiting dips on the 1st, 2nd, 5th and 6th of 20 steps, every dot alike; a done check draws from its left end and holds after 5 steps, idle is a ring; work and waiting never stop. `sessions` test: the mark takes the most pressing look, a failed command as an error. In the app on macOS, at 70% glass in Ely Dark, the recorder's stills showed each look in one column: the ripple, 40% filled, waiting lit, a check, two crosses and a ring; two stills 0.4 s apart showed the ripple at two steps | Pass |
|
||||||
|
| System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99, while the window is inactive or the session hidden, one a session every 3 s; a press shows the window and session | T40 | `term::reports` tests: a session posts once in 3 s, its own under one tag; a program's notifications go by their id and live until pressed, closed or expired, an expiry taking only its own post and none a later post holds; a session keeps its last 64; the text shows as text, and as markup's text on Linux; a session posts unless it is the one shown in an active window; waiting, done and error post, work and rest do not; a tag names the session that posted it, by itself, an id or a count. `term::titles`: OSC 0 and 2 set the title as vte splits it, 15 parts past the number at most, and as alacritty joins it; CSI 22 t and 23 t save it and bring it back; a full reset empties both. `term::tap`: the scanner finds CSI 22 t and 23 t as vte reads them; a title's body holds 65536 bytes, another's 4096. `term::pty`: titles, notifications and full resets reach the session in the output's order, one write and a write a sequence alike, a title of 5000 bytes and one of 17 parts among them. `term::notify`: the query, on Linux without close and `w`, alive, untracked and activation answers. In the app on macOS, outside a bundle, where gpui posts nothing and the log names each post: an OSC 9 posted and an OSC 777 within 3 s was dropped; an OSC 99 with `c=1` and `w=800` posted, its program heard `p=close;untracked`, alive listed it, and once it expired alive listed none; a closed one left alive empty; the query's answer came back; a command's end without its C posted nothing, and a failed one after its C posted; a notice after a title set, or after a full reset, in the same write named the session as split writes do, as did one after a full reset and 2 KB to 32 KB more of output, and a title of 17 parts or of 4100 bytes named the notice as it named the row, one write or two; a notice, alive and DA1 in one write came back as alive naming it, then DA1's answer; a notice set to expire whose session ended expired after it; a hidden session that waited and worked again in one output posted its wait; and a notice sent while the window was active and the session shown posted nothing, the next, once the window lost focus, posted. No system showed one yet: on macOS gpui posts only from the app bundle, which asks the owner's leave first, and Windows and Linux did not run it; a press needs the system's notification center, so the owner posts and presses one on each | Partial |
|
||||||
| 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 |
|
||||||
@@ -68,8 +78,12 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `ColumnShell` with three columns; blur when the setting is on | T11, T19 | `shot` names a `BlurredView` under the window | Pass |
|
| `ColumnShell` with three columns; blur when the setting is on | T11, T19 | `shot` names a `BlurredView` under the window | Pass |
|
||||||
| Columns toggle and keep their state | T11, T23 | a click on each title bar button hid its column and saved it, and a restart opened the window with both hidden (147 columns against 87); Cmd-B with the app active; Ctrl-Shift-B on a real X11 window | Pass |
|
| Columns toggle and keep their state | T11, T23 | a click on each title bar button hid its column and saved it, and a restart opened the window with both hidden (147 columns against 87); Cmd-B with the app active; Ctrl-Shift-B on a real X11 window | Pass |
|
||||||
| The title shows the session's title, nothing else | T11 | log: "the title is", per session shown | Pass |
|
| The window opens at the size it was left at; maximized or full screen, the size before stays; a kept size past the screen shrinks to it | T30 | `a_value_out_of_range_names_its_field`; on X11 under xfwm4: the first start opened at 1200 by 760 and wrote nothing; a resize to 900 by 600 was kept with one write; maximized by xfwm4's Alt+F10 the window spanned the 1400 by 900 screen while the file kept 900 by 600, and restored it was 900 by 600 again; maximized, minimized and restored, the file kept 900 by 600; resized to 950 by 650 and minimized at once, the file kept 900 by 600 while minimized and 950 by 650 once restored; the next start opened at 950 by 650 and wrote nothing; `WM_NORMAL_HINTS` names a 100 by 100 minimum, and a resize to 900 by 50 held at 900 by 100, kept so; a kept 3000 by 2000 opened at 1400 by 900, logged; on macOS, through gpui's own window: a kept 900 by 600 opened at 900 by 600, where a centered edge on a half point had grown it to 601, and a move wrote nothing; a resize to 923 by 617, covered by an opaque window within 150 ms and uncovered, was kept once shown, and the next start opened at 923 by 617; zoomed, minimized, restored, full screen and back, the file kept 923 by 617; a 100 by 100 window was kept; a kept 5000 by 4000 opened at the visible 1920 by 1113, logged; on Windows the owner dragged v0.1.2's window to a new size, closed it, and it opened again at that size; a mouse drag on macOS not yet shown | Pass |
|
||||||
|
| The sessions and right columns resize by a drag on their border toward the terminal, 160 to 960 pixels, kept in settings | T33 | Ely `5c2822c` and `005faef`: `shell::tests::border`: a border toward the filling column drags its width, before it by its right and after it by its left; a drag stops at each limit; a drag in a header moves no window in Windows', macOS' and Linux's style (the test window panics on a move); a drag sets off from the width drawn, which holds Windows' caption buttons inside the window; a short shell squeezes both columns, and a drag moves each border the pointer's way from the width kept, a drag with no sideways move changing none (Ely `0b4848b`): on macOS 900 and 900 kept in a 1200 window drew 572 each, and a drag of 20 from the left border saved 920 and moved it right, where review round 1 saw it jump to 435; on X11 900 and 900 in a 1380 window, a drag of 40 saved 940 and moved both borders right, and a drag with no sideways move kept them; a drag out and back to where it set off gives the width it had (Ely `da3fdca`): on macOS each border dragged 50 out and back kept 240 and 240 and came back to its pixel, where review round 2 saw 290 kept, and on X11 both settled at 240 with nothing written; a resizing column with no border toward a filling one fails; `a_value_out_of_range_names_its_field` (`panel_width` 1000); on macOS under `shot`, the sessions border dragged in the header and the right border in the body moved 50 points each, saved 290 and 290 once, and the next start opened at them; on X11 under xfwm4: a drag in the body set 300, one on the right border in the header set 300 and left the window at 0,0, drags stopped at 960 and 160, the next start opened at 160, and both borders paint the theme's border color, #37384A, as before; the line had painted half a pixel on macOS and none on X11 until Ely `005faef`; no real drag on Windows yet | Partial |
|
||||||
|
| The title shows the session's title, nothing else | T11, T40 | log: "the title is", per session shown; since T40 the title comes from the tap in the output's order, and the shell tests read each shell's title from it | Pass |
|
||||||
|
| The pointer over the terminal is the arrow | T26 | the terminal sets no pointer, so gpui's arrow shows; on X11 with the pointer over the grid, gpui asked for no I-beam ("text"), which it had asked for, and failed to load under Xvfb, before | Pass |
|
||||||
| The title bar is transparent | T11, T19 | T19 review | Pass |
|
| The title bar is transparent | T11, T19 | T19 review | Pass |
|
||||||
|
| Every title bar control takes its press, Ely's window buttons on Windows and Linux too | T26 | Ely `cdc5ce8`: `window_controls_occlude_what_lies_under` (each style; before, the box under Windows' caption buttons took the press), `a_wobbling_press_on_a_slot_moves_no_window` (before, the window moved), `a_wobbling_press_on_a_column_title_moves_the_window`; on the owner's PC, window messages asked the app for Windows' hit test along the title bar: v0.1.0 answered caption from end to end, T26's build answers client over the toggles and +, caption over the titles, min, max and close over Ely's buttons, and a press on close closed the app; the owner, given v0.1.1's build and these controls to click, reported it works on Windows | Pass |
|
||||||
|
|
||||||
## 7. Sessions panel
|
## 7. Sessions panel
|
||||||
|
|
||||||
@@ -84,7 +98,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
|||||||
|
|
||||||
| PRD line | Task | Evidence | Status |
|
| PRD line | Task | Evidence | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Git and Files switch | T13 | T13 review | Pass |
|
| A switch at the top, centered below the title bar: Git, Files | T13, T26 | T13 review; a capture on X11 shows it centered below the title bar, the right toggle and Ely's window buttons in the bar above | Pass |
|
||||||
| The session's folder as a tree, folders first, Git colors | T13 | `files::tests` | Pass |
|
| The session's folder as a tree, folders first, Git colors | T13 | `files::tests` | Pass |
|
||||||
| Double press opens a folder, or a file in the default app | T13 | T13 review, with a logging `open` stub | Pass |
|
| Double press opens a folder, or a file in the default app | T13 | T13 review, with a logging `open` stub | Pass |
|
||||||
| Reloads on a new folder and at each prompt | T13 | log: "the tree shows", per folder reported | Pass |
|
| Reloads on a new folder and at each prompt | T13 | log: "the tree shows", per folder reported | Pass |
|
||||||
@@ -104,18 +118,21 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
|||||||
|
|
||||||
| PRD line | Task | Evidence | Status |
|
| PRD line | Task | Evidence | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Cmd-, opens it; categories left, rows right; changes apply at once and save | T16 | T16 review | Pass |
|
| Cmd-, opens it; categories left, rows right, each scrolling on its own; changes apply at once and save | T16, T26 | T16 review; on X11, eight wheel notches over the Terminal page carried it down to its Edit and View shortcuts while the category column and both headers stayed | Pass |
|
||||||
| General: Shell, Start in | T16 | `settings::tests` | Pass |
|
| On Windows and Linux, a gear in the title bar opens it too | T26 | on X11 under Xvfb with xfwm4, xdotool's click on the gear, just inside the right toggle, opened the 900×640 Settings window ("settings: the window opens"), after a click on + opened a session; sent through `App::dispatch_action`, the same click had opened nothing; the owner, given v0.1.1's build and the gear to click, reported it works on Windows | Pass |
|
||||||
|
| Terminal: Shell, Start in | T16, T26 | `settings::tests`; the General page is gone, and a capture on X11 shows its two rows leading Terminal | Pass |
|
||||||
| Appearance: theme grids, dark theme, font, size, line height, ligatures, cursor and preview, opacity, blur, padding | T17 | T17 review | Pass |
|
| Appearance: theme grids, dark theme, font, size, line height, ligatures, cursor and preview, opacity, blur, padding | T17 | T17 review | Pass |
|
||||||
|
| On Omarchy, the Omarchy theme first in the grid | T27 | on X11 with Omarchy's theme folder, the Omarchy card leads the grid, chosen, over a preview in its colors | Pass |
|
||||||
| Terminal: scrollback, Option as Alt (macOS), copy on select | T18 | T18 review | Pass |
|
| Terminal: scrollback, Option as Alt (macOS), copy on select | T18 | T18 review | Pass |
|
||||||
|
| Terminal: every shortcut, with a search | T26 | read from the keymap, the bindings that apply in the main window's terminal; on X11 the list showed this platform's keys (Ctrl+Shift+T, Alt+1 and on), and typing "paste" left Edit's Paste, Ctrl+Shift+V | Pass |
|
||||||
|
|
||||||
## 11. Background blur
|
## 11. Background blur
|
||||||
|
|
||||||
| PRD line | Task | Evidence | Status |
|
| PRD line | Task | Evidence | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| macOS: `Blurred` (NSVisualEffectView) | T19, T23 | `shot` reads `BlurredView, blur 30` off the view's backdrop layer; the owner saw the desktop blur | Pass |
|
| macOS: `Blurred` (NSVisualEffectView) | T19, T23 | `shot` reads `BlurredView, blur 30` off the view's backdrop layer; the owner saw the desktop blur | Pass |
|
||||||
| Windows: `Blurred` (acrylic) | T19 | the same gpui call; CI builds and tests on Windows, but no one has seen the window | Partial |
|
| Windows: `Blurred` (acrylic) | T19 | the same gpui call; on 2026-10-15 the owner saw the window blur on Windows | Pass |
|
||||||
| Wayland: `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur` | T20 | `blur::wayland` tests on a `wayland-server` compositor; no real compositor | Partial |
|
| Wayland: `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur` | T20, T28 | `blur::wayland` tests on a `wayland-server` compositor; on the owner's Omarchy 4.0.4, Hyprland 0.56.2 took `ext_background_effect_v1` from a T27 release build: the log named it and the pane's region, and the wallpaper behind the window blurred while Hyprland's blur was on and showed sharp while it was off; Plasma and GNOME not run | Partial |
|
||||||
| X11: `_KDE_NET_WM_BLUR_BEHIND_REGION`, removed when blur turns off | T21 | `blur::x11` test on Xvfb; the property on a real xfwm4 window; KWin not run | Partial |
|
| X11: `_KDE_NET_WM_BLUR_BEHIND_REGION`, removed when blur turns off | T21 | `blur::x11` test on Xvfb; the property on a real xfwm4 window; KWin not run | Partial |
|
||||||
| Neither protocol: transparency without blur; the log names the path | T20, T21 | the log lines in `src/blur` | Pass |
|
| Neither protocol: transparency without blur; the log names the path | T20, T21 | the log lines in `src/blur` | Pass |
|
||||||
|
|
||||||
@@ -125,6 +142,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| The macOS menu bar | T22 | `shot` lists every item with its key | Pass |
|
| The macOS menu bar | T22 | `shot` lists every item with its key | Pass |
|
||||||
| The same shortcuts off macOS, Ctrl-Shift where they collide | T22 | xdotool on a real X11 window: select all, size, toggle, find, quit | Pass |
|
| The same shortcuts off macOS, Ctrl-Shift where they collide | T22 | xdotool on a real X11 window: select all, size, toggle, find, quit | Pass |
|
||||||
|
| On Windows Ctrl-V pastes too, as in Windows Terminal | T26 | the binding is Windows' alone; on a real X11 window, with a command on the clipboard through xclip, Ctrl-Shift-V pasted its 33 characters and the shell ran it, and Ctrl-V pasted nothing, as Linux keeps it for programs; the owner, given v0.1.1's build and Ctrl-V to press, reported it works on Windows | Pass |
|
||||||
|
|
||||||
## 13. Acceptance
|
## 13. Acceptance
|
||||||
|
|
||||||
@@ -132,7 +150,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Each task ticked with a passing review | each task is ticked only after its review, as `tasks/reviews.md` records; T04 and T10 reached the three-round cap without a passing round, their last fixes standing on red-green tests | Partial |
|
| Each task ticked with a passing review | each task is ticked only after its review, as `tasks/reviews.md` records; T04 and T10 reached the three-round cap without a passing round, their last fixes standing on red-green tests | Partial |
|
||||||
| `scripts/check.sh` passes | macOS: 135 tests, clippy, fmt, 500 lines; Linux through `scripts/linux.sh`: 138 tests, clippy, on aarch64 in Colima and on x86_64 in WSL's Docker | Pass |
|
| `scripts/check.sh` passes | macOS: 135 tests, clippy, fmt, 500 lines; Linux through `scripts/linux.sh`: 138 tests, clippy, on aarch64 in Colima and on x86_64 in WSL's Docker | Pass |
|
||||||
| CI green on macOS, Ubuntu and Windows | run 926 on `65bb4d3`: all three jobs pass; see the CI notes below | Pass |
|
| CI green on macOS, Ubuntu and Windows | run 955 on `43ea5b1`, v0.1.1's commit: all three jobs pass; see the CI notes below | Pass |
|
||||||
|
|
||||||
## 14. Icon and the macOS app
|
## 14. Icon and the macOS app
|
||||||
|
|
||||||
@@ -140,16 +158,16 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| The icon: Ely, a girl with long pink hair, holding a terminal | T24 | `assets/icons`, drawn at 16 to 1024 px and legible at 32; the owner saw it | Pass |
|
| The icon: Ely, a girl with long pink hair, holding a terminal | T24 | `assets/icons`, drawn at 16 to 1024 px and legible at 32; the owner saw it | Pass |
|
||||||
| macOS: the rounded square; Windows and Linux: the figure alone, flat | T24 | `elyterminal-macos.svg` on Apple's grid; `elyterminal.svg`, which the Linux tarball carries and the Windows `.ico` renders | Pass |
|
| macOS: the rounded square; Windows and Linux: the figure alone, flat | T24 | `elyterminal-macos.svg` on Apple's grid; `elyterminal.svg`, which the Linux tarball carries and the Windows `.ico` renders | Pass |
|
||||||
| `scripts/mac-app.sh` builds the app, signed ad hoc, and its disk image | T24 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal` | Pass |
|
| `scripts/mac-app.sh` builds the app, signed ad hoc, or for a release with Developer ID and notarized, and its disk image | T24, T26, T31, T41 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal`; for v0.1.1 the Release workflow (run 959) ran it on `macos-signing`: notarization Accepted, the ticket stapled, and on this Mac the downloaded image and its app pass `codesign --verify --strict` under the Developer ID and Gatekeeper's `accepted, source=Notarized Developer ID`; the owner opened it; for v0.1.2 the Release workflow (run 1001) ran it again: the downloaded image's stapled ticket validates and Gatekeeper accepts it as `Notarized Developer ID`, and its app, version 0.1.2, passes `codesign --verify --strict --deep` under the Developer ID with the hardened runtime and is accepted the same way; for v0.1.3 the Release workflow (run 1157) ran it again, and its image and app, version 0.1.3, check the same way; without the key the script stops before building | Pass |
|
||||||
|
|
||||||
## 15. Release
|
## 15. Release
|
||||||
|
|
||||||
| PRD line | Task | Evidence | Status |
|
| PRD line | Task | Evidence | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25 | published after T25's review | Open |
|
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31, T41 | `v0.1.3` at `7569f00`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1157), signed and notarized; published once CI run 1156 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag names `7569f00`. `v0.1.2` at `4364802`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1001), signed and notarized; published once CI run 1000 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag names `4364802`. `v0.1.1` at `43ea5b1`: the zip from its script on the owner's PC, the disk image from the Release workflow, signed and notarized; the Linux tarball, rebuilt for T27 from `aae7fba` by its script on the owner's PC under Omarchy, took the first one's place, and the notes say so; each downloaded back anonymously, once the repository was public, matches the SHA-256 in the release notes. `v0.1.0` at `0075667`, its three files built from that commit by their scripts and checked again: the disk image verifies and its app passes `codesign --verify --strict`; the zip's executable has the icon and no C runtime DLL; the tarball opens its window on Ubuntu 22.04; each file downloaded back matches the SHA-256 in the release notes | Pass |
|
||||||
| The Windows executable carries the icon | T25 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen | Pass |
|
| The Windows executable carries the icon | T25, T31, T41 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen; v0.1.2's and v0.1.3's executables the same | Pass |
|
||||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same | Pass |
|
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27, T41 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same; v0.1.2's passes the same checks on Ubuntu 22.04, run in the owner's WSL, `WM_CLASS` too; T27's rebuild passes the same checks on Ubuntu 22.04, whose llvmpipe (LLVM 15) leaves the window black in a capture, the first tarball's too, while Debian 13's paints it; v0.1.3's holds the same three files, its desktop entry names `com.zacharyzhang.elyterminal`, and its binary asks for glibc 2.35 at most | Pass |
|
||||||
| Each build comes from its script | T25 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL | Pass |
|
| Each build comes from its script | T25, T27, T31, T41 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL, and for T27 on the owner's PC under Omarchy; v0.1.2's and v0.1.3's three the same way, the disk image's on `macos-signing` | Pass |
|
||||||
|
|
||||||
## CI notes
|
## CI notes
|
||||||
|
|
||||||
@@ -165,6 +183,8 @@ Run 907 then failed the PowerShell test on macOS: it typed its probe right after
|
|||||||
|
|
||||||
Run 926, on `65bb4d3`, passed all three jobs: macOS ran `scripts/check.sh` (135 tests, the fonts check, clippy, fmt, the 500-line rule) and the build; Ubuntu ran 138 tests and the build; Windows ran 113 tests and the build, without the Unix-only pty and shell tests.
|
Run 926, on `65bb4d3`, passed all three jobs: macOS ran `scripts/check.sh` (135 tests, the fonts check, clippy, fmt, the 500-line rule) and the build; Ubuntu ran 138 tests and the build; Windows ran 113 tests and the build, without the Unix-only pty and shell tests.
|
||||||
|
|
||||||
|
After run 928 cargo failed on the Windows PC: `Swatinem/rust-cache`'s post step had deleted `rustup` from the host's `~/.cargo/bin`, on the Mac mini too, leaving the proxies pointing at nothing. The cache step now runs only in the Linux container. On the hosts, `rustup toolchain install --no-self-update` reads `rust-toolchain.toml` in place of dtolnay's action, which ran `rustup default`, so CI leaves their default toolchain alone. Run 934, on `0075667`, passed all three jobs, each on 1.95.0 "overridden by rust-toolchain.toml".
|
||||||
|
|
||||||
## The owner's checks
|
## The owner's checks
|
||||||
|
|
||||||
On 2026-10-15 the owner ran the app on macOS and found three faults, now fixed:
|
On 2026-10-15 the owner ran the app on macOS and found three faults, now fixed:
|
||||||
@@ -172,3 +192,10 @@ On 2026-10-15 the owner ran the app on macOS and found three faults, now fixed:
|
|||||||
- The desktop showed through sharp: on macOS 27 gpui's `Selection` material builds no blur; the window's view now takes `underWindowBackground`, radius 30.
|
- The desktop showed through sharp: on macOS 27 gpui's `Selection` material builds no blur; the window's view now takes `underWindowBackground`, radius 30.
|
||||||
- The debug build lagged; the release build does not.
|
- The debug build lagged; the release build does not.
|
||||||
- No button hid the side columns; each now has one at its end of the title bar, its state saved.
|
- No button hid the side columns; each now has one at its end of the title bar, its state saved.
|
||||||
|
|
||||||
|
On 2026-10-16 the owner ran it on Omarchy 4.0.4 under Hyprland:
|
||||||
|
|
||||||
|
- No window blurred: Omarchy turns Hyprland's own blur off, and Hyprland then blurs nothing; T28 says how to turn it on.
|
||||||
|
- The v0.1.1 Linux tarball built from `aae7fba`, installed by hand, wore the Omarchy theme.
|
||||||
|
|
||||||
|
On 2026-10-16 the owner ran v0.1.2 on Windows: dragged to a new size, closed and opened again, the window opened at that size.
|
||||||
@@ -120,3 +120,96 @@ Each task gets at most three codex rounds. Each round: the commit, the verdict,
|
|||||||
## T24
|
## T24
|
||||||
|
|
||||||
- Round 1, c0a9eb3..65bb4d3: PASS. Before the review, 65bb4d3 named the icon's parts as SVG groups instead of comments, drawing the same pixels, and made mac-app.sh read the version in a clone named elyterminal. CI run 926 on 65bb4d3 passed on macOS, Ubuntu and Windows.
|
- Round 1, c0a9eb3..65bb4d3: PASS. Before the review, 65bb4d3 named the icon's parts as SVG groups instead of comments, drawing the same pixels, and made mac-app.sh read the version in a clone named elyterminal. CI run 926 on 65bb4d3 passed on macOS, Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T25
|
||||||
|
|
||||||
|
- Round 1, 2b8ae5d and 0075667: FAIL. The v0.1.0 release was not yet on Gitea, so the task line's release stood open.
|
||||||
|
- Round 2, 59e41ea: PASS. v0.1.0 at 0075667 carries the disk image, the Windows zip and the Linux tarball; the reviewer downloaded each and found its SHA-256, signature, icon, manifest and glibc as recorded. CI run 934 on 0075667 passed on macOS, Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T26
|
||||||
|
|
||||||
|
- Round 1, 6f7d3a9 and 0f87676 with Ely cdc5ce8: FAIL. v0.1.1 was still a draft, so the task line's release stood open.
|
||||||
|
- Round 2, c316d27, 90a4625, 7bfb462, 43ea5b1 and 194c7bb: PASS. The owner's later asks went in first: a gear for Settings on Windows and Linux, Ctrl-V paste on Windows, the arrow pointer, the settings window without General, with the shortcut list and with columns that scroll apart, and the release's disk image signed with Developer ID and notarized on the macos-signing runner. v0.1.1 at 43ea5b1 is published and the repository public; the reviewer downloaded each file anonymously, checked its hash, signature and notarization, and drove the app on X11 and macOS. CI run 955 on 43ea5b1 passed on macOS, Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T27
|
||||||
|
|
||||||
|
- Round 1, 340c7a9 and d8ebade: FAIL. A failed read marked the file's stamp as seen, so a theme file made readable again was never worn; `is_file`, `exists` and `metadata().ok()` took a permission error for absence, so a fresh install wore Catppuccin Mocha over an unreadable Omarchy folder, and an outage while following logged nothing. Fixed in aae7fba: the watcher compares the file's text, logs a failure once while it lasts and tries again; only a missing file means no Omarchy, and a file that cannot be read stops the start. The v0.1.1 Linux tarball was rebuilt from aae7fba on the owner's PC, which now runs Omarchy, and took the place of the one from 340c7a9 (1faf8ca).
|
||||||
|
- Round 2, aae7fba and 1faf8ca: PASS. The reviewer reproduced both faults on the parent build and saw them gone, ran all 22 of Omarchy's themes through the window, and matched the new tarball's download to its SHA-256. CI run 976 on aae7fba passed on macOS and Ubuntu; its Windows job waits for the PC to run Windows again.
|
||||||
|
|
||||||
|
## T28
|
||||||
|
|
||||||
|
- Round 1, 741e48b with the v0.1.1 release notes: PASS. The reviewer read the new lines against Hyprland 0.56.2's `shouldBlur` and background-effect protocol and Omarchy 4.0.4's default `looknfeel.lua`, and the owner's captures with Hyprland's blur on and off against the acceptance row, which stays Partial for Wayland.
|
||||||
|
|
||||||
|
## T29
|
||||||
|
|
||||||
|
- Round 1, 3e30f95: FAIL. A shell that ran `exec herdr` kept its process group, so the session never looked at its foreground again and the panel stayed where it was; a relative `HERDR_SOCKET_PATH` was looked up from ELYTerminal's own folder, not the client's, so no socket answered. Fixed in 2af45cb: the foreground is its group and the executable its leader runs, a program that did not read is tried again, and a relative socket lies in the client's folder (`/proc/PID/cwd`, `proc_pidinfo` on macOS); on Linux an executable an update replaced still names itself herdr.
|
||||||
|
- Round 2, 2af45cb: PASS. The reviewer ran real herdr 0.9.5 under the native view: a direct start, a delayed exec, a relative socket, both at once, and a folder with a space and an é each gave the folder events herdr's own snapshot named. CI run 988 on 3e30f95 and run 990 on 2af45cb passed on macOS, Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T30
|
||||||
|
|
||||||
|
- Round 1, 1bf420c: FAIL. On X11 a maximized window, minimized, wrote the screen's size, since gpui's X11 window calls a maximized window unmaximized while it is hidden; and the window could be dragged under the 100 by 100 the settings keep, so a smaller size was refused and lost. Fixed in 2f58a06: a hidden window keeps the size from before, and the window is no smaller than 100 by 100.
|
||||||
|
- Round 2, 2f58a06: FAIL. A size the window took just before something covered it was skipped while it was hidden and never read again once it showed. Fixed in 9729d24: a window that shows again is read again; and the window opens centered at whole points, since macOS grew a window whose centered edge fell on a half point by a point, so a kept 900 by 600 had opened at 900 by 601.
|
||||||
|
- Round 3, 9729d24: PASS. The reviewer replayed the cover on macOS through gpui and AppKit, against the parent too, and saw the size kept once shown and the restart open at it; the window opened at its kept size and a move wrote nothing; on X11 a maximized, minimized and restored window and a resize minimized at once kept the right size, and the 100 by 100 minimum held. CI runs 993 on 1bf420c and 995 on 2f58a06 passed on macOS, Ubuntu and Windows; run 996 on 9729d24 passed on Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T31
|
||||||
|
|
||||||
|
- Round 1, 4364802 and 2a1060c, with release v0.1.2: PASS. The reviewer compared `Cargo.lock` with its parent, where only elyterminal moved to 0.1.2; matched the 112 files of the source the builds came from to the tag byte for byte; downloaded the three files anonymously against their SHA-256s; checked the disk image's notarization and its app's signature, the zip's icon, manifest and imports, and the tarball's glibc 2.35; and ran the downloaded macOS app with herdr 0.9.5: the folder followed herdr's focus, and the window reopened at its kept 923 by 617. CI run 1000 on 4364802 passed on macOS, Ubuntu and Windows before the release was published; Release run 1001 built the notarized disk image.
|
||||||
|
|
||||||
|
## T32
|
||||||
|
|
||||||
|
- Round 1, d7dc1af: FAIL. On Windows the search took the herdr nearest under the shell, so a server the client started under itself, or one started beside it, was read as the session's herdr; its arguments named no client, nothing was tried again, and the panel stayed where it was. Fixed in 3b5bf46: the herdr processes at or under the shell are listed, none under another herdr, whose server and panes are its own; when that list changes their arguments are read, nearest first, and a server or a command beside the client is passed by.
|
||||||
|
- Round 2, 3b5bf46: PASS. The reviewer ran the committed search and pick against both trees, a nested server, a stale parent loop, an unread process that reads later, a client's start, end and return, and a remote client, eight probes; undid each half of the fix in a copy and saw its regression test fail; and on macOS saw a herdr answer that came 800 ms after the shell's report dropped. CI run 1027 on d7dc1af passed on macOS, Ubuntu and Windows, run 1046 on 3b5bf46 on Ubuntu and Windows, the Windows runner running 120 and then 121 tests; the Linux suite and the real-herdr X11 run passed on both. No real herdr has run on Windows yet.
|
||||||
|
|
||||||
|
## T33
|
||||||
|
|
||||||
|
- Round 1, 6336c05 and da55942, with Ely 5c2822c and 005faef: FAIL. In a window too narrow for both kept widths, a drag set off from the squeezed width drawn, and the width it reported was squeezed again: the border jumped back against the pointer, and a drag with no sideways move saved the squeezed width. Fixed in 2173c74 through Ely 0b4848b: a drag sets off from the wider of the width drawn and the one kept, and one that has not moved sideways changes nothing.
|
||||||
|
- Round 2, 2173c74, with Ely 0b4848b: FAIL. A drag that went out and came back where it set off kept the last width it had reported, since its move back to the press was taken for no move. Fixed in 3ea8b61 through Ely da3fdca: a drag back where it set off gives the width it had, and the width a drag sets off from lives with its press, so each press starts afresh.
|
||||||
|
- Round 3, 3ea8b61, with Ely da3fdca: PASS. The reviewer replayed drags out and back on both borders on macOS, at 240 and squeezed at 900 and 900, presses one after another, a second drag with no sideways move, a drag past a limit and back, and a column hidden and shown again, and saw each width and border come back; Ely's round-trip test failed on 0b4848b and passed on da3fdca. Before the review, testing found the handle's line painted half a pixel on macOS and none on X11, since gpui paints a border no wider than half its box; Ely 005faef draws it filled. CI runs 1058 on da55942 and 1065 on 2173c74 passed on macOS, Ubuntu and Windows; run 1067 on 3ea8b61 passed on Ubuntu and Windows, its macOS job queued behind Ely's. No real drag on Windows yet.
|
||||||
|
|
||||||
|
## T34
|
||||||
|
|
||||||
|
- Round 1, c53b540: FAIL. PowerShell called a cmdlet's failure under `-ErrorAction Ignore` a success and read 1 for a native exit under `$PSNativeCommandUseErrorActionPreference`; bash 3.2 sent no C or D for a line that is only a subshell, and none at all while the user held a DEBUG trap; zsh left the first prompt unmarked under a theme that sets PS1 in its own hook. Testing before the review had found PowerShell's prompt broken under strict mode. Fixed in 0a22200: PowerShell reads a native code by the new object each run stores; bash 3.2 runs the user's trap after its own and keeps `$_`, which its trap had overwritten, and a lone subshell's D comes from the command number; zsh wraps the first prompt again through sched, which would reset `$pipestatus` on later prompts.
|
||||||
|
- Round 2, 0a22200: FAIL. Under `extdebug` the bash 3.2 trap's status skipped the next command; a lone subshell still sent no C on bash 3.2; PowerShell gave a cmdlet's failure the code of a program run earlier on the line. A probe then found the trap running for key bindings as for typed commands, with nothing in it to tell them apart. Fixed in d4bc6e6: bash 3.2 drops its DEBUG trap and sends no C, its D from the command number; PowerShell reads the line's syntax tree, since a hook on every command lookup slowed a loop by half; each check waits for the prompt before its line, so a repeated status is the line's own.
|
||||||
|
- Round 3, d4bc6e6: FAIL. The reviewer accepted bash 3.2 without C. PowerShell read 1 for a call through a variable, and bash without `promptvars` marked no command and said nothing. Fixed in 2c25116, after the last round, each with a check that fails on d4bc6e6: a line keeps a program's code unless it surely ends in PowerShell, and bash without `promptvars`, zsh without `prompt_percent` and PowerShell without PSReadLine say at start what they cannot mark. CI runs 1071 on c53b540, 1074 on 0a22200, 1076 on d4bc6e6 and 1077 on 2c25116 passed on macOS, Ubuntu and Windows; the Linux suite ran fish 4.0 and bash 5.2, CI's Ubuntu fish 3.7.
|
||||||
|
|
||||||
|
## T35
|
||||||
|
|
||||||
|
- Round 1, 15e83f5: FAIL. A prompt that opens a link of its own lost its mark after that link, and a mark inside a link left open took over the program's target; a blank row inside a prompt split it in two; the rewrite made `133;AB`, which the session refuses, a prompt. Fixed in 310c219: an OSC that may be a mark is held until it ends and read as the session reads it, and each prompt's link carries the prompt's number.
|
||||||
|
- Round 2, 310c219: FAIL. A prompt all inside a program's link carried no number, nor a first row that was; an ESC followed by 1 MiB of controls held them all, and the tap stopped reading. Fixed in e2bb615: a program's link inside a prompt keeps its target and carries the prompt's number in its id, a prompt inside a link left open opens it again with the number, and controls inside an escape go on before the ESC.
|
||||||
|
- Round 3, e2bb615: FAIL. A target over 256 bytes went unnumbered, and the end of such a prompt left output numbered; controls after an OSC's closing ESC went on before it, so the parser, still inside the OSC, dropped a line break. Fixed in 11ec0ef, after the last round, each with a check that fails on e2bb615: targets are kept whole as the scanner keeps them, and the tap reads about 2 KB at a time to hold the growth, as fast through a pty as larger reads; an ESC that ends an OSC the parser has goes on at once. The reviewer's 33 round 3 probes then ran against 11ec0ef: 32 passed, and the one left counts 200 reads, which carry 2 KB each now, not a stall, as its pty twin showed READY. CI runs 1080 on 15e83f5, 1082 on 310c219, 1083 on e2bb615 and 1085 on 11ec0ef passed on macOS, Ubuntu and Windows. The keys off macOS are bound, not run.
|
||||||
|
|
||||||
|
## T36
|
||||||
|
|
||||||
|
- Round 1, 42b272e: FAIL. A clear dropped the records before its other pairs were checked, so a clear with a newline in its msg emptied them; only the last of a repeated key was checked, so an earlier app over 32 bytes passed; the 4096-byte bound counted the body after the scanner skipped its controls, so 5018 bytes passed; the scanner left an escape at DEL or a high byte, which vte passes by, so `ESC DEL c` reset the screen and kept the records. Fixed in 4a269cb: every pair of a known key is checked, a clear's too, before the last of each wins; the scanner counts a sequence from OSC through ST with its skipped controls; the scanners and the prompt rewrite pass DEL and high bytes by in an escape.
|
||||||
|
- Round 2, 4a269cb: PASS. The reviewer's probes failed on 42b272e and passed on 4a269cb at every read split; in the app on macOS the clear with a newline, the repeated long app and the 5018-byte report were refused, a full reset emptied the records, and a program read the query's answer before DA1's. CI runs 1089 on 42b272e and 1090 on 4a269cb passed on macOS, Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T37
|
||||||
|
|
||||||
|
- Round 1, ef33a0d: FAIL. OSC 99's base64 was decoded and checked as UTF-8 chunk by chunk, so a payload split inside a group or inside a character dropped; decoded text was refused for a newline, which only plain text may not hold; the scanner bounded the whole body at 4096 bytes, so a full 4096-byte encoded payload with its metadata dropped and its next chunk showed alone. Fixed in 628ed6b: base64 is read group by group, so chunks split before encoding or after join, and the text is checked as UTF-8 once whole; plain text alone refuses controls; a chunk that breaks a rule drops what its notification gathered; the scanner keeps an OSC 99 body to 5124 bytes, room for 1024 bytes of metadata and a full payload.
|
||||||
|
- Round 2, 628ed6b: PASS. In the app on macOS the round 1 probes gave Hello, the split emoji, both lines of the body and 3072 `a`s with END; boundary probes passed 1,627 chunkings after encoding and 433 before, and the scanner 5,140 read splits; the round 1 probes failed again on ef33a0d and with the scanner's old bound. CI runs 1093 on ef33a0d and 1095 on 628ed6b passed on macOS, Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T38
|
||||||
|
|
||||||
|
- Round 1, a175b18: FAIL. herdr's records took ids under `herdr/`, which a program may name through OSC 7501, so a program's `herdr/build` went at herdr's first snapshot, an empty one too, and at its leaving. Fixed in 7d56063: herdr's records take `herdr:` before the pane, a colon no OSC 7501 id holds, since a program's id ends at one; the test that keeps a program's `herdr/build` and `herdr` through an empty snapshot, a full one and herdr's leaving fails with the old prefix.
|
||||||
|
- Round 2, 7d56063: PASS. The reviewer's round 1 probes failed on a175b18 and passed on 7d56063; probes with a socket and JSON replies of the reviewer's own, under real GPUI scheduling and the project's pty, passed the states and fields, clear and reset, errors, a reply after the shell's folder, and herdr's leaving; no herdr server of the owner's was asked. CI runs 1127 on 72acbe4, 1128 on a175b18 and 1139 on 7d56063 passed on macOS, Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T39
|
||||||
|
|
||||||
|
- Round 1, 85bcbf4: FAIL. A hidden session that waited, worked and waited again in one output lost its mark, since the workspace heard the records once an output; an open hover kept the text it opened with; ten long records ran past the window and could not be read; at 0% the ring vanished on a selected row in Ely Dark, its track the row's ground. Fixed in 53b8978: the view notes each change of the state its row shows and tells the workspace of each; hover reads the records and the last command's status, now the view's, at each frame, stays under the pointer and scrolls within the window; the ring holds Ely's loader.
|
||||||
|
- Round 2, 53b8978: FAIL. herdr's records replaced in the same state left an open hover as it was, since nothing drew the view again; a hidden session that waited and worked again in one output still lost its mark, since the workspace checked the state at the output's end. Fixed in 3aaeb36: a change of herdr's records draws the view again; a hidden session's turn to waiting marks its row though it went on within the same output.
|
||||||
|
- Round 3, 3aaeb36 at efda50f: PASS. In the app on macOS the reviewer's repros of all three rounds passed: herdr's new records showed in an open hover through a mock herdr of the reviewer's own, a hidden session that waited and worked, or waited and finished, in one output kept its mark until shown, and the hover, long list, scrolling and Ely Dark 0% repros held. CI runs 1142 on b504bfb, 1144 on 85bcbf4, 1146 on 53b8978 and 1149 on efda50f, which holds 3aaeb36, passed on macOS, Ubuntu and Windows.
|
||||||
|
|
||||||
|
## T40
|
||||||
|
|
||||||
|
- Pre-review, 4e029f1, a first version kept in a clone and never pushed: three Claude agents read it against kitty's spec, gpui's platform code, and the PRD and rules. They found the alive answer behind a later DA1, since the workspace answered it after the output; close and expiry claimed on Linux, where gpui closes nothing; an expiry lost with its session; a body read as markup on Linux; a press lost with its record; bash 3.2's redrawn D posting again; and no test of the posting conditions. 31de5e4 rewrote it so each session keeps its notices in its terminal view, where answers keep their order; 52576bb fixed the rest; efda50f posts at each turn of the row, as T39's round 2 set the waiting mark.
|
||||||
|
- Round 1, 31de5e4 to 52576bb: FAIL. The first prompt, sent at 31de5e4, was lost while Codex started, so the round ran at 52576bb. A notification took the session's title from before a title set or a full reset in the same output, since the tap reads a notice before alacritty's parser reads the title. Fixed in 22752a2: the tap follows OSC 0 and 2, the title stack and full resets, and sends each title in order.
|
||||||
|
- Round 2, 22752a2: FAIL. alacritty's later title events still overwrote the tap's, so a full reset, 2 KB more output and a notice named the old title; the tap kept 17 parts of a title where vte keeps 15, and dropped a title over 4096 bytes that vte keeps. Fixed in 140c2a5: the title comes from the tap alone, so the row, the window and a notification name the session alike; the tap splits a title as vte does and keeps 65536 bytes of one.
|
||||||
|
- Round 3, 140c2a5: PASS. In the app on macOS, outside the bundle, where the log names each post, the reviewer's probes of all three rounds passed: a title set in one write or split, after a reset with up to 32 KB more output, of 17 parts and of 4100 bytes; 21 title cases matched the pinned vte and alacritty; and the lifecycle, rate limit, untracked, alive before DA1, expiry, close, in-view and press probes held. No system has shown a notification yet; the owner posts and presses one on each. CI runs 1148 on 31de5e4, 1149 on efda50f and 1150 on 52576bb passed on macOS, Ubuntu and Windows; 140c2a5's code is CI'd on T41's version commit, which holds it.
|
||||||
|
|
||||||
|
## T41
|
||||||
|
|
||||||
|
- Round 1, 7569f00 with release v0.1.3: PASS. The reviewer compared `Cargo.lock` with its parent, where of 844 packages only elyterminal moved, to 0.1.3; matched the 127 files of the tag's source archive to 7569f00 byte for byte; downloaded the three files anonymously against their SHA-256s; checked the disk image's stapled notarization and its app's Developer ID signature, hardened runtime and version 0.1.3, the zip's icon and manifest against their sources byte for byte and its imports, no C runtime DLL among them, and the tarball's three files and glibc 2.35; and matched the notes to T32 to T40. The app was not run, since its first notification asks the owner's leave. CI run 1156 on 7569f00 passed on macOS, Ubuntu and Windows before the release was published; Release run 1157 built the notarized disk image.
|
||||||
|
|
||||||
|
## T42
|
||||||
|
|
||||||
|
- No review: the owner waived it for this change.
|
||||||
Reference in new issue
Block a user