Merge remote-tracking branch 'origin/main' into feat/mobile-input

# Conflicts:
#	Cargo.lock
This commit is contained in:
l0ng-ai
2026-09-29 18:37:23 +08:00
91 changed files with 21452 additions and 1163 deletions
+22
View File
@@ -92,6 +92,11 @@ src/terminal/search.rs|.is_absolute()
# can be handed a path. Always `std::env::temp_dir()` on this machine.
src/terminal/view.rs|std::fs::create_dir_all
src/terminal/view.rs|std::fs::write
# The source side of a paste/drop upload into a remote pane: the paths the
# clipboard or desktop hands over are on this machine by construction, so
# asking whether one is a directory is a local read. The remote side of the
# copy goes over SFTP.
src/terminal/view.rs|std::fs::metadata
# Asking whether a file would be *launched* rather than shown before handing it
# to the desktop opener. Only reachable behind `host_id.is_local()` — a path on
@@ -104,6 +109,23 @@ src/ui/code_editor.rs|std::fs::metadata
# that copy goes through `Host`, and the one `std::fs::copy` that touches a
# destination sits inside a branch already gated on `host.id().is_local()`.
src/ui/file_copy.rs|std::fs::
# The code editor's language servers run on this machine and only ever take a
# local buffer: `OpenFile::local` refuses anything whose host is not local, so
# every path `ui::lsp` holds is on this disk. It reads files a server points
# into that are not open (to measure a column, to apply a rename to disk),
# looks for project markers above a file (`Cargo.toml`, `package.json`, …),
# and checks a server binary is executable. A remote workspace gets language
# servers by running them over there, not by routing these reads.
src/ui/lsp/editor.rs|std::fs::read_to_string
src/ui/lsp/locations.rs|std::fs::read_to_string
src/ui/lsp/mod.rs|std::fs::read_to_string
src/ui/lsp/servers.rs|std::fs::metadata
src/ui/lsp/servers.rs|std::fs::read_to_string
# Its tests live in their own file (not a trailing `mod tests`), writing
# fixtures into a local temp dir.
src/ui/lsp/tests.rs|std::fs::write
src/ui/lsp/tests.rs|.canonicalize()
EOF
)
+295 -39
View File
@@ -5,7 +5,7 @@ All notable changes to tty7 are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [26.9.4] - 2026-09-29
### Added
@@ -19,9 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
machine has no agent to offer. **New Agent Tab** (⌘⇧A) is unchanged: it
still starts the one used last.
- **Find in files, in the right panel's new Search tab.** Type and the active
tab's project — the same roots the Files tab shows — is searched as you go,
with hits grouped by file, a count per file, and each match highlighted in
- **Find in files, from the right panel's Files tab.** Its field now matches
file names and file contents at once, in two sections. Type and the active
tab's project — the same roots the tree shows — is searched as you go, with
content hits grouped by file, a count per file, and each match highlighted in
its line. Click a hit to open the file in the built-in editor at that line
and column; Enter searches again. Toggles for match case, whole word and
regular expressions sit at the end of the field. The walk honours
@@ -44,9 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
signed out, and a private repository or a spent rate limit says to run
`gh auth login`. Screenshots pasted into an issue are shown; images from
any other host are shown as links rather than loaded, and non-web link
targets are disabled. The Info tab gains a GitHub
row that opens the branch you are on. The tab talks to `api.github.com` only
while you use it.
targets are disabled. The tab talks to `api.github.com` only while you use
it.
- **Reorder the active tab from the keyboard** (`MoveTabLeft` / `MoveTabRight`).
The tab moves one slot past its neighbour — the keyboard form of dragging it
@@ -92,14 +92,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **A port forward can be switched off without losing its rule** (#439). A
forward could only be removed, so pointing one local port at a different
remote target meant deleting the rule and typing the other one in again. Each
forward in the Ports section, and each rule under Settings → SSH → Port
forwarding, now has a switch: off releases the listener and keeps the rule in
the list, on binds it again from the rule it was made from. Switching one on
while another switched-on forward holds its port is refused with a notice
naming that forward, rather than failing at bind time. A switch flipped in the
panel on a rule that came from a saved host is written back to that host, so
the next connection opens the same set; a rule saved switched off is listed
but not opened. Profiles saved before this load with every rule on.
forward in the Ports section now has a switch: off releases the listener and
keeps the rule in the list, on binds it again from the rule it was made from.
Switching one on while another switched-on forward holds its port is refused
with a notice naming that forward, rather than failing at bind time. A switch
flipped in the panel on a rule that came from a saved host is written back to
that host, so the next connection opens the same set; a rule saved switched
off is listed but not opened. Profiles saved before this load with every rule
on.
- **Windows file paths are links** (#965). `C:\Users\me\a.png`,
`c:/Users/me/a.png` and `\\server\share\a.png` underline and open like any
@@ -124,6 +124,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`link_file_open` makes "Open" do. Shown for files on this machine only, and
not when "Open" already uses the system opener.
- **The code editor talks to language servers** (#1002). Opening a local file
starts the server for its language — rust-analyzer,
typescript-language-server, pyright (or pylsp), gopls or clangd, whichever
is on `PATH` — one per project root, shared by every buffer under it.
Diagnostics are underlined with their message on hover and counted in the
status bar, and a Problems list (⌘⇧M, Ctrl+Shift+M elsewhere) gathers the
errors and warnings of every open file. Completion, hover, signature help,
go to definition (F12 or ⌘-click), Find All References (⇧F12), code actions
(⌘.), Format Document (⇧⌥F) and Rename Symbol (F2) work across files; the
keys are bound inside the editor only, so terminal programs keep F2 and F12.
A server missing from `PATH` costs only its language, with a one-line hint
in the status bar; a server whose last file closed shuts down after a grace
period, and one that crashes is restarted a few times. Files on a remote
workspace get no server. `editor_lsp` turns it all off.
- **Multiple cursors, git change markers, symbols and a split in the code
editor** (#1002). ⌘D, ⌘⇧L and ⌘⌥↑ / ⌘⌥↓ add cursors, and the status bar
counts the selections. The gutter marks lines added, modified or deleted
against the file's staged version, on remote workspaces too; ⌥F5 / ⇧⌥F5 step
through the changes, ⌥F3 or a click on a marker peeks one, and Revert Change
undoes it as one step (`editor_git_gutter` turns the markers off). Go to
Symbol (⌘⇧O) lists the file's outline, previewing each symbol as it is
arrowed through, and once a query is typed adds the language server's
matches from across the project; a breadcrumb row over the text shows the
path and the symbols around the caret. Back and forward (Ctrl+− / Ctrl+⇧+−
on macOS, Alt+← / Alt+→ elsewhere) walk a per-tab history of jumps. ⌘\
(Ctrl+\ elsewhere, bound only in the editor so the terminal keeps SIGQUIT)
opens the file in a second group on the right, saved with the tab. The
right-click menu gains Toggle Comment, Move, Duplicate and Delete Line and
Go to Matching Bracket, and case transforms, Trim Trailing Whitespace and
Join Lines are bindable commands. **Settings → General → Editor** holds the
change markers, language servers, soft wrap and rendered Markdown.
- **Quick open, Go to Line, New File and Save As in the code editor** (#984).
Go to File (⌘O on macOS, unbound elsewhere) finds any file in the active
tab's project by fuzzy name — on local, SSH and WSL workspaces alike,
skipping what the Files tree hides — and `name:line[:col]` jumps to that
spot. Go to Line is Ctrl+G. Save As uses the native panel locally and a
path bar on a remote workspace, and the files each tab has open are
remembered across restarts. Loading and saving keep a file's encoding
(UTF-8, GB18030, Windows-1252), byte-order mark and CRLF line endings as
they were, indentation is detected, `.editorconfig` is honoured, and the
status bar shows indentation, encoding and line ending.
- **A local file pasted or dropped into a remote pane is uploaded there**
(#1004). Copying a file in Finder and pasting it into an SSH pane, or
dropping it on one, pasted this machine's path, which the remote program
cannot open; only clipboard images were uploaded. Every local path handed to
a pane now takes one route: on an SSH host it is uploaded into a directory
of its own under `~/.cache/tty7/clipboard`, so it keeps its real name, and
the remote path is pasted; in a WSL pane it is rewritten to `/mnt/<drive>/…`;
locally it is pasted as before. Folders upload recursively, a large file
appears at its path only once complete, a transfer is given up only after
it stops making progress, and staged pastes older than a week are pruned.
Rows dragged from a remote Files tree are no longer taken for local files.
- **The GitHub tab shows a pull request's checks, reviews and merge state**
(#1000). The detail leads with the check runs and commit statuses on the
head commit, failures first, with durations and links to their logs; then
one row per reviewer; and a line under the branches such as "Waiting on 1
check", "Merge conflicts" or "Ready to merge". While a check is running the
checks are re-read every 20 seconds when signed in. The list pins the pull
request of the pane's branch under the repository row, found through the
branch's upstream so a fork's branch resolves under the fork's owner. Long
comment threads, descriptions, reviewer lists and file lists fold behind a
"show all" row.
- **The GitHub tab tries every signed-in `gh` account** (#987). Only gh's
active account was used, so a private repository owned by an organisation
that another signed-in account belongs to read as a 404. On a 404, 401 or
403 the tab now retries with gh's other `github.com` accounts and remembers,
per owner, the one that got through. A token from `GH_TOKEN` or
`GITHUB_TOKEN` is still used on its own.
- **A link a table wrapped inside its cell opens whole** (#1001). Markdown
tables — Claude Code's among them — wrap a long cell by hand, one bordered
row per line, so hovering or clicking a wrapped URL only ever got the
fragment on one row. A link now carries on from a row into the next when its
text reaches the cell's right border and resumes at the left one, with the
borders lined up; only `│`, `┃` and `║` count as borders, never a bare `|`.
The hover underline covers just the cell's text on each row.
- **Qoder CN CLI support** (by @ArnoChenFx in #988). The mainland-China build
of Qoder (`qodercn`, `qoderclicn`, `qoder-cn`) is recognised, reports its
status through hooks and has its past sessions listed, honouring
`QODERCN_CONFIG_DIR`.
- **Prime Agent and Empryo are recognised, and Antigravity reports its
status** (by @kalpakprod in #975). Prime Agent (`prime-agent`) resumes and
forks its sessions and reports status through the same extension bridge as
Pi. Empryo (`empryo`) is detected and resumed with `--session`; it gets no
status hooks yet, because it strips tty7's variables from hook processes.
Antigravity CLI (`agy`) gets a `tty7` hook set in
`~/.gemini/config/hooks.json`, beside the user's own, so its tab shows
working and done.
- **A working agent's status dot blinks.** It alternates every 600 ms between
its colour and a paler blend of it, so a column of tabs shows at a glance
which agents are still going. The repaints stop once no agent is working.
### Changed
- **Every pick-one setting is a dropdown.** Settings drew a single choice two
@@ -141,17 +241,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
are unchanged.
- **The command palette is now Search Everywhere, with tabs.** <kbd>⌘ P</kbd>
(<kbd>Ctrl ⇧ P</kbd> elsewhere) opens one search over four tabs — **All**,
**Actions**, **Terminals** and **Hosts** — walked with <kbd>⇥</kbd> /
<kbd>⇧ ⇥</kbd>, keeping what is typed. **Terminals** lists every open tab of
every workspace, this window's most recently used first, and jumps to it
wherever it lives; the shells and agents it can open follow. **Hosts** holds
the saved SSH hosts and whatever address or `ssh …` command line is typed,
which replaces the separate input *SSH: Add Connection…* used to open. **All**
shows the best few rows of each, the tab with the best match first, and folds
the rest into a row that opens its tab. With nothing typed, <kbd>⌘ P</kbd>
<kbd>⏎</kbd> goes back to the tab you were just in. The keybinding action is
still `TogglePalette`, so a custom binding keeps working.
(<kbd>Ctrl ⇧ P</kbd> elsewhere) opens one search over **All**, **Terminals**,
**Sessions**, **Hosts** and **Commands**, walked with <kbd>⇥</kbd> / <kbd>⇧
⇥</kbd>, keeping what is typed. **Terminals** lists every open tab of every
workspace, this window's most recently used first, and jumps to it wherever it
lives; the shells and agents it can open follow. **Hosts** holds the saved SSH
hosts and whatever address or `ssh …` command line is typed, which replaces
the separate input *SSH: Add Connection…* used to open. **All** shows the best
few rows of each, the tab with the best match first, and folds the rest into a
row that opens its tab; before anything is typed it leads each section with
its recent rows and tops it up to five from the rest, so a fresh window is not
empty. With nothing typed, <kbd>⌘ P</kbd> <kbd>⏎</kbd> goes back to the tab
you were just in. The keybinding action is still `TogglePalette`, so a custom
binding keeps working.
- **Resume a past agent session from Search Everywhere.** Its **Sessions** tab
lists past sessions of Claude Code, Codex, Gemini CLI, Qwen Code, Qoder,
CodeBuddy, Pi, Oh My Pi, Kimi Code, Copilot CLI, Droid, Cursor CLI and
@@ -184,16 +286,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(#955). Groups now come in two halves. Below, every tab you have not pinned is
filed under its git repository, and an SSH tab under the host it is on rather
than its remote path — `/home/ubuntu` on two machines used to share a header.
Above sit the groups you keep, each marked ◆ beside its name, in the order you
drag them into, until you delete them. A pinned group can keep a folder: a tab
whose working directory enters it joins it (the deepest folder wins when they
nest, and a worktree of a pinned repo counts), while a tab you drag out stays
out until it leaves the folder and comes back. Pin an auto group with the ◆ on
its header or by dragging the header up among the pinned ones; pin a folder by
dropping it from Finder, with **Pin as Group** in the Files panel, or with
**Open Folder as Group…** in the palette. **New Group** in the palette or on a
tab's right-click makes a label group. Deleting a group closes nothing — its
tabs go back to auto grouping — and dragging a tab below the pinned groups
Above sit the groups you keep, each marked with a pin beside its name, in the
order you drag them into, until you delete them. A pinned group can keep a
folder: a tab whose working directory enters it joins it (the deepest folder
wins when they nest, and a worktree of a pinned repo counts), while a tab you
drag out stays out until it leaves the folder and comes back. Pin an auto
group with the pin on its header or by dragging the header up among the pinned
ones; pin a folder by dropping it from Finder, with **Pin as Group** in the
Files panel, or with **Open Folder as Group…** in the palette. **New Group**
in the palette, on a tab's right-click or on a right-click in the empty
sidebar below the tabs makes a label group. Deleting a group closes nothing —
its tabs go back to auto grouping — and dragging a tab below the pinned groups
does the same for one tab. Groups, their order and which are folded are stored
with the workspace, so every window onto it agrees. **Settings → General →
Auto grouping** replaces the three-way *Sidebar grouping* choice; off,
@@ -268,6 +371,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
a TUI that never sets a shape itself, such as Claude Code. A shell prompt in
vi mode keeps its own insert/normal shapes.
- **Agent status catches interrupts, stale turns and permission prompts**
(#1003). Claude Code's `Stop` hook skips user interrupts, so after Esc or
Ctrl+C a pane stayed on working until the next prompt. Now an Esc or Ctrl+C
on a pane whose agent is mid-turn, with no hook event in the second after
it, ends the turn; and a turn that has been working for 30 minutes without
a hook event goes idle. These guessed states raise no notification or
unread badge, and the agent's next real event overrides them. Claude Code
and Codex now report a permission prompt or a question the moment it opens
rather than seconds later (Codex never did), and Codex leaves waiting once
the approved tool has run. The "finished" notification waits 1.5 seconds
and is dropped if the next turn starts first. Existing hook installs are
refreshed at launch.
- **Settings open in a window of their own, rebuilt.** Settings used to cover
the workspace, hiding the terminal a setting was being tried on. It is a
separate window now; opening it again raises it, and closing it with
unsaved forms or a theme draft still asks first. The page is rebuilt with a
searchable sidebar, per-page modified counts and a *Modified only* switch,
and an inline Reset on each changed row. Theme menus have a live preview,
search and swatches, and font menus draw each family in itself. Recording a
shortcut that another action already has asks Replace or Cancel instead of
taking it over silently. SSH hosts are one column of recent hosts, with
*Show all* by source and a search, and open in place. Integrations gets an
agent search and a menu per agent (Reinstall, Reveal hook file, Uninstall).
General gains startup and restore; updates and the server move to About.
(#973)
- **The window is redesigned.** The side rails share the window's fill,
divided by a hairline; the current tab is a neutral selected step with a
semibold title instead of a blue wash, and tab titles are spelled out in
full wherever they fit. The right panel's tabs are words — Info, Files,
Changes, GitHub. The workspace switcher, Search Everywhere, the Changes tab,
the diff overlay, dialogs, menus, the SFTP browser and the home page follow
the same design: neutral selection instead of accent washes (the
switcher's hard-coded blue row is gone), an inverted neutral primary
button, lighter scrims and headings no longer in capitals. The default
Light theme takes a new ink and accent. (#973, #993)
- **Search sits in the middle of the title bar** (#990). The centred path
repeated what the sidebar already says; it is now a field-shaped
**Search…** button, with its shortcut, that opens Search Everywhere —
centred over the terminal column on Windows and Linux. The trailing `…`
menu, which held only Search Everywhere and Settings, is gone: both keep
their shortcuts and the macOS menu bar entries.
- **Every confirmation is tty7's own dialog, and failures say what failed**
(#985). Closing a busy tab fell through to a system alert (NSAlert on
macOS, TaskDialog on Windows) while every other question was one of tty7's
cards; all confirmations are that card now, on every platform, with Return
and Escape keeping their meaning and a destructive answer painted red.
About twenty failure notices were one plain line such as `push: fatal: …`;
they are error toasts now, titled with what failed ("git push failed") and
the reason beneath. The delete confirmation says the file is removed for
good rather than moved to the Trash.
- **The code editor's header, menus and find bar match the rest of the
window** (#1005, #992). The file strip keeps only the files most recently
brought forward (three docked, seven filled, fewer with side panels open) in
a stable order; a `+N` button lists every open file with a filter, keyboard
navigation, *Close saved* and *Close others*. A Dock / Fill switch replaces
the header's right-click menu, and a filled editor leads with a pill naming
the terminal it covers that docks it back. The text's right-click menu,
which was the OS's own menu, is drawn like tty7's other menus and offers
Attach to Agent (the selected lines ride along as `#L3-9`), Find, Go to Line
and the file's own items; file tabs get Close, Close Others and Close to the
Right. The find bar is one filled pill with the match count and a clear
current-match colour, and ⌘⌥F / ⌘R (Ctrl+H elsewhere) opens it with the
replace row.
### Removed
- **The Window & Tabs settings page.** Its three tab settings — New tab
@@ -287,6 +459,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
moves to v11, so each remote host needs one Update Server, which ends the
sessions on it.
- **Editing a saved host's authentication, proxy, port forwarding and
advanced options in Settings.** The rebuilt SSH page edits Alias,
HostName, User, Port, IdentityFile and ProxyJump; values already saved for
the rest are kept and still used. (#973)
### Fixed
- **Quick launch found only the agents in the GUI's own bare `PATH`.** The app
@@ -311,9 +488,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
"Settings → Agents" — a page that is called Integrations — now names it
correctly in every language and in the `tty7` CLI.
- **Return runs the top row after a search that found nothing.** Backspacing
from a query with no results to one with some — or opening the search
already filtered, as the New Tab menu's *Other Shells…* row does — left no row selected, so Return did nothing until an arrow key was
pressed.
from a query with no results to one with some — or opening the search already
filtered, as the New Tab menu's *Other Shells…* row does — left no row
selected, so Return did nothing until an arrow key was pressed.
- **A typed `ssh -p 2222 me@box` is no longer offered as an address.** The
address parser read everything before the `@` as the user name and offered
to connect as `ssh -p 2222 me`; a line with spaces is now always taken as an
@@ -341,6 +518,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Not on Linux, where the text system reports a glyph's advance box rather
than its ink.
- **A host with `Compression yes` prints again** (reported by @RedwindA in
#997). It negotiated `zlib@openssh.com`, and the SSH library tty7 used cut
every packet that inflated past twice its compressed size, so the zlib
stream desynced right after authentication: the session showed as
connected and never printed a byte. The library is now pinned to a build
with the upstream fixes. Re-importing an ssh config now also updates the
algorithms of hosts that already exist.
- **The OpenCode plugin loads on OpenCode 2.x** (reported by @ArnoChenFx in
#999). 2.x only loads a plugin's default export shaped `{ id, setup }` and
rejected tty7's with a load error. The generated plugin now serves 1.x and
2.x, follows 2.x's event API, and starts its helper without `sh`, which
Windows does not have.
- **The code editor no longer loses edits** (#984). A save truncated the file
in place, so a crash or a full disk mid-save destroyed it; local saves now
go through a temporary file renamed over the target, falling back to an
in-place write only where a rename would change something visible. The
same file open in two tabs is one buffer, not two diverging copies.
Closing a tab, its last pane or the window, or quitting, asks about
unsaved files (Save / Cancel / Discard) instead of dropping them. Renaming
or deleting a file in the Files tree follows the open buffer, so a save no
longer recreates the old path, and a save asks before overwriting a change
made elsewhere. Undoing back to the saved text clears the unsaved mark, and
a reload keeps undo.
- **Typing Chinese through an input method no longer crashes the code
editor** (#993). When a re-parse ran past its time budget, stale
highlighting could put a style boundary inside the committed character and
panic the app.
- **Selecting text in rendered Markdown no longer blanks it out** (#991). The
selection was painted over the glyphs, so a multi-line selection in, say, a
pull request's description became a solid grey block.
- **The Processes and Ports sections see the pane's shell on a busy Mac**
(reported by @zcyc in #731). The macOS process list read the pid count as a
byte count, so on a machine with about 700 processes only the newest 60 or
so were seen; a pane whose shell started earlier showed no processes and no
ports, with nothing flagged.
- **A Files tree on a WSL distro stays in step with it** (reported by
@zt449569708 in #942). Copying a downloaded file onto the distro's share
no longer brings a `name:Zone.Identifier` file with it; a file removed from
the shell inside the distro leaves the tree (the share accepts a change
watch and never reports a change, so it is now polled every 2 seconds); and
right-clicking a row selects it.
- **The quit confirmation on Linux is no longer cut off mid-sentence**
(reported by @XuJinNet in #920). Linux has no native dialog, and the
fallback set the message and detail as single unbreakable lines in a
fixed-width box. Its text wraps now.
- **Fork no longer picks up the previous agent's session id** (by
@CodeHourra in #957). When the foreground agent in a pane changed, or
another agent's hook wrote to the same terminal, the old session id stayed
with the new agent.
- **Windows no longer freeze for a round trip when a remote watch closes.**
Closing a watch on a remote workspace waited for the server's answer, often
on the UI thread, so every window stalled for a round trip — up to five
seconds on a quiet link. The request is now sent without waiting.
- **A burst of pane output no longer holds up the UI.** The reader parsed each
batch, and a reconnect's whole replay of up to 8 MB, in one hold of the
grid lock that every UI handler touching the grid waits on. It now lets go
every 64 KB.
- **A command named in the search leads over a session that mentions it**
(#985). Typing `worktree` and Return resumed a past agent session instead of
opening New Worktree Tab: the fuzzy matcher spent the first letter on its
leftmost occurrence and scored a title containing the whole word as a poor
match, and session titles often open with a command's name. The matcher now
keeps the best alignment, and on the All tab sessions yield to an equally
good command.
- **Menu items bound to Return or Tab show the right shortcut on macOS.** A
menu item bound to ⌘↵, such as Toggle Fullscreen, read as ⌘E. (#973)
## [26.9.3] - 2026-09-23
### Added
Generated
+441 -61
View File
@@ -337,7 +337,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -348,7 +348,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1202,6 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
dependencies = [
"memchr",
"regex-automata",
"serde_core",
]
@@ -1486,9 +1487,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.6"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1496,9 +1497,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.6"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
@@ -1508,9 +1509,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.4"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -1885,6 +1886,12 @@ dependencies = [
"metal",
]
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -2185,6 +2192,46 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "defmt"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
dependencies = [
"defmt 1.1.1",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "delegate"
version = "0.13.5"
@@ -2302,7 +2349,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2340,7 +2387,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading 0.7.4",
"libloading 0.8.9",
]
[[package]]
@@ -2497,11 +2544,16 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encoding_rs"
version = "0.8.35"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
@@ -2627,7 +2679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2704,6 +2756,18 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "faster-hex"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed"
dependencies = [
"autocfg",
"defmt 0.3.100",
"heapless 0.8.0",
"serde",
]
[[package]]
name = "fastrand"
version = "1.9.0"
@@ -3327,6 +3391,217 @@ dependencies = [
"winapi",
]
[[package]]
name = "gix-actor"
version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e046e9a929e8e1f40f9a34f736408c4c079f9cf004429f3e718d270e847be96"
dependencies = [
"bstr",
"gix-date",
"gix-error",
]
[[package]]
name = "gix-date"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8beceb16a77fb222923592864b94beb4ad4c2b175a7055c2cac6815ee0370a"
dependencies = [
"bstr",
"gix-error",
"itoa",
"jiff",
]
[[package]]
name = "gix-discover"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4476ba055ec5fbb05f59dd98380b596428da060beac5f9dccd7374302ad1fbfb"
dependencies = [
"bstr",
"dunce",
"gix-error",
"gix-fs",
"gix-path",
"gix-ref",
"gix-sec",
]
[[package]]
name = "gix-error"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4a5c4cd326ddfe7f046ad9eed05461c59af6ea98b681648bac73954317addd"
dependencies = [
"bstr",
]
[[package]]
name = "gix-features"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb3a430dbc02d6c5e30a9f92083459593495ea22a476e837d248b22d800eed80"
dependencies = [
"gix-path",
"gix-trace",
"gix-utils",
"libc",
"prodash",
"walkdir",
]
[[package]]
name = "gix-fs"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0105ab1cef4542d092c27766d2daf1548fcbad7e460dc2208642c120bb19b3ad"
dependencies = [
"bstr",
"gix-error",
"gix-features",
"gix-path",
"gix-utils",
]
[[package]]
name = "gix-hash"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a22b6febaaee8546eb0c9dd21ecdd93aca269a6cb3897e1250f0f60933c0174"
dependencies = [
"faster-hex",
"gix-error",
"gix-features",
"sha1dc",
]
[[package]]
name = "gix-hashtable"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c55bff2f32f6f5a95bdd43e5e5e01b0656250cffbc48eda19db0afa3d73adf2f"
dependencies = [
"gix-hash",
"hashbrown 0.17.1",
"parking_lot",
]
[[package]]
name = "gix-lock"
version = "25.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd5ddfd0cc476fef744ac544783b96a9ff5d49f8de541e6e9d6d6002aa90720"
dependencies = [
"gix-error",
"gix-tempfile",
"gix-utils",
]
[[package]]
name = "gix-object"
version = "0.65.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a72909eeeed845721a143ef3dbeb4d6cd5df79c839fe7eefc138cb4f982eba3e"
dependencies = [
"bstr",
"gix-actor",
"gix-date",
"gix-error",
"gix-features",
"gix-hash",
"gix-hashtable",
"gix-utils",
"gix-validate",
"itoa",
"smallvec",
]
[[package]]
name = "gix-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd051ac60c5dbce2228daabf25b6a76408ac732d416c678cfa0a4914d13f60b8"
dependencies = [
"bstr",
"gix-error",
"gix-trace",
"gix-validate",
]
[[package]]
name = "gix-ref"
version = "0.68.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c90ceeec67de03b737c30b1b69116ca56fec803edde022f3d25181f0f597083d"
dependencies = [
"gix-actor",
"gix-error",
"gix-features",
"gix-fs",
"gix-hash",
"gix-lock",
"gix-object",
"gix-path",
"gix-tempfile",
"gix-utils",
"gix-validate",
"memmap2",
]
[[package]]
name = "gix-sec"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8eb983a830ba586b61f7005deaf44609e1a4186cbf13a0e8668154ca910461"
dependencies = [
"bitflags 2.13.1",
"gix-path",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "gix-tempfile"
version = "25.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dd7dd8df3345933caddd6ce67821c9eec17192564f1011cf0bd0b8cb0d07ffb"
dependencies = [
"gix-fs",
"libc",
"parking_lot",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "gix-trace"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df45b2cf6052d9e52681130c17d67505b023e8933ad974f94a2be0b0d24f636a"
[[package]]
name = "gix-utils"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70525766f53426ca4cd2ea4d58b0b31488dfedb416b3254e2efdd9dc184cc880"
dependencies = [
"fastrand 2.5.0",
"getrandom 0.4.3",
"unicode-normalization",
]
[[package]]
name = "gix-validate"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30574f81fc41b2ca3582ece2bdf8696bdfe9c170aa7e8bcafb81199c86b1ceea"
dependencies = [
"bstr",
"gix-error",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -3539,7 +3814,7 @@ dependencies = [
"gpui_macros",
"gpui_shared_string",
"gpui_util",
"heapless",
"heapless 0.9.3",
"http_client",
"image",
"inventory",
@@ -3592,7 +3867,7 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.5.2"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#379c56a2ffbcb9771fb12e9e2fd6cc68a725c154"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#d4ad0c108fbfa5423d4b522fa603caac5b47dbba"
dependencies = [
"aho-corasick",
"anyhow",
@@ -3675,7 +3950,7 @@ dependencies = [
[[package]]
name = "gpui-component-assets"
version = "0.5.1"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#379c56a2ffbcb9771fb12e9e2fd6cc68a725c154"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#d4ad0c108fbfa5423d4b522fa603caac5b47dbba"
dependencies = [
"anyhow",
"gpui",
@@ -3689,7 +3964,7 @@ dependencies = [
[[package]]
name = "gpui-component-macros"
version = "0.5.1"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#379c56a2ffbcb9771fb12e9e2fd6cc68a725c154"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#d4ad0c108fbfa5423d4b522fa603caac5b47dbba"
dependencies = [
"proc-macro2",
"quote",
@@ -4093,6 +4368,16 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heapless"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "heapless"
version = "0.9.3"
@@ -4765,9 +5050,9 @@ checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]]
name = "iroh"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f8d1cfffc83efe39a1031aab423ce09cb8048071baba550508931e9a81ce46"
checksum = "885787b892b5e2507c701f132ecbd45d2bad4bbb75157a19427087c16dadb833"
dependencies = [
"backon",
"blake3",
@@ -4815,9 +5100,9 @@ dependencies = [
[[package]]
name = "iroh-base"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd1efd1aaecd68d4d1b0727a30c5811f43fb822843e48f65f6e5db3b7256cc9"
checksum = "6ae9092be76c9f5429776ab3394f5a5a78dc19dc2524267e884a5b9781546c3f"
dependencies = [
"curve25519-dalek",
"data-encoding",
@@ -4904,9 +5189,9 @@ dependencies = [
[[package]]
name = "iroh-relay"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beb2294a9749d6a25fd7cd8bcf0fccd932f20716d4f967d85d3f23c7135ae6a2"
checksum = "ee116a8233f84980574bb8d11e4517503080cd2c3605bafca04ee9c35d708bd9"
dependencies = [
"blake3",
"bytes",
@@ -5028,6 +5313,60 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "jiff"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
dependencies = [
"defmt 1.1.1",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-link 0.2.1",
]
[[package]]
name = "jiff-core"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt 1.1.1",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -5825,7 +6164,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5881,9 +6220,15 @@ dependencies = [
"once_cell",
"png",
"thiserror 2.0.20",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "n0-dns-resolver"
version = "0.1.0"
@@ -6418,7 +6763,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7076,7 +7421,7 @@ dependencies = [
[[package]]
name = "pageant"
version = "0.2.1"
source = "git+https://github.com/ayamir/russh?rev=0d1d073350ed823069252075cbf3db9672d5b490#0d1d073350ed823069252075cbf3db9672d5b490"
source = "git+https://github.com/l0ng-ai/russh?rev=34f73e68d875b6fc2529654102ee65bafc691c59#34f73e68d875b6fc2529654102ee65bafc691c59"
dependencies = [
"base16ct",
"byteorder",
@@ -7454,13 +7799,13 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plist"
version = "1.10.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"indexmap",
"quick-xml 0.41.0",
"quick-xml 0.42.0",
"serde",
"time",
]
@@ -7754,7 +8099,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.13+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
]
[[package]]
@@ -7834,6 +8179,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prodash"
version = "31.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
dependencies = [
"parking_lot",
]
[[package]]
name = "profiling"
version = "1.0.18"
@@ -7967,6 +8321,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
@@ -8020,7 +8383,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8549,7 +8912,7 @@ dependencies = [
[[package]]
name = "russh"
version = "0.62.2"
source = "git+https://github.com/ayamir/russh?rev=0d1d073350ed823069252075cbf3db9672d5b490#0d1d073350ed823069252075cbf3db9672d5b490"
source = "git+https://github.com/l0ng-ai/russh?rev=34f73e68d875b6fc2529654102ee65bafc691c59#34f73e68d875b6fc2529654102ee65bafc691c59"
dependencies = [
"aes 0.9.2",
"aws-lc-rs",
@@ -8620,7 +8983,7 @@ dependencies = [
[[package]]
name = "russh-cryptovec"
version = "0.62.0"
source = "git+https://github.com/ayamir/russh?rev=0d1d073350ed823069252075cbf3db9672d5b490#0d1d073350ed823069252075cbf3db9672d5b490"
source = "git+https://github.com/l0ng-ai/russh?rev=34f73e68d875b6fc2529654102ee65bafc691c59#34f73e68d875b6fc2529654102ee65bafc691c59"
dependencies = [
"log",
"nix 0.31.3",
@@ -8651,7 +9014,7 @@ dependencies = [
[[package]]
name = "russh-util"
version = "0.52.0"
source = "git+https://github.com/ayamir/russh?rev=0d1d073350ed823069252075cbf3db9672d5b490#0d1d073350ed823069252075cbf3db9672d5b490"
source = "git+https://github.com/l0ng-ai/russh?rev=34f73e68d875b6fc2529654102ee65bafc691c59#34f73e68d875b6fc2529654102ee65bafc691c59"
dependencies = [
"chrono",
"tokio",
@@ -8793,7 +9156,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9397,6 +9760,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha1dc"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -9641,7 +10010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9796,7 +10165,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9931,7 +10300,7 @@ name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#5c390b9359e39f645ed6819e0be4bf3157e8ab3f"
dependencies = [
"heapless",
"heapless 0.9.3",
"log",
"rayon",
"tracing",
@@ -10245,7 +10614,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -10613,9 +10982,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.13+spec-1.1.0"
version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -10770,7 +11139,7 @@ dependencies = [
"once_cell",
"png",
"thiserror 2.0.20",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -11161,7 +11530,7 @@ dependencies = [
[[package]]
name = "tty7"
version = "26.9.3"
version = "26.9.4"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -11179,6 +11548,7 @@ dependencies = [
"ksni",
"libc",
"log",
"lsp-types",
"memchr",
"notify 8.2.0",
"notify-rust",
@@ -11211,7 +11581,7 @@ dependencies = [
[[package]]
name = "tty7-cli"
version = "26.9.3"
version = "26.9.4"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -11226,12 +11596,13 @@ dependencies = [
[[package]]
name = "tty7-core"
version = "26.9.3"
version = "26.9.4"
dependencies = [
"anyhow",
"base64 0.22.1",
"core-foundation 0.10.0",
"getrandom 0.3.4",
"gix-discover",
"hex",
"ignore",
"libc",
@@ -11256,7 +11627,7 @@ dependencies = [
"system-configuration-sys",
"tempfile",
"tokio",
"toml_edit 0.25.13+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
"ureq",
"uuid",
"windows-sys 0.59.0",
@@ -11265,7 +11636,7 @@ dependencies = [
[[package]]
name = "tty7-gateway"
version = "26.9.3"
version = "26.9.4"
dependencies = [
"anyhow",
"clap",
@@ -11286,7 +11657,7 @@ dependencies = [
[[package]]
name = "tty7-mobile-client"
version = "26.9.3"
version = "26.9.4"
dependencies = [
"anyhow",
"iroh",
@@ -11299,7 +11670,7 @@ dependencies = [
[[package]]
name = "tty7-mobile-proto"
version = "26.9.3"
version = "26.9.4"
dependencies = [
"base64 0.22.1",
"serde",
@@ -11309,7 +11680,7 @@ dependencies = [
[[package]]
name = "tty7-server"
version = "26.9.3"
version = "26.9.4"
dependencies = [
"libc",
"tempfile",
@@ -11336,7 +11707,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -11387,6 +11758,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -11463,9 +11843,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"log",
@@ -11481,9 +11861,9 @@ dependencies = [
[[package]]
name = "ureq-proto"
version = "0.6.1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64 0.23.1",
"http",
@@ -11607,9 +11987,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.26.0"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -12216,7 +12596,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -12978,7 +13358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
+13 -7
View File
@@ -82,6 +82,9 @@ tempfile = "3"
# userinfo and man-page names back out.
url = "2"
percent-encoding = "2"
# The code editor's language-server client (`ui::lsp`). The same version
# gpui-component's LSP hooks take their types from.
lsp-types = { version = "0.97.0", features = ["proposed"] }
# The code editor's file loading and saving (`ui::editor_text`). `encoding_rs`
# decodes legacy-encoded text (GB18030, Windows-1252) and writes it back in the
@@ -296,7 +299,7 @@ edition = "2024"
# they must never drift apart. This is the line a release bump edits (and the
# one nightly's `awk '/^version = /'` stamps: it is the first such line in the
# file, since the package entries above are all `version.workspace = true`).
version = "26.9.3"
version = "26.9.4"
[workspace.dependencies]
# Our fork's `tty7` branch carries the local customizations tty7 relies on:
@@ -354,12 +357,15 @@ smallvec = "1"
smol = "2"
[patch.crates-io]
# Temporary until upstream russh releases gssapi-with-mic client auth support
# (https://github.com/Eugeny/russh/pull/737) — remove this patch and take the
# crates.io release once it lands. Pinned to an exact rev (never a branch):
# russh is the SSH protocol layer handling user credentials, and a moving
# branch on a third-party fork could change what `cargo update` builds.
russh = { git = "https://github.com/ayamir/russh", rev = "0d1d073350ed823069252075cbf3db9672d5b490" }
# Our fork (l0ng-ai/russh, branch `tty7`): ayamir's gssapi-with-mic client auth
# (0d1d073) plus Eugeny/russh 58886f4d + 24e2c374, which stop zlib from
# truncating packets that inflate past 2x — without them a host with
# `Compression yes` connects and never prints a byte (#997). GSSAPI has since
# landed upstream (#738, in 0.62.7 / 0.63), but 0.62.7 lacks 24e2c374 and 0.63
# changes `check_server_key`; drop this patch when moving to 0.63.2+. Pinned
# to an exact rev (never a branch): russh is the SSH protocol layer handling
# user credentials, and `cargo update` must not change what it builds.
russh = { git = "https://github.com/l0ng-ai/russh", rev = "34f73e68d875b6fc2529654102ee65bafc691c59" }
[workspace.lints.clippy]
dbg_macro = "deny"
-1
View File
@@ -57,7 +57,6 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com
| **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> Search Everywhere · <kbd>⌘ F</kbd> scrollback search · hibernate a tab to free its memory · IME |
| **Side panel** | <kbd>⌘ J</kbd> · process tree and listening ports · file tree · changes and diffs · find in files · GitHub issues and pull requests, read-only, signed in through `gh` |
| **Customization** | 13 built-in themes · your own YAML · iTerm2 color import · rebindable shortcuts · English, 简体中文, 日本語 interface |
| **Shell integration** | injected when a pane starts, nothing to install · prompt marks · working directory · exit codes · command-finished notifications · zsh, bash, fish, PowerShell, WSL, remote panes |
| **Remote workspaces** | remote files, repos, changes, diffs, worktrees, tabs, and panes · reconnect from any client and continue where you left off |
| **SSH** | native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged `tty7-server` install |
-1
View File
@@ -56,7 +56,6 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
| **编辑器级输入** | 基于历史的内联补全建议 · Tab 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · <kbd>⌃ R</kbd> 模糊搜索历史 |
| **窗口** | 标签页与分屏 · <kbd>⌘ P</kbd> 随处搜索 · <kbd>⌘ F</kbd> 回滚搜索 · 休眠标签页以释放内存 · 输入法 |
| **侧边面板** | <kbd>⌘ J</kbd> · 进程树与监听端口 · 文件树 · 改动与 diff · 在文件中查找 · GitHub issue 与 PR(只读,通过 `gh` 登录) |
| **个性化** | 13 套内置主题 · 自定义 YAML · 导入 iTerm2 配色 · 快捷键可重新绑定 · 界面支持 English、简体中文、日本語 |
| **Shell 集成** | pane 启动时自动注入,不用你装什么 · 提示符边界 · 工作目录 · 退出码 · 命令跑完发通知 · 覆盖 zsh、bash、fish、PowerShell、WSL 和远程 pane |
| **远程工作区** | 远端的文件、仓库、改动、diff、worktree、标签页和 pane · 从任意客户端重连,接着离开时的位置继续 |
| **SSH** | 自带 russh 实现,不依赖外部 ssh:profile 凭据存入 keychain · SFTP 面板 · 端口转发 · 跳板机 · `tty7-server` 只需安装一次,无需 root |
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></svg>

After

Width:  |  Height:  |  Size: 251 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8.2 12.3l2.6 2.6 5-5.6"/></svg>

After

Width:  |  Height:  |  Size: 256 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-opacity=".35"/><path d="M12 3a9 9 0 0 1 9 9"/><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 340 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>

After

Width:  |  Height:  |  Size: 240 B

+94
View File
@@ -147,6 +147,12 @@ pub enum Command {
#[command(subcommand, about = "Panes: the terminals themselves (%N addresses)")]
Pane(PaneCmd),
#[command(
subcommand,
about = "Git worktrees tty7 makes: one per task, set up and handed to an agent"
)]
Worktree(WorktreeCmd),
#[command(
subcommand,
about = "Machines: this one plus every remote the server has a link to"
@@ -575,6 +581,94 @@ pub enum TabCmd {
},
}
#[derive(Debug, Subcommand)]
pub enum WorktreeCmd {
#[command(
about = "Create a worktree under <repo>/.tty7/worktrees, open a tab there, and run \
.tty7/setup then the agent in it; print the path",
long_about = "Create a worktree under <repo>/.tty7/worktrees, open a tab there, and \
run .tty7/setup then the agent in it; print the path.\n\n\
Gitignored files listed in .worktreeinclude are copied over first \
(copy-on-write where the filesystem can). The setup script gets \
TTY7_ROOT_PATH, TTY7_WORKTREE_PATH, TTY7_WORKTREE_NAME and TTY7_PORT, \
the first of ten ports this worktree has to itself."
)]
New {
#[arg(
value_name = "NAME",
help = "Directory name (default: a fresh two-word name)"
)]
name: Option<String>,
#[arg(long, value_name = "BRANCH", help = "Branch to create (default: NAME)")]
branch: Option<String>,
#[arg(
long,
value_name = "REF",
help = "Start point (default: the remote's default branch, fetched first)"
)]
from: Option<String>,
#[arg(
long,
value_name = "AGENT",
help = "Agent to start after setup: claude, codex, …"
)]
agent: Option<String>,
#[arg(
long,
value_name = "TEXT",
requires = "agent",
help = "The agent's first message (claude, codex and gemini take one)"
)]
task: Option<String>,
#[arg(long, help = "Do not run .tty7/setup")]
no_setup: bool,
#[arg(
long,
value_name = "WORKSPACE",
help = "Workspace for the tab (default: this one)"
)]
ws: Option<String>,
#[arg(
long,
value_name = "DIR",
help = "Repository to branch (default: the current directory)"
)]
repo: Option<std::path::PathBuf>,
},
#[command(about = "Checkouts of the repository here; tty7's are marked")]
Ls {
#[arg(
long,
value_name = "DIR",
help = "Repository (default: the current directory)"
)]
repo: Option<std::path::PathBuf>,
},
#[command(
about = "Remove a tty7 worktree and its branch if merged; its last state stays under \
refs/tty7/trash/<name>"
)]
Rm {
#[arg(value_name = "NAME|PATH")]
target: String,
#[arg(
long,
help = "Remove even with uncommitted changes (they are still snapshotted)"
)]
force: bool,
#[arg(long, help = "Close the panes still working in it first")]
close_panes: bool,
#[arg(
long,
value_name = "DIR",
help = "Repository (default: the current directory)"
)]
repo: Option<std::path::PathBuf>,
},
}
#[derive(Debug, Subcommand)]
pub enum PaneCmd {
#[command(about = "Panes of a workspace, or of the whole machine")]
+278 -1
View File
@@ -12,7 +12,7 @@ use crate::address::{self, Context, WorkspaceAddress};
use crate::backend::{Backend, RunSpec};
use crate::cli::{
CaptureArgs, Cli, Command, ExecArgs, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd,
SplitArgs, TabCmd, WaitArgs, WaitState, WsCmd,
SplitArgs, TabCmd, WaitArgs, WaitState, WorktreeCmd, WsCmd,
};
use crate::exec::ExecEnd;
use crate::output;
@@ -93,6 +93,46 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
Some(Command::Pane(PaneCmd::Close { targets, orphans })) => {
pane_close(&targets, orphans, ctx, backend)
}
Some(Command::Worktree(cmd)) => {
if let Some(machine) = machine.as_deref() {
bail!(
"`tty7 worktree` works on this machine's repositories only — -m {machine} \
would have run git here, not there"
);
}
match cmd {
WorktreeCmd::New {
name,
branch,
from,
agent,
task,
no_setup,
ws,
repo,
} => worktree_new(
WorktreeNew {
name,
branch,
from,
agent,
task,
no_setup,
ws,
repo,
},
ctx,
backend,
),
WorktreeCmd::Ls { repo } => worktree_ls(repo, backend),
WorktreeCmd::Rm {
target,
force,
close_panes,
repo,
} => worktree_rm(&target, force, close_panes, repo, backend),
}
}
Some(Command::Events) => events(json_mode, backend),
Some(Command::Agents) => agents(backend),
Some(Command::Wait(args)) => wait(args, ctx, backend),
@@ -1114,6 +1154,243 @@ fn hang_up_removed_panes(request: &str, reply: ReplyOk, backend: &mut dyn Backen
Ok(())
}
struct WorktreeNew {
name: Option<String>,
branch: Option<String>,
from: Option<String>,
agent: Option<String>,
task: Option<String>,
no_setup: bool,
ws: Option<String>,
repo: Option<std::path::PathBuf>,
}
fn repo_or_here(repo: Option<std::path::PathBuf>) -> Result<std::path::PathBuf> {
match repo {
Some(repo) => Ok(repo),
None => std::env::current_dir().context("cannot read the current directory"),
}
}
/// The same worktree the GUI's New Worktree dialog makes, with the same first
/// line typed into its tab. Setup runs unless `--no-setup`: whoever can run
/// this can already run the script by hand, so the GUI's approval step would
/// guard nothing here.
fn worktree_new(args: WorktreeNew, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
use tty7_core::core::cli_agent::CLIAgent;
use tty7_core::core::worktree::{self, WorktreeRequest, setup};
let host = tty7_core::host::local::LocalHost::new();
let repo = repo_or_here(args.repo)?;
let agent = args
.agent
.as_deref()
.map(|slug| {
CLIAgent::from_slug(slug).ok_or_else(|| anyhow::anyhow!("no agent called \"{slug}\""))
})
.transpose()?;
// The workspace is settled before anything is created, so a bad --ws
// leaves no worktree behind.
let machine = fetch_machine(backend)?;
let ws = resolve_ws(args.ws.as_deref(), ctx, &machine)?;
let defaults = worktree::defaults(&*host, &repo).map_err(anyhow::Error::msg)?;
let name = args.name.unwrap_or(defaults.name);
let req = WorktreeRequest {
branch: args.branch.unwrap_or_else(|| name.clone()),
name,
base: args.from.unwrap_or(defaults.base),
};
let wt = worktree::create(&*host, &repo, &req).map_err(anyhow::Error::msg)?;
let setup = (!args.no_setup && cfg!(unix))
.then(|| setup::find(&*host, &wt.path))
.flatten()
.zip(setup::env(&*host, &wt.main_root, &wt.path));
let overrides = tty7_core::core::config::Config::load().agent_launch;
let agent_line =
agent.map(|a| setup::agent_line(a, args.task.as_deref().unwrap_or(""), &overrides));
let line = setup::launch_line(
&wt.path,
setup
.as_ref()
.map(|(s, env)| (s.script.as_path(), env.as_slice())),
agent_line,
);
let cwd = wt.path.to_string_lossy().into_owned();
let pane = backend.spawn_shell(ws, Some(cwd.clone()))?;
let tab = match backend.control(ControlRequest::TabCreate {
workspace: ws,
at: None,
pane: PaneSeed {
pane,
cwd: Some(cwd.clone()),
ssh_spec: None,
agent: None,
shell: None,
},
tab: None,
})? {
ReplyOk::TabTree(tab) => *tab,
other => bail!("the server answered TabCreate with {other:?}"),
};
backend.control(ControlRequest::TabRename {
workspace: ws,
tab: tab.id,
name: Some(wt.branch.clone()),
})?;
if let Some(line) = &line {
backend.send_input(pane, format!("{line}\r").into_bytes())?;
}
let mut notes = vec![format!("branch {} from {}", wt.branch, req.base)];
if !wt.carried.copied.is_empty() {
notes.push(format!("copied {}", wt.carried.copied.join(", ")));
}
for (path, why) in &wt.carried.skipped {
notes.push(format!("not copied {path}: {why}"));
}
notes.push(format!("pane %{pane}"));
if setup.is_some() {
notes.push("running .tty7/setup".into());
}
report(
format!("{cwd}\n {}", notes.join(" · ")),
json!({
"path": cwd,
"branch": wt.branch,
"base": req.base,
"tab": tab.id.to_string(),
"pane": pane,
"setup": setup.is_some(),
"port": setup::port_base(&wt.path),
"copied": wt.carried.copied,
"skipped": wt.carried.skipped.iter().map(|(p, why)| json!({ "path": p, "reason": why })).collect::<Vec<_>>(),
"command": line,
}),
)
}
fn worktree_ls(repo: Option<std::path::PathBuf>, backend: &mut dyn Backend) -> Result<Outcome> {
let host = tty7_core::host::local::LocalHost::new();
let repo = repo_or_here(repo)?;
let listed = tty7_core::core::worktree::list(&*host, &repo).map_err(anyhow::Error::msg)?;
// Each live pane belongs to the deepest checkout its cwd is in: the main
// checkout contains `.tty7/worktrees/*`, and a pane in one of those is
// not also working in main.
let mut owner: Vec<Vec<u64>> = vec![Vec::new(); listed.len()];
for pane in backend.list_panes().unwrap_or_default() {
let Some(cwd) = pane.cwd.as_deref().filter(|_| pane.alive) else {
continue;
};
let deepest = listed
.iter()
.enumerate()
.filter(|(_, wt)| cwd.starts_with(&wt.path))
.max_by_key(|(_, wt)| wt.path.components().count());
if let Some((i, _)) = deepest {
owner[i].push(pane.pane_id);
}
}
let mut human = Vec::new();
let mut rows = Vec::new();
for (wt, inside) in listed.iter().zip(owner) {
let kind = match (wt.main, wt.managed) {
(true, _) => "main",
(false, true) => "tty7",
(false, false) => "",
};
let panes_text = inside
.iter()
.map(|p| format!("%{p}"))
.collect::<Vec<_>>()
.join(" ");
human.push(format!(
"{:<5} {:<28} {:<12} {}",
kind,
wt.branch.as_deref().unwrap_or("(detached)"),
panes_text,
wt.path.display()
));
rows.push(json!({
"path": wt.path,
"branch": wt.branch,
"main": wt.main,
"managed": wt.managed,
"panes": inside,
}));
}
report(human.join("\n"), json!({ "worktrees": rows }))
}
fn worktree_rm(
target: &str,
force: bool,
close_panes: bool,
repo: Option<std::path::PathBuf>,
backend: &mut dyn Backend,
) -> Result<Outcome> {
use tty7_core::core::worktree;
let host = tty7_core::host::local::LocalHost::new();
let repo = repo_or_here(repo)?;
let wt = worktree::find(&*host, &repo, target).map_err(anyhow::Error::msg)?;
let occupying: Vec<u64> = backend
.list_panes()?
.into_iter()
.filter(|p| p.alive && p.cwd.as_deref().is_some_and(|c| c.starts_with(&wt.path)))
.map(|p| p.pane_id)
.collect();
if !occupying.is_empty() {
let list = occupying
.iter()
.map(|p| format!("%{p}"))
.collect::<Vec<_>>()
.join(" ");
if !close_panes {
bail!(
"{list} still working in {} — close them, or pass --close-panes",
wt.path.display()
);
}
// A dirty checkout is refused before anything is closed, so a
// missing --force does not cost the panes.
if wt.dirty && !force {
bail!(
"{} has uncommitted changes — pass --force to remove it anyway (they are snapshotted first)",
wt.path.display()
);
}
for pane in &occupying {
backend.kill_pane(*pane)?;
}
}
let removed = worktree::remove(&*host, &wt, force).map_err(|e| {
anyhow::anyhow!(if wt.dirty && !force {
format!("{e} — pass --force to remove it anyway (they are snapshotted first)")
} else {
e
})
})?;
let mut notes = vec![format!("removed {}", wt.path.display())];
if let Some(snapshot) = &removed.snapshot {
notes.push(format!("last state in {snapshot}"));
}
if removed.branch_kept {
notes.push(format!("kept branch {} (unmerged commits)", wt.branch));
}
report(
notes.join(" · "),
json!({
"path": wt.path,
"branch": wt.branch,
"snapshot": removed.snapshot,
"branch_kept": removed.branch_kept,
"closed_panes": occupying,
}),
)
}
fn tab_rename(tab: &str, name: String, backend: &mut dyn Backend) -> Result<Outcome> {
let addr = address::parse_tab(tab)?;
let machine = fetch_machine(backend)?;
+100
View File
@@ -103,6 +103,10 @@ fn main() {
"send_paste_asks_the_pane_for_its_bracketed_paste_mode",
send_paste_asks_the_pane_for_its_bracketed_paste_mode,
),
(
"worktree_new_sets_up_the_checkout_and_rm_takes_it_down",
worktree_new_sets_up_the_checkout_and_rm_takes_it_down,
),
];
let mut failed = 0;
@@ -1275,3 +1279,99 @@ fn send_paste_asks_the_pane_for_its_bracketed_paste_mode(daemon: &Daemon) {
let seen = await_capture(daemon, &address, "tty7_e2e_framed");
assert!(seen.contains("[200~"), "{seen:?}");
}
/// `worktree new` end to end against a real server: the checkout, its
/// `.worktreeinclude` copy, the tab, and `.tty7/setup` actually running in
/// that tab with its variables; then `ls` sees the pane in it, and `rm` will
/// not take the checkout from under the pane unless told to.
fn worktree_new_sets_up_the_checkout_and_rm_takes_it_down(daemon: &Daemon) {
// The setup script is a POSIX executable; Windows has no `env` to run it.
if cfg!(windows) {
return;
}
let repo_dir = tempfile::tempdir().expect("temp repo");
let repo = repo_dir.path();
let git = |args: &[&str]| {
let out = Command::new("git")
.args(["-c", "user.email=t@t", "-c", "user.name=t"])
.args(args)
.current_dir(repo)
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "-q"]);
std::fs::write(repo.join(".gitignore"), ".env\nsetup-ran\n").unwrap();
std::fs::write(repo.join(".worktreeinclude"), ".env\n").unwrap();
std::fs::create_dir(repo.join(".tty7")).unwrap();
let script = repo.join(".tty7/setup");
std::fs::write(
&script,
"#!/bin/sh\necho \"$TTY7_PORT $TTY7_WORKTREE_NAME\" > setup-ran\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
std::fs::write(repo.join(".env"), "SECRET=1\n").unwrap();
let ws = daemon.run_json(&["new", &workdir()]);
let ws = ws["id"].as_str().expect("new prints the workspace id");
let repo_arg = repo.to_str().unwrap();
let made = daemon.run_json(&["worktree", "new", "e2e-wt", "--ws", ws, "--repo", repo_arg]);
let path = std::path::PathBuf::from(made["path"].as_str().expect("new prints the path"));
let pane = made["pane"].as_u64().expect("worktree new prints the pane");
let port = made["port"].as_u64().expect("worktree new prints the port");
assert_eq!(made["setup"], true, "{made}");
assert_eq!(
std::fs::read_to_string(path.join(".env")).unwrap(),
"SECRET=1\n"
);
let ran = path.join("setup-ran");
let deadline = Instant::now() + SETTLE_WITHIN;
let written = loop {
let text = std::fs::read_to_string(&ran).unwrap_or_default();
if text.ends_with('\n') {
break text;
}
assert!(
Instant::now() < deadline,
"setup never ran; the pane shows:\n{}",
daemon.run_ok(&["capture", &format!("%{pane}"), "--scrollback"])
);
std::thread::sleep(Duration::from_millis(200));
};
assert_eq!(written, format!("{port} e2e-wt\n"));
let listed = daemon.run_json(&["worktree", "ls", "--repo", repo_arg]);
let rows = listed["worktrees"].as_array().expect("ls lists worktrees");
assert_eq!(rows.len(), 2, "{listed}");
assert_eq!(rows[1]["managed"], true, "{listed}");
assert_eq!(rows[1]["panes"][0].as_u64(), Some(pane), "{listed}");
let refused = daemon.run(&["worktree", "rm", "e2e-wt", "--repo", repo_arg]);
assert!(
!refused.status.success(),
"rm took the checkout from under its pane"
);
assert!(path.exists());
let removed = daemon.run_json(&[
"worktree",
"rm",
"e2e-wt",
"--repo",
repo_arg,
"--close-panes",
]);
assert_eq!(removed["snapshot"], "refs/tty7/trash/e2e-wt", "{removed}");
assert!(!path.exists());
}
+8
View File
@@ -42,6 +42,14 @@ uuid = { version = "1", features = ["v4", "serde"] }
# even though only the GUI computes one today.
sha2 = "0.11"
# Finding the repository a pane's cwd is in (`core::git::head`) without a
# `git` process: the sidebar asks it of every pane, and a spawn that fails —
# `/usr/bin/git` on macOS is a shim that dies whenever the selected Xcode is
# mid-switch — used to read as "not a repository" and ungroup the tab. Only the
# discovery crate: counts, diffs and every write still go through the `git`
# CLI, so what the sidebar says stays the same answer the diff views give.
gix-discover = { version = "0.56", features = ["sha1"] }
# The gitignore matcher chain (`core::gitignore`) the file tree dims entries
# with — the same crate ripgrep uses. Lives here rather than in the GUI because
# the remote server has to answer "is this path ignored?" with the identical
+257 -63
View File
@@ -42,7 +42,20 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str {
if ran_by_grok { "grok" } else { agent }
}
/// The tools that stop a turn to ask the user a question: Claude's
/// `AskUserQuestion` and Codex's `request_user_input`. Neither goes through a
/// permission prompt, so their `PreToolUse` is the only sign the turn is now
/// waiting on the user.
const QUESTION_TOOLS: &[&str] = &["AskUserQuestion", "request_user_input"];
fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> {
// `tool-start` is not an event of its own: it fires before every tool
// call, and only the ones that ask the user something change the status.
if event == "tool-start" {
let payload = serde_json::from_str::<serde_json::Value>(stdin_json).ok()?;
let tool = payload.get("tool_name").and_then(|t| t.as_str())?;
return QUESTION_TOOLS.contains(&tool).then_some("question-asked");
}
// Antigravity has no turn-start event, only `PreInvocation` before every
// model call. The first call of a turn starts it; later ones are the same
// turn still working, and must not count as new prompts. `Stop` with
@@ -892,18 +905,33 @@ fn home_dir() -> Option<PathBuf> {
const OWNED_FILE_STEM_JSON: &str = "tty7.json";
const OWNED_FILE_STEM_JS: &str = "tty7.js";
/// `PermissionRequest` fires the moment the permission dialog opens; the
/// `Notification` that also reports it can trail by seconds, and stays as the
/// fallback for older Claude builds that do not know the event. A hook that
/// prints nothing leaves the decision to the dialog. `PreToolUse` is there for
/// `AskUserQuestion` alone (see [`QUESTION_TOOLS`]).
const CLAUDE_HOOK_EVENTS: &[(&str, &str)] = &[
("SessionStart", "session-start"),
("UserPromptSubmit", "prompt-submit"),
("PermissionRequest", "permission-request"),
("PreToolUse", "tool-start"),
("Notification", "notification"),
("PostToolUse", "tool-complete"),
("Stop", "stop"),
("SessionEnd", "session-end"),
];
/// Codex's `PermissionRequest` answered with no output falls through to its
/// own approval prompt, so it reports the wait without deciding anything.
/// `PostToolUse` is what takes the pane off waiting once the approved tool has
/// run, and `PreToolUse` catches `request_user_input` (see
/// [`QUESTION_TOOLS`]), which Codex runs without asking for approval.
const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[
("SessionStart", "session-start"),
("UserPromptSubmit", "prompt-submit"),
("PermissionRequest", "permission-request"),
("PreToolUse", "tool-start"),
("PostToolUse", "tool-complete"),
("Stop", "stop"),
];
@@ -1666,77 +1694,156 @@ fn goose_hooks_json(target: &HookTarget) -> Option<String> {
serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok()
}
/// The OpenCode plugin bridge — one file that both plugin loaders accept.
///
/// OpenCode 1.x (since 1.3.4) takes a default export `{ id, server }`, and
/// OpenCode 2.x takes `{ id, setup }` (or `{ id, effect }`) and rejects
/// anything else with "Plugin must export a default definition with an id
/// and an effect or setup function" (#999). Each loader ignores the key the
/// other one reads, so a default export carrying all three loads on both.
/// The named `Tty7Presence` export is the same 1.x factory, for 1.x builds
/// older than 1.3.4 that call every export; newer loaders never look at it.
///
/// The two majors differ in more than the export: 2.x hands `setup` no Bun
/// `$` and no `event` hook — events come from `ctx.event.subscribe()`, with
/// their fields under `data` rather than `properties`, and permission prompts
/// arrive as `permission.asked`. Both feed one tracker, so the session-id,
/// subagent and busy/idle rules are written once.
///
/// The emitter is spawned directly (no shell): `sh` is not there on Windows,
/// and 2.x may run on Node rather than Bun.
fn opencode_plugin_js(target: &HookTarget) -> Option<String> {
let prefix = serde_json::to_string(&format!(
"{} ",
target.hook_command(HookAgent::OpenCode, "").trim_end()
))
.ok()?;
Some(format!(
let exe = serde_json::to_string(&target.exe.display().to_string()).ok()?;
Some(
r#"// tty7 agent-hook opencode bridge — generated by tty7, do not edit.
// Bridges OpenCode plugin events onto `tty7 agent-hook opencode <event>`,
// which is inert outside tty7 (gated on the TTY7 env var).
export const Tty7Presence = async ({{ $ }}) => {{
if (!process.env["TTY7"]) return {{}}
const cmd = {prefix}
// Loads on OpenCode 1.x (default `{ id, server }`) and 2.x (`{ id, setup }`).
import { spawn } from "node:child_process"
const EXE = __EXE__
// Spawned directly, not through a shell: there is no `sh` on Windows.
function run(event, sessionId) {
return new Promise((resolve) => {
try {
const payload = sessionId ? JSON.stringify({ session_id: sessionId }) : ""
const child = spawn(EXE, ["agent-hook", "opencode", event], {
stdio: [payload ? "pipe" : "ignore", "ignore", "ignore"],
windowsHide: true,
})
child.on("error", () => resolve())
child.on("close", () => resolve())
if (payload) {
child.stdin.on("error", () => {})
child.stdin.end(payload)
}
} catch {
resolve()
}
})
}
// One pane's view of the event stream, shared by both plugin APIs.
function presence() {
let sessionId = ""
let announced = ""
// A subagent runs in a child session on the *same* event stream, and its
// status events are indistinguishable from the pane's own — only
// `session.created`/`session.updated` name a parent. Remember the children,
// so a task tool cannot hand the pane the wrong session to resume, nor call
// the pane done when only the subagent is.
// status events are indistinguishable from the pane's own — only session
// creation names a parent. Remember the children, so a task tool cannot
// hand the pane the wrong session to resume, nor call the pane done when
// only the subagent is.
const children = new Set()
const emit = (event) => {{
// Every event carries the session id (`properties.sessionID`), so a pane
// that restarts can resume the same session with `opencode --session`.
const payload = sessionId ? new Response(JSON.stringify({{ session_id: sessionId }})) : undefined
const proc = payload ? $`sh -c ${{cmd + event}} < ${{payload}}` : $`sh -c ${{cmd + event}}`
return proc.quiet().nothrow()
}}
// The session id is only known once opencode creates the session (the
// `session.created` event); the session-start report rides on the first
// event that names one, so a restored pane can reattach to it.
const capture = async (id) => {{
// Every event carries the session id, so a pane that restarts can resume
// the same session with `opencode --session`.
const emit = (event) => run(event, sessionId)
// The session id is only known once opencode creates the session; the
// session-start report rides on the first event that names one, so a
// restored pane can reattach to it.
const capture = async (id) => {
if (id && !children.has(id)) sessionId = id
if (sessionId && sessionId !== announced) {{
if (sessionId && sessionId !== announced) {
announced = sessionId
await emit("session-start")
}}
}}
const ACTION = {{
}
}
const ACTION = {
"session.status.busy": "prompt-submit",
"session.status.idle": "stop",
"session.idle": "stop",
"permission.asked": "permission-request",
"permission.replied": "prompt-submit",
}}
}
// `fields` is 1.x's `event.properties` or 2.x's `event.data`.
const observe = async (type, fields) => {
const info = fields.info
// 1.x: `session.created`/`session.updated` carry the whole session.
if (info?.id && info.parentID) children.add(info.id)
// 2.x: `session.created` names the session and its parent directly.
if (type === "session.created" && fields.sessionID && fields.parentID) children.add(fields.sessionID)
if (fields.sessionID && children.has(fields.sessionID)) return
await capture(fields.sessionID)
const key = type === "session.status" ? `session.status.${fields.status?.type}` : type
const action = ACTION[key]
if (action) await emit(action)
}
return { emit, capture, observe }
}
return {{
dispose: async () => {{
await emit("session-end")
}},
"tool.execute.before": async (input) => {{
await capture(input?.sessionID)
await emit("prompt-submit")
}},
"permission.ask": async (input) => {{
await capture(input?.sessionID)
await emit("permission-request")
}},
event: async ({{ event }}) => {{
const properties = event.properties ?? {{}}
const info = properties.info
if (info?.id && info.parentID) children.add(info.id)
if (properties.sessionID && children.has(properties.sessionID)) return
await capture(properties.sessionID)
const key = event.type === "session.status" ? `session.status.${{properties.status?.type}}` : event.type
const action = ACTION[key]
if (action) await emit(action)
}},
}}
}}
// OpenCode 1.x: a factory returning hooks, run in the TUI's own process.
const server = async () => {
if (!process.env["TTY7"]) return {}
const pane = presence()
return {
dispose: async () => {
await pane.emit("session-end")
},
"tool.execute.before": async (input) => {
await pane.capture(input?.sessionID)
await pane.emit("prompt-submit")
},
"permission.ask": async (input) => {
await pane.capture(input?.sessionID)
await pane.emit("permission-request")
},
event: async ({ event }) => {
await pane.observe(event.type, event.properties ?? {})
},
}
}
// OpenCode 2.x: `setup` subscribes to the event stream and returns cleanup.
const setup = async (ctx) => {
if (!process.env["TTY7"]) return
// By default 2.x runs plugins in one background service shared by every
// opencode on the machine: its sessions are not this pane's, and the
// emitter it spawns has no way back to the pane's terminal. Only a server
// the TUI started for itself (`--standalone`, or `opencode serve` run in
// the pane) belongs to one pane.
if (process.argv.includes("--service")) return
if (typeof ctx?.event?.subscribe !== "function") return
const pane = presence()
const abort = new AbortController()
// Not awaited: setup has to return for the plugin to finish loading.
;(async () => {
try {
for await (const event of ctx.event.subscribe({ signal: abort.signal })) {
if (event?.type) await pane.observe(event.type, event.data ?? {})
}
} catch {}
})()
return async () => {
abort.abort()
await pane.emit("session-end")
}
}
export const Tty7Presence = server
export default { id: "tty7", server, setup }
"#
))
.replace("__EXE__", &exe),
)
}
/// The Pi extension bridge, shared with Oh My Pi.
@@ -1966,6 +2073,46 @@ mod tests {
assert_eq!(effective_agent("grok", false), "grok");
}
#[test]
fn tool_start_only_speaks_for_question_tools() {
for (agent, tool) in [
("claude", "AskUserQuestion"),
("codex", "request_user_input"),
] {
let input = format!(r#"{{"tool_name":"{tool}","tool_input":{{}}}}"#);
assert_eq!(
effective_event(agent, "tool-start", &input),
Some("question-asked"),
"{agent} {tool}"
);
}
for input in [
r#"{"tool_name":"Bash"}"#,
r#"{"tool_name":"shell"}"#,
"{}",
"not json",
] {
assert_eq!(
effective_event("claude", "tool-start", input),
None,
"{input}"
);
}
}
#[test]
fn claude_and_codex_report_permission_prompts_first_hand() {
for events in [CLAUDE_HOOK_EVENTS, CODEX_HOOK_EVENTS] {
for (hook, sentinel) in [
("PermissionRequest", "permission-request"),
("PreToolUse", "tool-start"),
("PostToolUse", "tool-complete"),
] {
assert!(events.contains(&(hook, sentinel)), "{hook}");
}
}
}
#[test]
fn every_installed_event_parses_as_a_sentinel_kind() {
use crate::core::cli_agent::parse_agent_event;
@@ -1985,6 +2132,9 @@ mod tests {
.chain(CURSOR_HOOK_EVENTS)
.map(|(_, e)| *e)
.chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e))
// Never sent as itself: `effective_event` turns it into a real
// kind or drops it (`tool_start_only_speaks_for_question_tools`).
.filter(|e| *e != "tool-start")
.collect();
events.extend([
"prompt-submit",
@@ -2964,36 +3114,80 @@ mod tests {
let opencode = opencode_plugin_js(&target).expect("opencode content builds");
assert!(opencode.contains("agent-hook opencode"));
assert!(opencode.contains(hook_exe));
assert!(opencode.contains(&format!("const EXE = {exe_json}")));
assert!(opencode.contains(r#"process.env["TTY7"]"#));
for (needle, message) in [
(
"properties.sessionID",
"opencode captures the session id from event properties",
r#"export default { id: "tty7", server, setup }"#,
"opencode default-exports one definition both plugin loaders accept (#999)",
),
(
"export const Tty7Presence = server",
"opencode keeps the named factory for 1.x loaders that call every export",
),
(
r#"spawn(EXE, ["agent-hook", "opencode", event]"#,
"opencode spawns the emitter without a shell, which Windows lacks",
),
(
"event.properties ?? {}",
"opencode 1.x reads event fields from properties",
),
(
"ctx.event.subscribe(",
"opencode 2.x reads events from the subscribe stream",
),
(
"event.data ?? {}",
"opencode 2.x reads event fields from data",
),
(
r#"process.argv.includes("--service")"#,
"opencode 2.x stays inert in the machine-wide background service",
),
(
r#"session_id: sessionId"#,
"opencode forwards the session id to the emitter",
),
(
"session.status",
"opencode maps session.status busy/idle to prompt-submit/stop",
r#""session.status.busy": "prompt-submit""#,
"opencode maps session.status busy to prompt-submit",
),
(
"session.idle",
r#""session.status.idle": "stop""#,
"opencode maps session.status idle to stop",
),
(
r#""session.idle": "stop""#,
"opencode still maps the session.idle event to stop",
),
(
"info.parentID",
"opencode tells a subagent's child session apart from the pane's own",
r#""permission.asked": "permission-request""#,
"opencode 2.x reports its permission.asked event as a permission request",
),
(
"children.has(properties.sessionID)",
r#""permission.ask": async"#,
"opencode 1.x keeps the permission.ask hook",
),
(
"info.parentID",
"opencode 1.x tells a subagent's child session apart from the pane's own",
),
(
"fields.parentID",
"opencode 2.x tells a subagent's child session apart from the pane's own",
),
(
"children.has(fields.sessionID)",
"opencode lets a child session's events pass without touching the pane",
),
] {
assert!(opencode.contains(needle), "{message}");
}
assert!(
!opencode.contains("sh -c"),
"opencode no longer routes the emitter through sh"
);
for (agent, slug, package) in [
(HookAgent::Pi, "pi", "@mariozechner/pi-coding-agent"),
+145 -1
View File
@@ -677,6 +677,18 @@ impl CLIAgent {
.map_or_else(|| self.binary().to_string(), str::to_string)
}
/// The arguments that start an interactive session whose first message is
/// `prompt`. `None` for an agent whose positional prompt means a one-shot,
/// non-interactive run, or whose form is not known — the caller then
/// starts the agent bare rather than guess.
pub fn prompt_args(self, prompt: &str) -> Option<Vec<String>> {
match self {
CLIAgent::Claude | CLIAgent::Codex => Some(vec![prompt.to_string()]),
CLIAgent::Gemini => Some(vec!["-i".to_string(), prompt.to_string()]),
_ => None,
}
}
/// `argv` as a command line worth launching this agent with again: its
/// binary followed by the flags it ran with, minus everything that names
/// one particular session (a resume, a fork, a session id) and minus
@@ -971,6 +983,13 @@ pub struct AgentSessionState {
/// later one that finished while nobody was watching (#870).
#[serde(default)]
pub turns: u64,
/// The status was concluded by tty7, not reported by the agent: a turn
/// the user interrupted ([`Self::assume_interrupted`]) or one that went
/// quiet for too long ([`Self::assume_stale`]). Cleared by the next real
/// event, and a tool finishing after it proves the guess wrong and puts
/// the turn back on [`AgentStatus::Working`].
#[serde(default)]
pub inferred: bool,
}
impl AgentStatus {
@@ -989,8 +1008,44 @@ impl AgentSessionState {
AgentStatus::Idle
}
/// Whether a turn is in flight: the only statuses a user interrupt or a
/// lost `Stop` can leave behind.
pub fn mid_turn(&self) -> bool {
matches!(self.status, AgentStatus::Working | AgentStatus::Waiting)
}
/// End the turn the user just interrupted.
///
/// Most agents fire no hook when <kbd>Esc</kbd> or <kbd>Ctrl+C</kbd>
/// cancels a turn — Claude's `Stop` explicitly skips user interrupts — so
/// without this the pane stays on working until the next prompt, and a
/// close asks about a turn that is long over. The caller has already
/// waited for a real event to say otherwise.
pub fn assume_interrupted(&mut self) {
if !self.mid_turn() {
return;
}
self.turns = self.turns.wrapping_add(1);
self.status = AgentStatus::Done;
self.message = None;
self.inferred = true;
}
/// Give up on a turn that has gone silent for longer than any real one
/// stays quiet. Idle rather than Done: nothing says it finished, only
/// that nothing says it is still going.
pub fn assume_stale(&mut self) {
if self.status != AgentStatus::Working {
return;
}
self.status = AgentStatus::Idle;
self.message = None;
self.inferred = true;
}
pub fn apply_event(&mut self, ev: &AgentEvent) {
self.rich = true;
let guessed = std::mem::take(&mut self.inferred);
if let Some(id) = &ev.session_id {
self.session_id = Some(id.clone());
}
@@ -1018,7 +1073,7 @@ impl AgentSessionState {
}
AgentEventKind::ToolComplete => {
self.activity = self.activity.wrapping_add(1);
if self.status == AgentStatus::Waiting {
if self.status == AgentStatus::Waiting || guessed {
self.status = AgentStatus::Working;
self.message = None;
}
@@ -1648,6 +1703,95 @@ mod tests {
assert_eq!(s.activity, 4);
}
#[test]
fn an_assumed_interrupt_ends_the_turn_until_the_agent_says_otherwise() {
let ev = |kind| AgentEvent {
agent: Some(CLIAgent::Claude),
kind,
session_id: None,
message: None,
cwd: None,
prompt: None,
};
let mut s = AgentSessionState::default();
s.assume_interrupted();
assert_eq!(s.status, AgentStatus::Idle, "no turn, nothing to interrupt");
assert!(!s.inferred);
s.apply_event(&ev(AgentEventKind::PromptSubmit));
s.apply_event(&ev(AgentEventKind::PermissionRequest));
s.assume_interrupted();
assert_eq!(
s.status,
AgentStatus::Done,
"a rejected prompt ends the turn"
);
assert_eq!(s.turns, 1);
assert!(s.inferred);
s.apply_event(&ev(AgentEventKind::ToolComplete));
assert_eq!(
s.status,
AgentStatus::Working,
"a tool finishing after the key proves the turn was still going"
);
assert!(!s.inferred);
s.apply_event(&ev(AgentEventKind::Stop));
assert_eq!(s.turns, 2);
s.apply_event(&ev(AgentEventKind::ToolComplete));
assert_eq!(
s.status,
AgentStatus::Done,
"a real Stop is not a guess and stays put"
);
s.apply_event(&ev(AgentEventKind::PromptSubmit));
s.assume_interrupted();
s.apply_event(&ev(AgentEventKind::Notification));
assert!(!s.inferred, "any real event retires the guess");
s.apply_event(&ev(AgentEventKind::ToolComplete));
assert_eq!(s.status, AgentStatus::Done);
}
#[test]
fn a_stale_turn_goes_idle_and_comes_back_on_the_next_tool() {
let ev = |kind| AgentEvent {
agent: Some(CLIAgent::Claude),
kind,
session_id: None,
message: None,
cwd: None,
prompt: None,
};
let mut s = AgentSessionState::default();
s.apply_event(&ev(AgentEventKind::PromptSubmit));
s.apply_event(&ev(AgentEventKind::PermissionRequest));
s.assume_stale();
assert_eq!(
s.status,
AgentStatus::Waiting,
"a prompt nobody answered is not stale"
);
s.apply_event(&ev(AgentEventKind::ToolComplete));
s.assume_stale();
assert_eq!(s.status, AgentStatus::Idle);
assert_eq!(s.turns, 0, "going quiet is not finishing");
assert!(s.inferred);
s.apply_event(&ev(AgentEventKind::ToolComplete));
assert_eq!(s.status, AgentStatus::Working);
}
#[test]
fn a_session_state_from_an_older_daemon_is_not_inferred() {
let s: AgentSessionState = serde_json::from_str(r#"{"status":"done"}"#).unwrap();
assert!(!s.inferred);
}
#[test]
fn each_finished_turn_is_counted_once() {
let ev = |kind| AgentEvent {
+19
View File
@@ -263,6 +263,18 @@ pub struct Config {
/// [`Self::editor_soft_wrap`]. Files that are not Markdown ignore it.
#[serde(default)]
pub editor_markdown_preview: bool,
/// Whether the code editor starts language servers (rust-analyzer,
/// typescript-language-server, …) for the files it opens, for
/// diagnostics, completion, hover and go to definition. On by default;
/// a language whose server is not installed simply goes without.
#[serde(default = "default_true")]
pub editor_lsp: bool,
/// Whether the code editor marks lines that differ from the file's
/// staged (index) version in its gutter. On by default; flipped by the
/// `ToggleEditorGitGutter` command and remembered like
/// [`Self::editor_soft_wrap`].
#[serde(default = "default_true")]
pub editor_git_gutter: bool,
/// Whether the sidebar files tabs nobody pinned into groups of its own —
/// by repository, and by host for an SSH pane. Off, those tabs sit in one
/// flat list below the pinned groups, which are the user's and show
@@ -420,6 +432,10 @@ pub struct Config {
/// "New Agent Tab" opens.
#[serde(default)]
pub agent_frecency: HashMap<String, ProfileUsage>,
/// Approved `.tty7/setup` scripts: repo (`worktree::setup::trust_key`) to
/// the sha256 of the content approved. A changed script asks again.
#[serde(default)]
pub worktree_setup_trust: HashMap<String, String>,
/// Past agent sessions taken out of the search's Sessions tab, as
/// `<agent slug>:<session id>`. Only the listing forgets them; the
/// agent's own history is not touched.
@@ -764,6 +780,8 @@ impl Default for Config {
scm_changes_tree: false,
editor_soft_wrap: false,
editor_markdown_preview: false,
editor_lsp: true,
editor_git_gutter: true,
sidebar_auto_grouping: true,
notify_on_command_finish: NotifyMode::Unfocused,
check_for_updates: true,
@@ -806,6 +824,7 @@ impl Default for Config {
agent_commands: HashMap::new(),
agent_launch: HashMap::new(),
agent_frecency: HashMap::new(),
worktree_setup_trust: HashMap::new(),
hidden_agent_sessions: BTreeSet::new(),
restore_agent_sessions: true,
per_pane_history: false,
+184
View File
@@ -0,0 +1,184 @@
//! Which repository a directory is in, read off the disk.
//!
//! The sidebar asks this of every pane, and asking `git` meant three processes
//! a pane — each of which can fail for reasons that have nothing to do with the
//! directory. On macOS `/usr/bin/git` is a shim that hands off to whichever
//! Xcode is selected, and dies while that selection is mid-switch; every probe
//! in that window used to come back "not a repository" and pull its tab out of
//! its group. Nothing here spawns anything: the answer is a handful of small
//! files, which is all `git rev-parse` reads for it too.
//!
//! Only the *where*: root, home and the branch name. The line counts beside
//! the branch still come from `git diff --numstat`, the same CLI the diff views
//! read, so the two never disagree about a number.
use std::path::{Path, PathBuf};
/// Where a directory's repository is, as far as the files on disk say.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct RepoHead {
/// The working tree the directory is in — `git rev-parse --show-toplevel`.
pub root: PathBuf,
/// The main working tree a linked one belongs to; `root` otherwise.
pub home: PathBuf,
/// The checked-out branch, when HEAD names one plainly. `None` for
/// anything `git` should spell instead — a detached HEAD, whose short sha
/// `git` lengthens until it is unambiguous, and a reftable repository,
/// whose HEAD file is a placeholder.
pub branch: Option<String>,
}
/// The repository `cwd` is in, or `None` when it is in none.
///
/// A bare repository counts as none, and so does a `.git` directory itself —
/// `git rev-parse --show-toplevel` refuses both, and there is no working tree
/// for a tab to be grouped under. So does a directory that does not exist.
pub fn read_head(cwd: &Path) -> Option<RepoHead> {
let options = gix_discover::upwards::Options::default().apply_environment();
let (path, _trust) = gix_discover::upwards_opts(cwd, options).ok()?;
let (git_dir, work_dir) = path.into_repository_and_work_tree_directories();
let work_dir = work_dir?;
// `git` answers with the physical path, and the root is the key the diff
// views file their own `rev-parse` answer under: a pane that reached its
// repository through a symlink has to land on the same spelling.
let root = std::fs::canonicalize(&work_dir).ok()?;
let git_dir = std::fs::canonicalize(&git_dir).ok()?;
// Standing in the git dir itself: discovery walks up to the tree it
// belongs to, but `git` refuses the question there, and so does this.
if std::fs::canonicalize(cwd).is_ok_and(|cwd| cwd.starts_with(&git_dir)) {
return None;
}
let home = home_of(&root, &git_dir);
let branch = std::fs::read_to_string(git_dir.join("HEAD"))
.ok()
.and_then(|head| branch_of(&head));
Some(RepoHead { root, home, branch })
}
/// The main working tree `root` belongs to, given its git directory.
///
/// A linked worktree's private git dir names the shared one in `commondir`;
/// that shared dir is the main tree's `.git`, whose parent is the home. A
/// shared dir with another name is a bare repository with worktrees hung off
/// it, and the repository itself is the home — the same rule `repo_home`
/// applies to what `rev-parse --git-common-dir` prints.
fn home_of(root: &Path, git_dir: &Path) -> PathBuf {
let Ok(common) = std::fs::read_to_string(git_dir.join("commondir")) else {
return root.to_path_buf();
};
let Ok(common) = std::fs::canonicalize(git_dir.join(common.trim_end_matches(['\n', '\r'])))
else {
return root.to_path_buf();
};
if common == git_dir {
return root.to_path_buf();
}
match (common.file_name(), common.parent()) {
(Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(),
_ => common,
}
}
/// The branch a HEAD file names, spelled the way `git symbolic-ref --short`
/// would, or `None` where that is not a plain read.
fn branch_of(head: &str) -> Option<String> {
let name = head
.trim_end_matches(['\n', '\r'])
.strip_prefix("ref: refs/heads/")?;
// Reftable keeps HEAD only for older tools to trip over.
(!name.is_empty() && name != ".invalid").then(|| name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::git::test_support::{PINS, one_spelling};
fn git(cwd: &Path, args: &[&str]) -> Option<String> {
let mut full = PINS.to_vec();
full.extend_from_slice(args);
let out = crate::core::git::git_output(cwd, &full).ok()?;
out.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
#[test]
fn head_files_spell_branches_like_git() {
assert_eq!(branch_of("ref: refs/heads/main\n").as_deref(), Some("main"));
assert_eq!(
branch_of("ref: refs/heads/feat/x\r\n").as_deref(),
Some("feat/x")
);
assert_eq!(
branch_of("0123456789abcdef0123456789abcdef01234567\n"),
None
);
assert_eq!(branch_of("ref: refs/heads/.invalid\n"), None);
assert_eq!(branch_of("ref: refs/remotes/origin/main\n"), None);
}
/// The whole point: every answer here is the one `git` gives, for a main
/// tree, a subdirectory, a linked worktree and a plain directory.
#[test]
fn agrees_with_git_about_repos_and_worktrees() {
let dir = tempfile::tempdir().unwrap();
let main = dir.path().join("main");
std::fs::create_dir(&main).unwrap();
if git(&main, &["init", "--quiet"]).is_none() {
return; // no git on this machine
}
git(&main, &["symbolic-ref", "HEAD", "refs/heads/trunk"]).unwrap();
git(&main, &["commit", "--quiet", "--allow-empty", "-m", "one"]).unwrap();
let sub = main.join("src/deep");
std::fs::create_dir_all(&sub).unwrap();
let linked = dir.path().join("linked");
git(
&main,
&[
"worktree",
"add",
"--quiet",
"-b",
"feat/x",
linked.to_str().unwrap(),
],
)
.unwrap();
let canon = |p: &Path| std::fs::canonicalize(p).unwrap();
for (cwd, root, home, branch) in [
(&main, &main, &main, "trunk"),
(&sub, &main, &main, "trunk"),
(&linked, &linked, &main, "feat/x"),
] {
let got = read_head(cwd).unwrap_or_else(|| panic!("{} is a repo", cwd.display()));
assert_eq!(got.root, canon(root), "root of {}", cwd.display());
assert_eq!(got.home, canon(home), "home of {}", cwd.display());
assert_eq!(got.branch.as_deref(), Some(branch));
// In one spelling: `canonicalize` answers `\\?\C:\…` on Windows and
// git `C:/…`. `probe_repo` folds the first into the local spelling
// before anything keys on it.
assert_eq!(
one_spelling(&got.root),
one_spelling(Path::new(
&git(cwd, &["rev-parse", "--show-toplevel"]).unwrap()
)),
"and git names the same root"
);
}
git(&main, &["checkout", "--quiet", "--detach"]).unwrap();
assert_eq!(
read_head(&main).unwrap().branch,
None,
"git spells a detached HEAD"
);
assert_eq!(
read_head(&main.join(".git")),
None,
"a git dir has no working tree"
);
assert_eq!(read_head(&dir.path().join("gone")), None);
}
}
+110 -12
View File
@@ -10,6 +10,7 @@
//! questions for a remote workspace that `LocalHost` answers for this machine,
//! and the conformance suite holds the two to the same behaviour.
pub mod diff;
pub mod head;
pub mod log;
pub mod ops;
pub mod status;
@@ -35,9 +36,59 @@ pub struct RepoSnapshot {
pub counts: Option<(u32, u32)>,
}
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
let paths = git(
host,
/// What asking a directory "which repository are you in" came back with.
///
/// Two ways of not getting a snapshot, and they must not be confused. "Not a
/// repository" is an answer about the directory and holds until it moves; a
/// probe that *failed* — `git` would not start, the Xcode shim behind
/// `/usr/bin/git` had no developer directory to hand off to, a remote link
/// dropped mid-call — says nothing about it at all. Filing the second as the
/// first is what used to pull a tab sitting in a perfectly good repository out
/// of its sidebar group, for good: an idle pane never asks again.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum RepoProbe {
Repo(RepoSnapshot),
NotARepo,
Failed,
}
/// Exit status of a `git` that ran and refused: `fatal: not a git repository`,
/// and the handful of other fatals (a `safe.directory` refusal among them)
/// that mean this directory is not one git will work in.
const GIT_FATAL: i32 = 128;
pub fn probe_repo(host: &dyn Host, cwd: &Path) -> RepoProbe {
let (root, home, branch) = match host.repo_head(cwd) {
Some(None) => return RepoProbe::NotARepo,
Some(Some(head)) => (
crate::core::path_spelling::spelling_on_buf(host.id(), &head.root),
crate::core::path_spelling::spelling_on_buf(host.id(), &head.home),
head.branch,
),
None => match locate(host, cwd) {
Ok((root, home)) => (root, home, None),
Err(outcome) => return outcome,
},
};
// A branch the files could not spell plainly — a detached HEAD above all —
// is `git`'s to name, so it matches what the diff views print for it.
// `rev-parse` has already said this is a repository, so a branch that
// cannot be read is the probe failing, not the directory changing its mind.
let Some(branch) = branch.or_else(|| branch_name(host, cwd)) else {
return RepoProbe::Failed;
};
RepoProbe::Repo(RepoSnapshot {
home,
root,
branch,
counts: diff_numstat(host, cwd),
})
}
/// Root and home of `cwd`'s repository, asked of `git` — for a host that
/// cannot read them in-process ([`Host::repo_head`]).
fn locate(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), RepoProbe> {
let out = match host.git(
cwd,
&[
"rev-parse",
@@ -46,17 +97,42 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
"--git-dir",
"--git-common-dir",
],
)?;
) {
Ok(out) => out,
// A directory that is gone — a worktree removed under a pane still
// sitting in it — is not a repository, and never will be again.
Err(_)
if host
.stat(cwd)
.is_err_and(|e| e.kind() == io::ErrorKind::NotFound) =>
{
return Err(RepoProbe::NotARepo);
}
Err(_) => return Err(RepoProbe::Failed),
};
if !out.success() {
return Err(match out.status {
Some(GIT_FATAL) => RepoProbe::NotARepo,
_ => RepoProbe::Failed,
});
}
let Ok(paths) = String::from_utf8(out.stdout) else {
return Err(RepoProbe::Failed);
};
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
let root = git_path(host, lines.next()?);
let Some(root) = lines.next().map(|l| git_path(host, l)) else {
return Err(RepoProbe::Failed);
};
let home = repo_home(host, &root, lines.next(), lines.next());
let branch = branch_name(host, cwd)?;
Some(RepoSnapshot {
home,
root,
branch,
counts: diff_numstat(host, cwd),
})
Ok((root, home))
}
/// [`probe_repo`] for a caller that only wants the snapshot.
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
match probe_repo(host, cwd) {
RepoProbe::Repo(snap) => Some(snap),
RepoProbe::NotARepo | RepoProbe::Failed => None,
}
}
/// A path `git` just printed, in the spelling the rest of tty7 keys by.
@@ -498,6 +574,28 @@ mod tests {
);
}
#[test]
fn a_plain_or_vanished_directory_is_not_a_repo() {
let host = h();
let dir = tempfile::tempdir().unwrap();
let plain = dir.path().join("plain");
std::fs::create_dir(&plain).unwrap();
// A temp directory can sit under a repository of its own on some
// machine; git has nothing to say about this test's premise there.
if host
.git(&plain, &["rev-parse", "--git-dir"])
.is_ok_and(|o| o.success())
{
return;
}
assert_eq!(probe_repo(&*host, &plain), RepoProbe::NotARepo);
assert_eq!(
probe_repo(&*host, &dir.path().join("gone")),
RepoProbe::NotARepo,
"a removed worktree is not a failed probe"
);
}
#[test]
fn line_splitter_rejoins_across_chunks() {
let mut split = LineSplitter::default();
+229 -4
View File
@@ -8,7 +8,8 @@
use serde::de::DeserializeOwned;
use super::model::{
Comment, Detail, Item, Kind, PrFile, RawComment, RawFile, RawIssue, RawPull, StateFilter,
Checks, Comment, Detail, Item, ItemState, Kind, PrFile, RawCheckRuns, RawCombinedStatus,
RawComment, RawFile, RawIssue, RawPull, RawReview, StateFilter,
};
use super::remote::RepoSlug;
@@ -267,12 +268,32 @@ pub fn detail(t: &dyn Transport, slug: &RepoSlug, number: u64) -> Result<Detail,
let comments_truncated = reply.has_next;
let (mut pull, mut files, mut files_truncated) = (None, None, false);
let (mut checks_out, mut reviewers_out) = (None, None);
if is_pr {
let (pr_item, info) =
decode::<RawPull>(&t.get(&format!("{base}/pulls/{number}"))?)?.into_item();
let mut raw: RawPull = decode(&t.get(&format!("{base}/pulls/{number}"))?)?;
let teams = raw.requested_team_names(&slug.owner);
let requested = std::mem::take(&mut raw.requested_reviewers);
let (pr_item, info) = raw.into_item();
// `/pulls/{n}` is the one that knows about drafts; the issue's
// labels and comment count are kept, which `/pulls` omits.
item.state = pr_item.state;
// Checks and reviews are the panel's summary, not the pull request
// itself: one that cannot be read is left out rather than failing
// the whole view (an old commit's checks can be gone, a token can be
// scoped away from them).
if !info.head_sha.is_empty() {
checks_out = checks(t, slug, &info.head_sha)
.inspect_err(|e| log::warn!("github: checks of {}#{number}: {e}", slug.full()))
.ok();
}
reviewers_out = t
.get(&format!(
"{base}/pulls/{number}/reviews?per_page={DETAIL_PAGE}"
))
.and_then(|r| decode::<Vec<RawReview>>(&r))
.inspect_err(|e| log::warn!("github: reviews of {}#{number}: {e}", slug.full()))
.ok()
.map(|reviews| super::model::reviewers(&item.author, reviews, requested, teams));
pull = Some(info);
let reply = t.get(&format!(
"{base}/pulls/{number}/files?per_page={DETAIL_PAGE}"
@@ -294,6 +315,50 @@ pub fn detail(t: &dyn Transport, slug: &RepoSlug, number: u64) -> Result<Detail,
pull,
files,
files_truncated,
checks: checks_out,
reviewers: reviewers_out,
})
}
/// Every check on commit `sha`: its check runs and its commit statuses.
/// This is also what a pending pull request is polled with, so it stays two
/// requests however the detail around it grows.
pub fn checks(t: &dyn Transport, slug: &RepoSlug, sha: &str) -> Result<Checks, ApiError> {
let base = repo_path(slug);
let sha = super::remote::escape_path(sha);
let runs: RawCheckRuns = decode(&t.get(&format!(
"{base}/commits/{sha}/check-runs?per_page={DETAIL_PAGE}"
))?)?;
let statuses: RawCombinedStatus = decode(&t.get(&format!(
"{base}/commits/{sha}/status?per_page={DETAIL_PAGE}"
))?)?;
Ok(super::model::checks(runs, statuses))
}
/// The pull request branch `branch` of `head_owner`'s fork (or of the
/// repository itself) opened against `slug`: the open one if there is one,
/// else the most recently updated. `None` when the branch has none.
pub fn pull_for_branch(
t: &dyn Transport,
slug: &RepoSlug,
head_owner: &str,
branch: &str,
) -> Result<Option<Item>, ApiError> {
let head = escape_query(&format!("{head_owner}:{branch}"));
let path = format!(
"{}/pulls?state=all&head={head}&sort=updated&direction=desc&per_page=10",
repo_path(slug)
);
let items: Vec<Item> = decode::<Vec<RawPull>>(&t.get(&path)?)?
.into_iter()
.map(|p| p.into_item().0)
.collect();
let open = items
.iter()
.position(|i| matches!(i.state, ItemState::Open | ItemState::Draft));
Ok(match open {
Some(i) => items.into_iter().nth(i),
None => items.into_iter().next(),
})
}
@@ -301,7 +366,7 @@ pub fn detail(t: &dyn Transport, slug: &RepoSlug, number: u64) -> Result<Detail,
pub(crate) mod tests {
use super::*;
use crate::core::git::diff::FileStatus;
use crate::core::github::model::ItemState;
use crate::core::github::model::{CheckState, ItemState, MergeState, ReviewState};
use std::collections::HashMap;
use std::sync::Mutex;
@@ -530,6 +595,166 @@ pub(crate) mod tests {
assert!(d.files_truncated);
}
/// A pull request's issue, comments and `/pulls/{n}` — the three answers
/// every pull request detail starts from.
fn pull_fixture(t: &mut Fixture, pull: &str) {
t.on(
"/repos/l0ng-ai/tty7/issues/31",
r#"{"number": 31, "title": "Handle empty summaries", "state": "open",
"user": {"login": "ada"}, "pull_request": {"merged_at": null}}"#,
false,
);
t.on(
"/repos/l0ng-ai/tty7/issues/31/comments?per_page=100",
"[]",
false,
);
t.on("/repos/l0ng-ai/tty7/pulls/31", pull, false);
t.on(
"/repos/l0ng-ai/tty7/pulls/31/files?per_page=100",
"[]",
false,
);
}
const PULL_31: &str = r#"{"number": 31, "title": "Handle empty summaries", "state": "open",
"draft": false, "merged_at": null, "user": {"login": "ada"},
"head": {"ref": "fix/summary", "sha": "abc123"}, "base": {"ref": "main"},
"mergeable_state": "blocked",
"requested_reviewers": [{"login": "jonas"}], "requested_teams": [{"slug": "core"}]}"#;
#[test]
fn a_pull_request_detail_reads_its_checks_and_reviews() {
let mut t = Fixture::new();
pull_fixture(&mut t, PULL_31);
t.on(
"/repos/l0ng-ai/tty7/commits/abc123/check-runs?per_page=100",
r#"{"total_count": 3, "check_runs": [
{"name": "lint", "status": "completed", "conclusion": "success",
"started_at": "2026-09-01T10:00:00Z", "completed_at": "2026-09-01T10:00:18Z",
"html_url": "https://github.com/l0ng-ai/tty7/actions/runs/1"},
{"name": "test", "status": "in_progress", "conclusion": null,
"started_at": "2026-09-01T10:00:00Z"},
{"name": "docs", "status": "completed", "conclusion": "skipped"}]}"#,
false,
);
t.on(
"/repos/l0ng-ai/tty7/commits/abc123/status?per_page=100",
r#"{"total_count": 1, "statuses": [
{"context": "ci/deploy", "state": "failure",
"target_url": "https://ci.example.com/9", "updated_at": "2026-09-01T10:05:00Z"}]}"#,
false,
);
t.on(
"/repos/l0ng-ai/tty7/pulls/31/reviews?per_page=100",
r#"[{"user": {"login": "mara"}, "state": "APPROVED"},
{"user": {"login": "ada"}, "state": "COMMENTED"}]"#,
false,
);
let d = detail(&t, &slug(), 31).unwrap();
let pull = d.pull.as_ref().unwrap();
assert_eq!(pull.head_sha, "abc123");
assert_eq!(pull.merge_state, MergeState::Blocked);
let checks = d.checks.as_ref().unwrap();
let names: Vec<&str> = checks.items.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
vec!["ci/deploy", "test", "lint", "docs"],
"failures first"
);
assert_eq!(
checks.items[2].completed_at - checks.items[2].started_at,
18
);
assert_eq!(checks.counted(), 3, "a skipped check has no verdict");
assert_eq!(checks.rollup(), Some(CheckState::Failed));
assert!(!checks.truncated);
let reviewers = d.reviewers.as_ref().unwrap();
assert_eq!(
reviewers
.iter()
.map(|r| (r.login.as_str(), r.state))
.collect::<Vec<_>>(),
vec![
("mara", ReviewState::Approved),
("jonas", ReviewState::Requested),
("l0ng-ai/core", ReviewState::Requested),
],
"the author's own reply is not a review"
);
}
#[test]
fn checks_and_reviews_that_cannot_be_read_leave_the_detail_standing() {
let mut t = Fixture::new();
pull_fixture(&mut t, PULL_31);
// Nothing answers for check runs, statuses or reviews.
let d = detail(&t, &slug(), 31).unwrap();
assert!(d.pull.is_some());
assert!(d.checks.is_none());
assert!(d.reviewers.is_none());
}
#[test]
fn a_pull_request_without_a_head_sha_asks_for_no_checks() {
let mut t = Fixture::new();
pull_fixture(
&mut t,
r#"{"number": 31, "title": "x", "state": "open", "head": {"ref": "gone"}}"#,
);
let d = detail(&t, &slug(), 31).unwrap();
assert!(d.checks.is_none());
assert!(
!t.asked
.lock()
.unwrap()
.iter()
.any(|p| p.contains("/commits/")),
"no commit to ask about"
);
}
#[test]
fn a_branchs_pull_request_prefers_the_open_one() {
let mut t = Fixture::new();
t.on(
"/repos/l0ng-ai/tty7/pulls?state=all&head=bob%3Afeat%2Fpanel&sort=updated&direction=desc&per_page=10",
r#"[{"number": 20, "title": "Second try", "state": "closed", "merged_at": null},
{"number": 13, "title": "Add panel", "state": "open", "draft": true}]"#,
false,
);
let got = pull_for_branch(&t, &slug(), "bob", "feat/panel")
.unwrap()
.unwrap();
assert_eq!(got.number, 13);
assert_eq!(got.state, ItemState::Draft);
let mut t = Fixture::new();
t.on(
"/repos/l0ng-ai/tty7/pulls?state=all&head=bob%3Aold&sort=updated&direction=desc&per_page=10",
r#"[{"number": 20, "title": "Merged", "state": "closed", "merged_at": "2026-09-01T10:00:00Z"}]"#,
false,
);
assert_eq!(
pull_for_branch(&t, &slug(), "bob", "old")
.unwrap()
.unwrap()
.number,
20,
"no open one: the latest"
);
let mut t = Fixture::new();
t.on(
"/repos/l0ng-ai/tty7/pulls?state=all&head=bob%3Anone&sort=updated&direction=desc&per_page=10",
"[]",
false,
);
assert_eq!(pull_for_branch(&t, &slug(), "bob", "none"), Ok(None));
}
#[test]
fn a_failed_sub_request_fails_the_detail() {
let mut t = Fixture::new();
+4 -1
View File
@@ -16,6 +16,9 @@ pub mod remote;
pub mod token;
pub use api::{ApiError, ListPage, ListQuery, Reply, Transport};
pub use model::{Comment, Detail, Item, ItemState, Kind, Label, PrFile, PullInfo, StateFilter};
pub use model::{
Check, CheckState, Checks, Comment, Detail, Item, ItemState, Kind, Label, MergeState, PrFile,
PullInfo, Readiness, ReviewState, Reviewer, StateFilter, readiness,
};
pub use remote::{GitHubRemote, RepoSlug};
pub use token::{Token, TokenSource};
+564 -1
View File
@@ -86,11 +86,178 @@ pub struct Comment {
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct PullInfo {
pub head_ref: String,
/// The head commit — what the checks ran on. Empty if GitHub left it out.
pub head_sha: String,
pub base_ref: String,
pub additions: u32,
pub deletions: u32,
pub changed_files: u32,
pub commits: u32,
pub merge_state: MergeState,
}
/// GitHub's `mergeable_state`: whether the pull request could be merged now,
/// and if not, the broad reason. GitHub computes it lazily, so the first read
/// of a freshly pushed pull request is often `Unknown`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum MergeState {
/// Mergeable, everything required has passed.
Clean,
/// Mergeable, but a check that is not required is failing.
Unstable,
/// Branch protection says no: a required check or review is missing.
Blocked,
/// The base branch has moved on and protection requires being up to date.
Behind,
/// Merge conflicts.
Dirty,
Draft,
#[default]
Unknown,
}
impl MergeState {
fn parse(s: Option<&str>) -> MergeState {
match s {
// "has_hooks" is clean with a pre-receive hook in the way — for
// the panel's purposes, clean.
Some("clean") | Some("has_hooks") => MergeState::Clean,
Some("unstable") => MergeState::Unstable,
Some("blocked") => MergeState::Blocked,
Some("behind") => MergeState::Behind,
Some("dirty") => MergeState::Dirty,
Some("draft") => MergeState::Draft,
_ => MergeState::Unknown,
}
}
}
/// Where one CI check stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum CheckState {
// Declared in the order the panel lists them: what needs attention first.
Failed,
Pending,
Passed,
/// Skipped, neutral, or stale — ran (or was never going to) without a
/// verdict either way. Not counted toward "N of M passed".
Skipped,
}
/// One CI check on a pull request's head commit: a check run (GitHub Actions
/// and any other GitHub App) or a commit status (the older API some external
/// CI still reports through). GitHub's own page lists both side by side.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Check {
pub name: String,
pub state: CheckState,
/// Unix seconds, zero when unknown. A commit status has no start time.
pub started_at: i64,
pub completed_at: i64,
/// Its log or details page, when there is one.
pub url: Option<String>,
}
/// Every check on a head commit, the ones that need attention first.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct Checks {
pub items: Vec<Check>,
/// More checks exist than were fetched.
pub truncated: bool,
}
impl Checks {
pub fn count(&self, state: CheckState) -> usize {
self.items.iter().filter(|c| c.state == state).count()
}
/// The checks with a verdict to give: everything but the skipped ones.
pub fn counted(&self) -> usize {
self.items.len() - self.count(CheckState::Skipped)
}
/// The whole set in one state, the way a list row or a badge shows it:
/// any failure fails it, else anything running keeps it pending.
pub fn rollup(&self) -> Option<CheckState> {
if self.items.is_empty() {
return None;
}
[CheckState::Failed, CheckState::Pending, CheckState::Passed]
.into_iter()
.find(|s| self.count(*s) > 0)
.or(Some(CheckState::Skipped))
}
fn sort(&mut self) {
// Stable: within a state, GitHub's own order (by run, then context).
self.items.sort_by_key(|c| c.state);
}
}
/// Where one reviewer stands on a pull request.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ReviewState {
Approved,
ChangesRequested,
/// Left review comments without a verdict.
Commented,
/// Asked for a review that has not come in (or was asked again after one).
Requested,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Reviewer {
/// A user's login, or `org/team` for a requested team.
pub login: String,
pub state: ReviewState,
}
/// Whether an open pull request can be merged, and if not, the one thing
/// most worth saying about why.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Readiness {
Ready,
Conflicts,
ChecksFailing(usize),
WaitingOnChecks(usize),
ChangesRequested,
ReviewRequired,
Behind,
Blocked,
}
/// Folds the merge state, the checks and the reviews into one line's worth,
/// the way GitHub's merge box leads with its most pressing reason. `None` for
/// anything but an open, non-draft pull request, and while GitHub has not
/// worked the merge state out yet.
pub fn readiness(
state: ItemState,
merge: MergeState,
checks: Option<&Checks>,
reviewers: Option<&[Reviewer]>,
) -> Option<Readiness> {
if state != ItemState::Open {
return None;
}
let count = |s| checks.map_or(0, |c| c.count(s));
let has = |s| reviewers.is_some_and(|r| r.iter().any(|r| r.state == s));
Some(match merge {
MergeState::Unknown | MergeState::Draft => return None,
MergeState::Dirty => Readiness::Conflicts,
_ if count(CheckState::Failed) > 0 && merge != MergeState::Clean => {
Readiness::ChecksFailing(count(CheckState::Failed))
}
_ if count(CheckState::Pending) > 0 && merge != MergeState::Clean => {
Readiness::WaitingOnChecks(count(CheckState::Pending))
}
_ if has(ReviewState::ChangesRequested) && merge != MergeState::Clean => {
Readiness::ChangesRequested
}
MergeState::Behind => Readiness::Behind,
MergeState::Clean | MergeState::Unstable => Readiness::Ready,
MergeState::Blocked if !has(ReviewState::Approved) => Readiness::ReviewRequired,
MergeState::Blocked => Readiness::Blocked,
})
}
/// One file a pull request touches, from `/pulls/{n}/files`.
@@ -157,6 +324,12 @@ pub struct Detail {
/// `Some` for a pull request.
pub files: Option<Vec<PrFile>>,
pub files_truncated: bool,
/// The head commit's CI. `None` for an issue, and for a pull request
/// whose checks could not be read — a detail does not fail over them.
pub checks: Option<Checks>,
/// Reviews in and reviews asked for, one entry per reviewer. `None` as
/// for `checks`.
pub reviewers: Option<Vec<Reviewer>>,
}
// ---- wire ------------------------------------------------------------------
@@ -219,6 +392,14 @@ pub(crate) struct RawIssue {
pub(crate) struct RawBranchRef {
#[serde(default, rename = "ref")]
name: String,
#[serde(default)]
sha: String,
}
#[derive(Deserialize)]
pub(crate) struct RawTeam {
#[serde(default)]
slug: String,
}
/// An entry of `/pulls`, or `/pulls/{n}`.
@@ -257,6 +438,13 @@ pub(crate) struct RawPull {
changed_files: Option<u32>,
#[serde(default)]
commits: Option<u32>,
/// Only on `/pulls/{n}`.
#[serde(default)]
mergeable_state: Option<String>,
#[serde(default)]
pub(crate) requested_reviewers: Vec<RawUser>,
#[serde(default)]
pub(crate) requested_teams: Vec<RawTeam>,
}
#[derive(Deserialize)]
@@ -292,6 +480,60 @@ pub(crate) struct RawFile {
patch: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct RawReview {
#[serde(default)]
user: Option<RawUser>,
#[serde(default)]
state: String,
}
/// `/commits/{sha}/check-runs`.
#[derive(Deserialize)]
pub(crate) struct RawCheckRuns {
#[serde(default)]
pub(crate) total_count: usize,
#[serde(default)]
pub(crate) check_runs: Vec<RawCheckRun>,
}
#[derive(Deserialize)]
pub(crate) struct RawCheckRun {
#[serde(default)]
name: String,
#[serde(default)]
status: String,
#[serde(default)]
conclusion: Option<String>,
#[serde(default)]
started_at: Option<String>,
#[serde(default)]
completed_at: Option<String>,
#[serde(default)]
html_url: Option<String>,
}
/// `/commits/{sha}/status`: the latest status per context.
#[derive(Deserialize)]
pub(crate) struct RawCombinedStatus {
#[serde(default)]
pub(crate) total_count: usize,
#[serde(default)]
pub(crate) statuses: Vec<RawStatus>,
}
#[derive(Deserialize)]
pub(crate) struct RawStatus {
#[serde(default)]
context: String,
#[serde(default)]
state: String,
#[serde(default)]
target_url: Option<String>,
#[serde(default)]
updated_at: String,
}
fn login(user: Option<RawUser>) -> String {
user.map(|u| u.login).unwrap_or_default()
}
@@ -355,13 +597,16 @@ impl RawPull {
self.merged_at.is_some(),
self.draft.unwrap_or(false),
);
let (head_ref, head_sha) = self.head.map(|h| (h.name, h.sha)).unwrap_or_default();
let info = PullInfo {
head_ref: self.head.map(|h| h.name).unwrap_or_default(),
head_ref,
head_sha,
base_ref: self.base.map(|b| b.name).unwrap_or_default(),
additions: self.additions.unwrap_or(0),
deletions: self.deletions.unwrap_or(0),
changed_files: self.changed_files.unwrap_or(0),
commits: self.commits.unwrap_or(0),
merge_state: MergeState::parse(self.mergeable_state.as_deref()),
};
let item = Item {
number: self.number,
@@ -379,6 +624,134 @@ impl RawPull {
}
}
impl RawCheckRun {
pub(crate) fn into_check(self) -> Check {
let state = match (self.status.as_str(), self.conclusion.as_deref()) {
("completed", Some("success")) => CheckState::Passed,
("completed", Some("skipped" | "neutral" | "stale")) => CheckState::Skipped,
// failure, cancelled, timed_out, action_required, startup_failure
("completed", _) => CheckState::Failed,
// queued, in_progress, waiting, requested, pending
_ => CheckState::Pending,
};
Check {
name: self.name,
state,
started_at: self.started_at.as_deref().map_or(0, timestamp),
completed_at: self.completed_at.as_deref().map_or(0, timestamp),
url: self.html_url.filter(|u| !u.is_empty()),
}
}
}
impl RawStatus {
pub(crate) fn into_check(self) -> Check {
let state = match self.state.as_str() {
"success" => CheckState::Passed,
"pending" => CheckState::Pending,
// failure, error
_ => CheckState::Failed,
};
Check {
name: self.context,
state,
started_at: 0,
completed_at: if state == CheckState::Pending {
0
} else {
timestamp(&self.updated_at)
},
url: self.target_url.filter(|u| !u.is_empty()),
}
}
}
/// Check runs and commit statuses as one list, the ones needing attention
/// first. `/check-runs` already keeps only each check's latest run, and
/// `/status` each context's latest status.
pub(crate) fn checks(runs: RawCheckRuns, statuses: RawCombinedStatus) -> Checks {
let truncated =
runs.total_count > runs.check_runs.len() || statuses.total_count > statuses.statuses.len();
let mut checks = Checks {
items: runs
.check_runs
.into_iter()
.map(RawCheckRun::into_check)
.chain(statuses.statuses.into_iter().map(RawStatus::into_check))
.collect(),
truncated,
};
checks.sort();
checks
}
/// Each reviewer's standing, from the reviews in (oldest first, as GitHub
/// lists them) and the reviews still asked for.
///
/// A verdict sticks until the same reviewer gives another or it is dismissed;
/// a later plain comment does not undo an approval, the way GitHub's own
/// sidebar reads. Asking again puts a reviewer back to "requested" whatever
/// they said before. The author's replies in their own review threads are
/// reviews by GitHub's count, but not a reviewer.
pub(crate) fn reviewers(
author: &str,
reviews: Vec<RawReview>,
requested_users: Vec<RawUser>,
requested_teams: Vec<String>,
) -> Vec<Reviewer> {
let mut out: Vec<Reviewer> = Vec::new();
for review in reviews {
let login = login(review.user);
if login.is_empty() || login == author {
continue;
}
let verdict = match review.state.as_str() {
"APPROVED" => Some(ReviewState::Approved),
"CHANGES_REQUESTED" => Some(ReviewState::ChangesRequested),
"COMMENTED" | "DISMISSED" => None,
// PENDING: the viewer's own unsubmitted review.
_ => continue,
};
match out.iter_mut().find(|r| r.login == login) {
Some(r) => match verdict {
Some(v) => r.state = v,
None if review.state == "DISMISSED" => r.state = ReviewState::Commented,
None => {}
},
None => out.push(Reviewer {
login,
state: verdict.unwrap_or(ReviewState::Commented),
}),
}
}
let requested = requested_users
.into_iter()
.map(|u| u.login)
.chain(requested_teams)
.filter(|l| !l.is_empty());
for login in requested {
match out.iter_mut().find(|r| r.login == login) {
Some(r) => r.state = ReviewState::Requested,
None => out.push(Reviewer {
login,
state: ReviewState::Requested,
}),
}
}
out
}
impl RawPull {
/// The teams asked to review, as `org/team` when the org is known.
pub(crate) fn requested_team_names(&self, org: &str) -> Vec<String> {
self.requested_teams
.iter()
.filter(|t| !t.slug.is_empty())
.map(|t| format!("{org}/{}", t.slug))
.collect()
}
}
impl RawComment {
pub(crate) fn into_comment(self) -> Comment {
Comment {
@@ -491,6 +864,196 @@ mod tests {
assert_eq!(timestamp("garbage"), 0);
}
fn review(login: &str, state: &str) -> RawReview {
RawReview {
user: Some(RawUser {
login: login.into(),
}),
state: state.into(),
}
}
fn states(r: &[Reviewer]) -> Vec<(&str, ReviewState)> {
r.iter().map(|r| (r.login.as_str(), r.state)).collect()
}
#[test]
fn a_verdict_sticks_until_the_reviewer_gives_another() {
let got = reviewers(
"author",
vec![
review("mara", "CHANGES_REQUESTED"),
review("mara", "APPROVED"),
review("mara", "COMMENTED"),
review("jonas", "COMMENTED"),
review("kai", "APPROVED"),
review("kai", "DISMISSED"),
review("author", "COMMENTED"),
review("lee", "PENDING"),
],
Vec::new(),
Vec::new(),
);
assert_eq!(
states(&got),
vec![
("mara", ReviewState::Approved),
("jonas", ReviewState::Commented),
("kai", ReviewState::Commented),
]
);
}
#[test]
fn asking_again_puts_a_reviewer_back_to_requested() {
let got = reviewers(
"author",
vec![review("mara", "CHANGES_REQUESTED")],
vec![RawUser {
login: "mara".into(),
}],
vec!["acme/core".into()],
);
assert_eq!(
states(&got),
vec![
("mara", ReviewState::Requested),
("acme/core", ReviewState::Requested),
]
);
}
fn run(status: &str, conclusion: Option<&str>) -> RawCheckRun {
RawCheckRun {
name: format!("{status}/{conclusion:?}"),
status: status.into(),
conclusion: conclusion.map(str::to_string),
started_at: None,
completed_at: None,
html_url: None,
}
}
#[test]
fn check_runs_fold_into_four_states() {
for (status, conclusion, want) in [
("completed", Some("success"), CheckState::Passed),
("completed", Some("failure"), CheckState::Failed),
("completed", Some("cancelled"), CheckState::Failed),
("completed", Some("timed_out"), CheckState::Failed),
("completed", Some("action_required"), CheckState::Failed),
("completed", Some("skipped"), CheckState::Skipped),
("completed", Some("neutral"), CheckState::Skipped),
("queued", None, CheckState::Pending),
("in_progress", None, CheckState::Pending),
("waiting", None, CheckState::Pending),
] {
assert_eq!(
run(status, conclusion).into_check().state,
want,
"{status} {conclusion:?}"
);
}
}
fn checks_of(states: &[CheckState]) -> Checks {
Checks {
items: states
.iter()
.map(|s| Check {
name: String::new(),
state: *s,
started_at: 0,
completed_at: 0,
url: None,
})
.collect(),
truncated: false,
}
}
#[test]
fn a_rollup_is_failed_before_pending_before_passed() {
use CheckState::*;
assert_eq!(checks_of(&[]).rollup(), None);
assert_eq!(checks_of(&[Passed, Pending, Failed]).rollup(), Some(Failed));
assert_eq!(
checks_of(&[Passed, Pending, Skipped]).rollup(),
Some(Pending)
);
assert_eq!(checks_of(&[Passed, Skipped]).rollup(), Some(Passed));
assert_eq!(checks_of(&[Skipped]).rollup(), Some(Skipped));
}
#[test]
fn a_merge_box_leads_with_its_most_pressing_reason() {
use CheckState::*;
let approved = [Reviewer {
login: "mara".into(),
state: ReviewState::Approved,
}];
let changes = [Reviewer {
login: "mara".into(),
state: ReviewState::ChangesRequested,
}];
let r = |merge, checks: &[CheckState], reviews: &[Reviewer]| {
readiness(
ItemState::Open,
merge,
Some(&checks_of(checks)),
Some(reviews),
)
};
assert_eq!(
r(MergeState::Blocked, &[Passed, Pending], &approved),
Some(Readiness::WaitingOnChecks(1))
);
assert_eq!(
r(MergeState::Blocked, &[Failed, Failed, Pending], &approved),
Some(Readiness::ChecksFailing(2))
);
assert_eq!(
r(MergeState::Dirty, &[Failed], &approved),
Some(Readiness::Conflicts)
);
assert_eq!(
r(MergeState::Blocked, &[Passed], &changes),
Some(Readiness::ChangesRequested)
);
assert_eq!(
r(MergeState::Blocked, &[Passed], &[]),
Some(Readiness::ReviewRequired)
);
assert_eq!(
r(MergeState::Blocked, &[Passed], &approved),
Some(Readiness::Blocked)
);
assert_eq!(
r(MergeState::Behind, &[Passed], &approved),
Some(Readiness::Behind)
);
// Clean means everything required is in, whatever optional check is
// still running.
assert_eq!(
r(MergeState::Clean, &[Pending], &[]),
Some(Readiness::Ready)
);
assert_eq!(
r(MergeState::Unstable, &[Failed], &[]),
Some(Readiness::ChecksFailing(1))
);
assert_eq!(r(MergeState::Unknown, &[Passed], &approved), None);
assert_eq!(
readiness(ItemState::Draft, MergeState::Clean, None, None),
None,
"a draft is not up for merging"
);
assert_eq!(
readiness(ItemState::Merged, MergeState::Clean, None, None),
None
);
}
fn file(patch: Option<&str>, additions: u32, deletions: u32) -> PrFile {
PrFile {
path: "src/a file.rs".into(),
+1
View File
@@ -19,6 +19,7 @@ pub mod paste;
pub mod path_spelling;
pub mod proc;
pub mod session;
pub mod shell_quote;
pub mod shells;
#[allow(dead_code)]
pub mod ssh_profile;
-431
View File
@@ -1,431 +0,0 @@
use std::path::{Path, PathBuf};
use crate::core::codename::Names;
use crate::core::git::git_path;
use crate::host::Host;
#[derive(Debug)]
pub struct NewWorktree {
pub path: PathBuf,
pub branch: String,
}
#[derive(Debug, Clone)]
pub struct WorktreeRequest {
pub name: String,
pub branch: String,
pub base: String,
}
#[derive(Debug, Clone)]
pub struct WorktreeDefaults {
pub name: String,
pub base: String,
pub dir: PathBuf,
}
fn managed_root(host: &dyn Host, main_root: &Path) -> PathBuf {
host.join(&host.join(main_root, ".tty7"), "worktrees")
}
fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result<String, String> {
match host.git(dir, args) {
Ok(out) if out.success() => Ok(out.stdout_trimmed()),
Ok(out) => Err(out.stderr_trimmed()),
Err(e) => Err(format!("failed to run git: {e}")),
}
}
fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool {
git(
host,
repo_root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{name}"),
],
)
.is_ok()
}
#[derive(Debug, Clone)]
pub struct ManagedWorktree {
pub path: PathBuf,
pub branch: String,
pub main_root: PathBuf,
pub dirty: bool,
}
pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> {
let cwd = host.canonicalize(cwd).ok()?;
let suffix = host.join(Path::new(".tty7"), "worktrees");
if !cwd.ancestors().any(|a| a.ends_with(&suffix)) {
return None;
}
let path = git_path(
host,
&git(host, &cwd, &["rev-parse", "--show-toplevel"]).ok()?,
);
let main_root = git(
host,
&path,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)
.ok()
.map(|d| git_path(host, &d))?
.parent()?
.to_path_buf();
if !path.starts_with(managed_root(host, &main_root)) {
return None;
}
let branch = git(host, &path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let dirty = !git(host, &path, &["status", "--porcelain"])
.ok()?
.is_empty();
Some(ManagedWorktree {
path,
branch,
main_root,
dirty,
})
}
pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool {
let Ok(path) = host.canonicalize(path) else {
return false;
};
cwds.iter()
.any(|c| host.canonicalize(c).is_ok_and(|c| c.starts_with(&path)))
}
pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(), String> {
let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?;
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(path);
git(host, &wt.main_root, &args)?;
let _ = git(host, &wt.main_root, &["branch", "-d", &wt.branch]);
Ok(())
}
fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"])
.map_err(|_| "not inside a git repository".to_string())?;
let repo_root = git_path(host, &repo_root);
let main_root = git(
host,
cwd,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)
.ok()
.map(|d| git_path(host, &d))
.and_then(|d| d.parent().map(Path::to_path_buf))
.unwrap_or_else(|| repo_root.clone());
let dir = managed_root(host, &main_root);
Ok((repo_root, dir))
}
pub fn defaults(host: &dyn Host, cwd: &Path) -> Result<WorktreeDefaults, String> {
let (repo_root, dir) = repo_dir(host, cwd)?;
let name = Names::new().unique(|name| {
branch_exists(host, &repo_root, name) || host.exists(&host.join(&dir, name))
});
let base = git(host, &repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
.unwrap_or_else(|_| "HEAD".to_string());
Ok(WorktreeDefaults { name, base, dir })
}
pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewWorktree, String> {
if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\'])
{
return Err(format!("invalid worktree name \"{}\"", req.name));
}
let (repo_root, dir) = repo_dir(host, cwd)?;
host.create_dir(&dir, true)
.map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let ignore = host.join(
dir.parent().expect(".tty7/worktrees has a parent"),
".gitignore",
);
if !host.exists(&ignore) {
let _ = host.write_file(&ignore, b"*\n");
}
let path = host.join(&dir, &req.name);
if host.exists(&path) {
return Err(format!("{} already exists", path.display()));
}
git(
host,
&repo_root,
&[
"worktree",
"add",
"-b",
&req.branch,
path.to_str().ok_or("worktree path is not valid UTF-8")?,
&req.base,
],
)?;
Ok(NewWorktree {
path,
branch: req.branch.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn plain(p: &Path) -> PathBuf {
let s = p.to_string_lossy();
PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string())
}
fn sh(dir: &Path, args: &[&str]) {
assert!(
std::process::Command::new(args[0])
.args(&args[1..])
.current_dir(dir)
.output()
.unwrap()
.status
.success(),
"command failed: {args:?}"
);
}
fn temp_repo(name: &str) -> PathBuf {
let dir = scratch(name);
sh(&dir, &["git", "init", "-q"]);
sh(&dir, &["git", "config", "user.email", "t@t"]);
sh(&dir, &["git", "config", "user.name", "t"]);
std::fs::write(dir.join("a.txt"), "a").unwrap();
sh(&dir, &["git", "add", "."]);
sh(&dir, &["git", "commit", "-q", "-m", "init"]);
dir
}
fn h() -> crate::host::SharedHost {
crate::host::local::LocalHost::new()
}
fn req(name: &str) -> WorktreeRequest {
WorktreeRequest {
name: name.into(),
branch: name.into(),
base: "HEAD".into(),
}
}
#[test]
fn defaults_proposes_fresh_name_current_branch_and_target_dir() {
let h = h();
let repo = temp_repo("dflt");
let d = defaults(&*h, &repo).unwrap();
assert!(!branch_exists(&*h, &repo, &d.name));
let head = git(&*h, &repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
assert_eq!(d.base, head);
let canon = plain(&std::fs::canonicalize(&repo).unwrap());
assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees"));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_makes_worktree_on_new_branch_inside_the_repo() {
let h = h();
let repo = temp_repo("repo");
let wt = create(&*h, &repo, &req("quiet-otter")).unwrap();
assert!(wt.path.join("a.txt").exists());
assert!(branch_exists(&*h, &repo, &wt.branch));
let canon = plain(&std::fs::canonicalize(&repo).unwrap());
assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter"));
let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
assert_eq!(head, wt.branch);
assert_eq!(
std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(),
"*\n"
);
assert_eq!(git(&*h, &repo, &["status", "--porcelain"]).unwrap(), "");
assert!(
create(&*h, &repo, &req("quiet-otter"))
.unwrap_err()
.contains("already exists")
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_honors_custom_branch_and_base() {
let h = h();
let repo = temp_repo("base");
sh(&repo, &["git", "branch", "stable"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
sh(&repo, &["git", "add", "."]);
sh(&repo, &["git", "commit", "-q", "-m", "second"]);
let wt = create(
&*h,
&repo,
&WorktreeRequest {
name: "my-dir".into(),
branch: "feat/my-branch".into(),
base: "stable".into(),
},
)
.unwrap();
assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir");
let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
assert_eq!(head, "feat/my-branch");
assert!(wt.path.join("a.txt").exists());
assert!(!wt.path.join("b.txt").exists());
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_rejects_escaping_names() {
let h = h();
let repo = temp_repo("names");
for bad in ["", ".", "..", "a/b", "a\\b"] {
let mut r = req("x");
r.name = bad.into();
assert!(
create(&*h, &repo, &r)
.unwrap_err()
.contains("invalid worktree name"),
"{bad:?} should be rejected"
);
}
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_from_a_linked_worktree_lands_in_the_main_repo() {
let h = h();
let repo = temp_repo("nest");
let first = create(&*h, &repo, &req("first-wt")).unwrap();
let second = create(&*h, &first.path, &req("second-wt")).unwrap();
assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap());
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn managed_resolves_managed_checkouts_and_remove_cleans_up() {
let h = h();
let repo = temp_repo("mg");
let wt = create(&*h, &repo, &req("mg-wt")).unwrap();
assert!(managed(&*h, &repo).is_none());
let own = scratch("mg-own");
let _ = std::fs::remove_dir_all(&own);
sh(
&repo,
&[
"git",
"worktree",
"add",
"-b",
"own-branch",
own.to_str().unwrap(),
],
);
assert!(managed(&*h, &own).is_none());
let sub = wt.path.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let m = managed(&*h, &sub).unwrap();
assert_eq!(m.branch, wt.branch);
assert_eq!(m.path, wt.path);
assert!(!m.dirty);
std::fs::write(wt.path.join("b.txt"), "b").unwrap();
let m = managed(&*h, &wt.path).unwrap();
assert!(m.dirty);
assert!(remove(&*h, &m, false).is_err());
remove(&*h, &m, true).unwrap();
assert!(!wt.path.exists());
assert!(!branch_exists(&*h, &repo, &wt.branch));
let _ = std::fs::remove_dir_all(&repo);
let _ = std::fs::remove_dir_all(&own);
}
#[test]
fn writes_survive_the_optional_locks_invariant() {
let h = h();
let repo = temp_repo("locks");
let wt = create(&*h, &repo, &req("lock-wt")).unwrap();
assert!(wt.path.join("a.txt").exists());
let list = git(&*h, &repo, &["worktree", "list", "--porcelain"]).unwrap();
assert!(
list.lines()
.any(|l| l.starts_with("branch ") && l.ends_with(&wt.branch)),
"worktree list must show the new checkout: {list}"
);
std::fs::write(wt.path.join("c.txt"), "c").unwrap();
git(&*h, &wt.path, &["add", "."]).unwrap();
git(
&*h,
&wt.path,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"c",
],
)
.unwrap();
assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), "");
let m = managed(&*h, &wt.path).unwrap();
remove(&*h, &m, false).unwrap();
assert!(!wt.path.exists());
assert!(branch_exists(&*h, &repo, &wt.branch));
let plain_wt = create(&*h, &repo, &req("lock-wt2")).unwrap();
let m = managed(&*h, &plain_wt.path).unwrap();
remove(&*h, &m, false).unwrap();
assert!(!branch_exists(&*h, &repo, &plain_wt.branch));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn occupied_detects_live_cwds_inside_the_worktree() {
let h = h();
let repo = temp_repo("occ");
let wt = create(&*h, &repo, &req("occ-wt")).unwrap();
let inside = wt.path.join("deep");
std::fs::create_dir_all(&inside).unwrap();
assert!(occupied(&*h, &wt.path, &[repo.clone(), inside]));
assert!(!occupied(&*h, &wt.path, std::slice::from_ref(&repo)));
assert!(!occupied(&*h, &wt.path, &[wt.path.join("gone")]));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_outside_a_repo_errors() {
let h = h();
let plain = scratch("plain");
let err = create(&*h, &plain, &req("x")).unwrap_err();
assert_eq!(err, "not inside a git repository");
assert_eq!(
defaults(&*h, &plain).unwrap_err(),
"not inside a git repository"
);
let _ = std::fs::remove_dir_all(&plain);
}
}
@@ -0,0 +1,342 @@
//! `.worktreeinclude`: the gitignored files a fresh checkout cannot run
//! without — `.env`, `*.local.*`, a machine's own settings — carried over
//! from the main checkout when a worktree is created.
//!
//! The rules: gitignore syntax, and a path is copied only if it matches *and*
//! git ignores it. A tracked
//! file is already in the checkout, and an untracked-but-unignored one would
//! show up as a spurious change there.
//!
//! Copies are copy-on-write where the filesystem can: `clonefile` on macOS
//! (whole directory trees in one call), `std::fs::copy` elsewhere, which is
//! `copy_file_range` — a reflink on btrfs and XFS — on Linux. A cloned
//! `node_modules` costs no disk until one side writes to it. Nothing is ever
//! symlinked: two worktrees sharing one dependency tree break each other the
//! moment their lockfiles disagree.
//!
//! A remote host has no copy of its own to ask for, so there the bytes go
//! through `Host::read_file` / `write_file` under a budget — enough for the
//! config files this is for, not for a dependency tree.
use std::path::{Path, PathBuf};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use crate::host::Host;
pub const FILE: &str = ".worktreeinclude";
/// What a remote copy may move in total, and per file.
const REMOTE_BUDGET: u64 = 64 << 20;
const REMOTE_FILE_MAX: u64 = 16 << 20;
/// How many entries the walk under an ignored directory may look at.
const WALK_MAX: usize = 10_000;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Carried {
/// Paths (relative, `/`-separated) now in the worktree.
pub copied: Vec<String>,
/// Paths that matched but did not make it, and why.
pub skipped: Vec<(String, String)>,
}
/// Copy what `main_root`'s `.worktreeinclude` asks for into `worktree`.
pub fn carry(host: &dyn Host, main_root: &Path, worktree: &Path) -> Carried {
let mut out = Carried::default();
let Ok(bytes) = host.read_file(&host.join(main_root, FILE), 1 << 20) else {
return out;
};
let text = String::from_utf8_lossy(&bytes);
let rules = Rules::parse(&text);
if rules.is_empty() {
return out;
}
let picked = picks(host, main_root, &rules);
let mut budget = REMOTE_BUDGET;
for rel in picked {
let src = join_rel(host, main_root, &rel);
let dst = join_rel(host, worktree, &rel);
if host.stat(&dst).is_ok() {
continue;
}
let result = if host.id().is_local() {
copy_local(&src, &dst)
} else {
copy_remote(host, &src, &dst, &mut budget)
};
match result {
Ok(()) => out.copied.push(rel),
Err(e) => out.skipped.push((rel, e)),
}
}
out
}
struct Rules {
matcher: Gitignore,
/// Directory prefixes (`a/b/`) that anchored patterns point into. An
/// ignored directory git reports whole is only walked when one of these
/// leads inside it — a bare `.env` rule must not send the walk through
/// every `node_modules`.
anchors: Vec<String>,
}
impl Rules {
fn parse(text: &str) -> Rules {
let mut builder = GitignoreBuilder::new("");
let mut anchors = Vec::new();
for line in text.lines() {
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let _ = builder.add_line(None, line);
if line.starts_with('!') {
continue;
}
let body = line.trim_start_matches('/');
// Only a pattern with a slash before its last component is
// anchored; a leading `**` matches anywhere.
let Some(slash) = body.trim_end_matches('/').rfind('/') else {
continue;
};
if body.starts_with("**") {
continue;
}
let dir = &body[..=slash];
let literal = dir.find(['*', '?', '[']).map_or(dir, |glob| {
&dir[..dir[..glob].rfind('/').map_or(0, |s| s + 1)]
});
if !literal.is_empty() {
anchors.push(literal.to_string());
}
}
Rules {
matcher: builder.build().unwrap_or_else(|_| Gitignore::empty()),
anchors,
}
}
fn is_empty(&self) -> bool {
self.matcher.is_empty()
}
fn wants(&self, rel: &str, is_dir: bool) -> bool {
self.matcher
.matched_path_or_any_parents(rel, is_dir)
.is_ignore()
}
/// Whether some anchored pattern reaches inside directory `dir/`.
fn leads_into(&self, dir: &str) -> bool {
self.anchors
.iter()
.any(|a| a.starts_with(dir) || dir.starts_with(a.as_str()))
}
}
/// The ignored paths in `main_root` the rules select, outermost only.
fn picks(host: &dyn Host, main_root: &Path, rules: &Rules) -> Vec<String> {
let Ok(out) = host.git(
main_root,
&[
"ls-files",
"-z",
"--others",
"--ignored",
"--exclude-standard",
"--directory",
],
) else {
return Vec::new();
};
if !out.success() {
return Vec::new();
}
let mut found = Vec::new();
let mut walked = 0;
for entry in out.stdout.split(|b| *b == 0).filter(|e| !e.is_empty()) {
let entry = String::from_utf8_lossy(entry);
let (rel, is_dir) = match entry.strip_suffix('/') {
Some(dir) => (dir.to_string(), true),
None => (entry.to_string(), false),
};
if rules.wants(&rel, is_dir) {
found.push(rel);
} else if is_dir && rules.leads_into(&format!("{rel}/")) {
walk(host, main_root, &rel, rules, &mut found, &mut walked);
}
}
// `--directory` also lists an untracked directory whose only contents are
// ignored, though the directory itself is not. Git has the last word.
let found = confirm_ignored(host, main_root, found);
outermost(found)
}
fn walk(
host: &dyn Host,
root: &Path,
dir: &str,
rules: &Rules,
found: &mut Vec<String>,
walked: &mut usize,
) {
let Ok(entries) = host.read_dir(&join_rel(host, root, dir), None) else {
return;
};
for e in entries {
*walked += 1;
if *walked > WALK_MAX {
return;
}
let rel = format!("{dir}/{}", e.name);
let is_dir = e.is_dir && !e.is_symlink;
if rules.wants(&rel, is_dir) {
found.push(rel);
} else if is_dir && rules.leads_into(&format!("{rel}/")) {
walk(host, root, &rel, rules, found, walked);
}
}
}
fn confirm_ignored(host: &dyn Host, root: &Path, paths: Vec<String>) -> Vec<String> {
if paths.is_empty() {
return paths;
}
// `-z` needs `--stdin`, which `Host::git` cannot feed; unquoted lines it is.
let mut args = vec!["-c", "core.quotePath=false", "check-ignore", "--"];
args.extend(paths.iter().map(String::as_str));
let Ok(out) = host.git(root, &args) else {
return Vec::new();
};
let ignored: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::to_string)
.collect();
paths.into_iter().filter(|p| ignored.contains(p)).collect()
}
/// Drop every path that sits inside another one on the list.
fn outermost(mut paths: Vec<String>) -> Vec<String> {
paths.sort();
paths.dedup();
let mut kept: Vec<String> = Vec::new();
for p in paths {
if !kept.iter().any(|k| p.starts_with(&format!("{k}/"))) {
kept.push(p);
}
}
kept
}
fn join_rel(host: &dyn Host, root: &Path, rel: &str) -> PathBuf {
rel.split('/')
.fold(root.to_path_buf(), |p, c| host.join(&p, c))
}
fn copy_local(src: &Path, dst: &Path) -> Result<(), String> {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
if clonefile(src, dst).is_ok() {
return Ok(());
}
copy_tree(src, dst).map_err(|e| e.to_string())
}
/// One `clonefile(2)` — a whole directory tree included — or an error for the
/// caller to fall back from (another volume, a filesystem that is not APFS).
#[cfg(target_os = "macos")]
fn clonefile(src: &Path, dst: &Path) -> std::io::Result<()> {
use std::os::unix::ffi::OsStrExt;
let src = std::ffi::CString::new(src.as_os_str().as_bytes())?;
let dst = std::ffi::CString::new(dst.as_os_str().as_bytes())?;
// A symlink is cloned as the link, not what it points at. `<sys/clonefile.h>`;
// the libc crate does not export it.
const CLONE_NOFOLLOW: u32 = 0x0001;
match unsafe { libc::clonefile(src.as_ptr(), dst.as_ptr(), CLONE_NOFOLLOW) } {
0 => Ok(()),
_ => Err(std::io::Error::last_os_error()),
}
}
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
let meta = std::fs::symlink_metadata(src)?;
if meta.file_type().is_symlink() {
#[cfg(unix)]
return std::os::unix::fs::symlink(std::fs::read_link(src)?, dst);
#[cfg(not(unix))]
return Err(std::io::Error::other("symlinks are not copied here"));
}
if meta.is_dir() {
std::fs::create_dir(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
}
return Ok(());
}
std::fs::copy(src, dst).map(|_| ())
}
fn copy_remote(host: &dyn Host, src: &Path, dst: &Path, budget: &mut u64) -> Result<(), String> {
let meta = host.stat(src).map_err(|e| e.to_string())?;
if meta.is_symlink {
return Err("symlinks are not copied to a remote worktree".into());
}
if meta.is_dir {
host.create_dir(dst, true).map_err(|e| e.to_string())?;
for e in host.read_dir(src, None).map_err(|e| e.to_string())? {
copy_remote(
host,
&host.join(src, &e.name),
&host.join(dst, &e.name),
budget,
)?;
}
return Ok(());
}
if meta.len > REMOTE_FILE_MAX.min(*budget) {
return Err("too large to copy to a remote worktree".into());
}
let bytes = host
.read_file(src, REMOTE_FILE_MAX)
.map_err(|e| e.to_string())?;
*budget -= bytes.len() as u64;
if let Some(parent) = dst.parent() {
host.create_dir(parent, true).map_err(|e| e.to_string())?;
}
host.write_file(dst, &bytes).map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anchors_are_the_literal_directories_a_pattern_starts_in() {
let r = Rules::parse("# c\n.env\n/config/local/*.json\nsecrets/**\n**/x/y\n!keep\n");
assert_eq!(r.anchors, vec!["config/local/", "secrets/"]);
assert!(r.leads_into("config/"));
assert!(r.leads_into("config/local/"));
assert!(!r.leads_into("node_modules/"));
assert!(r.wants(".env", false));
assert!(r.wants("pkg/a/.env", false));
assert!(r.wants("config/local/a.json", false));
assert!(!r.wants("config/local/a.txt", false));
}
#[test]
fn outermost_drops_nested_paths() {
let got = outermost(vec![
"a/b".into(),
"a".into(),
"ab".into(),
"c/d".into(),
"a".into(),
]);
assert_eq!(got, vec!["a", "ab", "c/d"]);
}
}
+868
View File
@@ -0,0 +1,868 @@
//! Worktrees tty7 makes: one per task, under `<repo>/.tty7/worktrees/<name>`.
//!
//! - **Create** starts from the remote's default branch, fetched first
//! ([`defaults`]): a new task starts from what is merged, not from whatever
//! the current checkout happens to be on. Then the `.worktreeinclude` files
//! come over ([`include`]), and the first pane runs `.tty7/setup` before the
//! agent ([`setup`]).
//! - **Remove** files a snapshot of the checkout, uncommitted work included,
//! under `refs/tty7/trash/<name>` before deleting anything, so even "Discard
//! Changes & Remove" can be undone with `git checkout refs/tty7/trash/<name>`.
//! - **Names** are never reused: that snapshot ref retires the name. An agent
//! keys its session history by directory, and a new worktree at an old path
//! would open onto another task's conversations.
pub mod include;
pub mod setup;
use std::path::{Path, PathBuf};
use crate::core::codename::Names;
use crate::core::git::git_path;
use crate::host::Host;
/// Where removed worktrees leave their last state.
const TRASH_REFS: &str = "refs/tty7/trash";
#[derive(Debug)]
pub struct NewWorktree {
pub path: PathBuf,
pub branch: String,
pub main_root: PathBuf,
pub carried: include::Carried,
}
/// What [`remove`] did beyond deleting the checkout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Removed {
/// The ref holding the checkout's last state.
pub snapshot: Option<String>,
/// `git branch -d` refused: the branch has commits nothing else has.
pub branch_kept: bool,
}
#[derive(Debug, Clone)]
pub struct WorktreeRequest {
pub name: String,
pub branch: String,
pub base: String,
}
#[derive(Debug, Clone)]
pub struct WorktreeDefaults {
pub name: String,
pub base: String,
pub dir: PathBuf,
/// The repo has a `.tty7/setup`.
pub has_setup: bool,
/// Without one, the install its lockfile suggests.
pub setup_hint: Option<&'static str>,
}
fn managed_root(host: &dyn Host, main_root: &Path) -> PathBuf {
host.join(&host.join(main_root, ".tty7"), "worktrees")
}
fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result<String, String> {
match host.git(dir, args) {
Ok(out) if out.success() => Ok(out.stdout_trimmed()),
Ok(out) => Err(out.stderr_trimmed()),
Err(e) => Err(format!("failed to run git: {e}")),
}
}
fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool {
git(
host,
repo_root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{name}"),
],
)
.is_ok()
}
#[derive(Debug, Clone)]
pub struct ManagedWorktree {
pub path: PathBuf,
pub branch: String,
pub main_root: PathBuf,
pub dirty: bool,
}
pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> {
let cwd = host.canonicalize(cwd).ok()?;
let suffix = host.join(Path::new(".tty7"), "worktrees");
if !cwd.ancestors().any(|a| a.ends_with(&suffix)) {
return None;
}
let path = git_path(
host,
&git(host, &cwd, &["rev-parse", "--show-toplevel"]).ok()?,
);
let main_root = git(
host,
&path,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)
.ok()
.map(|d| git_path(host, &d))?
.parent()?
.to_path_buf();
if !path.starts_with(managed_root(host, &main_root)) {
return None;
}
let branch = git(host, &path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let dirty = !git(host, &path, &["status", "--porcelain"])
.ok()?
.is_empty();
Some(ManagedWorktree {
path,
branch,
main_root,
dirty,
})
}
pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool {
let Ok(path) = host.canonicalize(path) else {
return false;
};
cwds.iter()
.any(|c| host.canonicalize(c).is_ok_and(|c| c.starts_with(&path)))
}
pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<Removed, String> {
let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?;
if wt.dirty && !force {
return Err(format!("{} has uncommitted changes", wt.path.display()));
}
// Uncommitted work is only thrown away once it is safe somewhere else.
let snapshot = match snapshot(host, wt) {
Ok(r) => Some(r),
Err(e) if wt.dirty => {
return Err(format!(
"could not save the uncommitted changes, so nothing was removed: {e}"
));
}
Err(e) => {
log::warn!("no snapshot of {}: {e}", wt.path.display());
None
}
};
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(path);
git(host, &wt.main_root, &args)?;
let branch_kept =
wt.branch != "HEAD" && git(host, &wt.main_root, &["branch", "-d", &wt.branch]).is_err();
Ok(Removed {
snapshot,
branch_kept,
})
}
/// File the checkout's state under [`TRASH_REFS`]`/<name>`: its commit, or a
/// stash-shaped commit on top of it when there are changes, untracked files
/// included.
fn snapshot(host: &dyn Host, wt: &ManagedWorktree) -> Result<String, String> {
let name = wt
.path
.file_name()
.ok_or("worktree has no name")?
.to_string_lossy()
.into_owned();
let head = || git(host, &wt.path, &["rev-parse", "HEAD"]);
let commit = if wt.dirty {
// `stash create` only sees the index. The checkout is about to go, so
// staging everything in it costs nothing.
git(host, &wt.path, &["add", "-A"])?;
let made = git(
host,
&wt.path,
&["stash", "create", "tty7: removed worktree"],
)?;
if made.is_empty() { head()? } else { made }
} else {
head()?
};
let refname = format!("{TRASH_REFS}/{name}");
git(host, &wt.main_root, &["update-ref", &refname, &commit])?;
Ok(refname)
}
/// One checkout of a repo, as `git worktree list` knows it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
pub path: PathBuf,
/// `None` on a detached HEAD.
pub branch: Option<String>,
/// The repo's main checkout.
pub main: bool,
/// Made by tty7, under `.tty7/worktrees/`.
pub managed: bool,
}
/// Every checkout of the repo `cwd` is in, the main one first.
pub fn list(host: &dyn Host, cwd: &Path) -> Result<Vec<Listed>, String> {
let (_, dir) = repo_dir(host, cwd)?;
let out = git(host, cwd, &["worktree", "list", "--porcelain"])?;
let mut found: Vec<Listed> = Vec::new();
for line in out.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
let path = git_path(host, path);
found.push(Listed {
managed: path.starts_with(&dir),
main: found.is_empty(),
path,
branch: None,
});
} else if let (Some(branch), Some(last)) = (line.strip_prefix("branch "), found.last_mut())
{
last.branch = Some(branch.trim_start_matches("refs/heads/").to_string());
}
}
Ok(found)
}
/// The tty7 worktree `target` names: a path, or a name under the repo's
/// `.tty7/worktrees/`.
pub fn find(host: &dyn Host, cwd: &Path, target: &str) -> Result<ManagedWorktree, String> {
let as_path = Path::new(target);
let path = if host.is_absolute(as_path) || target.contains(['/', '\\']) {
as_path.to_path_buf()
} else {
host.join(&repo_dir(host, cwd)?.1, target)
};
if !host.exists(&path) {
return Err(format!("no worktree at {}", path.display()));
}
managed(host, &path).ok_or_else(|| format!("{} is not a worktree tty7 made", path.display()))
}
/// Names removed worktrees left behind.
fn retired(host: &dyn Host, repo_root: &Path) -> Vec<String> {
git(
host,
repo_root,
&["for-each-ref", "--format=%(refname:strip=3)", TRASH_REFS],
)
.map(|out| out.lines().map(str::to_string).collect())
.unwrap_or_default()
}
fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"])
.map_err(|_| "not inside a git repository".to_string())?;
let repo_root = git_path(host, &repo_root);
let main_root = git(
host,
cwd,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)
.ok()
.map(|d| git_path(host, &d))
.and_then(|d| d.parent().map(Path::to_path_buf))
.unwrap_or_else(|| repo_root.clone());
let dir = managed_root(host, &main_root);
Ok((repo_root, dir))
}
pub fn defaults(host: &dyn Host, cwd: &Path) -> Result<WorktreeDefaults, String> {
let (repo_root, dir) = repo_dir(host, cwd)?;
let retired = retired(host, &repo_root);
let name = Names::new().unique(|name| {
retired.iter().any(|r| r == name)
|| branch_exists(host, &repo_root, name)
|| host.exists(&host.join(&dir, name))
});
let has_setup = setup::find(host, &repo_root).is_some();
Ok(WorktreeDefaults {
name,
base: default_base(host, &repo_root),
dir,
has_setup,
setup_hint: (!has_setup)
.then(|| setup::hint(host, &repo_root))
.flatten(),
})
}
/// The remote's default branch (`origin/main`), or the current branch when
/// there is no remote to start from.
fn default_base(host: &dyn Host, repo_root: &Path) -> String {
let verify = |r: &str| git(host, repo_root, &["rev-parse", "--verify", "--quiet", r]).is_ok();
git(
host,
repo_root,
&[
"symbolic-ref",
"--quiet",
"--short",
"refs/remotes/origin/HEAD",
],
)
.ok()
.or_else(|| {
["main", "master"]
.into_iter()
.find(|b| verify(&format!("refs/remotes/origin/{b}")))
.map(|b| format!("origin/{b}"))
})
.unwrap_or_else(|| {
git(host, repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
.unwrap_or_else(|_| "HEAD".to_string())
})
}
/// Bring `base` up to date when it is a remote-tracking branch. Best effort:
/// offline, the local copy of the ref is still a fine place to start.
fn refresh(host: &dyn Host, repo_root: &Path, base: &str) {
let tracking = format!("refs/remotes/{base}");
if git(
host,
repo_root,
&["rev-parse", "--verify", "--quiet", &tracking],
)
.is_err()
{
return;
}
let Some((remote, branch)) = base.split_once('/') else {
return;
};
let _ = host.git_with_deadline(
repo_root,
&["fetch", "--quiet", "--no-tags", remote, branch],
std::time::Duration::from_secs(15),
);
}
/// Keep `.tty7/worktrees/` out of `git status` without a file in the tree,
/// so the repo's `.tty7/setup` stays committable. Repos set up before this
/// carry a `.tty7/.gitignore` of `*`, which hid the setup script too.
fn exclude(host: &dyn Host, main_root: &Path) {
let legacy = host.join(&host.join(main_root, ".tty7"), ".gitignore");
if host.read_file(&legacy, 64).is_ok_and(|b| b == b"*\n") {
let _ = host.remove(&legacy, false);
}
let Ok(common) = git(
host,
main_root,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
) else {
return;
};
let info = host.join(&git_path(host, &common), "info");
let file = host.join(&info, "exclude");
const LINE: &str = "/.tty7/worktrees/";
let current = host.read_file(&file, 1 << 20).unwrap_or_default();
let current = String::from_utf8_lossy(&current);
if current.lines().any(|l| l.trim() == LINE) {
return;
}
let mut next = current.into_owned();
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
next.push_str(LINE);
next.push('\n');
let _ = host.create_dir(&info, true);
let _ = host.write_file(&file, next.as_bytes());
}
pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewWorktree, String> {
if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\'])
{
return Err(format!("invalid worktree name \"{}\"", req.name));
}
let (repo_root, dir) = repo_dir(host, cwd)?;
let main_root = dir
.parent()
.and_then(Path::parent)
.expect(".tty7/worktrees sits two levels under the main checkout")
.to_path_buf();
host.create_dir(&dir, true)
.map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
exclude(host, &main_root);
let path = host.join(&dir, &req.name);
if host.exists(&path) {
return Err(format!("{} already exists", path.display()));
}
refresh(host, &repo_root, &req.base);
git(
host,
&repo_root,
&[
"worktree",
"add",
// Starting from `origin/main` must not make the branch track it:
// its first push belongs on a branch of its own name.
"--no-track",
"-b",
&req.branch,
path.to_str().ok_or("worktree path is not valid UTF-8")?,
&req.base,
],
)?;
let carried = include::carry(host, &main_root, &path);
Ok(NewWorktree {
path,
branch: req.branch.clone(),
main_root,
carried,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn plain(p: &Path) -> PathBuf {
let s = p.to_string_lossy();
PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string())
}
fn sh(dir: &Path, args: &[&str]) {
assert!(
std::process::Command::new(args[0])
.args(&args[1..])
.current_dir(dir)
.output()
.unwrap()
.status
.success(),
"command failed: {args:?}"
);
}
fn temp_repo(name: &str) -> PathBuf {
let dir = scratch(name);
sh(&dir, &["git", "init", "-q"]);
sh(&dir, &["git", "config", "user.email", "t@t"]);
sh(&dir, &["git", "config", "user.name", "t"]);
std::fs::write(dir.join("a.txt"), "a").unwrap();
sh(&dir, &["git", "add", "."]);
sh(&dir, &["git", "commit", "-q", "-m", "init"]);
dir
}
fn h() -> crate::host::SharedHost {
crate::host::local::LocalHost::new()
}
fn req(name: &str) -> WorktreeRequest {
WorktreeRequest {
name: name.into(),
branch: name.into(),
base: "HEAD".into(),
}
}
#[test]
fn defaults_proposes_fresh_name_current_branch_and_target_dir() {
let h = h();
let repo = temp_repo("dflt");
let d = defaults(&*h, &repo).unwrap();
assert!(!branch_exists(&*h, &repo, &d.name));
let head = git(&*h, &repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
assert_eq!(d.base, head);
let canon = plain(&std::fs::canonicalize(&repo).unwrap());
assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees"));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_makes_worktree_on_new_branch_inside_the_repo() {
let h = h();
let repo = temp_repo("repo");
let wt = create(&*h, &repo, &req("quiet-otter")).unwrap();
assert!(wt.path.join("a.txt").exists());
assert!(branch_exists(&*h, &repo, &wt.branch));
let canon = plain(&std::fs::canonicalize(&repo).unwrap());
assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter"));
let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
assert_eq!(head, wt.branch);
assert!(!canon.join(".tty7/.gitignore").exists());
let exclude = std::fs::read_to_string(canon.join(".git/info/exclude")).unwrap();
assert_eq!(
exclude
.lines()
.filter(|l| *l == "/.tty7/worktrees/")
.count(),
1
);
assert_eq!(git(&*h, &repo, &["status", "--porcelain"]).unwrap(), "");
assert!(
create(&*h, &repo, &req("quiet-otter"))
.unwrap_err()
.contains("already exists")
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_honors_custom_branch_and_base() {
let h = h();
let repo = temp_repo("base");
sh(&repo, &["git", "branch", "stable"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
sh(&repo, &["git", "add", "."]);
sh(&repo, &["git", "commit", "-q", "-m", "second"]);
let wt = create(
&*h,
&repo,
&WorktreeRequest {
name: "my-dir".into(),
branch: "feat/my-branch".into(),
base: "stable".into(),
},
)
.unwrap();
assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir");
let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
assert_eq!(head, "feat/my-branch");
assert!(wt.path.join("a.txt").exists());
assert!(!wt.path.join("b.txt").exists());
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_rejects_escaping_names() {
let h = h();
let repo = temp_repo("names");
for bad in ["", ".", "..", "a/b", "a\\b"] {
let mut r = req("x");
r.name = bad.into();
assert!(
create(&*h, &repo, &r)
.unwrap_err()
.contains("invalid worktree name"),
"{bad:?} should be rejected"
);
}
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_from_a_linked_worktree_lands_in_the_main_repo() {
let h = h();
let repo = temp_repo("nest");
let first = create(&*h, &repo, &req("first-wt")).unwrap();
let second = create(&*h, &first.path, &req("second-wt")).unwrap();
assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap());
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn managed_resolves_managed_checkouts_and_remove_cleans_up() {
let h = h();
let repo = temp_repo("mg");
let wt = create(&*h, &repo, &req("mg-wt")).unwrap();
assert!(managed(&*h, &repo).is_none());
let own = scratch("mg-own");
let _ = std::fs::remove_dir_all(&own);
sh(
&repo,
&[
"git",
"worktree",
"add",
"-b",
"own-branch",
own.to_str().unwrap(),
],
);
assert!(managed(&*h, &own).is_none());
let sub = wt.path.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let m = managed(&*h, &sub).unwrap();
assert_eq!(m.branch, wt.branch);
assert_eq!(m.path, wt.path);
assert!(!m.dirty);
std::fs::write(wt.path.join("b.txt"), "b").unwrap();
let m = managed(&*h, &wt.path).unwrap();
assert!(m.dirty);
assert!(remove(&*h, &m, false).is_err());
remove(&*h, &m, true).unwrap();
assert!(!wt.path.exists());
assert!(!branch_exists(&*h, &repo, &wt.branch));
let _ = std::fs::remove_dir_all(&repo);
let _ = std::fs::remove_dir_all(&own);
}
#[test]
fn writes_survive_the_optional_locks_invariant() {
let h = h();
let repo = temp_repo("locks");
let wt = create(&*h, &repo, &req("lock-wt")).unwrap();
assert!(wt.path.join("a.txt").exists());
let list = git(&*h, &repo, &["worktree", "list", "--porcelain"]).unwrap();
assert!(
list.lines()
.any(|l| l.starts_with("branch ") && l.ends_with(&wt.branch)),
"worktree list must show the new checkout: {list}"
);
std::fs::write(wt.path.join("c.txt"), "c").unwrap();
git(&*h, &wt.path, &["add", "."]).unwrap();
git(
&*h,
&wt.path,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"c",
],
)
.unwrap();
assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), "");
let m = managed(&*h, &wt.path).unwrap();
remove(&*h, &m, false).unwrap();
assert!(!wt.path.exists());
assert!(branch_exists(&*h, &repo, &wt.branch));
let plain_wt = create(&*h, &repo, &req("lock-wt2")).unwrap();
let m = managed(&*h, &plain_wt.path).unwrap();
remove(&*h, &m, false).unwrap();
assert!(!branch_exists(&*h, &repo, &plain_wt.branch));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn occupied_detects_live_cwds_inside_the_worktree() {
let h = h();
let repo = temp_repo("occ");
let wt = create(&*h, &repo, &req("occ-wt")).unwrap();
let inside = wt.path.join("deep");
std::fs::create_dir_all(&inside).unwrap();
assert!(occupied(&*h, &wt.path, &[repo.clone(), inside]));
assert!(!occupied(&*h, &wt.path, std::slice::from_ref(&repo)));
assert!(!occupied(&*h, &wt.path, &[wt.path.join("gone")]));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_outside_a_repo_errors() {
let h = h();
let plain = scratch("plain");
let err = create(&*h, &plain, &req("x")).unwrap_err();
assert_eq!(err, "not inside a git repository");
assert_eq!(
defaults(&*h, &plain).unwrap_err(),
"not inside a git repository"
);
let _ = std::fs::remove_dir_all(&plain);
}
#[test]
fn create_carries_worktreeinclude_matches_that_git_ignores() {
let h = h();
let repo = temp_repo("incl");
std::fs::write(repo.join(".gitignore"), ".env*\nnode_modules/\nlocal/\n").unwrap();
std::fs::write(
repo.join(".worktreeinclude"),
".env\n.env.local\nnode_modules/\n/local/cfg/*.json\nloose.txt\n",
)
.unwrap();
sh(&repo, &["git", "add", "."]);
sh(&repo, &["git", "commit", "-q", "-m", "include"]);
for (file, body) in [
(".env", "A=1"),
(".env.local", "B=2"),
(".env.other", "C=3"),
("node_modules/x/y.js", "y"),
("local/cfg/a.json", "{}"),
("local/cfg/b.txt", "b"),
("loose.txt", "not ignored"),
] {
let p = repo.join(file);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
#[cfg(unix)]
std::os::unix::fs::symlink("x/y.js", repo.join("node_modules/link.js")).unwrap();
let wt = create(&*h, &repo, &req("incl-wt")).unwrap();
let mut copied = wt.carried.copied.clone();
copied.sort();
assert_eq!(
copied,
[".env", ".env.local", "local/cfg/a.json", "node_modules"]
);
assert!(wt.carried.skipped.is_empty(), "{:?}", wt.carried.skipped);
assert_eq!(
std::fs::read_to_string(wt.path.join(".env")).unwrap(),
"A=1"
);
assert!(wt.path.join("node_modules/x/y.js").exists());
#[cfg(unix)]
assert!(
std::fs::symlink_metadata(wt.path.join("node_modules/link.js"))
.unwrap()
.file_type()
.is_symlink()
);
assert!(wt.path.join("local/cfg/a.json").exists());
assert!(!wt.path.join("local/cfg/b.txt").exists());
assert!(!wt.path.join(".env.other").exists());
assert!(!wt.path.join("loose.txt").exists());
assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), "");
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn remove_snapshots_the_checkout_and_retires_its_name() {
let h = h();
let repo = temp_repo("snap");
let wt = create(&*h, &repo, &req("snap-wt")).unwrap();
std::fs::write(wt.path.join("a.txt"), "changed").unwrap();
std::fs::write(wt.path.join("new.txt"), "untracked").unwrap();
let m = managed(&*h, &wt.path).unwrap();
assert!(remove(&*h, &m, false).unwrap_err().contains("uncommitted"));
assert!(retired(&*h, &repo).is_empty());
let removed = remove(&*h, &m, true).unwrap();
assert_eq!(removed.snapshot.as_deref(), Some("refs/tty7/trash/snap-wt"));
assert!(!removed.branch_kept);
assert!(!wt.path.exists());
let show = |f: &str| {
git(
&*h,
&repo,
&["show", &format!("refs/tty7/trash/snap-wt:{f}")],
)
};
assert_eq!(show("a.txt").unwrap(), "changed");
assert_eq!(show("new.txt").unwrap(), "untracked");
assert_eq!(retired(&*h, &repo), ["snap-wt"]);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn remove_reports_a_branch_it_had_to_keep() {
let h = h();
let repo = temp_repo("kept");
let wt = create(&*h, &repo, &req("kept-wt")).unwrap();
std::fs::write(wt.path.join("c.txt"), "c").unwrap();
sh(&wt.path, &["git", "add", "."]);
sh(&wt.path, &["git", "commit", "-q", "-m", "c"]);
let removed = remove(&*h, &managed(&*h, &wt.path).unwrap(), false).unwrap();
assert!(removed.branch_kept);
assert!(branch_exists(&*h, &repo, "kept-wt"));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn defaults_start_from_the_remote_default_branch_and_create_fetches_it() {
let h = h();
let upstream = temp_repo("up");
let clone = scratch("clone");
sh(
clone.parent().unwrap(),
&[
"git",
"clone",
"-q",
upstream.to_str().unwrap(),
clone.to_str().unwrap(),
],
);
let d = defaults(&*h, &clone).unwrap();
let expected = git(
&*h,
&clone,
&["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
)
.unwrap();
assert_eq!(d.base, expected);
// Upstream moves on after the clone; the worktree still starts there.
std::fs::write(upstream.join("later.txt"), "l").unwrap();
sh(&upstream, &["git", "add", "."]);
sh(&upstream, &["git", "commit", "-q", "-m", "later"]);
let mut r = req("fresh-wt");
r.base = d.base.clone();
let wt = create(&*h, &clone, &r).unwrap();
assert!(wt.path.join("later.txt").exists());
assert!(git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "@{upstream}"]).is_err());
let _ = std::fs::remove_dir_all(&upstream);
let _ = std::fs::remove_dir_all(&clone);
}
#[test]
fn list_and_find_see_the_managed_checkouts() {
let h = h();
let repo = temp_repo("list");
let wt = create(&*h, &repo, &req("list-wt")).unwrap();
let listed = list(&*h, &wt.path).unwrap();
assert_eq!(listed.len(), 2);
assert!(listed[0].main && !listed[0].managed);
assert!(!listed[1].main && listed[1].managed);
assert_eq!(listed[1].branch.as_deref(), Some("list-wt"));
assert_eq!(plain(&listed[1].path), plain(&wt.path));
assert_eq!(find(&*h, &repo, "list-wt").unwrap().branch, "list-wt");
assert_eq!(
find(&*h, &repo, wt.path.to_str().unwrap()).unwrap().branch,
"list-wt"
);
assert!(
find(&*h, &repo, "nope")
.unwrap_err()
.contains("no worktree")
);
let tmp = repo.to_str().unwrap();
assert!(
find(&*h, &repo, tmp)
.unwrap_err()
.contains("not a worktree tty7 made")
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn create_replaces_the_legacy_catch_all_gitignore() {
let h = h();
let repo = temp_repo("legacy");
std::fs::create_dir_all(repo.join(".tty7")).unwrap();
std::fs::write(repo.join(".tty7/.gitignore"), "*\n").unwrap();
std::fs::write(repo.join(".tty7/setup"), "#!/bin/sh\n").unwrap();
create(&*h, &repo, &req("legacy-wt")).unwrap();
create(&*h, &repo, &req("legacy-wt2")).unwrap();
assert!(!repo.join(".tty7/.gitignore").exists());
assert_eq!(
git(&*h, &repo, &["status", "--porcelain"]).unwrap(),
"?? .tty7/"
);
let exclude = std::fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
assert_eq!(
exclude
.lines()
.filter(|l| *l == "/.tty7/worktrees/")
.count(),
1
);
let _ = std::fs::remove_dir_all(&repo);
}
}
+238
View File
@@ -0,0 +1,238 @@
//! `.tty7/setup`: the project's own "make this checkout runnable" step —
//! install dependencies, write a `.env.local`, seed a database.
//!
//! It is an executable in the repo, any language, run in the new worktree's
//! first pane before the agent starts, so its output is on screen and a
//! failure stops there instead of under an agent that cannot build. tty7 does
//! not run it anywhere else: the `Host` trait cannot run a command, and a
//! pane is where a person can see what a script did.
//!
//! It is code from the repo, so it runs only once someone has approved that
//! exact content ([`Setup::digest`]) for that repo; an edit asks again.
//!
//! The script is told where the main checkout and the worktree are, and
//! `TTY7_PORT` starts a block of ten ports the
//! worktree can use without colliding with its siblings — derived from its
//! path, so no allocator has to remember anything and a worktree recreated at
//! the same place gets the same ports back.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::core::cli_agent::CLIAgent;
use crate::core::shell_quote::quote_for_shell;
use crate::host::{Host, HostId, fnv1a64};
pub const SCRIPT: &[&str] = &[".tty7", "setup"];
const PORT_FIRST: u16 = 20_000;
const PORT_BLOCKS: u64 = 1_000;
pub const PORT_BLOCK: u16 = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Setup {
pub script: PathBuf,
/// sha256 of the script, hex: what an approval is for.
pub digest: String,
}
/// The worktree's setup script, if it has one.
pub fn find(host: &dyn Host, worktree: &Path) -> Option<Setup> {
let script = SCRIPT
.iter()
.fold(worktree.to_path_buf(), |p, c| host.join(&p, c));
let bytes = host.read_file(&script, 1 << 20).ok()?;
Some(Setup {
script,
digest: hex(&Sha256::digest(&bytes)),
})
}
/// What an approval is filed under: the repo, on the machine it lives on.
pub fn trust_key(host: HostId, main_root: &Path) -> String {
format!("{}:{}", host.0, main_root.display())
}
/// The first of the worktree's [`PORT_BLOCK`] ports.
pub fn port_base(worktree: &Path) -> u16 {
let h = fnv1a64(worktree.to_string_lossy().as_bytes());
PORT_FIRST + (h % PORT_BLOCKS) as u16 * PORT_BLOCK
}
/// The variables the script runs with. `None` unless both paths are
/// absolute: a script doing `rm -rf "$TTY7_WORKTREE_PATH/dist"` must never
/// see an empty or relative value.
pub fn env(
host: &dyn Host,
main_root: &Path,
worktree: &Path,
) -> Option<Vec<(&'static str, String)>> {
if !host.is_absolute(main_root) || !host.is_absolute(worktree) {
return None;
}
let name = worktree.file_name()?.to_string_lossy().into_owned();
Some(vec![
("TTY7_ROOT_PATH", main_root.display().to_string()),
("TTY7_WORKTREE_PATH", worktree.display().to_string()),
("TTY7_WORKTREE_NAME", name),
("TTY7_PORT", port_base(worktree).to_string()),
])
}
/// For a repo without a setup script: the install its lockfile implies, as a
/// suggestion to show — never something run on the user's behalf.
pub fn hint(host: &dyn Host, worktree: &Path) -> Option<&'static str> {
const LOCKS: &[(&str, &str)] = &[
("pnpm-lock.yaml", "pnpm install"),
("bun.lock", "bun install"),
("bun.lockb", "bun install"),
("yarn.lock", "yarn install"),
("package-lock.json", "npm ci"),
("uv.lock", "uv sync"),
("poetry.lock", "poetry install"),
("Gemfile.lock", "bundle install"),
("composer.lock", "composer install"),
("mix.lock", "mix deps.get"),
];
LOCKS
.iter()
.find(|(file, _)| host.exists(&host.join(worktree, file)))
.map(|(_, cmd)| *cmd)
}
/// The line a new worktree's first pane types: the setup script under its
/// variables, then the agent — chained with `&&`, so a failed setup stops
/// there, on screen, instead of under an agent that cannot build.
///
/// It opens with a `cd` into `worktree`. The pane is spawned there, but a
/// shell's startup files may move it (`cd ~/src` in a `.zshrc`), and a setup
/// run from the main checkout would install into — and write files into —
/// the wrong tree.
pub fn launch_line(
worktree: &Path,
setup: Option<(&Path, &[(&'static str, String)])>,
agent: Option<String>,
) -> Option<String> {
let q = |s: &str| quote_for_shell(s, None);
let setup = setup.map(|(script, env)| {
let mut parts = vec!["env".to_string()];
parts.extend(env.iter().map(|(k, v)| format!("{k}={}", q(v))));
parts.push(q(&script.to_string_lossy()));
parts.join(" ")
});
let run = match (setup, agent) {
(Some(setup), Some(agent)) => format!("{setup} && {agent}"),
(setup, agent) => setup.or(agent)?,
};
Some(format!("cd {} && {run}", q(&worktree.to_string_lossy())))
}
/// `agent`'s launch line (`overrides` is `Config::agent_launch`), opening on
/// `task` when the agent takes a first message.
pub fn agent_line(agent: CLIAgent, task: &str, overrides: &HashMap<String, String>) -> String {
let mut line = agent.launch_command(overrides);
let task = task.trim();
if let Some(args) = (!task.is_empty())
.then(|| agent.prompt_args(task))
.flatten()
{
for arg in args {
line.push(' ');
line.push_str(&quote_for_shell(&arg, None));
}
}
line
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ports_are_stable_blocks_inside_the_range() {
let a = port_base(Path::new("/r/.tty7/worktrees/quiet-otter"));
assert_eq!(a, port_base(Path::new("/r/.tty7/worktrees/quiet-otter")));
assert_eq!((a - PORT_FIRST) % PORT_BLOCK, 0);
assert!(a >= PORT_FIRST && a + PORT_BLOCK <= PORT_FIRST + 10_000);
assert_ne!(a, port_base(Path::new("/r/.tty7/worktrees/amber-heron")));
}
#[cfg(unix)]
#[test]
fn launch_line_chains_setup_before_the_agent_and_quotes_values() {
let env = vec![
("TTY7_ROOT_PATH", "/r/my repo".to_string()),
("TTY7_PORT", "20010".to_string()),
];
let wt = Path::new("/r/my repo/.tty7/worktrees/w");
let script = Path::new("/r/my repo/.tty7/worktrees/w/.tty7/setup");
assert_eq!(
launch_line(wt, Some((script, &env)), Some("claude 'fix it'".into())).unwrap(),
"cd '/r/my repo/.tty7/worktrees/w' && \
env TTY7_ROOT_PATH='/r/my repo' TTY7_PORT=20010 \
'/r/my repo/.tty7/worktrees/w/.tty7/setup' && claude 'fix it'"
);
assert_eq!(
launch_line(wt, None, Some("codex".into())).unwrap(),
"cd '/r/my repo/.tty7/worktrees/w' && codex"
);
assert_eq!(launch_line(wt, None, None), None);
}
#[cfg(unix)]
#[test]
fn agent_line_passes_the_task_only_to_agents_that_take_one() {
let none = HashMap::new();
assert_eq!(
agent_line(CLIAgent::Claude, " it's done ", &none),
r"claude 'it'\''s done'"
);
assert_eq!(agent_line(CLIAgent::Gemini, "go", &none), "gemini -i go");
assert_eq!(agent_line(CLIAgent::Claude, " ", &none), "claude");
assert_eq!(agent_line(CLIAgent::Aider, "go", &none), "aider");
let flags = HashMap::from([(
"claude".to_string(),
"claude --dangerously-skip-permissions".to_string(),
)]);
assert_eq!(
agent_line(CLIAgent::Claude, "go", &flags),
"claude --dangerously-skip-permissions go"
);
}
#[test]
fn env_refuses_relative_paths() {
let h = crate::host::local::LocalHost::new();
// Absolute on every platform: `/abs` is not, on Windows.
let root = std::env::temp_dir();
let wt = root.join(".tty7").join("worktrees").join("w");
assert!(env(&*h, Path::new("rel"), &wt).is_none());
assert!(env(&*h, &root, Path::new("")).is_none());
let vars = env(&*h, &root, &wt).unwrap();
assert_eq!(vars[2], ("TTY7_WORKTREE_NAME", "w".to_string()));
}
#[test]
fn find_digests_the_script_and_hint_reads_lockfiles() {
let dir = std::env::temp_dir().join(format!("tty7-setup-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".tty7")).unwrap();
let h = crate::host::local::LocalHost::new();
assert!(find(&*h, &dir).is_none());
assert_eq!(hint(&*h, &dir), None);
std::fs::write(dir.join("pnpm-lock.yaml"), "").unwrap();
assert_eq!(hint(&*h, &dir), Some("pnpm install"));
std::fs::write(dir.join(".tty7/setup"), "#!/bin/sh\n").unwrap();
let a = find(&*h, &dir).unwrap();
assert_eq!(a.digest.len(), 64);
std::fs::write(dir.join(".tty7/setup"), "#!/bin/sh\necho x\n").unwrap();
assert_ne!(find(&*h, &dir).unwrap().digest, a.digest);
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -1761,6 +1761,7 @@ mod tests {
cwd: Some("/work/api".into()),
activity: 3,
turns: 1,
inferred: false,
},
}])),
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
+298
View File
@@ -735,10 +735,77 @@ struct PaneState {
agent: Option<crate::core::cli_agent::CLIAgent>,
agent_argv: Option<Vec<String>>,
agent_session: Option<crate::core::cli_agent::AgentSessionState>,
agent_clock: AgentClock,
alive: bool,
exit_code: Option<i32>,
}
/// When the agent session last heard from the agent, for the two conclusions
/// tty7 draws from silence: a turn the user interrupted, and a turn whose end
/// was lost.
#[derive(Default)]
struct AgentClock {
/// Bumped by every hook event and every session reset, so a pending
/// interrupt can tell whether anything spoke after the key it was armed by.
generation: u64,
/// The last hook event, or when the sweep first saw a turn it had no
/// event time for (a pane carried across a daemon handoff).
last_event: Option<std::time::Instant>,
/// An interrupt key is already waiting out [`INTERRUPT_SETTLE`].
interrupt_pending: bool,
}
/// How long after an interrupt key a real hook event still gets to speak
/// first. Agents that do report an interrupt (Kimi's `Interrupt`, a `Stop`
/// racing the key) do it well inside this.
const INTERRUPT_SETTLE: Duration = Duration::from_millis(1000);
/// How long a turn may stay on working with no hook event at all before the
/// status is given up on. Far past any single quiet stretch a live turn has:
/// Claude's longest tool call is capped at ten minutes, and every other event
/// — a tool finishing, a permission prompt — resets the clock.
pub(crate) const AGENT_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
/// Whether one input write is the user asking a running agent to stop: a bare
/// <kbd>Esc</kbd> or <kbd>Ctrl+C</kbd>, in the legacy encoding or in the
/// kitty keyboard protocol and xterm `modifyOtherKeys` forms a TUI may have
/// switched the terminal into. Only a whole write counts — the key arrives on
/// its own, and a paste that happens to hold `0x03` is not a keypress.
fn is_interrupt_key(bytes: &[u8]) -> bool {
match bytes {
b"\x1b" | b"\x03" | b"\x1b[27;5;99~" => return true,
_ => {}
}
let Some(body) = bytes
.strip_prefix(b"\x1b[")
.and_then(|b| b.strip_suffix(b"u"))
.and_then(|b| std::str::from_utf8(b).ok())
else {
return false;
};
let mut fields = body.split(';');
let Some(code) = fields
.next()
.and_then(|f| f.split(':').next())
.and_then(|c| c.parse::<u32>().ok())
else {
return false;
};
let mut modifiers = fields.next().unwrap_or("1").split(':');
let mods = modifiers
.next()
.and_then(|m| m.parse::<u32>().ok())
.unwrap_or(1);
let event = modifiers.next().map_or(Some(1), |e| e.parse::<u32>().ok());
if event != Some(1) {
return false;
}
// Caps Lock and Num Lock ride along in the mask without changing the key.
const LOCKS: u32 = 64 | 128;
let mods = mods.saturating_sub(1) & !LOCKS;
(code == 27 && mods == 0) || (code == 99 && mods == 4)
}
fn notify(st: &mut PaneState, msg: DaemonMsg) {
if let Some(sub) = &st.subscriber {
let _ = sub.send(msg.clone());
@@ -1699,6 +1766,7 @@ impl DaemonPane {
agent: None,
agent_session: None,
agent_argv: None,
agent_clock: AgentClock::default(),
alive: true,
exit_code: None,
},
@@ -1932,6 +2000,7 @@ impl DaemonPane {
agent: carried.agent,
agent_session: carried.agent_session,
agent_argv: carried.agent_argv,
agent_clock: AgentClock::default(),
alive: true,
exit_code: None,
},
@@ -1988,6 +2057,7 @@ impl DaemonPane {
agent: None,
agent_session: None,
agent_argv: None,
agent_clock: AgentClock::default(),
alive: true,
exit_code: None,
}));
@@ -2403,6 +2473,11 @@ impl DaemonPane {
agent_state_snapshot(&self.state.lock().unwrap())
}
/// See [`expire_stale_agent`].
pub fn expire_stale_agent(&self) {
expire_stale_agent(&mut self.state.lock().unwrap(), std::time::Instant::now());
}
pub fn gate(&self) -> Arc<OutputGate> {
self.gate.clone()
}
@@ -2411,6 +2486,9 @@ impl DaemonPane {
if bytes.is_empty() {
return;
}
if is_interrupt_key(bytes) {
arm_interrupt(&self.state);
}
if let Ok(mut writer) = self.writer.lock() {
let _ = writer.write_all(bytes);
let _ = writer.flush();
@@ -3232,6 +3310,73 @@ fn agent_from_shell_mark(
Some((agent, crate::core::cli_agent::command_argv(cmd)))
}
/// Start waiting out an interrupt key on a pane whose agent is mid-turn.
///
/// The key only *may* have stopped the turn — it also dismisses menus and
/// clears half-typed input — so nothing changes until [`INTERRUPT_SETTLE`]
/// passes with no hook event. A guess that still turns out wrong is undone by
/// the agent's next finished tool ([`AgentSessionState::apply_event`]).
///
/// [`AgentSessionState::apply_event`]: crate::core::cli_agent::AgentSessionState::apply_event
fn arm_interrupt(state: &Arc<Mutex<PaneState>>) {
let generation = {
let mut st = state.lock().unwrap();
let armable = st
.agent_session
.as_ref()
.is_some_and(|s| s.rich && s.mid_turn());
if !armable || st.agent_clock.interrupt_pending {
return;
}
st.agent_clock.interrupt_pending = true;
st.agent_clock.generation
};
let waiter = state.clone();
let spawned = std::thread::Builder::new()
.name("tty7-agent-interrupt".into())
.spawn(move || {
std::thread::sleep(INTERRUPT_SETTLE);
let mut st = waiter.lock().unwrap();
settle_interrupt(&mut st, generation);
});
if spawned.is_err() {
state.lock().unwrap().agent_clock.interrupt_pending = false;
}
}
fn settle_interrupt(st: &mut PaneState, armed_at: u64) {
st.agent_clock.interrupt_pending = false;
if st.agent_clock.generation != armed_at || !st.alive {
return;
}
let Some(sess) = st.agent_session.as_mut().filter(|s| s.mid_turn()) else {
return;
};
sess.assume_interrupted();
notify(st, DaemonMsg::AgentStatus(st.agent_session.clone()));
}
/// Give up on a turn that has said nothing for [`AGENT_STALE_AFTER`]. Run by
/// the daemon's periodic sweep, since a silent pane produces nothing that
/// would otherwise wake anything up to notice.
fn expire_stale_agent(st: &mut PaneState, now: std::time::Instant) {
let working = st
.agent_session
.as_ref()
.is_some_and(|s| s.status == crate::core::cli_agent::AgentStatus::Working);
if !working {
return;
}
let since = *st.agent_clock.last_event.get_or_insert(now);
if now.saturating_duration_since(since) < AGENT_STALE_AFTER {
return;
}
if let Some(sess) = st.agent_session.as_mut() {
sess.assume_stale();
}
notify(st, DaemonMsg::AgentStatus(st.agent_session.clone()));
}
fn apply_agent_signals(
st: &mut PaneState,
events: Vec<crate::core::cli_agent::AgentEvent>,
@@ -3257,6 +3402,8 @@ fn apply_agent_signals(
st.agent = event.agent;
notify(st, DaemonMsg::Agent(st.agent));
}
st.agent_clock.generation = st.agent_clock.generation.wrapping_add(1);
st.agent_clock.last_event = Some(std::time::Instant::now());
st.agent_session
.get_or_insert_with(AgentSessionState::default)
.apply_event(event);
@@ -3383,6 +3530,8 @@ fn apply_agent(
// Claude to omp (or back to the shell) must not keep the previous id.
if st.agent_session.is_some() {
st.agent_session = None;
st.agent_clock.generation = st.agent_clock.generation.wrapping_add(1);
st.agent_clock.last_event = None;
notify(st, DaemonMsg::AgentStatus(None));
}
if agent.is_none() {
@@ -5337,11 +5486,159 @@ mod tests {
agent: None,
agent_session: None,
agent_argv: None,
agent_clock: AgentClock::default(),
alive,
exit_code: None,
}
}
#[test]
fn interrupt_keys_are_recognised_in_every_encoding() {
for key in [
&b"\x1b"[..],
b"\x03",
b"\x1b[27u",
b"\x1b[27;1u",
b"\x1b[27;1:1u",
b"\x1b[27;65u",
b"\x1b[99;5u",
b"\x1b[99;5:1u",
b"\x1b[99:67;5u",
b"\x1b[27;5;99~",
] {
assert!(is_interrupt_key(key), "{key:?}");
}
for key in [
&b"\x1b[A"[..],
b"\x1b\x1b",
b"\x1b[27;1:3u",
b"\x1b[27;3u",
b"\x1b[99u",
b"\x1b[99;6u",
b"abc\x03",
b"\x1b[13u",
b"q",
] {
assert!(!is_interrupt_key(key), "{key:?}");
}
}
fn working_session() -> crate::core::cli_agent::AgentSessionState {
crate::core::cli_agent::AgentSessionState {
status: crate::core::cli_agent::AgentStatus::Working,
rich: true,
..Default::default()
}
}
#[test]
fn an_interrupt_settles_only_if_nothing_spoke_after_it() {
use crate::core::cli_agent::AgentStatus;
let mut st = test_state(true);
st.agent_session = Some(working_session());
st.agent_clock.interrupt_pending = true;
let armed = st.agent_clock.generation;
settle_interrupt(&mut st, armed);
let sess = st.agent_session.as_ref().unwrap();
assert_eq!(sess.status, AgentStatus::Done);
assert!(sess.inferred);
assert!(!st.agent_clock.interrupt_pending);
let mut st = test_state(true);
st.agent_session = Some(working_session());
let armed = st.agent_clock.generation;
st.agent_clock.generation += 1;
settle_interrupt(&mut st, armed);
assert_eq!(
st.agent_session.as_ref().unwrap().status,
AgentStatus::Working,
"a hook event after the key has the last word"
);
let mut st = test_state(false);
st.agent_session = Some(working_session());
let armed = st.agent_clock.generation;
settle_interrupt(&mut st, armed);
assert_eq!(
st.agent_session.as_ref().unwrap().status,
AgentStatus::Working,
"a dead pane is left to its exit report"
);
}
#[test]
fn an_interrupt_arms_once_and_only_mid_turn() {
let state = Arc::new(Mutex::new(test_state(true)));
arm_interrupt(&state);
assert!(
!state.lock().unwrap().agent_clock.interrupt_pending,
"no agent"
);
state.lock().unwrap().agent_session = Some(crate::core::cli_agent::AgentSessionState {
rich: false,
..working_session()
});
arm_interrupt(&state);
assert!(
!state.lock().unwrap().agent_clock.interrupt_pending,
"a status without hooks behind it has no Stop to miss"
);
state.lock().unwrap().agent_session = Some(working_session());
arm_interrupt(&state);
assert!(state.lock().unwrap().agent_clock.interrupt_pending);
}
#[test]
fn a_turn_silent_past_the_limit_goes_idle() {
use crate::core::cli_agent::AgentStatus;
let now = std::time::Instant::now();
let mut st = test_state(true);
st.agent_session = Some(working_session());
expire_stale_agent(&mut st, now);
assert_eq!(
st.agent_clock.last_event,
Some(now),
"a turn with no event time starts the clock rather than expiring"
);
expire_stale_agent(&mut st, now + AGENT_STALE_AFTER - Duration::from_secs(1));
assert_eq!(
st.agent_session.as_ref().unwrap().status,
AgentStatus::Working
);
expire_stale_agent(&mut st, now + AGENT_STALE_AFTER);
let sess = st.agent_session.as_ref().unwrap();
assert_eq!(sess.status, AgentStatus::Idle);
assert!(sess.inferred);
}
#[test]
fn hook_events_restart_the_agent_clock() {
use crate::core::cli_agent::{AgentEvent, AgentEventKind};
let mut st = test_state(true);
let before = st.agent_clock.generation;
apply_agent_signals(
&mut st,
vec![AgentEvent {
agent: None,
kind: AgentEventKind::PromptSubmit,
session_id: None,
message: None,
cwd: None,
prompt: None,
}],
None,
);
assert_ne!(st.agent_clock.generation, before);
assert!(st.agent_clock.last_event.is_some());
}
#[test]
fn observed_facts_prefer_the_sessions_argv_and_carry_its_status() {
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
@@ -5640,6 +5937,7 @@ mod tests {
cwd: None,
activity: 0,
turns: 0,
inferred: false,
});
apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07"));
assert_eq!(
+1
View File
@@ -1951,6 +1951,7 @@ mod tests {
cwd: Some("/repo/.claude/worktrees/fix-x".into()),
activity: 12,
turns: 4,
inferred: false,
})),
DaemonMsg::AgentStatus(None),
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
+22
View File
@@ -117,6 +117,27 @@ impl crate::host::server::PaneDirectory for Registry {
}
}
/// How often panes are checked for an agent turn that has gone silent past
/// [`crate::daemon::pane::AGENT_STALE_AFTER`]. The threshold is half an hour,
/// so being up to a minute late to notice costs nothing.
const AGENT_STALE_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
fn spawn_agent_stale_sweep(registry: Arc<Registry>) {
let spawned = std::thread::Builder::new()
.name("tty7-agent-stale".into())
.spawn(move || {
loop {
std::thread::sleep(AGENT_STALE_SWEEP_INTERVAL);
for pane in registry.all() {
pane.expire_stale_agent();
}
}
});
if let Err(e) = spawned {
log::warn!("could not start the agent status sweep: {e}");
}
}
const ORPHAN_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600);
fn spawn_orphan_sweep(registry: Arc<Registry>) {
@@ -616,6 +637,7 @@ fn run_with(registry: Arc<Registry>, alone: bool) -> anyhow::Result<()> {
}
spawn_orphan_sweep(registry.clone());
spawn_agent_stale_sweep(registry.clone());
// No sweeping here, deliberately — of either kind. Startup is the one
// moment this process knows least: it owns no panes yet, and the windows
// that know which of a dead daemon's files are still wanted cannot say so
+136
View File
@@ -602,6 +602,142 @@ mod tests {
]
);
}
/// What the zlib session test's server sends back for an `exec`: text that
/// inflates far past 2x its compressed size, then bytes that barely
/// compress at all — the two shapes russh's zlib once cut short.
fn zlib_payload() -> Vec<u8> {
let mut out = b"total 0\n".repeat(8 * 1024);
let mut x: u32 = 0x9e37_79b9;
out.extend((0..200 * 1024).map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x as u8
}));
out
}
struct ZlibSshd;
impl russh::server::Handler for ZlibSshd {
type Error = russh::Error;
async fn auth_none(&mut self, _user: &str) -> Result<russh::server::Auth, Self::Error> {
Ok(russh::server::Auth::Accept)
}
async fn channel_open_session(
&mut self,
_channel: russh::Channel<russh::server::Msg>,
reply: russh::server::ChannelOpenHandle,
_session: &mut russh::server::Session,
) -> Result<(), Self::Error> {
reply.accept().await;
Ok(())
}
async fn exec_request(
&mut self,
channel: russh::ChannelId,
_command: &[u8],
session: &mut russh::server::Session,
) -> Result<(), Self::Error> {
session.channel_success(channel)?;
for chunk in zlib_payload().chunks(16 * 1024) {
session.data(channel, chunk.to_vec())?;
}
session.eof(channel)?;
session.close(channel)?;
Ok(())
}
}
struct TrustingClient;
impl russh::client::Handler for TrustingClient {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_key: &russh::keys::PublicKey,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
/// #997: a host imported with `Compression yes` negotiates zlib, and the
/// russh we pinned truncated every packet that inflated past 2x — the
/// stream desynced right after auth and the pane never printed a byte.
#[tokio::test]
async fn a_zlib_session_delivers_every_byte() {
use russh::keys::PrivateKey;
use russh::keys::ssh_key::private::Ed25519Keypair;
let zlib = SshAlgorithms {
compression: vec!["zlib@openssh.com".into()],
..Default::default()
};
let preferred = build_preferred(&zlib, &[]);
assert_eq!(
preferred.compression.as_ref(),
&[russh::compression::ZLIB_LEGACY]
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind a loopback port");
let addr = listener.local_addr().expect("bound address");
let mut server_config = russh::server::Config::default();
server_config.inactivity_timeout = None;
server_config.preferred = preferred.clone();
server_config
.keys
.push(PrivateKey::from(Ed25519Keypair::from_seed(&[7; 32])));
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.expect("accept the test client");
let running = russh::server::run_stream(Arc::new(server_config), socket, ZlibSshd)
.await
.expect("server handshake");
let _ = running.await;
});
let received = tokio::time::timeout(Duration::from_secs(10), async {
let client_config = russh::client::Config {
preferred,
..Default::default()
};
let mut handle = russh::client::connect(Arc::new(client_config), addr, TrustingClient)
.await
.expect("client handshake");
let auth = handle
.authenticate_none("tester")
.await
.expect("auth round trip");
assert!(auth.success());
let mut channel = handle
.channel_open_session()
.await
.expect("session open is confirmed");
channel.exec(true, "ls").await.expect("exec request");
let mut received = Vec::new();
while let Some(msg) = channel.wait().await {
match msg {
russh::ChannelMsg::Data { data } => received.extend_from_slice(&data),
russh::ChannelMsg::Eof | russh::ChannelMsg::Close => break,
_ => {}
}
}
received
})
.await
.expect("a zlib session must not stall after auth");
server.abort();
let expected = zlib_payload();
assert_eq!(received.len(), expected.len());
assert!(received == expected, "the payload arrived corrupted");
}
}
#[cfg(test)]
+5
View File
@@ -316,6 +316,11 @@ impl Host for LocalHost {
git::git_output_with_env(cwd, args, &no_prompt_env())
}
fn repo_head(&self, cwd: &Path) -> Option<Option<git::head::RepoHead>> {
guard_off_ui();
Some(git::head::read_head(cwd))
}
fn git_lines(
&self,
cwd: &Path,
+12
View File
@@ -332,6 +332,18 @@ pub trait Host: Send + Sync + 'static {
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>;
/// The repository `cwd` is in, answered without running `git` — or
/// `None` when this host cannot, and [`crate::core::git::probe_repo`]
/// asks `git` instead.
///
/// Only this machine answers. A remote host could read the same files on
/// its side, but asking it to would be a request its server has never
/// heard of, and one of those takes the whole link down; the `git` it
/// runs over there has no Xcode shim to trip on anyway.
fn repo_head(&self, _cwd: &Path) -> Option<Option<crate::core::git::head::RepoHead>> {
None
}
/// `git`, but with an explicit ceiling on how long to wait for the far side.
///
/// Network verbs (`fetch`/`pull`/`push`) run for as long as the network
+50
View File
@@ -1228,6 +1228,56 @@ mod tests {
assert!(!inv.shells[0].args_are_tty7_defaults);
}
/// A remote host has no in-process answer, so the repo probe asks the
/// far side's `git` — and has to tell a refusal about the directory from
/// a `git` that never ran, or a tab loses its group to a flaky link.
#[test]
fn a_remote_repo_probe_tells_not_a_repo_from_a_failure() {
use crate::core::git::{RepoProbe, probe_repo};
let out = |status: i32, stdout: &str| {
ControlReply::Ok(ReplyOk::Output(Output {
status: Some(status),
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
}))
};
let (host, _seen) = host_with_peer('/', move |req| {
let reply = match req {
ControlRequest::Git { cwd, args } => match (cwd.as_str(), args[0].as_str()) {
("/repo", "rev-parse") => out(0, "/repo\n/repo/.git\n/repo/.git\n"),
("/repo", "symbolic-ref") => out(0, "main\n"),
("/repo", _) => out(0, "3\t1\tsrc/main.rs\n"),
("/plain", _) => out(128, ""),
("/shim", _) => out(1, ""),
("/gone", _) => {
ControlReply::Err(WireError::new(WireErrorKind::NotFound, "no cwd"))
}
_ => ControlReply::Err(WireError::new(WireErrorKind::ConnectionReset, "")),
},
ControlRequest::Stat { path } if path == "/gone" => {
ControlReply::Err(WireError::new(WireErrorKind::NotFound, "gone"))
}
ControlRequest::Stat { .. } => ControlReply::Ok(ReplyOk::Meta(meta())),
other => panic!("unexpected request {other:?}"),
};
Some((reply, vec![]))
});
let h: &dyn Host = host.as_ref();
let RepoProbe::Repo(snap) = probe_repo(h, Path::new("/repo")) else {
panic!("/repo is a repository");
};
assert_eq!(
(snap.root.as_path(), snap.branch.as_str()),
(Path::new("/repo"), "main")
);
assert_eq!(snap.counts, Some((3, 1)));
assert_eq!(probe_repo(h, Path::new("/plain")), RepoProbe::NotARepo);
assert_eq!(probe_repo(h, Path::new("/gone")), RepoProbe::NotARepo);
assert_eq!(probe_repo(h, Path::new("/shim")), RepoProbe::Failed);
assert_eq!(probe_repo(h, Path::new("/flaky")), RepoProbe::Failed);
}
#[test]
fn agent_sessions_come_from_the_peer() {
use crate::core::agent_history::PastSession;
+22
View File
@@ -353,6 +353,28 @@ one, else the agent running there, else the last segment of the cwd, else the
foreground process. `name` stays literal, so a script can tell a real name from
a stand-in.
## `worktree` — one checkout per task
The same worktrees the GUI's **New Worktree Tab…** makes (see
[Worktrees](/git/worktrees)), for scripts and for agents handing work to other
agents. Runs git on this machine only; `-m` is refused.
| Command | Effect | JSON |
|---|---|---|
| `worktree new [NAME] [--branch B] [--from REF] [--agent A] [--task TEXT] [--no-setup] [--ws WORKSPACE] [--repo DIR]` | Create the worktree, open a tab in it, run `.tty7/setup` then the agent there | `{"path","branch","base","tab","pane","setup","port","copied","skipped","command"}` |
| `worktree ls [--repo DIR]` | Every checkout of the repo; tty7's are marked, with the panes working in each | `{"worktrees":[{"path","branch","main","managed","panes"}]}` |
| `worktree rm NAME\|PATH [--force] [--close-panes] [--repo DIR]` | Remove a tty7 worktree, snapshotting it first | `{"path","branch","snapshot","branch_kept","closed_panes"}` |
```bash
tty7 worktree new --agent claude --task "fix the flaky login test"
```
`new` prints the path first, so `cd "$(tty7 worktree new | head -1)"` works.
Setup runs unless `--no-setup`: the GUI asks before running a repo's script for
the first time, but anyone who can run this command can already run the script
by hand. `rm` refuses while a pane is still working inside the checkout; pass
`--close-panes` to close them first.
## `pane` — panes
| Command | Effect | JSON |
+88 -12
View File
@@ -1,21 +1,24 @@
---
title: "Worktrees"
description: "An isolated checkout on a fresh branch, in one dialog and one tab."
description: "One checkout per task: fresh branch, set up, and handed to an agent — in one dialog."
---
Running two agents on the same repository at once means they fight over the
working tree. A git worktree is the fix, and tty7 makes it a single dialog.
working tree. A git worktree is the fix, and tty7 makes it a single dialog:
create the checkout, make it runnable, and start the agent in it.
## Creating one
**New Worktree Tab…** — in Search Everywhere, the tab's right-click menu, and
the application menu — asks three things:
the application menu — asks:
| Field | Default |
|---|---|
| **Worktree Name** | A fresh name that does not collide with an existing branch or directory |
| **Worktree Name** | A fresh name that no branch, directory or removed worktree has used |
| **New Branch** | The same name, editable |
| **Start From** | The branch you are currently on |
| **Start From** | The remote's default branch (`origin/main`), fetched first; the current branch when there is no remote |
| **Start** | A dropdown: Shell, or any agent on this machine — the last one used is picked |
| **Task** | The agent's first message (Claude Code, Codex and Gemini take one) |
Each field opens on a suggestion you can accept or type straight over.
@@ -23,9 +26,64 @@ Each field opens on a suggestion you can accept or type straight over.
<img src="/images/new-worktree.webp" alt="Creating a worktree" />
</Frame>
Confirm and tty7 creates the worktree, opens a tab in it, and starts a shell
there. The [sidebar](/window/sidebar) files it under the same repository group as
its parent, on its own branch.
Confirm and tty7 creates the worktree, copies in what `.worktreeinclude` asks
for, opens a tab there, and runs `.tty7/setup` followed by the agent. The
[sidebar](/window/sidebar) files it under the same repository group as its
parent, on its own branch.
The new branch does not track `origin/main`: its first `git push -u` goes to a
branch of its own name.
## Files a checkout needs: `.worktreeinclude`
A fresh checkout has none of your gitignored files — no `.env`, no local
settings. List the ones it needs in `.worktreeinclude` at the repository root,
in gitignore syntax:
```
.env
.env.local
config/local/*.json
```
A path is copied only if it matches **and** git ignores it.
Copies are copy-on-write where the filesystem supports it (APFS on macOS,
btrfs and XFS on Linux): a copied directory costs no disk until one side
changes it. Nothing is symlinked, because two checkouts sharing one dependency
tree break each other the moment their lockfiles disagree. On a remote machine
the files go over the connection, up to 64 MB in total — enough for
configuration, not for `node_modules`.
## Making it runnable: `.tty7/setup`
Commit an executable at `.tty7/setup` — any language, with a shebang — and
every new worktree runs it in its first tab, before the agent. Install
dependencies there, rather than copying them:
```sh
#!/bin/sh
set -e
pnpm install
echo "PORT=$TTY7_PORT" > .env.local
```
| Variable | Value |
|---|---|
| `TTY7_ROOT_PATH` | The main checkout |
| `TTY7_WORKTREE_PATH` | The new worktree |
| `TTY7_WORKTREE_NAME` | Its directory name |
| `TTY7_PORT` | The first of ten ports this worktree has to itself |
`TTY7_PORT` comes from the worktree's path, so a dev server started there does
not collide with its siblings, and the same worktree always gets the same
ports. The setup and the agent are chained with `&&`: if setup fails, the
agent does not start and the error stays on screen.
The script is code from the repository, so tty7 asks before running it the
first time, and again whenever its content changes. Without a setup script,
the dialog suggests one based on your lockfile (`pnpm install`, `uv sync`, …).
Setup runs on macOS and Linux; Windows worktrees skip it.
## Where they go
@@ -35,8 +93,8 @@ Worktrees land inside the repository, under:
<repo>/.tty7/worktrees/<name>
```
`<repo>/.tty7/.gitignore` is created with `*` in it the first time, so the
directory never shows up as an untracked mess in your own repository.
The directory is listed in `.git/info/exclude`, so it never shows up in
`git status`, and `.tty7/setup` next to it stays committable.
## Removing one
@@ -46,8 +104,26 @@ Closing a worktree tab offers to remove the worktree with it:
- **Dirty tree** — the dialog says so, and removing requires the explicit
*Discard Changes & Remove*.
Nothing is removed silently, and *Keep* leaves the worktree on disk for `git
worktree list` to find later.
Before anything is deleted, the checkout's state — uncommitted and untracked
files included — is saved under `refs/tty7/trash/<name>`. Even a discarded
change can come back:
```bash
git checkout refs/tty7/trash/<name> -- .
```
The branch is deleted only if it is merged; otherwise tty7 says it kept it.
*Keep* leaves the worktree on disk, and `tty7 worktree ls` finds it later.
## From the command line
```bash
tty7 worktree new --agent claude --task "fix the flaky login test"
tty7 worktree ls
tty7 worktree rm quiet-otter
```
See the [CLI reference](/cli/reference#worktree--one-checkout-per-task).
<Tip>
Pair this with [agent sessions](/agents/sessions): a worktree per agent means
+9 -4
View File
@@ -143,12 +143,12 @@ export const P = ({ br = "main", caret, children }) => (
export const O = ({ c = "", children }) => <div className={"t7w-out " + c}>{children}</div>;
{/* Sidebar for a frame: the base groups plus whatever the chapter changed. */}
{/* Sidebar for a frame: the base groups plus whatever the chapter changed. A split
stays in the same tab; the tab picks up the agent's status. */}
export const sidebar = (ch, t) => {
const g = GROUPS.map((x) => ({ ...x, items: x.items.map((it) => ({ ...it })) }));
const find = (title) => g.flatMap((x) => x.items).find((it) => it.t === title);
if (ch === 0) find("Refactor session auth").s = t < 3.1 ? "working" : "waiting";
// A split stays in the same tab; the tab picks up the agent's status.
if (ch === 1 && t >= 3.2) find("~/code/orbit").s = t < 6.4 ? "working" : "done";
if ((ch === 3 && t >= 4.1) || ch === 4) {
find("~/code/orbit").on = false;
@@ -370,10 +370,15 @@ export const Frame = ({ ch, t }) => {
);
};
{/* Mintlify documents useState and useEffect in MDX, so the mutable clock and the
element handle live in state rather than refs.
Keep JS comments out of every export body in this file, line and block comments alike: the
hosted Mintlify build fails to parse a page that has one, and shows only a
"parsing error" placeholder. mint dev and mint validate accept it, so the only
place it shows up is the live site. Comment at the top level, like this. */}
export const Demo = () => {
const [st, setSt] = useState({ ch: 0, t: 0 });
// Mintlify documents useState and useEffect in MDX, so the mutable clock
// and the element handle live in state rather than refs.
const [c0] = useState(() => ({ ch: 0, start: 0, visible: true, last: 0, paused: 0 }));
const [el, setEl] = useState(null);
useEffect(() => {
+23
View File
@@ -127,7 +127,30 @@ actions!(
EditorSave,
EditorSaveAs,
EditorGoToLine,
EditorGoToSymbol,
EditorNavigateBack,
EditorNavigateForward,
EditorSplitRight,
EditorFocusLeftGroup,
EditorFocusRightGroup,
EditorNewFile,
EditorGoToDefinition,
EditorQuickFix,
EditorRenameSymbol,
EditorFormatDocument,
EditorTransformUppercase,
EditorTransformLowercase,
EditorTransformTitleCase,
EditorTrimTrailingWhitespace,
EditorJoinLines,
EditorRemoveSurroundingBrackets,
EditorFindReferences,
EditorNextChange,
EditorPrevChange,
EditorRevertChange,
ToggleEditorGitGutter,
EditorPeekChange,
ToggleEditorProblems,
OpenSshProfiles,
RestartSshSession,
SendTab,
-1
View File
@@ -11,7 +11,6 @@ pub mod explorer_context_menu;
pub mod keychain;
pub mod rate_meter;
pub mod session;
pub mod shell_quote;
pub mod ssh_config;
pub mod update;
pub mod window_state;
+39 -1
View File
@@ -450,7 +450,8 @@ pub fn merge_imported(
|| current.user != profile.user
|| current.identity_files != profile.identity_files
|| current.proxy_command != profile.proxy_command
|| current.agent_forward != profile.agent_forward;
|| current.agent_forward != profile.agent_forward
|| current.algorithms != profile.algorithms;
if changed {
stats.updated += 1;
} else {
@@ -462,6 +463,10 @@ pub fn merge_imported(
current.identity_files = profile.identity_files;
current.proxy_command = profile.proxy_command;
current.agent_forward = profile.agent_forward;
// ssh config owns these just like host/port/user: without this a
// `Compression yes` or `Ciphers` line removed from the file stuck
// to the saved profile for good (#997).
current.algorithms = profile.algorithms;
}
None => {
stats.added += 1;
@@ -1097,6 +1102,39 @@ mod tests {
);
}
/// #997: turning `Compression` off in ssh config and importing again has
/// to reach the saved profile, not leave zlib stuck on it.
#[test]
fn reimport_replaces_algorithms_on_an_existing_host() {
let root = temp_root("import-algorithms");
let ssh = root.join(".ssh");
std::fs::create_dir_all(&ssh).unwrap();
let config = ssh.join("config");
std::fs::write(
&config,
"Host prod\n HostName 10.0.0.5\n Compression yes\n Ciphers aes256-ctr\n",
)
.unwrap();
let mut existing = Vec::new();
merge_imported(&mut existing, import_profiles_from(config.clone(), &root));
assert!(!existing[0].algorithms.compression.is_empty());
assert_eq!(existing[0].algorithms.cipher, vec!["aes256-ctr"]);
std::fs::write(&config, "Host prod\n HostName 10.0.0.5\n").unwrap();
let stats = merge_imported(&mut existing, import_profiles_from(config.clone(), &root));
assert_eq!(
stats,
MergeStats {
added: 0,
updated: 1,
unchanged: 0
}
);
assert!(existing[0].algorithms.compression.is_empty());
assert!(existing[0].algorithms.cipher.is_empty());
}
#[test]
fn merge_resolves_proxy_jump_to_profile_id() {
let root = temp_root("import-jump");
+10 -24
View File
@@ -1996,31 +1996,17 @@ impl TerminalElement {
let Some(link) = self.view.read(cx).hovered_link.as_ref() else {
return;
};
let (start, end) = (link.start, link.end);
let mut line = start.line.0;
while line <= end.line.0 {
let grid_row = line + display_offset;
if grid_row >= 0 && (grid_row as usize) < rows {
let grid_row = grid_row as usize;
let col_start = if line == start.line.0 {
start.column.0
} else {
0
};
let col_end = if line == end.line.0 {
end.column.0
} else {
cols.saturating_sub(1)
};
let mut col = col_start;
while col <= col_end && col < cols {
let cell = &mut buf[grid_row * cols + col];
cell.link_hover = true;
cell.underline_color = link_underline_color(cell, link.armed);
col += 1;
}
for &(start, end) in &link.runs {
let grid_row = start.line.0 + display_offset;
if grid_row < 0 || grid_row as usize >= rows {
continue;
}
let row = grid_row as usize * cols;
for col in start.column.0..=end.column.0.min(cols.saturating_sub(1)) {
let cell = &mut buf[row + col];
cell.link_hover = true;
cell.underline_color = link_underline_color(cell, link.armed);
}
line += 1;
}
}
+6 -2
View File
@@ -890,8 +890,12 @@ impl Tty7App {
// it against a local repository that merely shares the path.
let (host, path) = self.editor_active_location()?;
let root = cx
.try_global::<crate::terminal::git_status::GitStatusCache>()?
.repo_root_for(host, path.parent()?)?;
.try_global::<crate::terminal::git_status::GitStatusCache>()
.and_then(|cache| cache.repo_root_for(host, path.parent()?))
// The gutter's own base read finds the repository of a file no
// terminal has been in, and its markers need this watch to
// notice a stage or a checkout.
.or_else(|| self.editor_gutter_root())?;
Some((host, root.to_path_buf()))
}
+45 -1
View File
@@ -2,7 +2,7 @@ use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub use crate::core::git::{GitStatus, RepoSnapshot, probe};
pub use crate::core::git::{GitStatus, RepoProbe, RepoSnapshot, probe_repo};
use crate::ui::host_ops::{ByHost, HostId, InFlight};
/// The spelling a directory is keyed by in here.
@@ -213,6 +213,22 @@ impl GitStatusCache {
}
rerun
}
/// Land a probe that got no answer ([`RepoProbe::Failed`]).
///
/// Only the bookkeeping moves: the probe is no longer in flight, and the
/// throttle counts from now so a retry waits its turn. What the cache
/// knew about `cwd` stays exactly as it was — a repository it had found
/// is still that repository, and a cwd it had never answered stays
/// unanswered, which is what lets a tab keep the group it remembers
/// instead of dropping into Ungrouped on a `git` that did not run.
pub fn fail_probe(&mut self, host: HostId, cwd: &Path) -> bool {
let cwd = key(host, cwd);
let rerun = !self.probes.finish(&(host, cwd.to_path_buf()));
let throttle = self.throttle_key(host, &cwd).to_path_buf();
self.last_probe.insert(host, throttle, Instant::now());
rerun
}
}
#[cfg(test)]
@@ -403,6 +419,34 @@ mod tests {
assert!(cache.status_for(L, b).is_some());
}
#[test]
fn a_failed_probe_leaves_what_was_known() {
let mut cache = GitStatusCache::default();
let (repo, unseen) = (Path::new("/repo/a"), Path::new("/repo/b"));
cache.finish_probe(L, repo, Some(snap("/repo", "main", Some((1, 0)))));
assert!(cache.begin_probe(L, repo));
assert!(!cache.fail_probe(L, repo));
assert_eq!(
cache.known_repo_for(L, repo),
Some(Some(PathBuf::from("/repo"))),
"a git that did not run says nothing about the repository"
);
assert_eq!(cache.status_for(L, repo).unwrap().branch, "main");
assert!(cache.begin_probe(L, unseen));
cache.fail_probe(L, unseen);
assert_eq!(
cache.known_repo_for(L, unseen),
None,
"still unknown, not \"not a repository\""
);
assert!(
!cache.begin_probe_throttled(L, unseen, Duration::from_secs(60)),
"the retry waits out the throttle"
);
}
#[test]
fn known_repo_for_is_three_valued() {
let mut cache = GitStatusCache::default();
+3
View File
@@ -6572,6 +6572,7 @@ mod tests {
cwd: None,
activity: 0,
turns: 0,
inferred: false,
}))
.encode(daemon)
.unwrap();
@@ -6633,6 +6634,7 @@ mod tests {
cwd: None,
activity: 0,
turns: 0,
inferred: false,
}))
.encode(&mut daemon_side)
.unwrap();
@@ -6683,6 +6685,7 @@ mod tests {
cwd: None,
activity: 0,
turns: 0,
inferred: false,
}))
.encode(&mut daemon_side)
.unwrap();
+240 -6
View File
@@ -284,6 +284,11 @@ pub(super) fn logical_line_at<T: EventListener>(
if click.column.0 >= cols {
return None;
}
// Only a link is worth reading across a hard break, and a renderer that
// wrapped text inside a table cell has left nothing but hard breaks.
if bridge_hard_wrap && let Some(cell) = table_cell_at(term, click) {
return Some(cell);
}
let grid = term.grid();
let last_col = Column(cols - 1);
let top = term.topmost_line();
@@ -298,25 +303,128 @@ pub(super) fn logical_line_at<T: EventListener>(
};
let continues = |line: Line| wraps(line) || hard(line);
let mut start_line = click.line;
let (start_line, end_line) = run_of_rows(click.line, top, bottom, continues);
let rows = (start_line.0..=end_line.0).map(|line| (Line(line), Column(0), last_col));
read_rows(term, click, rows)
}
/// What a table's renderer draws between cells. Only the box-drawing ones: a
/// bare `|` is a shell pipe or a markdown source line far more often than it
/// is a border.
const CELL_BORDERS: [char; 3] = ['│', '┃', '║'];
/// The text of the table cell under `click`, gathered from every row the
/// table's renderer wrapped it onto.
///
/// It is the same stitching [`logical_line_at`] does for a whole row, only
/// inside the cell's own columns: a row carries on into the next when its
/// text runs up to the cell's right border and the next row's picks up at
/// the left one, link characters on both sides of the break. The border
/// columns have to line up too, which is what stops the walk at a `├─┼`
/// rule or the table's edge.
///
/// A cell that fits on one row is left to the ordinary reading, so a table
/// changes nothing until it actually wraps something.
fn table_cell_at<T: EventListener>(
term: &Term<T>,
click: Point,
) -> Option<(String, Vec<Point>, usize)> {
let grid = term.grid();
let cols = term.columns();
let is_border = |line: Line, col: usize| CELL_BORDERS.contains(&grid[line][Column(col)].c);
if is_border(click.line, click.column.0) {
return None;
}
let left = (0..click.column.0)
.rev()
.find(|&col| is_border(click.line, col))?;
let right = (click.column.0 + 1..cols).find(|&col| is_border(click.line, col))?;
// The written part of one row of the cell, padding trimmed off, or
// nothing when that row is not part of this cell.
let content = |line: Line| -> Option<(usize, usize)> {
if !(is_border(line, left) && is_border(line, right)) {
return None;
}
let written = |col: usize| {
let cell = &grid[line][Column(col)];
cell.c != ' ' || cell.flags.contains(Flags::WIDE_CHAR_SPACER)
};
let first = (left + 1..right).find(|&col| written(col))?;
let last = (left + 1..right).rev().find(|&col| written(col))?;
Some((first, last))
};
let top = term.topmost_line();
let bottom = term.bottommost_line();
let is_link_char = |c: char| super::search::is_url_char(c);
let continues = |line: Line| {
line < bottom && {
let next = line + 1;
match (content(line), content(next)) {
(Some((_, last)), Some((first, _))) => {
let head = grid[next][Column(first)].c;
// One column of padding either side is what every
// renderer leaves; a row that stops short of that ended
// on its own.
last + 2 >= right
&& first <= left + 2
&& is_link_char(grid[line][Column(last)].c)
&& is_link_char(head)
&& head != '@'
}
_ => false,
}
}
};
let (start_line, end_line) = run_of_rows(click.line, top, bottom, continues);
if start_line == end_line {
return None;
}
let rows = (start_line.0..=end_line.0).filter_map(|line| {
let line = Line(line);
let (first, last) = content(line)?;
Some((line, Column(first), Column(last)))
});
read_rows(term, click, rows)
}
/// The rows `click` shares a line of text with: every row above that
/// `continues` into the next, and every row below that one continues into.
fn run_of_rows(
click: Line,
top: Line,
bottom: Line,
continues: impl Fn(Line) -> bool,
) -> (Line, Line) {
let mut start_line = click;
let mut guard = 0;
while start_line > top && guard < MAX_WRAP_ROWS && continues(start_line - 1) {
start_line -= 1;
guard += 1;
}
let mut end_line = click.line;
let mut end_line = click;
guard = 0;
while end_line < bottom && guard < MAX_WRAP_ROWS && continues(end_line) {
end_line += 1;
guard += 1;
}
(start_line, end_line)
}
/// Reads the given stretch of each row, in order, as one string: its text,
/// the grid point of every character in it, and which of those is `click`.
fn read_rows<T: EventListener>(
term: &Term<T>,
click: Point,
rows: impl Iterator<Item = (Line, Column, Column)>,
) -> Option<(String, Vec<Point>, usize)> {
let grid = term.grid();
let mut text = String::new();
let mut points = Vec::new();
let mut click_idx = None;
let mut line = start_line;
while line <= end_line {
for col in 0..cols {
for (line, from, to) in rows {
for col in from.0..=to.0 {
let cell = &grid[line][Column(col)];
let p = Point::new(line, Column(col));
if cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) {
@@ -337,12 +445,52 @@ pub(super) fn logical_line_at<T: EventListener>(
text.push(cell.c);
points.push(p);
}
line += 1;
}
let click_idx = click_idx.filter(|&i| i < points.len())?;
Some((text, points, click_idx))
}
/// The cells `points` covers, as one run per row — what an underline has to
/// paint when those points are not one stretch of the grid. A wide glyph at
/// the end of a run takes its spacer column along.
pub(super) fn row_runs<T: EventListener>(term: &Term<T>, points: &[Point]) -> Vec<(Point, Point)> {
let mut runs: Vec<(Point, Point)> = Vec::new();
for &p in points {
match runs.last_mut() {
Some((_, end)) if end.line == p.line => *end = p,
_ => runs.push((p, p)),
}
}
let grid = term.grid();
for (_, end) in &mut runs {
let next = end.column + 1;
if next.0 < term.columns() && grid[end.line][next].flags.contains(Flags::WIDE_CHAR_SPACER) {
*end = Point::new(end.line, next);
}
}
runs
}
/// Every cell from `start` to `end` in reading order, as one run per row.
pub(super) fn grid_runs(start: Point, end: Point, cols: usize) -> Vec<(Point, Point)> {
(start.line.0..=end.line.0)
.map(|line| {
let line = Line(line);
let from = if line == start.line {
start.column
} else {
Column(0)
};
let to = if line == end.line {
end.column
} else {
Column(cols.saturating_sub(1))
};
(Point::new(line, from), Point::new(line, to))
})
.collect()
}
pub(super) fn pair_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
bracket_range(chars, click).or_else(|| quote_range(chars, click))
}
@@ -747,6 +895,92 @@ mod tests {
assert_eq!(url, "https://user1234@ex.com/z");
}
/// A table renderer wraps a long cell by hand: every row is a hard
/// newline, framed by borders, with the next cell's text in between.
/// This is the shape Claude Code prints a markdown table in.
const TABLE: [&str; 8] = [
"┌───────────────────────────────┬──────────┐",
"│ report.xlsx (https://s3.examp │ 10/2 │",
"│ le.com/a/b.xlsx?Sig=xy%3D&Exp │ 20:13 │",
"│ ires=179)(美妆) │ │",
"├───────────────────────────────┼──────────┤",
"│ short https://a.com │ 10/2 │",
"│ tail.com │ │",
"└───────────────────────────────┴──────────┘",
];
const TABLE_URL: &str = "https://s3.example.com/a/b.xlsx?Sig=xy%3D&Expires=179";
fn table_term() -> Term<VoidListener> {
term_with(50, 8, &TABLE.join("\r\n"))
}
fn table_col(line: usize, needle: &str) -> usize {
let row = TABLE[line];
row[..row.find(needle).expect("needle in fixture")]
.chars()
.count()
}
fn table_link(term: &Term<VoidListener>, line: usize, col: usize) -> Option<String> {
let click = Point::new(Line(line as i32), Column(col));
let (text, _points, idx) = logical_line_at(term, click, true)?;
crate::terminal::search::url_span_at(&text, idx).map(|(_, _, url)| url)
}
#[test]
fn a_url_a_table_wrapped_inside_its_cell_is_one_link() {
let term = table_term();
for (line, needle, where_) in [
(1, "https", "the row it starts on"),
(2, "Exp", "the middle row"),
(3, "ires", "the row it ends on"),
] {
assert_eq!(
table_link(&term, line, table_col(line, needle)).as_deref(),
Some(TABLE_URL),
"{where_} opens the whole URL"
);
}
}
#[test]
fn a_table_cell_underlines_only_its_own_columns() {
let term = table_term();
let click = Point::new(Line(2), Column(4));
let (text, points, idx) = logical_line_at(&term, click, true).expect("cell text");
let (s, e, _) = crate::terminal::search::url_span_at(&text, idx).expect("a url");
let runs: Vec<_> = row_runs(&term, &points[s..=e])
.iter()
.map(|(a, b)| (a.line.0, a.column.0, b.column.0))
.collect();
assert_eq!(
runs,
vec![(1, table_col(1, "https"), 30), (2, 2, 30), (3, 2, 9)]
);
}
#[test]
fn a_table_cell_that_stops_short_of_its_border_is_not_wrapped() {
let term = table_term();
assert_eq!(
table_link(&term, 5, table_col(5, "a.com")).as_deref(),
Some("https://a.com"),
"the next row's text is its own line, not the URL's tail"
);
}
#[test]
fn double_click_does_not_read_across_table_rows() {
let term = table_term();
let click = Point::new(Line(2), Column(4));
let (text, _points, _idx) = logical_line_at(&term, click, false).expect("row text");
assert!(
!text.contains("https"),
"selection keeps to its row: {text:?}"
);
}
#[test]
fn wide_glyph_and_its_spacer_resolve_to_the_same_word() {
ensure_segmenter();
+484 -157
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -149,7 +149,7 @@ pub(crate) fn fork_line(
/// Type `command` into the shell `slot` holds — now if it is up, or the
/// moment it lands if it is still connecting.
fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) {
pub(crate) fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) {
match slot {
PaneSlot::Ready(view) => view.read(cx).run_command_line(&command),
PaneSlot::Connecting(pending) => {
+212 -5
View File
@@ -1605,6 +1605,11 @@ impl Tty7App {
crate::ui::tray::init(cx);
}
app.refresh_shells(cx);
// Backstop for every other way a window goes away (a workspace
// closed from elsewhere): its language-server documents go with it.
let app_id = cx.entity_id();
cx.on_release(move |_, cx| crate::ui::lsp::LspStore::sync_window(app_id, Vec::new(), cx))
.detach();
cx.on_app_quit(|app, cx| {
app.save_session(cx);
crate::core::window_state::WindowState::from_bounds(app.window_bounds).save();
@@ -1670,7 +1675,10 @@ impl Tty7App {
if asked {
return false;
}
app.update(cx, |app, cx| app.prepare_window_close(cx));
app.update(cx, |app, cx| {
app.prepare_window_close(cx);
app.lsp_window_closed(cx);
});
true
});
@@ -1772,6 +1780,7 @@ impl Tty7App {
return;
}
self.prepare_window_close(cx);
self.lsp_window_closed(cx);
window.remove_window();
}
@@ -4591,7 +4600,7 @@ impl Tty7App {
(None, code) => host.code = code,
// Both had an editor: the files come along into this tab's strip
// rather than being dropped with the tab that brought them.
(Some(code), Some(theirs)) => code.adopt(&theirs.files),
(Some(code), Some(theirs)) => code.adopt(&theirs.all_files()),
(Some(_), None) => {}
}
if host.diff_overlay.is_none() {
@@ -5234,8 +5243,16 @@ impl Tty7App {
move |_this, found, cx| {
let Some(wt) = found else { return };
let path = wt.path.display().to_string();
let name = wt
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let detail = if wt.dirty {
t_fmt(L10nKey::AppWorktreeRemoveDetailDirty, &[("path", &path)])
t_fmt(
L10nKey::AppWorktreeRemoveDetailDirty,
&[("path", &path), ("name", &name)],
)
} else {
t_fmt(L10nKey::AppWorktreeRemoveDetailClean, &[("path", &path)])
};
@@ -5274,8 +5291,15 @@ impl Tty7App {
cx,
move |h| crate::core::worktree::remove(h, &wt, force),
move |_this, result, window, cx| match result {
Ok(()) => window.push_notification(
t_fmt(L10nKey::AppWorktreeRemoved, &[("branch", &branch)]),
Ok(removed) => window.push_notification(
t_fmt(
if removed.branch_kept {
L10nKey::AppWorktreeRemovedBranchKept
} else {
L10nKey::AppWorktreeRemoved
},
&[("branch", &branch)],
),
cx,
),
Err(e) => window.push_notification(
@@ -5624,9 +5648,12 @@ impl Tty7App {
);
}
/// Open `wt` in a new tab named after its branch, typing `first` — its
/// setup and agent — into the shell there.
pub(crate) fn open_worktree_tab(
&mut self,
wt: crate::core::worktree::NewWorktree,
first: Option<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -5653,6 +5680,9 @@ impl Tty7App {
return;
}
};
if let Some(first) = first {
crate::ui::agent_launch::run_when_ready(&view, first, cx);
}
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
@@ -5888,6 +5918,9 @@ impl Tty7App {
},
);
// The editor's navigation, while there is a file in front of it.
actions.extend(self.editor_palette_items());
// Groups are something the sidebar draws, so they are offered only
// while the tabs are in it. Opening a folder as a group asks the
// system picker, which browses this computer: a path picked there
@@ -5914,6 +5947,37 @@ impl Tty7App {
}
}
// The editor's text commands, while there is a buffer to run them on.
if self.editor_can_dispatch() {
actions.extend(
[
Item::localized(
L10nKey::CmdEditorTransformUppercase,
CommandKind::EditorTransformUppercase,
),
Item::localized(
L10nKey::CmdEditorTransformLowercase,
CommandKind::EditorTransformLowercase,
),
Item::localized(
L10nKey::CmdEditorTransformTitleCase,
CommandKind::EditorTransformTitleCase,
),
Item::localized(
L10nKey::CmdEditorTrimTrailingWhitespace,
CommandKind::EditorTrimTrailingWhitespace,
),
Item::localized(L10nKey::CmdEditorJoinLines, CommandKind::EditorJoinLines),
Item::localized(
L10nKey::CmdEditorRemoveSurroundingBrackets,
CommandKind::EditorRemoveSurroundingBrackets,
),
]
.into_iter()
.map(|item| item.in_group(CommandGroup::View)),
);
}
// Offered only where it would do something. A connection opened from a
// saved host has nothing to save, and a pane that is not an SSH one has
// no connection at all — either would be a row that quietly did nothing
@@ -6056,6 +6120,10 @@ impl Tty7App {
) {
let catalog = self.search_catalog(window, cx);
let view = cx.new(|cx| SearchView::new(catalog, tab, query, window, cx));
if tab.in_editor_row() {
let tabs = self.editor_search_tabs();
view.update(cx, |view, cx| view.set_editor_tabs(tabs, cx));
}
self.search_sub = Some(cx.subscribe_in(&view, window, Self::on_search_event));
self.search = Some(view.clone());
self.refresh_search_sessions(view, window, cx);
@@ -6131,6 +6199,13 @@ impl Tty7App {
if matches!(kind, CommandKind::SetTheme(_)) {
self.theme_preview_restore = None;
}
// Likewise the symbol Go to Symbol has the caret on.
if matches!(
kind,
CommandKind::GoToSymbol { .. } | CommandKind::GoToLocation { .. }
) {
self.editor_symbol_preview_commit();
}
self.close_search(window, cx);
self.run_command(kind, window, cx);
}
@@ -6143,6 +6218,22 @@ impl Tty7App {
}
}
SearchEvent::CancelThemePreview => self.cancel_preset_preview(window, cx),
SearchEvent::PreviewSymbol { line, column } => {
self.editor_symbol_preview(*line, *column, cx)
}
SearchEvent::PreviewLocation { path, line, column } => {
self.lsp_preview_location(path, *line, *column, cx)
}
// Each editor tab is opened the way its own chord opens it, so
// it arrives set up: the outline around the caret, the server
// to ask. Closing first puts back a caret Go to Symbol moved.
SearchEvent::SwitchEditorTab(tab) => {
self.close_search(window, cx);
match tab {
SearchTab::Symbols => self.editor_go_to_symbol(window, cx),
_ => self.open_search(SearchTab::Files, "", window, cx),
}
}
}
}
@@ -6153,6 +6244,9 @@ impl Tty7App {
// other than confirming the pick puts the old one back.
self.cancel_preset_preview(window, cx);
self.focus_active(window, cx);
// Nor was a symbol Go to Symbol previewed: the caret goes back, and
// the keyboard with it to the editor it came from.
self.editor_symbol_preview_cancel(window, cx);
cx.notify();
}
@@ -6303,6 +6397,34 @@ impl Tty7App {
}
ToggleDocumentPreview => self.toggle_document_preview(cx),
ToggleDocumentWrap => self.toggle_document_wrap(window, cx),
EditorTransformUppercase => {
self.editor_dispatch(&gpui_component::input::TransformToUppercase, window, cx);
}
EditorTransformLowercase => {
self.editor_dispatch(&gpui_component::input::TransformToLowercase, window, cx);
}
EditorTransformTitleCase => {
self.editor_dispatch(&gpui_component::input::TransformToTitleCase, window, cx);
}
EditorTrimTrailingWhitespace => {
self.editor_dispatch(&gpui_component::input::TrimTrailingWhitespace, window, cx);
}
EditorJoinLines => {
self.editor_dispatch(&gpui_component::input::JoinLines, window, cx);
}
EditorRemoveSurroundingBrackets => {
self.editor_dispatch(
&gpui_component::input::RemoveSurroundingBrackets,
window,
cx,
);
}
EditorGoToSymbol => self.editor_go_to_symbol(window, cx),
EditorNavigateBack => self.editor_navigate(false, window, cx),
EditorNavigateForward => self.editor_navigate(true, window, cx),
EditorSplitRight => {
self.editor_split(window, cx);
}
RestartSshSession => self.restart_ssh_session(window, cx),
SetTheme(i) => {
if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) {
@@ -6345,6 +6467,10 @@ impl Tty7App {
OpenFile { path, line, column } => {
self.open_indexed_file(&path, line, column, window, cx)
}
GoToSymbol { line, column } => self.editor_go_to_position(line, column, window, cx),
GoToLocation { path, line, column } => {
self.open_file_in_editor_at(&path, Some(line + 1), Some(column + 1), window, cx)
}
GoToTab { workspace, tab } => self.go_to_tab(workspace, tab, false, window, cx),
ResumeSession {
agent,
@@ -6481,6 +6607,16 @@ impl Tty7App {
L10nKey::SettingsSidebarGrouping => {
self.set_sidebar_auto_grouping(defaults.sidebar_auto_grouping, cx)
}
L10nKey::SettingsEditorGitGutter => {
self.set_editor_git_gutter(defaults.editor_git_gutter, cx)
}
L10nKey::SettingsEditorLsp => self.set_editor_lsp(defaults.editor_lsp, cx),
L10nKey::SettingsEditorSoftWrap => {
self.set_editor_soft_wrap(defaults.editor_soft_wrap, cx)
}
L10nKey::SettingsEditorMarkdownPreview => {
self.set_editor_markdown_preview(defaults.editor_markdown_preview, cx)
}
L10nKey::SettingsNotifyOnCommandFinish => {
self.set_notify_mode(defaults.notify_on_command_finish, cx)
}
@@ -9409,9 +9545,79 @@ impl Render for Tty7App {
}
this.editor_go_to_line(window, cx)
}))
// The language-server commands (`ui::lsp`) belong to the
// editor only while it has the focus; F2 and F12 mean
// something to programs in the terminal too.
.on_action(cx.listener(|this, _: &EditorGoToDefinition, window, cx| {
if !this.editor_has_focus(window, cx) {
cx.propagate();
return;
}
this.lsp_go_to_definition(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorQuickFix, window, cx| {
if !this.editor_has_focus(window, cx) {
cx.propagate();
return;
}
this.lsp_code_actions(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorRenameSymbol, window, cx| {
if !this.editor_has_focus(window, cx) {
cx.propagate();
return;
}
this.lsp_rename_start(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorFormatDocument, window, cx| {
if !this.editor_has_focus(window, cx) {
cx.propagate();
return;
}
this.lsp_format_document(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorFindReferences, window, cx| {
if !this.editor_has_focus(window, cx) {
cx.propagate();
return;
}
this.lsp_find_references(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorNewFile, window, cx| {
this.editor_new_file(window, cx)
}))
// The gutter's change markers. Each one lets the chord through
// when the editor is not focused or has nothing to do.
.on_action(cx.listener(|this, _: &EditorNextChange, window, cx| {
if !this.editor_gutter_step(true, window, cx) {
cx.propagate();
}
}))
.on_action(cx.listener(|this, _: &EditorPrevChange, window, cx| {
if !this.editor_gutter_step(false, window, cx) {
cx.propagate();
}
}))
.on_action(cx.listener(|this, _: &EditorRevertChange, window, cx| {
if !this.editor_gutter_revert_at_cursor(window, cx) {
cx.propagate();
}
}))
.on_action(cx.listener(|this, _: &ToggleEditorGitGutter, _, cx| {
this.toggle_editor_git_gutter(cx)
}))
.on_action(cx.listener(|this, _: &EditorPeekChange, window, cx| {
if !this.editor_gutter_peek_at_cursor(window, cx) {
cx.propagate();
}
}))
.on_action(cx.listener(|this, _: &ToggleEditorProblems, _, cx| {
if !this.code_panel_visible() {
cx.propagate();
return;
}
this.toggle_editor_problems(cx)
}))
.on_action(
cx.listener(|this, _: &Quit, window, cx| this.quit_stop_sessions(window, cx)),
)
@@ -9516,6 +9722,7 @@ impl Render for Tty7App {
.children(self.render_switcher(window, cx))
.when_some(self.search.clone(), |this, search| this.child(search))
.children(gpui_component::Root::render_notification_layer(window, cx));
let root = Self::with_editor_text_commands(root, cx);
if let Some(start) = prof {
crate::ui::perf::record("window", start.elapsed());
+13
View File
@@ -118,6 +118,19 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/github/pr-closed.svg" => include_bytes!("../../assets/icons/github/pr-closed.svg"),
"icons/github/pr-merged.svg" => include_bytes!("../../assets/icons/github/pr-merged.svg"),
"icons/github/pr-draft.svg" => include_bytes!("../../assets/icons/github/pr-draft.svg"),
// …and its checks': passed, failed, running, skipped.
"icons/github/check-passed.svg" => {
include_bytes!("../../assets/icons/github/check-passed.svg")
}
"icons/github/check-failed.svg" => {
include_bytes!("../../assets/icons/github/check-failed.svg")
}
"icons/github/check-pending.svg" => {
include_bytes!("../../assets/icons/github/check-pending.svg")
}
"icons/github/check-skipped.svg" => {
include_bytes!("../../assets/icons/github/check-skipped.svg")
}
_ => return None,
};
Some(bytes)
+620 -231
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+778
View File
@@ -0,0 +1,778 @@
//! A file's symbols — what Go to Symbol lists and the breadcrumbs walk.
//!
//! Built from the tree the editor already parsed for highlighting
//! ([`gpui_component::input::InputState::syntax_tree`]), with one small query
//! per language saying which nodes are symbols and where their names are. No
//! second parse, and nothing to install.
//!
//! An [`Outline`] is plain data, so it need not come from tree-sitter at all:
//! [`Outline::from_nodes`] takes the same nested shape a language server's
//! `textDocument/documentSymbol` answers with, and a buffer given one through
//! `Tty7App::editor_set_document_symbols` shows that instead of the tree's.
use std::collections::HashMap;
use std::ops::Range;
use std::sync::{Arc, LazyLock, Mutex};
use gpui_component::Rope;
use gpui_component::highlighter::LanguageRegistry;
use gpui_component::highlighter::tree_sitter::{self, Query, QueryCursor, StreamingIterator as _};
use gpui_component::input::{Position, RopeExt as _};
/// What a symbol is — the shapes the picker and the breadcrumbs tell apart.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum SymbolKind {
Module,
Namespace,
Class,
Struct,
Enum,
Interface,
Trait,
Impl,
Function,
Method,
Constructor,
Constant,
Variable,
Field,
Type,
Macro,
Heading,
}
impl SymbolKind {
/// The capture suffix a query names this kind by (`@item.function`).
fn from_capture(suffix: &str) -> Option<Self> {
Some(match suffix {
"module" => Self::Module,
"namespace" => Self::Namespace,
"class" => Self::Class,
"struct" => Self::Struct,
"enum" => Self::Enum,
"interface" => Self::Interface,
"trait" => Self::Trait,
"impl" => Self::Impl,
"function" => Self::Function,
"method" => Self::Method,
"constructor" => Self::Constructor,
"constant" => Self::Constant,
"variable" => Self::Variable,
"field" => Self::Field,
"type" => Self::Type,
"macro" => Self::Macro,
"heading" => Self::Heading,
_ => return None,
})
}
/// LSP's `SymbolKind` number, for a language server's answer. Kinds with
/// no counterpart here (a file, a package, an event…) fold into the
/// nearest one that draws the same.
pub(crate) fn from_lsp(kind: u32) -> Self {
match kind {
1 | 2 | 4 => Self::Module, // File, Module, Package
3 => Self::Namespace,
5 => Self::Class,
6 => Self::Method,
7 | 8 => Self::Field, // Property, Field
9 => Self::Constructor,
10 => Self::Enum,
11 => Self::Interface,
12 => Self::Function,
13 => Self::Variable,
14 | 22 => Self::Constant, // Constant, EnumMember
23 => Self::Struct,
26 => Self::Type, // TypeParameter
_ => Self::Variable,
}
}
/// Whether a function directly inside this is a method of it.
fn holds_methods(self) -> bool {
matches!(
self,
Self::Class | Self::Struct | Self::Enum | Self::Interface | Self::Trait | Self::Impl
)
}
/// The short tag the picker shows beside a name. Code's own words, the
/// same in every locale, like the keywords they stand for.
pub(crate) fn tag(self) -> &'static str {
match self {
Self::Module => "mod",
Self::Namespace => "namespace",
Self::Class => "class",
Self::Struct => "struct",
Self::Enum => "enum",
Self::Interface => "interface",
Self::Trait => "trait",
Self::Impl => "impl",
Self::Function => "fn",
Self::Method => "method",
Self::Constructor => "ctor",
Self::Constant => "const",
Self::Variable => "var",
Self::Field => "field",
Self::Type => "type",
Self::Macro => "macro",
Self::Heading => "#",
}
}
}
/// One symbol, flattened into its outline.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Symbol {
pub name: String,
pub kind: SymbolKind,
/// The whole item, 0-based, in the editor's own [`Position`]s.
pub range: Range<Position>,
/// Where a jump to it lands: its name.
pub selection: Position,
/// How many symbols it sits inside.
pub depth: usize,
/// The symbol it sits directly inside, as an index into the outline.
pub parent: Option<usize>,
}
/// A nested symbol, the shape an LSP `DocumentSymbol` has — what an outline
/// from anywhere other than the tree is built out of.
#[derive(Clone, Debug)]
pub(crate) struct SymbolNode {
pub name: String,
pub kind: SymbolKind,
pub range: Range<Position>,
pub selection: Position,
pub children: Vec<SymbolNode>,
}
/// A file's symbols in document order, every container before what it holds.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Outline {
pub symbols: Vec<Symbol>,
}
fn key(p: Position) -> (u32, u32) {
(p.line, p.character)
}
impl Outline {
/// Flattens a nested answer (a language server's `documentSymbol`).
pub(crate) fn from_nodes(nodes: Vec<SymbolNode>) -> Self {
fn walk(
nodes: Vec<SymbolNode>,
parent: Option<usize>,
depth: usize,
out: &mut Vec<Symbol>,
) {
let mut nodes = nodes;
nodes.sort_by_key(|n| key(n.range.start));
for node in nodes {
let ix = out.len();
out.push(Symbol {
name: node.name,
kind: node.kind,
range: node.range,
selection: node.selection,
depth,
parent,
});
walk(node.children, Some(ix), depth + 1, out);
}
}
let mut symbols = Vec::new();
walk(nodes, None, 0, &mut symbols);
Self { symbols }
}
pub(crate) fn is_empty(&self) -> bool {
self.symbols.is_empty()
}
/// The symbols `pos` is inside, outermost first — the breadcrumbs' trail.
pub(crate) fn chain_at(&self, pos: Position) -> Vec<usize> {
let at = key(pos);
// Children follow their parent and siblings never overlap, so the
// last symbol that holds the position is the innermost one.
let Some(innermost) = self
.symbols
.iter()
.rposition(|s| key(s.range.start) <= at && at <= key(s.range.end))
else {
return Vec::new();
};
let mut chain = vec![innermost];
let mut cur = innermost;
while let Some(parent) = self.symbols[cur].parent {
chain.push(parent);
cur = parent;
}
chain.reverse();
chain
}
/// The names of what symbol `ix` sits inside, outermost first.
pub(crate) fn container_path(&self, ix: usize) -> Vec<&str> {
let mut names = Vec::new();
let mut cur = self.symbols.get(ix).and_then(|s| s.parent);
while let Some(p) = cur {
names.push(self.symbols[p].name.as_str());
cur = self.symbols[p].parent;
}
names.reverse();
names
}
}
// ---- tree-sitter ----
//
// Captures: `@item.<kind>` on the node that is the symbol, `@name` on its name
// (several are joined with a space), `@body` on an impl's block — which names
// the symbol by everything before it, `impl<T> Display for Wrapper<T>` — and
// `@receiver` on a Go method's receiver list, which goes in front of the name
// as `(*Server).Serve`. When two patterns claim one node the earlier wins, so
// a catch-all belongs last.
const RUST: &str = r#"
(mod_item name: (identifier) @name) @item.module
(struct_item name: (type_identifier) @name) @item.struct
(union_item name: (type_identifier) @name) @item.struct
(enum_item name: (type_identifier) @name) @item.enum
(trait_item name: (type_identifier) @name) @item.trait
(impl_item body: (declaration_list) @body) @item.impl
(function_item name: (identifier) @name) @item.function
(function_signature_item name: (identifier) @name) @item.function
(const_item name: (identifier) @name) @item.constant
(static_item name: (identifier) @name) @item.constant
(type_item name: (type_identifier) @name) @item.type
(macro_definition name: (identifier) @name) @item.macro
"#;
const GO: &str = r#"
(function_declaration name: (identifier) @name) @item.function
(method_declaration receiver: (parameter_list) @receiver name: (field_identifier) @name) @item.method
(type_spec name: (type_identifier) @name type: (struct_type)) @item.struct
(type_spec name: (type_identifier) @name type: (interface_type)) @item.interface
(type_spec name: (type_identifier) @name) @item.type
(const_spec name: (identifier) @name) @item.constant
"#;
const PYTHON: &str = r#"
(class_definition name: (identifier) @name) @item.class
(function_definition name: (identifier) @name) @item.function
"#;
const JAVASCRIPT: &str = r#"
(class_declaration name: (_) @name) @item.class
(function_declaration name: (identifier) @name) @item.function
(generator_function_declaration name: (identifier) @name) @item.function
(method_definition name: (_) @name) @item.method
(variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)]) @item.function
"#;
const TYPESCRIPT_EXTRA: &str = r#"
(abstract_class_declaration name: (_) @name) @item.class
(interface_declaration name: (_) @name) @item.interface
(type_alias_declaration name: (_) @name) @item.type
(enum_declaration name: (_) @name) @item.enum
(internal_module name: (_) @name) @item.module
(function_signature name: (_) @name) @item.function
(method_signature name: (_) @name) @item.method
(abstract_method_signature name: (_) @name) @item.method
"#;
const C: &str = r#"
(function_definition declarator: (function_declarator declarator: (_) @name)) @item.function
(function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (_) @name))) @item.function
(struct_specifier name: (_) @name body: (_)) @item.struct
(union_specifier name: (_) @name body: (_)) @item.struct
(enum_specifier name: (_) @name body: (_)) @item.enum
(type_definition declarator: (type_identifier) @name) @item.type
"#;
const CPP_EXTRA: &str = r#"
(namespace_definition name: (_) @name) @item.namespace
(class_specifier name: (_) @name body: (_)) @item.class
(function_definition declarator: (reference_declarator (function_declarator declarator: (_) @name))) @item.function
(field_declaration declarator: (function_declarator declarator: (_) @name)) @item.method
(alias_declaration name: (type_identifier) @name) @item.type
"#;
const JAVA: &str = r#"
(class_declaration name: (identifier) @name) @item.class
(interface_declaration name: (identifier) @name) @item.interface
(enum_declaration name: (identifier) @name) @item.enum
(record_declaration name: (identifier) @name) @item.class
(annotation_type_declaration name: (identifier) @name) @item.interface
(method_declaration name: (identifier) @name) @item.method
(constructor_declaration name: (identifier) @name) @item.constructor
"#;
const MARKDOWN: &str = r#"
(section (atx_heading (inline) @name)) @item.heading
(setext_heading (paragraph (inline) @name)) @item.heading
"#;
const RUBY: &str = r#"
(class name: (_) @name) @item.class
(module name: (_) @name) @item.module
(method name: (_) @name) @item.method
(singleton_method name: (_) @name) @item.method
"#;
const BASH: &str = r#"
(function_definition name: (word) @name) @item.function
"#;
/// The query for a language the editor highlights, or `None` for one it has
/// no outline for.
fn query_source(language: &str) -> Option<String> {
Some(match language {
"rust" => RUST.into(),
"go" => GO.into(),
"python" => PYTHON.into(),
"javascript" => JAVASCRIPT.into(),
"typescript" | "tsx" => format!("{JAVASCRIPT}{TYPESCRIPT_EXTRA}"),
"c" => C.into(),
"cpp" => format!("{C}{CPP_EXTRA}"),
"java" => JAVA.into(),
"markdown" => MARKDOWN.into(),
"ruby" => RUBY.into(),
"bash" => BASH.into(),
_ => return None,
})
}
/// Compiled once per language per process. A query that does not compile
/// against its grammar is a bug here, not in the file: it is logged once and
/// the language goes without an outline.
fn query_for(language: &str) -> Option<Arc<Query>> {
static QUERIES: LazyLock<Mutex<HashMap<String, Option<Arc<Query>>>>> =
LazyLock::new(Default::default);
let mut queries = QUERIES.lock().unwrap_or_else(|e| e.into_inner());
if let Some(q) = queries.get(language) {
return q.clone();
}
let compiled = query_source(language).and_then(|source| {
let config = LanguageRegistry::singleton().language(language)?;
Query::new(&config.language, &source)
.map_err(|e| log::warn!("outline: the {language} query does not compile: {e}"))
.ok()
.map(Arc::new)
});
queries.insert(language.to_string(), compiled.clone());
compiled
}
/// Whether `language` has an outline at all.
pub(crate) fn supports(language: &str) -> bool {
query_source(language).is_some()
}
/// The longest name a symbol shows; an impl header with a where clause can
/// otherwise run the width of the screen.
const NAME_MAX_CHARS: usize = 80;
fn collapse(text: &str) -> String {
let mut out = text.split_whitespace().collect::<Vec<_>>().join(" ");
if out.chars().count() > NAME_MAX_CHARS {
out = out.chars().take(NAME_MAX_CHARS - 1).collect::<String>() + "…";
}
out
}
fn text_of(text: &Rope, range: Range<usize>) -> String {
let end = range.end.min(text.len());
let start = range.start.min(end);
text.slice(start..end).to_string()
}
/// The outline of `text`, as parsed into `tree`.
pub(crate) fn outline_from_tree(language: &str, tree: &tree_sitter::Tree, text: &Rope) -> Outline {
let Some(query) = query_for(language) else {
return Outline::default();
};
let names = query.capture_names();
struct Found {
pattern: usize,
kind: SymbolKind,
range: Range<usize>,
name: String,
selection: usize,
}
let mut found: HashMap<usize, Found> = HashMap::new();
let mut cursor = QueryCursor::new();
// No pattern has a predicate, so the text is never consulted.
let mut matches = cursor.matches(&query, tree.root_node(), &[] as &[u8]);
while let Some(m) = matches.next() {
let mut item = None;
let mut name_parts: Vec<(usize, String)> = Vec::new();
let mut body = None;
let mut receiver = None;
for cap in m.captures {
let capture = names[cap.index as usize];
let node = cap.node;
if let Some(kind) = capture.strip_prefix("item.") {
item = SymbolKind::from_capture(kind).map(|k| (k, node));
} else if capture == "name" {
name_parts.push((node.start_byte(), text_of(text, node.byte_range())));
} else if capture == "body" {
body = Some(node.start_byte());
} else if capture == "receiver" {
receiver = Some(text_of(text, node.byte_range()));
}
}
let Some((kind, node)) = item else { continue };
if found
.get(&node.id())
.is_some_and(|f| f.pattern <= m.pattern_index)
{
continue;
}
name_parts.sort_by_key(|(at, _)| *at);
let (name, selection) = match (name_parts.first(), body) {
(Some((at, _)), _) => (
name_parts
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(" "),
*at,
),
(None, Some(body)) => (text_of(text, node.start_byte()..body), node.start_byte()),
(None, None) => continue,
};
let mut name = collapse(&name);
if let Some(receiver) = receiver {
let ty = receiver
.trim_matches(|c| c == '(' || c == ')')
.split_whitespace()
.last()
.unwrap_or_default()
.to_string();
if !ty.is_empty() {
name = format!("({ty}).{name}");
}
}
if name.is_empty() {
continue;
}
found.insert(
node.id(),
Found {
pattern: m.pattern_index,
kind,
range: node.byte_range(),
name,
selection,
},
);
}
// Document order, a container before what it holds: by start, and the
// longer of two that start together first.
let mut items: Vec<Found> = found.into_values().collect();
items.sort_by(|a, b| {
a.range
.start
.cmp(&b.range.start)
.then(b.range.end.cmp(&a.range.end))
});
let mut symbols: Vec<Symbol> = Vec::with_capacity(items.len());
let mut open: Vec<(usize, usize)> = Vec::new(); // (index, end byte)
for f in items {
while open.last().is_some_and(|(_, end)| *end <= f.range.start) {
open.pop();
}
let parent = open.last().map(|(ix, _)| *ix);
let kind = match (f.kind, parent) {
(SymbolKind::Function, Some(p)) if symbols[p].kind.holds_methods() => {
SymbolKind::Method
}
(kind, _) => kind,
};
let ix = symbols.len();
symbols.push(Symbol {
name: f.name,
kind,
range: text.offset_to_position(f.range.start)..text.offset_to_position(f.range.end),
selection: text.offset_to_position(f.selection),
depth: open.len(),
parent,
});
open.push((ix, f.range.end));
}
Outline { symbols }
}
/// The outline of a text nobody has parsed yet — a buffer that has not been
/// drawn. Parses it once, here.
pub(crate) fn outline_of_text(language: &str, text: &Rope) -> Outline {
let Some(config) = LanguageRegistry::singleton().language(language) else {
return Outline::default();
};
let mut parser = tree_sitter::Parser::new();
if parser.set_language(&config.language).is_err() {
return Outline::default();
}
let source = text.to_string();
match parser.parse(&source, None) {
Some(tree) => outline_from_tree(language, &tree, text),
None => Outline::default(),
}
}
#[cfg(test)]
pub(crate) fn outline_of(language: &str, source: &str) -> Outline {
outline_of_text(language, &Rope::from(source))
}
#[cfg(test)]
mod tests {
use super::*;
/// `name@depth:kind` for every symbol, in order.
fn shape(language: &str, source: &str) -> Vec<String> {
outline_of(language, source)
.symbols
.iter()
.map(|s| format!("{}@{}:{}", s.name, s.depth, s.kind.tag()))
.collect()
}
#[test]
fn every_outline_query_compiles_against_its_grammar() {
for language in [
"rust",
"go",
"python",
"javascript",
"typescript",
"tsx",
"c",
"cpp",
"java",
"markdown",
"ruby",
"bash",
] {
assert!(query_for(language).is_some(), "{language}");
}
assert!(query_for("json").is_none());
}
#[test]
fn rust_nests_methods_under_their_impl() {
let src = "mod net {\n pub struct Server { port: u16 }\n impl<T> Display for Wrapper<T> where T: Debug {\n fn fmt(&self) {}\n }\n impl Server {\n pub fn serve(&self) {}\n const MAX: usize = 3;\n }\n}\ntrait Run { fn run(&self); }\nenum Mode { A, B }\nfn main() {}\n";
assert_eq!(
shape("rust", src),
[
"net@0:mod",
"Server@1:struct",
"impl<T> Display for Wrapper<T> where T: Debug@1:impl",
"fmt@2:method",
"impl Server@1:impl",
"serve@2:method",
"MAX@2:const",
"Run@0:trait",
"run@1:method",
"Mode@0:enum",
"main@0:fn",
]
);
}
#[test]
fn go_methods_carry_their_receiver() {
let src = "package main\n\ntype Server struct{}\ntype Handler interface{ Serve() }\ntype ID int\nconst Max = 3\nfunc (s *Server) Serve() {}\nfunc main() {}\n";
assert_eq!(
shape("go", src),
[
"Server@0:struct",
"Handler@0:interface",
"ID@0:type",
"Max@0:const",
"(*Server).Serve@0:method",
"main@0:fn",
]
);
}
#[test]
fn python_classes_hold_their_methods() {
let src = "class A:\n def f(self):\n def inner():\n pass\n\n@decorator\ndef g():\n pass\n";
assert_eq!(
shape("python", src),
["A@0:class", "f@1:method", "inner@2:fn", "g@0:fn"]
);
}
#[test]
fn javascript_finds_classes_methods_and_arrow_functions() {
let src = "class Widget {\n render() {}\n}\nfunction boot() {}\nconst handler = () => {};\nconst n = 3;\n";
assert_eq!(
shape("javascript", src),
[
"Widget@0:class",
"render@1:method",
"boot@0:fn",
"handler@0:fn"
]
);
}
#[test]
fn typescript_adds_its_type_declarations() {
let src = "interface Props { name: string }\ntype Id = number;\nenum Color { Red }\nexport class View {\n show(): void {}\n}\nnamespace Util { export function f() {} }\n";
let got = shape("typescript", src);
assert_eq!(
got,
[
"Props@0:interface",
"Id@0:type",
"Color@0:enum",
"View@0:class",
"show@1:method",
"Util@0:mod",
"f@1:fn",
]
);
let tsx = shape("tsx", "function App() { return <div/>; }\n");
assert_eq!(tsx, ["App@0:fn"]);
}
#[test]
fn c_and_cpp_find_functions_through_their_declarators() {
let c = "struct point { int x; };\ntypedef int id_t;\nstatic char *name(void) { return 0; }\nint main(void) { return 0; }\n";
assert_eq!(
shape("c", c),
["point@0:struct", "id_t@0:type", "name@0:fn", "main@0:fn"]
);
let cpp = "namespace app {\nclass Server {\n public:\n void serve();\n};\nvoid Server::serve() {}\n}\n";
assert_eq!(
shape("cpp", cpp),
[
"app@0:namespace",
"Server@1:class",
"serve@2:method",
"Server::serve@1:fn",
]
);
}
#[test]
fn java_nests_members_in_their_class() {
let src = "class App {\n App() {}\n void run() {}\n interface Listener { void on(); }\n}\nenum Kind { A }\n";
assert_eq!(
shape("java", src),
[
"App@0:class",
"App@1:ctor",
"run@1:method",
"Listener@1:interface",
"on@2:method",
"Kind@0:enum",
]
);
}
#[test]
fn markdown_headings_nest_by_level() {
let src = "# Title\n\nintro\n\n## Install\n\ntext\n\n### From source\n\n## Usage\n";
assert_eq!(
shape("markdown", src),
["Title@0:#", "Install@1:#", "From source@2:#", "Usage@1:#",]
);
// tree-sitter-md opens no section for an underlined heading, so it
// names only its own line.
assert_eq!(
shape("markdown", "Setext\n======\n\ntext\n"),
["Setext@0:#"]
);
}
#[test]
fn ruby_and_bash_have_outlines_too() {
assert_eq!(
shape("ruby", "module M\n class C\n def go; end\n end\nend\n"),
["M@0:mod", "C@1:class", "go@2:method"]
);
assert_eq!(
shape("bash", "setup() {\n :\n}\nfunction run { :; }\n"),
["setup@0:fn", "run@0:fn"]
);
}
#[test]
fn a_language_without_a_query_has_an_empty_outline() {
assert!(outline_of("json", "{\"a\": 1}").is_empty());
}
#[test]
fn the_chain_at_a_position_runs_outermost_to_innermost() {
let src = "mod net {\n impl Server {\n fn serve(&self) {\n let x = 1;\n }\n }\n}\nfn main() {}\n";
let outline = outline_of("rust", src);
let names = |pos: Position| -> Vec<String> {
outline
.chain_at(pos)
.into_iter()
.map(|i| outline.symbols[i].name.clone())
.collect()
};
assert_eq!(names(Position::new(3, 12)), ["net", "impl Server", "serve"]);
assert_eq!(names(Position::new(1, 0)), ["net"]);
assert_eq!(names(Position::new(7, 3)), ["main"]);
assert_eq!(names(Position::new(6, 0)), ["net"]);
assert!(names(Position::new(6, 2)).is_empty());
let serve = outline
.symbols
.iter()
.position(|s| s.name == "serve")
.unwrap();
assert_eq!(outline.container_path(serve), ["net", "impl Server"]);
// Lands on the name, not on the start of the item.
assert_eq!(outline.symbols[serve].selection, Position::new(2, 11));
}
#[test]
fn a_language_servers_nested_answer_flattens_the_same_way() {
let p = |l, c| Position::new(l, c);
let outline = Outline::from_nodes(vec![
SymbolNode {
name: "later".into(),
kind: SymbolKind::from_lsp(12),
range: p(10, 0)..p(12, 1),
selection: p(10, 3),
children: vec![],
},
SymbolNode {
name: "Server".into(),
kind: SymbolKind::from_lsp(5),
range: p(0, 0)..p(8, 1),
selection: p(0, 6),
children: vec![SymbolNode {
name: "serve".into(),
kind: SymbolKind::from_lsp(6),
range: p(2, 2)..p(4, 3),
selection: p(2, 6),
children: vec![],
}],
},
]);
let got: Vec<_> = outline
.symbols
.iter()
.map(|s| (s.name.as_str(), s.depth, s.parent))
.collect();
assert_eq!(
got,
[
("Server", 0, None),
("serve", 1, Some(0)),
("later", 0, None)
]
);
assert_eq!(outline.chain_at(p(3, 0)), [0, 1]);
}
}
+415
View File
@@ -0,0 +1,415 @@
//! The editor's Problems list: every error, warning and note the language
//! servers have published for the open files, grouped by file.
//!
//! It lives at the foot of the code panel rather than as a right-panel tab:
//! problems belong to the files open in the editor, the status bar's error and
//! warning counts are where people look for them, and a click lands in the
//! editor right above it. The status bar counts toggle it, as does the
//! `ToggleEditorProblems` command.
//!
//! Hints are left out, as VS Code leaves them out: servers use them for
//! inlay-ish suggestions that would bury the real problems.
use std::path::{Path, PathBuf};
use gpui::prelude::*;
use gpui::{AnyElement, Context, ScrollHandle, Window, div, px};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use lsp_types::DiagnosticSeverity;
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::lsp::LspStore;
/// The pane's height: a handful of rows, leaving the editor the rest.
const PANE_HEIGHT: f32 = 180.;
/// Past this many rows the list stops drawing and says how many are left.
const MAX_ROWS: usize = 1000;
#[derive(Default)]
pub(crate) struct ProblemsPane {
pub(crate) open: bool,
scroll: ScrollHandle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Severity {
Error,
Warning,
Info,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Problem {
/// Zero-based, in editor columns.
pub(crate) line: u32,
pub(crate) column: u32,
pub(crate) severity: Severity,
pub(crate) message: String,
pub(crate) source: Option<String>,
}
/// One file's problems, in the order they appear in it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct FileProblems {
pub(crate) path: PathBuf,
pub(crate) problems: Vec<Problem>,
}
/// Sort the store's snapshot into what the list draws: files by path, each
/// file's problems by position, hints dropped, files left with nothing gone.
pub(crate) fn collect(snapshot: Vec<(PathBuf, Vec<lsp_types::Diagnostic>)>) -> Vec<FileProblems> {
let mut files: Vec<FileProblems> = snapshot
.into_iter()
.map(|(path, diagnostics)| {
let mut problems: Vec<Problem> = diagnostics
.into_iter()
.filter_map(|d| {
let severity = match d.severity {
// A server that says nothing means an error (LSP leaves
// it to the client; every editor reads it that way).
None | Some(DiagnosticSeverity::ERROR) => Severity::Error,
Some(DiagnosticSeverity::WARNING) => Severity::Warning,
Some(DiagnosticSeverity::INFORMATION) => Severity::Info,
_ => return None,
};
Some(Problem {
line: d.range.start.line,
column: d.range.start.character,
severity,
message: d.message,
source: d.source,
})
})
.collect();
problems.sort_by(|a, b| {
(a.line, a.column, a.severity).cmp(&(b.line, b.column, b.severity))
});
FileProblems { path, problems }
})
.filter(|f| !f.problems.is_empty())
.collect();
files.sort_by(|a, b| a.path.cmp(&b.path));
files
}
/// The first line of a message: the list is one row per problem, and the
/// rest is a hover away in the editor.
fn headline(message: &str) -> &str {
message.lines().next().unwrap_or("").trim_end()
}
impl Tty7App {
pub(crate) fn toggle_editor_problems(&mut self, cx: &mut Context<Self>) {
self.editor.problems.open = !self.editor.problems.open;
cx.notify();
}
/// Where a file's name sits in the list: relative to the project the
/// tab shows, when it is inside it.
fn problems_label(&self, path: &Path) -> (String, String) {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
let dir = path.parent().map(|parent| {
self.tab_code()
.and_then(|c| {
c.roots
.iter()
.find_map(|r| parent.strip_prefix(r).ok().map(|p| (r, p)))
})
.map(|(root, rel)| {
let repo = root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
match rel.as_os_str().is_empty() {
true => repo,
false => format!("{repo}/{}", rel.display()),
}
})
.unwrap_or_else(|| parent.display().to_string())
});
(name, dir.unwrap_or_default())
}
pub(crate) fn render_editor_problems(
&self,
window: &Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if !self.editor.problems.open {
return None;
}
let files = collect(LspStore::diagnostics_snapshot(cx));
let theme = cx.theme();
let muted = theme.muted_foreground;
let (errors, warnings) =
files
.iter()
.flat_map(|f| &f.problems)
.fold((0, 0), |(e, w), p| match p.severity {
Severity::Error => (e + 1, w),
Severity::Warning => (e, w + 1),
Severity::Info => (e, w),
});
let header = h_flex()
.flex_none()
.h(px(28.))
.px(px(crate::ui::app::CONTENT_INSET))
.gap_2()
.items_center()
.border_t(crate::ui::theme::hairline(window))
.border_color(theme.sidebar_border)
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::MEDIUM)
.child(t(L10nKey::EditorProblemsTitle)),
)
.child(div().text_xs().text_color(muted).child(t_fmt(
L10nKey::LspProblemsTooltip,
&[
("errors", &errors.to_string()),
("warnings", &warnings.to_string()),
],
)))
.child(div().flex_1())
.child(
Button::new("editor-problems-close")
.ghost()
.xsmall()
.icon(IconName::Close)
.on_click(cx.listener(|this, _, _, cx| this.toggle_editor_problems(cx))),
);
let mut rows: Vec<AnyElement> = Vec::new();
let mut left = 0usize;
for (file_ix, file) in files.iter().enumerate() {
if rows.len() >= MAX_ROWS {
left += file.problems.len();
continue;
}
let (name, dir) = self.problems_label(&file.path);
rows.push(
h_flex()
.id(("editor-problems-file", file_ix))
.h(px(24.))
.px(px(crate::ui::app::CONTENT_INSET))
.gap_2()
.items_center()
.text_sm()
.child(
div()
.flex_none()
.font_weight(gpui::FontWeight::MEDIUM)
.child(name),
)
.child(
div()
.min_w_0()
.text_ellipsis()
.text_xs()
.text_color(muted)
.child(dir),
)
.child(
div()
.flex_none()
.text_xs()
.text_color(muted)
.child(file.problems.len().to_string()),
)
.into_any_element(),
);
for (ix, problem) in file.problems.iter().enumerate() {
if rows.len() >= MAX_ROWS {
left += file.problems.len() - ix;
break;
}
let (icon, color) = match problem.severity {
Severity::Error => (IconName::CircleX, theme.danger),
Severity::Warning => (IconName::TriangleAlert, theme.warning),
Severity::Info => (IconName::Info, theme.info),
};
let at = t_fmt(
L10nKey::EditorLnCol,
&[
("line", &(problem.line + 1).to_string()),
("column", &(problem.column + 1).to_string()),
],
);
let path = file.path.clone();
let (line, column) = (problem.line + 1, problem.column + 1);
rows.push(
h_flex()
.id(("editor-problem", rows.len()))
.h(px(22.))
.pl(px(crate::ui::app::CONTENT_INSET + 12.))
.pr(px(crate::ui::app::CONTENT_INSET))
.gap_2()
.items_center()
.text_sm()
.cursor_pointer()
.hover(|s| s.bg(theme.list_hover))
.child(Icon::new(icon).xsmall().text_color(color))
.child(
div()
.min_w_0()
.flex_1()
.text_ellipsis()
.whitespace_nowrap()
.child(headline(&problem.message).to_string()),
)
.children(problem.source.clone().map(|source| {
div().flex_none().text_xs().text_color(muted).child(source)
}))
.child(div().flex_none().text_xs().text_color(muted).child(at))
.on_click(cx.listener(move |this, _, window, cx| {
this.open_file_in_editor_at(
&path,
Some(line),
Some(column),
window,
cx,
);
}))
.into_any_element(),
);
}
}
if left > 0 {
rows.push(
div()
.px(px(crate::ui::app::CONTENT_INSET))
.py_1()
.text_xs()
.text_color(muted)
.child(t_fmt(
L10nKey::EditorProblemsMore,
&[("n", &left.to_string())],
))
.into_any_element(),
);
}
let body: AnyElement = if files.is_empty() {
div()
.px(px(crate::ui::app::CONTENT_INSET))
.py_2()
.text_sm()
.text_color(muted)
.child(t(L10nKey::EditorProblemsNone))
.into_any_element()
} else {
let scroll = self.editor.problems.scroll.clone();
crate::ui::scrollbar::with_vertical_scrollbar(
"editor-problems-scrollbar",
v_flex()
.id("editor-problems-list")
.size_full()
.overflow_y_scroll()
.track_scroll(&scroll)
.children(rows),
&scroll,
)
};
Some(
v_flex()
.id("editor-problems")
.flex_none()
.h(px(PANE_HEIGHT))
.w_full()
.child(header)
.child(div().flex_1().min_h_0().child(body))
.into_any_element(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{Diagnostic, Position, Range};
fn d(line: u32, character: u32, severity: Option<DiagnosticSeverity>, msg: &str) -> Diagnostic {
Diagnostic {
range: Range::new(
Position::new(line, character),
Position::new(line, character + 1),
),
severity,
message: msg.into(),
..Default::default()
}
}
#[test]
fn problems_are_sorted_by_file_then_position_without_hints() {
let files = collect(vec![
(
PathBuf::from("/p/src/b.rs"),
vec![
d(9, 0, Some(DiagnosticSeverity::WARNING), "w"),
d(2, 4, Some(DiagnosticSeverity::ERROR), "e"),
d(2, 1, None, "unspecified is an error"),
d(5, 0, Some(DiagnosticSeverity::HINT), "hint"),
],
),
(
PathBuf::from("/p/src/a.rs"),
vec![d(0, 0, Some(DiagnosticSeverity::INFORMATION), "i")],
),
(
PathBuf::from("/p/src/c.rs"),
vec![d(0, 0, Some(DiagnosticSeverity::HINT), "only a hint")],
),
]);
assert_eq!(files.len(), 2, "a file with only hints has no problems");
assert_eq!(files[0].path, PathBuf::from("/p/src/a.rs"));
assert_eq!(files[0].problems[0].severity, Severity::Info);
let b: Vec<_> = files[1]
.problems
.iter()
.map(|p| (p.line, p.column, p.severity))
.collect();
assert_eq!(
b,
vec![
(2, 1, Severity::Error),
(2, 4, Severity::Error),
(9, 0, Severity::Warning)
]
);
}
#[test]
fn a_row_shows_the_first_line_of_the_message() {
assert_eq!(
headline("mismatched types\nexpected `u8`"),
"mismatched types"
);
assert_eq!(headline(""), "");
}
/// The pane opens from its command and draws with nothing to show.
#[gpui::test]
fn the_pane_toggles_and_draws_empty(cx: &mut gpui::TestAppContext) {
use crate::ui::app::test_window;
let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1);
app.update_in(&mut vcx, |app, window, cx| {
app.editor_new_file(window, cx);
app.toggle_editor_problems(cx);
});
vcx.run_until_parked();
vcx.update(|window, cx| window.draw(cx));
app.update_in(&mut vcx, |app, window, cx| {
assert!(app.editor.problems.open);
assert!(app.render_editor_problems(window, cx).is_some());
app.toggle_editor_problems(cx);
assert!(app.render_editor_problems(window, cx).is_none());
});
}
}
+898
View File
@@ -0,0 +1,898 @@
//! The editor split in two: two groups of files side by side, each with a
//! file in front, the way VS Code's ⌘\ splits an editor.
//!
//! **One group has the focus, and it is `TabCode`'s own `files`/`active`.**
//! The other one waits in [`OtherGroup`]. Moving the focus swaps the two, so
//! everything that acts on "the file in front" — saving, go to line, the
//! language servers, the change markers, multiple cursors, back/forward, the
//! strip in the header — acts on the focused group without knowing a split
//! exists. The header's strip is the focused group's; the other group shows
//! its file in front and its breadcrumbs.
//!
//! **The same file in both groups.** A buffer is one `OpenFile` with one
//! `InputState`, which is what the language servers, the gutter markers, the
//! undo history and every hook in `code_editor.rs` key on. A second
//! `InputState` mirroring the first would need every one of those attached to
//! it twice, and edits copied back and forth without echoing — a lot of
//! surface for two views of one text. So the text is drawn once: in the group
//! with the focus. The other group shows a placeholder naming the file, and a
//! click on it moves the focus (and with it the live editor) over. The caret
//! and scroll of that file are therefore shared between the two groups; two
//! *different* files each keep their own, being different buffers.
//!
//! A group whose last file closes goes away, and the split with it.
use gpui::Focusable as _;
use gpui::prelude::*;
use gpui::{AnyElement, Context, MouseButton, SharedString, Window, div, px};
use gpui_component::{ActiveTheme as _, h_flex, v_flex};
use super::{BufferId, TabCode};
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t};
/// The group without the focus.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct OtherGroup {
pub(crate) files: Vec<BufferId>,
pub(crate) active: usize,
/// Whether the group *with* the focus is the left one.
pub(crate) focus_left: bool,
}
impl OtherGroup {
fn active_id(&self) -> Option<BufferId> {
self.files.get(self.active).copied()
}
}
/// Takes `id` out of one group's strip, bringing its neighbour forward the
/// way `TabCode::forget` does.
fn remove(files: &mut Vec<BufferId>, active: &mut usize, id: BufferId) -> bool {
let Some(pos) = files.iter().position(|f| *f == id) else {
return false;
};
files.remove(pos);
if *active > pos || *active >= files.len() {
*active = active.saturating_sub(1);
}
true
}
/// One side of the editor, as it is drawn.
pub(crate) struct Side {
pub(crate) files: Vec<BufferId>,
pub(crate) active: usize,
pub(crate) focused: bool,
}
impl TabCode {
pub(crate) fn is_split(&self) -> bool {
self.split.is_some()
}
/// Whether either group shows this buffer.
pub(crate) fn shows(&self, id: BufferId) -> bool {
self.files.contains(&id) || self.split.as_ref().is_some_and(|o| o.files.contains(&id))
}
/// How many groups show this buffer: 0, 1 or 2.
pub(crate) fn views_of(&self, id: BufferId) -> usize {
usize::from(self.files.contains(&id))
+ usize::from(self.split.as_ref().is_some_and(|o| o.files.contains(&id)))
}
/// Every buffer either group shows, once each, the focused group's first.
pub(crate) fn all_files(&self) -> Vec<BufferId> {
let mut all = self.files.clone();
if let Some(other) = &self.split {
for id in &other.files {
if !all.contains(id) {
all.push(*id);
}
}
}
all
}
/// The file in front of the group without the focus.
pub(crate) fn other_active_id(&self) -> Option<BufferId> {
self.split.as_ref().and_then(OtherGroup::active_id)
}
/// Gives the focus to the other group.
pub(crate) fn swap_focus(&mut self) {
let Some(other) = self.split.as_mut() else {
return;
};
std::mem::swap(&mut self.files, &mut other.files);
std::mem::swap(&mut self.active, &mut other.active);
other.focus_left = !other.focus_left;
}
/// Which side the focused group is drawn on: 0 left, 1 right. What tells
/// the two groups' elements apart.
pub(crate) fn focused_slot(&self) -> usize {
usize::from(!self.focus_is_left())
}
/// Whether the focused group is on the left. `true` when not split.
pub(crate) fn focus_is_left(&self) -> bool {
self.split.as_ref().is_none_or(|o| o.focus_left)
}
/// Shows `id` in the other group and gives that group the focus —
/// splitting the editor first if it is not split yet, with the new group
/// on the right.
pub(crate) fn split_with(&mut self, id: BufferId) {
match self.split.as_mut() {
None => {
self.split = Some(OtherGroup {
files: std::mem::replace(&mut self.files, vec![id]),
active: std::mem::replace(&mut self.active, 0),
focus_left: false,
});
}
Some(_) => {
self.swap_focus();
self.show(id);
}
}
}
/// Closes a file in the focused group only. The group goes if it was its
/// last file, and the focus passes to the one that is left.
pub(crate) fn close_in_focused(&mut self, id: BufferId) -> bool {
let closed = self.forget(id);
self.collapse();
closed
}
/// Takes a buffer out of both groups, for a buffer that is going away.
pub(crate) fn forget_everywhere(&mut self, id: BufferId) -> bool {
let mut gone = self.forget(id);
if let Some(other) = self.split.as_mut() {
gone |= remove(&mut other.files, &mut other.active, id);
}
self.collapse();
gone
}
/// A group with nothing left in it closes.
fn collapse(&mut self) {
let Some(other) = self.split.as_ref() else {
return;
};
if other.files.is_empty() {
self.split = None;
} else if self.files.is_empty() {
let other = self.split.take().unwrap_or_default();
self.files = other.files;
self.active = other.active;
}
}
/// Lays a tab's recorded groups back out once their files have loaded.
///
/// `restored` are the buffers the restore opened, already listed in the
/// focused group (they arrive in the background). Anything else the tab
/// shows was opened while they loaded, and is kept:
///
/// - A tab split in the meantime keeps its layout; the restored files
/// stay where they arrived, behind the file in front.
/// - Otherwise the recorded layout is put back, the files opened in the
/// meantime join the focused group, and the last of them stays in front.
///
/// `true` when nothing had been opened in the meantime.
pub(crate) fn restore_groups(
&mut self,
restored: &[BufferId],
left: (Vec<BufferId>, usize),
right: Option<(Vec<BufferId>, usize, bool)>,
) -> bool {
if self.is_split() {
return false;
}
let extras: Vec<BufferId> = self
.files
.iter()
.copied()
.filter(|id| !restored.contains(id))
.collect();
let front = self.active_id().filter(|id| extras.contains(id));
let (left, left_active) = left;
let right = right.filter(|(files, ..)| !files.is_empty());
match (left.is_empty(), right) {
(false, Some((right, right_active, focused))) => {
self.files = left;
self.active = left_active;
self.split = Some(OtherGroup {
files: right,
active: right_active,
focus_left: true,
});
if focused {
self.swap_focus();
}
}
(false, None) => {
self.files = left;
self.active = left_active;
}
(true, Some((right, right_active, _))) => {
self.files = right;
self.active = right_active;
}
(true, None) => {}
}
for id in &extras {
if !self.files.contains(id) {
self.files.push(*id);
}
}
if let Some(front) = front {
self.show(front);
}
extras.is_empty()
}
/// The two sides, left first. One when the editor is not split.
pub(crate) fn sides(&self) -> Vec<Side> {
let focused = Side {
files: self.files.clone(),
active: self.active,
focused: true,
};
let Some(other) = &self.split else {
return vec![focused];
};
let other_side = Side {
files: other.files.clone(),
active: other.active,
focused: false,
};
match other.focus_left {
true => vec![focused, other_side],
false => vec![other_side, focused],
}
}
}
impl Tty7App {
/// Splits the editor: the file in front opens in a second group to the
/// right, which takes the focus. Already split, it opens in the other
/// group instead. `false` with no file to split.
pub(crate) fn editor_split(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(code) = self.tab_code_mut() else {
return false;
};
let Some(id) = code.active_id() else {
return false;
};
code.split_with(id);
self.editor.bar = None;
self.focus_editor(window, cx);
cx.notify();
true
}
/// Gives the focus to the group on the left (or right). `false` when there
/// is no such group to go to — not split, or already there — so the chord
/// can carry on to the panes.
pub(crate) fn editor_focus_group(
&mut self,
left: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let Some(code) = self.tab_code_mut() else {
return false;
};
if !code.is_split() || code.focus_is_left() == left {
return false;
}
code.swap_focus();
self.editor.bar = None;
self.focus_editor(window, cx);
cx.notify();
true
}
/// Keeps the focused group the one whose text has the keyboard: a click,
/// or anything else that focuses the other group's editor, moves the
/// group focus there too. Run on every draw, and before every editor
/// command ([`Self::editor_split_command_sync`]): focus moves between
/// frames, and F12 or F2 in the first frame after one must act on the
/// group the caret is now in.
pub(crate) fn editor_split_follow_focus(&mut self, window: &Window, cx: &mut Context<Self>) {
let Some(other) = self.tab_code().and_then(TabCode::other_active_id) else {
return;
};
if self.tab_code().and_then(TabCode::active_id) == Some(other) {
return;
}
let focused = self.buffer(other).is_some_and(|f| {
f.input
.read(cx)
.focus_handle(cx)
.contains_focused(window, cx)
});
if focused && let Some(code) = self.tab_code_mut() {
code.swap_focus();
cx.notify();
}
}
/// Settles the group focus before any editor command runs, on the element
/// that holds the editor.
///
/// gpui draws a window whose focus moved before it dispatches the next
/// key, which settles the group on its own; a command from the menu bar
/// or a context menu is dispatched without that draw. A capture-phase
/// listener runs before the command's own handler wherever that handler
/// sits — here, in `app.rs` or in `ui::lsp` — and lets the command carry
/// on.
pub(crate) fn editor_split_command_sync<E: InteractiveElement>(
&self,
element: E,
cx: &mut Context<Self>,
) -> E {
use crate::core::actions::*;
macro_rules! before {
($el:expr, $($action:ty),+ $(,)?) => {
$el$(.capture_action(cx.listener(|this, _: &$action, window, cx| {
this.editor_split_follow_focus(window, cx);
})))+
};
}
before!(
element,
EditorSave,
EditorSaveAs,
EditorGoToLine,
EditorGoToSymbol,
EditorNavigateBack,
EditorNavigateForward,
EditorSplitRight,
EditorGoToDefinition,
EditorQuickFix,
EditorRenameSymbol,
EditorFormatDocument,
EditorTransformUppercase,
EditorTransformLowercase,
EditorTransformTitleCase,
EditorTrimTrailingWhitespace,
EditorJoinLines,
EditorRemoveSurroundingBrackets,
EditorFindReferences,
EditorNextChange,
EditorPrevChange,
EditorRevertChange,
EditorPeekChange,
ToggleDocumentPreview,
ToggleDocumentWrap,
)
}
/// The editor's text area: one group, or two side by side.
pub(crate) fn render_editor_groups(
&mut self,
focused_crumbs: Option<AnyElement>,
focused_body: AnyElement,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let pane = |crumbs: Option<AnyElement>, body: AnyElement| {
v_flex()
.flex_1()
.min_w_0()
.min_h_0()
.h_full()
.children(crumbs)
.child(div().flex_1().min_h_0().child(body))
};
let Some(code) = self.tab_code() else {
return pane(focused_crumbs, focused_body).into_any_element();
};
if !code.is_split() {
return pane(focused_crumbs, focused_body).into_any_element();
}
let focus_left = code.focus_is_left();
let other = code.other_active_id();
let same = other.is_some() && other == code.active_id();
let other_slot = usize::from(focus_left);
let (other_crumbs, other_body) = match other {
Some(id) if same => (None, self.render_editor_same_file(id, focus_left, cx)),
Some(id) => (
self.render_editor_breadcrumbs_for(id, other_slot, false, window, cx),
self.render_editor_body(Some(id), other_slot, cx),
),
None => (None, self.render_editor_empty(cx).into_any_element()),
};
let accent = cx.theme().primary;
let focused = pane(focused_crumbs, focused_body)
.border_t_2()
.border_color(accent)
.into_any_element();
let unfocused = pane(other_crumbs, other_body)
.border_t_2()
.border_color(gpui::transparent_black())
.id(("editor-group", other_slot))
// Before the text sees the click, so what the click does lands in
// the group it was made in.
.capture_any_mouse_down(cx.listener(move |this, _, window, cx| {
this.editor_focus_group(!focus_left, window, cx);
}))
.into_any_element();
let divider = div()
.flex_none()
.w(px(1.))
.h_full()
.bg(cx.theme().sidebar_border);
let (left, right) = match focus_left {
true => (focused, unfocused),
false => (unfocused, focused),
};
h_flex()
.flex_1()
.min_h_0()
.size_full()
.child(left)
.child(divider)
.child(right)
.into_any_element()
}
/// The other group's view of the file the focused group is showing: it is
/// drawn once, there, and a click brings it here.
fn render_editor_same_file(
&self,
id: BufferId,
focus_left: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let name: SharedString = self.buffer(id).map(|f| f.label()).unwrap_or_default();
let theme = cx.theme();
v_flex()
.id("editor-group-same-file")
.size_full()
.items_center()
.justify_center()
.gap_1()
.cursor_pointer()
.text_size(gpui::rems(crate::ui::right_panel::TEXT))
.text_color(theme.muted_foreground)
.child(div().text_color(theme.foreground).child(name))
.child(t(L10nKey::EditorSplitSameFile))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, window, cx| {
this.editor_focus_group(!focus_left, window, cx);
}),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(n: u64) -> Vec<BufferId> {
(1..=n).map(gpui::EntityId::from).collect()
}
fn code(files: &[BufferId], active: usize) -> TabCode {
let mut code = TabCode::new();
code.files = files.to_vec();
code.active = active;
code
}
#[test]
fn splitting_opens_the_file_in_front_in_a_focused_right_group() {
let ids = ids(3);
let mut code = code(&ids, 1);
code.split_with(ids[1]);
assert_eq!(code.files, [ids[1]]);
assert_eq!(code.active_id(), Some(ids[1]));
assert!(!code.focus_is_left());
let sides = code.sides();
assert_eq!(sides.len(), 2);
assert_eq!(
(sides[0].files.clone(), sides[0].focused),
(ids.clone(), false)
);
assert!(sides[1].focused);
assert_eq!(code.views_of(ids[1]), 2);
assert_eq!(code.views_of(ids[0]), 1);
assert_eq!(code.all_files(), [ids[1], ids[0], ids[2]]);
}
#[test]
fn the_focus_swaps_the_groups_but_not_their_places() {
let ids = ids(3);
let mut code = code(&ids[..2], 0);
code.split_with(ids[0]);
code.show(ids[2]);
code.swap_focus();
assert!(code.focus_is_left());
assert_eq!(code.files, ids[..2]);
assert_eq!(code.other_active_id(), Some(ids[2]));
let sides = code.sides();
assert!(sides[0].focused && !sides[1].focused);
assert_eq!(sides[1].files, [ids[0], ids[2]]);
}
#[test]
fn a_group_that_loses_its_last_file_closes() {
let ids = ids(2);
let mut code = code(&ids, 0);
code.split_with(ids[1]);
// Closing the right group's only file leaves the left one, focused.
assert!(code.close_in_focused(ids[1]));
assert!(!code.is_split());
assert_eq!(code.files, ids);
assert_eq!(code.active_id(), Some(ids[0]), "it keeps what was in front");
// Closing the other group's last file from elsewhere (the buffer went
// away) closes that group instead.
code.split_with(ids[0]);
code.swap_focus();
assert!(code.forget_everywhere(ids[0]));
assert!(!code.is_split());
assert_eq!(code.files, [ids[1]]);
assert!(!code.shows(ids[0]));
}
#[test]
fn a_restore_lays_out_the_recorded_groups() {
let ids = ids(3);
// As the restore finds it: every file arrived at the end of the strip.
let mut code = code(&ids, 0);
let untouched = code.restore_groups(
&ids,
(vec![ids[0], ids[1]], 1),
Some((vec![ids[2]], 0, true)),
);
assert!(untouched);
assert!(!code.focus_is_left());
assert_eq!(code.active_id(), Some(ids[2]));
assert_eq!(code.other_active_id(), Some(ids[1]));
}
#[test]
fn a_file_opened_while_the_restore_loaded_stays_in_front() {
let ids = ids(4);
// ids[3] was opened by hand; the restore's three arrived behind it.
let mut code = code(&[ids[3], ids[0], ids[1], ids[2]], 0);
let untouched = code.restore_groups(
&ids[..3],
(vec![ids[0], ids[1]], 0),
Some((vec![ids[2]], 0, false)),
);
assert!(!untouched);
assert!(code.is_split(), "the recorded split is still put back");
assert!(code.focus_is_left());
assert_eq!(code.files, [ids[0], ids[1], ids[3]]);
assert_eq!(
code.active_id(),
Some(ids[3]),
"what was opened stays in front"
);
assert_eq!(code.other_active_id(), Some(ids[2]));
}
#[test]
fn a_split_made_while_the_restore_loaded_is_kept() {
let ids = ids(4);
let mut code = code(&[ids[3]], 0);
code.split_with(ids[3]);
// The restore's files arrive in the focused group.
code.files.extend([ids[0], ids[1]]);
let before = (code.files.clone(), code.active, code.split.clone());
let untouched =
code.restore_groups(&ids[..2], (vec![ids[0]], 0), Some((vec![ids[1]], 0, true)));
assert!(!untouched);
assert_eq!(
(code.files.clone(), code.active, code.split.clone()),
before
);
}
#[test]
fn closing_in_one_group_keeps_the_file_in_the_other() {
let ids = ids(2);
let mut code = code(&ids, 0);
code.split_with(ids[0]);
code.show(ids[1]);
code.close_in_focused(ids[0]);
assert!(code.is_split());
assert!(code.shows(ids[0]));
assert_eq!(code.views_of(ids[0]), 1);
}
}
#[cfg(test)]
mod gpui_tests {
use std::path::PathBuf;
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::super::nav::gpui_tests::{caret, open_rs, put_caret};
use super::*;
use crate::ui::app::test_window::harness_with_tabs;
use crate::ui::editor_session::{self, SplitEditor, TabEditor};
fn front(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> PathBuf {
app.read_with(vcx, |app, _| app.active_buffer().unwrap().path.clone())
}
fn split(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> bool {
app.read_with(vcx, |app, _| app.tab_code().is_some_and(TabCode::is_split))
}
#[gpui::test]
fn two_groups_keep_their_own_file_and_caret(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
crate::ui::i18n::set_locale("en");
open_rs(&app, &mut vcx, "/split/a.rs");
put_caret(&app, &mut vcx, 5);
assert!(app.update_in(&mut vcx, |app, window, cx| app.editor_split(window, cx)));
assert!(split(&app, &mut vcx));
// The new group is on the right, focused, showing the same file.
app.read_with(&vcx, |app, _| {
let code = app.tab_code().unwrap();
assert!(!code.focus_is_left());
assert_eq!(code.active_id(), code.other_active_id());
});
// A file opened now opens in the focused (right) group only.
open_rs(&app, &mut vcx, "/split/b.rs");
put_caret(&app, &mut vcx, 40);
assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/b.rs"));
// To the left: a.rs, with its own caret.
assert!(app.update_in(&mut vcx, |app, window, cx| {
app.editor_focus_group(true, window, cx)
}));
assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/a.rs"));
assert_eq!(caret(&app, &mut vcx).line, 5);
assert!(
!app.update_in(&mut vcx, |app, window, cx| app
.editor_focus_group(true, window, cx)),
"no group further left: the chord carries on to the panes"
);
// And back.
assert!(app.update_in(&mut vcx, |app, window, cx| {
app.editor_focus_group(false, window, cx)
}));
assert_eq!(caret(&app, &mut vcx).line, 40);
// Closing both files of the right group closes the group.
app.update_in(&mut vcx, |app, window, cx| {
let n = app.tab_code().unwrap().files.len();
for _ in 0..n {
app.editor_close_file(0, window, cx);
}
});
assert!(!split(&app, &mut vcx));
assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/a.rs"));
assert!(
app.read_with(&vcx, |app, _| app
.editor
.buffers
.iter()
.all(|b| b.path != std::path::Path::new("/split/b.rs"))),
"a file no group shows any more is closed"
);
}
#[gpui::test]
fn a_split_is_recorded_and_comes_back(cx: &mut TestAppContext) {
let dir = tempfile::tempdir().unwrap();
// Canonical, as a loaded file's path is: /var is /private/var here.
let root = crate::ui::code_editor::tests::test_real_dir(dir.path());
let (a, b, c) = (root.join("a.rs"), root.join("b.rs"), root.join("c.rs"));
for p in [&a, &b, &c] {
std::fs::write(p, "fn main() {}\n").unwrap();
}
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
crate::ui::i18n::set_locale("en");
let state = TabEditor {
files: vec![a.clone(), b.clone()],
active: 1,
visible: true,
split: Some(SplitEditor {
files: vec![c.clone()],
active: 0,
focused: true,
}),
};
// The second tab was split when the window last closed.
let tab = app.read_with(&vcx, |app, _| app.tabs[1].tree_id.get());
vcx.update(|_, cx| editor_session::put(cx, tab, state.clone()));
app.update_in(&mut vcx, |app, window, cx| {
app.active = 1;
// As on a relaunch: nothing has been put back for it yet.
app.editor.restored.remove(&tab);
app.editor_restore_active(window, cx);
});
// The files are read on a real thread; let them land.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while !split(&app, &mut vcx) {
assert!(
std::time::Instant::now() < deadline,
"the files never landed"
);
std::thread::sleep(std::time::Duration::from_millis(10));
vcx.run_until_parked();
}
app.read_with(&vcx, |app, _| {
let code = app.tab_code().unwrap();
assert!(code.is_split());
assert!(!code.focus_is_left(), "the right group had the focus");
let path = |id| app.buffer(id).unwrap().path.clone();
assert_eq!(path(code.active_id().unwrap()), c);
assert_eq!(path(code.other_active_id().unwrap()), b);
});
// And recording it writes the same thing down.
app.update(&mut vcx, |app, cx| app.editor_record_sessions(cx));
let recorded = vcx.update(|_, cx| editor_session::get(cx, tab)).unwrap();
assert_eq!(recorded, state);
}
#[gpui::test]
fn a_split_with_nothing_on_the_left_still_comes_back(cx: &mut TestAppContext) {
// The left group held only untitled or remote buffers, which are
// not recorded: only the right group's files were written down.
let dir = tempfile::tempdir().unwrap();
let root = crate::ui::code_editor::tests::test_real_dir(dir.path());
let c = root.join("c.rs");
std::fs::write(&c, "fn main() {}\n").unwrap();
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
crate::ui::i18n::set_locale("en");
let state = TabEditor {
files: vec![],
active: 0,
visible: true,
split: Some(SplitEditor {
files: vec![c.clone()],
active: 0,
focused: true,
}),
};
let tab = app.read_with(&vcx, |app, _| app.tabs[0].tree_id.get());
vcx.update(|_, cx| editor_session::put(cx, tab, state.clone()));
app.update_in(&mut vcx, |app, window, cx| {
app.editor.restored.remove(&tab);
app.editor_restore_active(window, cx);
});
let has_files = |app: &Entity<Tty7App>, vcx: &mut VisualTestContext| {
app.read_with(vcx, |app, _| {
app.tab_code().is_some_and(|c| c.active_id().is_some())
})
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while !has_files(&app, &mut vcx) {
assert!(
std::time::Instant::now() < deadline,
"the right group's file never came back"
);
std::thread::sleep(std::time::Duration::from_millis(10));
vcx.run_until_parked();
}
app.read_with(&vcx, |app, _| {
let code = app.tab_code().unwrap();
assert_eq!(app.buffer(code.active_id().unwrap()).unwrap().path, c);
});
}
#[gpui::test]
fn a_key_pressed_right_after_a_focus_move_acts_on_the_new_group(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
crate::ui::i18n::set_locale("en");
open_rs(&app, &mut vcx, "/split/a.rs");
app.update_in(&mut vcx, |app, window, cx| app.editor_split(window, cx));
open_rs(&app, &mut vcx, "/split/b.rs");
vcx.run_until_parked();
assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/b.rs"));
// The keyboard moves to the left group's text, and a key follows
// before anything is drawn.
vcx.update(|window, cx| {
let left = app.read(cx).tab_code().unwrap().other_active_id().unwrap();
let input = app.read(cx).buffer(left).unwrap().input.clone();
input.update(cx, |state, cx| state.focus(window, cx));
let before = app.read(cx).active_buffer().unwrap().path.clone();
assert_eq!(
before,
PathBuf::from("/split/b.rs"),
"nothing has drawn yet"
);
// Go to Line asks whether the editor has the keyboard, which it
// only does if the group focus has followed it.
window.dispatch_keystroke(gpui::Keystroke::parse("ctrl-g").unwrap(), cx);
assert!(
app.read(cx).editor.bar.is_some(),
"the command saw the group the keyboard is in"
);
});
}
/// A command from a menu is dispatched without a draw first, so nothing
/// but the capture listener can have settled the group.
#[gpui::test]
fn a_menu_command_right_after_a_focus_move_acts_on_the_new_group(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
crate::ui::i18n::set_locale("en");
open_rs(&app, &mut vcx, "/split/a.rs");
app.update_in(&mut vcx, |app, window, cx| app.editor_split(window, cx));
open_rs(&app, &mut vcx, "/split/b.rs");
vcx.run_until_parked();
vcx.update(|window, cx| {
let left = app.read(cx).tab_code().unwrap().other_active_id().unwrap();
let input = app.read(cx).buffer(left).unwrap().input.clone();
input.update(cx, |state, cx| state.focus(window, cx));
let node = window.focused(cx).unwrap();
node.dispatch_action(&crate::core::actions::EditorGoToLine, window, cx);
assert!(
app.read(cx).editor.bar.is_some(),
"the command saw the group the keyboard is in"
);
let front = app.read(cx).active_buffer().unwrap().path.clone();
assert_eq!(front, PathBuf::from("/split/a.rs"));
});
}
#[gpui::test]
fn what_is_opened_while_a_restore_loads_survives_it(cx: &mut TestAppContext) {
let dir = tempfile::tempdir().unwrap();
let root = crate::ui::code_editor::tests::test_real_dir(dir.path());
let (a, b, c) = (root.join("a.rs"), root.join("b.rs"), root.join("c.rs"));
for p in [&a, &b, &c] {
std::fs::write(p, "fn main() {}\n").unwrap();
}
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
crate::ui::i18n::set_locale("en");
let state = TabEditor {
files: vec![a.clone(), b.clone()],
active: 0,
visible: true,
split: Some(SplitEditor {
files: vec![c.clone()],
active: 0,
focused: false,
}),
};
let tab = app.read_with(&vcx, |app, _| app.tabs[0].tree_id.get());
vcx.update(|_, cx| editor_session::put(cx, tab, state));
app.update_in(&mut vcx, |app, window, cx| {
app.editor.restored.remove(&tab);
app.editor_restore_active(window, cx);
});
// Opened by hand while the three are still being read.
open_rs(&app, &mut vcx, "/split/opened.rs");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while !split(&app, &mut vcx) {
assert!(
std::time::Instant::now() < deadline,
"the files never landed"
);
std::thread::sleep(std::time::Duration::from_millis(10));
vcx.run_until_parked();
}
app.read_with(&vcx, |app, _| {
let code = app.tab_code().unwrap();
let path = |id| app.buffer(id).unwrap().path.clone();
assert_eq!(
path(code.active_id().unwrap()),
PathBuf::from("/split/opened.rs")
);
let shown: Vec<PathBuf> = code.all_files().into_iter().map(path).collect();
for p in [&a, &b, &c] {
assert!(shown.contains(p), "{} came back", p.display());
}
assert_eq!(path(code.other_active_id().unwrap()), c);
assert!(code.visible);
});
}
}
+813
View File
@@ -0,0 +1,813 @@
//! The header's file strip when there are more files than it has room for,
//! and the controls that share the header with it.
//!
//! **The strip keeps the files you use.** A docked column fits two or three
//! names, a filled window six or seven; past that the strip used to scroll
//! sideways, which put the file you were just in out of sight as often as
//! not. Now it holds the most recently fronted files — in their strip order,
//! so nothing jumps about as you switch between them — and a `+N` button at
//! its end lists everything, hidden files first, with a filter box.
//!
//! **Dock / Fill** is a two-cell switch rather than a menu entry, and a filled
//! editor leads with a pill naming the terminal it covers, which docks it back
//! beside that terminal.
use gpui::prelude::*;
use gpui::{
Anchor, AnyElement, Context, Entity, FontWeight, SharedString, Subscription, Window, anchored,
deferred, div, px, rems,
};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use super::{BufferId, TabCode};
use crate::ui::app::Tty7App;
use crate::ui::document_column::DocumentChrome;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use tty7_core::core::config::DocumentLayout;
/// Height of a strip cell — a file, the `+N` button, the terminal pill.
pub(super) const CELL_H: f32 = 28.;
/// Their corner.
pub(super) const CELL_RADIUS: f32 = 7.;
/// The widest a file's name may make its cell before it is elided.
pub(super) const CELL_MAX_W: f32 = 168.;
/// Header text: a half step under the body size, like the rail's rows.
pub(super) const CELL_TEXT: f32 = 12.5 / 16.;
const PICKER_W: f32 = 340.;
const PICKER_LIST_MAX_H: f32 = 340.;
const PICKER_ROW_H: f32 = 28.;
impl TabCode {
/// Every file in the strip, the one in front first and the rest by when
/// they last were. Files that have never been in front follow in strip
/// order.
pub(crate) fn by_recency(&self) -> Vec<BufferId> {
let front = self.active_id();
let mut out: Vec<BufferId> = front.into_iter().collect();
for id in self.recent.iter().chain(self.files.iter()) {
if self.files.contains(id) && !out.contains(id) {
out.push(*id);
}
}
out
}
/// Moves the file in front to the head of `recent`. Run every frame the
/// editor draws, so whatever brought a file forward — a click, a link,
/// back/forward, a restore — is counted without a hook of its own. The
/// other group's files keep their places, so moving the focus between
/// groups of a split does not wipe either one's order.
pub(crate) fn note_front(&mut self) {
let order = self.by_recency();
let rest: Vec<BufferId> = self
.recent
.iter()
.copied()
.filter(|id| !order.contains(id) && self.shows(*id))
.collect();
self.recent = order;
self.recent.extend(rest);
}
/// Which files the strip shows when it has room for `cap`: the `cap` most
/// recent, as `(position in files, id)` in strip order; and the rest,
/// most recent first.
pub(crate) fn strip_split(&self, cap: usize) -> (Vec<(usize, BufferId)>, Vec<BufferId>) {
let order = self.by_recency();
let cap = cap.max(1);
let keep = &order[..cap.min(order.len())];
let shown = self
.files
.iter()
.copied()
.enumerate()
.filter(|(_, id)| keep.contains(id))
.collect();
let hidden = order.iter().skip(cap).copied().collect();
(shown, hidden)
}
}
/// The `+N` list while it is open.
pub(crate) struct StripPicker {
input: Entity<InputState>,
/// The highlighted row, an index into the rows the query leaves.
hi: usize,
/// Whether the pointer is on the `+N` button. A press there is the
/// button's to answer — it closes the list on click — so the list does
/// not also close itself on the press and have the click reopen it.
over_button: bool,
_sub: Subscription,
}
/// One row of the list.
struct PickerRow {
id: BufferId,
name: SharedString,
dir: SharedString,
dirty: bool,
front: bool,
}
impl Tty7App {
/// How many names the strip holds. Fixed counts rather than a measure:
/// names differ so much in width that a measured fit would reshuffle the
/// strip whenever a long one came forward.
pub(super) fn editor_strip_cap(&self, chrome: DocumentChrome, cx: &gpui::App) -> usize {
let (left, right) = (self.left_panel_open(cx), self.right_panel_open(cx));
match chrome {
DocumentChrome::Fill if left && right => 5,
DocumentChrome::Fill => 7,
_ if right => 2,
_ => 3,
}
}
fn editor_strip_rows(&self, cx: &gpui::App) -> Vec<PickerRow> {
let Some(code) = self.tab_code() else {
return Vec::new();
};
let front = code.active_id();
let roots = code.roots.as_slice();
let local_host = self.spawn_host(cx);
code.by_recency()
.into_iter()
.filter_map(|id| {
let f = self.buffer(id)?;
let dir = if f.untitled.is_some() {
String::new()
} else {
let local = f.host.id() == local_host;
let mut segs =
super::nav::path_segments(&f.path, if local { roots } else { &[] });
segs.pop();
segs.join("/")
};
Some(PickerRow {
id,
name: f.label(),
dir: dir.into(),
dirty: f.dirty,
front: Some(id) == front,
})
})
.collect()
}
/// The rows the query leaves, in the order Up/Down walks them.
fn editor_strip_matches(&self, cap: usize, cx: &gpui::App) -> (Vec<PickerRow>, usize) {
let q = self
.editor
.strip_picker
.as_ref()
.map(|p| p.input.read(cx).value().trim().to_lowercase())
.unwrap_or_default();
let rows = self.editor_strip_rows(cx);
if !q.is_empty() {
let hits: Vec<PickerRow> = rows
.into_iter()
.filter(|r| r.name.to_lowercase().contains(&q) || r.dir.to_lowercase().contains(&q))
.collect();
return (hits, 0);
}
// Hidden files first — they are what the list is for — then the ones
// the strip already shows. Rows are in recency order, and the strip
// shows the first `cap` of it.
let cap = cap.max(1).min(rows.len());
let mut rows = rows;
let shown: Vec<PickerRow> = rows.drain(..cap).collect();
let hidden = rows.len();
rows.extend(shown);
(rows, hidden)
}
pub(super) fn editor_toggle_strip_picker(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.editor.strip_picker.is_some() {
self.editor_close_strip_picker(window, cx);
return;
}
let n = self.tab_code().map_or(0, |c| c.files.len());
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t_fmt(L10nKey::EditorStripSearch, &[("n", &n.to_string())]))
});
input.update(cx, |state, cx| state.focus(window, cx));
let _sub = cx.subscribe_in(&input, window, |this, _, ev: &InputEvent, _, cx| {
if matches!(ev, InputEvent::Change) {
if let Some(p) = this.editor.strip_picker.as_mut() {
p.hi = 0;
}
cx.notify();
}
});
self.editor.strip_picker = Some(StripPicker {
input,
hi: 0,
over_button: true,
_sub,
});
cx.notify();
}
pub(super) fn editor_close_strip_picker(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.editor.strip_picker.take().is_some() {
self.focus_editor(window, cx);
cx.notify();
}
}
fn editor_strip_pick(&mut self, id: BufferId, window: &mut Window, cx: &mut Context<Self>) {
self.editor.strip_picker = None;
if let Some(pos) = self
.tab_code()
.and_then(|c| c.files.iter().position(|f| *f == id))
{
self.editor_activate(pos, window, cx);
}
cx.notify();
}
fn on_strip_picker_key(
&mut self,
ev: &gpui::KeyDownEvent,
cap: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let (rows, _) = self.editor_strip_matches(cap, cx);
let Some(p) = self.editor.strip_picker.as_mut() else {
return;
};
let n = rows.len();
match ev.keystroke.key.as_str() {
"escape" => self.editor_close_strip_picker(window, cx),
"up" if n > 0 => {
p.hi = p.hi.min(n - 1).saturating_sub(1);
cx.notify();
}
"down" if n > 0 => {
p.hi = (p.hi + 1).min(n - 1);
cx.notify();
}
"enter" => {
if let Some(row) = rows.get(p.hi.min(n.saturating_sub(1))) {
let id = row.id;
self.editor_strip_pick(id, window, cx);
}
}
_ => return,
}
cx.stop_propagation();
}
/// The `+N` button at the end of the strip, with its list when open.
pub(super) fn render_strip_overflow(
&self,
hidden: &[BufferId],
cap: usize,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if hidden.is_empty() {
return None;
}
let theme = cx.theme();
let open = self.editor.strip_picker.is_some();
let hidden_dirty = hidden
.iter()
.any(|id| self.buffer(*id).is_some_and(|f| f.dirty));
let button = h_flex()
.id("editor-strip-more")
.occlude()
.flex_none()
.h(px(CELL_H))
.px(px(8.))
.gap(px(5.))
.items_center()
.rounded(px(CELL_RADIUS))
.text_size(rems(12. / 16.))
.font_features(crate::ui::theme::tabular_figures())
.text_color(theme.muted_foreground)
.when(open, |d| d.bg(theme.sidebar_accent))
.hover(|s| s.bg(theme.sidebar_accent).text_color(theme.foreground))
.when(hidden_dirty, |d| {
d.child(
div()
.size(px(crate::ui::tab_strip::ROW_STATUS_DOT))
.rounded_full()
.bg(theme.muted_foreground),
)
})
.child(format!("+{}", hidden.len()))
.child(
Icon::new(IconName::ChevronDown)
.xsmall()
.text_color(theme.muted_foreground),
)
.tooltip(|window, cx| {
gpui_component::tooltip::Tooltip::new(t(L10nKey::EditorStripAllFiles))
.build(window, cx)
})
.on_hover(cx.listener(|this, over: &bool, _, _| {
if let Some(p) = this.editor.strip_picker.as_mut() {
p.over_button = *over;
}
}))
.on_click(cx.listener(|this, _, window, cx| {
this.editor_toggle_strip_picker(window, cx);
}));
Some(
div()
.relative()
.flex_none()
.child(button)
.children(self.render_strip_picker(cap, cx))
.into_any_element(),
)
}
fn render_strip_picker(&self, cap: usize, cx: &mut Context<Self>) -> Option<AnyElement> {
let picker = self.editor.strip_picker.as_ref()?;
let (rows, hidden) = self.editor_strip_matches(cap, cx);
let querying = !picker.input.read(cx).value().trim().is_empty();
let hi = picker.hi.min(rows.len().saturating_sub(1));
let theme = cx.theme();
let (fg, muted) = (theme.foreground, theme.muted_foreground);
let row_hover = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().popover.hover);
let heading = |label: SharedString| {
div()
.h(px(24.))
.pt(px(4.))
.px(px(8.))
.flex()
.items_center()
.text_size(rems(11. / 16.))
.font_weight(FontWeight::MEDIUM)
.text_color(muted)
.child(label)
};
let empty = rows.is_empty();
let mut list = v_flex()
.id("editor-strip-list")
.max_h(px(PICKER_LIST_MAX_H))
.overflow_y_scroll()
.gap(px(1.));
for (i, row) in rows.into_iter().enumerate() {
if !querying && i == 0 && hidden > 0 {
list = list.child(heading(
t_fmt(L10nKey::EditorStripHidden, &[("n", &hidden.to_string())]).into(),
));
}
if !querying && i == hidden {
list = list.child(heading(t(L10nKey::EditorStripInBar).into()));
}
let id = row.id;
let group: SharedString = format!("editor-strip-row-{i}").into();
let slot = div()
.id(("editor-strip-row-close", i))
.relative()
.flex_none()
.size(px(18.))
.rounded(px(4.))
.hover(|s| s.bg(theme.muted))
.when(row.dirty, |d| {
d.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.group_hover(group.clone(), |s| s.invisible())
.child(div().size(px(6.)).rounded_full().bg(muted)),
)
})
.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.when(row.dirty, |d| d.invisible())
.group_hover(group.clone(), |s| s.visible())
.child(Icon::new(IconName::Close).xsmall().text_color(muted)),
)
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.editor_close_buffer(id, window, cx);
}));
list = list.child(
h_flex()
.id(("editor-strip-row", i))
.group(group)
.flex_none()
.h(px(PICKER_ROW_H))
.pl(px(8.))
.pr(px(4.))
.gap(px(8.))
.items_center()
.rounded(px(6.))
.when(i == hi, |d| d.bg(row_hover))
.on_mouse_move(cx.listener(move |this, _, _, cx| {
if let Some(p) = this.editor.strip_picker.as_mut()
&& p.hi != i
{
p.hi = i;
cx.notify();
}
}))
.on_click(cx.listener(move |this, _, window, cx| {
this.editor_strip_pick(id, window, cx);
}))
.child(
div()
.min_w(px(40.))
.whitespace_nowrap()
.text_ellipsis()
.overflow_hidden()
.text_color(fg)
.when(row.front, |d| d.font_weight(FontWeight::MEDIUM))
.child(row.name),
)
.child(
div()
.flex_1()
.min_w_0()
.whitespace_nowrap()
.text_ellipsis()
.overflow_hidden()
.text_size(rems(11.5 / 16.))
.text_color(muted)
.child(row.dir),
)
.child(slot),
);
}
if empty {
list = list.child(
div()
.h(px(PICKER_ROW_H))
.px(px(8.))
.flex()
.items_center()
.text_color(muted)
.child(t(L10nKey::EditorStripNoMatch)),
);
}
let rule = || div().h(px(1.)).mx(px(6.)).my(px(4.)).bg(theme.border);
let footer_button = |key: &'static str, label: L10nKey| {
div()
.id(key)
.text_color(muted)
.hover(|s| s.text_color(fg))
.child(t(label))
};
let card = v_flex()
.occlude()
.w(px(PICKER_W))
.p(px(5.))
.bg(theme.popover)
.text_color(theme.popover_foreground)
.border_1()
.border_color(theme.border)
.rounded(px(10.))
.shadow_lg()
.text_size(rems(CELL_TEXT))
.on_key_down(
cx.listener(move |this, ev: &gpui::KeyDownEvent, window, cx| {
this.on_strip_picker_key(ev, cap, window, cx);
}),
)
.on_mouse_down_out(cx.listener(|this, _, window, cx| {
if this
.editor
.strip_picker
.as_ref()
.is_some_and(|p| p.over_button)
{
return;
}
this.editor_close_strip_picker(window, cx);
}))
.child(
h_flex()
.h(px(30.))
.px(px(4.))
.gap(px(3.))
.items_center()
.child(Icon::new(IconName::Search).xsmall().text_color(muted))
.child(
div().flex_1().min_w_0().child(
Input::new(&picker.input)
.appearance(false)
.small()
.text_size(rems(CELL_TEXT)),
),
),
)
.child(rule())
.child(list)
.child(rule())
.child(
h_flex()
.h(px(28.))
.px(px(8.))
.gap(px(14.))
.items_center()
.text_size(rems(12. / 16.))
.child(
footer_button("editor-strip-close-saved", L10nKey::EditorStripCloseSaved)
.on_click(cx.listener(|this, _, window, cx| {
this.editor.strip_picker = None;
let ids = this.editor_strip_others(true);
this.editor_close_buffers(ids, window, cx);
this.focus_editor(window, cx);
})),
)
.child(
footer_button("editor-strip-close-others", L10nKey::EditorStripCloseOthers)
.on_click(cx.listener(|this, _, window, cx| {
this.editor.strip_picker = None;
let ids = this.editor_strip_others(false);
this.editor_close_buffers(ids, window, cx);
this.focus_editor(window, cx);
})),
),
);
Some(
div()
.absolute()
.right_0()
.top(px(CELL_H + 4.))
.child(
deferred(
anchored()
.anchor(Anchor::TopRight)
.snap_to_window_with_margin(px(8.))
.child(card),
)
.with_priority(1),
)
.into_any_element(),
)
}
/// Every file in the strip but the one in front — only the saved ones
/// when `saved_only`.
fn editor_strip_others(&self, saved_only: bool) -> Vec<BufferId> {
let Some(code) = self.tab_code() else {
return Vec::new();
};
let front = code.active_id();
code.files
.iter()
.copied()
.filter(|id| Some(*id) != front)
.filter(|id| !saved_only || self.buffer(*id).is_some_and(|f| !f.dirty))
.collect()
}
/// Dock / Fill, as a two-cell switch: a raised cell on a sunken track.
pub(super) fn render_layout_switch(
&self,
chrome: DocumentChrome,
cx: &mut Context<Self>,
) -> AnyElement {
let theme = cx.theme();
let sf = cx.global::<crate::ui::presets::Surfaces>().window;
let dark = theme.mode.is_dark();
// What is on screen, not what was asked for: a window too narrow for
// a column fills even when set to dock.
let current = if chrome.is_dock() {
DocumentLayout::Dock
} else {
DocumentLayout::Fill
};
let cell = |i: usize, layout: DocumentLayout, tip: L10nKey| {
let live = layout == current;
let ink = if live {
theme.foreground
} else {
theme.muted_foreground
};
// The glyph: the window, with the column the editor takes in it.
let frame = div()
.relative()
.w(px(14.))
.h(px(11.))
.border_1()
.border_color(ink)
.rounded(px(2.));
let glyph = match layout {
DocumentLayout::Dock => frame.child(
div()
.absolute()
.top_0()
.bottom_0()
.left(px(4.))
.w(px(1.))
.bg(ink),
),
DocumentLayout::Fill => frame.child(
div()
.absolute()
.top(px(1.5))
.bottom(px(1.5))
.left(px(1.5))
.right(px(1.5))
.rounded(px(0.8))
.bg(ink),
),
};
div()
.id(("editor-layout", i))
.w(px(24.))
.h(px(22.))
.flex()
.items_center()
.justify_center()
.rounded(px(5.))
.when(live, |d| {
d.bg(if dark {
gpui::rgb(sf.selected).into()
} else {
theme.background
})
.shadow_xs()
})
.child(glyph)
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(t(tip)).build(window, cx)
})
.on_click(cx.listener(move |this, _, _, cx| {
this.set_document_layout(layout, cx);
}))
};
h_flex()
.id("editor-layout-switch")
.occlude()
.flex_none()
.ml(px(8.))
.p(px(2.))
.gap(px(2.))
.rounded(px(CELL_RADIUS))
.bg(gpui::rgb(sf.hover))
.child(cell(0, DocumentLayout::Dock, L10nKey::DocumentDock))
.child(cell(1, DocumentLayout::Fill, L10nKey::DocumentFill))
.into_any_element()
}
/// A filled editor's lead: the terminal it covers, by name, and a way back
/// to it that keeps the file open beside it.
pub(super) fn render_back_to_terminal(
&self,
window: &Window,
cx: &mut Context<Self>,
) -> AnyElement {
let theme = cx.theme();
let name = self
.tabs
.get(self.active)
.map(|tab| self.tab_label(tab, self.active, Some(window), cx))
.unwrap_or_default();
let badge_bg = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().window.selected);
h_flex()
.flex_none()
.items_center()
.child(
h_flex()
.id("editor-back-to-terminal")
.occlude()
.h(px(CELL_H))
.pl(px(8.))
.pr(px(10.))
.gap(px(7.))
.items_center()
.rounded(px(CELL_RADIUS))
.text_size(rems(CELL_TEXT))
.text_color(theme.muted_foreground)
.hover(|s| s.bg(theme.sidebar_accent).text_color(theme.foreground))
.child(
div()
.flex_none()
.size(px(16.))
.rounded_full()
.flex()
.items_center()
.justify_center()
.bg(badge_bg)
.font_family(theme.mono_font_family.clone())
.text_size(px(9.5))
.font_weight(FontWeight::SEMIBOLD)
.child(">_"),
)
.child(
div()
.max_w(px(CELL_MAX_W))
.whitespace_nowrap()
.text_ellipsis()
.overflow_hidden()
.child(name),
)
.tooltip(|window, cx| {
gpui_component::tooltip::Tooltip::new(t(L10nKey::DocumentDock))
.build(window, cx)
})
.on_click(cx.listener(|this, _, _, cx| {
this.set_document_layout(DocumentLayout::Dock, cx);
})),
)
.child(
div()
.flex_none()
.w(crate::ui::theme::hairline(window))
.h(px(16.))
.mx(px(6.))
.bg(theme.border),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(n: u64) -> Vec<BufferId> {
(1..=n).map(gpui::EntityId::from).collect()
}
fn code(files: &[BufferId], active: usize) -> TabCode {
let mut code = TabCode::new();
code.files = files.to_vec();
code.active = active;
code
}
#[test]
fn the_strip_keeps_the_files_last_in_front_in_their_strip_order() {
let ids = ids(5);
let mut code = code(&ids, 0);
for front in [3, 1, 4] {
code.active = front;
code.note_front();
}
let (shown, hidden) = code.strip_split(3);
// 4, 1 and 3 were in front last; they keep their strip positions.
assert_eq!(shown, vec![(1, ids[1]), (3, ids[3]), (4, ids[4])]);
// The rest by recency: 0 was in front before any of them, 2 never.
assert_eq!(hidden, vec![ids[0], ids[2]]);
}
#[test]
fn the_file_in_front_always_has_a_place() {
let ids = ids(4);
let mut code = code(&ids, 0);
code.note_front();
// Brought forward from outside the strip's view (a link, the list)
// without a frame drawn in between.
code.active = 3;
let (shown, _) = code.strip_split(1);
assert_eq!(shown, vec![(3, ids[3])]);
}
#[test]
fn a_split_keeps_the_other_groups_order() {
let ids = ids(4);
let mut code = code(&ids[..2], 1);
code.split = Some(super::super::split::OtherGroup {
files: ids[2..].to_vec(),
active: 0,
focus_left: true,
});
code.note_front();
code.swap_focus();
code.note_front();
code.swap_focus();
code.note_front();
assert_eq!(code.recent, vec![ids[1], ids[0], ids[2], ids[3]]);
}
#[test]
fn closed_files_leave_the_order() {
let ids = ids(3);
let mut code = code(&ids, 2);
code.note_front();
code.files.remove(1);
code.active = 1;
code.note_front();
assert_eq!(code.recent, vec![ids[2], ids[0]]);
}
}
+30
View File
@@ -159,6 +159,36 @@ pub(crate) fn labelled(name: impl IntoElement, input: Input, cx: &App) -> Div {
.child(field(input, cx))
}
/// A label over a control that is not a text field.
pub(crate) fn labelled_control(name: impl IntoElement, control: impl IntoElement, cx: &App) -> Div {
v_flex()
.gap(px(LABEL_GAP))
.child(label(name, cx))
.child(control)
}
/// A dropdown's closed face, in the same well as a text field, so a form of
/// fields and choices still reads as one column.
pub(crate) fn select_well(
id: impl Into<gpui::ElementId>,
value: impl Into<gpui::SharedString>,
cx: &App,
) -> gpui::Stateful<Div> {
let tk = crate::ui::settings::kit::Tk::of(cx);
h_flex()
.id(id)
.h(px(FIELD_H))
.pl(px(10.))
.pr(px(9.))
.gap(px(8.))
.items_center()
.rounded(crate::ui::rounding::ROW_RADIUS)
.bg(well_fill(cx))
.cursor_pointer()
.child(div().flex_1().min_w_0().truncate().child(value.into()))
.child(crate::ui::settings::kit::chevron_down(&tk))
}
/// A block of read-only detail — a host and its key — set on the field's
/// fill, so it reads as a value to check rather than a line of prose.
pub(crate) fn well(cx: &App) -> Div {
+31 -1
View File
@@ -24,7 +24,8 @@ const MAX_TABS: usize = 256;
const WRITE_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
/// One tab's editor, as it was last seen.
/// One tab's editor, as it was last seen. `files` and `active` are the left
/// group's — the only group, unless the editor was split.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct TabEditor {
pub(crate) files: Vec<PathBuf>,
@@ -32,6 +33,20 @@ pub(crate) struct TabEditor {
pub(crate) active: usize,
#[serde(default)]
pub(crate) visible: bool,
/// The right group of a split editor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) split: Option<SplitEditor>,
}
/// The right-hand group of a split editor.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SplitEditor {
pub(crate) files: Vec<PathBuf>,
#[serde(default)]
pub(crate) active: usize,
/// Whether this group, not the left one, had the focus.
#[serde(default)]
pub(crate) focused: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -188,6 +203,11 @@ mod tests {
files: vec![PathBuf::from("/src/main.rs"), PathBuf::from("/README.md")],
active: 1,
visible: true,
split: Some(SplitEditor {
files: vec![PathBuf::from("/src/lib.rs")],
active: 0,
focused: true,
}),
},
touched: 7,
},
@@ -197,6 +217,16 @@ mod tests {
assert_eq!(back.tabs[&id].state, doc.tabs[&id].state);
}
#[test]
fn a_record_from_before_the_split_still_reads() {
let old = r#"{"tabs": {}}"#;
assert!(serde_json::from_str::<Doc>(old).is_ok());
let state: TabEditor =
serde_json::from_str(r#"{"files": ["/a.rs"], "active": 0, "visible": true}"#).unwrap();
assert_eq!(state.split, None);
assert!(!serde_json::to_string(&state).unwrap().contains("split"));
}
#[test]
fn the_oldest_tabs_are_forgotten_first() {
let mut doc = Doc::default();
+25 -3
View File
@@ -1856,6 +1856,7 @@ impl Tty7App {
let is_dir = row.entry.is_dir;
let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path);
let muted = cx.theme().muted_foreground;
let paths_are_local = self.spawn_host(cx).is_local();
// A placeholder standing in for children that are not there. Not a
// file, so it takes none of the row machinery below — no hover, no
@@ -2057,11 +2058,26 @@ impl Tty7App {
}
}),
)
.on_drag(ExternalPaths(vec![path.clone()].into()), {
// A local tree's rows are this machine's files, as good as ones
// dragged in from Finder, and every drop target takes them that
// way. A remote tree's rows name files on the far machine: they
// travel as their own type, so no target that reads, uploads or
// pins local paths can mistake one for a file here.
.map(|d| {
let name = row.entry.name.clone();
move |_, _, _, cx| {
let ghost = move |cx: &mut App| {
let name = name.clone();
cx.new(|_| DragGhost { name })
};
match paths_are_local {
true => d.on_drag(
ExternalPaths(vec![path.clone()].into()),
move |_, _, _, cx| ghost(cx),
),
false => d
.on_drag(RemotePathDrag { path: path.clone() }, move |_, _, _, cx| {
ghost(cx)
}),
}
})
// The other direction: files dropped on this row are copied in.
@@ -2082,7 +2098,6 @@ impl Tty7App {
let path = path.clone();
let is_root = row.is_root;
let show_hidden = self.file_tree.show_hidden;
let paths_are_local = self.spawn_host(cx).is_local();
move |menu, _window, cx| {
Self::tree_row_context_menu(
menu,
@@ -2334,6 +2349,13 @@ struct DragGhost {
name: String,
}
/// A row dragged out of a remote Files tree: a path on that remote, which
/// nothing on this machine can open. See the row's `on_drag`.
#[derive(Clone)]
pub(crate) struct RemotePathDrag {
pub path: PathBuf,
}
impl gpui::Render for DragGhost {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
+587 -23
View File
@@ -3,9 +3,10 @@
//! Title, state, author, labels, the body and the conversation as Markdown —
//! through gpui-component's `TextView`, after `core::github::markdown` has
//! turned images into links and disarmed non-web link targets — and, for a
//! pull request, its branches and changed files. A file opens in the diff
//! overlay, the same surface a commit's files open in, fed the patch GitHub
//! sent rather than one git read.
//! pull request, its branches, where it stands on merging, its checks, its
//! reviewers and its changed files. A file opens in the diff overlay, the
//! same surface a commit's files open in, fed the patch GitHub sent rather
//! than one git read.
use std::sync::Arc;
@@ -13,12 +14,17 @@ use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px, rems}
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use tty7_core::core::git::diff::{CommitLabel, DiffBudget, DiffSnapshot, DiffSource};
use tty7_core::core::github::{Comment, Detail, PrFile, RepoSlug};
use tty7_core::core::github::{
Check, CheckState, Checks, Comment, Detail, PrFile, Readiness, RepoSlug, ReviewState, Reviewer,
readiness,
};
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::github::now_unix;
use crate::ui::github::{Fold, now_unix};
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::panel_github::{describe_error, github_tile, label_chip, state_glyph, state_label};
use crate::ui::panel_github::{
check_glyph, check_label, describe_error, github_tile, label_chip, state_glyph, state_label,
};
use crate::ui::right_panel::{
HEADING, META, META_MONO, ROW_FILL_RADIUS, ROW_INSET, TEXT, TEXT_INSET, git_badge,
};
@@ -28,6 +34,27 @@ use crate::ui::scm::status::{status_color, status_glyph};
const ROW_H: f32 = 26.;
const SECTION_GAP: f32 = 16.;
/// Past this many checks, the ones that passed or were skipped fold into one
/// row: a repository with a build matrix has dozens, and the one that failed
/// is what the section is read for.
const CHECKS_FOLD_AT: usize = 5;
/// Past this many changed files, only the first [`FILES_SHOWN_FOLDED`] show
/// until the list is unfolded — the conversation under it stays in reach.
const FILES_FOLD_AT: usize = 10;
const FILES_SHOWN_FOLDED: usize = 8;
/// Past this many reviewers, only the first few show.
const REVIEWERS_FOLD_AT: usize = 5;
/// Past this many comments, the middle folds away the way GitHub's own
/// timeline does: the opening one stays, and the latest few.
const COMMENTS_FOLD_AT: usize = 4;
const COMMENTS_KEPT_LATEST: usize = 2;
/// A description or comment longer than this is clamped to [`CLAMP_HEIGHT`]
/// until unfolded. Judged on the source, since the rendered height is only
/// known after layout; a pasted log or a long template trips it, a few
/// paragraphs do not.
const CLAMP_LINES: usize = 18;
const CLAMP_CHARS: usize = 1600;
const CLAMP_HEIGHT: f32 = 16.;
impl Tty7App {
/// The rows pinned over the detail, and the detail itself.
@@ -74,13 +101,34 @@ impl Tty7App {
let mut body = v_flex()
.pb(px(16.))
.child(self.github_detail_head(&detail, cx))
.child(markdown_block(
format!("gh-body-{}-{number}", slug.full()),
&detail.body,
t(L10nKey::GitHubNoDescription),
.child(self.github_detail_head(&detail, cx));
// What a pull request is usually opened to find out — can it go in,
// and if not, what is it waiting for — before what it says.
if let Some(checks) = detail.checks.as_ref().filter(|c| !c.items.is_empty()) {
let unfolded = self.github_unfolded(slug, number, Fold::Checks);
let toggle = self.github_fold_toggle(slug, number, Fold::Checks, cx);
body = body.child(checks_section(
checks,
&detail.item.html_url,
unfolded,
toggle,
cx,
));
}
if let Some(reviewers) = detail.reviewers.as_ref().filter(|r| !r.is_empty()) {
let unfolded = self.github_unfolded(slug, number, Fold::Reviews);
let toggle = self.github_fold_toggle(slug, number, Fold::Reviews, cx);
body = body.child(reviews_section(reviewers, unfolded, toggle, cx));
}
body = body.child(self.github_long_markdown(
format!("gh-body-{}-{number}", slug.full()),
&detail.body,
t(L10nKey::GitHubNoDescription),
slug,
number,
Fold::Body,
cx,
));
if let Some(err) = error {
// A refresh that failed over a detail already on screen: keep the
// detail, and say why it may be out of date.
@@ -94,6 +142,26 @@ impl Tty7App {
(pinned, body.into_any_element())
}
fn github_unfolded(&self, slug: &RepoSlug, number: u64, fold: Fold) -> bool {
self.github.unfolded.contains(&(slug.clone(), number, fold))
}
fn github_fold_toggle(
&self,
slug: &RepoSlug,
number: u64,
fold: Fold,
cx: &mut Context<Self>,
) -> impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static {
let key = (slug.clone(), number, fold);
cx.listener(move |this, _, _window, cx| {
if !this.github.unfolded.remove(&key) {
this.github.unfolded.insert(key.clone());
}
cx.notify();
})
}
fn github_back_row(
&self,
repo: &RepoKey,
@@ -247,6 +315,15 @@ impl Tty7App {
.child(t_plural(L10nKey::GitHubCommits, pull.commits as usize, &[])),
),
);
let ready = readiness(
item.state,
pull.merge_state,
detail.checks.as_ref(),
detail.reviewers.as_deref(),
);
if let Some(ready) = ready {
head = head.child(readiness_line(ready, cx));
}
}
if !item.labels.is_empty() {
head = head.child(
@@ -274,8 +351,53 @@ impl Tty7App {
t_plural(L10nKey::GitHubComments, count, &[]),
cx,
));
for c in &detail.comments {
section = section.child(comment_block(slug, number, c, now, fg, muted, cx));
let total = detail.comments.len();
let foldable = total > COMMENTS_FOLD_AT;
let unfolded = self.github_unfolded(slug, number, Fold::Comments);
let hidden = if foldable && !unfolded {
1..total - COMMENTS_KEPT_LATEST
} else {
0..0
};
for (i, c) in detail.comments.iter().enumerate() {
if hidden.contains(&i) {
if i == hidden.start {
let toggle = self.github_fold_toggle(slug, number, Fold::Comments, cx);
let text = t_plural(L10nKey::GitHubShowHiddenComments, hidden.len(), &[]);
section = section.child(
div().pt(px(10.)).px(px(CONTENT_INSET)).child(
fold_row("panel-github-fold-comments", None, text, false, cx)
.on_click(toggle),
),
);
}
continue;
}
let body = self.github_long_markdown(
format!("gh-comment-{}-{number}-{}", slug.full(), c.id),
&c.body,
"",
slug,
number,
Fold::Comment(c.id),
cx,
);
section = section.child(comment_block(c, now, fg, muted, body));
}
if foldable && unfolded {
let toggle = self.github_fold_toggle(slug, number, Fold::Comments, cx);
section = section.child(
div().pt(px(10.)).px(px(CONTENT_INSET)).child(
fold_row(
"panel-github-fold-comments",
None,
t(L10nKey::GitHubShowLess).to_string(),
true,
cx,
)
.on_click(toggle),
),
);
}
if detail.comments_truncated {
section = section.child(more_on_github(
@@ -287,6 +409,55 @@ impl Tty7App {
section.into_any_element()
}
/// Markdown that is clamped, with a row to unfold it, when its source
/// runs long.
#[allow(clippy::too_many_arguments)]
fn github_long_markdown(
&self,
id: String,
source: &str,
empty: &str,
slug: &RepoSlug,
number: u64,
fold: Fold,
cx: &mut Context<Self>,
) -> AnyElement {
let block = markdown_block(id.clone(), source, empty, cx);
if !runs_long(source) {
return block;
}
let unfolded = self.github_unfolded(slug, number, fold);
let toggle = self.github_fold_toggle(slug, number, fold, cx);
let text = if unfolded {
t(L10nKey::GitHubShowLess)
} else {
t(L10nKey::GitHubShowFullText)
};
v_flex()
.child(if unfolded {
block
} else {
div()
.max_h(rems(CLAMP_HEIGHT))
.overflow_hidden()
.child(block)
.into_any_element()
})
.child(
div().pt(px(4.)).px(px(CONTENT_INSET)).child(
fold_row(
SharedString::from(format!("{id}-fold")),
None,
text.to_string(),
unfolded,
cx,
)
.on_click(toggle),
),
)
.into_any_element()
}
fn github_detail_files(
&self,
repo: &RepoKey,
@@ -339,13 +510,37 @@ impl Tty7App {
.child(div().text_color(added_ink).child(format!("+{added}")))
.child(div().text_color(removed_ink).child(format!("−{removed}"))),
);
let number = detail.item.number;
let foldable = files.len() > FILES_FOLD_AT;
let unfolded = self.github_unfolded(slug, number, Fold::Files);
// The file open in the diff overlay stays listed, folded or not.
let shown = |i: usize, f: &PrFile| {
!foldable
|| unfolded
|| i < FILES_SHOWN_FOLDED
|| focused.as_deref() == Some(f.path.as_str())
};
let mut rows = v_flex().px(px(CONTENT_INSET));
for (i, file) in files.iter().enumerate() {
if !shown(i, file) {
continue;
}
let selected = focused.as_deref() == Some(file.path.as_str());
rows = rows.child(
self.github_file_row(i, file, selected, repo, &source, &head_ref, &files, cx),
);
}
if foldable {
let text = if unfolded {
t(L10nKey::GitHubShowLess).to_string()
} else {
t_plural(L10nKey::GitHubShowAllFiles, files.len(), &[])
};
let toggle = self.github_fold_toggle(slug, number, Fold::Files, cx);
rows = rows.child(
fold_row("panel-github-fold-files", None, text, unfolded, cx).on_click(toggle),
);
}
let mut section = v_flex().mt(px(SECTION_GAP)).child(heading).child(rows);
if detail.files_truncated {
section = section.child(more_on_github(
@@ -460,6 +655,351 @@ impl Tty7App {
}
}
/// The merge box's one line: a glyph in the colour of how close it is, and
/// the most pressing reason it is not in yet.
fn readiness_line(ready: Readiness, cx: &gpui::App) -> AnyElement {
let theme = cx.theme();
let (glyph, ink, text) = match ready {
Readiness::Ready => (
CheckState::Passed,
theme.success,
t(L10nKey::GitHubReadyToMerge).to_string(),
),
Readiness::Conflicts => (
CheckState::Failed,
theme.danger,
t(L10nKey::GitHubMergeConflicts).to_string(),
),
Readiness::ChecksFailing(n) => (
CheckState::Failed,
theme.danger,
t_plural(L10nKey::GitHubChecksFailing, n, &[]),
),
Readiness::ChangesRequested => (
CheckState::Failed,
theme.danger,
t(L10nKey::GitHubReviewChangesRequested).to_string(),
),
Readiness::WaitingOnChecks(n) => (
CheckState::Pending,
theme.warning,
t_plural(L10nKey::GitHubWaitingOnChecks, n, &[]),
),
Readiness::ReviewRequired => (
CheckState::Pending,
theme.warning,
t(L10nKey::GitHubReviewRequired).to_string(),
),
Readiness::Behind => (
CheckState::Pending,
theme.warning,
t(L10nKey::GitHubBehindBase).to_string(),
),
Readiness::Blocked => (
CheckState::Failed,
theme.warning,
t(L10nKey::GitHubMergeBlocked).to_string(),
),
};
h_flex()
.items_center()
.gap(px(6.))
.child(check_glyph(glyph, cx))
.child(
div()
.text_size(rems(META))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(ink)
.child(text),
)
.into_any_element()
}
/// `18s`, `1m 12s`, `14m`, `2h 5m` — as short as GitHub's own check list.
pub(crate) fn short_duration(secs: i64) -> String {
let secs = secs.max(0);
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
match (h, m, s) {
(0, 0, s) => format!("{s}s"),
(0, m, 0) => format!("{m}m"),
(0, m, s) if m < 10 => format!("{m}m {s}s"),
(0, m, _) => format!("{m}m"),
(h, 0, _) => format!("{h}h"),
(h, m, _) => format!("{h}h {m}m"),
}
}
/// How long a check took, or has been running. Nothing for a check that
/// never said when it started (a commit status, a queued run).
fn check_duration(check: &Check, now: i64) -> Option<String> {
if check.started_at <= 0 {
return None;
}
let end = match check.state {
CheckState::Pending => now,
_ if check.completed_at >= check.started_at => check.completed_at,
_ => return None,
};
Some(short_duration(end - check.started_at))
}
fn checks_section(
checks: &Checks,
html_url: &str,
unfolded: bool,
toggle: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static,
cx: &gpui::App,
) -> AnyElement {
let theme = cx.theme();
let muted = theme.muted_foreground;
let mono = theme.mono_font_family.clone();
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let now = now_unix();
let counted = checks.counted();
let summary = if counted == 0 {
t(L10nKey::GitHubChecksNoneCounted).to_string()
} else {
t_fmt(
L10nKey::GitHubChecksPassed,
&[
("passed", &checks.count(CheckState::Passed).to_string()),
("total", &counted.to_string()),
],
)
};
let heading = h_flex()
.items_center()
.gap(px(6.))
.min_h(px(22.))
.px(px(TEXT_INSET))
.child(
div()
.text_size(rems(HEADING))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(muted)
.child(t(L10nKey::GitHubChecks)),
)
.child(div().flex_1())
.child(div().text_size(rems(META)).text_color(muted).child(summary));
let foldable = checks.items.len() > CHECKS_FOLD_AT;
let folded = foldable && !unfolded;
let quiet = |s: CheckState| matches!(s, CheckState::Passed | CheckState::Skipped);
let mut rows = v_flex().px(px(CONTENT_INSET));
for (i, check) in checks.items.iter().enumerate() {
if folded && quiet(check.state) {
continue;
}
let duration = check_duration(check, now);
let state = check_label(check.state);
let mut row = h_flex()
.id(("panel-github-check", i))
.items_center()
.gap(px(8.))
.h(px(ROW_H))
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.rounded(ROW_FILL_RADIUS)
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(state).build(window, cx)
})
.child(check_glyph(check.state, cx))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(TEXT))
.text_color(if check.state == CheckState::Skipped {
muted
} else {
gpui::rgb(sf.text_resting).into()
})
.child(check.name.clone()),
)
.children(duration.map(|d| {
div()
.flex_none()
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(muted)
.child(d)
}));
if let Some(url) = check.url.clone() {
row = row
.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.on_click(move |_, _window, cx| cx.open_url(&url));
}
rows = rows.child(row);
}
if foldable {
let (passed, skipped) = (
checks.count(CheckState::Passed),
checks.count(CheckState::Skipped),
);
let (glyph, text) = if unfolded {
(None, t(L10nKey::GitHubShowLess).to_string())
} else {
let mut parts = Vec::new();
if passed > 0 {
parts.push(t_plural(L10nKey::GitHubPassedCount, passed, &[]));
}
if skipped > 0 {
parts.push(t_plural(L10nKey::GitHubSkippedCount, skipped, &[]));
}
let glyph = if passed > 0 {
CheckState::Passed
} else {
CheckState::Skipped
};
(Some(glyph), parts.join(" · "))
};
rows = rows.child(
fold_row("panel-github-fold-checks", glyph, text, unfolded, cx).on_click(toggle),
);
}
let mut section = v_flex().mt(px(SECTION_GAP)).child(heading).child(rows);
if checks.truncated {
section = section.child(more_on_github(
"panel-github-more-checks",
format!("{html_url}/checks"),
cx,
));
}
section.into_any_element()
}
/// The row a folded section ends in: what is tucked away (or "show less"),
/// and a chevron saying which way it goes.
fn fold_row(
id: impl Into<gpui::ElementId>,
glyph: Option<CheckState>,
text: String,
unfolded: bool,
cx: &gpui::App,
) -> gpui::Stateful<gpui::Div> {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let muted = cx.theme().muted_foreground;
h_flex()
.id(id)
.items_center()
.gap(px(8.))
.h(px(ROW_H))
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.rounded(ROW_FILL_RADIUS)
.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.children(glyph.map(|g| check_glyph(g, cx)))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(META))
.text_color(muted)
.child(text),
)
.child(
Icon::new(if unfolded {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.xsmall()
.text_color(muted),
)
}
fn reviews_section(
reviewers: &[Reviewer],
unfolded: bool,
toggle: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static,
cx: &gpui::App,
) -> AnyElement {
let theme = cx.theme();
let muted = theme.muted_foreground;
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let foldable = reviewers.len() > REVIEWERS_FOLD_AT;
let shown = if foldable && !unfolded {
REVIEWERS_FOLD_AT - 1
} else {
reviewers.len()
};
let mut rows = v_flex().px(px(CONTENT_INSET));
for (i, r) in reviewers.iter().enumerate().take(shown) {
let (label, ink) = match r.state {
ReviewState::Approved => (L10nKey::GitHubReviewApproved, theme.success),
ReviewState::ChangesRequested => (L10nKey::GitHubReviewChangesRequested, theme.danger),
ReviewState::Commented => (L10nKey::GitHubReviewCommented, muted),
ReviewState::Requested => (L10nKey::GitHubReviewRequested, muted),
};
let initial = r
.login
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_default();
rows = rows.child(
h_flex()
.id(("panel-github-reviewer", i))
.items_center()
.gap(px(8.))
.h(px(ROW_H))
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.child(
div()
.flex_none()
.flex()
.items_center()
.justify_center()
.size(px(16.))
.rounded_full()
.bg(theme.foreground.opacity(0.08))
.text_size(rems(10. / 16.))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(muted)
.child(initial),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(TEXT))
.text_color(gpui::rgb(sf.text_resting))
.child(r.login.clone()),
)
.child(
div()
.flex_none()
.text_size(rems(META))
.text_color(ink)
.child(t(label)),
),
);
}
if foldable {
let text = if unfolded {
t(L10nKey::GitHubShowLess).to_string()
} else {
t_plural(L10nKey::GitHubShowAllReviewers, reviewers.len(), &[])
};
rows = rows.child(
fold_row("panel-github-fold-reviews", None, text, unfolded, cx).on_click(toggle),
);
}
v_flex()
.mt(px(SECTION_GAP))
.child(section_heading(t(L10nKey::GitHubReviews).to_string(), cx))
.child(rows)
.into_any_element()
}
/// Headings a step or two over the body rather than the document-sized ramp
/// the text view defaults to: an issue's `## What happened?` is a label in a
/// 280px column, not a page title.
@@ -554,15 +1094,12 @@ fn renders_safely(node: &gpui_component::text::markdown_ast::Node) -> bool {
.is_none_or(|children| children.iter().all(renders_safely))
}
#[allow(clippy::too_many_arguments)]
fn comment_block(
slug: &RepoSlug,
number: u64,
c: &Comment,
now: i64,
fg: gpui::Hsla,
muted: gpui::Hsla,
cx: &gpui::App,
body: AnyElement,
) -> AnyElement {
let when = (c.created_at > 0).then(|| relative_time(now, c.created_at));
v_flex()
@@ -582,15 +1119,15 @@ fn comment_block(
)
.children(when.map(|w| div().text_color(muted).child(w))),
)
.child(markdown_block(
format!("gh-comment-{}-{number}-{}", slug.full(), c.id),
&c.body,
"",
cx,
))
.child(body)
.into_any_element()
}
/// Whether a Markdown source is long enough to clamp.
fn runs_long(source: &str) -> bool {
source.chars().count() > CLAMP_CHARS || source.lines().count() > CLAMP_LINES
}
fn more_on_github(id: &'static str, url: String, cx: &gpui::App) -> AnyElement {
h_flex()
.id(id)
@@ -607,3 +1144,30 @@ fn more_on_github(id: &'static str, url: String, cx: &gpui::App) -> AnyElement {
.child(Icon::new(IconName::ExternalLink).xsmall())
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::{runs_long, short_duration};
#[test]
fn a_pasted_log_runs_long_and_a_few_paragraphs_do_not() {
assert!(!runs_long("It crashes.\n\nSteps:\n1. open\n2. resize"));
assert!(runs_long(&"line\n".repeat(40)));
assert!(runs_long(&"word ".repeat(400)));
}
#[test]
fn durations_read_as_short_as_githubs() {
assert_eq!(short_duration(18), "18s");
assert_eq!(short_duration(120), "2m");
assert_eq!(short_duration(72), "1m 12s");
assert_eq!(short_duration(14 * 60 + 5), "14m");
assert_eq!(short_duration(3600), "1h");
assert_eq!(short_duration(2 * 3600 + 5 * 60), "2h 5m");
assert_eq!(
short_duration(-3),
"0s",
"a clock skew is not negative time"
);
}
}
+375
View File
@@ -20,6 +20,12 @@
//! tabs or panes back and forth does not refetch; an entry older than
//! [`STALE_AFTER`] is revalidated in the background while the old rows stay
//! on screen.
//!
//! Two things are fresher than that. A pull request on screen with checks
//! still running has just its checks re-read every [`CHECKS_POLL`], signed in
//! only, so "waiting on 1 check" turns into a verdict without anyone pressing
//! refresh. And the pane's branch is read every [`BRANCH_TTL`], so the pull
//! request pinned over the list follows a `git switch`.
pub(crate) mod detail;
@@ -33,6 +39,9 @@ use tty7_core::core::github::{
ApiError, Detail, GitHubRemote, Item, Kind, RepoSlug, StateFilter, Transport,
};
use tty7_core::core::config::RightPanelTab;
use tty7_core::core::github::CheckState;
use crate::ui::app::Tty7App;
use crate::ui::host_ops::SharedHost;
use crate::ui::scm::panel::RepoLookup;
@@ -41,6 +50,13 @@ use crate::ui::scm::state::RepoKey;
/// How old a cached list or detail may get before a render revalidates it.
pub(crate) const STALE_AFTER: Duration = Duration::from_secs(120);
/// How often an open pull request's running checks are re-read. Two requests
/// a go — well inside a signed-in rate limit, which is the only one polled.
pub(crate) const CHECKS_POLL: Duration = Duration::from_secs(20);
/// How long the pane's branch is trusted before it is read again.
const BRANCH_TTL: Duration = Duration::from_secs(10);
/// How far a list reads on by itself through pages that filter down to
/// nothing (a pull-request-heavy repository's `/issues`), before it leaves
/// the rest to "load more".
@@ -82,6 +98,41 @@ pub(crate) struct DetailCache {
pub(crate) error: Option<ApiError>,
pub(crate) fetched: Option<Instant>,
seq: u64,
/// A checks poll is scheduled or in flight.
polling: bool,
}
/// The pane's branch, and the branch it pushes to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct BranchHead {
pub(crate) branch: String,
/// `origin/feat/x`, when it tracks one.
pub(crate) upstream: Option<String>,
}
#[derive(Default)]
pub(crate) struct BranchLookup {
/// `None` on a detached HEAD, or before the first answer.
pub(crate) head: Option<BranchHead>,
pub(crate) read_at: Option<Instant>,
pub(crate) loading: bool,
}
/// Which pull request a branch would have: opened against `slug`, from
/// `owner`'s `branch`.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct BranchPullKey {
pub(crate) slug: RepoSlug,
pub(crate) owner: String,
pub(crate) branch: String,
}
#[derive(Default)]
pub(crate) struct BranchPullCache {
pub(crate) item: Option<Item>,
pub(crate) loading: bool,
pub(crate) error: bool,
pub(crate) fetched: Option<Instant>,
}
#[derive(Default)]
@@ -105,6 +156,24 @@ pub(crate) struct GitHubPanelState {
/// The list row under the pointer, by number: the one that shows its
/// labels and age.
pub(crate) hovered: Option<u64>,
pub(crate) branches: HashMap<RepoKey, BranchLookup>,
pub(crate) branch_pulls: HashMap<BranchPullKey, BranchPullCache>,
/// The long sections of a detail the user unfolded.
pub(crate) unfolded: std::collections::HashSet<(RepoSlug, u64, Fold)>,
}
/// A detail section that folds when it runs long.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Fold {
Checks,
Files,
Reviews,
/// The comments between the first and the last few.
Comments,
/// The description, clamped when long.
Body,
/// One long comment, by id.
Comment(u64),
}
/// What the panel can say about the active pane's repository.
@@ -114,6 +183,7 @@ pub(crate) enum GhTarget {
NotARepo,
NoRemote,
Ready {
host: SharedHost,
repo: RepoKey,
remotes: Arc<Vec<GitHubRemote>>,
chosen: GitHubRemote,
@@ -221,6 +291,7 @@ impl Tty7App {
Some(chosen) => {
let chosen = chosen.clone();
GhTarget::Ready {
host,
repo,
remotes,
chosen,
@@ -377,6 +448,7 @@ impl Tty7App {
}
};
if !due {
self.github_poll_checks(&key, cx);
return;
}
let Some(connection) = self.github_connection(cx) else {
@@ -414,6 +486,213 @@ impl Tty7App {
.detach();
}
/// Re-read the checks of the pull request on screen, after a pause, while
/// any of them are still running. Safe to call every frame: one poll per
/// detail is ever scheduled. Stops by itself once the detail is closed,
/// the tab is hidden, or every check has a verdict — and then marks the
/// detail due, so the merge state is read again with the verdicts in.
fn github_poll_checks(&mut self, key: &(RepoSlug, u64), cx: &mut Context<Self>) {
let Some(entry) = self.github.details.get(key) else {
return;
};
let pending = entry.detail.as_ref().is_some_and(|d| {
d.checks
.as_ref()
.is_some_and(|c| c.count(CheckState::Pending) > 0)
&& d.pull.as_ref().is_some_and(|p| !p.head_sha.is_empty())
});
// Signed out, the hourly allowance is 60 requests; a poll would spend
// it in ten minutes. Refresh still works by hand.
let signed_in = self
.github
.connection
.as_ref()
.is_some_and(|c| c.transport.authenticated());
if entry.polling
|| entry.loading
|| !pending
|| !signed_in
|| !self.github_detail_shown(key)
{
return;
}
if let Some(entry) = self.github.details.get_mut(key) {
entry.polling = true;
}
let key = key.clone();
let timer = cx.background_executor().timer(CHECKS_POLL);
cx.spawn(async move |this, cx| {
timer.await;
let request = this
.update(cx, |this, _cx| {
let entry = this.github.details.get_mut(&key)?;
let sha = entry
.detail
.as_ref()?
.pull
.as_ref()
.map(|p| p.head_sha.clone())?;
let seq = entry.seq;
let transport = this.github.connection.as_ref()?.transport.clone();
if !this.github_detail_shown(&key) {
return None;
}
Some((sha, seq, transport))
})
.ok()
.flatten();
let Some((sha, seq, transport)) = request else {
let _ = this.update(cx, |this, _cx| {
if let Some(entry) = this.github.details.get_mut(&key) {
entry.polling = false;
}
});
return;
};
let slug = key.0.clone();
let result = off_ui(move || api::checks(&*transport, &slug, &sha)).await;
let _ = this.update(cx, |this, cx| {
let Some(entry) = this.github.details.get_mut(&key) else {
return;
};
entry.polling = false;
// A full read landed (or started) meanwhile: it has the
// newer word on the checks.
if entry.seq != seq || entry.loading {
return;
}
let Some(Ok(checks)) = result else {
return;
};
let settled = checks.count(CheckState::Pending) == 0;
if let Some(detail) = entry.detail.as_mut() {
Arc::make_mut(detail).checks = Some(checks);
}
if settled {
entry.fetched = None;
}
cx.notify();
});
})
.detach();
}
/// Whether `key`'s detail is what the right panel is showing.
fn github_detail_shown(&self, key: &(RepoSlug, u64)) -> bool {
self.right_panel_visible
&& self.right_panel_tab == RightPanelTab::GitHub
&& self.github.open.as_ref() == Some(key)
}
/// The branch `repo` is on, re-read every [`BRANCH_TTL`].
pub(crate) fn github_branch(
&mut self,
host: SharedHost,
repo: &RepoKey,
cx: &mut Context<Self>,
) -> Option<BranchHead> {
let entry = self.github.branches.entry(repo.clone()).or_default();
let due = !entry.loading && entry.read_at.is_none_or(|t| t.elapsed() > BRANCH_TTL);
let head = entry.head.clone();
if due {
entry.loading = true;
let root = repo.root.clone();
let repo = repo.clone();
crate::ui::host_ops::HostOps::run(
host,
cx,
move |h| {
use tty7_core::core::git::git;
let branch = git(h, &root, &["symbolic-ref", "-q", "--short", "HEAD"])?
.trim()
.to_string();
if branch.is_empty() {
return None;
}
let upstream = git(
h,
&root,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
&format!("{branch}@{{upstream}}"),
],
)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Some(BranchHead { branch, upstream })
},
move |this, head, cx| {
let entry = this.github.branches.entry(repo).or_default();
entry.loading = false;
entry.read_at = Some(Instant::now());
if entry.head != head {
entry.head = head;
cx.notify();
}
},
);
}
head
}
/// The pull request the pane's branch has on the repository shown, if
/// any — fetched once and revalidated like a list.
pub(crate) fn github_branch_pull(
&mut self,
host: SharedHost,
repo: &RepoKey,
remotes: &[GitHubRemote],
chosen: &GitHubRemote,
cx: &mut Context<Self>,
) -> Option<Item> {
let head = self.github_branch(host, repo, cx)?;
let key = branch_pull_key(&head, remotes, chosen)?;
let entry = self.github.branch_pulls.entry(key.clone()).or_default();
let item = entry.item.clone();
let due = !entry.loading
&& !entry.error
&& entry.fetched.is_none_or(|t| t.elapsed() > STALE_AFTER);
if !due {
return item;
}
let Some(connection) = self.github_connection(cx) else {
return item;
};
if let Some(entry) = self.github.branch_pulls.get_mut(&key) {
entry.loading = true;
}
cx.spawn(async move |this, cx| {
let transport = connection.transport.clone();
let q = key.clone();
let Some(result) =
off_ui(move || api::pull_for_branch(&*transport, &q.slug, &q.owner, &q.branch))
.await
else {
return;
};
let _ = this.update(cx, |this, cx| {
let entry = this.github.branch_pulls.entry(key.clone()).or_default();
entry.loading = false;
match result {
Ok(item) => {
entry.item = item;
entry.error = false;
entry.fetched = Some(Instant::now());
}
Err(e) => {
log::warn!("github: pull request of {}:{}: {e}", key.owner, key.branch);
entry.error = true;
}
}
cx.notify();
});
})
.detach();
item
}
/// Start over for the repository on screen: resolve the token again (a
/// `gh auth login` since the last try counts), re-read the remotes, and
/// mark the visible list and detail due.
@@ -441,6 +720,13 @@ impl Tty7App {
entry.seq += 1;
}
self.github.details.retain(|_, e| e.detail.is_some());
for entry in self.github.branches.values_mut() {
entry.read_at = None;
}
for entry in self.github.branch_pulls.values_mut() {
entry.error = false;
entry.fetched = None;
}
cx.notify();
}
@@ -471,9 +757,98 @@ impl Tty7App {
}
}
/// Where to look for `head`'s pull request: its upstream's remote, when that
/// is a GitHub remote (a fork's branch lives in the fork), else the repository
/// shown under the local branch's name. `None` when it tracks a branch
/// somewhere other than GitHub.
pub(crate) fn branch_pull_key(
head: &BranchHead,
remotes: &[GitHubRemote],
chosen: &GitHubRemote,
) -> Option<BranchPullKey> {
let (owner, branch) = match &head.upstream {
None => (chosen.slug.owner.clone(), head.branch.clone()),
Some(upstream) => {
// The longest remote name that prefixes it: `origin` must not
// claim `origin-old/x`, and a remote may itself contain a `/`.
let remote = remotes
.iter()
.filter(|r| {
upstream
.strip_prefix(r.remote.as_str())
.is_some_and(|rest| rest.starts_with('/'))
})
.max_by_key(|r| r.remote.len())?;
let branch = upstream[remote.remote.len() + 1..].to_string();
(remote.slug.owner.clone(), branch)
}
};
(!branch.is_empty()).then(|| BranchPullKey {
slug: chosen.slug.clone(),
owner,
branch,
})
}
/// Seconds since the epoch, for relative times.
pub(crate) fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
#[cfg(test)]
mod tests {
use super::*;
fn remote(name: &str, owner: &str) -> GitHubRemote {
GitHubRemote {
remote: name.into(),
slug: RepoSlug {
owner: owner.into(),
name: "widgets".into(),
},
}
}
fn head(branch: &str, upstream: Option<&str>) -> BranchHead {
BranchHead {
branch: branch.into(),
upstream: upstream.map(str::to_string),
}
}
#[test]
fn a_forks_branch_is_looked_for_under_the_forks_owner() {
let remotes = [remote("origin", "me"), remote("upstream", "acme")];
let key = branch_pull_key(
&head("local-name", Some("origin/feat/x")),
&remotes,
&remotes[1],
)
.unwrap();
assert_eq!(key.slug.owner, "acme", "asked of the repository shown");
assert_eq!((key.owner.as_str(), key.branch.as_str()), ("me", "feat/x"));
}
#[test]
fn an_untracked_branch_is_looked_for_under_its_own_name() {
let remotes = [remote("origin", "acme")];
let key = branch_pull_key(&head("feat/y", None), &remotes, &remotes[0]).unwrap();
assert_eq!(
(key.owner.as_str(), key.branch.as_str()),
("acme", "feat/y")
);
}
#[test]
fn the_longest_remote_name_claims_the_upstream() {
let remotes = [remote("origin", "a"), remote("origin-old", "b")];
let key = branch_pull_key(&head("x", Some("origin-old/x")), &remotes, &remotes[0]).unwrap();
assert_eq!(key.owner, "b");
assert!(
branch_pull_key(&head("x", Some("gitlab/x")), &remotes, &remotes[0]).is_none(),
"a branch pushed somewhere else has no pull request here"
);
}
}
+165 -4
View File
@@ -152,10 +152,16 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SearchTabActions => "Commands",
L10nKey::SearchTabTerminals => "Terminals",
L10nKey::SearchTabHosts => "Hosts",
L10nKey::SearchTabSymbols => "Symbols",
L10nKey::SearchPlaceholderAll => "Search files, actions, terminals and hosts…",
L10nKey::SearchPlaceholderActions => "Search commands…",
L10nKey::SearchPlaceholderTerminals => "Search open tabs, shells and agents…",
L10nKey::SearchPlaceholderHosts => "Search hosts, or type user@host to connect…",
L10nKey::SearchPlaceholderSymbols => "Go to a symbol in this file…",
L10nKey::SearchSymbolsNone => "No symbols in this file",
L10nKey::SearchSymbolsNoneHint => {
"Symbols are listed for Rust, Go, Python, JavaScript, TypeScript, C, C++, Java, Ruby, shell and Markdown."
}
L10nKey::SearchTabFiles => "Files",
L10nKey::SearchPlaceholderFiles => "Go to a file by name — add :line to jump to it…",
L10nKey::SearchFilesNoRoots => "No project to search",
@@ -1114,9 +1120,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SftpTransferCancelled => "cancelled",
L10nKey::SftpTransferError => "error",
L10nKey::SftpTransferListFailed => "Could not check transfers: {error}",
L10nKey::SftpImagePasteUploadFailed => {
"Could not upload the pasted image to {host}: {error}"
}
L10nKey::SftpPasteUploadFailed => "Could not upload {name} to {host}: {error}",
L10nKey::LinkFileOpenFailed => "Could not open {path}: {error}",
L10nKey::ForwardDisconnected => "Disconnected",
L10nKey::ForwardDisconnectedFrom => "Disconnected from {host}",
@@ -1204,8 +1208,16 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::EditorUnsavedChanges => "\"{name}\" has unsaved changes",
L10nKey::EditorDiscard => "Discard",
L10nKey::EditorNoFileOpen => "No file open",
L10nKey::EditorStripSearch => "Search {n} open files",
L10nKey::EditorStripAllFiles => "All open files",
L10nKey::EditorStripHidden => "Hidden · {n}",
L10nKey::EditorStripInBar => "In tab bar",
L10nKey::EditorStripNoMatch => "No open files match",
L10nKey::EditorStripCloseSaved => "Close saved",
L10nKey::EditorStripCloseOthers => "Close others",
L10nKey::EditorBackToTerminal => "Back to Terminal (Esc)",
L10nKey::EditorLnCol => "Ln {line}, Col {column}",
L10nKey::EditorSelections => "({n} selections)",
L10nKey::EditorEdit => "Edit",
L10nKey::EditorPreview => "Preview",
L10nKey::EditorWrapOn => "Wrap: on",
@@ -1228,8 +1240,71 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::EditorAlreadyOpen => "\"{path}\" is already open in the editor",
L10nKey::EditorGoToLine => "Go to line",
L10nKey::EditorGoToLineAction => "Go to Line…",
L10nKey::EditorGoToMatchingBracket => "Go to Matching Bracket",
L10nKey::EditorToggleComment => "Toggle Comment",
L10nKey::EditorMoveLineUp => "Move Line Up",
L10nKey::EditorMoveLineDown => "Move Line Down",
L10nKey::EditorDuplicateLine => "Duplicate Line",
L10nKey::EditorDeleteLine => "Delete Line",
L10nKey::EditorCopyRelativePath => "Copy Relative Path",
L10nKey::EditorGitNextChange => "Go to Next Change",
L10nKey::EditorGitPrevChange => "Go to Previous Change",
L10nKey::EditorGitRevertChange => "Revert Change",
L10nKey::EditorGitToggleGutter => "Toggle Git Change Markers",
L10nKey::EditorGitPeekRevert => "Revert",
L10nKey::EditorGitPeekSummary => "−{removed} +{added} lines against the staged version",
L10nKey::EditorGitPeekAddedOnly => {
"These lines are new: the staged version has nothing here."
}
L10nKey::EditorGitPeekChange => "Peek Change",
L10nKey::EditorGitPeekKeys => "Enter reverts · Esc closes",
L10nKey::EditorProblemsTitle => "Problems",
L10nKey::EditorProblemsToggle => "Toggle Problems",
L10nKey::EditorProblemsNone => "No problems in the open files.",
L10nKey::EditorProblemsMore => "…and {n} more",
L10nKey::SettingsEditor => "Editor",
L10nKey::SettingsEditorGitGutter => "Git change markers",
L10nKey::SettingsEditorGitGutterDesc => {
"Mark lines that differ from the staged version beside the line numbers and on the scrollbar."
}
L10nKey::SettingsEditorLsp => "Language servers",
L10nKey::SettingsEditorLspDesc => {
"Start a language server for files it knows, for errors, completion and go to definition. Only files on this machine."
}
L10nKey::SettingsEditorSoftWrap => "Wrap long lines",
L10nKey::SettingsEditorSoftWrapDesc => {
"Open files with soft wrap on. The status bar's Wrap button changes it for one file."
}
L10nKey::SettingsEditorMarkdownPreview => "Open Markdown rendered",
L10nKey::SettingsEditorMarkdownPreviewDesc => {
"Show Markdown files as a rendered preview rather than as source when they open."
}
L10nKey::SettingsSearchEditorGitGutterKeywords => {
"git gutter diff changes markers staged index scm vcs modified added deleted"
}
L10nKey::SettingsSearchEditorLspKeywords => {
"lsp language server diagnostics errors warnings completion rust-analyzer definition"
}
L10nKey::SettingsSearchEditorSoftWrapKeywords => "wrap soft wrap long lines editor",
L10nKey::SettingsSearchEditorMarkdownPreviewKeywords => {
"markdown preview rendered md readme"
}
L10nKey::EditorGoToLinePlaceholder => "Line, or line:column (1–{total})",
L10nKey::EditorGoToSymbolAction => "Go to Symbol in Editor…",
L10nKey::EditorNavigateBack => "Go Back",
L10nKey::EditorNavigateForward => "Go Forward",
L10nKey::EditorSplitRight => "Split Editor Right",
L10nKey::EditorFocusLeftGroup => "Focus Left Editor Group",
L10nKey::EditorFocusRightGroup => "Focus Right Editor Group",
L10nKey::EditorSplitSameFile => "Open in the other group — click to edit it here",
L10nKey::CmdEditorGoToSymbol => "Editor: Go to Symbol…",
L10nKey::SearchHeadingReferences => "References",
L10nKey::SearchSectionThisFile => "In This File",
L10nKey::SearchSectionProject => "Project",
L10nKey::SearchHeadingDefinitions => "Definitions",
L10nKey::CmdEditorGoBack => "Editor: Go Back",
L10nKey::CmdEditorGoForward => "Editor: Go Forward",
L10nKey::CmdEditorSplitRight => "Editor: Split Right",
L10nKey::EditorSaveAs => "Save as",
L10nKey::EditorSaveAsAction => "Save As…",
L10nKey::EditorSaveAsPlaceholder => "Full path to save to",
@@ -1240,6 +1315,21 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::EditorFileDeletedOnDisk => "This file was deleted on disk",
L10nKey::EditorIndentSpaces => "Spaces: {n}",
L10nKey::EditorIndentTabs => "Tab Size: {n}",
L10nKey::LspGoToDefinition => "Go to Definition",
L10nKey::LspQuickFix => "Quick Fix…",
L10nKey::LspFormatDocument => "Format Document",
L10nKey::LspRenameSymbol => "Rename symbol",
L10nKey::LspRenameSymbolAction => "Rename Symbol…",
L10nKey::LspRenamePlaceholder => "New name for {name}",
L10nKey::LspRenameFailed => "Could not rename {name}",
L10nKey::LspServerMissing => "{name} not installed",
L10nKey::LspServerStarting => "{name} starting…",
L10nKey::LspServerDown => "{name} stopped",
L10nKey::LspProblemsTooltip => "{errors} errors, {warnings} warnings",
L10nKey::SearchTabLocations => "Locations",
L10nKey::SearchPlaceholderLocations => "Filter the places found…",
L10nKey::SearchLocationsNone => "Nothing found",
L10nKey::LspFindReferences => "Find All References",
L10nKey::PanelInfoTitle => "Info",
L10nKey::PanelChangesTitle => "Changes",
L10nKey::PanelScmTitle => "Changes",
@@ -1709,6 +1799,12 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdDocumentWidthTwoThirds => "Document: Two-Thirds Width",
L10nKey::CmdToggleDocumentPreview => "Document: Toggle Markdown Preview",
L10nKey::CmdToggleDocumentWrap => "Document: Toggle Word Wrap",
L10nKey::CmdEditorTransformUppercase => "Document: Transform to Uppercase",
L10nKey::CmdEditorTransformLowercase => "Document: Transform to Lowercase",
L10nKey::CmdEditorTransformTitleCase => "Document: Transform to Title Case",
L10nKey::CmdEditorTrimTrailingWhitespace => "Document: Trim Trailing Whitespace",
L10nKey::CmdEditorJoinLines => "Document: Join Lines",
L10nKey::CmdEditorRemoveSurroundingBrackets => "Document: Remove Surrounding Brackets",
L10nKey::CmdGitCommit => "Git: Commit",
L10nKey::CmdGitStageAll => "Git: Stage All Changes",
L10nKey::CmdGitUnstageAll => "Git: Unstage All Changes",
@@ -1793,7 +1889,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
"config.json could not be read. tty7 kept the settings it is running on and left the file exactly as it is. Fix its permissions or contents and tty7 reloads; saving a setting first overwrites it."
}
L10nKey::AppWorktreeRemoveDetailDirty => {
"The closed tab's worktree at {path} has uncommitted changes."
"The closed tab's worktree at {path} has uncommitted changes. They are saved to refs/tty7/trash/{name} before anything is deleted."
}
L10nKey::AppWorktreeRemoveDetailClean => "The closed tab's worktree at {path} is clean.",
L10nKey::AppWorktreeRemoveTitle => "Remove worktree \"{branch}\"?",
@@ -1816,6 +1912,23 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::PaneDragHandleTooltip => "Drag to move this pane",
L10nKey::AppWorktreeRemoved => "Removed worktree \"{branch}\"",
L10nKey::AppWorktreeRemoveFailed => "Worktree removal failed: {error}",
L10nKey::WorktreePromptAgent => "Start",
L10nKey::WorktreePromptShell => "Shell",
L10nKey::WorktreePromptTask => "Task",
L10nKey::WorktreePromptSetup => "Runs .tty7/setup first",
L10nKey::WorktreePromptSetupHint => {
"No .tty7/setup. Add one to run `{command}` in new worktrees"
}
L10nKey::AppWorktreeSetupTitle => "Run this repo's setup script?",
L10nKey::AppWorktreeSetupDetail => {
"{path} runs in the new tab before anything else. Approve it only if you trust this repo; tty7 asks again whenever the script changes."
}
L10nKey::AppWorktreeSetupRun => "Run Setup",
L10nKey::AppWorktreeSetupSkip => "Skip",
L10nKey::AppWorktreeNotCarried => "Not copied from .worktreeinclude: {paths}",
L10nKey::AppWorktreeRemovedBranchKept => {
"Removed worktree; kept branch \"{branch}\", which has unmerged commits"
}
L10nKey::AppForkStillConnecting => "Could not fork: the pane is still connecting",
L10nKey::AppPaneNoCodingAgent => "This pane isn't running a coding agent",
L10nKey::AppForkNoCommand => "tty7 has no fork command for {name}",
@@ -2134,6 +2247,33 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::GitHubCommits => "{count} commits",
L10nKey::GitHubOpenedAt => "opened {when}",
L10nKey::GitHubUpdatedAt => "updated {when}",
L10nKey::GitHubChecks => "Checks",
L10nKey::GitHubChecksPassed => "{passed} of {total} passed",
L10nKey::GitHubChecksNoneCounted => "No checks with a result",
L10nKey::GitHubCheckPassed => "Passed",
L10nKey::GitHubCheckFailed => "Failed",
L10nKey::GitHubCheckPending => "In progress",
L10nKey::GitHubCheckSkipped => "Skipped",
L10nKey::GitHubReviews => "Reviews",
L10nKey::GitHubReviewApproved => "Approved",
L10nKey::GitHubReviewChangesRequested => "Changes requested",
L10nKey::GitHubReviewCommented => "Commented",
L10nKey::GitHubReviewRequested => "Requested",
L10nKey::GitHubReadyToMerge => "Ready to merge",
L10nKey::GitHubMergeConflicts => "Merge conflicts",
L10nKey::GitHubReviewRequired => "Review required",
L10nKey::GitHubBehindBase => "Behind the base branch",
L10nKey::GitHubMergeBlocked => "Blocked by branch protection",
L10nKey::GitHubThisBranch => "This branch",
L10nKey::GitHubChecksFailing => "{count} checks failing",
L10nKey::GitHubWaitingOnChecks => "Waiting on {count} checks",
L10nKey::GitHubShowLess => "Show less",
L10nKey::GitHubShowAllFiles => "Show all {count} files",
L10nKey::GitHubPassedCount => "{count} passed",
L10nKey::GitHubSkippedCount => "{count} skipped",
L10nKey::GitHubShowFullText => "Show full text",
L10nKey::GitHubShowHiddenComments => "Show {count} more comments",
L10nKey::GitHubShowAllReviewers => "Show all {count} reviewers",
}
}
@@ -2301,6 +2441,27 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat
(L10nKey::GitHubCommits, "zero") => "No commits",
(L10nKey::GitHubCommits, "one") => "1 commit",
(L10nKey::GitHubCommits, "other") => "{count} commits",
(L10nKey::GitHubChecksFailing, "zero") => "No checks failing",
(L10nKey::GitHubChecksFailing, "one") => "1 check failing",
(L10nKey::GitHubChecksFailing, "other") => "{count} checks failing",
(L10nKey::GitHubWaitingOnChecks, "zero") => "Not waiting on checks",
(L10nKey::GitHubWaitingOnChecks, "one") => "Waiting on 1 check",
(L10nKey::GitHubWaitingOnChecks, "other") => "Waiting on {count} checks",
(L10nKey::GitHubShowAllFiles, "zero") => "No files",
(L10nKey::GitHubShowAllFiles, "one") => "Show 1 file",
(L10nKey::GitHubShowAllFiles, "other") => "Show all {count} files",
(L10nKey::GitHubPassedCount, "zero") => "None passed",
(L10nKey::GitHubPassedCount, "one") => "1 passed",
(L10nKey::GitHubPassedCount, "other") => "{count} passed",
(L10nKey::GitHubSkippedCount, "zero") => "None skipped",
(L10nKey::GitHubSkippedCount, "one") => "1 skipped",
(L10nKey::GitHubSkippedCount, "other") => "{count} skipped",
(L10nKey::GitHubShowHiddenComments, "zero") => "No more comments",
(L10nKey::GitHubShowHiddenComments, "one") => "Show 1 more comment",
(L10nKey::GitHubShowHiddenComments, "other") => "Show {count} more comments",
(L10nKey::GitHubShowAllReviewers, "zero") => "No reviewers",
(L10nKey::GitHubShowAllReviewers, "one") => "Show 1 reviewer",
(L10nKey::GitHubShowAllReviewers, "other") => "Show all {count} reviewers",
_ => return None,
};
Some(res)
+164 -3
View File
@@ -159,10 +159,16 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SearchTabActions => "コマンド",
L10nKey::SearchTabTerminals => "ターミナル",
L10nKey::SearchTabHosts => "ホスト",
L10nKey::SearchTabSymbols => "シンボル",
L10nKey::SearchPlaceholderAll => "ファイル、アクション、ターミナル、ホストを検索…",
L10nKey::SearchPlaceholderActions => "コマンドを検索…",
L10nKey::SearchPlaceholderTerminals => "開いているタブ、シェル、エージェントを検索…",
L10nKey::SearchPlaceholderHosts => "ホストを検索、または user@host を入力して接続…",
L10nKey::SearchPlaceholderSymbols => "このファイル内のシンボルへ移動…",
L10nKey::SearchSymbolsNone => "このファイルにシンボルはありません",
L10nKey::SearchSymbolsNoneHint => {
"Rust、Go、Python、JavaScript、TypeScript、C、C++、Java、Ruby、シェル、Markdown のシンボルを一覧できます。"
}
L10nKey::SearchTabFiles => "ファイル",
L10nKey::SearchPlaceholderFiles => "名前でファイルに移動(:行番号 でその行へ)…",
L10nKey::SearchFilesNoRoots => "検索するプロジェクトがありません",
@@ -1169,8 +1175,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SftpTransferCancelled => "キャンセル済み",
L10nKey::SftpTransferError => "エラー",
L10nKey::SftpTransferListFailed => "転送状況を取得できませんでした: {error}",
L10nKey::SftpImagePasteUploadFailed => {
"貼り付けた画像を {host} にアップロードできませんでした: {error}"
L10nKey::SftpPasteUploadFailed => {
"{name} を {host} にアップロードできませんでした: {error}"
}
L10nKey::LinkFileOpenFailed => "{path} を開けませんでした: {error}",
L10nKey::ForwardDisconnected => "切断済み",
@@ -1258,8 +1264,16 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorUnsavedChanges => "「{name}」には保存されていない変更があります",
L10nKey::EditorDiscard => "破棄",
L10nKey::EditorNoFileOpen => "開かれているファイルはありません",
L10nKey::EditorStripSearch => "開いている {n} 個のファイルを検索",
L10nKey::EditorStripAllFiles => "開いているすべてのファイル",
L10nKey::EditorStripHidden => "非表示 · {n}",
L10nKey::EditorStripInBar => "タブバーに表示中",
L10nKey::EditorStripNoMatch => "一致する開いているファイルはありません",
L10nKey::EditorStripCloseSaved => "保存済みを閉じる",
L10nKey::EditorStripCloseOthers => "ほかを閉じる",
L10nKey::EditorBackToTerminal => "ターミナルに戻る (Esc)",
L10nKey::EditorLnCol => "行 {line}, 列 {column}",
L10nKey::EditorSelections => "({n} 個の選択範囲)",
L10nKey::EditorEdit => "編集",
L10nKey::EditorPreview => "プレビュー",
L10nKey::EditorWrapOn => "折り返し: オン",
@@ -1282,8 +1296,69 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorAlreadyOpen => "「{path}」はすでにエディタで開かれています",
L10nKey::EditorGoToLine => "行へ移動",
L10nKey::EditorGoToLineAction => "行へ移動…",
L10nKey::EditorGoToMatchingBracket => "対応する括弧へ移動",
L10nKey::EditorToggleComment => "コメントの切り替え",
L10nKey::EditorMoveLineUp => "行を上へ移動",
L10nKey::EditorMoveLineDown => "行を下へ移動",
L10nKey::EditorDuplicateLine => "行を複製",
L10nKey::EditorDeleteLine => "行を削除",
L10nKey::EditorCopyRelativePath => "相対パスをコピー",
L10nKey::EditorGitNextChange => "次の変更へ移動",
L10nKey::EditorGitPrevChange => "前の変更へ移動",
L10nKey::EditorGitRevertChange => "変更を元に戻す",
L10nKey::EditorGitToggleGutter => "Git 変更マーカーの切り替え",
L10nKey::EditorGitPeekRevert => "元に戻す",
L10nKey::EditorGitPeekSummary => "ステージ済みの版と比べて −{removed} +{added} 行",
L10nKey::EditorGitPeekAddedOnly => {
"新しく追加された行です。ステージ済みの版にはここに何もありません。"
}
L10nKey::EditorGitPeekChange => "変更をプレビュー",
L10nKey::EditorGitPeekKeys => "Enter で元に戻す · Esc で閉じる",
L10nKey::EditorProblemsTitle => "問題",
L10nKey::EditorProblemsToggle => "問題の表示切り替え",
L10nKey::EditorProblemsNone => "開いているファイルに問題はありません。",
L10nKey::EditorProblemsMore => "…ほか {n} 件",
L10nKey::SettingsEditor => "エディタ",
L10nKey::SettingsEditorGitGutter => "Git 変更マーカー",
L10nKey::SettingsEditorGitGutterDesc => {
"ステージ済みの版と異なる行を、行番号の横とスクロールバーに示します。"
}
L10nKey::SettingsEditorLsp => "言語サーバー",
L10nKey::SettingsEditorLspDesc => {
"対応するファイルで言語サーバーを起動し、エラー表示・補完・定義へ移動を使えるようにします。このマシン上のファイルのみ。"
}
L10nKey::SettingsEditorSoftWrap => "長い行を折り返す",
L10nKey::SettingsEditorSoftWrapDesc => {
"ファイルを折り返し表示で開きます。ステータスバーの折り返しボタンでファイルごとに切り替えられます。"
}
L10nKey::SettingsEditorMarkdownPreview => "Markdown をレンダリングして開く",
L10nKey::SettingsEditorMarkdownPreviewDesc => {
"Markdown ファイルを開いたとき、ソースではなくレンダリングしたプレビューを表示します。"
}
L10nKey::SettingsSearchEditorGitGutterKeywords => {
"git 変更 マーカー 差分 ステージ 変更 追加 削除 gutter diff"
}
L10nKey::SettingsSearchEditorLspKeywords => "lsp 言語サーバー 診断 エラー 警告 補完 定義",
L10nKey::SettingsSearchEditorSoftWrapKeywords => "折り返し 長い行 wrap",
L10nKey::SettingsSearchEditorMarkdownPreviewKeywords => {
"markdown プレビュー レンダリング md"
}
L10nKey::EditorGoToLinePlaceholder => "行番号、または 行:列(1–{total})",
L10nKey::EditorGoToSymbolAction => "エディタ内のシンボルへ移動…",
L10nKey::EditorNavigateBack => "戻る",
L10nKey::EditorNavigateForward => "進む",
L10nKey::EditorSplitRight => "エディタを右に分割",
L10nKey::EditorFocusLeftGroup => "左のエディタグループにフォーカス",
L10nKey::EditorFocusRightGroup => "右のエディタグループにフォーカス",
L10nKey::EditorSplitSameFile => "もう一方のグループで開いています — クリックしてここで編集",
L10nKey::CmdEditorGoToSymbol => "エディタ: シンボルへ移動…",
L10nKey::SearchHeadingReferences => "参照",
L10nKey::SearchSectionThisFile => "このファイル",
L10nKey::SearchSectionProject => "プロジェクト",
L10nKey::SearchHeadingDefinitions => "定義",
L10nKey::CmdEditorGoBack => "エディタ: 戻る",
L10nKey::CmdEditorGoForward => "エディタ: 進む",
L10nKey::CmdEditorSplitRight => "エディタ: 右に分割",
L10nKey::EditorSaveAs => "名前を付けて保存",
L10nKey::EditorSaveAsAction => "名前を付けて保存…",
L10nKey::EditorSaveAsPlaceholder => "保存先のフルパス",
@@ -1294,6 +1369,21 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorFileDeletedOnDisk => "このファイルはディスク上で削除されました",
L10nKey::EditorIndentSpaces => "スペース: {n}",
L10nKey::EditorIndentTabs => "タブ幅: {n}",
L10nKey::LspGoToDefinition => "定義へ移動",
L10nKey::LspQuickFix => "クイックフィックス…",
L10nKey::LspFormatDocument => "ドキュメントのフォーマット",
L10nKey::LspRenameSymbol => "シンボル名を変更",
L10nKey::LspRenameSymbolAction => "シンボル名を変更…",
L10nKey::LspRenamePlaceholder => "{name} の新しい名前",
L10nKey::LspRenameFailed => "{name} の名前を変更できませんでした",
L10nKey::LspServerMissing => "{name} 未インストール",
L10nKey::LspServerStarting => "{name} 起動中…",
L10nKey::LspServerDown => "{name} 停止",
L10nKey::LspProblemsTooltip => "エラー {errors} 件、警告 {warnings} 件",
L10nKey::SearchTabLocations => "場所",
L10nKey::SearchPlaceholderLocations => "見つかった場所を絞り込む…",
L10nKey::SearchLocationsNone => "見つかりませんでした",
L10nKey::LspFindReferences => "すべての参照を検索",
L10nKey::PanelInfoTitle => "情報",
L10nKey::PanelChangesTitle => "変更",
L10nKey::PanelScmTitle => "変更",
@@ -1760,6 +1850,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdDocumentWidthTwoThirds => "ドキュメント: 幅3分の2",
L10nKey::CmdToggleDocumentPreview => "ドキュメント: Markdown プレビューを切り替え",
L10nKey::CmdToggleDocumentWrap => "ドキュメント: 折り返しを切り替え",
L10nKey::CmdEditorTransformUppercase => "ドキュメント: 大文字に変換",
L10nKey::CmdEditorTransformLowercase => "ドキュメント: 小文字に変換",
L10nKey::CmdEditorTransformTitleCase => "ドキュメント: 単語の先頭を大文字に変換",
L10nKey::CmdEditorTrimTrailingWhitespace => "ドキュメント: 末尾の空白を削除",
L10nKey::CmdEditorJoinLines => "ドキュメント: 行を結合",
L10nKey::CmdEditorRemoveSurroundingBrackets => "ドキュメント: 外側の括弧を削除",
L10nKey::CmdGitCommit => "Git: コミット",
L10nKey::CmdGitStageAll => "Git: すべての変更をステージ",
L10nKey::CmdGitUnstageAll => "Git: すべてのステージを取り消す",
@@ -1842,7 +1938,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
"config.json を読み込めませんでした。実行中の設定を保持し、ファイルもそのままです。権限か内容を直せば自動で再読み込みされます。それまでに設定を保存すると上書きされます"
}
L10nKey::AppWorktreeRemoveDetailDirty => {
"閉じたタブの {path} にあるワークツリーには未コミットの変更があります"
"閉じたタブの {path} にあるワークツリーには未コミットの変更があります。削除前に refs/tty7/trash/{name} へ保存します"
}
L10nKey::AppWorktreeRemoveDetailClean => {
"閉じたタブの {path} にあるワークツリーはクリーンです"
@@ -1867,6 +1963,23 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::PaneDragHandleTooltip => "ドラッグしてこのペインを移動",
L10nKey::AppWorktreeRemoved => "ワークツリー「{branch}」を削除しました",
L10nKey::AppWorktreeRemoveFailed => "ワークツリーの削除に失敗しました: {error}",
L10nKey::WorktreePromptAgent => "起動",
L10nKey::WorktreePromptShell => "シェル",
L10nKey::WorktreePromptTask => "タスク",
L10nKey::WorktreePromptSetup => "最初に .tty7/setup を実行",
L10nKey::WorktreePromptSetupHint => {
".tty7/setup がありません。追加すると新しいワークツリーで `{command}` を実行します"
}
L10nKey::AppWorktreeSetupTitle => "このリポジトリのセットアップスクリプトを実行しますか?",
L10nKey::AppWorktreeSetupDetail => {
"{path} は新しいタブで最初に実行されます。このリポジトリを信頼できる場合のみ許可してください。スクリプトが変わるたびに再確認します"
}
L10nKey::AppWorktreeSetupRun => "実行",
L10nKey::AppWorktreeSetupSkip => "スキップ",
L10nKey::AppWorktreeNotCarried => ".worktreeinclude から複製されませんでした: {paths}",
L10nKey::AppWorktreeRemovedBranchKept => {
"ワークツリーを削除しました。ブランチ「{branch}」には未マージのコミットがあるため残しました"
}
L10nKey::AppForkStillConnecting => "フォークできませんでした: ペインはまだ接続中です",
L10nKey::AppPaneNoCodingAgent => "このペインはコーディングエージェントを実行していません",
L10nKey::AppForkNoCommand => "tty7 には {name} 用のフォークコマンドがありません",
@@ -2197,6 +2310,33 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::GitHubCommits => "{count} 件のコミット",
L10nKey::GitHubOpenedAt => "作成 {when}",
L10nKey::GitHubUpdatedAt => "更新 {when}",
L10nKey::GitHubChecks => "チェック",
L10nKey::GitHubChecksPassed => "{total} 件中 {passed} 件成功",
L10nKey::GitHubChecksNoneCounted => "結果のあるチェックはありません",
L10nKey::GitHubCheckPassed => "成功",
L10nKey::GitHubCheckFailed => "失敗",
L10nKey::GitHubCheckPending => "実行中",
L10nKey::GitHubCheckSkipped => "スキップ",
L10nKey::GitHubReviews => "レビュー",
L10nKey::GitHubReviewApproved => "承認済み",
L10nKey::GitHubReviewChangesRequested => "変更をリクエスト",
L10nKey::GitHubReviewCommented => "コメント済み",
L10nKey::GitHubReviewRequested => "リクエスト中",
L10nKey::GitHubReadyToMerge => "マージ可能",
L10nKey::GitHubMergeConflicts => "マージコンフリクトあり",
L10nKey::GitHubReviewRequired => "レビューが必要",
L10nKey::GitHubBehindBase => "ベースブランチより遅れています",
L10nKey::GitHubMergeBlocked => "ブランチ保護によりブロック",
L10nKey::GitHubThisBranch => "このブランチ",
L10nKey::GitHubChecksFailing => "{count} 件のチェックが失敗",
L10nKey::GitHubWaitingOnChecks => "{count} 件のチェックを待機中",
L10nKey::GitHubShowLess => "折りたたむ",
L10nKey::GitHubShowAllFiles => "{count} 件のファイルをすべて表示",
L10nKey::GitHubPassedCount => "{count} 件成功",
L10nKey::GitHubSkippedCount => "{count} 件スキップ",
L10nKey::GitHubShowFullText => "全文を表示",
L10nKey::GitHubShowHiddenComments => "ほか {count} 件のコメントを表示",
L10nKey::GitHubShowAllReviewers => "{count} 人のレビュアーをすべて表示",
})
}
@@ -2357,6 +2497,27 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat
(L10nKey::GitHubCommits, "zero") => "コミットはありません",
(L10nKey::GitHubCommits, "one") => "1 件のコミット",
(L10nKey::GitHubCommits, "other") => "{count} 件のコミット",
(L10nKey::GitHubChecksFailing, "zero") => "失敗しているチェックはありません",
(L10nKey::GitHubChecksFailing, "one") => "1 件のチェックが失敗",
(L10nKey::GitHubChecksFailing, "other") => "{count} 件のチェックが失敗",
(L10nKey::GitHubWaitingOnChecks, "zero") => "待機中のチェックはありません",
(L10nKey::GitHubWaitingOnChecks, "one") => "1 件のチェックを待機中",
(L10nKey::GitHubWaitingOnChecks, "other") => "{count} 件のチェックを待機中",
(L10nKey::GitHubShowAllFiles, "zero") => "ファイルなし",
(L10nKey::GitHubShowAllFiles, "one") => "1 件のファイルを表示",
(L10nKey::GitHubShowAllFiles, "other") => "{count} 件のファイルをすべて表示",
(L10nKey::GitHubPassedCount, "zero") => "成功なし",
(L10nKey::GitHubPassedCount, "one") => "1 件成功",
(L10nKey::GitHubPassedCount, "other") => "{count} 件成功",
(L10nKey::GitHubSkippedCount, "zero") => "スキップなし",
(L10nKey::GitHubSkippedCount, "one") => "1 件スキップ",
(L10nKey::GitHubSkippedCount, "other") => "{count} 件スキップ",
(L10nKey::GitHubShowHiddenComments, "zero") => "ほかのコメントはありません",
(L10nKey::GitHubShowHiddenComments, "one") => "ほか 1 件のコメントを表示",
(L10nKey::GitHubShowHiddenComments, "other") => "ほか {count} 件のコメントを表示",
(L10nKey::GitHubShowAllReviewers, "zero") => "レビュアーなし",
(L10nKey::GitHubShowAllReviewers, "one") => "1 人のレビュアーを表示",
(L10nKey::GitHubShowAllReviewers, "other") => "{count} 人のレビュアーをすべて表示",
_ => return None,
};
Some(res)
+126 -1
View File
@@ -193,10 +193,14 @@ l10n_keys! {
SearchTabActions,
SearchTabTerminals,
SearchTabHosts,
SearchTabSymbols,
SearchPlaceholderAll,
SearchPlaceholderActions,
SearchPlaceholderTerminals,
SearchPlaceholderHosts,
SearchPlaceholderSymbols,
SearchSymbolsNone,
SearchSymbolsNoneHint,
SearchTabFiles,
SearchPlaceholderFiles,
SearchFilesNoRoots,
@@ -861,7 +865,7 @@ l10n_keys! {
SftpTransferCancelled,
SftpTransferError,
SftpTransferListFailed,
SftpImagePasteUploadFailed,
SftpPasteUploadFailed,
LinkFileOpenFailed,
ForwardDisconnected,
ForwardDisconnectedFrom,
@@ -934,8 +938,16 @@ l10n_keys! {
EditorUnsavedChanges,
EditorDiscard,
EditorNoFileOpen,
EditorStripSearch,
EditorStripAllFiles,
EditorStripHidden,
EditorStripInBar,
EditorStripNoMatch,
EditorStripCloseSaved,
EditorStripCloseOthers,
EditorBackToTerminal,
EditorLnCol,
EditorSelections,
EditorEdit,
EditorPreview,
EditorWrapOn,
@@ -954,8 +966,55 @@ l10n_keys! {
EditorAlreadyOpen,
EditorGoToLine,
EditorGoToLineAction,
EditorGoToMatchingBracket,
EditorToggleComment,
EditorMoveLineUp,
EditorMoveLineDown,
EditorDuplicateLine,
EditorDeleteLine,
EditorCopyRelativePath,
EditorGitNextChange,
EditorGitPrevChange,
EditorGitRevertChange,
EditorGitToggleGutter,
EditorGitPeekRevert,
EditorGitPeekSummary,
EditorGitPeekAddedOnly,
EditorGitPeekChange,
EditorGitPeekKeys,
EditorProblemsTitle,
EditorProblemsToggle,
EditorProblemsNone,
EditorProblemsMore,
SettingsEditor,
SettingsEditorGitGutter,
SettingsEditorGitGutterDesc,
SettingsEditorLsp,
SettingsEditorLspDesc,
SettingsEditorSoftWrap,
SettingsEditorSoftWrapDesc,
SettingsEditorMarkdownPreview,
SettingsEditorMarkdownPreviewDesc,
SettingsSearchEditorGitGutterKeywords,
SettingsSearchEditorLspKeywords,
SettingsSearchEditorSoftWrapKeywords,
SettingsSearchEditorMarkdownPreviewKeywords,
EditorGoToLinePlaceholder,
EditorGoToSymbolAction,
EditorNavigateBack,
EditorNavigateForward,
EditorSplitRight,
EditorFocusLeftGroup,
EditorFocusRightGroup,
EditorSplitSameFile,
CmdEditorGoToSymbol,
SearchHeadingReferences,
SearchSectionThisFile,
SearchSectionProject,
SearchHeadingDefinitions,
CmdEditorGoBack,
CmdEditorGoForward,
CmdEditorSplitRight,
EditorSaveAs,
EditorSaveAsAction,
EditorSaveAsPlaceholder,
@@ -966,6 +1025,21 @@ l10n_keys! {
EditorFileDeletedOnDisk,
EditorIndentSpaces,
EditorIndentTabs,
LspGoToDefinition,
LspQuickFix,
LspFormatDocument,
LspRenameSymbol,
LspRenameSymbolAction,
LspRenamePlaceholder,
LspRenameFailed,
LspServerMissing,
LspServerStarting,
LspServerDown,
LspProblemsTooltip,
SearchTabLocations,
SearchPlaceholderLocations,
SearchLocationsNone,
LspFindReferences,
PanelInfoTitle,
PanelChangesTitle,
PanelScmTitle,
@@ -1451,6 +1525,12 @@ l10n_keys! {
CmdDocumentWidthTwoThirds,
CmdToggleDocumentPreview,
CmdToggleDocumentWrap,
CmdEditorTransformUppercase,
CmdEditorTransformLowercase,
CmdEditorTransformTitleCase,
CmdEditorTrimTrailingWhitespace,
CmdEditorJoinLines,
CmdEditorRemoveSurroundingBrackets,
CmdGitCommit,
CmdGitStageAll,
CmdGitUnstageAll,
@@ -1530,6 +1610,17 @@ l10n_keys! {
PaneDragHandleTooltip,
AppWorktreeRemoved,
AppWorktreeRemoveFailed,
WorktreePromptAgent,
WorktreePromptShell,
WorktreePromptTask,
WorktreePromptSetup,
WorktreePromptSetupHint,
AppWorktreeSetupTitle,
AppWorktreeSetupDetail,
AppWorktreeSetupRun,
AppWorktreeSetupSkip,
AppWorktreeNotCarried,
AppWorktreeRemovedBranchKept,
AppForkStillConnecting,
AppPaneNoCodingAgent,
AppForkNoCommand,
@@ -1679,6 +1770,33 @@ l10n_keys! {
GitHubCommits,
GitHubOpenedAt,
GitHubUpdatedAt,
GitHubChecks,
GitHubChecksPassed,
GitHubChecksNoneCounted,
GitHubCheckPassed,
GitHubCheckFailed,
GitHubCheckPending,
GitHubCheckSkipped,
GitHubReviews,
GitHubReviewApproved,
GitHubReviewChangesRequested,
GitHubReviewCommented,
GitHubReviewRequested,
GitHubReadyToMerge,
GitHubMergeConflicts,
GitHubReviewRequired,
GitHubBehindBase,
GitHubMergeBlocked,
GitHubThisBranch,
GitHubChecksFailing,
GitHubWaitingOnChecks,
GitHubShowLess,
GitHubShowAllFiles,
GitHubPassedCount,
GitHubSkippedCount,
GitHubShowFullText,
GitHubShowHiddenComments,
GitHubShowAllReviewers,
}
/// The source control strings that are translated but not yet displayed.
@@ -1995,6 +2113,13 @@ mod tests {
L10nKey::HomeTimeMonthsAgo,
L10nKey::GitHubComments,
L10nKey::GitHubCommits,
L10nKey::GitHubChecksFailing,
L10nKey::GitHubWaitingOnChecks,
L10nKey::GitHubShowAllFiles,
L10nKey::GitHubPassedCount,
L10nKey::GitHubSkippedCount,
L10nKey::GitHubShowHiddenComments,
L10nKey::GitHubShowAllReviewers,
];
for key in plural_keys {
for branch in ["zero", "one", "other"] {
+157 -2
View File
@@ -135,10 +135,16 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SearchTabActions => "命令",
L10nKey::SearchTabTerminals => "终端",
L10nKey::SearchTabHosts => "主机",
L10nKey::SearchTabSymbols => "符号",
L10nKey::SearchPlaceholderAll => "搜索文件、操作、终端和主机…",
L10nKey::SearchPlaceholderActions => "搜索命令…",
L10nKey::SearchPlaceholderTerminals => "搜索已打开的标签页、Shell 和 Agent…",
L10nKey::SearchPlaceholderHosts => "搜索主机,或输入 user@host 连接…",
L10nKey::SearchPlaceholderSymbols => "跳转到本文件中的符号…",
L10nKey::SearchSymbolsNone => "此文件中没有符号",
L10nKey::SearchSymbolsNoneHint => {
"支持 Rust、Go、Python、JavaScript、TypeScript、C、C++、Java、Ruby、Shell 和 Markdown 的符号。"
}
L10nKey::SearchTabFiles => "文件",
L10nKey::SearchPlaceholderFiles => "按名称转到文件,加上 :行号 可直接跳转…",
L10nKey::SearchFilesNoRoots => "没有可搜索的项目",
@@ -1045,7 +1051,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SftpTransferCancelled => "已取消",
L10nKey::SftpTransferError => "错误",
L10nKey::SftpTransferListFailed => "无法获取传输状态:{error}",
L10nKey::SftpImagePasteUploadFailed => "无法将粘贴的图片上传到 {host}:{error}",
L10nKey::SftpPasteUploadFailed => "无法将 {name} 上传到 {host}:{error}",
L10nKey::LinkFileOpenFailed => "无法打开 {path}:{error}",
L10nKey::ForwardDisconnected => "已断开",
L10nKey::ForwardDisconnectedFrom => "与 {host} 的连接已断开",
@@ -1122,8 +1128,16 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorUnsavedChanges => "“{name}”有未保存的更改",
L10nKey::EditorDiscard => "放弃",
L10nKey::EditorNoFileOpen => "没有打开的文件",
L10nKey::EditorStripSearch => "搜索 {n} 个打开的文件",
L10nKey::EditorStripAllFiles => "所有打开的文件",
L10nKey::EditorStripHidden => "已收起 · {n}",
L10nKey::EditorStripInBar => "在标签栏中",
L10nKey::EditorStripNoMatch => "没有匹配的打开文件",
L10nKey::EditorStripCloseSaved => "关闭已保存",
L10nKey::EditorStripCloseOthers => "关闭其他",
L10nKey::EditorBackToTerminal => "返回终端 (Esc)",
L10nKey::EditorLnCol => "行 {line},列 {column}",
L10nKey::EditorSelections => "({n} 个选区)",
L10nKey::EditorEdit => "编辑",
L10nKey::EditorPreview => "预览",
L10nKey::EditorWrapOn => "自动换行:开",
@@ -1146,8 +1160,63 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorAlreadyOpen => "“{path}”已在编辑器中打开",
L10nKey::EditorGoToLine => "跳转到行",
L10nKey::EditorGoToLineAction => "跳转到行…",
L10nKey::EditorGoToMatchingBracket => "跳转到匹配的括号",
L10nKey::EditorToggleComment => "切换注释",
L10nKey::EditorMoveLineUp => "上移行",
L10nKey::EditorMoveLineDown => "下移行",
L10nKey::EditorDuplicateLine => "复制行",
L10nKey::EditorDeleteLine => "删除行",
L10nKey::EditorCopyRelativePath => "复制相对路径",
L10nKey::EditorGitNextChange => "转到下一处更改",
L10nKey::EditorGitPrevChange => "转到上一处更改",
L10nKey::EditorGitRevertChange => "还原此处更改",
L10nKey::EditorGitToggleGutter => "切换 Git 更改标记",
L10nKey::EditorGitPeekRevert => "还原",
L10nKey::EditorGitPeekSummary => "与暂存版本相比 −{removed} +{added} 行",
L10nKey::EditorGitPeekAddedOnly => "这些是新增的行:暂存版本中此处没有内容。",
L10nKey::EditorGitPeekChange => "查看更改",
L10nKey::EditorGitPeekKeys => "回车还原 · Esc 关闭",
L10nKey::EditorProblemsTitle => "问题",
L10nKey::EditorProblemsToggle => "切换问题列表",
L10nKey::EditorProblemsNone => "打开的文件中没有问题。",
L10nKey::EditorProblemsMore => "……另有 {n} 项",
L10nKey::SettingsEditor => "编辑器",
L10nKey::SettingsEditorGitGutter => "Git 更改标记",
L10nKey::SettingsEditorGitGutterDesc => "在行号旁和滚动条上标出与暂存版本不同的行。",
L10nKey::SettingsEditorLsp => "语言服务器",
L10nKey::SettingsEditorLspDesc => {
"为支持的文件启动语言服务器,提供错误提示、补全和跳转到定义。仅限本机文件。"
}
L10nKey::SettingsEditorSoftWrap => "自动换行",
L10nKey::SettingsEditorSoftWrapDesc => {
"打开文件时启用自动换行。状态栏的换行按钮可单独切换某个文件。"
}
L10nKey::SettingsEditorMarkdownPreview => "以渲染形式打开 Markdown",
L10nKey::SettingsEditorMarkdownPreviewDesc => {
"打开 Markdown 文件时显示渲染后的预览而不是源码。"
}
L10nKey::SettingsSearchEditorGitGutterKeywords => {
"git 更改 标记 差异 暂存 修改 新增 删除 gutter diff"
}
L10nKey::SettingsSearchEditorLspKeywords => "lsp 语言服务器 诊断 错误 警告 补全 定义",
L10nKey::SettingsSearchEditorSoftWrapKeywords => "换行 自动换行 长行 wrap",
L10nKey::SettingsSearchEditorMarkdownPreviewKeywords => "markdown 预览 渲染 md",
L10nKey::EditorGoToLinePlaceholder => "行号,或 行:列(1–{total})",
L10nKey::EditorGoToSymbolAction => "跳转到编辑器中的符号…",
L10nKey::EditorNavigateBack => "后退",
L10nKey::EditorNavigateForward => "前进",
L10nKey::EditorSplitRight => "向右拆分编辑器",
L10nKey::EditorFocusLeftGroup => "聚焦左侧编辑器组",
L10nKey::EditorFocusRightGroup => "聚焦右侧编辑器组",
L10nKey::EditorSplitSameFile => "已在另一组中打开 — 点击在此编辑",
L10nKey::CmdEditorGoToSymbol => "编辑器:跳转到符号…",
L10nKey::SearchHeadingReferences => "引用",
L10nKey::SearchSectionThisFile => "本文件",
L10nKey::SearchSectionProject => "项目",
L10nKey::SearchHeadingDefinitions => "定义",
L10nKey::CmdEditorGoBack => "编辑器:后退",
L10nKey::CmdEditorGoForward => "编辑器:前进",
L10nKey::CmdEditorSplitRight => "编辑器:向右拆分",
L10nKey::EditorSaveAs => "另存为",
L10nKey::EditorSaveAsAction => "另存为…",
L10nKey::EditorSaveAsPlaceholder => "要保存到的完整路径",
@@ -1158,6 +1227,21 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorFileDeletedOnDisk => "此文件已在磁盘上被删除",
L10nKey::EditorIndentSpaces => "空格:{n}",
L10nKey::EditorIndentTabs => "Tab 宽度:{n}",
L10nKey::LspGoToDefinition => "转到定义",
L10nKey::LspQuickFix => "快速修复…",
L10nKey::LspFormatDocument => "格式化文档",
L10nKey::LspRenameSymbol => "重命名符号",
L10nKey::LspRenameSymbolAction => "重命名符号…",
L10nKey::LspRenamePlaceholder => "{name} 的新名称",
L10nKey::LspRenameFailed => "无法重命名 {name}",
L10nKey::LspServerMissing => "未安装 {name}",
L10nKey::LspServerStarting => "{name} 启动中…",
L10nKey::LspServerDown => "{name} 已停止",
L10nKey::LspProblemsTooltip => "{errors} 个错误,{warnings} 个警告",
L10nKey::SearchTabLocations => "位置",
L10nKey::SearchPlaceholderLocations => "筛选找到的位置…",
L10nKey::SearchLocationsNone => "未找到任何位置",
L10nKey::LspFindReferences => "查找所有引用",
L10nKey::PanelInfoTitle => "信息",
L10nKey::PanelChangesTitle => "更改",
L10nKey::PanelScmTitle => "更改",
@@ -1585,6 +1669,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdDocumentWidthTwoThirds => "文档:三分之二宽",
L10nKey::CmdToggleDocumentPreview => "文档:切换 Markdown 预览",
L10nKey::CmdToggleDocumentWrap => "文档:切换自动换行",
L10nKey::CmdEditorTransformUppercase => "文档:转换为大写",
L10nKey::CmdEditorTransformLowercase => "文档:转换为小写",
L10nKey::CmdEditorTransformTitleCase => "文档:转换为词首字母大写",
L10nKey::CmdEditorTrimTrailingWhitespace => "文档:删除行尾空白",
L10nKey::CmdEditorJoinLines => "文档:合并行",
L10nKey::CmdEditorRemoveSurroundingBrackets => "文档:删除外层括号",
L10nKey::CmdGitCommit => "Git:提交",
L10nKey::CmdGitStageAll => "Git:暂存全部更改",
L10nKey::CmdGitUnstageAll => "Git:取消暂存全部更改",
@@ -1666,7 +1756,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
"config.json 读取失败。tty7 保留了当前在用的设置,文件也原样保留。修好权限或内容后会自动重载;在此之前保存设置会覆盖它。"
}
L10nKey::AppWorktreeRemoveDetailDirty => {
"位于 {path} 的已关闭标签页的 worktree 有未提交的变更。"
"位于 {path} 的已关闭标签页的 worktree 有未提交的变更。删除前会先存到 refs/tty7/trash/{name}。"
}
L10nKey::AppWorktreeRemoveDetailClean => "位于 {path} 的已关闭标签页的 worktree 是干净的。",
L10nKey::AppWorktreeRemoveTitle => "删除 worktree“{branch}”?",
@@ -1687,6 +1777,23 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::PaneDragHandleTooltip => "拖动可把这个窗格挪到别处",
L10nKey::AppWorktreeRemoved => "已删除 worktree“{branch}”",
L10nKey::AppWorktreeRemoveFailed => "删除 worktree 失败:{error}",
L10nKey::WorktreePromptAgent => "启动",
L10nKey::WorktreePromptShell => "终端",
L10nKey::WorktreePromptTask => "任务",
L10nKey::WorktreePromptSetup => "先运行 .tty7/setup",
L10nKey::WorktreePromptSetupHint => {
"没有 .tty7/setup。加一个,新 worktree 就会先运行 `{command}`"
}
L10nKey::AppWorktreeSetupTitle => "运行这个仓库的 setup 脚本?",
L10nKey::AppWorktreeSetupDetail => {
"{path} 会在新标签页里最先运行。只在信任这个仓库时批准;脚本内容一变,tty7 会再问一次。"
}
L10nKey::AppWorktreeSetupRun => "运行",
L10nKey::AppWorktreeSetupSkip => "跳过",
L10nKey::AppWorktreeNotCarried => "未按 .worktreeinclude 复制:{paths}",
L10nKey::AppWorktreeRemovedBranchKept => {
"已删除 worktree;分支“{branch}”有未合并的提交,已保留"
}
L10nKey::AppForkStillConnecting => "无法 fork:窗格仍在连接中",
L10nKey::AppPaneNoCodingAgent => "此窗格未运行编码 agent",
L10nKey::AppForkNoCommand => "tty7 没有用于 {name} 的 fork 命令",
@@ -1989,6 +2096,33 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::GitHubCommits => "{count} 个提交",
L10nKey::GitHubOpenedAt => "创建 {when}",
L10nKey::GitHubUpdatedAt => "更新 {when}",
L10nKey::GitHubChecks => "检查",
L10nKey::GitHubChecksPassed => "{total} 项通过 {passed} 项",
L10nKey::GitHubChecksNoneCounted => "没有得出结果的检查",
L10nKey::GitHubCheckPassed => "通过",
L10nKey::GitHubCheckFailed => "失败",
L10nKey::GitHubCheckPending => "进行中",
L10nKey::GitHubCheckSkipped => "已跳过",
L10nKey::GitHubReviews => "审查",
L10nKey::GitHubReviewApproved => "已批准",
L10nKey::GitHubReviewChangesRequested => "要求修改",
L10nKey::GitHubReviewCommented => "已评论",
L10nKey::GitHubReviewRequested => "待审查",
L10nKey::GitHubReadyToMerge => "可以合并",
L10nKey::GitHubMergeConflicts => "有合并冲突",
L10nKey::GitHubReviewRequired => "需要审查",
L10nKey::GitHubBehindBase => "落后于目标分支",
L10nKey::GitHubMergeBlocked => "被分支保护规则阻止",
L10nKey::GitHubThisBranch => "当前分支",
L10nKey::GitHubChecksFailing => "{count} 项检查失败",
L10nKey::GitHubWaitingOnChecks => "等待 {count} 项检查",
L10nKey::GitHubShowLess => "收起",
L10nKey::GitHubShowAllFiles => "显示全部 {count} 个文件",
L10nKey::GitHubPassedCount => "{count} 项通过",
L10nKey::GitHubSkippedCount => "{count} 项跳过",
L10nKey::GitHubShowFullText => "展开全文",
L10nKey::GitHubShowHiddenComments => "显示另外 {count} 条评论",
L10nKey::GitHubShowAllReviewers => "显示全部 {count} 位审查人",
})
}
@@ -2125,6 +2259,27 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat
(L10nKey::GitHubCommits, "zero") => "没有提交",
(L10nKey::GitHubCommits, "one") => "1 个提交",
(L10nKey::GitHubCommits, "other") => "{count} 个提交",
(L10nKey::GitHubChecksFailing, "zero") => "没有失败的检查",
(L10nKey::GitHubChecksFailing, "one") => "1 项检查失败",
(L10nKey::GitHubChecksFailing, "other") => "{count} 项检查失败",
(L10nKey::GitHubWaitingOnChecks, "zero") => "无需等待检查",
(L10nKey::GitHubWaitingOnChecks, "one") => "等待 1 项检查",
(L10nKey::GitHubWaitingOnChecks, "other") => "等待 {count} 项检查",
(L10nKey::GitHubShowAllFiles, "zero") => "没有文件",
(L10nKey::GitHubShowAllFiles, "one") => "显示 1 个文件",
(L10nKey::GitHubShowAllFiles, "other") => "显示全部 {count} 个文件",
(L10nKey::GitHubPassedCount, "zero") => "没有通过",
(L10nKey::GitHubPassedCount, "one") => "1 项通过",
(L10nKey::GitHubPassedCount, "other") => "{count} 项通过",
(L10nKey::GitHubSkippedCount, "zero") => "没有跳过",
(L10nKey::GitHubSkippedCount, "one") => "1 项跳过",
(L10nKey::GitHubSkippedCount, "other") => "{count} 项跳过",
(L10nKey::GitHubShowHiddenComments, "zero") => "没有更多评论",
(L10nKey::GitHubShowHiddenComments, "one") => "显示另外 1 条评论",
(L10nKey::GitHubShowHiddenComments, "other") => "显示另外 {count} 条评论",
(L10nKey::GitHubShowAllReviewers, "zero") => "没有审查人",
(L10nKey::GitHubShowAllReviewers, "one") => "显示 1 位审查人",
(L10nKey::GitHubShowAllReviewers, "other") => "显示全部 {count} 位审查人",
_ => return None,
};
Some(res)
+510
View File
@@ -70,6 +70,89 @@ fn fixed_bindings() -> Vec<KeyBinding> {
let search = Some(crate::ui::search::KEY_CONTEXT);
bindings.push(KeyBinding::new("tab", SearchNextTab, search));
bindings.push(KeyBinding::new("shift-tab", SearchPrevTab, search));
// The code editor's multi-cursor chords, laid down again after tty7's
// own table. gpui ranks a context-free binding as deep as the focused
// context, and breaks the tie by whichever was added last — so the copies
// gpui-component installs lose ⌘D to `SplitRight` and ⌘⌥↑/↓ to
// `FocusPaneUp`/`Down`, whose handlers sit on the workspace root and take
// the key before the editor is asked. Re-added here they win inside an
// `Input`; only the code editor handles them, so in any other text field
// gpui falls through to the pane binding next in line.
{
use gpui_component::input::{
AddCursorAbove, AddCursorBelow, SelectAllOccurrences, SelectNextOccurrence,
};
let input = Some("Input");
bindings.push(KeyBinding::new("secondary-d", SelectNextOccurrence, input));
bindings.push(KeyBinding::new(
"secondary-shift-l",
SelectAllOccurrences,
input,
));
bindings.push(KeyBinding::new("secondary-alt-up", AddCursorAbove, input));
bindings.push(KeyBinding::new("secondary-alt-down", AddCursorBelow, input));
// ⌘K ⌘D: a chord whose first key is `ClearScrollback` on macOS. gpui
// drops a pending chord that ranks below a complete match, so this
// too has to come after tty7's table. Code editor only: in any other
// field ⌘K goes straight through, without waiting for a second key.
bindings.push(KeyBinding::new(
"secondary-k secondary-d",
gpui_component::input::SkipOccurrence,
Some(gpui_component::input::CODE_EDITOR_CONTEXT),
));
}
// The language-server commands, on the keys VS Code taught everyone, and
// only inside a text field: F2 and F12 in a terminal belong to the
// program running there. The handlers act only for the code editor and
// let the key go on anywhere else. ⌘. / Ctrl+. needs nothing here: the
// editor binds it to the same code-action menu itself.
let input = Some("Input");
bindings.push(KeyBinding::new("f12", EditorGoToDefinition, input));
bindings.push(KeyBinding::new("f2", EditorRenameSymbol, input));
bindings.push(KeyBinding::new("shift-alt-f", EditorFormatDocument, input));
bindings.push(KeyBinding::new("shift-f12", EditorFindReferences, input));
// The gutter's change markers, on VS Code's chords. Only in an `Input`:
// a function key bound for the whole window would never reach the shell,
// and the handlers let the key through when the code editor is not the
// field that has it.
{
let input = Some("Input");
bindings.push(KeyBinding::new("alt-f5", EditorNextChange, input));
bindings.push(KeyBinding::new("shift-alt-f5", EditorPrevChange, input));
bindings.push(KeyBinding::new("alt-f3", EditorPeekChange, input));
}
// The code editor's line commands, for the same tie: ⌘/ is the shortcut
// sheet, ⌘↵ fullscreen, ⌘⇧↵ maximize and ⌥↑/⌥↓ pane focus off macOS, all
// context-free. Bound on `CodeEditor`, which only a multi-line code
// editor declares, so every other text field and the terminal keep the
// app's keys.
{
use gpui_component::input::{
CODE_EDITOR_CONTEXT, CopyLineDown, CopyLineUp, DeleteLine, InsertLineAbove,
InsertLineBelow, MoveLineDown, MoveLineUp, MoveToMatchingBracket, SelectLine,
ToggleBlockComment, ToggleLineComment,
};
let editor = Some(CODE_EDITOR_CONTEXT);
bindings.extend([
KeyBinding::new("secondary-/", ToggleLineComment, editor),
KeyBinding::new("alt-shift-a", ToggleBlockComment, editor),
KeyBinding::new("alt-up", MoveLineUp, editor),
KeyBinding::new("alt-down", MoveLineDown, editor),
KeyBinding::new("alt-shift-up", CopyLineUp, editor),
KeyBinding::new("alt-shift-down", CopyLineDown, editor),
KeyBinding::new("secondary-shift-k", DeleteLine, editor),
KeyBinding::new("secondary-enter", InsertLineBelow, editor),
KeyBinding::new("secondary-shift-enter", InsertLineAbove, editor),
KeyBinding::new("secondary-l", SelectLine, editor),
KeyBinding::new("secondary-shift-\\", MoveToMatchingBracket, editor),
]);
#[cfg(target_os = "macos")]
bindings.push(KeyBinding::new(
"ctrl-j",
gpui_component::input::JoinLines,
editor,
));
}
bindings
}
@@ -292,6 +375,11 @@ fn steals_a_control_code(chord: &str) -> bool {
/// simply swallows the byte does not belong on this list.
fn control_code_binding_allowed(action: &str, chord: &str) -> bool {
matches!(action, "EditorSave" | "EditorGoToLine")
// Bound in the text field's context only, and handled only by the
// code editor: in the terminal the chord is never matched, and in any
// other field it falls through. VS Code's Go Forward, ⌃⇧-, folds to
// Ctrl+_ (US) there.
|| action_context(action) == Some(crate::ui::code_editor::NAV_KEY_CONTEXT)
|| (cfg!(not(target_os = "macos")) && chord == "ctrl-v")
}
@@ -590,8 +678,60 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> {
("EditorSave", "secondary-s"),
("EditorSaveAs", "secondary-shift-s"),
("EditorGoToLine", "ctrl-g"),
// VS Code's chords. ⌘⇧O is the workspace switcher everywhere but the
// editor, which takes it back while it has the focus (see
// `action_context`). Back and Forward are ⌃- and ⌃⇧- on macOS; off
// it ⌃- is Decrease Font Size, so Alt+←/→ as VS Code has them there.
("EditorGoToSymbol", "secondary-shift-o"),
("EditorNavigateBack", per_platform("ctrl--", "alt-left")),
(
"EditorNavigateForward",
per_platform("ctrl-shift--", "alt-right"),
),
// VS Code's Split Editor. Off macOS Ctrl+\ is the terminal's SIGQUIT,
// which it keeps: the binding lives in the editor's context only.
("EditorSplitRight", "secondary-\\"),
// Moving between the two groups, on the chords that move between
// panes — at the edge of the editor, or with no split, they carry on
// to the panes. Off macOS those are Alt+←/→, which Back and Forward
// hold inside the editor.
(
"EditorFocusLeftGroup",
per_platform("secondary-alt-left", "ctrl-alt-left"),
),
(
"EditorFocusRightGroup",
per_platform("secondary-alt-right", "ctrl-alt-right"),
),
// Unbound: ⌘N is New Window, and the editor's header has a + for it.
("EditorNewFile", ""),
// The language-server commands. Unbound here — a default on a bare
// function key would take it from the shell — and bound instead in
// the editor's own context by `fixed_bindings`.
("EditorGoToDefinition", ""),
("EditorQuickFix", ""),
("EditorRenameSymbol", ""),
("EditorFormatDocument", ""),
// The editor's text commands. Unbound: none of them has a chord in
// VS Code either, except Join Lines, whose ⌃J is a fixed `CodeEditor`
// binding on macOS (in the terminal it is a line feed).
("EditorTransformUppercase", ""),
("EditorTransformLowercase", ""),
("EditorTransformTitleCase", ""),
("EditorTrimTrailingWhitespace", ""),
("EditorJoinLines", ""),
("EditorRemoveSurroundingBrackets", ""),
("EditorFindReferences", ""),
// Alt+F5 / Shift+Alt+F5 (VS Code's) are fixed `Input`-context
// bindings: a default on a function key would hide it from the shell.
("EditorNextChange", ""),
("EditorPrevChange", ""),
("EditorRevertChange", ""),
("ToggleEditorGitGutter", ""),
// Alt+F3 is a fixed `Input`-context binding, like Alt+F5.
("EditorPeekChange", ""),
// VS Code's chord for its Problems panel.
("ToggleEditorProblems", "secondary-shift-m"),
("OpenSshProfiles", ""),
("RestartSshSession", "secondary-shift-r"),
("Quit", per_platform("secondary-q", "secondary-shift-q")),
@@ -878,10 +1018,99 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::Terminal,
t(L10nKey::EditorGoToLineAction).to_string(),
),
"EditorGoToDefinition" => (
CommandGroup::Terminal,
t(L10nKey::LspGoToDefinition).to_string(),
),
"EditorQuickFix" => (CommandGroup::Terminal, t(L10nKey::LspQuickFix).to_string()),
"EditorRenameSymbol" => (
CommandGroup::Terminal,
t(L10nKey::LspRenameSymbolAction).to_string(),
),
"EditorFormatDocument" => (
CommandGroup::Terminal,
t(L10nKey::LspFormatDocument).to_string(),
),
"EditorTransformUppercase" => (
CommandGroup::Terminal,
t(L10nKey::CmdEditorTransformUppercase).to_string(),
),
"EditorTransformLowercase" => (
CommandGroup::Terminal,
t(L10nKey::CmdEditorTransformLowercase).to_string(),
),
"EditorTransformTitleCase" => (
CommandGroup::Terminal,
t(L10nKey::CmdEditorTransformTitleCase).to_string(),
),
"EditorTrimTrailingWhitespace" => (
CommandGroup::Terminal,
t(L10nKey::CmdEditorTrimTrailingWhitespace).to_string(),
),
"EditorJoinLines" => (
CommandGroup::Terminal,
t(L10nKey::CmdEditorJoinLines).to_string(),
),
"EditorRemoveSurroundingBrackets" => (
CommandGroup::Terminal,
t(L10nKey::CmdEditorRemoveSurroundingBrackets).to_string(),
),
"EditorFindReferences" => (
CommandGroup::Terminal,
t(L10nKey::LspFindReferences).to_string(),
),
"EditorGoToSymbol" => (
CommandGroup::Terminal,
t(L10nKey::EditorGoToSymbolAction).to_string(),
),
"EditorNavigateBack" => (
CommandGroup::Terminal,
t(L10nKey::EditorNavigateBack).to_string(),
),
"EditorNavigateForward" => (
CommandGroup::Terminal,
t(L10nKey::EditorNavigateForward).to_string(),
),
"EditorSplitRight" => (
CommandGroup::Terminal,
t(L10nKey::EditorSplitRight).to_string(),
),
"EditorFocusLeftGroup" => (
CommandGroup::Terminal,
t(L10nKey::EditorFocusLeftGroup).to_string(),
),
"EditorFocusRightGroup" => (
CommandGroup::Terminal,
t(L10nKey::EditorFocusRightGroup).to_string(),
),
"EditorNewFile" => (
CommandGroup::Terminal,
t(L10nKey::EditorNewFile).to_string(),
),
"EditorNextChange" => (
CommandGroup::Terminal,
t(L10nKey::EditorGitNextChange).to_string(),
),
"EditorPrevChange" => (
CommandGroup::Terminal,
t(L10nKey::EditorGitPrevChange).to_string(),
),
"EditorRevertChange" => (
CommandGroup::Terminal,
t(L10nKey::EditorGitRevertChange).to_string(),
),
"ToggleEditorGitGutter" => (
CommandGroup::Terminal,
t(L10nKey::EditorGitToggleGutter).to_string(),
),
"EditorPeekChange" => (
CommandGroup::Terminal,
t(L10nKey::EditorGitPeekChange).to_string(),
),
"ToggleEditorProblems" => (
CommandGroup::Terminal,
t(L10nKey::EditorProblemsToggle).to_string(),
),
"OpenSshProfiles" => (
CommandGroup::Ssh,
t(L10nKey::CmdSshManageProfiles).to_string(),
@@ -1440,6 +1669,12 @@ fn action_context(action: &str) -> Option<&'static str> {
// carries on to the PTY as SYN (#677).
"AlternatePaste" => Some("Terminal && !alt_screen"),
"ScmCommit" | "ScmCommitAmend" => Some("ScmCommit"),
"EditorGoToSymbol"
| "EditorNavigateBack"
| "EditorNavigateForward"
| "EditorSplitRight"
| "EditorFocusLeftGroup"
| "EditorFocusRightGroup" => Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
_ => None,
}
}
@@ -1572,7 +1807,58 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"EditorSave" => KeyBinding::new(keystroke, EditorSave, None),
"EditorSaveAs" => KeyBinding::new(keystroke, EditorSaveAs, None),
"EditorGoToLine" => KeyBinding::new(keystroke, EditorGoToLine, None),
"EditorGoToSymbol" => KeyBinding::new(
keystroke,
EditorGoToSymbol,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"EditorNavigateBack" => KeyBinding::new(
keystroke,
EditorNavigateBack,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"EditorNavigateForward" => KeyBinding::new(
keystroke,
EditorNavigateForward,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"EditorSplitRight" => KeyBinding::new(
keystroke,
EditorSplitRight,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"EditorFocusLeftGroup" => KeyBinding::new(
keystroke,
EditorFocusLeftGroup,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"EditorFocusRightGroup" => KeyBinding::new(
keystroke,
EditorFocusRightGroup,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"EditorNewFile" => KeyBinding::new(keystroke, EditorNewFile, None),
"EditorGoToDefinition" => KeyBinding::new(keystroke, EditorGoToDefinition, None),
"EditorQuickFix" => KeyBinding::new(keystroke, EditorQuickFix, None),
"EditorRenameSymbol" => KeyBinding::new(keystroke, EditorRenameSymbol, None),
"EditorFormatDocument" => KeyBinding::new(keystroke, EditorFormatDocument, None),
"EditorTransformUppercase" => KeyBinding::new(keystroke, EditorTransformUppercase, None),
"EditorTransformLowercase" => KeyBinding::new(keystroke, EditorTransformLowercase, None),
"EditorTransformTitleCase" => KeyBinding::new(keystroke, EditorTransformTitleCase, None),
"EditorTrimTrailingWhitespace" => {
KeyBinding::new(keystroke, EditorTrimTrailingWhitespace, None)
}
"EditorJoinLines" => KeyBinding::new(keystroke, EditorJoinLines, None),
"EditorRemoveSurroundingBrackets" => {
KeyBinding::new(keystroke, EditorRemoveSurroundingBrackets, None)
}
"EditorFindReferences" => KeyBinding::new(keystroke, EditorFindReferences, None),
"EditorNextChange" => KeyBinding::new(keystroke, EditorNextChange, None),
"EditorPrevChange" => KeyBinding::new(keystroke, EditorPrevChange, None),
"EditorRevertChange" => KeyBinding::new(keystroke, EditorRevertChange, None),
"ToggleEditorGitGutter" => KeyBinding::new(keystroke, ToggleEditorGitGutter, None),
"EditorPeekChange" => KeyBinding::new(keystroke, EditorPeekChange, None),
"ToggleEditorProblems" => KeyBinding::new(keystroke, ToggleEditorProblems, None),
"OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None),
"RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None),
"Quit" => KeyBinding::new(keystroke, Quit, None),
@@ -2595,6 +2881,60 @@ mod gpui_tests {
.collect()
}
/// The code editor's line commands sit on chords the app also spends —
/// ⌘/ is the shortcut sheet, ⌘↵ fullscreen, ⌘⇧↵ maximize, ⌥↑/⌥↓ pane
/// focus off macOS. `fixed_bindings` re-adds them on the `CodeEditor`
/// context the editor declares beside `Input`, after the app's table, so
/// inside the editor they win and everywhere else nothing changes.
#[gpui::test]
fn the_code_editor_chords_win_inside_the_editor(cx: &mut TestAppContext) {
cx.update(|cx| {
running_on_json(cx, "{}");
let first = |keys: &str, stack: &[&str]| {
let input: Vec<Keystroke> = keys
.split(' ')
.map(|k| Keystroke::parse(k).expect("the typed keystroke parses"))
.collect();
let stack: Vec<gpui::KeyContext> = stack
.iter()
.map(|c| gpui::KeyContext::parse(c).expect("the context parses"))
.collect();
cx.key_bindings()
.borrow()
.bindings_for_input(&input, &stack)
.0
.first()
.map(|b| b.action().name())
};
let editor = ["Workspace", "Input CodeEditor"];
let join_lines = cfg!(target_os = "macos").then_some(("ctrl-j", "input::JoinLines"));
for (keys, action) in join_lines.into_iter().chain([
("secondary-/", "input::ToggleLineComment"),
("alt-up", "input::MoveLineUp"),
("alt-down", "input::MoveLineDown"),
("alt-shift-up", "input::CopyLineUp"),
("alt-shift-down", "input::CopyLineDown"),
("secondary-shift-k", "input::DeleteLine"),
("secondary-enter", "input::InsertLineBelow"),
("secondary-shift-enter", "input::InsertLineAbove"),
("secondary-l", "input::SelectLine"),
("secondary-shift-\\", "input::MoveToMatchingBracket"),
]) {
assert_eq!(first(keys, &editor), Some(action), "{keys} in the editor");
assert_ne!(
first(keys, &["Workspace", "Input"]),
Some(action),
"{keys} in a plain text field"
);
assert_ne!(
first(keys, &["Workspace", "Terminal"]),
Some(action),
"{keys} in a terminal"
);
}
});
}
#[gpui::test]
fn a_chord_added_in_config_keeps_the_default_one(cx: &mut TestAppContext) {
use gpui::Action as _;
@@ -2833,4 +3173,174 @@ mod gpui_tests {
assert_eq!(backspace(cx), inherited, "nor may a rebind");
});
}
/// The gutter's next / previous change chords live only in an `Input`:
/// the editor gets them, a terminal never loses a function key to them.
#[gpui::test]
fn editor_change_chords_are_bound_only_in_an_input(cx: &mut TestAppContext) {
use gpui::Action as _;
cx.update(|cx| {
running_on_json(cx, "{}");
let hits = |keys: &str, contexts: &[&str]| -> Vec<&'static str> {
let input = [Keystroke::parse(keys).expect("the keystroke parses")];
let context: Vec<_> = contexts
.iter()
.map(|c| gpui::KeyContext::parse(c).expect("the context parses"))
.collect();
cx.key_bindings()
.borrow()
.bindings_for_input(&input, &context)
.0
.iter()
.map(|b| b.action().name())
.collect()
};
assert_eq!(
hits("alt-f5", &["Workspace", "Input"]).first(),
Some(&EditorNextChange::name_for_type())
);
assert_eq!(
hits("shift-alt-f5", &["Workspace", "Input"]).first(),
Some(&EditorPrevChange::name_for_type())
);
assert_eq!(
hits("alt-f3", &["Workspace", "Input"]).first(),
Some(&EditorPeekChange::name_for_type())
);
assert!(hits("alt-f5", &["Workspace"]).is_empty());
});
}
/// The code editor's multi-cursor chords sit on keys tty7 ships for panes
/// (⌘D split, ⌘⌥↑/↓ focus). `fixed_bindings` re-adds them after the
/// pane table so they rank first inside an `Input`; any other text field
/// has no handler for them, and gpui falls through to the pane binding
/// listed after. A terminal never sees them.
#[gpui::test]
fn editor_multi_cursor_chords_outrank_the_pane_bindings(cx: &mut TestAppContext) {
use gpui::Action as _;
use gpui_component::input::{
AddCursorAbove, AddCursorBelow, SelectAllOccurrences, SelectNextOccurrence,
};
cx.update(|cx| {
running_on_json(cx, "{}");
let in_input = |keys: &str| -> Vec<&'static str> {
let input = [Keystroke::parse(keys).expect("the keystroke parses")];
let context = [
gpui::KeyContext::parse("Workspace").expect("the context parses"),
gpui::KeyContext::parse("Input").expect("the context parses"),
];
cx.key_bindings()
.borrow()
.bindings_for_input(&input, &context)
.0
.iter()
.map(|b| b.action().name())
.collect()
};
let cases = [
("secondary-d", SelectNextOccurrence::name_for_type()),
("secondary-shift-l", SelectAllOccurrences::name_for_type()),
("secondary-alt-up", AddCursorAbove::name_for_type()),
("secondary-alt-down", AddCursorBelow::name_for_type()),
];
for (keys, editor_action) in cases {
let hits = in_input(keys);
assert_eq!(hits.first(), Some(&editor_action), "{keys} in the editor");
}
if cfg!(target_os = "macos") {
assert!(in_input("secondary-d").contains(&SplitRight::name_for_type()));
assert_eq!(
fired(cx, "secondary-d").first(),
Some(&SplitRight::name_for_type()),
"a terminal still splits on ⌘D"
);
}
// ⌘K ⌘D in the code editor skips to the next occurrence, and ⌘K
// there waits for the chord's second key.
let editor = [
gpui::KeyContext::parse("Workspace").expect("the context parses"),
gpui::KeyContext::parse("Input CodeEditor").expect("the context parses"),
];
let keymap = cx.key_bindings();
let keymap = keymap.borrow();
let chord = [
Keystroke::parse("secondary-k").unwrap(),
Keystroke::parse("secondary-d").unwrap(),
];
let (hits, _) = keymap.bindings_for_input(&chord, &editor);
assert_eq!(
hits.first().map(|b| b.action().name()),
Some(gpui_component::input::SkipOccurrence::name_for_type())
);
let (_, pending) =
keymap.bindings_for_input(&[Keystroke::parse("secondary-k").unwrap()], &editor);
assert!(pending, "⌘K waits for the chord in the code editor");
// A plain text field isn't held up.
let field = [
gpui::KeyContext::parse("Workspace").expect("the context parses"),
gpui::KeyContext::parse("Input").expect("the context parses"),
];
let (_, pending) =
keymap.bindings_for_input(&[Keystroke::parse("secondary-k").unwrap()], &field);
assert!(!pending, "⌘K goes straight through in other fields");
});
}
/// Go to Symbol and Back/Forward sit on chords the window already uses
/// (⌘⇧O is the workspace switcher; off macOS Alt+←/→ move between
/// panes). Bound at the text field's depth and after the window's table,
/// they rank first inside the editor, and a terminal never sees them.
#[gpui::test]
fn editor_navigation_chords_outrank_the_window_inside_a_text_field(cx: &mut TestAppContext) {
use gpui::Action as _;
cx.update(|cx| {
running_on_json(cx, "{}");
let in_input = |keys: &str| -> Vec<&'static str> {
let input = [Keystroke::parse(keys).expect("the keystroke parses")];
let context = [
gpui::KeyContext::parse("Workspace").expect("the context parses"),
gpui::KeyContext::parse("Input").expect("the context parses"),
];
cx.key_bindings()
.borrow()
.bindings_for_input(&input, &context)
.0
.iter()
.map(|b| b.action().name())
.collect()
};
let (back, forward) = match cfg!(target_os = "macos") {
true => ("ctrl--", "ctrl-shift--"),
false => ("alt-left", "alt-right"),
};
for (keys, action) in [
("secondary-shift-o", EditorGoToSymbol::name_for_type()),
(back, EditorNavigateBack::name_for_type()),
(forward, EditorNavigateForward::name_for_type()),
("secondary-\\", EditorSplitRight::name_for_type()),
(
per_platform("secondary-alt-left", "ctrl-alt-left"),
EditorFocusLeftGroup::name_for_type(),
),
(
per_platform("secondary-alt-right", "ctrl-alt-right"),
EditorFocusRightGroup::name_for_type(),
),
] {
assert_eq!(
in_input(keys).first(),
Some(&action),
"{keys} in the editor"
);
assert!(!fired(cx, keys).contains(&action), "{keys} in a terminal");
}
assert_eq!(
fired(cx, "secondary-shift-o").first(),
Some(&ToggleSwitcher::name_for_type()),
"a terminal still opens the switcher"
);
});
}
}
+576
View File
@@ -0,0 +1,576 @@
//! One running language server: its process, the threads that pump its
//! pipes, and the request/response bookkeeping on top of [`super::rpc`].
//!
//! Each pipe gets a dedicated thread rather than a task on gpui's background
//! executor: a read that blocks until the server says something would park
//! one of the executor's few workers for the life of the server. The threads
//! end on their own when the pipes close, which killing the process does.
//!
//! What the server sends unprompted — diagnostics, its own requests, the pipe
//! closing — arrives on the [`ServerEvent`] channel, for the store to handle
//! on the main thread.
use std::collections::HashMap;
use std::io::{BufRead as _, BufReader, Read, Write};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use anyhow::{Context as _, anyhow};
use serde_json::Value;
use super::rpc::{self, FrameReader, Incoming, RequestId, ResponseError};
type Reply = smol::channel::Sender<Result<Value, ResponseError>>;
/// Requests waiting for an answer. `None` once the server's output has
/// closed: nothing will ever be answered again, and a request made after
/// that fails at once instead of waiting forever.
type Pending = Arc<Mutex<Option<HashMap<RequestId, Reply>>>>;
/// How long an ordinary request waits for its answer. Everything the editor
/// asks is interactive — someone is waiting on a completion, a jump, a
/// rename — and a server that has not answered in this long is stuck.
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// `initialize` may index before answering; give it longer.
const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(60);
/// Something the server did that nobody asked for.
#[derive(Debug)]
pub(crate) enum ServerEvent {
Notification {
method: String,
params: Value,
},
Request {
id: Value,
method: String,
params: Value,
},
/// The server's output closed: it exited, crashed or was killed.
Exited,
}
pub(crate) struct LspClient {
next_id: AtomicI64,
pending: Pending,
out: std::sync::mpsc::Sender<Vec<u8>>,
/// Messages held back until the server has answered `initialize`, which
/// the protocol requires to be the first thing it sees. `None` once the
/// gate is open.
gate: Mutex<Option<Vec<Vec<u8>>>>,
child: Mutex<Option<Child>>,
}
impl LspClient {
/// Starts `program` in `root` and wires up its pipes.
pub(crate) fn spawn(
name: &str,
program: &Path,
args: &[&str],
root: &Path,
) -> anyhow::Result<(Arc<Self>, smol::channel::Receiver<ServerEvent>)> {
let mut command = Command::new(program);
command
.args(args)
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
// CREATE_NO_WINDOW: a server is a pipe, not a console.
command.creation_flags(0x0800_0000);
}
let mut child = command
.spawn()
.with_context(|| format!("starting {}", program.display()))?;
let stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
if let Some(stderr) = child.stderr.take() {
let name = name.to_owned();
// A server that fills its stderr pipe with nobody reading blocks
// on the next write — and stops answering. Drained into the log.
std::thread::Builder::new()
.name("tty7-lsp-stderr".into())
.spawn(move || {
for line in BufReader::new(stderr).lines() {
let Ok(line) = line else { break };
log::debug!("lsp {name} stderr: {line}");
}
})
.ok();
}
let (client, events) = Self::from_streams(name, stdout, stdin)?;
*client.child.lock().unwrap() = Some(child);
Ok((client, events))
}
/// The client over any pair of pipes — a real process's, or a test's.
pub(crate) fn from_streams(
name: &str,
reader: impl Read + Send + 'static,
mut writer: impl Write + Send + 'static,
) -> anyhow::Result<(Arc<Self>, smol::channel::Receiver<ServerEvent>)> {
let (out, out_rx) = std::sync::mpsc::channel::<Vec<u8>>();
std::thread::Builder::new()
.name("tty7-lsp-write".into())
.spawn(move || {
// Ends when every sender is gone — the client dropped — or
// the pipe breaks. Either way stdin closes behind it, which
// is the last word a server needs to exit.
while let Ok(bytes) = out_rx.recv() {
if writer
.write_all(&bytes)
.and_then(|_| writer.flush())
.is_err()
{
break;
}
}
})?;
let pending: Pending = Arc::new(Mutex::new(Some(HashMap::new())));
let (events_tx, events) = smol::channel::unbounded();
{
let pending = pending.clone();
let name = name.to_owned();
let mut reader = reader;
std::thread::Builder::new()
.name("tty7-lsp-read".into())
.spawn(move || {
let mut frames = FrameReader::default();
let mut chunk = vec![0u8; 64 * 1024];
loop {
let n = match reader.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
frames.push(&chunk[..n]);
while let Some(body) = frames.next_frame() {
let Some(message) = rpc::classify(&body) else {
log::debug!("lsp {name}: ignoring an unreadable message");
continue;
};
let event = match message {
Incoming::Response { id, result } => {
let reply = pending
.lock()
.unwrap()
.as_mut()
.and_then(|waiting| waiting.remove(&id));
if let Some(reply) = reply {
let _ = reply.try_send(result);
}
continue;
}
Incoming::Request { id, method, params } => {
ServerEvent::Request { id, method, params }
}
Incoming::Notification { method, params } => {
ServerEvent::Notification { method, params }
}
};
if events_tx.send_blocking(event).is_err() {
// Nobody is listening any more; keep reading
// so the server never blocks on a full pipe.
}
}
}
// Every request still waiting will never be answered:
// dropping the senders fails them rather than leaving
// them hanging.
pending.lock().unwrap().take();
let _ = events_tx.send_blocking(ServerEvent::Exited);
})?;
}
Ok((
Arc::new(Self {
next_id: AtomicI64::new(1),
pending,
out,
gate: Mutex::new(Some(Vec::new())),
child: Mutex::new(None),
}),
events,
))
}
fn send(&self, bytes: Vec<u8>, gated: bool) {
let mut gate = self.gate.lock().unwrap();
match gate.as_mut() {
Some(held) if gated => held.push(bytes),
_ => {
let _ = self.out.send(bytes);
}
}
}
/// Lets through everything held back for `initialize`, in the order it
/// was sent. Held under the gate's lock, so nothing sent meanwhile can
/// overtake it.
pub(crate) fn open_gate(&self) {
let mut gate = self.gate.lock().unwrap();
for bytes in gate.take().unwrap_or_default() {
let _ = self.out.send(bytes);
}
}
/// Sends a request, returning the server's answer. Dropping the future
/// before it resolves tells the server to stop working on it.
pub(crate) fn request_raw(
self: &Arc<Self>,
method: &str,
params: Value,
) -> impl std::future::Future<Output = anyhow::Result<Value>> + use<> {
self.request_inner(method, params, true, REQUEST_TIMEOUT)
}
/// [`Self::request_raw`] with its own time limit.
pub(crate) fn request_raw_within(
self: &Arc<Self>,
method: &str,
params: Value,
timeout: Duration,
) -> impl std::future::Future<Output = anyhow::Result<Value>> + use<> {
self.request_inner(method, params, true, timeout)
}
/// `initialize`, which is the one request that goes past the gate.
pub(crate) fn initialize(
self: &Arc<Self>,
params: Value,
) -> impl std::future::Future<Output = anyhow::Result<Value>> + use<> {
self.request_inner("initialize", params, false, INITIALIZE_TIMEOUT)
}
fn request_inner(
self: &Arc<Self>,
method: &str,
params: Value,
gated: bool,
timeout: Duration,
) -> impl std::future::Future<Output = anyhow::Result<Value>> + use<> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = smol::channel::bounded(1);
// Checked and registered under one lock: the reader closes the map
// under the same lock, so a request either makes it in before the
// server went away (and is failed with the rest) or sees it gone.
let registered = match self.pending.lock().unwrap().as_mut() {
Some(waiting) => {
waiting.insert(id, tx);
true
}
None => false,
};
if registered {
self.send(rpc::encode(&rpc::request(id, method, params)), gated);
}
let guard = CancelOnDrop {
client: Arc::downgrade(self),
id,
done: !registered,
};
let method = method.to_owned();
async move {
// Bound whole, not just the field written below: a closure
// capturing only `guard.done` would drop the guard — and cancel
// the request — before it was ever sent an answer.
let mut guard = guard;
if !registered {
return Err(anyhow!("{method}: the language server has exited"));
}
let answer = smol::future::or(async { Some(rx.recv().await) }, async {
smol::Timer::after(timeout).await;
None
})
.await;
// A timeout leaves `done` unset, so dropping the guard tells the
// server to stop working on it.
let Some(answer) = answer else {
return Err(anyhow!("{method}: no answer in {}s", timeout.as_secs_f32()));
};
guard.done = true;
match answer {
Ok(Ok(value)) => Ok(value),
Ok(Err(e)) => Err(anyhow::Error::new(e).context(method)),
Err(_) => Err(anyhow!("{method}: the language server went away")),
}
}
}
/// A typed request.
pub(crate) fn request<R: lsp_types::request::Request>(
self: &Arc<Self>,
params: R::Params,
) -> impl std::future::Future<Output = anyhow::Result<R::Result>> + use<R> {
let params = serde_json::to_value(params).unwrap_or(Value::Null);
let answer = self.request_raw(R::METHOD, params);
async move { Ok(serde_json::from_value(answer.await?)?) }
}
pub(crate) fn notify_raw(&self, method: &str, params: Value) {
self.send(rpc::encode(&rpc::notification(method, params)), true);
}
pub(crate) fn notify<N: lsp_types::notification::Notification>(&self, params: N::Params) {
let params = serde_json::to_value(params).unwrap_or(Value::Null);
self.notify_raw(N::METHOD, params);
}
/// Answers a request the server made.
pub(crate) fn respond(&self, id: Value, result: Result<Value, ResponseError>) {
self.send(rpc::encode(&rpc::response(id, result)), false);
}
/// Kills the process, if there is one and it is still running, and reaps
/// it so it does not linger as a zombie.
pub(crate) fn kill(&self) {
if let Some(mut child) = self.child.lock().unwrap().take() {
if matches!(child.try_wait(), Ok(None)) {
let _ = child.kill();
}
let _ = child.wait();
}
}
/// Whether the process has already exited on its own.
pub(crate) fn has_exited(&self) -> bool {
match self.child.lock().unwrap().as_mut() {
Some(child) => !matches!(child.try_wait(), Ok(None)),
None => true,
}
}
/// Reaps the process if it has exited, without killing it.
pub(crate) fn reap_if_exited(&self) -> bool {
let mut slot = self.child.lock().unwrap();
let exited = match slot.as_mut() {
Some(child) => !matches!(child.try_wait(), Ok(None)),
None => return true,
};
if exited {
slot.take();
}
exited
}
}
impl Drop for LspClient {
fn drop(&mut self) {
// The last line of defence against a server outliving tty7's
// interest in it. A clean shutdown has already reaped it.
self.kill();
}
}
struct CancelOnDrop {
client: Weak<LspClient>,
id: RequestId,
done: bool,
}
impl Drop for CancelOnDrop {
fn drop(&mut self) {
if self.done {
return;
}
let Some(client) = self.client.upgrade() else {
return;
};
let removed = client
.pending
.lock()
.unwrap()
.as_mut()
.and_then(|waiting| waiting.remove(&self.id));
if removed.is_some() {
client.notify_raw("$/cancelRequest", serde_json::json!({ "id": self.id }));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A server on the other end of two pipes, driven by the test.
struct FakeServer {
frames: FrameReader,
from_client: std::io::PipeReader,
to_client: std::io::PipeWriter,
}
impl FakeServer {
fn next(&mut self) -> Value {
let mut chunk = [0u8; 256];
loop {
if let Some(body) = self.frames.next_frame() {
return serde_json::from_slice(&body).unwrap();
}
let n = self.from_client.read(&mut chunk).unwrap();
assert!(n > 0, "the client closed its end");
self.frames.push(&chunk[..n]);
}
}
fn send(&mut self, value: Value) {
// Written in two pieces, so the client's reader has to put a
// frame back together.
let bytes = rpc::encode(&value);
let (a, b) = bytes.split_at(bytes.len() / 2);
self.to_client.write_all(a).unwrap();
self.to_client.flush().unwrap();
self.to_client.write_all(b).unwrap();
self.to_client.flush().unwrap();
}
}
fn connect() -> (
Arc<LspClient>,
smol::channel::Receiver<ServerEvent>,
FakeServer,
) {
let (client_reads, server_writes) = std::io::pipe().unwrap();
let (server_reads, client_writes) = std::io::pipe().unwrap();
let (client, events) =
LspClient::from_streams("fake", client_reads, client_writes).unwrap();
(
client,
events,
FakeServer {
frames: FrameReader::default(),
from_client: server_reads,
to_client: server_writes,
},
)
}
#[test]
fn nothing_but_initialize_reaches_the_server_until_the_gate_opens() {
let (client, _events, mut server) = connect();
client.notify_raw("textDocument/didOpen", json!({ "n": 1 }));
let init = client.initialize(json!({}));
let first = server.next();
assert_eq!(first["method"], "initialize");
server
.send(json!({ "jsonrpc": "2.0", "id": first["id"], "result": { "capabilities": {} } }));
let answer = smol::block_on(init).unwrap();
assert_eq!(answer, json!({ "capabilities": {} }));
client.notify_raw("initialized", json!({}));
client.open_gate();
// Held messages come out first, in the order they were sent.
assert_eq!(server.next()["method"], "textDocument/didOpen");
assert_eq!(server.next()["method"], "initialized");
}
#[test]
fn answers_find_their_way_back_to_the_request_that_asked() {
let (client, _events, mut server) = connect();
client.open_gate();
let a = client.request_raw("a", json!(1));
let b = client.request_raw("b", json!(2));
let ra = server.next();
let rb = server.next();
// Answered out of order.
server.send(json!({ "jsonrpc": "2.0", "id": rb["id"], "result": "B" }));
server.send(json!({
"jsonrpc": "2.0",
"id": ra["id"],
"error": { "code": -32800, "message": "cancelled" },
}));
assert_eq!(smol::block_on(b).unwrap(), json!("B"));
let err = smol::block_on(a).unwrap_err();
assert!(format!("{err:#}").contains("cancelled"), "{err:#}");
}
#[test]
fn server_requests_and_notifications_arrive_as_events_and_can_be_answered() {
let (client, events, mut server) = connect();
client.open_gate();
server.send(json!({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": { "uri": "file:///a.rs", "diagnostics": [] },
}));
server.send(json!({
"jsonrpc": "2.0",
"id": "cfg-1",
"method": "workspace/configuration",
"params": { "items": [{}] },
}));
match smol::block_on(events.recv()).unwrap() {
ServerEvent::Notification { method, .. } => {
assert_eq!(method, "textDocument/publishDiagnostics")
}
other => panic!("{other:?}"),
}
let ServerEvent::Request { id, method, .. } = smol::block_on(events.recv()).unwrap() else {
panic!("expected a request");
};
assert_eq!(method, "workspace/configuration");
client.respond(id, Ok(json!([null])));
let reply = server.next();
assert_eq!(reply["id"], "cfg-1");
assert_eq!(reply["result"], json!([null]));
}
#[test]
fn a_closed_pipe_fails_waiting_requests_and_says_the_server_exited() {
let (client, events, server) = connect();
client.open_gate();
let waiting = client.request_raw("slow", Value::Null);
drop(server);
assert!(smol::block_on(waiting).is_err());
assert!(matches!(
smol::block_on(events.recv()).unwrap(),
ServerEvent::Exited
));
}
#[test]
fn a_request_after_the_server_exited_fails_at_once() {
let (client, events, server) = connect();
client.open_gate();
drop(server);
assert!(matches!(
smol::block_on(events.recv()).unwrap(),
ServerEvent::Exited
));
// No timeout involved: a limit of an hour would hang the test.
let late =
client.request_raw_within("textDocument/hover", Value::Null, Duration::from_secs(3600));
let err = smol::block_on(late).unwrap_err();
assert!(format!("{err:#}").contains("exited"), "{err:#}");
}
#[test]
fn a_request_nobody_answers_times_out_and_is_cancelled() {
let (client, _events, mut server) = connect();
client.open_gate();
let asked = client.request_raw_within("slow", Value::Null, Duration::from_millis(50));
let sent = server.next();
let err = smol::block_on(asked).unwrap_err();
assert!(format!("{err:#}").contains("no answer"), "{err:#}");
let cancel = server.next();
assert_eq!(cancel["method"], "$/cancelRequest");
assert_eq!(cancel["params"]["id"], sent["id"]);
}
#[test]
fn dropping_an_unanswered_request_cancels_it() {
let (client, _events, mut server) = connect();
client.open_gate();
let asked = client.request_raw("textDocument/completion", Value::Null);
let sent = server.next();
drop(asked);
let cancel = server.next();
assert_eq!(cancel["method"], "$/cancelRequest");
assert_eq!(cancel["params"]["id"], sent["id"]);
}
}
+445
View File
@@ -0,0 +1,445 @@
//! Translating between the positions a language server speaks and the ones
//! the editor does.
//!
//! A server counts columns in UTF-16 code units unless it negotiated
//! otherwise. The editor keeps byte offsets into its rope, and
//! gpui-component's own LSP plumbing (`RopeExt::position_to_offset`, the
//! diagnostic set, the completion menu) reads a [`Position`]'s `character` as
//! a count of `char`s. So everything crossing the boundary is converted here,
//! once: server → editor positions before they reach gpui-component, editor
//! byte offsets → server positions before a request goes out.
//!
//! The three agree on ASCII and part ways on everything else: `é` is one
//! char and one UTF-16 unit but two bytes; `🎉` is one char, two UTF-16
//! units and four bytes.
use std::ops::Range;
use gpui_component::{Rope, RopeExt as _};
use lsp_types::{Position, TextEdit};
/// What a server's `character` counts.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Encoding {
Utf8,
#[default]
Utf16,
Utf32,
}
impl Encoding {
pub(crate) fn from_kind(kind: Option<&lsp_types::PositionEncodingKind>) -> Self {
match kind.map(|k| k.as_str()) {
Some("utf-8") => Self::Utf8,
Some("utf-32") => Self::Utf32,
_ => Self::Utf16,
}
}
fn units(self, c: char) -> usize {
match self {
Self::Utf8 => c.len_utf8(),
Self::Utf16 => c.len_utf16(),
Self::Utf32 => 1,
}
}
}
/// The server's column for the byte offset `byte` into `line`.
pub(crate) fn column_of_byte(line: &str, byte: usize, enc: Encoding) -> u32 {
line.char_indices()
.take_while(|(i, _)| *i < byte)
.map(|(_, c)| enc.units(c))
.sum::<usize>() as u32
}
/// The byte offset into `line` of the server's column `units`. A column past
/// the end of the line lands at its end; one inside a character (half a
/// surrogate pair) lands before that character.
pub(crate) fn byte_of_column(line: &str, units: u32, enc: Encoding) -> usize {
let units = units as usize;
let mut seen = 0;
for (i, c) in line.char_indices() {
let next = seen + enc.units(c);
if next > units {
return i;
}
seen = next;
}
line.len()
}
fn line_text(rope: &Rope, row: usize) -> String {
rope.slice_line(row).to_string()
}
/// A byte offset in the editor → a server position.
pub(crate) fn offset_to_lsp(rope: &Rope, offset: usize, enc: Encoding) -> Position {
let point = rope.offset_to_point(offset);
let line = line_text(rope, point.row);
Position::new(point.row as u32, column_of_byte(&line, point.column, enc))
}
/// A server position → a byte offset in the editor, clamped into the text.
#[cfg(test)]
pub(crate) fn lsp_to_offset(rope: &Rope, pos: Position, enc: Encoding) -> usize {
let row = pos.line as usize;
if row >= rope.lines_len() {
return rope.len();
}
let line = line_text(rope, row);
rope.line_start_offset(row) + byte_of_column(&line, pos.character, enc)
}
/// A server position → the char-counted position gpui-component expects.
pub(crate) fn lsp_to_editor(rope: &Rope, pos: Position, enc: Encoding) -> Position {
if enc == Encoding::Utf32 {
return pos;
}
let row = pos.line as usize;
if row >= rope.lines_len() {
return pos;
}
let line = line_text(rope, row);
let byte = byte_of_column(&line, pos.character, enc);
Position::new(pos.line, line[..byte].chars().count() as u32)
}
pub(crate) fn range_to_editor(
rope: &Rope,
range: lsp_types::Range,
enc: Encoding,
) -> lsp_types::Range {
lsp_types::Range::new(
lsp_to_editor(rope, range.start, enc),
lsp_to_editor(rope, range.end, enc),
)
}
/// A server's diagnostics with their ranges in editor columns, ready for
/// `DiagnosticSet::extend`.
pub(crate) fn diagnostics_to_editor(
rope: &Rope,
diagnostics: &[lsp_types::Diagnostic],
enc: Encoding,
) -> Vec<lsp_types::Diagnostic> {
diagnostics
.iter()
.map(|d| lsp_types::Diagnostic {
range: range_to_editor(rope, d.range, enc),
..d.clone()
})
.collect()
}
/// How many errors and warnings a set of diagnostics holds. A diagnostic
/// without a severity counts as neither — the client decides, and the
/// status bar only claims what the server did.
pub(crate) fn count_problems(diagnostics: &[lsp_types::Diagnostic]) -> (usize, usize) {
diagnostics
.iter()
.fold((0, 0), |(e, w), d| match d.severity {
Some(lsp_types::DiagnosticSeverity::ERROR) => (e + 1, w),
Some(lsp_types::DiagnosticSeverity::WARNING) => (e, w + 1),
_ => (e, w),
})
}
/// `edits` in the order they can be applied one at a time.
///
/// Every edit in a `TextEdit[]` is written against the original text, so
/// applying the one furthest into the document first keeps the positions of
/// the rest valid. Edits that start at the same place are inserted in the
/// order the server listed them, which going backwards means reversing them.
pub(crate) fn in_application_order(edits: &[TextEdit]) -> Vec<TextEdit> {
let mut indexed: Vec<(usize, &TextEdit)> = edits.iter().enumerate().collect();
indexed.sort_by(|(ia, a), (ib, b)| {
let ka = (a.range.start.line, a.range.start.character, *ia);
let kb = (b.range.start.line, b.range.start.character, *ib);
kb.cmp(&ka)
});
indexed.into_iter().map(|(_, e)| e.clone()).collect()
}
/// `edits` with their ranges in editor columns and in application order —
/// what `InputState::apply_lsp_edits`, which applies them as given, needs.
pub(crate) fn edits_to_editor(rope: &Rope, edits: &[TextEdit], enc: Encoding) -> Vec<TextEdit> {
in_application_order(edits)
.into_iter()
.map(|e| TextEdit {
range: range_to_editor(rope, e.range, enc),
new_text: e.new_text,
})
.collect()
}
/// `text` with `edits` applied — for a file a rename touches that has no
/// buffer open.
pub(crate) fn apply_edits(text: &str, edits: &[TextEdit], enc: Encoding) -> String {
let starts = line_starts(text);
let offset = |pos: Position| -> usize {
let Some(&start) = starts.get(pos.line as usize) else {
return text.len();
};
let end = starts
.get(pos.line as usize + 1)
.map(|&next| next - 1)
.unwrap_or(text.len());
let line = &text[start..end];
let line = line.strip_suffix('\r').unwrap_or(line);
start + byte_of_column(line, pos.character, enc)
};
let mut out = text.to_owned();
for edit in in_application_order(edits) {
let start = offset(edit.range.start);
let end = offset(edit.range.end).max(start);
out.replace_range(start..end, &edit.new_text);
}
out
}
fn line_starts(text: &str) -> Vec<usize> {
std::iter::once(0)
.chain(text.match_indices('\n').map(|(i, _)| i + 1))
.collect()
}
/// The byte range an editor range covers, for a request about a selection.
pub(crate) fn offsets_to_lsp_range(
rope: &Rope,
range: Range<usize>,
enc: Encoding,
) -> lsp_types::Range {
lsp_types::Range::new(
offset_to_lsp(rope, range.start, enc),
offset_to_lsp(rope, range.end, enc),
)
}
/// A completion's snippet as the plain text it would expand to with every
/// placeholder left at its default. The editor has no snippet engine; this
/// is what it inserts instead of `${1:x}` litter.
pub(crate) fn snippet_to_plain(snippet: &str) -> String {
let mut out = String::with_capacity(snippet.len());
let mut chars = snippet.chars().peekable();
expand(&mut chars, &mut out, false);
out
}
/// Expands until the end, or until the `}` closing the placeholder being
/// expanded when `nested`.
fn expand(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, out: &mut String, nested: bool) {
while let Some(c) = chars.next() {
match c {
'\\' => {
if let Some(next) = chars.next() {
out.push(next);
}
}
'}' if nested => return,
'$' => match chars.peek().copied() {
Some(d) if d.is_ascii_digit() => {
while chars.peek().is_some_and(|d| d.is_ascii_digit()) {
chars.next();
}
}
Some('{') => {
chars.next();
// `${1:default}`, `${1|one,two|}`, `${1}` or `${name}`.
while chars
.peek()
.is_some_and(|d| d.is_ascii_alphanumeric() || *d == '_')
{
chars.next();
}
match chars.next() {
Some(':') => expand(chars, out, true),
Some('|') => {
let mut first = true;
while let Some(c) = chars.next() {
match c {
'|' => {
// The closing `}`.
chars.next();
break;
}
',' => first = false,
'\\' => {
if let Some(next) = chars.next()
&& first
{
out.push(next);
}
}
c if first => out.push(c),
_ => {}
}
}
}
// `${1}`, or something this does not understand:
// skip to its end.
Some('}') | None => {}
Some(_) => {
let mut depth = 1;
for c in chars.by_ref() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
}
}
}
}
// `$name` (a variable) expands to nothing we know.
Some(d) if d.is_ascii_alphabetic() || d == '_' => {
while chars
.peek()
.is_some_and(|d| d.is_ascii_alphanumeric() || *d == '_')
{
chars.next();
}
}
_ => out.push('$'),
},
c => out.push(c),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::Range as LspRange;
fn pos(line: u32, character: u32) -> Position {
Position::new(line, character)
}
fn edit(sl: u32, sc: u32, el: u32, ec: u32, text: &str) -> TextEdit {
TextEdit {
range: LspRange::new(pos(sl, sc), pos(el, ec)),
new_text: text.into(),
}
}
#[test]
fn columns_count_utf16_units_by_default() {
// a é 🎉 b: bytes 0,1,3,7 — UTF-16 units 0,1,2,4 — chars 0,1,2,3.
let line = "aé🎉b";
let enc = Encoding::Utf16;
assert_eq!(column_of_byte(line, 0, enc), 0);
assert_eq!(column_of_byte(line, 1, enc), 1);
assert_eq!(column_of_byte(line, 3, enc), 2);
assert_eq!(column_of_byte(line, 7, enc), 4);
assert_eq!(column_of_byte(line, 8, enc), 5);
assert_eq!(byte_of_column(line, 2, enc), 3);
assert_eq!(byte_of_column(line, 4, enc), 7);
assert_eq!(byte_of_column(line, 5, enc), 8);
// Half a surrogate pair lands before the character, never inside it.
assert_eq!(byte_of_column(line, 3, enc), 3);
// Past the end of the line: the end of the line.
assert_eq!(byte_of_column(line, 99, enc), 8);
}
#[test]
fn utf8_and_utf32_count_bytes_and_chars() {
let line = "aé🎉b";
assert_eq!(column_of_byte(line, 7, Encoding::Utf8), 7);
assert_eq!(byte_of_column(line, 3, Encoding::Utf8), 3);
assert_eq!(column_of_byte(line, 7, Encoding::Utf32), 3);
assert_eq!(byte_of_column(line, 3, Encoding::Utf32), 7);
}
#[test]
fn rope_offsets_and_server_positions_round_trip() {
let rope = Rope::from("fn a() {}\nlet 🎉 = \"é\";\n");
let enc = Encoding::Utf16;
let second = "fn a() {}\n".len();
let quote = second + "let 🎉 = ".len();
assert_eq!(offset_to_lsp(&rope, quote, enc), pos(1, 9));
assert_eq!(lsp_to_offset(&rope, pos(1, 9), enc), quote);
// A line past the end of the text clamps to the end.
assert_eq!(lsp_to_offset(&rope, pos(9, 0), enc), rope.len());
// And the editor's own positions count chars.
assert_eq!(lsp_to_editor(&rope, pos(1, 9), enc), pos(1, 8));
assert_eq!(lsp_to_editor(&rope, pos(0, 3), enc), pos(0, 3));
}
#[test]
fn diagnostics_move_to_editor_columns_and_are_counted() {
let rope = Rope::from("🎉🎉x\n");
let d = |sev, c| lsp_types::Diagnostic {
range: LspRange::new(pos(0, c), pos(0, c + 1)),
severity: sev,
message: "m".into(),
..Default::default()
};
let diags = vec![
d(Some(lsp_types::DiagnosticSeverity::ERROR), 4),
d(Some(lsp_types::DiagnosticSeverity::WARNING), 0),
d(None, 0),
];
let mapped = diagnostics_to_editor(&rope, &diags, Encoding::Utf16);
assert_eq!(mapped[0].range, LspRange::new(pos(0, 2), pos(0, 3)));
assert_eq!(mapped[0].message, "m");
assert_eq!(count_problems(&diags), (1, 1));
}
#[test]
fn edits_apply_back_to_front_and_same_place_inserts_keep_their_order() {
let edits = vec![
edit(0, 0, 0, 0, "A"),
edit(1, 0, 1, 3, "LET"),
edit(0, 0, 0, 0, "B"),
edit(0, 3, 0, 5, "__"),
];
let ordered = in_application_order(&edits);
let texts: Vec<&str> = ordered.iter().map(|e| e.new_text.as_str()).collect();
assert_eq!(texts, ["LET", "__", "B", "A"]);
assert_eq!(
apply_edits("abcdef\nlet x\n", &edits, Encoding::Utf16),
"ABabc__f\nLET x\n"
);
}
#[test]
fn edits_on_a_file_count_utf16_columns_and_ignore_crlf() {
let text = "let 🎉x = 1;\r\nx;\r\n";
// `x` after the emoji is UTF-16 column 6.
let edits = vec![edit(0, 6, 0, 7, "y"), edit(1, 0, 1, 1, "y")];
assert_eq!(
apply_edits(text, &edits, Encoding::Utf16),
"let 🎉y = 1;\r\ny;\r\n"
);
}
#[test]
fn editor_edits_convert_each_range_against_the_original_text() {
let rope = Rope::from("🎉a🎉b\n");
let edits = vec![edit(0, 2, 0, 3, "A"), edit(0, 5, 0, 6, "B")];
let mapped = edits_to_editor(&rope, &edits, Encoding::Utf16);
assert_eq!(mapped[0].range, LspRange::new(pos(0, 3), pos(0, 4)));
assert_eq!(mapped[0].new_text, "B");
assert_eq!(mapped[1].range, LspRange::new(pos(0, 1), pos(0, 2)));
}
#[test]
fn snippets_become_their_default_text() {
assert_eq!(snippet_to_plain("println!(\"$1\")$0"), "println!(\"\")");
assert_eq!(
snippet_to_plain("fn ${1:name}(${2:args}) {\n\t$0\n}"),
"fn name(args) {\n\t\n}"
);
assert_eq!(snippet_to_plain("${1:outer ${2:inner}}"), "outer inner");
assert_eq!(snippet_to_plain("${1|one,two|}"), "one");
assert_eq!(snippet_to_plain("cost: \\$5 ${TM_FILENAME}"), "cost: $5 ");
assert_eq!(snippet_to_plain("a $ b"), "a $ b");
assert_eq!(snippet_to_plain("x${1}y"), "xy");
}
}
+471
View File
@@ -0,0 +1,471 @@
//! Where the code editor meets the language servers: the few calls
//! `code_editor.rs` makes when its buffers change, the commands (go to
//! definition, format, rename), and the status bar's word on the server.
use std::path::PathBuf;
use std::rc::Rc;
use gpui::prelude::*;
use gpui::{AnyElement, Context, Entity, WeakEntity, Window, div, px};
use gpui_component::input::InputState;
use gpui_component::menu::PopupMenu;
use gpui_component::{
ActiveTheme as _, Icon, IconName, RopeExt as _, Sizable as _, WindowExt as _, h_flex,
};
use lsp_types::Position;
use super::convert::{self, Encoding};
use super::{Freshen, LspStatus, LspStore};
use crate::ui::app::Tty7App;
use crate::ui::code_editor::{BufferId, LocalFile};
use crate::ui::i18n::{L10nKey, t, t_fmt};
/// One buffer offered to the language servers.
pub(crate) struct LspBuffer {
pub(crate) input: Entity<InputState>,
pub(crate) path: PathBuf,
pub(crate) language: &'static str,
pub(crate) app: WeakEntity<Tty7App>,
}
impl Tty7App {
fn lsp_enabled(cx: &gpui::App) -> bool {
cx.global::<crate::core::config::Config>().editor_lsp
}
/// The set of open buffers changed — one opened, closed, or was saved
/// under a new name. Servers start and documents open or close to match.
pub(crate) fn lsp_sync_buffers(&self, cx: &mut Context<Self>) {
let app = cx.entity().downgrade();
let buffers = if Self::lsp_enabled(cx) {
self.editor_local_files()
.into_iter()
.map(|f| LspBuffer {
input: f.input,
path: f.path,
language: f.language,
app: app.clone(),
})
.collect()
} else {
Vec::new()
};
let app = cx.entity().downgrade();
LspStore::register_window(
cx.entity_id(),
Rc::new(move |cx: &mut gpui::App| {
app.update(cx, |app, cx| app.lsp_sync_buffers(cx)).is_ok()
}),
cx,
);
LspStore::sync_window(cx.entity_id(), buffers, cx);
}
/// The window is closing: every document it owns is closed now, so an
/// idle server can wind down instead of outliving the window (with a
/// tray icon the app itself keeps running).
pub(crate) fn lsp_window_closed(&self, cx: &mut Context<Self>) {
LspStore::sync_window(cx.entity_id(), Vec::new(), cx);
}
/// A buffer's text changed.
pub(crate) fn lsp_buffer_edited(&self, id: BufferId, cx: &mut Context<Self>) {
if let Some(f) = self.editor_local_file(id) {
LspStore::edited(f.input.entity_id(), &f.path, cx);
self.lsp_signature_help_after_edit(&f, cx);
}
}
/// A buffer was written to disk.
pub(crate) fn lsp_buffer_saved(&self, id: BufferId, cx: &mut Context<Self>) {
if let Some(f) = self.editor_local_file(id) {
LspStore::saved(f.input.entity_id(), &f.path, cx);
}
}
/// The front file, if a server has it and the editor has the focus.
fn lsp_active(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<(LocalFile, super::DocContext)> {
let f = self.editor_active_local_file()?;
let text = f.input.read(cx).text().clone();
let doc = LspStore::context(
&f.path,
f.input.entity_id(),
Freshen::Text(&text),
Some(window.window_handle()),
cx,
)?;
Some((f, doc))
}
/// Opens `path` with the cursor on a server's `position` — through the
/// same open-at-line path a `file.rs:12:5` link takes, so navigation
/// history sees a definition jump like any other.
pub(crate) fn lsp_open_location(
&mut self,
path: PathBuf,
position: Position,
encoding: Encoding,
window: &mut Window,
cx: &mut Context<Self>,
) {
// The open path counts columns in chars, from one.
let target = path.clone();
let open = move |app: &mut Tty7App,
character: u32,
window: &mut Window,
cx: &mut Context<Tty7App>| {
app.open_file_in_editor_at(
&target,
Some(position.line + 1),
Some(character + 1),
window,
cx,
);
};
let open_buffer = self
.editor_local_files()
.into_iter()
.find(|f| f.path == path.as_path());
if let Some(f) = open_buffer {
let text = f.input.read(cx).text().clone();
let character = convert::lsp_to_editor(&text, position, encoding).character;
open(self, character, window, cx);
return;
}
// Not open: the column is converted against the file on disk.
let read_from = path.clone();
let line = position.line as usize;
cx.spawn_in(window, async move |app, cx| {
let character = cx
.background_spawn(async move {
let text = std::fs::read_to_string(&read_from).ok()?;
let line = text.lines().nth(line)?;
let byte = convert::byte_of_column(line, position.character, encoding);
Some(line[..byte].chars().count() as u32)
})
.await
.unwrap_or(position.character);
let _ = app.update_in(cx, |app, window, cx| open(app, character, window, cx));
})
.detach();
}
/// F12: the definition of the symbol under the cursor.
pub(crate) fn lsp_go_to_definition(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((f, doc)) = self.lsp_active(window, cx) else {
return;
};
let state = f.input.read(cx);
let request = super::providers::definition_request(&doc, state.text(), state.cursor());
let (encoding, root) = (doc.encoding, doc.root.clone());
cx.spawn_in(window, async move |app, cx| {
let links = match request.await {
Ok(links) => links,
Err(e) => {
log::debug!("lsp: definition: {e:#}");
return;
}
};
let places: Vec<_> = links
.into_iter()
.filter_map(|l| {
Some((
super::uri_to_path(&l.target_uri)?,
l.target_selection_range.start,
))
})
.collect();
let _ = app.update_in(cx, |app, window, cx| match places.as_slice() {
[] => {}
// One answer is followed straight away, through the same path
// a secondary-click takes.
[(path, position)] => {
app.lsp_open_location(path.clone(), *position, encoding, window, cx)
}
_ => app.lsp_show_places(
L10nKey::SearchHeadingDefinitions,
places,
encoding,
root,
window,
cx,
),
});
})
.detach();
}
/// ⇧⌥F: the server's formatting of the whole file, applied as edits so
/// it undoes like one.
pub(crate) fn lsp_format_document(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((f, doc)) = self.lsp_active(window, cx) else {
return;
};
if doc.caps.document_formatting_provider.is_none() {
return;
}
let before = f.input.read(cx).text().clone();
let request = doc.client.request::<lsp_types::request::Formatting>(
lsp_types::DocumentFormattingParams {
text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
options: lsp_types::FormattingOptions {
tab_size: f.indent.size as u32,
insert_spaces: !f.indent.hard_tabs,
trim_trailing_whitespace: Some(true),
insert_final_newline: Some(true),
..Default::default()
},
work_done_progress_params: Default::default(),
},
);
let input = f.input.clone();
let encoding = doc.encoding;
cx.spawn_in(window, async move |_, cx| {
let edits = match request.await {
Ok(Some(edits)) if !edits.is_empty() => edits,
Ok(_) => return,
Err(e) => {
log::info!("lsp: formatting: {e:#}");
return;
}
};
let _ = cx.update(|window, cx| {
input.update(cx, |state, cx| {
// Typed over while the server worked: its edits describe
// a text that is gone.
if *state.text() != before {
return;
}
let edits = convert::edits_to_editor(&before, &edits, encoding);
state.apply_lsp_edits(&edits, window, cx);
});
});
})
.detach();
}
/// F2: asks for a new name for the symbol under the cursor.
pub(crate) fn lsp_rename_start(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((f, doc)) = self.lsp_active(window, cx) else {
return;
};
if doc.caps.rename_provider.is_none() {
return;
}
let state = f.input.read(cx);
let offset = state.cursor();
let Some(word) = state
.text()
.word_range(offset)
.map(|r| state.text().slice(r).to_string())
else {
return;
};
if word.trim().is_empty() {
return;
}
self.editor_open_rename_bar(f.id, offset, word, window, cx);
}
/// The rename prompt was answered.
pub(crate) fn lsp_rename(
&mut self,
id: BufferId,
offset: usize,
new_name: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(f) = self.editor_local_file(id) else {
return;
};
let text = f.input.read(cx).text().clone();
let Some(doc) = LspStore::context(
&f.path,
f.input.entity_id(),
Freshen::Text(&text),
Some(window.window_handle()),
cx,
) else {
return;
};
// Every open buffer as the server sees it now; the answer is applied
// only if none of those it touches has changed by then.
let baseline = LspStore::snapshot(cx);
let old_name = text
.word_range(offset)
.map(|r| text.slice(r).to_string())
.unwrap_or_default();
let request = doc
.client
.request::<lsp_types::request::Rename>(lsp_types::RenameParams {
text_document_position: lsp_types::TextDocumentPositionParams::new(
lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
convert::offset_to_lsp(&text, offset, doc.encoding),
),
new_name,
work_done_progress_params: Default::default(),
});
let encoding = doc.encoding;
cx.spawn_in(window, async move |_, cx| {
let result = request.await;
let _ = cx.update(|window, cx| match result {
Ok(Some(edit)) => {
super::apply_workspace_edit(edit, encoding, Some(baseline), cx);
}
Ok(None) => {}
Err(e) => {
let context = t_fmt(L10nKey::LspRenameFailed, &[("name", &old_name)]);
window.push_notification(
crate::ui::host_ops::failure(context, &format!("{e:#}")),
cx,
);
}
});
})
.detach();
}
/// ⌘.: the server's fixes and refactors at the cursor, in the editor's
/// own code-action menu.
pub(crate) fn lsp_code_actions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.editor_active_local_file().is_none() {
return;
}
window.dispatch_action(Box::new(gpui_component::input::ToggleCodeActions), cx);
}
/// The language-server commands for the editor's right-click menu, once
/// the buffer's server is up.
pub(crate) fn lsp_menu_items(
&self,
menu: PopupMenu,
id: BufferId,
cx: &gpui::App,
) -> PopupMenu {
use crate::core::actions::{
EditorFindReferences, EditorFormatDocument, EditorGoToDefinition, EditorQuickFix,
EditorRenameSymbol,
};
let Some(f) = self.editor_local_file(id) else {
return menu;
};
if !matches!(
LspStore::status(&f.path, f.language, cx),
Some(LspStatus::Running { .. })
) {
return menu;
}
menu.separator()
.menu(
t(L10nKey::LspGoToDefinition),
Box::new(EditorGoToDefinition),
)
.menu(
t(L10nKey::LspFindReferences),
Box::new(EditorFindReferences),
)
.menu(t(L10nKey::LspQuickFix), Box::new(EditorQuickFix))
.menu(
t(L10nKey::LspRenameSymbolAction),
Box::new(EditorRenameSymbol),
)
.menu(
t(L10nKey::LspFormatDocument),
Box::new(EditorFormatDocument),
)
}
/// The status bar's word on the front file's server: its problem counts
/// once it runs, or why there are none.
/// The language server's state for the status bar, and whether it is the
/// missing-server note, which leads the bar rather than joining the
/// readouts at its end.
pub(crate) fn render_lsp_status(&self, cx: &mut Context<Self>) -> Option<(AnyElement, bool)> {
if !Self::lsp_enabled(cx) {
return None;
}
let f = self.editor_active_local_file()?;
let status = LspStore::status(&f.path, f.language, cx)?;
let muted = cx.theme().muted_foreground;
let element = match status {
LspStatus::Missing(name) => {
return Some((
h_flex()
.min_w_0()
.gap(px(6.))
.items_center()
.child(
div()
.flex_none()
.size(px(5.))
.rounded_full()
.bg(cx.theme().warning),
)
.child(
div()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.child(t_fmt(L10nKey::LspServerMissing, &[("name", name)])),
)
.into_any_element(),
true,
));
}
LspStatus::Starting(name) => div()
.flex_none()
.child(t_fmt(L10nKey::LspServerStarting, &[("name", &name)]))
.into_any_element(),
LspStatus::Down(name) => div()
.flex_none()
.child(t_fmt(L10nKey::LspServerDown, &[("name", &name)]))
.into_any_element(),
LspStatus::Running {
name,
errors,
warnings,
} => {
let tip = format!(
"{name} — {}",
t_fmt(
L10nKey::LspProblemsTooltip,
&[
("errors", &errors.to_string()),
("warnings", &warnings.to_string())
],
)
);
let count = |icon: IconName, n: usize, color: gpui::Hsla| {
h_flex()
.gap(px(3.))
.items_center()
.child(Icon::new(icon).xsmall().text_color(if n > 0 {
color
} else {
muted
}))
.child(n.to_string())
};
h_flex()
.id("lsp-status")
.flex_none()
.gap_2()
.items_center()
.font_features(crate::ui::theme::tabular_figures())
.child(count(IconName::CircleX, errors, cx.theme().danger))
.child(count(IconName::TriangleAlert, warnings, cx.theme().warning))
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(tip.clone()).build(window, cx)
})
.into_any_element()
}
};
Some((element, false))
}
}
+508
View File
@@ -0,0 +1,508 @@
//! Places a language server points at — every reference to a symbol, or a
//! definition with more than one answer — listed in the search's Locations
//! tab: arrowing through it previews a place in the file in front, Return
//! goes there.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use gpui::{App, AppContext as _, Context, Window};
use gpui_component::{Rope, RopeExt as _};
use lsp_types::Position;
use super::convert::{self, Encoding};
use super::{Freshen, LspStore};
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t};
use crate::ui::search::{CommandKind, Item, SearchTab};
/// One place, in the editor's terms: 0-based, the column counted in chars,
/// with the line it is on for the row to show.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Found {
pub(crate) path: PathBuf,
pub(crate) line: u32,
pub(crate) column: u32,
pub(crate) text: String,
}
/// The longest line text a row shows.
const LINE_PREVIEW: usize = 160;
/// Converts server positions to [`Found`]s. Files open in the editor are
/// measured against their buffer (`open`), the rest against the disk — so
/// this runs off the main thread. Sorted by file, then position, duplicates
/// dropped.
pub(crate) fn resolve(
places: Vec<(PathBuf, Position)>,
open: HashMap<PathBuf, Rope>,
enc: Encoding,
) -> Vec<Found> {
let mut found: Vec<Found> = measure(places, &open, enc).into_iter().flatten().collect();
found.sort_by(|a, b| (&a.path, a.line, a.column).cmp(&(&b.path, b.line, b.column)));
found.dedup();
found
}
/// [`resolve`] without the sorting: one answer per place, in order, `None`
/// for a place past the end of its file.
pub(crate) fn measure(
places: Vec<(PathBuf, Position)>,
open: &HashMap<PathBuf, Rope>,
enc: Encoding,
) -> Vec<Option<Found>> {
let mut read: HashMap<PathBuf, Option<Vec<String>>> = HashMap::new();
places
.into_iter()
.map(|(path, pos)| {
let line = match open.get(&path) {
Some(rope) if (pos.line as usize) < rope.lines_len() => {
rope.slice_line(pos.line as usize).to_string()
}
Some(_) => return None,
None => read
.entry(path.clone())
.or_insert_with(|| {
std::fs::read_to_string(&path)
.ok()
.map(|t| t.lines().map(str::to_owned).collect())
})
.as_ref()
.and_then(|lines| lines.get(pos.line as usize).cloned())
.unwrap_or_default(),
};
let byte = convert::byte_of_column(&line, pos.character, enc);
let column = line[..byte].chars().count() as u32;
let text: String = line.trim().chars().take(LINE_PREVIEW).collect();
Some(Found {
path,
line: pos.line,
column,
text,
})
})
.collect()
}
/// A search row for `found`, its path shown relative to `root`.
pub(crate) fn item(found: &Found, root: &Path) -> Item {
let shown = found.path.strip_prefix(root).unwrap_or(&found.path);
Item::new(
format!("{}:{}", shown.display(), found.line + 1),
CommandKind::GoToLocation {
path: found.path.clone(),
line: found.line,
column: found.column,
},
)
.with_subtitle(found.text.clone())
.with_alias(found.path.display().to_string())
}
impl Tty7App {
/// The open local buffers' texts, for measuring places in them.
fn lsp_open_texts(&self, cx: &App) -> HashMap<PathBuf, Rope> {
self.editor_local_files()
.into_iter()
.map(|f| (f.path, f.input.read(cx).text().clone()))
.collect()
}
/// Shows what a server found: one place is gone to at once, several
/// are listed to pick from.
pub(crate) fn lsp_show_places(
&mut self,
heading: L10nKey,
places: Vec<(PathBuf, Position)>,
encoding: Encoding,
root: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
if places.is_empty() {
return;
}
let open = self.lsp_open_texts(cx);
cx.spawn_in(window, async move |app, cx| {
let found = cx
.background_spawn(async move { resolve(places, open, encoding) })
.await;
let _ = app.update_in(cx, |app, window, cx| match found.as_slice() {
[] => {}
[one] => app.open_file_in_editor_at(
&one.path,
Some(one.line + 1),
Some(one.column + 1),
window,
cx,
),
many => {
let items = many.iter().map(|f| item(f, &root)).collect();
app.open_search(SearchTab::Locations, "", window, cx);
app.editor_begin_preview(cx);
if let Some(view) = app.search.clone() {
view.update(cx, |view, cx| {
view.set_locations(Some(t(heading)), items, window, cx)
});
}
}
});
})
.detach();
}
/// ⇧F12: every reference to the symbol under the cursor.
pub(crate) fn lsp_find_references(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(f) = self.editor_active_local_file() else {
return;
};
let (text, offset) = {
let state = f.input.read(cx);
(state.text().clone(), state.cursor())
};
let Some(doc) = LspStore::context(
&f.path,
f.input.entity_id(),
Freshen::Text(&text),
Some(window.window_handle()),
cx,
) else {
return;
};
if doc.caps.references_provider.is_none() {
return;
}
let request =
doc.client
.request::<lsp_types::request::References>(lsp_types::ReferenceParams {
text_document_position: lsp_types::TextDocumentPositionParams::new(
lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
convert::offset_to_lsp(&text, offset, doc.encoding),
),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
context: lsp_types::ReferenceContext {
include_declaration: true,
},
});
let (encoding, root) = (doc.encoding, doc.root.clone());
cx.spawn_in(window, async move |app, cx| {
let locations = match request.await {
Ok(locations) => locations.unwrap_or_default(),
Err(e) => {
log::debug!("lsp: references: {e:#}");
return;
}
};
let places = locations
.into_iter()
.filter_map(|l| Some((super::uri_to_path(&l.uri)?, l.range.start)))
.collect();
let _ = app.update_in(cx, |app, window, cx| {
app.lsp_show_places(
L10nKey::SearchHeadingReferences,
places,
encoding,
root,
window,
cx,
)
});
})
.detach();
}
/// What the Symbols tab asks as its query changes: the front file's
/// language server, for symbols anywhere in the project. `None` when
/// there is no such server, and the tab lists the file's own alone.
pub(crate) fn lsp_project_symbol_query(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<crate::ui::search::LiveQuery> {
let f = self.editor_active_local_file()?;
let doc = LspStore::context(&f.path, f.input.entity_id(), Freshen::Skip, None, cx)?;
doc.caps.workspace_symbol_provider.as_ref()?;
let app = cx.entity().downgrade();
let handle = window.window_handle();
let path = f.path.clone();
let requester = f.input.entity_id();
Some(std::rc::Rc::new(move |query: &str, cx: &mut App| {
let (app, path, query) = (app.clone(), path.clone(), query.to_owned());
// Deferred: the query arrives from inside the search's own
// update, and answering it updates the search.
cx.defer(move |cx| {
let _ = handle.update(cx, |_, window, cx| {
let _ = app.update(cx, |app, cx| {
app.lsp_workspace_query(&path, requester, query, window, cx)
});
});
});
}))
}
fn lsp_workspace_query(
&mut self,
path: &Path,
requester: gpui::EntityId,
query: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
use std::sync::atomic::{AtomicU64, Ordering};
// The latest query wins: an answer to an older one is dropped.
static LATEST: AtomicU64 = AtomicU64::new(0);
let seq = LATEST.fetch_add(1, Ordering::Relaxed) + 1;
if query.trim().is_empty() {
if let Some(view) = self.search.clone() {
view.update(cx, |view, cx| {
view.set_project_symbols(Vec::new(), window, cx)
});
}
return;
}
let Some(doc) = LspStore::context(path, requester, Freshen::Skip, None, cx) else {
return;
};
let client = doc.client.clone();
let (encoding, root) = (doc.encoding, doc.root.clone());
let open = self.lsp_open_texts(cx);
let front = path.to_path_buf();
cx.spawn_in(window, async move |app, cx| {
// Typing fast asks once, for what was typed last.
cx.background_executor()
.timer(std::time::Duration::from_millis(150))
.await;
if LATEST.load(Ordering::Relaxed) != seq {
return;
}
let answer = client
.request::<lsp_types::request::WorkspaceSymbolRequest>(
lsp_types::WorkspaceSymbolParams {
query,
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
},
)
.await;
// The front file's own symbols are listed above these already.
let symbols = match answer {
Ok(Some(symbols)) => workspace_symbols(symbols)
.into_iter()
.filter(|s| s.path != front)
.collect::<Vec<_>>(),
Ok(None) => Vec::new(),
Err(e) => {
log::debug!("lsp: workspace/symbol: {e:#}");
return;
}
};
let items = cx
.background_spawn(async move {
let places = symbols
.iter()
.map(|s| (s.path.clone(), s.position))
.collect();
measure(places, &open, encoding)
.into_iter()
.zip(symbols)
.filter_map(|(found, symbol)| Some(symbol_item(&found?, &symbol, &root)))
.collect::<Vec<_>>()
})
.await;
if LATEST.load(Ordering::Relaxed) != seq {
return;
}
let _ = app.update_in(cx, |app, window, cx| {
if let Some(view) = app.search.clone()
&& view.read(cx).tab() == SearchTab::Symbols
{
view.update(cx, |view, cx| view.set_project_symbols(items, window, cx));
}
});
})
.detach();
}
/// The Locations list moved onto a place: shown at once when it is in
/// the file in front, where the preview runs.
pub(crate) fn lsp_preview_location(
&mut self,
path: &Path,
line: u32,
column: u32,
cx: &mut Context<Self>,
) {
let in_front = self
.editor_preview_buffer()
.and_then(|id| self.editor_local_file(id))
.is_some_and(|f| f.path == path);
if in_front {
self.editor_symbol_preview(line, column, cx);
}
}
}
/// The most project symbols (LSP `workspace/symbol`) listed for one query.
const MAX_WORKSPACE_SYMBOLS: usize = 200;
/// One project symbol, before its column is measured. "Workspace" is the
/// protocol's word for the server's project root — not a tty7 workspace.
pub(crate) struct WorkspaceHit {
pub(crate) name: String,
pub(crate) kind: lsp_types::SymbolKind,
pub(crate) container: Option<String>,
pub(crate) path: PathBuf,
pub(crate) position: Position,
}
/// A `workspace/symbol` answer in either of its shapes. A symbol known only
/// by its file (no range yet) lands on the file's first line.
#[allow(deprecated)]
pub(crate) fn workspace_symbols(answer: lsp_types::WorkspaceSymbolResponse) -> Vec<WorkspaceHit> {
use lsp_types::{OneOf, WorkspaceSymbolResponse};
let hits: Vec<WorkspaceHit> = match answer {
WorkspaceSymbolResponse::Flat(symbols) => symbols
.into_iter()
.filter_map(|s| {
Some(WorkspaceHit {
path: super::uri_to_path(&s.location.uri)?,
position: s.location.range.start,
name: s.name,
kind: s.kind,
container: s.container_name,
})
})
.collect(),
WorkspaceSymbolResponse::Nested(symbols) => symbols
.into_iter()
.filter_map(|s| {
let (uri, position) = match s.location {
OneOf::Left(l) => (l.uri, l.range.start),
OneOf::Right(l) => (l.uri, Position::default()),
};
Some(WorkspaceHit {
path: super::uri_to_path(&uri)?,
position,
name: s.name,
kind: s.kind,
container: s.container_name,
})
})
.collect(),
};
hits.into_iter().take(MAX_WORKSPACE_SYMBOLS).collect()
}
fn symbol_item(found: &Found, symbol: &WorkspaceHit, root: &Path) -> Item {
let kind = serde_json::to_value(symbol.kind)
.ok()
.and_then(|v| v.as_u64())
.unwrap_or(0);
let shown = found.path.strip_prefix(root).unwrap_or(&found.path);
let place = format!("{}:{}", shown.display(), found.line + 1);
let subtitle = match symbol.container.as_deref().filter(|c| !c.is_empty()) {
Some(container) => format!("{container} · {place}"),
None => place,
};
Item::new(
symbol.name.clone(),
CommandKind::GoToLocation {
path: found.path.clone(),
line: found.line,
column: found.column,
},
)
.with_subtitle(subtitle)
.with_note(crate::ui::code_editor::outline::SymbolKind::from_lsp(kind as u32).tag())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn places_are_measured_against_open_buffers_and_the_disk() {
let dir = tempfile::TempDir::new().unwrap();
let on_disk = dir.path().join("b.rs");
std::fs::write(&on_disk, "fn b() {\n let 🎉 = a();\n}\n").unwrap();
let open_path = dir.path().join("a.rs");
let open = HashMap::from([(open_path.clone(), Rope::from("pub fn é_a() {}\n"))]);
let found = resolve(
vec![
// `a` after the emoji: UTF-16 column 13, char column 12.
(on_disk.clone(), Position::new(1, 13)),
(open_path.clone(), Position::new(0, 7)),
(open_path.clone(), Position::new(0, 7)),
(open_path.clone(), Position::new(9, 0)),
],
open,
Encoding::Utf16,
);
assert_eq!(
found,
[
Found {
path: open_path,
line: 0,
column: 7,
text: "pub fn é_a() {}".into(),
},
Found {
path: on_disk,
line: 1,
column: 12,
text: "let 🎉 = a();".into(),
},
]
);
}
#[test]
#[allow(deprecated)]
fn workspace_symbols_become_rows_naming_where_they_live() {
let root = super::super::test_path("/p");
let uri = super::super::path_to_uri(&root.join("src").join("net.rs")).unwrap();
let hits = workspace_symbols(lsp_types::WorkspaceSymbolResponse::Flat(vec![
lsp_types::SymbolInformation {
name: "Server".into(),
kind: lsp_types::SymbolKind::STRUCT,
tags: None,
deprecated: None,
location: lsp_types::Location::new(
uri,
lsp_types::Range::new(Position::new(3, 11), Position::new(3, 17)),
),
container_name: Some("net".into()),
},
]));
assert_eq!(hits.len(), 1);
let found = Found {
path: hits[0].path.clone(),
line: 3,
column: 11,
text: String::new(),
};
let row = symbol_item(&found, &hits[0], &root);
assert_eq!(row.title, "Server");
let rel = Path::new("src").join("net.rs");
assert_eq!(
row.subtitle.as_deref(),
Some(format!("net · {}:4", rel.display()).as_str())
);
assert_eq!(row.note.as_deref(), Some("struct"));
}
#[test]
fn a_row_names_its_file_relative_to_the_project() {
let found = Found {
path: PathBuf::from("/p/src/lib.rs"),
line: 4,
column: 2,
text: "x".into(),
};
let row = item(&found, Path::new("/p"));
assert_eq!(row.title, "src/lib.rs:5");
assert_eq!(row.subtitle.as_deref(), Some("x"));
}
}
+1479
View File
File diff suppressed because it is too large Load Diff
+648
View File
@@ -0,0 +1,648 @@
//! gpui-component's editor hooks, answered by a language server.
//!
//! One [`LspProvider`] per buffer, installed into the buffer's
//! `InputState::lsp` while the buffer has a server and taken out when it
//! stops having one. Every request carries positions through
//! [`super::convert`]: gpui-component counts columns in chars, a server in
//! UTF-16 units.
//!
//! The completion, hover and definition hooks run inside the buffer's own
//! update, so they must not read the buffer: the text they need is handed in.
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use anyhow::Result;
use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity, Window};
use gpui_component::input::{
CodeActionProvider, CompletionProvider, DefinitionProvider, HoverProvider, InputState,
};
use gpui_component::{Rope, RopeExt as _};
use lsp_types::{
CodeAction, CodeActionOrCommand, CompletionContext, CompletionResponse, CompletionTextEdit,
CompletionTriggerKind, InsertTextFormat, LocationLink,
};
use super::convert::{self, Encoding};
use super::{DocContext, Freshen, LspStore};
use crate::ui::app::Tty7App;
/// The most completion items handed to the menu. A server asked at the top
/// of a file can offer thousands; the menu filters as you type, and the
/// server is asked again on the next keystroke anyway.
const MAX_COMPLETIONS: usize = 300;
pub(crate) struct LspProvider {
/// Every open buffer's text when the code-action menu was last filled:
/// an action picked from it is applied only if what it edits is
/// unchanged since.
actions_baseline: std::cell::RefCell<Option<std::collections::HashMap<PathBuf, Rope>>>,
path: PathBuf,
/// The buffer this provider serves — always the document's owner.
input: gpui::EntityId,
app: WeakEntity<Tty7App>,
}
pub(super) fn install(
input: &Entity<InputState>,
path: &Path,
app: WeakEntity<Tty7App>,
cx: &mut App,
) {
let provider = Rc::new(LspProvider {
actions_baseline: Default::default(),
path: path.to_path_buf(),
input: input.entity_id(),
app,
});
input.update(cx, |state, cx| {
state.lsp.completion_provider = Some(provider.clone());
state.lsp.hover_provider = Some(provider.clone());
state.lsp.definition_provider = Some(provider.clone());
state.lsp.code_action_providers = vec![provider];
cx.notify();
});
}
pub(super) fn uninstall(input: &Entity<InputState>, cx: &mut App) {
input.update(cx, |state, cx| {
state.lsp.completion_provider = None;
state.lsp.hover_provider = None;
state.lsp.definition_provider = None;
state.lsp.code_action_providers.clear();
cx.notify();
});
}
impl LspProvider {
fn context(&self, sync: Freshen<'_>, window: &Window, cx: &mut App) -> Option<DocContext> {
LspStore::context(
&self.path,
self.input,
sync,
Some(window.window_handle()),
cx,
)
}
}
fn not_available<T: Default + Send + 'static>() -> Task<Result<T>> {
Task::ready(Ok(T::default()))
}
impl CompletionProvider for LspProvider {
fn completions(
&self,
text: &Rope,
offset: usize,
_trigger: CompletionContext,
window: &mut Window,
cx: &mut Context<InputState>,
) -> Task<Result<CompletionResponse>> {
let Some(doc) = self.context(Freshen::Text(text), window, cx) else {
return Task::ready(Ok(CompletionResponse::Array(Vec::new())));
};
if doc.caps.completion_provider.is_none() {
return Task::ready(Ok(CompletionResponse::Array(Vec::new())));
}
// gpui-component reports the word typed so far as the "trigger
// character"; a server wants the character that actually triggered.
let point = text.offset_to_point(offset);
let line = text.slice_line(point.row).to_string();
let before = line.get(..point.column).and_then(|l| l.chars().last());
let triggers = LspStore::completion_triggers(&self.path, cx);
let context = match before.map(String::from) {
Some(c) if triggers.contains(&c) => CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: Some(c),
},
_ => CompletionContext {
trigger_kind: CompletionTriggerKind::INVOKED,
trigger_character: None,
},
};
let params = lsp_types::CompletionParams {
text_document_position: lsp_types::TextDocumentPositionParams::new(
lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
convert::offset_to_lsp(text, offset, doc.encoding),
),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
context: Some(context),
};
let request = doc.client.request::<lsp_types::request::Completion>(params);
let text = text.clone();
let encoding = doc.encoding;
cx.background_spawn(async move {
let items = match request.await? {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
None => Vec::new(),
};
let items = items
.into_iter()
.take(MAX_COMPLETIONS)
.map(|item| completion_to_editor(item, &text, encoding))
.collect();
Ok(CompletionResponse::Array(items))
})
}
fn resolve_completion(
&self,
item: lsp_types::CompletionItem,
text: &Rope,
cx: &mut App,
) -> Task<Result<lsp_types::CompletionItem>> {
// Resolving asks nothing of the text, which the caller may be in the
// middle of changing: nothing is sent first.
let Some(doc) = LspStore::context(&self.path, self.input, Freshen::Skip, None, cx) else {
return Task::ready(Ok(item));
};
let resolvable = doc
.caps
.completion_provider
.as_ref()
.is_some_and(|c| c.resolve_provider == Some(true));
if !resolvable {
return Task::ready(Ok(item));
}
let request = doc
.client
.request::<lsp_types::request::ResolveCompletionItem>(item.clone());
let text = text.clone();
let encoding = doc.encoding;
cx.background_spawn(async move {
Ok(match request.await {
Ok(resolved) => merge_resolved(item, resolved, &text, encoding),
Err(e) => {
log::debug!("lsp: completionItem/resolve: {e:#}");
item
}
})
})
}
fn is_completion_trigger(
&self,
_offset: usize,
new_text: &str,
cx: &mut Context<InputState>,
) -> bool {
let Some(last) = new_text.chars().last() else {
return false;
};
if last.is_alphanumeric() || last == '_' {
return true;
}
LspStore::completion_triggers(&self.path, cx).contains(&last.to_string())
}
}
/// A completion item as the editor's menu can insert it: ranges in editor
/// columns, snippets flattened to plain text.
pub(crate) fn completion_to_editor(
mut item: lsp_types::CompletionItem,
text: &Rope,
encoding: Encoding,
) -> lsp_types::CompletionItem {
let snippet = item.insert_text_format == Some(InsertTextFormat::SNIPPET);
if snippet {
item.insert_text = item.insert_text.map(|t| convert::snippet_to_plain(&t));
item.insert_text_format = Some(InsertTextFormat::PLAIN_TEXT);
}
item.text_edit = item.text_edit.map(|edit| match edit {
CompletionTextEdit::Edit(mut e) => {
e.range = convert::range_to_editor(text, e.range, encoding);
if snippet {
e.new_text = convert::snippet_to_plain(&e.new_text);
}
CompletionTextEdit::Edit(e)
}
// Declined in the client capabilities, but a server may send one
// anyway: replacing is what accepting a completion does here.
CompletionTextEdit::InsertAndReplace(e) => CompletionTextEdit::Edit(lsp_types::TextEdit {
range: convert::range_to_editor(text, e.replace, encoding),
new_text: if snippet {
convert::snippet_to_plain(&e.new_text)
} else {
e.new_text
},
}),
});
item.additional_text_edits = item
.additional_text_edits
.map(|edits| additional_edits_to_editor(edits, text, encoding));
item
}
fn additional_edits_to_editor(
edits: Vec<lsp_types::TextEdit>,
text: &Rope,
encoding: Encoding,
) -> Vec<lsp_types::TextEdit> {
edits
.into_iter()
.map(|e| lsp_types::TextEdit {
range: convert::range_to_editor(text, e.range, encoding),
new_text: e.new_text,
})
.collect()
}
/// What `completionItem/resolve` adds to an item the menu already holds.
///
/// Only the fields resolving is for are taken: the item the server echoes
/// back carries the editor-column ranges it was sent, which converting again
/// would shift. An item resolved without imports says so with an empty list,
/// so accepting it does not ask again.
pub(crate) fn merge_resolved(
mut item: lsp_types::CompletionItem,
resolved: lsp_types::CompletionItem,
text: &Rope,
encoding: Encoding,
) -> lsp_types::CompletionItem {
item.documentation = resolved.documentation.or(item.documentation);
item.detail = resolved.detail.or(item.detail);
item.command = resolved.command.or(item.command);
item.additional_text_edits = Some(
resolved
.additional_text_edits
.map(|edits| additional_edits_to_editor(edits, text, encoding))
.or(item.additional_text_edits)
.unwrap_or_default(),
);
item
}
impl HoverProvider for LspProvider {
fn hover(
&self,
text: &Rope,
offset: usize,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Option<lsp_types::Hover>>> {
let Some(doc) = self.context(Freshen::Text(text), window, cx) else {
return not_available();
};
if doc.caps.hover_provider.is_none() {
return not_available();
}
let params = lsp_types::HoverParams {
text_document_position_params: lsp_types::TextDocumentPositionParams::new(
lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
convert::offset_to_lsp(text, offset, doc.encoding),
),
work_done_progress_params: Default::default(),
};
let request = doc
.client
.request::<lsp_types::request::HoverRequest>(params);
let text = text.clone();
let encoding = doc.encoding;
cx.background_spawn(async move {
let Some(mut hover) = request.await? else {
return Ok(None);
};
if hover_is_empty(&hover.contents) {
return Ok(None);
}
hover.range = hover
.range
.map(|r| convert::range_to_editor(&text, r, encoding));
hover.contents = fence_language_strings(hover.contents);
Ok(Some(hover))
})
}
}
fn hover_is_empty(contents: &lsp_types::HoverContents) -> bool {
use lsp_types::{HoverContents, MarkedString};
let blank = |m: &MarkedString| match m {
MarkedString::String(s) => s.trim().is_empty(),
MarkedString::LanguageString(l) => l.value.trim().is_empty(),
};
match contents {
HoverContents::Scalar(m) => blank(m),
HoverContents::Array(a) => a.iter().all(blank),
HoverContents::Markup(m) => m.value.trim().is_empty(),
}
}
/// The popover renders Markdown and shows a bare `{ language, value }` pair as
/// plain text; as a fenced block it gets its code styling.
fn fence_language_strings(contents: lsp_types::HoverContents) -> lsp_types::HoverContents {
use lsp_types::{HoverContents, MarkedString};
let fence = |m: MarkedString| match m {
MarkedString::LanguageString(l) => {
MarkedString::String(format!("```{}\n{}\n```", l.language, l.value))
}
other => other,
};
match contents {
HoverContents::Scalar(m) => HoverContents::Scalar(fence(m)),
HoverContents::Array(a) => HoverContents::Array(a.into_iter().map(fence).collect()),
markup => markup,
}
}
impl DefinitionProvider for LspProvider {
fn definitions(
&self,
text: &Rope,
offset: usize,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Vec<LocationLink>>> {
let Some(doc) = self.context(Freshen::Text(text), window, cx) else {
return not_available();
};
let request = definition_request(&doc, text, offset);
let text = text.clone();
let encoding = doc.encoding;
cx.background_spawn(async move {
let mut links = request.await?;
// Only the origin is in this buffer; the target is converted when
// it is followed, against the file it is in.
for link in &mut links {
link.origin_selection_range = link
.origin_selection_range
.map(|r| convert::range_to_editor(&text, r, encoding));
}
Ok(links)
})
}
fn open_definition(&self, location: &LocationLink, window: &mut Window, cx: &mut App) -> bool {
let Some(path) = super::uri_to_path(&location.target_uri) else {
// An `http:` target and the like: gpui-component opens it.
return false;
};
let position = location.target_selection_range.start;
let encoding = LspStore::context(&self.path, self.input, Freshen::Skip, None, cx)
.map(|d| d.encoding)
.unwrap_or_default();
let app = self.app.clone();
// Deferred: this runs inside the buffer's update, and opening a file
// — even this same one — updates buffers.
window.defer(cx, move |window, cx| {
let _ = app.update(cx, |app, cx| {
app.lsp_open_location(path, position, encoding, window, cx)
});
});
true
}
}
/// `textDocument/definition` at `offset`, answered as location links
/// whatever shape the server replied in.
pub(crate) fn definition_request(
doc: &DocContext,
text: &Rope,
offset: usize,
) -> impl std::future::Future<Output = Result<Vec<LocationLink>>> + use<> {
let params = lsp_types::GotoDefinitionParams {
text_document_position_params: lsp_types::TextDocumentPositionParams::new(
lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
convert::offset_to_lsp(text, offset, doc.encoding),
),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let supported = doc.caps.definition_provider.is_some();
let request = doc
.client
.request::<lsp_types::request::GotoDefinition>(params);
async move {
if !supported {
return Ok(Vec::new());
}
use lsp_types::GotoDefinitionResponse as R;
let to_link = |l: lsp_types::Location| LocationLink {
origin_selection_range: None,
target_uri: l.uri,
target_range: l.range,
target_selection_range: l.range,
};
Ok(match request.await? {
Some(R::Scalar(l)) => vec![to_link(l)],
Some(R::Array(ls)) => ls.into_iter().map(to_link).collect(),
Some(R::Link(links)) => links,
None => Vec::new(),
})
}
}
impl CodeActionProvider for LspProvider {
fn id(&self) -> SharedString {
"lsp".into()
}
fn code_actions(
&self,
state: Entity<InputState>,
range: Range<usize>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Vec<CodeAction>>> {
let text = state.read(cx).text().clone();
let Some(doc) = self.context(Freshen::Text(&text), window, cx) else {
return not_available();
};
if doc.caps.code_action_provider.is_none() {
return not_available();
}
*self.actions_baseline.borrow_mut() = Some(LspStore::snapshot(cx));
let lsp_range = convert::offsets_to_lsp_range(&text, range, doc.encoding);
// The diagnostics the range touches, which is what turns a server's
// quick fixes on.
let diagnostics = doc
.diagnostics
.iter()
.filter(|d| {
d.range.start.line <= lsp_range.end.line && d.range.end.line >= lsp_range.start.line
})
.cloned()
.collect();
let params = lsp_types::CodeActionParams {
text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
range: lsp_range,
context: lsp_types::CodeActionContext {
diagnostics,
only: None,
trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let request = doc
.client
.request::<lsp_types::request::CodeActionRequest>(params);
cx.background_spawn(async move {
Ok(request
.await?
.unwrap_or_default()
.into_iter()
.map(|a| match a {
CodeActionOrCommand::CodeAction(a) => a,
CodeActionOrCommand::Command(command) => CodeAction {
title: command.title.clone(),
command: Some(command),
..Default::default()
},
})
.filter(|a| a.disabled.is_none())
.collect())
})
}
fn perform_code_action(
&self,
_state: Entity<InputState>,
action: CodeAction,
_push_to_history: bool,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
// Inside the buffer's update: it cannot be read, and the actions on
// offer were computed against text that was already sent.
let Some(doc) = self.context(Freshen::Skip, window, cx) else {
return Task::ready(Ok(()));
};
let resolvable = matches!(
&doc.caps.code_action_provider,
Some(lsp_types::CodeActionProviderCapability::Options(o))
if o.resolve_provider == Some(true)
);
// Taken when the menu was filled, which is also when an action that
// came with its edit was computed. Without one there is nothing to
// vouch for the open buffers, and an edit to any of them is refused.
let baseline = self
.actions_baseline
.borrow_mut()
.take()
.unwrap_or_default();
let client = doc.client.clone();
let encoding = doc.encoding;
window.spawn(cx, async move |cx| {
let action = if action.edit.is_none() && action.data.is_some() && resolvable {
client
.request::<lsp_types::request::CodeActionResolveRequest>(action.clone())
.await
.unwrap_or(action)
} else {
action
};
if let Some(edit) = action.edit {
let applied = cx.update(|_, cx| {
super::apply_workspace_edit(edit, encoding, Some(baseline), cx)
})?;
if !applied {
// Its command would act on the same stale ground.
return Ok(());
}
}
if let Some(command) = action.command {
// Whatever this changes comes back as `workspace/applyEdit`.
client
.request::<lsp_types::request::ExecuteCommand>(
lsp_types::ExecuteCommandParams {
command: command.command,
arguments: command.arguments.unwrap_or_default(),
work_done_progress_params: Default::default(),
},
)
.await?;
}
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{CompletionItem, Position, Range as LspRange, TextEdit};
#[test]
fn a_snippet_completion_arrives_as_plain_text_in_editor_columns() {
let text = Rope::from("let 🎉 = fo\n");
let item = CompletionItem {
label: "foo".into(),
insert_text_format: Some(InsertTextFormat::SNIPPET),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
// UTF-16: `let ` 4, emoji 2, ` = ` 3 → `fo` at 9..11.
range: LspRange::new(Position::new(0, 9), Position::new(0, 11)),
new_text: "foo(${1:x})$0".into(),
})),
..Default::default()
};
let item = completion_to_editor(item, &text, Encoding::Utf16);
let Some(CompletionTextEdit::Edit(edit)) = item.text_edit else {
panic!("expected a plain edit");
};
assert_eq!(edit.new_text, "foo(x)");
assert_eq!(
edit.range,
LspRange::new(Position::new(0, 8), Position::new(0, 10))
);
assert_eq!(item.insert_text_format, Some(InsertTextFormat::PLAIN_TEXT));
}
#[test]
fn resolving_adds_imports_in_editor_columns_and_keeps_the_edit() {
let text = Rope::from("use é;\nfoo\n");
let item = CompletionItem {
label: "foo".into(),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: LspRange::new(Position::new(1, 0), Position::new(1, 3)),
new_text: "foo".into(),
})),
..Default::default()
};
let resolved = CompletionItem {
label: "foo".into(),
documentation: Some(lsp_types::Documentation::String("Does foo.".into())),
// Echoed back as sent; must not be converted a second time.
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: LspRange::new(Position::new(9, 9), Position::new(9, 9)),
new_text: "wrong".into(),
})),
additional_text_edits: Some(vec![TextEdit {
// After `use é;` — UTF-16 column 6 is char column 6 here.
range: LspRange::new(Position::new(0, 6), Position::new(0, 6)),
new_text: "\nuse bar::foo;".into(),
}]),
..Default::default()
};
let merged = merge_resolved(item.clone(), resolved, &text, Encoding::Utf16);
assert_eq!(merged.text_edit, item.text_edit);
assert!(merged.documentation.is_some());
assert_eq!(
merged.additional_text_edits.unwrap()[0].new_text,
"\nuse bar::foo;"
);
// Nothing to add: said so, so accepting does not ask again.
let bare = merge_resolved(item.clone(), item, &text, Encoding::Utf16);
assert_eq!(bare.additional_text_edits, Some(vec![]));
}
#[test]
fn language_strings_in_a_hover_become_fenced_code() {
let contents = lsp_types::HoverContents::Scalar(lsp_types::MarkedString::LanguageString(
lsp_types::LanguageString {
language: "rust".into(),
value: "fn main()".into(),
},
));
let lsp_types::HoverContents::Scalar(lsp_types::MarkedString::String(s)) =
fence_language_strings(contents)
else {
panic!("expected a markdown string");
};
assert_eq!(s, "```rust\nfn main()\n```");
assert!(hover_is_empty(&lsp_types::HoverContents::Array(vec![])));
}
}
+294
View File
@@ -0,0 +1,294 @@
//! JSON-RPC 2.0 as a language server speaks it over stdio: every message is a
//! `Content-Length` header block followed by exactly that many bytes of JSON.
//!
//! Nothing here touches a process or a thread, so the framing — the part a
//! pipe hands over in arbitrary pieces — can be tested byte by byte.
use serde_json::{Value, json};
/// The id tty7 gives its own requests. A server's requests to us may carry a
/// string id instead; those are echoed back as the raw [`Value`].
pub(crate) type RequestId = i64;
/// Frames one message for the wire.
pub(crate) fn encode(message: &Value) -> Vec<u8> {
let body = serde_json::to_vec(message).unwrap_or_default();
let mut out = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes();
out.extend_from_slice(&body);
out
}
pub(crate) fn request(id: RequestId, method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
}
pub(crate) fn notification(method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "method": method, "params": params })
}
pub(crate) fn response(id: Value, result: Result<Value, ResponseError>) -> Value {
match result {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(e) => json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": e.code, "message": e.message },
}),
}
}
/// The `error` member of a response.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ResponseError {
pub(crate) code: i64,
pub(crate) message: String,
}
impl ResponseError {
pub(crate) const METHOD_NOT_FOUND: i64 = -32601;
pub(crate) fn method_not_found(method: &str) -> Self {
Self {
code: Self::METHOD_NOT_FOUND,
message: format!("tty7 does not handle {method}"),
}
}
}
impl std::fmt::Display for ResponseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({})", self.message, self.code)
}
}
impl std::error::Error for ResponseError {}
/// One message from the server, sorted by what it asks of us.
#[derive(Debug, PartialEq)]
pub(crate) enum Incoming {
/// The answer to one of our requests.
Response {
id: RequestId,
result: Result<Value, ResponseError>,
},
/// The server asking us something; it waits for a [`response`].
Request {
id: Value,
method: String,
params: Value,
},
Notification {
method: String,
params: Value,
},
}
/// Sorts a decoded body. `None` for anything that is not a message this
/// client can act on — a response to an id it never sent, a batch, garbage.
pub(crate) fn classify(body: &[u8]) -> Option<Incoming> {
let mut value: Value = serde_json::from_slice(body).ok()?;
let obj = value.as_object_mut()?;
let method = obj.get("method").and_then(Value::as_str).map(str::to_owned);
let params = obj.remove("params").unwrap_or(Value::Null);
match (method, obj.remove("id")) {
(Some(method), Some(id)) if !id.is_null() => Some(Incoming::Request { id, method, params }),
(Some(method), _) => Some(Incoming::Notification { method, params }),
(None, Some(id)) => {
let id = id.as_i64()?;
let result = match obj.remove("error") {
Some(Value::Object(err)) => Err(ResponseError {
code: err.get("code").and_then(Value::as_i64).unwrap_or(0),
message: err
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
}),
_ => Ok(obj.remove("result").unwrap_or(Value::Null)),
};
Some(Incoming::Response { id, result })
}
(None, None) => None,
}
}
/// The longest header block believed. A server that sends more than this
/// without a blank line is not speaking LSP, and the buffer must not grow
/// without bound waiting for one.
const MAX_HEADER: usize = 8 * 1024;
/// Reassembles messages from whatever pieces a pipe delivers.
#[derive(Default)]
pub(crate) struct FrameReader {
buf: Vec<u8>,
}
impl FrameReader {
pub(crate) fn push(&mut self, bytes: &[u8]) {
self.buf.extend_from_slice(bytes);
}
/// The next complete body, if one has arrived. A header block without a
/// usable `Content-Length` is dropped and reading carries on after it —
/// one garbled message costs that message, not the session.
pub(crate) fn next_frame(&mut self) -> Option<Vec<u8>> {
loop {
let Some(header_end) = find(&self.buf, b"\r\n\r\n") else {
if self.buf.len() > MAX_HEADER {
// No header terminator in sight: keep only a tail that
// could still be the start of one.
let keep = self.buf.len() - 3;
self.buf.drain(..keep);
}
return None;
};
let header = String::from_utf8_lossy(&self.buf[..header_end]);
let length = header.split("\r\n").find_map(|line| {
let (name, value) = line.split_once(':')?;
name.trim()
.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
});
let body_start = header_end + 4;
let Some(length) = length else {
log::warn!("lsp: dropping a message without Content-Length: {header:?}");
self.buf.drain(..body_start);
continue;
};
if self.buf.len() < body_start + length {
return None;
}
let body = self.buf[body_start..body_start + length].to_vec();
self.buf.drain(..body_start + length);
return Some(body);
}
}
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_message_round_trips_through_the_framing() {
let msg = request(7, "textDocument/hover", json!({ "x": "é🎉" }));
let bytes = encode(&msg);
let mut reader = FrameReader::default();
reader.push(&bytes);
let body = reader.next_frame().expect("a whole frame");
assert_eq!(serde_json::from_slice::<Value>(&body).unwrap(), msg);
assert!(reader.next_frame().is_none());
}
#[test]
fn the_length_counts_bytes_not_characters() {
let bytes = encode(&json!("é🎉"));
let text = String::from_utf8(bytes).unwrap();
// `"é🎉"` is 2 quotes + 2 + 4 bytes.
assert!(text.starts_with("Content-Length: 8\r\n\r\n"), "{text:?}");
}
#[test]
fn frames_split_at_every_byte_still_come_out_whole() {
let a = notification("a", json!({ "n": 1 }));
let b = notification("b", json!({ "text": "π".repeat(50) }));
let mut stream = encode(&a);
stream.extend(encode(&b));
let mut reader = FrameReader::default();
let mut got = Vec::new();
for byte in &stream {
reader.push(std::slice::from_ref(byte));
while let Some(body) = reader.next_frame() {
got.push(serde_json::from_slice::<Value>(&body).unwrap());
}
}
assert_eq!(got, vec![a, b]);
}
#[test]
fn two_frames_in_one_read_both_come_out() {
let mut stream = encode(&json!(1));
stream.extend(encode(&json!(2)));
let mut reader = FrameReader::default();
reader.push(&stream);
assert_eq!(reader.next_frame().as_deref(), Some(&b"1"[..]));
assert_eq!(reader.next_frame().as_deref(), Some(&b"2"[..]));
assert_eq!(reader.next_frame(), None);
}
#[test]
fn extra_headers_and_odd_case_are_accepted() {
let body = br#"{"jsonrpc":"2.0","method":"x"}"#;
let mut stream = format!(
"content-type: application/vscode-jsonrpc; charset=utf-8\r\nCONTENT-LENGTH:{}\r\n\r\n",
body.len()
)
.into_bytes();
stream.extend_from_slice(body);
let mut reader = FrameReader::default();
reader.push(&stream);
assert_eq!(reader.next_frame().as_deref(), Some(&body[..]));
}
#[test]
fn a_header_without_a_length_is_skipped() {
let mut stream = b"X-Nothing: 1\r\n\r\n".to_vec();
stream.extend(encode(&json!(3)));
let mut reader = FrameReader::default();
reader.push(&stream);
assert_eq!(reader.next_frame().as_deref(), Some(&b"3"[..]));
}
#[test]
fn messages_are_sorted_by_what_they_ask() {
assert_eq!(
classify(br#"{"jsonrpc":"2.0","id":3,"result":{"a":1}}"#),
Some(Incoming::Response {
id: 3,
result: Ok(json!({ "a": 1 }))
})
);
assert_eq!(
classify(br#"{"jsonrpc":"2.0","id":4,"error":{"code":-32601,"message":"nope"}}"#),
Some(Incoming::Response {
id: 4,
result: Err(ResponseError {
code: -32601,
message: "nope".into()
})
})
);
assert_eq!(
classify(
br#"{"jsonrpc":"2.0","id":"abc","method":"workspace/configuration","params":{}}"#
),
Some(Incoming::Request {
id: json!("abc"),
method: "workspace/configuration".into(),
params: json!({})
})
);
assert_eq!(
classify(br#"{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics"}"#),
Some(Incoming::Notification {
method: "textDocument/publishDiagnostics".into(),
params: Value::Null
})
);
// A `null` result is still a result.
assert_eq!(
classify(br#"{"jsonrpc":"2.0","id":5,"result":null}"#),
Some(Incoming::Response {
id: 5,
result: Ok(Value::Null)
})
);
assert_eq!(classify(b"not json"), None);
assert_eq!(classify(br#"{"id":"x","result":1}"#), None);
}
}
+397
View File
@@ -0,0 +1,397 @@
//! Which language server serves which files, where to find it, and which
//! directory it should treat as the project.
//!
//! [`SERVERS`] is the whole registry: one row per server. Adding a language
//! is adding a row — its editor language names, the commands to try in order
//! (the first one found on `PATH` wins), and the files that mark a project's
//! root.
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub(crate) struct ServerSpec {
/// Shown to people, and the key a server is known by.
pub(crate) name: &'static str,
/// The editor's language names (`code_editor::language_for_path`) this
/// server handles.
pub(crate) languages: &'static [&'static str],
/// Programs to try, in order, with their arguments.
pub(crate) commands: &'static [(&'static str, &'static [&'static str])],
/// Files or directories whose presence marks a project root, nearest
/// first. See [`find_root`] for how they are weighed.
pub(crate) root_markers: &'static [&'static str],
/// Markers that, when found further up, win over a nearer one — a Cargo
/// or Go workspace above the crate or module the file is in.
pub(crate) outer_markers: &'static [&'static str],
/// The settings tty7 gives the server, as JSON keyed by configuration
/// section — what `workspace/configuration` answers from.
pub(crate) settings: &'static str,
/// The section, if any, that also goes as `initializationOptions`: some
/// servers read their settings only from there.
pub(crate) init_section: Option<&'static str>,
}
pub(crate) const SERVERS: &[ServerSpec] = &[
ServerSpec {
name: "rust-analyzer",
languages: &["rust"],
commands: &[("rust-analyzer", &[])],
root_markers: &["Cargo.toml"],
outer_markers: &["Cargo.toml#workspace"],
// `cargo check` on save is where rust-analyzer's type errors and
// lints come from; without it only its own syntax-level
// diagnostics arrive.
settings: r#"{ "rust-analyzer": { "checkOnSave": true, "check": { "command": "check" } } }"#,
init_section: Some("rust-analyzer"),
},
ServerSpec {
name: "typescript-language-server",
languages: &["typescript", "tsx", "javascript"],
commands: &[("typescript-language-server", &["--stdio"])],
root_markers: &["tsconfig.json", "jsconfig.json", "package.json"],
outer_markers: &[],
settings: "{}",
init_section: None,
},
ServerSpec {
name: "pyright",
languages: &["python"],
commands: &[
("pyright-langserver", &["--stdio"]),
("basedpyright-langserver", &["--stdio"]),
("pylsp", &[]),
],
root_markers: &[
"pyproject.toml",
"pyrightconfig.json",
"setup.py",
"setup.cfg",
"requirements.txt",
],
outer_markers: &[],
settings: r#"{ "python": { "analysis": { "autoSearchPaths": true, "useLibraryCodeForTypes": true } } }"#,
init_section: None,
},
ServerSpec {
name: "gopls",
languages: &["go"],
commands: &[("gopls", &[])],
root_markers: &["go.mod"],
outer_markers: &["go.work"],
settings: r#"{ "gopls": {} }"#,
init_section: Some("gopls"),
},
ServerSpec {
name: "clangd",
languages: &["c", "cpp"],
commands: &[("clangd", &[])],
root_markers: &["compile_commands.json", "compile_flags.txt", ".clangd"],
outer_markers: &[],
settings: "{}",
init_section: None,
},
];
impl ServerSpec {
pub(crate) fn settings(&self) -> serde_json::Value {
serde_json::from_str(self.settings).unwrap_or_else(|_| serde_json::json!({}))
}
pub(crate) fn initialization_options(&self) -> serde_json::Value {
match self.init_section {
Some(section) => configuration_item(&self.settings(), Some(section)),
None => serde_json::Value::Null,
}
}
}
/// One item of a `workspace/configuration` answer: the part of `settings`
/// under the dotted `section`, or all of it when none is named. A section
/// tty7 sets nothing for gets an empty object rather than `null` — several
/// servers read fields off the answer and fail on a null.
pub(crate) fn configuration_item(
settings: &serde_json::Value,
section: Option<&str>,
) -> serde_json::Value {
let Some(section) = section.filter(|s| !s.is_empty()) else {
return settings.clone();
};
section
.split('.')
.try_fold(settings, |value, key| value.get(key))
.cloned()
.unwrap_or_else(|| serde_json::json!({}))
}
/// Markers every server falls back on when its own are nowhere to be found.
const FALLBACK_MARKERS: &[&str] = &[".git", ".hg", ".jj"];
pub(crate) fn spec_for_language(language: &str) -> Option<&'static ServerSpec> {
SERVERS.iter().find(|s| s.languages.contains(&language))
}
/// The `languageId` a server expects in `didOpen`, which is finer-grained
/// than the editor's highlighting language: JSX and TSX are their own ids.
pub(crate) fn language_id(path: &Path, language: &str) -> &'static str {
let ext = path
.extension()
.and_then(OsStr::to_str)
.map(str::to_ascii_lowercase)
.unwrap_or_default();
match (language, ext.as_str()) {
("javascript", "jsx") => "javascriptreact",
("tsx", _) => "typescriptreact",
("typescript", _) => "typescript",
("javascript", _) => "javascript",
("rust", _) => "rust",
("python", _) => "python",
("go", _) => "go",
("c", _) => "c",
("cpp", _) => "cpp",
_ => "plaintext",
}
}
/// The first of `spec`'s commands that exists on `path` (a `PATH`-style
/// list), as a full path to run and its arguments.
///
/// tty7 fills its own `PATH` in from the login shell at startup
/// (`enrich_path_from_login_shell` in `main.rs`), so a GUI launched from the
/// Dock still finds what `~/.cargo/bin` or `nvm` put on the terminal's.
pub(crate) fn resolve(
spec: &ServerSpec,
path: &OsStr,
) -> Option<(PathBuf, &'static [&'static str])> {
spec.commands
.iter()
.find_map(|(program, args)| Some((find_program(program, path)?, *args)))
}
fn find_program(program: &str, path: &OsStr) -> Option<PathBuf> {
let exts: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat", ""]
} else {
&[""]
};
std::env::split_paths(path).find_map(|dir| {
exts.iter()
.map(|ext| dir.join(format!("{program}{ext}")))
.find(|candidate| is_executable(candidate))
})
}
fn is_executable(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
/// The directory a server should open as the project that contains `file`.
///
/// The nearest ancestor holding one of the server's own markers wins; above
/// it, the furthest ancestor holding an outer marker wins over that (a
/// `Cargo.toml` with a `[workspace]` above a member crate's). Without any
/// marker a version-control root will do, and without that the file's own
/// directory. The home directory and the filesystem root are never chosen:
/// pointing a server at either makes it index everything a person owns.
pub(crate) fn find_root(file: &Path, spec: &ServerSpec, home: Option<&Path>) -> PathBuf {
let dir = file.parent().unwrap_or(file).to_path_buf();
let too_broad = |p: &Path| p.parent().is_none() || home.is_some_and(|h| h == p);
let ancestors: Vec<&Path> = dir.ancestors().filter(|p| !too_broad(p)).collect();
let nearest = ancestors
.iter()
.find(|p| spec.root_markers.iter().any(|m| has_marker(p, m)));
let outer = ancestors
.iter()
.rev()
.find(|p| spec.outer_markers.iter().any(|m| has_marker(p, m)));
let chosen = match (nearest, outer) {
// Only an outer marker that actually encloses the nearest root counts.
(Some(near), Some(out)) if near.starts_with(out) => Some(out),
(Some(near), _) => Some(near),
(None, Some(out)) => Some(out),
(None, None) => ancestors
.iter()
.find(|p| FALLBACK_MARKERS.iter().any(|m| p.join(m).exists())),
};
chosen.map(|p| p.to_path_buf()).unwrap_or(dir)
}
/// `name` exists in `dir`. `Cargo.toml#workspace` asks for a `Cargo.toml`
/// with a `[workspace]` table in it.
fn has_marker(dir: &Path, marker: &str) -> bool {
match marker.split_once('#') {
Some((file, table)) => std::fs::read_to_string(dir.join(file))
.is_ok_and(|text| text.lines().any(|line| line.trim() == format!("[{table}]"))),
None => dir.join(marker).exists(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(files: &[&str]) -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
for f in files {
let p = dir.path().join(f);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
if f.ends_with('/') {
std::fs::create_dir_all(&p).unwrap();
} else {
let body = if f.ends_with("ws/Cargo.toml") {
"[workspace]\nmembers = [\"crates/*\"]\n"
} else {
"[package]\nname = \"x\"\n"
};
std::fs::write(&p, body).unwrap();
}
}
dir
}
fn rust() -> &'static ServerSpec {
spec_for_language("rust").unwrap()
}
#[test]
fn a_member_crate_opens_as_its_cargo_workspace() {
let t = tree(&[
"ws/Cargo.toml",
"ws/crates/a/Cargo.toml",
"ws/crates/a/src/lib.rs",
]);
let root = find_root(&t.path().join("ws/crates/a/src/lib.rs"), rust(), None);
assert_eq!(root, t.path().join("ws"));
}
#[test]
fn a_crate_outside_any_workspace_is_its_own_root() {
let t = tree(&["solo/Cargo.toml", "solo/src/main.rs"]);
let root = find_root(&t.path().join("solo/src/main.rs"), rust(), None);
assert_eq!(root, t.path().join("solo"));
}
#[test]
fn the_nearest_package_json_wins_for_typescript() {
let t = tree(&[
"repo/package.json",
"repo/web/package.json",
"repo/web/src/a.ts",
]);
let ts = spec_for_language("typescript").unwrap();
let root = find_root(&t.path().join("repo/web/src/a.ts"), ts, None);
assert_eq!(root, t.path().join("repo/web"));
}
#[test]
fn a_go_work_above_the_module_wins() {
let t = tree(&["w/go.work", "w/svc/go.mod", "w/svc/main.go"]);
let go = spec_for_language("go").unwrap();
assert_eq!(
find_root(&t.path().join("w/svc/main.go"), go, None),
t.path().join("w")
);
}
#[test]
fn without_markers_the_repository_and_then_the_folder_are_used() {
let t = tree(&["repo/.git/", "repo/tools/x.py", "loose/y.py"]);
let py = spec_for_language("python").unwrap();
assert_eq!(
find_root(&t.path().join("repo/tools/x.py"), py, None),
t.path().join("repo")
);
assert_eq!(
find_root(&t.path().join("loose/y.py"), py, None),
t.path().join("loose")
);
}
#[test]
fn home_is_never_a_root_even_with_a_dotfiles_repository() {
let t = tree(&["home/.git/", "home/notes/n.py"]);
let home = t.path().join("home");
let py = spec_for_language("python").unwrap();
assert_eq!(
find_root(&home.join("notes/n.py"), py, Some(&home)),
home.join("notes")
);
}
#[test]
fn language_ids_distinguish_jsx_and_tsx() {
assert_eq!(
language_id(Path::new("a.jsx"), "javascript"),
"javascriptreact"
);
assert_eq!(language_id(Path::new("a.js"), "javascript"), "javascript");
assert_eq!(language_id(Path::new("a.tsx"), "tsx"), "typescriptreact");
assert_eq!(language_id(Path::new("a.rs"), "rust"), "rust");
}
#[test]
fn configuration_answers_by_section_and_never_with_null() {
let ra = rust().settings();
assert_eq!(
configuration_item(&ra, Some("rust-analyzer"))["checkOnSave"],
serde_json::json!(true)
);
assert_eq!(
configuration_item(&ra, Some("rust-analyzer.check.command")),
serde_json::json!("check")
);
assert_eq!(
configuration_item(&ra, Some("editor")),
serde_json::json!({})
);
assert_eq!(configuration_item(&ra, None), ra);
assert_eq!(
rust().initialization_options()["checkOnSave"],
serde_json::json!(true)
);
for spec in SERVERS {
assert!(spec.settings().is_object(), "{} settings parse", spec.name);
}
}
#[test]
fn every_language_has_at_most_one_server() {
let mut seen = std::collections::HashSet::new();
for spec in SERVERS {
for lang in spec.languages {
assert!(seen.insert(*lang), "{lang} is served twice");
}
}
}
#[cfg(unix)]
#[test]
fn the_first_command_found_on_path_is_used() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::TempDir::new().unwrap();
let pylsp = dir.path().join("pylsp");
std::fs::write(&pylsp, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&pylsp, std::fs::Permissions::from_mode(0o755)).unwrap();
// Not executable: not a candidate.
std::fs::write(dir.path().join("pyright-langserver"), "").unwrap();
let py = spec_for_language("python").unwrap();
let (program, args) = resolve(py, dir.path().as_os_str()).unwrap();
assert_eq!(program, pylsp);
assert!(args.is_empty());
assert!(resolve(rust(), dir.path().as_os_str()).is_none());
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Signature help: the parameters of the call the cursor is in, shown in a
//! popover once `(` or `,` is typed and kept current while typing goes on
//! inside the call. The server decides when the cursor has left it (it
//! answers with nothing), and Escape puts the popover away.
use gpui::Context;
use gpui_component::RopeExt as _;
use lsp_types::{Documentation, ParameterLabel, SignatureHelp};
use super::convert::{self, Encoding};
use super::{Freshen, LspStore};
use crate::ui::app::Tty7App;
use crate::ui::code_editor::LocalFile;
/// Characters that open signature help when the server names none.
const DEFAULT_TRIGGERS: [&str; 2] = ["(", ","];
fn documentation(doc: &Documentation) -> &str {
match doc {
Documentation::String(s) => s,
Documentation::MarkupContent(m) => &m.value,
}
}
/// Markdown's own punctuation, escaped so a signature reads as written —
/// `*const T` is not emphasis.
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if matches!(
c,
'\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>' | '#' | '|'
) {
out.push('\\');
}
out.push(c);
}
out
}
/// The popover's text: the active signature with the active parameter in
/// bold, then what the server says about that parameter and the function.
pub(crate) fn markdown(help: &SignatureHelp) -> Option<String> {
let index = help.active_signature.unwrap_or(0) as usize;
let signature = help
.signatures
.get(index)
.or_else(|| help.signatures.first())?;
let label = signature.label.as_str();
let active = signature.active_parameter.or(help.active_parameter);
let span = active
.and_then(|ix| signature.parameters.as_ref()?.get(ix as usize))
.and_then(|param| match &param.label {
ParameterLabel::Simple(name) => {
let start = label.find(name.as_str())?;
Some((start, start + name.len(), param))
}
ParameterLabel::LabelOffsets([start, end]) => {
// Offsets into the label, in UTF-16 units whatever the
// position encoding.
let start = convert::byte_of_column(label, *start, Encoding::Utf16);
let end = convert::byte_of_column(label, *end, Encoding::Utf16).max(start);
Some((start, end, param))
}
});
let mut out = String::new();
if help.signatures.len() > 1 {
out.push_str(&format!("{}/{} ", index + 1, help.signatures.len()));
}
match span {
Some((start, end, _)) if start < end => {
out.push_str(&escape(&label[..start]));
out.push_str("**");
out.push_str(&escape(&label[start..end]));
out.push_str("**");
out.push_str(&escape(&label[end..]));
}
_ => out.push_str(&escape(label)),
}
if let Some(doc) = span.and_then(|(_, _, p)| p.documentation.as_ref()) {
let doc = documentation(doc).trim();
if !doc.is_empty() {
out.push_str("\n\n");
out.push_str(doc);
}
}
if let Some(doc) = signature.documentation.as_ref() {
let doc = documentation(doc).trim();
if !doc.is_empty() {
out.push_str("\n\n");
out.push_str(doc);
}
}
Some(out)
}
impl Tty7App {
/// After an edit: opens signature help on a trigger character, and
/// keeps an open one current.
pub(crate) fn lsp_signature_help_after_edit(&self, f: &LocalFile, cx: &mut Context<Self>) {
let (text, cursor, visible) = {
let state = f.input.read(cx);
(
state.text().clone(),
state.cursor(),
state.is_signature_help_visible(),
)
};
let point = text.offset_to_point(cursor);
let line = text.slice_line(point.row).to_string();
let Some(before) = line.get(..point.column).and_then(|l| l.chars().next_back()) else {
return;
};
// Looked at without sending the edit: most keystrokes stop here, and
// the server hears about them on the usual debounce.
let Some(doc) = LspStore::context(&f.path, f.input.entity_id(), Freshen::Skip, None, cx)
else {
return;
};
let Some(options) = doc.caps.signature_help_provider.as_ref() else {
return;
};
let typed = before.to_string();
let is_trigger = match &options.trigger_characters {
Some(chars) => chars.contains(&typed),
None => DEFAULT_TRIGGERS.contains(&typed.as_str()),
};
if !is_trigger && !visible {
return;
}
let Some(doc) =
LspStore::context(&f.path, f.input.entity_id(), Freshen::Text(&text), None, cx)
else {
return;
};
let context = lsp_types::SignatureHelpContext {
trigger_kind: if is_trigger {
lsp_types::SignatureHelpTriggerKind::TRIGGER_CHARACTER
} else {
lsp_types::SignatureHelpTriggerKind::CONTENT_CHANGE
},
trigger_character: is_trigger.then_some(typed),
is_retrigger: visible,
active_signature_help: None,
};
let request = doc
.client
.request::<lsp_types::request::SignatureHelpRequest>(lsp_types::SignatureHelpParams {
context: Some(context),
text_document_position_params: lsp_types::TextDocumentPositionParams::new(
lsp_types::TextDocumentIdentifier::new(doc.uri.clone()),
convert::offset_to_lsp(&text, cursor, doc.encoding),
),
work_done_progress_params: Default::default(),
});
let input = f.input.clone();
let anchor = cursor - before.len_utf8()..cursor;
cx.spawn(async move |_, cx| {
let shown = match request.await {
Ok(Some(help)) => markdown(&help),
Ok(None) => None,
Err(e) => {
log::debug!("lsp: signatureHelp: {e:#}");
return;
}
};
input.update(cx, |state, cx| {
// Typed on since: the next edit asks again.
if *state.text() != text {
return;
}
match shown {
Some(markdown) => state.show_signature_help(anchor, markdown, cx),
None => state.hide_signature_help(cx),
}
});
})
.detach();
}
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{ParameterInformation, SignatureInformation};
fn help(params: Vec<ParameterLabel>, active: Option<u32>) -> SignatureHelp {
SignatureHelp {
signatures: vec![SignatureInformation {
label: "fn copy(src: *const u8, n: usize)".into(),
documentation: Some(Documentation::String("Copies.".into())),
parameters: Some(
params
.into_iter()
.map(|label| ParameterInformation {
label,
documentation: None,
})
.collect(),
),
active_parameter: None,
}],
active_signature: Some(0),
active_parameter: active,
}
}
#[test]
fn the_active_parameter_is_bold_and_markdown_is_escaped() {
let by_name = help(
vec![
ParameterLabel::Simple("src: *const u8".into()),
ParameterLabel::Simple("n: usize".into()),
],
Some(1),
);
assert_eq!(
markdown(&by_name).unwrap(),
"fn copy(src: \\*const u8, **n: usize**)\n\nCopies."
);
let by_offset = help(
vec![
ParameterLabel::LabelOffsets([8, 22]),
ParameterLabel::LabelOffsets([24, 32]),
],
Some(0),
);
assert_eq!(
markdown(&by_offset).unwrap(),
"fn copy(**src: \\*const u8**, n: usize)\n\nCopies."
);
assert!(
markdown(&SignatureHelp {
signatures: vec![],
active_signature: None,
active_parameter: None
})
.is_none()
);
}
}
+184
View File
@@ -0,0 +1,184 @@
//! A server's `textDocument/documentSymbol`, as the editor's outline.
//!
//! The editor already builds an outline from its own parse tree; a server's
//! is better where there is one — it knows `impl` blocks from macros, and
//! languages the tree queries do not cover. It replaces the tree's through
//! `Tty7App::editor_set_document_symbols`, refreshed a moment after the
//! server hears about each edit.
use gpui_component::Rope;
use lsp_types::{DocumentSymbol, DocumentSymbolResponse, SymbolInformation};
use super::convert::{self, Encoding};
use crate::ui::code_editor::outline::{Outline, SymbolKind, SymbolNode};
fn kind(kind: lsp_types::SymbolKind) -> SymbolKind {
let n = serde_json::to_value(kind)
.ok()
.and_then(|v| v.as_u64())
.unwrap_or(0);
SymbolKind::from_lsp(n as u32)
}
/// The answer as an outline, its positions converted to the editor's
/// columns against `text` — the text the server answered about.
pub(crate) fn outline(response: DocumentSymbolResponse, text: &Rope, enc: Encoding) -> Outline {
let nodes = match response {
DocumentSymbolResponse::Nested(symbols) => {
symbols.into_iter().map(|s| node(s, text, enc)).collect()
}
DocumentSymbolResponse::Flat(symbols) => nest_flat(symbols, text, enc),
};
Outline::from_nodes(nodes)
}
fn node(symbol: DocumentSymbol, text: &Rope, enc: Encoding) -> SymbolNode {
let range = convert::range_to_editor(text, symbol.range, enc);
SymbolNode {
name: symbol.name,
kind: kind(symbol.kind),
range: range.start..range.end,
selection: convert::lsp_to_editor(text, symbol.selection_range.start, enc),
children: symbol
.children
.unwrap_or_default()
.into_iter()
.map(|c| node(c, text, enc))
.collect(),
}
}
/// The older, flat answer: nested by which range holds which, since the
/// `containerName` it carries is a name, not a reference.
fn nest_flat(mut symbols: Vec<SymbolInformation>, text: &Rope, enc: Encoding) -> Vec<SymbolNode> {
let key = |p: lsp_types::Position| (p.line, p.character);
// Outer before inner: by start, then the longer range first.
symbols.sort_by(|a, b| {
key(a.location.range.start)
.cmp(&key(b.location.range.start))
.then(key(b.location.range.end).cmp(&key(a.location.range.end)))
});
let contains = |outer: &lsp_types::Range, inner: &lsp_types::Range| {
key(outer.start) <= key(inner.start) && key(inner.end) <= key(outer.end)
};
// A stack of open containers: (their server range, the node).
let mut stack: Vec<(lsp_types::Range, SymbolNode)> = Vec::new();
let mut roots = Vec::new();
fn pop_into(stack: &mut Vec<(lsp_types::Range, SymbolNode)>, roots: &mut Vec<SymbolNode>) {
if let Some((_, done)) = stack.pop() {
match stack.last_mut() {
Some((_, parent)) => parent.children.push(done),
None => roots.push(done),
}
}
}
for s in symbols {
let range = s.location.range;
while stack
.last()
.is_some_and(|(outer, _)| !contains(outer, &range))
{
pop_into(&mut stack, &mut roots);
}
let editor = convert::range_to_editor(text, range, enc);
stack.push((
range,
SymbolNode {
name: s.name,
kind: kind(s.kind),
range: editor.start..editor.end,
selection: editor.start,
children: Vec::new(),
},
));
}
while !stack.is_empty() {
pop_into(&mut stack, &mut roots);
}
roots
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{Location, Position, Range};
fn range(a: u32, b: u32, c: u32, d: u32) -> Range {
Range::new(Position::new(a, b), Position::new(c, d))
}
#[test]
#[allow(deprecated)]
fn a_flat_answer_is_nested_by_range() {
let text = Rope::from("struct A {\n f: u8,\n}\nfn g() {}\n");
let uri: lsp_types::Uri = "file:///a.rs".parse().unwrap();
let info = |name: &str, kind, r| SymbolInformation {
name: name.into(),
kind,
tags: None,
deprecated: None,
location: Location::new(uri.clone(), r),
container_name: None,
};
let outline = outline(
DocumentSymbolResponse::Flat(vec![
info("g", lsp_types::SymbolKind::FUNCTION, range(3, 0, 3, 9)),
info("f", lsp_types::SymbolKind::FIELD, range(1, 2, 1, 7)),
info("A", lsp_types::SymbolKind::STRUCT, range(0, 0, 2, 1)),
]),
&text,
Encoding::Utf16,
);
let shape: Vec<(&str, usize, SymbolKind)> = outline
.symbols
.iter()
.map(|s| (s.name.as_str(), s.depth, s.kind))
.collect();
assert_eq!(
shape,
[
("A", 0, SymbolKind::Struct),
("f", 1, SymbolKind::Field),
("g", 0, SymbolKind::Function),
]
);
}
#[test]
#[allow(deprecated)]
fn a_nested_answer_keeps_its_shape_in_editor_columns() {
let text = Rope::from("mod 🎉m {\n fn f() {}\n}\n");
let sym = |name: &str, kind, r: Range, sel: Range, children| DocumentSymbol {
name: name.into(),
detail: None,
kind,
tags: None,
deprecated: None,
range: r,
selection_range: sel,
children,
};
let outline = outline(
DocumentSymbolResponse::Nested(vec![sym(
"m",
lsp_types::SymbolKind::MODULE,
range(0, 0, 2, 1),
// `m` after the emoji: UTF-16 column 6, char column 5.
range(0, 6, 0, 7),
Some(vec![sym(
"f",
lsp_types::SymbolKind::FUNCTION,
range(1, 4, 1, 14),
range(1, 7, 1, 8),
None,
)]),
)]),
&text,
Encoding::Utf16,
);
assert_eq!(outline.symbols.len(), 2);
assert_eq!(outline.symbols[0].selection, Position::new(0, 5));
assert_eq!(outline.symbols[1].parent, Some(0));
assert_eq!(outline.symbols[1].kind, SymbolKind::Function);
}
}
+544
View File
@@ -0,0 +1,544 @@
use std::collections::HashMap;
use lsp_types::{
AnnotatedTextEdit, DocumentChangeOperation, DocumentChanges, OneOf,
OptionalVersionedTextDocumentIdentifier, Position, Range, ResourceOp, TextDocumentEdit,
TextEdit, WorkspaceEdit,
};
use super::*;
/// A `file://` URI spelled out, not built from a path: these tests are
/// about grouping URIs, and `/a.rs` is not an absolute path on Windows.
fn uri(path: &str) -> Uri {
format!("file://{path}").parse().unwrap()
}
fn edit(line: u32, text: &str) -> TextEdit {
TextEdit {
range: Range::new(Position::new(line, 0), Position::new(line, 1)),
new_text: text.into(),
}
}
#[cfg(unix)]
#[test]
fn file_uris_round_trip_including_spaces_and_non_ascii() {
let path = Path::new("/tmp/a dir/ü.rs");
let u = path_to_uri(path).unwrap();
assert_eq!(u.as_str(), "file:///tmp/a%20dir/%C3%BC.rs");
assert_eq!(uri_to_path(&u).as_deref(), Some(path));
let web: Uri = "https://docs.rs/x".parse().unwrap();
assert_eq!(uri_to_path(&web), None);
}
#[test]
fn a_workspace_edit_is_grouped_by_file_from_either_shape() {
let mut changes = HashMap::new();
changes.insert(uri("/b.rs"), vec![edit(1, "b")]);
changes.insert(uri("/a.rs"), vec![edit(0, "a")]);
let files = workspace_edit_files(WorkspaceEdit {
changes: Some(changes),
..Default::default()
});
let names: Vec<&str> = files.iter().map(|(u, _)| u.as_str()).collect();
assert_eq!(names, ["file:///a.rs", "file:///b.rs"]);
let doc_edit = |path: &str, edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>>| TextDocumentEdit {
text_document: OptionalVersionedTextDocumentIdentifier {
uri: uri(path),
version: None,
},
edits,
};
let files = workspace_edit_files(WorkspaceEdit {
document_changes: Some(DocumentChanges::Operations(vec![
DocumentChangeOperation::Edit(doc_edit("/a.rs", vec![OneOf::Left(edit(0, "x"))])),
DocumentChangeOperation::Op(ResourceOp::Create(lsp_types::CreateFile {
uri: uri("/new.rs"),
options: None,
annotation_id: None,
})),
DocumentChangeOperation::Edit(doc_edit(
"/a.rs",
vec![OneOf::Right(AnnotatedTextEdit {
text_edit: edit(2, "y"),
annotation_id: "rename".into(),
})],
)),
])),
..Default::default()
});
assert_eq!(
files.len(),
1,
"edits to one file are merged; resource ops skipped"
);
let texts: Vec<&str> = files[0].1.iter().map(|e| e.new_text.as_str()).collect();
assert_eq!(texts, ["x", "y"]);
}
#[test]
fn a_rename_across_a_file_applies_every_occurrence() {
// What a server sends for renaming `foo` to `renamed`, applied to a file
// with no buffer open.
let text = "fn foo() {}\nfn main() { foo(); /* 🎉 */ foo(); }\n";
let at = |line, start, end| TextEdit {
range: Range::new(Position::new(line, start), Position::new(line, end)),
new_text: "renamed".into(),
};
let edits = vec![at(1, 12, 15), at(0, 3, 6), at(1, 28, 31)];
assert_eq!(
convert::apply_edits(text, &edits, Encoding::Utf16),
"fn renamed() {}\nfn main() { renamed(); /* 🎉 */ renamed(); }\n"
);
}
/// Drives a real language server through the whole client: spawn,
/// `initialize` behind the gate, `didOpen`, diagnostics arriving as an
/// event, a hover request, and a clean `shutdown`/`exit` with the process
/// reaped. clangd, because it ships with Xcode's command line tools and needs
/// no project to index; the file's type error is what it has to find.
///
/// `cargo test -p tty7 --bin tty7-app lsp_smoke -- --ignored`
#[test]
#[ignore = "needs clangd on PATH"]
fn lsp_smoke_a_real_server_publishes_diagnostics_and_shuts_down() {
use std::time::Duration;
let spec = servers::spec_for_language("c").unwrap();
let Some((program, args)) =
servers::resolve(spec, &std::env::var_os("PATH").unwrap_or_default())
else {
eprintln!("clangd not on PATH; skipping");
return;
};
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path().canonicalize().unwrap();
let file = root.join("main.c");
let text = "int main(void) {\n int x = \"not a number\";\n return x;\n}\n";
std::fs::write(&file, text).unwrap();
let (client, events) = LspClient::spawn("clangd", &program, args, &root).unwrap();
let within =
|secs: u64, fut: std::pin::Pin<Box<dyn std::future::Future<Output = Option<Value>>>>| {
smol::block_on(smol::future::or(fut, async move {
smol::Timer::after(Duration::from_secs(secs)).await;
None
}))
};
let init = client.initialize(initialize_params(&root, spec));
let result = within(30, Box::pin(async move { init.await.ok() })).expect("initialize answered");
let init: lsp_types::InitializeResult = serde_json::from_value(result).unwrap();
assert!(init.capabilities.hover_provider.is_some());
client.notify_raw("initialized", json!({}));
client.open_gate();
let uri = path_to_uri(&file).unwrap();
client.notify::<lsp_types::notification::DidOpenTextDocument>(
lsp_types::DidOpenTextDocumentParams {
text_document: lsp_types::TextDocumentItem::new(
uri.clone(),
"c".into(),
0,
text.into(),
),
},
);
let events2 = events.clone();
let published = within(
60,
Box::pin(async move {
while let Ok(event) = events2.recv().await {
match event {
ServerEvent::Notification { method, params }
if method == "textDocument/publishDiagnostics" =>
{
return Some(params);
}
_ => {}
}
}
None
}),
)
.expect("diagnostics published");
let params: lsp_types::PublishDiagnosticsParams = serde_json::from_value(published).unwrap();
assert_eq!(params.uri, uri);
let (errors, _) = convert::count_problems(&params.diagnostics);
assert!(errors >= 1, "{:?}", params.diagnostics);
assert!(params.diagnostics.iter().any(|d| d.range.start.line == 1));
let hover = client.request::<lsp_types::request::HoverRequest>(lsp_types::HoverParams {
text_document_position_params: lsp_types::TextDocumentPositionParams::new(
lsp_types::TextDocumentIdentifier::new(uri),
Position::new(2, 11),
),
work_done_progress_params: Default::default(),
});
let hovered = within(
30,
Box::pin(async move { hover.await.ok().map(|h| serde_json::to_value(h).unwrap()) }),
);
assert!(
hovered.is_some_and(|h| !h.is_null()),
"hover over `x` says something"
);
// The outline, the references to `x`, and signature help inside a call
// all come back in shapes the editor reads.
let doc_id = || lsp_types::TextDocumentIdentifier::new(path_to_uri(&file).unwrap());
let symbols = client.request::<lsp_types::request::DocumentSymbolRequest>(
lsp_types::DocumentSymbolParams {
text_document: doc_id(),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
},
);
let outline = within(
30,
Box::pin(async move {
symbols
.await
.ok()
.flatten()
.map(|s| serde_json::to_value(s).unwrap())
}),
)
.map(|v| {
symbols::outline(
serde_json::from_value(v).unwrap(),
&gpui_component::Rope::from(text),
Encoding::Utf16,
)
})
.expect("an outline");
assert!(
outline.symbols.iter().any(|s| s.name == "main"),
"{outline:?}"
);
let references = client.request::<lsp_types::request::References>(lsp_types::ReferenceParams {
text_document_position: lsp_types::TextDocumentPositionParams::new(
doc_id(),
Position::new(2, 11),
),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
context: lsp_types::ReferenceContext {
include_declaration: true,
},
});
let found = within(
30,
Box::pin(async move {
references
.await
.ok()
.flatten()
.map(|r| serde_json::to_value(r).unwrap())
}),
)
.expect("references");
let found: Vec<lsp_types::Location> = serde_json::from_value(found).unwrap();
assert!(
found.len() >= 2,
"the declaration and the use of x: {found:?}"
);
let shutdown = client.request_raw("shutdown", Value::Null);
within(10, Box::pin(async move { shutdown.await.ok() }));
client.notify_raw("exit", Value::Null);
let mut exited = false;
for _ in 0..50 {
if client.reap_if_exited() {
exited = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
client.kill();
assert!(exited, "clangd exits on `exit`");
}
/// A store with one running server on the far end of two pipes, and one
/// document owned by `owner`. Returns the path and what the server reads.
mod harness {
use std::io::Read as _;
use gpui::{Entity, TestAppContext, WindowHandle};
use gpui_component::input::InputState;
use super::super::*;
use crate::ui::lsp::rpc::FrameReader;
pub(super) struct FakeServer {
frames: FrameReader,
from_client: std::io::PipeReader,
_to_client: std::io::PipeWriter,
}
impl FakeServer {
pub(super) fn next(&mut self) -> Value {
let mut chunk = [0u8; 4096];
loop {
if let Some(body) = self.frames.next_frame() {
return serde_json::from_slice(&body).unwrap();
}
let n = self.from_client.read(&mut chunk).unwrap();
assert!(n > 0, "the client closed its end");
self.frames.push(&chunk[..n]);
}
}
}
pub(super) struct Setup {
pub(super) window: WindowHandle<InputState>,
pub(super) owner: Entity<InputState>,
pub(super) other: Entity<InputState>,
pub(super) path: PathBuf,
pub(super) server: FakeServer,
}
pub(super) fn setup(cx: &mut TestAppContext) -> Setup {
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(crate::core::config::Config::default());
});
let window = cx.add_window(|window, cx| {
InputState::new(window, cx)
.code_editor("rust")
.multi_line(true)
.default_value("fn foo() {}\n")
});
let owner = window.root(cx).unwrap();
let other = window
.update(cx, |_, window, cx| {
cx.new(|cx| {
InputState::new(window, cx)
.code_editor("rust")
.multi_line(true)
.default_value("fn foo() { /* the other window's edit */ }\n")
})
})
.unwrap();
let path = test_path("/tmp/tty7-lsp-test/src/lib.rs");
let (client_reads, server_writes) = std::io::pipe().unwrap();
let (server_reads, client_writes) = std::io::pipe().unwrap();
let (client, _events) =
LspClient::from_streams("fake", client_reads, client_writes).unwrap();
client.open_gate();
let key = ServerKey {
name: "rust-analyzer",
root: test_path("/tmp/tty7-lsp-test"),
};
cx.update(|cx| {
LspStore::ensure(cx);
let text = owner.read(cx).text().clone();
let store = cx.global_mut::<LspStore>();
store.servers.insert(
key.clone(),
Server {
spec: servers::spec_for_language("rust").unwrap(),
label: "fake".into(),
generation: 1,
phase: Phase::Running,
client: Some(client),
caps: Rc::new(lsp_types::ServerCapabilities::default()),
encoding: Encoding::Utf16,
crashes: Vec::new(),
idle_seq: 0,
_tasks: Vec::new(),
},
);
store.docs.insert(
path.clone(),
Doc {
uri: path_to_uri(&path).unwrap(),
language_id: "rust",
server: key,
version: 0,
owner: owner.downgrade(),
owner_id: owner.entity_id(),
app_id: owner.entity_id(),
app: WeakEntity::new_invalid(),
window: Some(window.into()),
sent: Some(text),
dirty: false,
change_seq: 0,
symbols_seq: 0,
diagnostics: Vec::new(),
problems: (0, 0),
},
);
});
Setup {
window,
owner,
other,
path,
server: FakeServer {
frames: FrameReader::default(),
from_client: server_reads,
_to_client: server_writes,
},
}
}
}
/// The same file open in a second window is a second buffer, maybe with
/// other text. Its requests are refused before anything is sent, so the
/// server's copy stays the owner's.
#[gpui::test]
fn a_buffer_that_does_not_own_the_document_never_syncs(cx: &mut gpui::TestAppContext) {
let mut s = harness::setup(cx);
cx.update(|cx| {
let other_text = s.other.read(cx).text().clone();
let refused = LspStore::context(
&s.path,
s.other.entity_id(),
Freshen::Text(&other_text),
None,
cx,
);
assert!(refused.is_none(), "a non-owner gets no server");
let owner_text = gpui_component::Rope::from("fn foo() { owner(); }\n");
let allowed = LspStore::context(
&s.path,
s.owner.entity_id(),
Freshen::Text(&owner_text),
None,
cx,
);
assert!(allowed.is_some());
});
// The first thing the server hears is the owner's change: the other
// buffer's text never went out.
let change = s.server.next();
assert_eq!(change["method"], "textDocument/didChange");
assert_eq!(
change["params"]["contentChanges"][0]["text"],
"fn foo() { owner(); }\n"
);
}
#[test]
fn an_edit_is_stale_when_a_file_it_touches_changed_opened_or_closed() {
let rope = gpui_component::Rope::from;
let (a, b, c) = (
PathBuf::from("/a"),
PathBuf::from("/b"),
PathBuf::from("/c"),
);
let baseline = HashMap::from([(a.clone(), rope("x")), (b.clone(), rope("y"))]);
let targets = vec![a.clone(), c.clone()];
// `a` unchanged, `c` on disk then and now.
let now = HashMap::from([(a.clone(), rope("x")), (b.clone(), rope("typed"))]);
assert_eq!(
first_stale(&targets, &now, &baseline),
None,
"only targets count"
);
// `a` typed in.
let now = HashMap::from([(a.clone(), rope("x!"))]);
assert_eq!(first_stale(&targets, &now, &baseline), Some(a.clone()));
// `c` opened since.
let now = HashMap::from([(a.clone(), rope("x")), (c.clone(), rope("z"))]);
assert_eq!(first_stale(&targets, &now, &baseline), Some(c.clone()));
// `a` closed since.
assert_eq!(first_stale(&targets, &HashMap::new(), &baseline), Some(a));
}
/// A rename or code action's edit, answered against a text the buffer has
/// since moved on from, is dropped whole; answered against the text it
/// still holds, it is applied.
#[gpui::test]
fn a_workspace_edit_for_a_changed_buffer_is_not_applied(cx: &mut gpui::TestAppContext) {
let s = harness::setup(cx);
let rename = |cx: &mut gpui::App, baseline| {
let mut changes = HashMap::new();
changes.insert(
path_to_uri(&s.path).unwrap(),
vec![TextEdit {
range: Range::new(Position::new(0, 3), Position::new(0, 6)),
new_text: "bar".into(),
}],
);
let edit = WorkspaceEdit {
changes: Some(changes),
..Default::default()
};
apply_workspace_edit(edit, Encoding::Utf16, Some(baseline), cx)
};
let baseline = cx.update(|cx| LspStore::snapshot(cx));
// Typed in after the request went out.
s.window
.update(cx, |state, window, cx| {
state.set_value("fn foo() { typed }\n", window, cx)
})
.unwrap();
assert!(!cx.update(|cx| rename(cx, baseline)));
cx.run_until_parked();
assert_eq!(
cx.update(|cx| s.owner.read(cx).text().to_string()),
"fn foo() { typed }\n"
);
let baseline = cx.update(|cx| LspStore::snapshot(cx));
assert!(cx.update(|cx| rename(cx, baseline)));
cx.run_until_parked();
assert_eq!(
cx.update(|cx| s.owner.read(cx).text().to_string()),
"fn bar() { typed }\n"
);
}
/// Turning the setting back on asks every window for its open files again,
/// which is what starts their servers.
#[gpui::test]
fn turning_lsp_back_on_offers_every_window_again(cx: &mut gpui::TestAppContext) {
use std::cell::Cell;
cx.update(|cx| cx.set_global(crate::core::config::Config::default()));
let asked = Rc::new(Cell::new(0));
let window_gone = Rc::new(Cell::new(false));
cx.update(|cx| {
let asked = asked.clone();
LspStore::register_window(
EntityId::from(1u64),
Rc::new(move |_| {
asked.set(asked.get() + 1);
true
}),
cx,
);
let gone = window_gone.clone();
LspStore::register_window(
EntityId::from(2u64),
Rc::new(move |_| {
gone.set(true);
false
}),
cx,
);
});
let set = |cx: &mut gpui::TestAppContext, on: bool| {
cx.update(|cx| {
cx.update_global::<crate::core::config::Config, _>(|c, _| c.editor_lsp = on)
});
cx.run_until_parked();
};
set(cx, false);
assert_eq!(asked.get(), 0, "turning it off asks nobody");
set(cx, true);
assert_eq!(asked.get(), 1);
assert!(window_gone.get());
// A closed window is forgotten; one still open is asked on every turn-on.
set(cx, false);
set(cx, true);
assert_eq!(asked.get(), 2);
assert_eq!(cx.update(|cx| cx.global::<LspStore>().windows.len()), 1);
}
+1
View File
@@ -22,6 +22,7 @@ pub mod host_registry;
pub mod i18n;
pub mod keymap;
pub mod local_link;
pub mod lsp;
pub mod machine_mirror;
pub mod notice;
pub mod pane;
+288 -7
View File
@@ -10,7 +10,7 @@ use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use tty7_core::core::github::{
ApiError, GitHubRemote, Item, ItemState, Kind, Label, RepoSlug, StateFilter,
ApiError, CheckState, GitHubRemote, Item, ItemState, Kind, Label, RepoSlug, StateFilter,
};
use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App};
@@ -48,7 +48,7 @@ impl Tty7App {
// other tab has, just to hold ↻. Refresh sits with the repository's
// other actions instead — in the repo row, and in a detail's header.
let title = self.panel_title(t(L10nKey::PanelGitHubTitle), None, None, window, cx);
let (repo, remotes, chosen) = match target {
let (host, repo, remotes, chosen) = match target {
GhTarget::NoPane => {
let body = self.panel_empty(
t(L10nKey::PanelNoWorkingDirectory),
@@ -78,10 +78,11 @@ impl Tty7App {
return self.github_shell(title, Vec::new(), body, false);
}
GhTarget::Ready {
host,
repo,
remotes,
chosen,
} => (repo, remotes, chosen),
} => (host, repo, remotes, chosen),
};
// A detail belongs to the repository it was opened from. If the pane
@@ -98,10 +99,20 @@ impl Tty7App {
let query = self.github_query(&chosen.slug);
self.github_ensure_list(&query, cx);
let mut pinned = vec![
self.github_repo_row(&repo, &remotes, &chosen, cx),
self.github_switch_row(cx),
];
let mut pinned = vec![self.github_repo_row(&repo, &remotes, &chosen, cx)];
// The pull request the pane is working on, one click from the list
// whichever way the list is switched.
if let Some(item) = self.github_branch_pull(host, &repo, &remotes, &chosen, cx) {
let branch = self
.github
.branches
.get(&repo)
.and_then(|b| b.head.as_ref())
.map(|h| h.branch.clone())
.unwrap_or_default();
pinned.push(self.github_branch_pull_row(&chosen.slug, &branch, &item, cx));
}
pinned.push(self.github_switch_row(cx));
if let Some(label) = self.github.label.clone() {
pinned.push(self.github_label_filter_row(&label, cx));
}
@@ -287,6 +298,102 @@ impl Tty7App {
.into_any_element()
}
/// The pane's branch's pull request, under a caption naming the branch:
/// its state, number and title, and — once its detail has been read — how
/// its checks stand. A list row in every other respect, so it rests
/// unfilled and lights up on hover, or while its detail is the one open.
fn github_branch_pull_row(
&self,
slug: &RepoSlug,
branch: &str,
item: &Item,
cx: &mut Context<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let muted = cx.theme().muted_foreground;
let mono = cx.theme().mono_font_family.clone();
let number = item.number;
let key = (slug.clone(), number);
let rollup = self
.github
.details
.get(&key)
.and_then(|d| d.detail.as_ref())
.and_then(|d| d.checks.as_ref())
.and_then(|c| c.rollup());
let open = self.github.open.as_ref() == Some(&key);
let slug = slug.clone();
let title = SharedString::from(item.title.clone());
let caption = h_flex()
.items_center()
.gap(px(6.))
.min_w_0()
.px(px(TEXT_INSET))
.text_size(rems(META))
.text_color(muted)
.child(
Icon::empty()
.path("icons/git-branch.svg")
.xsmall()
.text_color(muted),
)
.child(div().flex_none().child(t(L10nKey::GitHubThisBranch)))
.child(
div()
.min_w_0()
.truncate()
.font_family(mono.clone())
.child(branch.to_string()),
);
let row = h_flex()
.id("panel-github-branch-pull")
.items_center()
.gap(px(8.))
.h(px(ROW_H))
.w_full()
.px(px(ROW_INSET))
.rounded(ROW_FILL_RADIUS)
.cursor_pointer()
.when(open, |r| r.bg(gpui::rgb(sf.selected)))
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(title.clone()).build(window, cx)
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.github_open_detail(slug.clone(), number, cx);
}))
.child(div().flex_none().child(state_glyph(item.state, true, cx)))
.child(
div()
.flex_none()
.text_size(rems(META))
.font_family(mono)
.text_color(muted)
.child(format!("#{number}")),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(TEXT))
.text_color(gpui::rgb(if open {
sf.text_selected
} else {
sf.text_resting
}))
.child(item.title.clone()),
)
.children(rollup.map(|s| check_glyph(s, cx)));
v_flex()
.flex_none()
.gap(px(4.))
.pt(px(PINNED_GAP))
.child(caption)
.child(div().px(px(CONTENT_INSET)).child(row))
.into_any_element()
}
/// Issues | Pull Requests on the left, Open | Closed on the right.
fn github_switch_row(&self, cx: &mut Context<Self>) -> AnyElement {
let kind = self.github.kind;
@@ -627,6 +734,33 @@ pub(crate) fn state_glyph(state: ItemState, is_pr: bool, cx: &gpui::App) -> AnyE
.into_any_element()
}
/// A check's glyph: a shape per state, coloured the way the rest of the
/// panel colours success, failure and waiting.
pub(crate) fn check_glyph(state: CheckState, cx: &gpui::App) -> AnyElement {
let theme = cx.theme();
let (path, color) = match state {
CheckState::Passed => ("icons/github/check-passed.svg", theme.success),
CheckState::Failed => ("icons/github/check-failed.svg", theme.danger),
CheckState::Pending => ("icons/github/check-pending.svg", theme.warning),
CheckState::Skipped => ("icons/github/check-skipped.svg", theme.muted_foreground),
};
gpui::svg()
.path(path)
.flex_none()
.size(px(GLYPH))
.text_color(color)
.into_any_element()
}
pub(crate) fn check_label(state: CheckState) -> &'static str {
t(match state {
CheckState::Passed => L10nKey::GitHubCheckPassed,
CheckState::Failed => L10nKey::GitHubCheckFailed,
CheckState::Pending => L10nKey::GitHubCheckPending,
CheckState::Skipped => L10nKey::GitHubCheckSkipped,
})
}
pub(crate) fn state_label(state: ItemState) -> &'static str {
t(match state {
ItemState::Open => L10nKey::GitHubOpen,
@@ -939,4 +1073,151 @@ mod gpui_tests {
test_window::quiesce(&mut vcx, Some(&root));
let _ = std::fs::remove_dir_all(&root);
}
/// A signed-in fake with one pull request on the pane's branch, whose one
/// check is running until `done` is set.
struct PullFake {
asked: Mutex<Vec<String>>,
done: std::sync::atomic::AtomicBool,
}
impl Transport for PullFake {
fn get(&self, path: &str) -> Result<Reply, ApiError> {
self.asked.lock().unwrap().push(path.to_string());
let done = self.done.load(std::sync::atomic::Ordering::SeqCst);
let body =
if path.starts_with("/repos/acme/widgets/pulls?state=all&head=acme%3Afeat%2Fx&") {
r#"[{"number": 31, "title": "Handle empty summaries", "state": "open",
"head": {"ref": "feat/x", "sha": "abc"}, "base": {"ref": "main"}}]"#
} else if path.starts_with("/repos/acme/widgets/issues?") {
"[]"
} else if path == "/repos/acme/widgets/issues/31" {
r#"{"number": 31, "title": "Handle empty summaries", "state": "open",
"user": {"login": "ada"}, "pull_request": {"merged_at": null}}"#
} else if path.starts_with("/repos/acme/widgets/issues/31/comments")
|| path.starts_with("/repos/acme/widgets/pulls/31/files")
{
"[]"
} else if path == "/repos/acme/widgets/pulls/31" {
r#"{"number": 31, "title": "Handle empty summaries", "state": "open",
"user": {"login": "ada"}, "mergeable_state": "blocked",
"head": {"ref": "feat/x", "sha": "abc"}, "base": {"ref": "main"},
"requested_reviewers": [{"login": "jonas"}]}"#
} else if path.starts_with("/repos/acme/widgets/pulls/31/reviews") {
r#"[{"user": {"login": "mara"}, "state": "APPROVED"}]"#
} else if path.starts_with("/repos/acme/widgets/commits/abc/check-runs") {
if done {
r#"{"total_count": 1, "check_runs": [{"name": "test",
"status": "completed", "conclusion": "success"}]}"#
} else {
r#"{"total_count": 1, "check_runs": [{"name": "test",
"status": "in_progress"}]}"#
}
} else if path.starts_with("/repos/acme/widgets/commits/abc/status") {
r#"{"total_count": 0, "statuses": []}"#
} else {
return Err(ApiError::NotFound);
};
Ok(Reply {
body: body.as_bytes().to_vec(),
has_next: false,
})
}
fn authenticated(&self) -> bool {
true
}
}
#[gpui::test]
fn the_branchs_pull_request_is_pinned_and_its_running_checks_polled(cx: &mut TestAppContext) {
use tty7_core::core::github::CheckState;
let root = scratch("branch-pull");
git(&root, &["init", "--quiet", "-b", "feat/x"]);
git(
&root,
&["remote", "add", "origin", "https://github.com/acme/widgets"],
);
let fake = Arc::new(PullFake {
asked: Mutex::new(Vec::new()),
done: std::sync::atomic::AtomicBool::new(false),
});
let (app, mut vcx, mut pane) = test_window::harness_with_pane(cx);
let transport: Arc<dyn Transport> = fake.clone();
app.update_in(&mut vcx, |app, _, cx| {
app.github.connector = Some(Arc::new(move || Connection {
transport: transport.clone(),
}));
app.right_panel_visible = true;
app.right_panel_tab = RightPanelTab::GitHub;
cx.notify();
});
DaemonMsg::Cwd(root.clone())
.encode(&mut pane)
.expect("the pane's socket takes the cwd");
settle(&app, &mut vcx, "the branch's pull request", |app| {
app.github
.branch_pulls
.values()
.any(|p| p.item.as_ref().is_some_and(|i| i.number == 31))
});
let slug = app.update_in(&mut vcx, |app, _, cx| {
let slug = app.github.branch_pulls.keys().next().unwrap().slug.clone();
app.github_open_detail(slug.clone(), 31, cx);
slug
});
let key = (slug.clone(), 31);
let checks = |app: &Tty7App| {
app.github
.details
.get(&key)
.and_then(|d| d.detail.as_ref())
.and_then(|d| d.checks.clone())
};
settle(&app, &mut vcx, "the detail with its checks", |app| {
checks(app).is_some()
});
let reviewers = app.update_in(&mut vcx, |app, _, _| {
app.github.details[&key]
.detail
.as_ref()
.unwrap()
.reviewers
.clone()
.unwrap()
});
assert_eq!(reviewers.len(), 2, "one approval, one request");
assert_eq!(
app.update_in(&mut vcx, |app, _, _| checks(app).unwrap().rollup()),
Some(CheckState::Pending)
);
let reads_of_pull = || {
fake.asked
.lock()
.unwrap()
.iter()
.filter(|p| *p == "/repos/acme/widgets/pulls/31")
.count()
};
assert_eq!(reads_of_pull(), 1);
fake.done.store(true, std::sync::atomic::Ordering::SeqCst);
vcx.executor()
.advance_clock(crate::ui::github::CHECKS_POLL + std::time::Duration::from_secs(1));
settle(&app, &mut vcx, "the poll's verdict", |app| {
checks(app).and_then(|c| c.rollup()) == Some(CheckState::Passed)
});
// With every verdict in, the detail is read again for the merge state.
settle(&app, &mut vcx, "the detail read again", |_| {
reads_of_pull() >= 2
});
test_window::quiesce(&mut vcx, Some(&root));
let _ = std::fs::remove_dir_all(&root);
}
}
+51 -1
View File
@@ -49,6 +49,11 @@ fn build(
/// worktree form, with three fields, is 440.
const ALERT_W: f32 = 360.;
/// A third answer does not fit beside the other two at [`ALERT_W`]: the
/// update prompt's "Update and relaunch / Install on Next Launch / Later"
/// needs about 400 of button row, and the card clipped it.
const ALERT_W_WIDE: f32 = 460.;
/// How far down the alert sits, at most.
const ALERT_TOP: f32 = 180.;
@@ -139,6 +144,11 @@ impl Render for TextPrompt {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let muted = theme.muted_foreground;
// gpui paints the prompt as a root of its own, beside the window's
// `Root` rather than inside it, so nothing it sets is inherited here:
// left alone the card is set in gpui's `.SystemUIFont`, not the
// Interface font the rest of the chrome uses (#920).
let font_family = theme.font_family.clone();
let rungs = dialog::popover_rungs(cx);
// Answer 0 goes rightmost, where the native dialogs put it and where
@@ -186,14 +196,23 @@ impl Render for TextPrompt {
.child(detail)
}));
// Buttons never shrink, so a row too long for the card — a locale
// with longer labels than the width was sized for — wraps onto a
// second line, still flush right, rather than running off the edge.
let answers = h_flex()
.flex_wrap()
.justify_end()
.items_center()
.gap(px(8.))
.children(apart)
.child(div().flex_1())
.children(packed);
let card = dialog::card(ALERT_W, cx)
let width = match self.answers.len() {
0..=2 => ALERT_W,
_ => ALERT_W_WIDE,
};
let card = dialog::card(width, cx)
.max_w(gpui::relative(0.9))
.gap(px(16.))
.pt(px(20.))
@@ -211,6 +230,7 @@ impl Render for TextPrompt {
.id("text-prompt")
.track_focus(&self.focus)
.size_full()
.font_family(font_family)
// Nothing under the scrim answers the pointer while the question
// is up: the prompt is painted over the window, not into it, and
// gpui hands a click to every hitbox under it that is not
@@ -316,6 +336,36 @@ mod tests {
});
}
/// #920. The quit-and-stop question is asked through `window.prompt`, and
/// with the builder installed it must never reach the platform — on Linux
/// that meant gpui's fallback, a white box that clipped the text to one
/// line and ignored the theme. Return still answers it.
#[gpui::test]
fn the_quit_prompt_is_drawn_by_the_app_not_the_platform(cx: &mut TestAppContext) {
use crate::ui::i18n::{L10nKey, t};
let (_app, mut vcx) = crate::ui::app::test_window::harness(cx);
let mut answer = vcx.update(|window, cx| {
super::install(cx);
window.prompt(
gpui::PromptLevel::Warning,
t(L10nKey::QuitStopServerTitle),
Some(t(L10nKey::QuitStopServerBody)),
&crate::ui::confirm_answers(t(L10nKey::QuitAndStop), t(L10nKey::Cancel)),
cx,
)
});
vcx.run_until_parked();
assert!(
!vcx.has_pending_prompt(),
"the quit prompt fell through to the platform dialog"
);
vcx.simulate_keystrokes("enter");
vcx.run_until_parked();
assert_eq!(answer.try_recv().ok().flatten(), Some(0));
}
#[gpui::test]
fn a_lone_ok_answers_escape_too(cx: &mut TestAppContext) {
let p = prompt(cx, &[PromptButton::new("OK")]);
+49
View File
@@ -90,6 +90,13 @@ pub enum CommandKind {
DocumentWidthTwoThirds,
ToggleDocumentPreview,
ToggleDocumentWrap,
/// The code editor's text commands, run on the active buffer.
EditorTransformUppercase,
EditorTransformLowercase,
EditorTransformTitleCase,
EditorTrimTrailingWhitespace,
EditorJoinLines,
EditorRemoveSurroundingBrackets,
RestartSshSession,
ScmCommit,
ScmStageAll,
@@ -116,6 +123,22 @@ pub enum CommandKind {
line: Option<u32>,
column: Option<u32>,
},
/// The code editor's navigation, from the palette.
EditorGoToSymbol,
EditorNavigateBack,
EditorNavigateForward,
EditorSplitRight,
/// A Go to Symbol row: this 0-based place in the file in front.
GoToSymbol {
line: u32,
column: u32,
},
/// A place a language server found: 0-based, the column in chars.
GoToLocation {
path: std::path::PathBuf,
line: u32,
column: u32,
},
/// Connect with a typed `ssh` command line (`-p`, `-J`, an alias…).
OpenSshConnect(String),
SetTheme(usize),
@@ -247,6 +270,16 @@ impl CommandKind {
DocumentWidthTwoThirds => "document-width-two-thirds",
ToggleDocumentPreview => "document-preview",
ToggleDocumentWrap => "document-wrap",
EditorTransformUppercase => "editor-uppercase",
EditorTransformLowercase => "editor-lowercase",
EditorTransformTitleCase => "editor-title-case",
EditorTrimTrailingWhitespace => "editor-trim-trailing-whitespace",
EditorJoinLines => "editor-join-lines",
EditorRemoveSurroundingBrackets => "editor-remove-brackets",
EditorGoToSymbol => "editor-go-to-symbol",
EditorNavigateBack => "editor-go-back",
EditorNavigateForward => "editor-go-forward",
EditorSplitRight => "editor-split-right",
RestartSshSession => "ssh-reconnect",
ScmCommit => "git-commit",
ScmStageAll => "git-stage-all",
@@ -270,6 +303,8 @@ impl CommandKind {
| SetTheme(_)
| GoToTab { .. }
| OpenFile { .. }
| GoToSymbol { .. }
| GoToLocation { .. }
| ResumeSession { .. }
| ForkSession { .. }
| CopySessionId(_)
@@ -292,6 +327,8 @@ impl CommandKind {
CutText => return inline("secondary-x"),
PasteText => return inline("secondary-v"),
SelectAllText => return inline("secondary-a"),
// A fixed `CodeEditor` binding, not one of the keymap's slots.
EditorJoinLines => return inline("ctrl-j"),
LaunchAgent(agent) => {
return crate::ui::keymap::effective_key(
crate::ui::agent_launch::launch_action_name(*agent),
@@ -375,6 +412,16 @@ impl CommandKind {
DocumentWidthTwoThirds => "DocumentWidthTwoThirds",
ToggleDocumentPreview => "ToggleDocumentPreview",
ToggleDocumentWrap => "ToggleDocumentWrap",
EditorTransformUppercase => "EditorTransformUppercase",
EditorTransformLowercase => "EditorTransformLowercase",
EditorTransformTitleCase => "EditorTransformTitleCase",
EditorTrimTrailingWhitespace => "EditorTrimTrailingWhitespace",
EditorJoinLines => "EditorJoinLines",
EditorRemoveSurroundingBrackets => "EditorRemoveSurroundingBrackets",
EditorGoToSymbol => "EditorGoToSymbol",
EditorNavigateBack => "EditorNavigateBack",
EditorNavigateForward => "EditorNavigateForward",
EditorSplitRight => "EditorSplitRight",
RestartSshSession => "RestartSshSession",
OpenSshProfiles => "OpenSshProfiles",
ScmCommit => "ScmCommit",
@@ -406,6 +453,8 @@ impl CommandKind {
| SetTheme(_)
| GoToTab { .. }
| OpenFile { .. }
| GoToSymbol { .. }
| GoToLocation { .. }
| ResumeSession { .. }
| ForkSession { .. }
| CopySessionId(_)
+37 -3
View File
@@ -17,7 +17,7 @@ mod view;
pub(crate) use command::{Avatar, ChromeState, CommandGroup, CommandKind, Item};
pub(crate) use files::{FileIndexStore, FileList};
pub(crate) use score::fuzzy_score;
pub(crate) use sources::{Catalog, host_items};
pub(crate) use sources::{Catalog, LiveQuery, host_items};
pub(crate) use view::{KEY_CONTEXT, SearchEvent, SearchView};
use crate::ui::i18n::{L10nKey, t};
@@ -30,6 +30,12 @@ pub(crate) enum SearchTab {
Terminals,
Sessions,
Hosts,
/// Go to Symbol: the symbols of the file in front of the editor. Like
/// Files, reached by its chord (or the breadcrumbs), never by the row.
Symbols,
/// Places a language server found — the references to a symbol, or its
/// several definitions (`ui::lsp`). Reached only by those commands.
Locations,
}
impl SearchTab {
@@ -49,6 +55,17 @@ impl SearchTab {
SearchTab::Actions,
];
/// The editor's row: finding your way around the code, as opposed to
/// around the window. Go to File and Go to Symbol open on it, and Tab
/// walks it the way it walks the window's row. Symbols shows only with a
/// file in front; once something is typed it also lists what the file's
/// language server finds across the project.
pub(crate) const EDITOR_ORDER: [SearchTab; 2] = [SearchTab::Files, SearchTab::Symbols];
pub(crate) fn in_editor_row(self) -> bool {
Self::EDITOR_ORDER.contains(&self)
}
pub(crate) fn title(self) -> &'static str {
t(match self {
SearchTab::All => L10nKey::SearchTabAll,
@@ -57,9 +74,19 @@ impl SearchTab {
SearchTab::Terminals => L10nKey::SearchTabTerminals,
SearchTab::Sessions => L10nKey::SearchTabSessions,
SearchTab::Hosts => L10nKey::SearchTabHosts,
SearchTab::Symbols => L10nKey::SearchTabSymbols,
SearchTab::Locations => L10nKey::SearchTabLocations,
})
}
/// A tab outside the window's row. It never sits under that row with
/// nothing lit, and Tab never trades it for the terminals: the editor's
/// tabs get their own row (`EDITOR_ORDER`), and a language server's places
/// stand alone under their name.
pub(crate) fn stands_alone(self) -> bool {
!Self::ORDER.contains(&self)
}
pub(crate) fn placeholder(self) -> &'static str {
t(match self {
SearchTab::All => L10nKey::SearchPlaceholderAll,
@@ -68,6 +95,8 @@ impl SearchTab {
SearchTab::Terminals => L10nKey::SearchPlaceholderTerminals,
SearchTab::Sessions => L10nKey::SearchPlaceholderSessions,
SearchTab::Hosts => L10nKey::SearchPlaceholderHosts,
SearchTab::Symbols => L10nKey::SearchPlaceholderSymbols,
SearchTab::Locations => L10nKey::SearchPlaceholderLocations,
})
}
@@ -100,7 +129,12 @@ mod tests {
#[test]
fn files_is_reached_by_its_chord_not_by_the_row() {
assert!(!SearchTab::ORDER.contains(&SearchTab::Files));
// Tab out of Go to File lands on the row again.
assert_eq!(SearchTab::Files.step(true), SearchTab::Terminals);
assert!(!SearchTab::ORDER.contains(&SearchTab::Symbols));
// Reached by a chord, they stand alone rather than sit under a row
// with nothing in it lit.
for tab in [SearchTab::Files, SearchTab::Symbols, SearchTab::Locations] {
assert!(tab.stands_alone());
}
assert!(SearchTab::ORDER.iter().all(|tab| !tab.stands_alone()));
}
}
+190
View File
@@ -90,8 +90,24 @@ pub(crate) struct Catalog {
/// The project's files, from the window's last walk of it. A fresh walk
/// may land after the search opens (`Tty7App::refresh_file_index`).
pub files: FileList,
/// The symbols of the file in front of the editor, in document order,
/// each with how deep it sits — filled only for Go to Symbol
/// (`Tty7App::editor_go_to_symbol`).
pub symbols: Vec<(usize, Item)>,
/// What a language server found for Find References or a definition
/// with more than one answer (`ui::lsp`), in the order to list them.
pub locations: Vec<Item>,
/// What the front file's language server found across the project for
/// the Symbols tab's query (`ui::lsp`), listed after the file's own.
pub project_symbols: Vec<Item>,
/// Asked with every query the Symbols tab is given; its answer comes
/// back through `SearchView::set_project_symbols`.
pub live_query: Option<LiveQuery>,
}
/// Something that answers a query later — a language server.
pub(crate) type LiveQuery = std::rc::Rc<dyn Fn(&str, &mut App)>;
impl Catalog {
pub(crate) fn new(mut actions: Vec<Item>, terminals: Vec<Item>, hosts: Vec<Item>) -> Self {
// Stable, so a group keeps the order its rows were listed in. The
@@ -109,6 +125,10 @@ impl Catalog {
sessions: Vec::new(),
sessions_here: 0,
files: FileList::default(),
symbols: Vec::new(),
locations: Vec::new(),
project_symbols: Vec::new(),
live_query: None,
}
}
@@ -123,12 +143,17 @@ impl Catalog {
})),
SearchTab::Hosts => Some(Box::new(Hosts(&self.hosts))),
SearchTab::Files => Some(Box::new(Files(&self.files))),
SearchTab::Symbols => Some(Box::new(Symbols(&self.symbols))),
SearchTab::Locations => Some(Box::new(Locations(&self.locations))),
}
}
/// The sections `tab` shows for `query`.
pub(crate) fn sections(&self, tab: SearchTab, query: &str, cx: &App) -> Vec<Section> {
let query = query.trim();
if tab == SearchTab::Symbols && !query.is_empty() && !self.project_symbols.is_empty() {
return self.symbols_here_and_in_the_project(query, cx);
}
if let Some(source) = self.source(tab) {
return match query.is_empty() {
true => source.browse(cx),
@@ -138,6 +163,29 @@ impl Catalog {
self.all(query, cx)
}
/// A Symbols search once the language server has answered: the file's
/// own symbols first, since the one in front is the likeliest meant, then
/// the project's — each under a heading only when both have rows.
fn symbols_here_and_in_the_project(&self, query: &str, cx: &App) -> Vec<Section> {
let rows = |hits: Vec<(i32, Item)>| -> Vec<Row> {
hits.into_iter().map(|(_, item)| Row::Item(item)).collect()
};
let here = rows(Symbols(&self.symbols).search(query, cx));
let everywhere = rows(rank(&self.project_symbols, query, |_| 0));
let both = !here.is_empty() && !everywhere.is_empty();
[
(L10nKey::SearchSectionThisFile, here),
(L10nKey::SearchSectionProject, everywhere),
]
.into_iter()
.filter(|(_, rows)| !rows.is_empty())
.map(|(title, rows)| Section {
title: both.then(|| t(title).into()),
rows,
})
.collect()
}
/// Every tab's top rows, each under the tab's name.
///
/// Tabs are ordered by their best row, not fixed: the Return key takes the
@@ -421,6 +469,83 @@ impl Source for Sessions<'_> {
/// A typed address outranks any saved host: typing one is saying where to go.
const TYPED_ADDRESS_SCORE: i32 = 10_000;
/// Go to Symbol's rows. Browsing shows the file's shape, each symbol
/// indented under what holds it; a search ranks them flat, each naming what
/// holds it instead.
struct Symbols<'a>(&'a [(usize, Item)]);
/// How far one level of nesting indents a symbol while browsing.
const SYMBOL_INDENT: &str = " ";
impl Source for Symbols<'_> {
fn tab(&self) -> SearchTab {
SearchTab::Symbols
}
fn browse(&self, _cx: &App) -> Vec<Section> {
if self.0.is_empty() {
return Vec::new();
}
vec![Section {
title: None,
rows: self
.0
.iter()
.map(|(depth, item)| {
let mut item = item.clone();
item.title = format!("{}{}", SYMBOL_INDENT.repeat(*depth), item.title);
item.subtitle = None;
Row::Item(item)
})
.collect(),
}]
}
fn highlights(&self, _cx: &App) -> Vec<Item> {
Vec::new()
}
fn on_the_empty_all_tab(&self) -> bool {
false
}
fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> {
let items: Vec<Item> = self.0.iter().map(|(_, item)| item.clone()).collect();
rank(&items, query, |_| 0)
}
}
/// Find References' rows: listed as found, filtered by path and line text.
struct Locations<'a>(&'a [Item]);
impl Source for Locations<'_> {
fn tab(&self) -> SearchTab {
SearchTab::Locations
}
fn browse(&self, _cx: &App) -> Vec<Section> {
if self.0.is_empty() {
return Vec::new();
}
vec![Section {
title: None,
rows: self.0.iter().cloned().map(Row::Item).collect(),
}]
}
fn highlights(&self, _cx: &App) -> Vec<Item> {
Vec::new()
}
fn on_the_empty_all_tab(&self) -> bool {
false
}
fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> {
rank(self.0, query, |_| 0)
}
}
struct Hosts<'a>(&'a [Item]);
impl Source for Hosts<'_> {
@@ -602,6 +727,71 @@ mod tests {
.collect()
}
/// Symbols lists the file's own first; what the language server found
/// across the project follows, and headings appear only when both do.
#[gpui::test]
fn symbols_lists_the_file_then_the_project(cx: &mut TestAppContext) {
with_config(cx);
let here = |name: &str, line| {
(
0,
Item::new(name, CommandKind::GoToSymbol { line, column: 0 }),
)
};
let there = |name: &str| {
Item::new(
name,
CommandKind::GoToLocation {
path: "/p/other.rs".into(),
line: 0,
column: 0,
},
)
};
let mut catalog = Catalog::new(Vec::new(), Vec::new(), Vec::new());
catalog.symbols = vec![here("render", 3), here("Config", 9)];
let shape = |catalog: &Catalog, query: &str, cx: &mut TestAppContext| {
cx.update(|cx| {
catalog
.sections(SearchTab::Symbols, query, cx)
.into_iter()
.map(|s| {
let rows: Vec<String> = s
.rows
.iter()
.filter_map(|r| r.item().map(|i| i.title.clone()))
.collect();
(s.title.map(|t| t.to_string()), rows)
})
.collect::<Vec<_>>()
})
};
// Before the server answers: the file alone, untitled.
assert_eq!(
shape(&catalog, "render", cx),
vec![(None, vec!["render".to_string()])]
);
catalog.project_symbols = vec![there("render_row"), there("unrelated")];
assert_eq!(
shape(&catalog, "render", cx),
vec![
(Some("In This File".into()), vec!["render".to_string()]),
(Some("Project".into()), vec!["render_row".to_string()]),
]
);
// Nothing here: the project's rows need no heading.
assert_eq!(
shape(&catalog, "unrelated", cx),
vec![(None, vec!["unrelated".to_string()])]
);
// Nothing typed: the file's outline, never the project.
assert!(
shape(&catalog, "", cx)
.iter()
.all(|(_, rows)| !rows.contains(&"render_row".to_string()))
);
}
#[gpui::test]
fn actions_are_filed_under_their_group_whatever_order_they_came_in(cx: &mut TestAppContext) {
with_config(cx);
+278 -27
View File
@@ -80,6 +80,15 @@ impl SearchDelegate {
})
}
/// How many rows the filter left, for a standalone tab's heading.
fn row_count(&self) -> usize {
self.sections
.iter()
.flat_map(|s| s.rows.iter())
.filter(|r| r.item().is_some())
.count()
}
fn first_row(&self) -> Option<IndexPath> {
let section = self.sections.iter().position(|s| !s.rows.is_empty())?;
Some(IndexPath::new(0).section(section))
@@ -213,6 +222,11 @@ impl ListDelegate for SearchDelegate {
cx: &mut Context<ListState<Self>>,
) -> Task<()> {
self.query = query.to_string();
if self.scope == Scope::Tab(SearchTab::Symbols)
&& let Some(live) = self.catalog.live_query.clone()
{
live(query, cx);
}
self.refresh(cx);
// Through `set_selected_index`, not by hand: the row index may not have
// moved, but the row under it has, and the theme picker previews the
@@ -271,6 +285,13 @@ impl ListDelegate for SearchDelegate {
Scope::Tab(SearchTab::Sessions) if self.query.trim().is_empty() => {
t(L10nKey::SearchSessionsEmptyHint)
}
Scope::Tab(SearchTab::Locations) if self.catalog.locations.is_empty() => {
t(L10nKey::SearchLocationsNone)
}
Scope::Tab(SearchTab::Symbols) if self.catalog.symbols.is_empty() => {
headline = t(L10nKey::SearchSymbolsNone);
t(L10nKey::SearchSymbolsNoneHint)
}
_ => t(L10nKey::PaletteTryDifferentSearch),
};
// The headline in body ink and the way out under it in caption ink:
@@ -373,6 +394,22 @@ pub enum SearchEvent {
PreviewTheme(usize),
/// Put back the theme that was live before the preview started.
CancelThemePreview,
/// Show this 0-based place in the file Go to Symbol is listing, leaving
/// the keyboard in the search. Closing the search without a choice puts
/// the caret back (`Tty7App::close_search`).
PreviewSymbol {
line: u32,
column: u32,
},
/// Move to another tab on the editor's row, which the app sets up.
SwitchEditorTab(SearchTab),
/// The same for a place a language server found (`ui::lsp`), which may
/// be in another file.
PreviewLocation {
path: std::path::PathBuf,
line: u32,
column: u32,
},
}
pub struct SearchView {
@@ -389,6 +426,18 @@ pub struct SearchView {
/// How many session lists have arrived since the search opened. A test
/// waits on it: the scan runs on a real thread and lands when it lands.
sessions_landed: usize,
/// The symbol Go to Symbol opened on — the one around the caret — which
/// is not previewed until the highlight has moved: opening the picker
/// must not move the caret.
symbol_initial: Option<CommandKind>,
symbol_moved: bool,
/// What a language server's list is of — References, Definitions — shown
/// where the scope row would be. The other standalone tabs are named by
/// their title.
heading: Option<&'static str>,
/// The editor's row as this window can fill it (`SearchTab::EDITOR_ORDER`
/// less what cannot answer), set by whoever opened an editor tab.
editor_tabs: Vec<SearchTab>,
_sub: Subscription,
}
@@ -420,6 +469,10 @@ impl SearchView {
parked_query: None,
previewing: None,
sessions_landed: 0,
symbol_initial: None,
symbol_moved: false,
heading: None,
editor_tabs: Vec::new(),
_sub,
}
}
@@ -526,6 +579,96 @@ impl SearchView {
self.update_catalog(|catalog| catalog.files = files, window, cx);
}
/// Go to Symbol's rows, with the highlight on `selected` — the symbol the
/// caret is in — when it is one of them.
pub(crate) fn set_symbols(
&mut self,
symbols: Vec<(usize, Item)>,
selected: Option<CommandKind>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.symbol_moved = false;
self.symbol_initial = selected.clone();
self.update_catalog(|catalog| catalog.symbols = symbols, window, cx);
if let Some(kind) = selected {
self.list.update(cx, |state, cx| {
if let Some(ix) = state.delegate().position_of(&kind) {
state.set_selected_index(Some(ix), window, cx);
state.scroll_to_selected_item(window, cx);
}
});
}
}
/// Find References' rows (`ui::lsp`). The first is highlighted and not
/// previewed — opening the list must not move the caret — until the
/// highlight moves.
pub(crate) fn set_locations(
&mut self,
heading: Option<&'static str>,
locations: Vec<Item>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.heading = heading;
self.symbol_moved = false;
self.symbol_initial = locations.first().map(|item| item.kind.clone());
self.update_catalog(|catalog| catalog.locations = locations, window, cx);
}
/// The editor tabs this window can offer, for the editor's row.
pub(crate) fn set_editor_tabs(&mut self, tabs: Vec<SearchTab>, cx: &mut Context<Self>) {
self.editor_tabs = tabs;
cx.notify();
}
/// The row over the list: the window's, the editor's (when it has more
/// than one tab to offer), or none — the tab then stands alone under its
/// name.
fn scope_row(&self) -> Option<Vec<SearchTab>> {
if !self.tab.stands_alone() {
return Some(SearchTab::ORDER.to_vec());
}
(self.tab.in_editor_row() && self.editor_tabs.len() > 1).then(|| self.editor_tabs.clone())
}
/// Moves to `tab` on whichever row it is on. The editor's tabs each need
/// the app to set them up (an outline, a server to ask), so those go by
/// way of it.
fn go_to_tab(&mut self, tab: SearchTab, window: &mut Window, cx: &mut Context<Self>) {
if tab == self.tab {
return;
}
if tab.in_editor_row() {
cx.emit(SearchEvent::SwitchEditorTab(tab));
} else {
self.set_tab(tab, None, window, cx);
}
}
/// What the language server found across the project for the Symbols
/// tab's current query, listed after the file's own symbols.
pub(crate) fn set_project_symbols(
&mut self,
symbols: Vec<Item>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_catalog(|catalog| catalog.project_symbols = symbols, window, cx);
}
/// Sets what the Symbols tab asks the language server as the query
/// changes.
pub(crate) fn set_live_query(
&mut self,
live: crate::ui::search::LiveQuery,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_catalog(|catalog| catalog.live_query = Some(live), window, cx);
}
/// Changes part of the catalog under an open list. The highlight stays on
/// the row it was on when that row is still there, so a list that fills in
/// under the cursor does not move what Return runs.
@@ -554,13 +697,26 @@ impl SearchView {
cx.notify();
}
#[cfg(test)]
pub(crate) fn symbol_count(&self) -> usize {
self.catalog.symbols.len()
}
/// The tab showing — or, in a row's own list, the one Escape returns to.
pub(crate) fn tab(&self) -> SearchTab {
self.tab
}
fn step_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
self.set_tab(self.tab.step(forward), None, window, cx);
let Some(row) = self.scope_row() else { return };
let n = row.len();
let i = row.iter().position(|t| *t == self.tab).unwrap_or(0);
let next = row[if forward {
(i + 1) % n
} else {
(i + n - 1) % n
}];
self.go_to_tab(next, window, cx);
}
fn open_themes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -689,6 +845,32 @@ impl SearchView {
false => cx.emit(SearchEvent::Dismiss),
},
ListEvent::Select(ix) => {
if !self.in_sub_list()
&& self.tab == SearchTab::Symbols
&& let Some(Row::Item(item)) = list.read(cx).delegate().row_at(*ix)
&& let CommandKind::GoToSymbol { line, column } = item.kind
{
if self.symbol_moved || self.symbol_initial.as_ref() != Some(&item.kind) {
self.symbol_moved = true;
cx.emit(SearchEvent::PreviewSymbol { line, column });
}
return;
}
if !self.in_sub_list()
&& matches!(self.tab, SearchTab::Locations | SearchTab::Symbols)
&& let Some(Row::Item(item)) = list.read(cx).delegate().row_at(*ix)
&& let CommandKind::GoToLocation { path, line, column } = &item.kind
{
if self.symbol_moved || self.symbol_initial.as_ref() != Some(&item.kind) {
self.symbol_moved = true;
cx.emit(SearchEvent::PreviewLocation {
path: path.clone(),
line: *line,
column: *column,
});
}
return;
}
if self.in_sub_list()
&& let Some(Row::Item(item)) = list.read(cx).delegate().row_at(*ix)
&& let CommandKind::SetTheme(i) = item.kind
@@ -718,32 +900,68 @@ impl SearchView {
.bg(theme.popover)
.border_b_1()
.border_color(theme.border)
.children(SearchTab::ORDER.into_iter().enumerate().map(|(i, tab)| {
let active = tab == self.tab;
div()
.id(("search-tab", i))
.h(px(24.))
.px(px(9.))
.flex()
.items_center()
.rounded(px(6.))
.text_size(rems(SCOPE_TEXT))
.cursor_pointer()
.map(|d| match active {
true => d
.bg(active_bg)
.text_color(fg)
.font_weight(FontWeight::MEDIUM),
false => d
.when(self.scope_row().is_none(), |row| {
// Go to File's rows are a capped sample of the project, so a
// count there would read as the size of something it is not.
let count = match self.tab {
SearchTab::Files => 0,
_ => self.list.read(cx).delegate().row_count(),
};
let heading = match self.tab {
SearchTab::Locations => self.heading.unwrap_or_else(|| self.tab.title()),
_ => self.tab.title(),
};
row.child(
div()
.h(px(24.))
.px(px(9.))
.flex()
.items_center()
.rounded(px(6.))
.bg(active_bg)
.text_size(rems(SCOPE_TEXT))
.text_color(fg)
.font_weight(FontWeight::MEDIUM)
.child(heading),
)
.when(count > 0, |row| {
row.child(
div()
.px(px(6.))
.text_size(rems(SCOPE_TEXT))
.text_color(muted)
.hover(move |d| d.bg(hover_bg).text_color(fg)),
})
.child(tab.title())
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
this.set_tab(tab, None, window, cx);
this.list.update(cx, |state, cx| state.focus(window, cx));
}))
}))
.child(count.to_string()),
)
})
})
.when_some(self.scope_row(), |row, tabs| {
row.children(tabs.into_iter().enumerate().map(|(i, tab)| {
let active = tab == self.tab;
div()
.id(("search-tab", i))
.h(px(24.))
.px(px(9.))
.flex()
.items_center()
.rounded(px(6.))
.text_size(rems(SCOPE_TEXT))
.cursor_pointer()
.map(|d| match active {
true => d
.bg(active_bg)
.text_color(fg)
.font_weight(FontWeight::MEDIUM),
false => d
.text_color(muted)
.hover(move |d| d.bg(hover_bg).text_color(fg)),
})
.child(tab.title())
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
this.go_to_tab(tab, window, cx);
this.list.update(cx, |state, cx| state.focus(window, cx));
}))
}))
})
}
/// The switcher's footer, minus its New workspace button: the keys that
@@ -757,7 +975,7 @@ impl SearchView {
.children(keys)
.child(label)
};
let tabs = !self.in_sub_list();
let tabs = !self.in_sub_list() && self.scope_row().is_some();
h_flex()
.flex_none()
.items_center()
@@ -973,6 +1191,17 @@ impl Render for SearchView {
.on_action(
cx.listener(|this, _: &SearchPrevTab, window, cx| this.step_tab(false, window, cx)),
)
// Go to Symbol's chord puts Go to Symbol away, as Go to File's
// does; anywhere else in the search it is the switcher's.
.on_action(cx.listener(
|this, _: &crate::core::actions::EditorGoToSymbol, _window, cx| {
if this.tab == SearchTab::Symbols && !this.in_sub_list() {
cx.emit(SearchEvent::Dismiss);
} else {
cx.propagate();
}
},
))
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
if is_edit_gesture(&ev.keystroke) && this.on_edit_gesture(window, cx) {
cx.stop_propagation();
@@ -1165,6 +1394,28 @@ mod tests {
assert!(app.read_with(&vcx, |app, _| app.search.is_none()));
}
/// A tab reached by its own chord stands alone: Tab does not swap Go to
/// File or the places a language server found for the terminals.
#[gpui::test]
fn a_standalone_tab_keeps_its_list_on_tab(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
crate::ui::i18n::set_locale("en");
for tab in [SearchTab::Files, SearchTab::Symbols, SearchTab::Locations] {
app.update_in(&mut vcx, |app, window, cx| {
app.open_search(tab, "", window, cx)
});
vcx.run_until_parked();
let view = open(&app, &mut vcx);
view.update_in(&mut vcx, |view, window, cx| {
view.step_tab(true, window, cx);
view.step_tab(false, window, cx);
});
view.read_with(&vcx, |view, _| assert_eq!(view.tab, tab));
app.update_in(&mut vcx, |app, window, cx| app.close_search(window, cx));
vcx.run_until_parked();
}
}
/// The Actions tab's "Go to File…" row moves the search to its Files tab
/// rather than closing it, with whatever found the row cleared away.
#[gpui::test]
+35
View File
@@ -31,6 +31,7 @@ use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::presets;
mod agents;
mod editor;
mod hosts;
pub(crate) mod kit;
pub(crate) mod mobile;
@@ -682,6 +683,26 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsSidebarGrouping,
keywords: SettingsSearchSidebarGroupingKeywords,
},
SearchEntry {
section: General,
title: SettingsEditorGitGutter,
keywords: SettingsSearchEditorGitGutterKeywords,
},
SearchEntry {
section: General,
title: SettingsEditorLsp,
keywords: SettingsSearchEditorLspKeywords,
},
SearchEntry {
section: General,
title: SettingsEditorSoftWrap,
keywords: SettingsSearchEditorSoftWrapKeywords,
},
SearchEntry {
section: General,
title: SettingsEditorMarkdownPreview,
keywords: SettingsSearchEditorMarkdownPreviewKeywords,
},
SearchEntry {
section: General,
title: SettingsNotifyOnCommandFinish,
@@ -778,6 +799,10 @@ impl SearchEntry {
L10nKey::SettingsNewTabPosition => "new_tab_position",
L10nKey::SettingsTabBarPosition => "tab_bar_position",
L10nKey::SettingsSidebarGrouping => "sidebar_auto_grouping",
L10nKey::SettingsEditorGitGutter => "editor_git_gutter",
L10nKey::SettingsEditorLsp => "editor_lsp",
L10nKey::SettingsEditorSoftWrap => "editor_soft_wrap",
L10nKey::SettingsEditorMarkdownPreview => "editor_markdown_preview",
L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish",
L10nKey::SettingsNotifyThreshold => "notify_threshold_secs",
L10nKey::SettingsTerminalBell => "bell",
@@ -888,6 +913,10 @@ impl SearchEntry {
L10nKey::SettingsNewTabPosition => t(L10nKey::SettingsNewTabPositionDesc),
L10nKey::SettingsTabBarPosition => t(L10nKey::SettingsTabBarPositionDesc),
L10nKey::SettingsSidebarGrouping => t(L10nKey::SettingsSidebarGroupingDesc),
L10nKey::SettingsEditorGitGutter => t(L10nKey::SettingsEditorGitGutterDesc),
L10nKey::SettingsEditorLsp => t(L10nKey::SettingsEditorLspDesc),
L10nKey::SettingsEditorSoftWrap => t(L10nKey::SettingsEditorSoftWrapDesc),
L10nKey::SettingsEditorMarkdownPreview => t(L10nKey::SettingsEditorMarkdownPreviewDesc),
L10nKey::SettingsNotifyOnCommandFinish => t(L10nKey::SettingsNotifyOnCommandFinishDesc),
L10nKey::SettingsNotifyThreshold => t(L10nKey::SettingsNotifyThresholdDesc),
L10nKey::SettingsAppHttpProxy => t(L10nKey::SettingsAppHttpProxyDesc),
@@ -933,6 +962,12 @@ impl SearchEntry {
L10nKey::SettingsSidebarGrouping => {
cfg.sidebar_auto_grouping != defaults.sidebar_auto_grouping
}
L10nKey::SettingsEditorGitGutter => cfg.editor_git_gutter != defaults.editor_git_gutter,
L10nKey::SettingsEditorLsp => cfg.editor_lsp != defaults.editor_lsp,
L10nKey::SettingsEditorSoftWrap => cfg.editor_soft_wrap != defaults.editor_soft_wrap,
L10nKey::SettingsEditorMarkdownPreview => {
cfg.editor_markdown_preview != defaults.editor_markdown_preview
}
L10nKey::SettingsNotifyOnCommandFinish => {
cfg.notify_on_command_finish != defaults.notify_on_command_finish
}
+81
View File
@@ -0,0 +1,81 @@
//! The Editor group on the General page: the code editor's switches that
//! used to live only in config.json or in a status-bar toggle.
use super::*;
impl Tty7App {
pub(super) fn render_editor_group(&self, cx: &mut Context<Self>) -> AnyElement {
let cfg = cx.global::<Config>();
let (git_gutter, lsp, soft_wrap, markdown) = (
cfg.editor_git_gutter,
cfg.editor_lsp,
cfg.editor_soft_wrap,
cfg.editor_markdown_preview,
);
let git_gutter =
self.settings_switch("wt-editor-git-gutter", git_gutter, cx, |this, on, _, cx| {
this.set_editor_git_gutter(on, cx)
});
let lsp = self.settings_switch("wt-editor-lsp", lsp, cx, |this, on, _, cx| {
this.set_editor_lsp(on, cx)
});
let soft_wrap =
self.settings_switch("wt-editor-soft-wrap", soft_wrap, cx, |this, on, _, cx| {
this.set_editor_soft_wrap(on, cx)
});
let markdown =
self.settings_switch("wt-editor-markdown", markdown, cx, |this, on, _, cx| {
this.set_editor_markdown_preview(on, cx)
});
self.settings_group(
Some(t(L10nKey::SettingsEditor)),
None,
[
self.settings_row(
t(L10nKey::SettingsEditorGitGutter),
t(L10nKey::SettingsEditorGitGutterDesc),
git_gutter,
cx,
),
self.settings_row(
t(L10nKey::SettingsEditorLsp),
t(L10nKey::SettingsEditorLspDesc),
lsp,
cx,
),
self.settings_row(
t(L10nKey::SettingsEditorSoftWrap),
t(L10nKey::SettingsEditorSoftWrapDesc),
soft_wrap,
cx,
),
self.settings_row(
t(L10nKey::SettingsEditorMarkdownPreview),
t(L10nKey::SettingsEditorMarkdownPreviewDesc),
markdown,
cx,
),
]
.map(IntoElement::into_any_element),
cx,
)
}
/// `LspStore` watches the config: off closes every document and lets
/// its server go; on asks every window for the files it has open, which
/// starts their servers again.
pub(crate) fn set_editor_lsp(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.editor_lsp = on);
cx.notify();
}
/// What new files open with; files already open keep their own Wrap.
pub(crate) fn set_editor_soft_wrap(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.editor_soft_wrap = on);
}
/// What Markdown files open as; files already open keep their own mode.
pub(crate) fn set_editor_markdown_preview(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.editor_markdown_preview = on);
}
}
+1
View File
@@ -194,6 +194,7 @@ impl Tty7App {
cx,
),
self.render_tabs_group(cx),
self.render_editor_group(cx),
self.settings_group(
Some(t(L10nKey::SettingsNotifications)),
None,
+4
View File
@@ -86,6 +86,10 @@ pub(crate) fn set_menus(cx: &mut App) {
Menu::new(t(L10nKey::AppMenuView)).items([
MenuItem::action(t(L10nKey::AppMenuSearchEverywhere), TogglePalette),
MenuItem::action(t(L10nKey::CmdGoToFile), QuickOpenFile),
MenuItem::action(t(L10nKey::EditorGoToSymbolAction), EditorGoToSymbol),
MenuItem::action(t(L10nKey::EditorNavigateBack), EditorNavigateBack),
MenuItem::action(t(L10nKey::EditorNavigateForward), EditorNavigateForward),
MenuItem::action(t(L10nKey::EditorSplitRight), EditorSplitRight),
MenuItem::separator(),
MenuItem::action(t(L10nKey::AppMenuIncreaseFontSize), IncreaseFontSize),
MenuItem::action(t(L10nKey::AppMenuDecreaseFontSize), DecreaseFontSize),
+234 -20
View File
@@ -1,10 +1,15 @@
use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px, rems};
use gpui::{
AnyElement, Context, Div, Entity, PromptLevel, Subscription, Window, div, prelude::*, px, rems,
};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::{ActiveTheme as _, WindowExt as _};
use crate::core::worktree::{WorktreeDefaults, WorktreeRequest};
use crate::core::cli_agent::CLIAgent;
use crate::core::config::Config;
use crate::core::worktree::{NewWorktree, WorktreeDefaults, WorktreeRequest, setup};
use crate::ui::app::Tty7App;
use crate::ui::dialog::{self, Tone};
use crate::ui::host_ops::HostId;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::right_panel::META_MONO;
@@ -15,10 +20,30 @@ pub(crate) struct WorktreePrompt {
name: Entity<InputState>,
branch: Entity<InputState>,
base: Entity<InputState>,
task: Entity<InputState>,
agents: Vec<CLIAgent>,
/// Which of `agents` the first pane starts; `None` is a plain shell.
agent: Option<usize>,
/// The Start dropdown is open.
agent_menu: bool,
has_setup: bool,
setup_hint: Option<&'static str>,
busy: bool,
_subs: Vec<Subscription>,
}
/// What the new worktree's first pane was asked to start.
struct Start {
agent: Option<CLIAgent>,
task: String,
}
/// A created worktree, and the setup script its checkout carries.
struct Created {
wt: NewWorktree,
setup: Option<(setup::Setup, Vec<(&'static str, String)>)>,
}
impl Tty7App {
pub(crate) fn open_worktree_prompt(
&mut self,
@@ -36,8 +61,9 @@ impl Tty7App {
let name = prefill(defaults.name.clone(), window, cx);
let branch = prefill(defaults.name, window, cx);
let base = prefill(defaults.base, window, cx);
let task = cx.new(|cx| InputState::new(window, cx));
name.update(cx, |state, cx| state.focus(window, cx));
let subs = [&name, &branch, &base]
let subs = [&name, &branch, &base, &task]
.into_iter()
.map(|input| {
cx.subscribe_in(
@@ -51,6 +77,12 @@ impl Tty7App {
)
})
.collect();
let agents = self.offered_agents(cx);
// A worktree is usually for a task, so it opens on the agent last
// used; Shell is one pick away.
let agent =
crate::ui::agent_launch::most_recent(&agents, &cx.global::<Config>().agent_frecency)
.and_then(|a| agents.iter().position(|x| *x == a));
self.worktree_prompt = Some(WorktreePrompt {
host,
cwd,
@@ -58,6 +90,12 @@ impl Tty7App {
name,
branch,
base,
task,
agents,
agent,
agent_menu: false,
has_setup: defaults.has_setup,
setup_hint: defaults.setup_hint,
busy: false,
_subs: subs,
});
@@ -99,20 +137,34 @@ impl Tty7App {
base
},
};
let start = Start {
agent: p.agent.map(|i| p.agents[i]),
task: p.task.read(cx).value().to_string(),
};
let p = self.worktree_prompt.as_mut().expect("checked above");
p.busy = true;
let cwd = p.cwd.clone();
let host = p.host.clone();
let host_id = host.id();
cx.notify();
crate::ui::host_ops::HostOps::run_in(
host,
window,
cx,
move |h| crate::core::worktree::create(h, &cwd, &req),
move |h| {
let wt = crate::core::worktree::create(h, &cwd, &req)?;
// The script is a POSIX executable run through `env`; a
// Windows host has neither.
let setup = (h.separator() == '/')
.then(|| setup::find(h, &wt.path))
.flatten()
.zip(setup::env(h, &wt.main_root, &wt.path));
Ok::<_, String>(Created { wt, setup })
},
move |this, result, window, cx| match result {
Ok(wt) => {
Ok(created) => {
this.worktree_prompt = None;
this.open_worktree_tab(wt, window, cx);
this.start_worktree(host_id, created, start, window, cx);
}
Err(e) => {
if let Some(p) = this.worktree_prompt.as_mut() {
@@ -131,6 +183,132 @@ impl Tty7App {
);
}
/// Open the worktree's tab, running its setup first — once the script's
/// content has been approved for this repo.
fn start_worktree(
&mut self,
host: HostId,
created: Created,
start: Start,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Created { wt, setup } = created;
if !wt.carried.skipped.is_empty() {
let paths = wt
.carried
.skipped
.iter()
.map(|(p, why)| format!("{p} ({why})"))
.collect::<Vec<_>>()
.join(", ");
window.push_notification(
t_fmt(L10nKey::AppWorktreeNotCarried, &[("paths", &paths)]),
cx,
);
}
let agent = start
.agent
.map(|a| setup::agent_line(a, &start.task, &cx.global::<Config>().agent_launch));
let Some((script, env)) = setup else {
let line = setup::launch_line(&wt.path, None, agent);
self.open_worktree_tab(wt, line, window, cx);
return;
};
let key = setup::trust_key(host, &wt.main_root);
if cx.global::<Config>().worktree_setup_trust.get(&key) == Some(&script.digest) {
let line = setup::launch_line(&wt.path, Some((&script.script, &env)), agent);
self.open_worktree_tab(wt, line, window, cx);
return;
}
let answer = window.prompt(
PromptLevel::Warning,
t(L10nKey::AppWorktreeSetupTitle),
Some(&t_fmt(
L10nKey::AppWorktreeSetupDetail,
&[("path", &script.script.display().to_string())],
)),
&crate::ui::confirm_answers(
t(L10nKey::AppWorktreeSetupRun),
t(L10nKey::AppWorktreeSetupSkip),
),
cx,
);
cx.spawn_in(window, async move |this, cx| {
let run = matches!(answer.await, Ok(0));
let _ = this.update_in(cx, |this, window, cx| {
if run {
this.update_config(cx, |cfg| {
cfg.worktree_setup_trust.insert(key, script.digest.clone());
});
}
let setup = run.then_some((script.script.as_path(), env.as_slice()));
let line = setup::launch_line(&wt.path, setup, agent);
this.open_worktree_tab(wt, line, window, cx);
});
})
.detach();
}
fn close_worktree_start_menu(&mut self, cx: &mut Context<Self>) {
if let Some(p) = self.worktree_prompt.as_mut().filter(|p| p.agent_menu) {
p.agent_menu = false;
cx.notify();
}
}
/// The Start dropdown: Shell, then every agent this machine offers.
fn render_worktree_start(&self, p: &WorktreePrompt, cx: &mut Context<Self>) -> Div {
use crate::ui::settings::kit;
let tk = kit::Tk::of(cx);
let current = p.agent.map_or(t(L10nKey::WorktreePromptShell), |i| {
p.agents[i].display_name()
});
let trigger = dialog::select_well("worktree-start", current, cx)
// Not a click on the form, which closes the menu this reopens.
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click(cx.listener(|this, _, _, cx| {
if let Some(p) = this.worktree_prompt.as_mut() {
p.agent_menu = !p.agent_menu;
cx.notify();
}
}));
let menu = p.agent_menu.then(|| {
let choices = std::iter::once((None, t(L10nKey::WorktreePromptShell))).chain(
p.agents
.iter()
.enumerate()
.map(|(i, a)| (Some(i), a.display_name())),
);
let mut panel = kit::menu_panel(&tk).min_w(px(220.));
for (row, (choice, label)) in choices.enumerate() {
panel = panel.child(
kit::menu_row(
gpui::ElementId::NamedInteger("worktree-start-item".into(), row as u64),
false,
&tk,
)
.child(kit::check_mark(choice == p.agent, &tk))
.child(label)
.on_click(cx.listener(move |this, _, _, cx| {
if let Some(p) = this.worktree_prompt.as_mut() {
p.agent = choice;
p.agent_menu = false;
cx.notify();
}
})),
);
}
// `popover_below` measures from a 26px settings trigger.
kit::popover_below(false, dialog::FIELD_H - 26. + 4., panel)
});
dialog::labelled_control(
t(L10nKey::WorktreePromptAgent),
div().relative().child(trigger).children(menu),
cx,
)
}
pub(crate) fn render_worktree_prompt_overlay(
&self,
cx: &mut Context<Self>,
@@ -159,35 +337,65 @@ impl Tty7App {
})
.display()
.to_string();
let mono = cx.theme().mono_font_family.clone();
let meta = move |text: String| {
div()
.truncate()
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(muted)
.child(text)
};
let setup_note = match (p.has_setup, p.setup_hint) {
(true, _) => Some(t(L10nKey::WorktreePromptSetup).to_string()),
(false, Some(command)) => Some(t_fmt(
L10nKey::WorktreePromptSetupHint,
&[("command", command)],
)),
(false, None) => None,
};
// Only an agent that can open on a first message gets the Task box;
// for the rest it would be a field that does nothing.
let takes_task = p
.agent
.is_some_and(|i| p.agents[i].prompt_args("").is_some());
let start = (!p.agents.is_empty()).then(|| self.render_worktree_start(p, cx));
let rungs = dialog::popover_rungs(cx);
let card = dialog::card(440., cx)
.child(dialog::header(t(L10nKey::WorktreePromptTitle), cx))
.child(
dialog::body()
// A click anywhere else in the form closes the Start menu.
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|this, _: &gpui::MouseDownEvent, _, cx| {
this.close_worktree_start_menu(cx)
}),
)
// The path preview hangs off the Name field it follows,
// closer to it than the next field is.
.child(
dialog::labelled(t(L10nKey::WorktreePromptName), Input::new(&p.name), cx)
.child(
div()
.truncate()
.text_size(rems(META_MONO))
.font_family(cx.theme().mono_font_family.clone())
.text_color(muted)
.child(preview),
),
.child(meta(preview)),
)
.child(dialog::labelled(
t(L10nKey::WorktreePromptBranch),
Input::new(&p.branch),
cx,
))
.child(dialog::labelled(
t(L10nKey::WorktreePromptBase),
Input::new(&p.base),
cx,
)),
.child(
dialog::labelled(t(L10nKey::WorktreePromptBase), Input::new(&p.base), cx)
.children(setup_note.map(meta)),
)
.children(start)
.when(takes_task, |body| {
body.child(dialog::labelled(
t(L10nKey::WorktreePromptTask),
Input::new(&p.task),
cx,
))
}),
)
.child(
dialog::footer(cx)
@@ -225,7 +433,13 @@ impl Tty7App {
// palette and the switcher already answer to.
.bg(crate::ui::presets::scrim_fill(cx))
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
if ev.keystroke.key == "escape" {
if ev.keystroke.key != "escape" {
return;
}
// Escape backs out one level: an open menu first.
if this.worktree_prompt.as_ref().is_some_and(|p| p.agent_menu) {
this.close_worktree_start_menu(cx);
} else {
this.cancel_worktree_prompt(window, cx);
}
}))