Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
||||
@@ -22,17 +22,18 @@ 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`.
|
||||
- `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/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/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
|
||||
|
||||
- `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/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page, `window/terminal` its Terminal page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `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/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; `herdr` follows herdr's focused pane through herdr's socket while herdr's client leads the pty's foreground. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
|
||||
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
@@ -62,28 +63,34 @@ 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: 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: 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: 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: 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 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: 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: 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: 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-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: 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: 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: 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: 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. Focus belongs to herdr's server, so clients of one server follow one focus; a client of another machine's server (`--remote`, `--machine`) is logged, not followed. A failure is logged once while it lasts. Windows has none of this: there herdr is a beta on named pipes.
|
||||
- 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.
|
||||
Generated
+2
-2
@@ -1701,7 +1701,7 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
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=cdc5ce8247b58a876ffed9626953c3cb490a67ed#cdc5ce8247b58a876ffed9626953c3cb490a67ed"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -1747,7 +1747,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "elyterminal"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "elyterminal"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
@@ -12,7 +12,7 @@ publish = false
|
||||
alacritty_terminal = { version = "=0.26.0", default-features = false }
|
||||
anyhow = "1"
|
||||
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 = "cdc5ce8247b58a876ffed9626953c3cb490a67ed" }
|
||||
env_logger = "0.11"
|
||||
futures = "0.3"
|
||||
gethostname = "1.1.0"
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -36,6 +36,7 @@ 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 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.
|
||||
- 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
|
||||
|
||||
@@ -43,6 +44,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
|
||||
- PTY: alacritty's `tty` and event loop. The pty is wrapped so its reader passes bytes through an OSC 7 scanner. The scanner reports the working directory.
|
||||
- Shell integration for zsh, bash, fish and PowerShell, injected at launch as Ghostty does: each prompt sends OSC 7 and a title. A shell without integration reports nothing, and the panels say so.
|
||||
- Inside herdr, which keeps its panes' OSC 7, the session's folder is that of herdr's focused pane, read from herdr's socket while herdr's client runs in the foreground, on macOS and Linux.
|
||||
- Grid: colors (named, 256, truecolor, OSC 4/10/11/12), bold, dim, italic, underline (single, double, curly, dotted, dashed), strikethrough, inverse, hidden, wide characters, combining marks.
|
||||
- Box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) painted as geometry, so lines and segments join across cells.
|
||||
- Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font.
|
||||
@@ -59,8 +61,11 @@ 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.
|
||||
- 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 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 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.
|
||||
- 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
|
||||
|
||||
@@ -71,7 +76,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -87,35 +92,35 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
|
||||
## 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
|
||||
- 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.
|
||||
- Font family: a list of installed fonts. Font size. Line height. Ligatures.
|
||||
- Cursor: style (block, bar, underline, hollow), blink, with a live preview.
|
||||
- Background opacity: a slider. Background blur: a switch.
|
||||
- Padding: horizontal and vertical.
|
||||
- Terminal
|
||||
- Shell: a path; empty runs the login shell.
|
||||
- Start in: Home, or the active session's folder.
|
||||
- Scrollback lines.
|
||||
- Option as Alt: off, left, right, both (macOS only).
|
||||
- Copy on select.
|
||||
- Shortcuts: every shortcut, as this platform's keys, with a search.
|
||||
|
||||
## 11. Background blur
|
||||
|
||||
- macOS: gpui's `WindowBackgroundAppearance::Blurred` (NSVisualEffectView).
|
||||
- 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.
|
||||
- A compositor with neither protocol shows transparency without blur. The app logs which path it took.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
@@ -127,13 +132,13 @@ 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.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
- 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.
|
||||
- Linux, x86_64, glibc 2.35 or later, with libxkbcommon, libxkbcommon-x11 and libxcb:
|
||||
|
||||
@@ -44,14 +44,16 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
||||
- The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; find searches the scrollback.
|
||||
- Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title.
|
||||
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound.
|
||||
- A button at each end of the title bar hides or shows its side column; the window opens as it was left.
|
||||
- Files shows the session's folder as a tree with Git's marks.
|
||||
- A button at each end of the title bar hides or shows its side column; the window opens as it was left, at the size 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, on macOS and Linux, the folder is that of herdr's focused pane.
|
||||
- Git shows the branch, stages, unstages, discards, commits, lists recent commits and opens diffs, all through the `git` command line.
|
||||
- Themes: Ely Dark, Ely Light, Catppuccin Mocha, Macchiato, Frappé and Latte, Tokyo Night, Dracula, Nord, Gruvbox Dark, Rosé Pine, Solarized Light, One Light and Kanagawa Wave. A second theme can take over while the system is dark.
|
||||
- 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.
|
||||
- Themes: Ely Dark, Ely Light, Catppuccin Mocha, Macchiato, Frappé and Latte, Tokyo Night, Dracula, Nord, Gruvbox Dark, Rosé Pine, Solarized Light, One Light and Kanagawa Wave. A second theme can take over while the system is dark. On Omarchy 4, an Omarchy theme follows the system's theme as it switches, and is the default there.
|
||||
- Background opacity and blur: NSVisualEffectView on macOS, acrylic on Windows, `ext_background_effect_v1` or `org_kde_kwin_blur` on Wayland, `_KDE_NET_WM_BLUR_BEHIND_REGION` on X11, which KWin reads. Blur shows only while the opacity is under 100%. Hyprland blurs only while its own `decoration:blur:enabled` is on, and Omarchy 4.0.4 turns it off; to turn it on there, add `hl.config({ decoration = { blur = { enabled = true } } })` to `~/.config/hypr/looknfeel.lua`.
|
||||
|
||||
## Keys
|
||||
|
||||
Settings › Terminal lists them all, as this platform binds them.
|
||||
|
||||
| | macOS | Windows and Linux |
|
||||
|---|---|---|
|
||||
| New session | Cmd-T | Ctrl-Shift-T |
|
||||
@@ -60,7 +62,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 |
|
||||
| Sessions column | Cmd-B | Ctrl-Shift-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 |
|
||||
| Find | Cmd-F | Ctrl-Shift-F |
|
||||
| Bigger, smaller, reset size | Cmd-=, Cmd-minus, Cmd-0 | Ctrl-=, Ctrl-minus, Ctrl-0 |
|
||||
|
||||
@@ -67,4 +67,19 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
|
||||
|
||||
## 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.
|
||||
- [ ] 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.
|
||||
+31
-2
@@ -1,7 +1,18 @@
|
||||
#!/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
|
||||
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
|
||||
version=$(cargo pkgid)
|
||||
version=${version##*[#@]}
|
||||
@@ -38,11 +49,29 @@ cat > "$app/Contents/Info.plist" <<EOF
|
||||
</plist>
|
||||
EOF
|
||||
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"
|
||||
ditto "$app" "$work/disk/ELYTerminal.app"
|
||||
ln -s /Applications "$work/disk/Applications"
|
||||
dmg=$out/ELYTerminal-$version.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: $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"
|
||||
+90
-6
@@ -1,10 +1,10 @@
|
||||
use std::borrow::Cow;
|
||||
use std::{borrow::Cow, cell::RefCell, rc::Rc, time::Duration};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use ely_gpui_component::theme::ActiveTheme;
|
||||
use gpui::{
|
||||
App, AppContext, Bounds, TitlebarOptions, WindowBounds, WindowDecorations, WindowHandle,
|
||||
WindowOptions, px, size,
|
||||
App, AppContext, Bounds, Context, Pixels, Size, Task, TitlebarOptions, Window, WindowBounds,
|
||||
WindowDecorations, WindowHandle, WindowOptions, px, size,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -17,6 +17,9 @@ use crate::{
|
||||
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.
|
||||
pub const APP_ID: &str = "com.zacharyzhang.elyterminal";
|
||||
|
||||
@@ -64,6 +67,8 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
mac::repeat_held_keys();
|
||||
mac::deliver_command_key_ups()?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
themes::omarchy::start(cx)?;
|
||||
themes::apply(cx);
|
||||
let chosen = |settings: &settings::Settings| {
|
||||
let theme = (settings.theme.clone(), settings.dark_theme.clone());
|
||||
@@ -83,10 +88,16 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
|
||||
/// Opens the main window running `launch`.
|
||||
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 least = px(*settings::WINDOW.start());
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
// No smaller than the settings keep.
|
||||
window_min_size: Some(size(least, least)),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(TITLE.into()),
|
||||
appears_transparent: true,
|
||||
@@ -103,11 +114,84 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
|
||||
window
|
||||
.observe_window_appearance(|_, cx| themes::apply(cx))
|
||||
.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!(
|
||||
"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
|
||||
);
|
||||
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();
|
||||
}
|
||||
+19
-5
@@ -75,6 +75,9 @@ pub struct Settings {
|
||||
pub show_sessions: bool,
|
||||
pub show_panel: bool,
|
||||
pub panel: PanelTab,
|
||||
/// The main window's size, unmaximized, in pixels.
|
||||
pub window_width: f32,
|
||||
pub window_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -82,7 +85,7 @@ impl Default for Settings {
|
||||
Self {
|
||||
shell: String::new(),
|
||||
start_in: StartIn::Home,
|
||||
theme: "catppuccin-mocha".into(),
|
||||
theme: themes::default_id().into(),
|
||||
dark_theme: None,
|
||||
font_family: "JetBrains Mono".into(),
|
||||
font_size: 13.0,
|
||||
@@ -100,6 +103,8 @@ impl Default for Settings {
|
||||
show_sessions: true,
|
||||
show_panel: true,
|
||||
panel: PanelTab::Git,
|
||||
window_width: 1200.0,
|
||||
window_height: 760.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +114,7 @@ pub const LINE_HEIGHT: RangeInclusive<f32> = 1.0..=2.0;
|
||||
pub const OPACITY: RangeInclusive<f32> = 0.3..=1.0;
|
||||
pub const PADDING: RangeInclusive<f32> = 0.0..=64.0;
|
||||
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
|
||||
pub const WINDOW: RangeInclusive<f32> = 100.0..=16384.0;
|
||||
|
||||
impl Settings {
|
||||
/// Every value inside its range, every theme built in; the error names the field.
|
||||
@@ -119,15 +125,14 @@ impl Settings {
|
||||
within("padding_x", self.padding_x, &PADDING)?;
|
||||
within("padding_y", self.padding_y, &PADDING)?;
|
||||
within("scrollback", self.scrollback, &SCROLLBACK)?;
|
||||
within("window_width", self.window_width, &WINDOW)?;
|
||||
within("window_height", self.window_height, &WINDOW)?;
|
||||
for (field, id) in [
|
||||
("theme", Some(&self.theme)),
|
||||
("dark_theme", self.dark_theme.as_ref()),
|
||||
] {
|
||||
if let Some(id) = id {
|
||||
anyhow::ensure!(
|
||||
themes::find(id).is_some(),
|
||||
"{field} is {id:?}, no built-in theme"
|
||||
);
|
||||
anyhow::ensure!(themes::known(id), "{field} is {id:?}, no built-in theme");
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
||||
@@ -228,6 +233,15 @@ mod tests {
|
||||
error.to_string(),
|
||||
"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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+58
-62
@@ -3,11 +3,11 @@ use std::fmt::Debug;
|
||||
use ely_gpui_component::{
|
||||
buttons::SegmentedControl,
|
||||
feedback::InlineMessage,
|
||||
forms::{InputEvent, PathInput, Switch, TextInput},
|
||||
primitives::{FocusScope, IconName, Severity},
|
||||
settings::{SettingsLayout, SettingsRow, SettingsSection},
|
||||
forms::{InputEvent, Switch, TextInput},
|
||||
lists::{ListItem, SelectableList},
|
||||
primitives::{FocusScope, Icon, IconName, Severity},
|
||||
shell::{ColumnShell, ResizeBorder, ShellColumn},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
theme::{ActiveTheme, ControlSize, IconSize},
|
||||
};
|
||||
use gpui::{
|
||||
App, AppContext, Bounds, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
|
||||
@@ -16,7 +16,7 @@ use gpui::{
|
||||
actions, div, prelude::FluentBuilder, px, size,
|
||||
};
|
||||
|
||||
use super::{Settings, SettingsStore, StartIn};
|
||||
use super::{Settings, SettingsStore};
|
||||
use crate::{app::APP_ID, menus, term::BLINK};
|
||||
|
||||
mod appearance;
|
||||
@@ -28,8 +28,7 @@ actions!(elyterminal, [OpenSettings, CloseSettings]);
|
||||
const CONTEXT: &str = "Settings";
|
||||
|
||||
/// Each category's key, name and icon.
|
||||
const SECTIONS: [(&str, &str, IconName); 3] = [
|
||||
("general", "General", IconName::Settings),
|
||||
const SECTIONS: [(&str, &str, IconName); 2] = [
|
||||
("appearance", "Appearance", IconName::Palette),
|
||||
("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 {
|
||||
focus: FocusHandle,
|
||||
section: SharedString,
|
||||
shell: Entity<TextInput>,
|
||||
/// The shortcut list's search.
|
||||
search: Entity<TextInput>,
|
||||
/// Why the typed shell is not saved.
|
||||
refused: Option<SharedString>,
|
||||
/// Why the last change did not save.
|
||||
@@ -110,6 +111,7 @@ impl SettingsWindow {
|
||||
input
|
||||
});
|
||||
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| {
|
||||
if let InputEvent::Changed = event {
|
||||
let shell = input.read(cx).text().to_string();
|
||||
@@ -123,6 +125,7 @@ impl SettingsWindow {
|
||||
focus,
|
||||
section: SECTIONS[0].0.into(),
|
||||
shell,
|
||||
search,
|
||||
refused: None,
|
||||
failed: None,
|
||||
drafts: 0,
|
||||
@@ -167,46 +170,36 @@ impl SettingsWindow {
|
||||
};
|
||||
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.
|
||||
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 categories; a press, Up and Down, or Enter opens one.
|
||||
fn sections(open: &SharedString, cx: &Context<SettingsWindow>) -> SelectableList {
|
||||
let muted = cx.theme().colors.fg_muted;
|
||||
let list = SECTIONS.iter().fold(
|
||||
SelectableList::new("settings-section-list").selected([open.clone()]),
|
||||
|list, (key, name, icon)| {
|
||||
let item = ListItem::new(SharedString::from(format!("settings-section-{key}")), *name)
|
||||
.leading(Icon::new(*icon).size(IconSize::Sm).color(muted));
|
||||
list.row(*key, item)
|
||||
},
|
||||
);
|
||||
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`.
|
||||
@@ -289,9 +282,7 @@ fn set(
|
||||
|
||||
impl Render for SettingsWindow {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let pick = cx.entity();
|
||||
let page = match self.section.as_ref() {
|
||||
"general" => self.general(cx).into_any_element(),
|
||||
"appearance" => self.appearance(window, cx).into_any_element(),
|
||||
"terminal" => self.terminal(cx).into_any_element(),
|
||||
other => unreachable!("settings: no section {other}"),
|
||||
@@ -302,26 +293,31 @@ impl Render for SettingsWindow {
|
||||
.gap_4()
|
||||
.children((self.failed.clone()).map(|why| InlineMessage::new(Severity::Danger, why)))
|
||||
.child(page);
|
||||
let layout = SettingsLayout::new("settings", SECTIONS, self.section.clone(), {
|
||||
move |key, _, cx| {
|
||||
pick.update(cx, |this, cx| {
|
||||
this.section = key.clone();
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
})
|
||||
.page(page);
|
||||
let (_, name, _) = SECTIONS
|
||||
.iter()
|
||||
.find(|(key, ..)| *key == self.section.as_ref())
|
||||
.expect("the open section is listed");
|
||||
let list = div()
|
||||
.id("settings-list")
|
||||
.size_full()
|
||||
.overflow_y_scroll()
|
||||
.p_2()
|
||||
.child(sections(&self.section, cx));
|
||||
let body = div()
|
||||
.id("settings-body")
|
||||
.size_full()
|
||||
.overflow_y_scroll()
|
||||
.p_6()
|
||||
.child(layout);
|
||||
let shell = ColumnShell::new("settings").column(
|
||||
ShellColumn::new("settings-column")
|
||||
.title("Settings")
|
||||
.child(body),
|
||||
);
|
||||
.child(page);
|
||||
// The categories and the page scroll apart.
|
||||
let shell = ColumnShell::new("settings")
|
||||
.column(
|
||||
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.
|
||||
div()
|
||||
.size_full()
|
||||
|
||||
@@ -103,7 +103,7 @@ impl SettingsWindow {
|
||||
settings.cursor_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 cursor = SettingsSection::new("Cursor")
|
||||
.row(SettingsRow::new("Style").control(style))
|
||||
@@ -113,7 +113,7 @@ impl SettingsWindow {
|
||||
settings.cursor_blink,
|
||||
|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 slider = Slider::new("background_opacity", opacity)
|
||||
.range(percent(*OPACITY.start()), percent(*OPACITY.end()))
|
||||
@@ -207,7 +207,7 @@ fn fraction(percent: f64) -> 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(
|
||||
this: &Entity<SettingsWindow>,
|
||||
title: Option<&'static str>,
|
||||
@@ -222,7 +222,8 @@ fn grid(
|
||||
} else {
|
||||
("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 key = SharedString::from(format!("{field}-{id}"));
|
||||
RadioCard::new(key, chosen == Some(id), each.name)
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
use ely_gpui_component::{
|
||||
forms::NumberInput,
|
||||
settings::{SettingsRow, SettingsSection},
|
||||
theme::ControlSize,
|
||||
buttons::SegmentedControl,
|
||||
feedback::InlineMessage,
|
||||
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 crate::settings::{OptionAsAlt, SCROLLBACK, SettingsStore};
|
||||
use super::{OpenSettings, SettingsWindow, choice, set, switch};
|
||||
use crate::{
|
||||
menus::{Bigger, Minimize, Quit, ResetSize, Smaller},
|
||||
settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn},
|
||||
term::input::{self, Find},
|
||||
workspace::{
|
||||
self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession,
|
||||
TogglePanel, ToggleSessions,
|
||||
},
|
||||
};
|
||||
|
||||
/// Each way Option types Alt, its key and its name.
|
||||
const OPTIONS: [(OptionAsAlt, &str, &str); 4] = [
|
||||
@@ -16,11 +29,42 @@ const OPTIONS: [(OptionAsAlt, &str, &str); 4] = [
|
||||
(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 {
|
||||
/// Scrollback, Option as Alt on macOS, and copy on select.
|
||||
pub(super) fn terminal(&self, cx: &Context<Self>) -> SettingsSection {
|
||||
/// Shell, start in, scrollback, Option as Alt on macOS and copy on select, then the shortcuts.
|
||||
pub(super) fn terminal(&self, cx: &Context<Self>) -> Div {
|
||||
let settings = SettingsStore::get(cx);
|
||||
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))
|
||||
.range(f64::from(*SCROLLBACK.start()), f64::from(*SCROLLBACK.end()))
|
||||
.step(1000.)
|
||||
@@ -35,6 +79,8 @@ impl SettingsWindow {
|
||||
}
|
||||
});
|
||||
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));
|
||||
// Off macOS, Alt is always Alt.
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -53,6 +99,86 @@ impl SettingsWindow {
|
||||
settings.copy_on_select,
|
||||
|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", "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,482 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{OsStr, OsString},
|
||||
io::{self, BufRead, BufReader, Write},
|
||||
os::unix::{ffi::OsStringExt, net::UnixStream},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use gpui::{Context, Task};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::view::{TerminalEvent, TerminalView};
|
||||
|
||||
/// How often the session's foreground program is checked.
|
||||
const CHECK: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long herdr may take to answer.
|
||||
const WAIT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The socket request for herdr's session: its focus and its panes' folders.
|
||||
const SNAPSHOT: &[u8] = b"{\"id\":\"elyterminal\",\"method\":\"session.snapshot\",\"params\":{}}\n";
|
||||
|
||||
/// A program's arguments, and its variables as the kernel holds them.
|
||||
type Invocation = (Vec<String>, HashMap<String, OsString>);
|
||||
|
||||
/// Follows the pane herdr's server has focused while herdr's client runs in the foreground.
|
||||
pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||
cx.spawn(async move |view, cx| {
|
||||
let mut seen: Option<(i32, Option<PathBuf>)> = None;
|
||||
let (mut socket, mut shown, mut retry, mut failed) = (None, None, false, String::new());
|
||||
loop {
|
||||
cx.background_executor().timer(CHECK).await;
|
||||
let Ok((pid, group)) = view.update(cx, |view, _| (view.pty.pid, view.pty.foreground()))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let group = match group {
|
||||
Ok(group) => Some(group),
|
||||
Err(error) => {
|
||||
if seen.is_some() {
|
||||
log::debug!("terminal {pid}: its foreground does not read: {error}");
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
// An exec keeps the group and swaps the program, so the two name the foreground.
|
||||
let program = match group {
|
||||
Some(group) => {
|
||||
let exe = cx
|
||||
.background_executor()
|
||||
.spawn(async move { path(group).ok() })
|
||||
.await;
|
||||
Some((group, exe))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if program != seen {
|
||||
if let Some((group, None)) = &program {
|
||||
log::debug!(
|
||||
"terminal {pid}: process {group}, another user's or gone, does not read"
|
||||
);
|
||||
}
|
||||
seen = program.clone();
|
||||
shown = None;
|
||||
retry = true;
|
||||
failed.clear();
|
||||
if socket.take().is_some() {
|
||||
log::info!("terminal {pid}: herdr left the foreground");
|
||||
}
|
||||
}
|
||||
if retry {
|
||||
let found = match program {
|
||||
Some((group, Some(exe)))
|
||||
if exe.file_name().is_some_and(|name| name == "herdr") =>
|
||||
{
|
||||
cx.background_executor()
|
||||
.spawn(async move { client(group) })
|
||||
.await
|
||||
}
|
||||
_ => 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;
|
||||
};
|
||||
match cx
|
||||
.background_executor()
|
||||
.spawn(async move { folder(&path) })
|
||||
.await
|
||||
{
|
||||
Ok(Some(folder)) if shown.as_ref() != Some(&folder) => {
|
||||
failed.clear();
|
||||
let report = folder.clone();
|
||||
// herdr may have left while it answered; the shell's own report then stands.
|
||||
let Ok(still) = view.update(cx, |view, cx| {
|
||||
let still = view.pty.foreground().ok() == group;
|
||||
if still {
|
||||
cx.emit(TerminalEvent::Folder(report));
|
||||
}
|
||||
still
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
if still {
|
||||
log::info!(
|
||||
"terminal {pid}: herdr's focused pane is in {}",
|
||||
folder.display()
|
||||
);
|
||||
shown = Some(folder);
|
||||
}
|
||||
}
|
||||
Ok(_) => failed.clear(),
|
||||
Err(error) => failure(&mut failed, pid, &error),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Logs `error` once while it lasts; a server not up yet is no error.
|
||||
fn failure(failed: &mut String, pid: u32, error: &anyhow::Error) {
|
||||
let text = format!("{error:#}");
|
||||
if text != *failed {
|
||||
let level = if starting(error) {
|
||||
log::Level::Info
|
||||
} else {
|
||||
log::Level::Error
|
||||
};
|
||||
log::log!(level, "terminal {pid}: {text}; the panel keeps its folder");
|
||||
*failed = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// herdr's client runs before its server listens: no socket yet, or none answering.
|
||||
fn starting(error: &anyhow::Error) -> bool {
|
||||
error.downcast_ref::<io::Error>().is_some_and(|error| {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The socket of the herdr server whose terminal client leads process group `group`, if it is one.
|
||||
fn client(group: i32) -> anyhow::Result<Option<PathBuf>> {
|
||||
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)))
|
||||
}
|
||||
|
||||
/// The socket a herdr process with `args` and `env` shows, if it is herdr's terminal client.
|
||||
pub(super) fn follows(
|
||||
args: &[String],
|
||||
env: &HashMap<String, OsString>,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
if !attaches(args) {
|
||||
return Ok(None);
|
||||
}
|
||||
let remote = |arg: &String| {
|
||||
["--remote", "--machine"]
|
||||
.iter()
|
||||
.any(|flag| arg == flag || arg.starts_with(&format!("{flag}=")))
|
||||
};
|
||||
anyhow::ensure!(
|
||||
!args.iter().any(remote),
|
||||
"herdr: its client shows another machine's server, whose folders cannot show here"
|
||||
);
|
||||
socket(args, env).map(Some)
|
||||
}
|
||||
|
||||
/// Whether `args` start herdr's terminal client, not one of its commands.
|
||||
fn attaches(args: &[String]) -> bool {
|
||||
let mut rest = args.iter().skip(1);
|
||||
while let Some(arg) = rest.next() {
|
||||
match arg.as_str() {
|
||||
"--session" | "--remote" | "--machine" | "--remote-keybindings" => {
|
||||
rest.next();
|
||||
}
|
||||
"--version" | "-V" | "--help" | "-h" | "--default-config" | "--skill" => return false,
|
||||
"client" => return true,
|
||||
"session" => return rest.next().is_some_and(|next| next == "attach"),
|
||||
flag if flag.starts_with('-') => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The socket herdr 0.9's client finds: a named session, `HERDR_SOCKET_PATH`, `HERDR_SESSION`, the default.
|
||||
fn socket(args: &[String], env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||
let dir = match (env.get("XDG_CONFIG_HOME"), env.get("HOME")) {
|
||||
(Some(config), _) => PathBuf::from(config).join("herdr"),
|
||||
(None, Some(home)) => PathBuf::from(home).join(".config/herdr"),
|
||||
(None, None) => anyhow::bail!("herdr: its client has no HOME"),
|
||||
};
|
||||
let named = |name: &OsStr| {
|
||||
if name == "default" {
|
||||
dir.join("herdr.sock")
|
||||
} else {
|
||||
dir.join("sessions").join(name).join("herdr.sock")
|
||||
}
|
||||
};
|
||||
if let Some(name) = session(args) {
|
||||
return Ok(named(OsStr::new(&name)));
|
||||
}
|
||||
if let Some(path) = env.get("HERDR_SOCKET_PATH") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(env
|
||||
.get("HERDR_SESSION")
|
||||
.map_or_else(|| dir.join("herdr.sock"), |name| named(name)))
|
||||
}
|
||||
|
||||
/// The session `session attach NAME`, `--session NAME` or `--session=NAME` names, as herdr reads it.
|
||||
fn session(args: &[String]) -> Option<String> {
|
||||
if let [_, noun, verb, name] = args
|
||||
&& noun == "session"
|
||||
&& verb == "attach"
|
||||
{
|
||||
return Some(name.clone());
|
||||
}
|
||||
let mut named = None;
|
||||
let mut rest = args.iter().skip(1);
|
||||
while let Some(arg) = rest.next() {
|
||||
if arg == "--" {
|
||||
break;
|
||||
}
|
||||
if arg == "--session" {
|
||||
named = rest.next().cloned();
|
||||
} else if let Some(name) = arg.strip_prefix("--session=") {
|
||||
named = Some(name.to_owned());
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// The folder of the pane herdr's server has focused; none while it has none or knows none.
|
||||
fn folder(socket: &Path) -> anyhow::Result<Option<PathBuf>> {
|
||||
let ask = || -> io::Result<String> {
|
||||
let mut stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(WAIT))?;
|
||||
stream.set_write_timeout(Some(WAIT))?;
|
||||
stream.write_all(SNAPSHOT)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(stream).read_line(&mut reply)?;
|
||||
Ok(reply)
|
||||
};
|
||||
let reply = ask().with_context(|| format!("herdr: asking {}", socket.display()))?;
|
||||
focused(&reply).with_context(|| format!("herdr: {}", socket.display()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reply {
|
||||
result: Option<Snapshot>,
|
||||
error: Option<Failure>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Snapshot {
|
||||
snapshot: Session,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Session {
|
||||
focused_pane_id: Option<String>,
|
||||
panes: Vec<Pane>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Pane {
|
||||
pane_id: String,
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Failure {
|
||||
code: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// The focused pane's folder in herdr's `session.snapshot` reply.
|
||||
pub(super) fn focused(reply: &str) -> anyhow::Result<Option<PathBuf>> {
|
||||
let reply: Reply = serde_json::from_str(reply).context("its reply does not parse")?;
|
||||
if let Some(Failure { code, message }) = reply.error {
|
||||
anyhow::bail!("{code}: {message}");
|
||||
}
|
||||
let session = reply.result.context("its reply holds no result")?.snapshot;
|
||||
let Some(id) = session.focused_pane_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let pane = session
|
||||
.panes
|
||||
.into_iter()
|
||||
.find(|pane| pane.pane_id == id)
|
||||
.with_context(|| format!("its focused pane {id} is not among its panes"))?;
|
||||
if let Some(cwd) = &pane.cwd {
|
||||
anyhow::ensure!(
|
||||
cwd.is_absolute(),
|
||||
"pane {id}'s folder {} is relative",
|
||||
cwd.display()
|
||||
);
|
||||
}
|
||||
Ok(pane.cwd)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn path(pid: i32) -> io::Result<PathBuf> {
|
||||
std::fs::read_link(format!("/proc/{pid}/exe")).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::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")]
|
||||
fn launch(pid: i32) -> io::Result<Invocation> {
|
||||
let read = |name: &str| std::fs::read(format!("/proc/{pid}/{name}"));
|
||||
let (args, env) = (read("cmdline")?, read("environ")?);
|
||||
let args = strings(&args).map(lossy).collect();
|
||||
let env = strings(&env).filter_map(variable).collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cwd(pid: i32) -> 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")]
|
||||
fn path(pid: i32) -> io::Result<PathBuf> {
|
||||
let mut buffer = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: the buffer holds the size given.
|
||||
let length =
|
||||
unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
|
||||
if length <= 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
buffer.truncate(length as usize);
|
||||
Ok(PathBuf::from(OsString::from_vec(buffer)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch(pid: i32) -> io::Result<Invocation> {
|
||||
let mut name = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
|
||||
let mut ask = |buffer: &mut Vec<u8>| {
|
||||
let mut size = buffer.len();
|
||||
let into = if buffer.is_empty() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
buffer.as_mut_ptr().cast()
|
||||
};
|
||||
// SAFETY: `into` holds `size` bytes, or is null to ask for the size.
|
||||
let done = unsafe {
|
||||
libc::sysctl(
|
||||
name.as_mut_ptr(),
|
||||
3,
|
||||
into,
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if done != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(size)
|
||||
};
|
||||
let mut buffer = Vec::new();
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.resize(size, 0);
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.truncate(size);
|
||||
procargs(&buffer)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cwd(pid: i32) -> 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()),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,164 @@
|
||||
use std::{fs, os::unix::net::UnixListener};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(words: &str) -> Vec<String> {
|
||||
words.split(' ').map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> HashMap<String, OsString> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_owned(), OsString::from(value)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn found(words: &str, env: &HashMap<String, OsString>) -> PathBuf {
|
||||
follows(&args(words), env)
|
||||
.expect("a local client")
|
||||
.expect("herdr's client")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_finds_its_server_as_herdr_does() {
|
||||
let home = env(&[("HOME", "/h")]);
|
||||
let default = PathBuf::from("/h/.config/herdr/herdr.sock");
|
||||
let work = PathBuf::from("/h/.config/herdr/sessions/work/herdr.sock");
|
||||
assert_eq!(found("herdr", &home), default);
|
||||
assert_eq!(found("herdr client", &home), default);
|
||||
assert_eq!(found("herdr --session work", &home), work);
|
||||
assert_eq!(found("herdr --session=work", &home), work);
|
||||
assert_eq!(found("herdr session attach work", &home), work);
|
||||
assert_eq!(found("herdr --session default", &home), default);
|
||||
let config = env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]);
|
||||
assert_eq!(
|
||||
found("herdr", &config),
|
||||
PathBuf::from("/x/herdr/herdr.sock")
|
||||
);
|
||||
let socket = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "/s.sock")]);
|
||||
assert_eq!(found("herdr", &socket), PathBuf::from("/s.sock"));
|
||||
assert_eq!(found("herdr --session work", &socket), work);
|
||||
let 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| focused(&reply(focused_pane, cwd));
|
||||
assert_eq!(
|
||||
read(r#""w1:p2""#, r#""/srv/b""#).expect("a folder"),
|
||||
Some("/srv/b".into())
|
||||
);
|
||||
assert_eq!(read("null", r#""/srv/b""#).expect("no focus"), None);
|
||||
assert_eq!(read(r#""w1:p2""#, "null").expect("no folder"), None);
|
||||
let error = read(r#""w1:p9""#, r#""/b""#).expect_err("a lost pane");
|
||||
assert!(error.to_string().contains("w1:p9"), "{error}");
|
||||
let error = read(r#""w1:p2""#, r#""b""#).expect_err("a relative folder");
|
||||
assert!(error.to_string().contains("relative"), "{error}");
|
||||
let failed =
|
||||
r#"{"id":"elyterminal","error":{"code":"invalid_request","message":"no such method"}}"#;
|
||||
let error = focused(failed).expect_err("an error");
|
||||
assert_eq!(error.to_string(), "invalid_request: no such method");
|
||||
assert!(focused("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn herdr_answers_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("elyterminal-herdr-{}", std::process::id()));
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir).expect("a stale test folder goes");
|
||||
}
|
||||
fs::create_dir_all(&dir).expect("a test folder");
|
||||
let socket = dir.join("herdr.sock");
|
||||
let listener = UnixListener::bind(&socket).expect("a socket");
|
||||
let answer = reply(r#""w1:p2""#, r#""/srv""#);
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().expect("a client");
|
||||
let mut request = String::new();
|
||||
BufReader::new(&stream)
|
||||
.read_line(&mut request)
|
||||
.expect("a request");
|
||||
(&stream)
|
||||
.write_all(format!("{answer}\n").as_bytes())
|
||||
.expect("a reply");
|
||||
request
|
||||
});
|
||||
assert_eq!(folder(&socket).expect("an answer"), Some("/srv".into()));
|
||||
assert_eq!(server.join().expect("the server").as_bytes(), SNAPSHOT);
|
||||
fs::remove_dir_all(&dir).expect("the test folder goes");
|
||||
let error = folder(&socket).expect_err("no server");
|
||||
assert!(format!("{error:#}").contains("asking"), "{error:#}");
|
||||
assert!(starting(&error), "{error:#}");
|
||||
assert!(!starting(&focused("").expect_err("no reply")));
|
||||
}
|
||||
|
||||
/// macOS hides the variables of its own programs, such as `/bin/sleep`, so the test reads itself.
|
||||
#[test]
|
||||
fn a_process_reads_with_its_path_arguments_variables_and_folder() {
|
||||
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(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_updated_herdr_still_names_itself() {
|
||||
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() {
|
||||
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());
|
||||
}
|
||||
@@ -47,6 +47,10 @@ pub fn bind_keys(cx: &mut App) {
|
||||
["tab", "shift-tab", "f6", "shift-f6"]
|
||||
.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 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod boxes;
|
||||
mod find;
|
||||
pub mod frame;
|
||||
#[cfg(unix)]
|
||||
mod herdr;
|
||||
pub mod ink;
|
||||
pub mod input;
|
||||
pub mod keys;
|
||||
|
||||
+33
-3
@@ -56,7 +56,7 @@ pub struct Pty {
|
||||
pub term: Arc<FairMutex<Term<Listener>>>,
|
||||
pub pid: u32,
|
||||
notifier: Notifier,
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
master: std::fs::File,
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ pub fn spawn(
|
||||
)
|
||||
})?;
|
||||
let pid = child_id(&raw)?;
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
let master = raw
|
||||
.master()
|
||||
.map_err(|error| anyhow::anyhow!("pty {pid}: no copy of the master: {error}"))?;
|
||||
@@ -141,7 +141,7 @@ pub fn spawn(
|
||||
term,
|
||||
pid,
|
||||
notifier,
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
master,
|
||||
},
|
||||
events,
|
||||
@@ -184,6 +184,15 @@ impl Pty {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Pty {
|
||||
/// The process group the program runs in the foreground.
|
||||
pub fn foreground(&self) -> std::io::Result<i32> {
|
||||
let group = rustix_openpty::rustix::termios::tcgetpgrp(&self.master)?;
|
||||
Ok(group.as_raw_nonzero().get())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
impl Pty {
|
||||
/// Whether the program reads raw keys, canonical mode off.
|
||||
@@ -305,6 +314,27 @@ mod tests {
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_foreground_is_the_program_s_own_group() {
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "exec sleep 30".into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, _events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while pty.foreground().ok() != Some(pty.pid as i32) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{:?}",
|
||||
pty.foreground()
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
|
||||
let ran = run("stty size", (9000, 8));
|
||||
|
||||
+1
-2
@@ -45,8 +45,7 @@ impl Pty {
|
||||
self.child.id()
|
||||
}
|
||||
|
||||
/// A copy of the master; tests read its modes.
|
||||
#[cfg(test)]
|
||||
/// A copy of the master, which reads the foreground and, in tests, the modes.
|
||||
pub fn master(&self) -> io::Result<File> {
|
||||
self.file.try_clone()
|
||||
}
|
||||
|
||||
+8
-5
@@ -73,6 +73,8 @@ pub struct TerminalView {
|
||||
shown: bool,
|
||||
_events: Task<()>,
|
||||
_blink: Task<()>,
|
||||
#[cfg(unix)]
|
||||
_herdr: Task<()>,
|
||||
_focus: [Subscription; 3],
|
||||
_settings: Subscription,
|
||||
_zoom: Subscription,
|
||||
@@ -117,6 +119,8 @@ impl TerminalView {
|
||||
shown: true,
|
||||
_events: Self::pump(events, cx),
|
||||
_blink: Self::blink(cx),
|
||||
#[cfg(unix)]
|
||||
_herdr: super::herdr::follow(cx),
|
||||
_settings: cx.observe_global::<SettingsStore>(|view, cx| {
|
||||
view.pty
|
||||
.term
|
||||
@@ -243,9 +247,9 @@ impl TerminalView {
|
||||
self.pty.pid
|
||||
),
|
||||
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 rgb = Ink::new(theme, term.colors()).query(index, term.colors());
|
||||
let rgb = Ink::new(&theme, term.colors()).query(index, term.colors());
|
||||
drop(term);
|
||||
self.pty.write(reply(rgb).into_bytes());
|
||||
}
|
||||
@@ -322,9 +326,9 @@ fn look(settings: &Settings, points: f32, ink: &Ink, window: &Window) -> anyhow:
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
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 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| {
|
||||
finder.lit(&term, cx.theme().colors.warning)
|
||||
});
|
||||
@@ -360,7 +364,6 @@ impl Render for TerminalView {
|
||||
.on_any_mouse_down(cx.listener(Self::mouse_down))
|
||||
.on_mouse_move(cx.listener(Self::mouse_move))
|
||||
.on_scroll_wheel(cx.listener(Self::wheel))
|
||||
.cursor_text()
|
||||
.size_full()
|
||||
.px(px(settings.padding_x))
|
||||
.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.
|
||||
pub fn apply(cx: &mut App) {
|
||||
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 mode = if theme.is_dark() {
|
||||
Mode::Dark
|
||||
} else {
|
||||
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);
|
||||
log::info!("theme: {} in {mode:?}, glass at {opacity}", theme.id);
|
||||
}
|
||||
+39
-5
@@ -1,10 +1,12 @@
|
||||
mod chrome;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod omarchy;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use ely_gpui_component::theme::Palette;
|
||||
use gpui::{Hsla, Rgba, WindowAppearance};
|
||||
use gpui::{App, Global, Hsla, Rgba, WindowAppearance};
|
||||
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn active(settings: &Settings, appearance: WindowAppearance) -> &'static TerminalTheme {
|
||||
pub fn chosen(settings: &Settings, appearance: WindowAppearance) -> &str {
|
||||
let dark = matches!(
|
||||
appearance,
|
||||
WindowAppearance::Dark | WindowAppearance::VibrantDark
|
||||
);
|
||||
let id = match (&settings.dark_theme, dark) {
|
||||
match (&settings.dark_theme, dark) {
|
||||
(Some(id), true) => id,
|
||||
_ => &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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn the_dark_theme_wins_only_in_dark_mode() {
|
||||
let settings = Settings {
|
||||
@@ -94,21 +178,18 @@ fn the_dark_theme_wins_only_in_dark_mode() {
|
||||
..Settings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
active(&settings, WindowAppearance::Light).id,
|
||||
chosen(&settings, WindowAppearance::Light),
|
||||
"catppuccin-latte"
|
||||
);
|
||||
assert_eq!(
|
||||
active(&settings, WindowAppearance::VibrantDark).id,
|
||||
chosen(&settings, WindowAppearance::VibrantDark),
|
||||
"catppuccin-mocha"
|
||||
);
|
||||
let single = Settings {
|
||||
dark_theme: None,
|
||||
..settings
|
||||
};
|
||||
assert_eq!(
|
||||
active(&single, WindowAppearance::Dark).id,
|
||||
"catppuccin-latte"
|
||||
);
|
||||
assert_eq!(chosen(&single, WindowAppearance::Dark), "catppuccin-latte");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+21
-15
@@ -18,7 +18,7 @@ use crate::{
|
||||
git::panel::{Acted, GitPanel},
|
||||
menus,
|
||||
sessions::{self, Session},
|
||||
settings::{PanelTab, Settings, SettingsStore, StartIn},
|
||||
settings::{PanelTab, Settings, SettingsStore, StartIn, window::OpenSettings},
|
||||
term::{
|
||||
Events, Launch, Pty, TerminalEvent, TerminalView, config,
|
||||
shell::{ShellIntegration, launch},
|
||||
@@ -45,7 +45,7 @@ actions!(
|
||||
pub struct SelectSession(pub usize);
|
||||
|
||||
/// 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.
|
||||
pub const TITLE: &str = "ELYTerminal";
|
||||
@@ -332,10 +332,7 @@ fn column_toggle(
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(tip)
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.stop_propagation();
|
||||
workspace.update(cx, |this, cx| this.toggle(flip, cx));
|
||||
})
|
||||
.on_click(move |_, _, cx| workspace.update(cx, |this, cx| this.toggle(flip, cx)))
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
@@ -359,6 +356,20 @@ impl Render for Workspace {
|
||||
tip(settings.show_panel, "Git and Files"),
|
||||
|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 shown = self
|
||||
.sessions
|
||||
@@ -379,7 +390,6 @@ impl Render for Workspace {
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("New session")
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
workspace.update(cx, |this, cx| this.new_session(window, cx))
|
||||
});
|
||||
shell = shell.column(
|
||||
@@ -410,11 +420,6 @@ impl Render for Workspace {
|
||||
.segment("files", "Files", None)
|
||||
.size(ControlSize::Sm)
|
||||
.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
|
||||
.sessions
|
||||
.get(self.active)
|
||||
@@ -433,14 +438,15 @@ impl Render for Workspace {
|
||||
shell = shell.column(terminal).column(
|
||||
ShellColumn::new("panel")
|
||||
.width(width)
|
||||
.leading(switch)
|
||||
.action(right)
|
||||
.action(end)
|
||||
// Below the title bar, clear of Windows' caption buttons.
|
||||
.child(div().flex().justify_center().px_2().pt_2().child(switch))
|
||||
.children(quiet.then(caption))
|
||||
.child(body(tab == PanelTab::Git).child(self.git.clone().cached(fill())))
|
||||
.child(body(tab == PanelTab::Files).child(self.files.clone())),
|
||||
);
|
||||
} 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.
|
||||
div()
|
||||
|
||||
+29
-13
@@ -2,14 +2,14 @@
|
||||
|
||||
`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 one has looked at the window on Windows, which CI builds and tests; 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
|
||||
|
||||
| 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 |
|
||||
| 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 | 17 of the 22 fields have a row; `show_sessions`, `show_panel` and `panel` are the main window's toggles and switch, 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 |
|
||||
| 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 |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -43,6 +43,7 @@ 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -50,6 +51,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|---|
|
||||
| PTY through alacritty's event loop; OSC 7 scanned on the reader | T04 | `term::pty`, `term::tap` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass |
|
||||
| Shell integration for zsh, bash, fish, PowerShell; a shell without it reports nothing and the panels say so | T05 | `term::shell` tests on macOS and Linux | Pass |
|
||||
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS and Linux | T29 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report, 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 | Pass |
|
||||
| Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass |
|
||||
| Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass |
|
||||
| JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |
|
||||
@@ -68,8 +70,11 @@ 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 |
|
||||
| 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 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; a drag on macOS and any run on Windows not yet shown | Partial |
|
||||
| The title shows the session's title, nothing else | T11 | log: "the title is", per session shown | 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 |
|
||||
| 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
|
||||
|
||||
@@ -84,7 +89,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -104,18 +109,21 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|
||||
| PRD line | Task | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| Cmd-, opens it; categories left, rows right; changes apply at once and save | T16 | T16 review | Pass |
|
||||
| General: Shell, Start in | T16 | `settings::tests` | 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 |
|
||||
| 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 |
|
||||
| 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: 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
|
||||
|
||||
| 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 |
|
||||
| Windows: `Blurred` (acrylic) | T19 | the same gpui call; CI builds and tests on Windows, but no one has seen the window | Partial |
|
||||
| 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 |
|
||||
| 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, 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 |
|
||||
| Neither protocol: transparency without blur; the log names the path | T20, T21 | the log lines in `src/blur` | Pass |
|
||||
|
||||
@@ -125,6 +133,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 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
|
||||
|
||||
@@ -132,7 +141,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 |
|
||||
| `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
|
||||
|
||||
@@ -140,16 +149,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 |
|
||||
| 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 | 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; without the key the script stops before building | Pass |
|
||||
|
||||
## 15. Release
|
||||
|
||||
| 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 | `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 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 |
|
||||
| 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 |
|
||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27 | 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; 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 | Pass |
|
||||
| Each build comes from its script | T25, T27 | `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 | Pass |
|
||||
|
||||
## CI notes
|
||||
|
||||
@@ -165,6 +174,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.
|
||||
|
||||
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
|
||||
|
||||
On 2026-10-15 the owner ran the app on macOS and found three faults, now fixed:
|
||||
@@ -172,3 +183,8 @@ 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 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.
|
||||
|
||||
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.
|
||||
@@ -120,3 +120,33 @@ Each task gets at most three codex rounds. Each round: the commit, the verdict,
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
Reference in new issue
Block a user