Merge pull request #158 from l0ng-ai/worktree-code-panel

feat(right-panel): docked detail panel, code editor overlay and LSP
This commit is contained in:
l0ng-ai
2026-07-24 17:28:29 +08:00
committed by GitHub
29 changed files with 6796 additions and 156 deletions
Generated
+370 -3
View File
@@ -3180,7 +3180,7 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.5.2"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9"
dependencies = [
"aho-corasick",
"anyhow",
@@ -3220,7 +3220,40 @@ dependencies = [
"smol",
"tracing",
"tree-sitter",
"tree-sitter-astro-next",
"tree-sitter-bash",
"tree-sitter-c",
"tree-sitter-c-sharp",
"tree-sitter-cmake",
"tree-sitter-cpp",
"tree-sitter-css",
"tree-sitter-diff",
"tree-sitter-elixir",
"tree-sitter-embedded-template",
"tree-sitter-go",
"tree-sitter-graphql",
"tree-sitter-html",
"tree-sitter-java",
"tree-sitter-javascript",
"tree-sitter-jsdoc",
"tree-sitter-json",
"tree-sitter-kotlin-sg",
"tree-sitter-lua",
"tree-sitter-make",
"tree-sitter-md",
"tree-sitter-php",
"tree-sitter-proto",
"tree-sitter-python",
"tree-sitter-ruby",
"tree-sitter-rust",
"tree-sitter-scala",
"tree-sitter-sequel",
"tree-sitter-svelte-next",
"tree-sitter-swift",
"tree-sitter-toml-ng",
"tree-sitter-typescript",
"tree-sitter-yaml",
"tree-sitter-zig",
"unicode-segmentation",
"uuid",
"windows 0.58.0",
@@ -3230,7 +3263,7 @@ dependencies = [
[[package]]
name = "gpui-component-assets"
version = "0.5.1"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9"
dependencies = [
"anyhow",
"gpui",
@@ -3244,7 +3277,7 @@ dependencies = [
[[package]]
name = "gpui-component-macros"
version = "0.5.1"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5"
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9"
dependencies = [
"proc-macro2",
"quote",
@@ -9135,6 +9168,166 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-astro-next"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "794a4a59fc2d88e49b4bc41fef9522d77184a36f4e68bbaf545cd1eb2364c46e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-bash"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-c"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-c-sharp"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-cmake"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-cpp"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-css"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-diff"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-elixir"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-embedded-template"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-go"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-graphql"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-html"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-java"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-javascript"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-jsdoc"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-json"
version = "0.24.8"
@@ -9145,12 +9338,182 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-kotlin-sg"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06ec43ae3c12165d4ac08afe4e1f5fc6757ffe274fa7bd5af9007ef11ba4319"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-lua"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-make"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-md"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-php"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-proto"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-python"
version = "0.23.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-ruby"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-rust"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-scala"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-sequel"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-svelte-next"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f88190d0743e897c3e148a7e241aba0a8844b8afe816943851426e3f7b9753"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-swift"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-toml-ng"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-typescript"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-yaml"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-zig"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "triomphe"
version = "0.1.15"
@@ -9189,6 +9552,7 @@ dependencies = [
"gpui-component",
"gpui-component-assets",
"gpui_platform",
"ignore",
"image",
"jieba-rs",
"keyring",
@@ -9196,6 +9560,7 @@ dependencies = [
"libc",
"libgssapi",
"log",
"lsp-types",
"memchr",
"notify 8.2.0",
"notify-rust",
@@ -9207,6 +9572,7 @@ dependencies = [
"regex",
"reqwest_client",
"resvg",
"ropey",
"russh",
"russh-sftp",
"serde",
@@ -9217,6 +9583,7 @@ dependencies = [
"smol",
"tokio",
"tray-icon",
"url",
"uuid",
"windows-sys 0.59.0",
"winresource",
+23 -5
View File
@@ -76,9 +76,21 @@ alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev
notify-rust = "4"
# Filesystem watcher for live config reload: watches `config.json` and reloads
# `Config` + re-applies the theme without a restart.
# `Config` + re-applies the theme without a restart. The code panel (file tree /
# editor, `ui::code`) reuses it to refresh the tree and detect external edits.
notify = "8"
# Code panel (file tree + editor, `ui::code`). `ropey` and `lsp-types` are pinned
# to the exact versions gpui-component uses so its `InputState` rope / LSP
# provider types unify with ours; `ignore` supplies the gitignore matcher chain
# the file tree uses to dim ignored entries (same crate ripgrep uses).
ropey = "=2.0.0-beta.1"
lsp-types = { version = "0.97.0", features = ["proposed"] }
ignore = "0.4"
# `url` does the file-path ↔ `file://` URI conversion for the LSP client
# (percent-encoding, Windows drive letters); already in the tree via gpui.
url = "2"
# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, behind
# one blocking `Read`/`Write`/`resize` API. This is what lets `daemon::pane` share
# a single code path across platforms instead of hand-rolling fd/ioctl/signal code.
@@ -228,10 +240,16 @@ edition = "2024"
[workspace.dependencies]
# Our fork's `tty7` branch carries the local customizations tty7 relies on:
# `PopupMenu::with_size` plus the 1px hairline menu separator. The exact commit
# is still pinned by Cargo.lock. For co-developing the UI crate, point these
# back at a sibling checkout: `path = "../gpui-component/crates/{ui,assets}"`.
gpui-component = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.2" }
# `PopupMenu::with_size`, the 1px hairline menu separator, and the custom-button
# label color the chrome tiles need. The exact commit is still pinned by
# Cargo.lock. For co-developing the UI crate, point these back at a sibling
# checkout: `path = "../gpui-component/crates/{ui,assets}"` — but never commit
# that, since a path dependency can't resolve on CI.
#
# `tree-sitter-languages` compiles the grammars the code editor highlights with.
gpui-component = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.2", features = [
"tree-sitter-languages",
] }
gpui-component-assets = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.1" }
gpui = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe" }
+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="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.5"/><path d="M12 11.25v5"/><circle cx="12" cy="7.6" r="0.95" fill="currentColor" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 313 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="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>

After

Width:  |  Height:  |  Size: 418 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="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="4"/><rect x="5.6" y="5.6" width="5.2" height="12.8" rx="1.8" fill="currentColor" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 329 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="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="4"/><rect x="13.2" y="5.6" width="5.2" height="12.8" rx="1.8" fill="currentColor" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 330 B

+25
View File
@@ -55,10 +55,35 @@ actions!(
// Switch the tab bar between the horizontal title-bar strip and the
// vertical left-side sidebar (persists `tab_bar_position`).
ToggleTabSidebar,
// Collapse/expand the left tab sidebar in place (persists
// `sidebar_collapsed`). Unlike `ToggleTabSidebar` this does not switch
// the tab bar to the horizontal strip — the rail just goes away and
// comes back at the same width.
ToggleLeftPanel,
// Show/hide the right detail panel — session info, working-tree changes,
// and the file tree (persists `right_panel_visible`).
ToggleRightPanel,
// Jump straight to one of the right panel's tabs, opening the panel if
// it was closed. Unit actions rather than one parameterized action so
// config/Settings can bind them by name; unbound by default, since the
// panel's own tab row is the primary way in.
ShowRightPanelInfo,
ShowRightPanelOutline,
ShowRightPanelChanges,
ShowRightPanelFiles,
OpenSettings,
RestartDaemon,
// Toggle the SFTP file panel for the focused native-SSH pane (WS5).
ToggleSftp,
// Toggle the code panel: a full-body overlay of [file tree | editor]
// covering the terminal (settings-overlay style).
ToggleCodePanel,
// Save the editor panel's active file (⌘S).
EditorSave,
// LSP jump to the definition of the symbol at the editor cursor (F12).
EditorGotoDefinition,
// LSP list references to the symbol at the editor cursor (⇧F12).
EditorFindReferences,
// Open the SSH profile manager/editor full-window page (WS6, FR-P1).
OpenSshProfiles,
// Reconnect a dead native-SSH pane in place (WS6, FR-E4).
+48
View File
@@ -117,6 +117,25 @@ pub struct Config {
/// the live layout re-clamps it to `[180, window_width/2]`.
#[serde(default = "default_sidebar_width")]
pub sidebar_width: f32,
/// Whether the vertical tab sidebar is collapsed out of the layout (only
/// meaningful when `tab_bar_position` is `left`). Distinct from
/// `tab_bar_position`: collapsing hides the rail *without* falling back to
/// the horizontal title-bar strip, so the terminal gets the full width and
/// re-expanding restores the same rail. Toggled by `ToggleLeftPanel`.
#[serde(default)]
pub sidebar_collapsed: bool,
/// Whether the right detail panel (session info / changes / files) is
/// docked open. Toggled by `ToggleRightPanel`.
#[serde(default)]
pub right_panel_visible: bool,
/// Width (px) of the right detail panel. Re-clamped by the live layout the
/// same way `sidebar_width` is.
#[serde(default = "default_right_panel_width")]
pub right_panel_width: f32,
/// Which tab the right detail panel last had selected, so reopening it lands
/// where it was left.
#[serde(default, deserialize_with = "de_lenient")]
pub right_panel_tab: RightPanelTab,
/// How the vertical tab sidebar arranges its rows (only meaningful when
/// `tab_bar_position` is `left`): grouped under a header per git work tree
/// (`repo`, the default), or one flat list (`none`).
@@ -499,6 +518,10 @@ impl Default for Config {
// `left` opts into the vertical sidebar.
tab_bar_position: TabBarPosition::Top,
sidebar_width: default_sidebar_width(),
sidebar_collapsed: false,
right_panel_visible: false,
right_panel_width: default_right_panel_width(),
right_panel_tab: RightPanelTab::Info,
sidebar_grouping: SidebarGrouping::Repo,
notify_on_command_finish: NotifyMode::Unfocused,
// Opt-out, not opt-in: a stale terminal that never tells you it's
@@ -606,6 +629,10 @@ impl Config {
self.sidebar_width = default_sidebar_width();
}
self.sidebar_width = self.sidebar_width.clamp(100.0, 2000.0);
if !self.right_panel_width.is_finite() || self.right_panel_width <= 0.0 {
self.right_panel_width = default_right_panel_width();
}
self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0);
// An empty or whitespace-only file-open command means "no override"; the
// settings text field yields `""` when cleared, so fold it back to `None`
// rather than trying to run an empty command.
@@ -820,6 +847,27 @@ fn default_prefix() -> String {
"ctrl-b".to_string()
}
/// Which tab the right detail panel shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RightPanelTab {
/// Session facts: cwd, shell, branch, agent.
#[default]
Info,
/// The pane's command history as a navigable outline (OSC 133 marks).
Outline,
/// The pane's working-tree diff.
Changes,
/// The file tree rooted at the pane's repository.
Files,
}
/// Serde default for [`Config::right_panel_width`]: wide enough for a file path
/// plus its `+N M` counts without the tree turning into an ellipsis parade.
fn default_right_panel_width() -> f32 {
260.
}
/// Serde default for [`Config::sidebar_width`]: a comfortable rail width that
/// clears the tab labels without eating too much of the terminal.
fn default_sidebar_width() -> f32 {
+1
View File
@@ -23,6 +23,7 @@
pub mod pane;
pub mod pidfile;
pub mod procinfo;
pub mod protocol;
pub(crate) mod remote;
pub mod server;
+14
View File
@@ -1120,6 +1120,20 @@ impl DaemonPane {
}
}
/// The pane's process tree and listening ports, for the GUI's details panel
/// (`QueryProcs`). A native-SSH pane has no local process tree at all — its
/// commands run on the far side — so it answers empty rather than reporting
/// the daemon's own descendants.
pub fn procs(&self) -> crate::daemon::protocol::PaneProcs {
let Some(pty) = self.pty() else {
return Default::default();
};
let Some(shell_pid) = pty.shell_pid else {
return Default::default();
};
crate::daemon::procinfo::snapshot(shell_pid, pty_foreground_pgid(&pty.master))
}
/// The local-PTY backend, or `None` for a native-SSH pane. PTY-only
/// operations (resize via master, signal groups, foreground proc queries)
/// short-circuit when this is `None`.
+413
View File
@@ -0,0 +1,413 @@
//! What a pane is *running*: the process tree under its shell, and the TCP ports
//! that tree is listening on. Feeds the GUI's details panel (`QueryProcs`).
//!
//! Everything here is best-effort and read-only. A pid can exit between the
//! table walk and the name lookup, `lsof` may be missing, `/proc` may be
//! unreadable — each of those degrades to a shorter list, never an error. The
//! panel showing one fewer row is a non-event; a details query that can fail is
//! a support burden.
//!
//! Called on demand from the details panel, not on a timer — see the doc on
//! [`ClientMsg::QueryProcs`](crate::daemon::protocol::ClientMsg::QueryProcs) for
//! why this is pull-based when `Cwd` and `Agent` are pushed.
use std::collections::HashMap;
use crate::daemon::protocol::{PaneProcs, PortEntry, ProcEntry};
/// Depth cap on the process walk. Deep trees are real (a shell running `make`
/// running a compiler driver running the compiler), but past a handful of hops
/// the rows stop being information and start being noise in a 260px column.
const MAX_DEPTH: u8 = 6;
/// Hard cap on rows, so a pane that spawned a thousand workers can't turn a
/// details query into a wire-format stress test.
const MAX_PROCS: usize = 64;
/// The process tree under `shell_pid` plus its listening ports. `fg_pgid` is the
/// PTY's foreground process group, used to mark the row the user is looking at;
/// pass `None` when it isn't known.
pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> PaneProcs {
let table = process_table();
let procs = walk(&table, shell_pid, fg_pgid);
let ports = listening_ports(&procs);
PaneProcs { procs, ports }
}
/// One row of the system process table, reduced to what the walk needs.
struct Row {
ppid: u32,
pgid: u32,
name: String,
}
/// Depth-first from the shell, so the caller can render in order and indent by
/// `depth` without rebuilding a hierarchy.
fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<ProcEntry> {
// Children by parent, so the descent is a lookup rather than a table scan
// per node. Sorted by pid: the process table's own order is unspecified, and
// a list that reshuffles between two refreshes reads as churn.
let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
for (pid, row) in table {
children.entry(row.ppid).or_default().push(*pid);
}
for kids in children.values_mut() {
kids.sort_unstable();
}
let mut out = Vec::new();
let mut stack = vec![(shell_pid, 0u8)];
while let Some((pid, depth)) = stack.pop() {
let Some(row) = table.get(&pid) else { continue };
if out.len() >= MAX_PROCS {
break;
}
out.push(ProcEntry {
pid,
name: row.name.clone(),
depth,
foreground: fg_pgid.is_some_and(|g| g as u32 == row.pgid),
});
if depth + 1 > MAX_DEPTH {
continue;
}
if let Some(kids) = children.get(&pid) {
// Pushed in reverse so the pop order stays ascending by pid.
for kid in kids.iter().rev() {
stack.push((*kid, depth + 1));
}
}
}
out
}
// ── Platform: the process table ─────────────────────────────────────────────
/// macOS: one `proc_listallpids` sweep, then `PROC_PIDTBSDINFO` per pid for
/// parent/group. Cheaper than shelling out to `ps`, and it can't be defeated by
/// a user's `ps` alias or a locale-dependent column layout.
#[cfg(target_os = "macos")]
fn process_table() -> HashMap<u32, Row> {
let mut table = HashMap::new();
// Ask for the count first, then read into a buffer sized from it (plus slack,
// since processes can appear between the two calls).
// SAFETY: the documented "how big a buffer do I need" form — null buffer,
// zero size — which only returns a byte count.
let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
if bytes <= 0 {
return table;
}
let cap = (bytes as usize / std::mem::size_of::<libc::c_int>()) + 64;
let mut pids = vec![0 as libc::c_int; cap];
// SAFETY: buffer and its true byte length; the call writes at most that many
// bytes and returns how many it wrote.
let written = unsafe {
libc::proc_listallpids(
pids.as_mut_ptr() as *mut libc::c_void,
(cap * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if written <= 0 {
return table;
}
let n = written as usize / std::mem::size_of::<libc::c_int>();
for &pid in pids.iter().take(n.min(cap)) {
if pid <= 0 {
continue;
}
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
// SAFETY: zeroed buffer of the expected type, real size passed; the
// result is read back only when the kernel filled exactly that many
// bytes (a short return means the pid died mid-walk).
let ret = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
&mut info as *mut _ as *mut libc::c_void,
size,
)
};
if ret != size {
continue;
}
// `pbi_comm` is the kernel's truncated name (16 bytes). Prefer the full
// executable basename, which is what the user typed.
let name = proc_name(pid).unwrap_or_else(|| cstr_field(&info.pbi_comm));
table.insert(
pid as u32,
Row {
ppid: info.pbi_ppid,
pgid: info.pbi_pgid,
name,
},
);
}
table
}
/// Read a fixed-size, NUL-padded C char array into a `String`.
#[cfg(target_os = "macos")]
fn cstr_field(buf: &[libc::c_char]) -> String {
let bytes: Vec<u8> = buf
.iter()
.take_while(|c| **c != 0)
.map(|c| *c as u8)
.collect();
String::from_utf8_lossy(&bytes).into_owned()
}
/// Linux: `/proc/<pid>/stat` carries ppid and pgid in fixed positions. The
/// comm field is parenthesized and may itself contain spaces and parens, so the
/// fields after it are located from the *last* `)`, not by splitting the line.
#[cfg(target_os = "linux")]
fn process_table() -> HashMap<u32, Row> {
let mut table = HashMap::new();
let Ok(dir) = std::fs::read_dir("/proc") else {
return table;
};
for entry in dir.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|s| s.parse::<u32>().ok())
else {
continue;
};
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
continue;
};
let Some(close) = stat.rfind(')') else {
continue;
};
let mut fields = stat[close + 1..].split_whitespace();
// After `)`: state, ppid, pgrp, …
let (Some(_state), Some(ppid), Some(pgid)) = (fields.next(), fields.next(), fields.next())
else {
continue;
};
let (Ok(ppid), Ok(pgid)) = (ppid.parse::<u32>(), pgid.parse::<u32>()) else {
continue;
};
let name = proc_name(pid as i32).unwrap_or_else(|| {
// Fall back to the parenthesized comm already in hand.
stat[..close]
.rfind('(')
.map_or_else(|| String::new(), |open| stat[open + 1..close].to_string())
});
table.insert(pid, Row { ppid, pgid, name });
}
table
}
/// Windows: reuse the existing toolhelp snapshot. It carries no process-group
/// concept, so nothing is ever marked foreground — matching how `foreground_title`
/// already treats the platform.
#[cfg(windows)]
fn process_table() -> HashMap<u32, Row> {
crate::daemon::winproc::snapshot()
.into_iter()
.map(|p| {
(
p.pid,
Row {
// `winproc::Proc` names the parent link `parent`.
ppid: p.parent,
pgid: 0,
name: p.name,
},
)
})
.collect()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
fn process_table() -> HashMap<u32, Row> {
HashMap::new()
}
/// Executable basename of `pid` (macOS).
#[cfg(target_os = "macos")]
fn proc_name(pid: i32) -> Option<String> {
let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
// SAFETY: valid, correctly-sized buffer; `proc_pidpath` writes at most
// `buf.len()` bytes and returns the count (<=0 on failure).
let ret =
unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) };
if ret <= 0 {
return None;
}
let path = std::str::from_utf8(&buf[..ret as usize]).ok()?;
Some(path.rsplit('/').next().unwrap_or(path).to_string())
}
/// Executable basename of `pid` via `/proc/<pid>/exe` (Linux). Unreadable for
/// processes we don't own, hence the caller's `comm` fallback.
#[cfg(target_os = "linux")]
fn proc_name(pid: i32) -> Option<String> {
let path = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?;
let name = path.file_name()?.to_str()?;
let name = name.strip_suffix(" (deleted)").unwrap_or(name);
(!name.is_empty()).then(|| name.to_string())
}
// ── Platform: listening ports ───────────────────────────────────────────────
/// TCP listeners owned by any pid in `procs`, via `lsof`.
///
/// Shelling out rather than reading the socket tables directly: on macOS the
/// only supported route is a private `libproc` fd walk, and on Linux matching
/// `/proc/net/tcp` inodes against every pid's fds costs more syscalls than the
/// subprocess. `lsof` ships with macOS; where it's missing this returns empty,
/// which just hides the row.
#[cfg(unix)]
fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
use std::process::{Command, Stdio};
if procs.is_empty() {
return Vec::new();
}
let pid_list = procs
.iter()
.map(|p| p.pid.to_string())
.collect::<Vec<_>>()
.join(",");
// `-Fpn`: machine-readable output, pid (`p…`) and name (`n…`) fields only,
// one per line. `-nP` skips DNS and /etc/services lookups — both can block.
let out = Command::new("lsof")
.args([
"-nP",
"-iTCP",
"-sTCP:LISTEN",
"-a",
"-p",
&pid_list,
"-Fpn",
])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output();
let Ok(out) = out else { return Vec::new() };
let text = String::from_utf8_lossy(&out.stdout);
let by_pid: HashMap<u32, &str> = procs.iter().map(|p| (p.pid, p.name.as_str())).collect();
let mut ports: Vec<PortEntry> = Vec::new();
let mut current = 0u32;
for line in text.lines() {
let Some((tag, rest)) = line.split_at_checked(1) else {
continue;
};
match tag {
"p" => current = rest.parse().unwrap_or(0),
"n" => {
let Some(port) = parse_listen_port(rest) else {
continue;
};
// One listener commonly binds both v4 and v6, or several
// addresses on the same port; the panel wants the port once.
if ports.iter().any(|e| e.port == port && e.pid == current) {
continue;
}
ports.push(PortEntry {
port,
pid: current,
name: by_pid
.get(&current)
.copied()
.unwrap_or_default()
.to_string(),
});
}
_ => {}
}
}
ports.sort_by_key(|e| (e.port, e.pid));
ports
}
#[cfg(not(unix))]
fn listening_ports(_procs: &[ProcEntry]) -> Vec<PortEntry> {
Vec::new()
}
/// The port out of an `lsof -Fn` name field: `*:3000`, `127.0.0.1:8080`,
/// `[::1]:5173`, sometimes with a trailing ` (LISTEN)` despite `-F`.
fn parse_listen_port(name: &str) -> Option<u16> {
let name = name.split_whitespace().next()?;
// Split on the *last* colon: an IPv6 literal is full of them.
let (_, port) = name.rsplit_once(':')?;
port.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn row(ppid: u32, name: &str) -> Row {
Row {
ppid,
pgid: 0,
name: name.to_string(),
}
}
#[test]
fn walk_is_depth_first_from_the_shell() {
let table: HashMap<u32, Row> = [
(100, row(1, "zsh")),
(200, row(100, "make")),
(300, row(200, "cc")),
(400, row(100, "vim")),
// A sibling process outside the shell's tree must not appear.
(500, row(1, "Finder")),
]
.into_iter()
.collect();
let got = walk(&table, 100, None);
let names: Vec<_> = got.iter().map(|p| (p.name.as_str(), p.depth)).collect();
assert_eq!(
names,
vec![("zsh", 0), ("make", 1), ("cc", 2), ("vim", 1)],
"depth-first, ascending pid, shell's tree only"
);
}
#[test]
fn walk_marks_the_foreground_process_group() {
let mut table: HashMap<u32, Row> = [(100, row(1, "zsh")), (200, row(100, "vim"))]
.into_iter()
.collect();
table.get_mut(&100).unwrap().pgid = 100;
table.get_mut(&200).unwrap().pgid = 200;
let got = walk(&table, 100, Some(200));
assert!(
!got[0].foreground,
"the shell is backgrounded while vim runs"
);
assert!(got[1].foreground, "vim's group owns the terminal");
}
#[test]
fn walk_survives_a_cycle_in_the_table() {
// Two processes claiming each other as parent — impossible on a live
// kernel, but the table is a non-atomic sweep of pids that can be reused
// mid-walk, so the descent must terminate regardless.
let table: HashMap<u32, Row> = [(100, row(200, "a")), (200, row(100, "b"))]
.into_iter()
.collect();
let got = walk(&table, 100, None);
assert!(got.len() <= MAX_PROCS, "bounded, not infinite");
}
#[test]
fn parses_lsof_listen_addresses() {
assert_eq!(parse_listen_port("*:3000"), Some(3000));
assert_eq!(parse_listen_port("127.0.0.1:8080"), Some(8080));
assert_eq!(parse_listen_port("[::1]:5173"), Some(5173));
assert_eq!(parse_listen_port("*:5432 (LISTEN)"), Some(5432));
assert_eq!(parse_listen_port("/tmp/some.sock"), None);
}
}
+58
View File
@@ -473,6 +473,40 @@ pub struct ManagedForward {
pub status: ForwardStatus,
}
/// One process running under a pane's shell, for the details panel's process
/// list. `depth` is hops from the shell (the shell itself is 0), which is all the
/// UI needs to indent the tree — sending the parent pid would make the client
/// rebuild a hierarchy the daemon already walked.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcEntry {
pub pid: u32,
pub name: String,
pub depth: u8,
/// Whether this process (or its group) currently owns the terminal — the one
/// the user is actually looking at.
#[serde(default)]
pub foreground: bool,
}
/// A TCP port a pane's process tree is listening on. The pane that started a dev
/// server is exactly the context in which "which port is this on?" gets asked, so
/// the answer belongs next to the process list rather than in a global inspector.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortEntry {
pub port: u16,
pub pid: u32,
pub name: String,
}
/// Reply to `QueryProcs`: what a pane is running, and what it's listening on.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneProcs {
/// Depth-first from the shell, so rendering in order gives a readable tree.
pub procs: Vec<ProcEntry>,
/// Ascending by port; deduped, since one listener can bind several addresses.
pub ports: Vec<PortEntry>,
}
fn default_term() -> String {
"xterm-256color".to_string()
}
@@ -815,6 +849,15 @@ pub enum ClientMsg {
/// Ask for the managed forwards attributed to `pane_id`. Control-connection
/// message; the daemon replies with a `ForwardList`.
ListForwards { pane_id: u64 },
/// One-shot query for a pane's process tree and listening ports, over a
/// short-lived control connection; the daemon replies with `PaneProcs`.
///
/// Deliberately pull-based, unlike `Cwd`/`Agent` which the daemon pushes:
/// walking the process table and probing sockets costs far more than sniffing
/// an OSC sequence, and the answer is only ever looked at while the details
/// panel's Info tab is open. Pushing it on a timer would burn that cost for
/// every pane, forever, to feed a view that's usually closed.
QueryProcs { pane_id: u64 },
/// Ask which protocol version the daemon speaks (control connection); the
/// daemon replies `Version`. A daemon that predates versioning doesn't know
/// this kind and drops the connection instead of replying — the client
@@ -895,6 +938,8 @@ pub enum DaemonMsg {
/// Reply to `AddForward` / `RemoveForward` / `ListForwards`: the managed
/// forwards currently attributed to the requested pane (WS4).
ForwardList(Vec<ManagedForward>),
/// Reply to `QueryProcs`.
Procs(PaneProcs),
/// Reply to `Version`.
Version(DaemonVersion),
/// A request failed (e.g. `Attach` to an unknown/dead pane id).
@@ -950,6 +995,9 @@ mod kind {
/// `Version` — protocol-version handshake. 40 sits clear of every reserved
/// range above (WS3 1619, WS4 2024, SFTP 3036).
pub const VERSION: u8 = 40;
/// `QueryProcs` — a pane's process tree + listening ports, for the details
/// panel. 50 sits clear of every range above and of `VERSION`.
pub const QUERY_PROCS: u8 = 50;
// Daemon -> client
pub const SPAWNED: u8 = 1;
@@ -985,6 +1033,8 @@ mod kind {
/// `Version` — reply to the client-space `VERSION` request (same value by
/// design; the spaces are independent).
pub const VERSION_REPLY: u8 = 40;
/// `Procs` — reply to the client-space `QUERY_PROCS` request.
pub const PROCS: u8 = 50;
}
/// Write one framed message: `[u32 LE len][u8 kind][payload]`.
@@ -1126,6 +1176,9 @@ impl ClientMsg {
pane_id,
forward_id,
} => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?),
ClientMsg::QueryProcs { pane_id } => {
write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?)
}
ClientMsg::ListForwards { pane_id } => {
write_frame(w, kind::LIST_FORWARDS, &to_json(pane_id)?)
}
@@ -1191,6 +1244,9 @@ impl ClientMsg {
kind::SFTP_TRANSFER_LIST => ClientMsg::SftpTransferList {
pane_id: from_json(&payload)?,
},
kind::QUERY_PROCS => ClientMsg::QueryProcs {
pane_id: from_json(&payload)?,
},
kind::ADD_FORWARD => {
let (pane_id, rule) = from_json(&payload)?;
ClientMsg::AddForward { pane_id, rule }
@@ -1269,6 +1325,7 @@ impl DaemonMsg {
write_frame(w, kind::SFTP_TRANSFER_PROGRESS, &to_json(jobs)?)
}
DaemonMsg::ForwardList(list) => write_frame(w, kind::FORWARD_LIST, &to_json(list)?),
DaemonMsg::Procs(procs) => write_frame(w, kind::PROCS, &to_json(procs)?),
DaemonMsg::Version(version) => write_frame(w, kind::VERSION_REPLY, &to_json(version)?),
DaemonMsg::Error(msg) => write_frame(w, kind::ERROR, &to_json(msg)?),
}
@@ -1316,6 +1373,7 @@ impl DaemonMsg {
},
kind::SFTP_TRANSFER_PROGRESS => DaemonMsg::SftpTransferProgress(from_json(&payload)?),
kind::FORWARD_LIST => DaemonMsg::ForwardList(from_json(&payload)?),
kind::PROCS => DaemonMsg::Procs(from_json(&payload)?),
kind::VERSION_REPLY => DaemonMsg::Version(from_json(&payload)?),
kind::ERROR => DaemonMsg::Error(from_json(&payload)?),
other => {
+10
View File
@@ -509,6 +509,16 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
Ok(())
}
ClientMsg::QueryProcs { pane_id } => {
let mut w = write_stream;
// An unknown/dead pane answers empty rather than `Error`: the details
// panel polls while the user watches, and a pane closing mid-flight is
// ordinary, not a failure worth surfacing.
let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
DaemonMsg::Procs(procs).encode(&mut w)?;
Ok(())
}
ClientMsg::ListForwards { pane_id } => {
let mut w = write_stream;
let list = crate::daemon::ssh::SshManager::global().list_forwards(pane_id);
+419
View File
@@ -0,0 +1,419 @@
//! Command marks: where each shell prompt started in the scrollback, so the
//! details panel's Outline can list a pane's commands and scroll back to one.
//!
//! Fed by the reader thread from OSC 133 (`A` prompt start, `C` command start,
//! `D` command done — the same shell-integration marks the daemon sniffs for
//! prompt state). The daemon reports only *whether* the shell is at its prompt;
//! positions have to come from the client, because only the client holds the
//! grid those positions are relative to.
//!
//! # Why a mark stores its text
//!
//! A grid row has no stable identity. Alacritty's `Line` is relative to the
//! viewport, so anything recorded in those coordinates slides as output arrives.
//! Converting to an absolute index from the top of history (`history_size -
//! display_offset + line`) is stable — *until the scrollback fills*. After that
//! alacritty discards the oldest row per new row, every surviving row's absolute
//! index silently decreases, and the amount discarded is not observable from
//! outside the emulator: `history_size` is pinned at the limit, and nothing else
//! exposes the scroll count. (Counting it exactly would mean wrapping
//! `vte::ansi::Handler` to intercept every line-producing sequence — 71 methods,
//! all with no-op defaults, so a future `vte` upgrade that adds one would
//! silently break rendering. Not worth it for this.)
//!
//! So the absolute index is treated as a *hint* and the row's text as the
//! *truth*: each mark records what its row said when it was made, and a reader
//! re-reads the row before trusting the position. A mark whose row no longer
//! matches has drifted out from under us and is reported stale rather than
//! silently scrolling somewhere wrong. Below the scrollback limit — which is
//! where a pane spends most of its life — the hint is exact and the check always
//! passes.
use std::sync::{Arc, Mutex};
/// Cap on retained marks. Deep scrollback holds far more prompts than a panel
/// list is useful at, and the oldest are the likeliest to have drifted anyway.
const MAX_MARKS: usize = 500;
/// One shell prompt, and the command run from it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommandMark {
/// Row index from the top of the scrollback at record time — the position
/// hint. See the module docs for when it stops being exact.
pub row: i64,
/// What the row said when the mark was made, used to detect drift. Empty
/// while the prompt has been printed but nothing has been typed yet.
pub text: String,
/// Exit code from `OSC 133;D`, once the command finishes.
pub exit: Option<i32>,
/// Whether the command has finished (a `D` mark arrived). Distinct from
/// `exit.is_some()`: a `D` without a code still means "done".
pub done: bool,
}
/// A pane's marks, shared between the reader thread (writer) and the UI (reader).
#[derive(Clone, Default)]
pub struct Marks(Arc<Mutex<Vec<CommandMark>>>);
impl Marks {
pub fn new() -> Self {
Self::default()
}
/// Begin a mark at `row` (`OSC 133;A` — the shell is about to print a
/// prompt). `text` is the row's current content, which is normally empty at
/// this point and gets filled in by [`set_text`](Self::set_text) once the
/// command has been typed.
pub fn begin(&self, row: i64, text: String) {
let Ok(mut marks) = self.0.lock() else { return };
// A prompt redraw (a resize, a `clear`, zle repainting the line) re-emits
// `A` on the same row. Update in place rather than stacking duplicates.
if marks.last().is_some_and(|m| m.row == row && !m.done) {
if let Some(last) = marks.last_mut() {
last.text = text;
}
return;
}
marks.push(CommandMark {
row,
text,
exit: None,
done: false,
});
// Trim from the front: oldest marks age out of the scrollback first.
let overflow = marks.len().saturating_sub(MAX_MARKS);
if overflow > 0 {
marks.drain(..overflow);
}
}
/// Attach the command line to the open mark (`OSC 133;C` — the user hit
/// enter, so the prompt row now holds the command). Ignored when no mark is
/// open, which is what a `C` without a preceding `A` means.
pub fn set_text(&self, text: String) {
let Ok(mut marks) = self.0.lock() else { return };
if let Some(last) = marks.last_mut() {
if !last.done {
last.text = text;
}
}
}
/// Close the open mark (`OSC 133;D[;exit]`).
pub fn finish(&self, exit: Option<i32>) {
let Ok(mut marks) = self.0.lock() else { return };
if let Some(last) = marks.last_mut() {
last.done = true;
last.exit = exit;
}
}
/// Snapshot for rendering, newest last. Marks that never got a command are
/// dropped: a bare prompt the user typed nothing at is not an outline entry.
pub fn list(&self) -> Vec<CommandMark> {
let Ok(marks) = self.0.lock() else {
return Vec::new();
};
marks
.iter()
.filter(|m| !m.text.trim().is_empty())
.cloned()
.collect()
}
/// Drop everything (the pane was cleared, so every position is meaningless).
pub fn clear(&self) {
if let Ok(mut marks) = self.0.lock() {
marks.clear();
}
}
}
/// Parse the exit code out of an `OSC 133;D` payload: `D`, `D;0`, `D;1`, and
/// zsh's `D;aborted` all occur. Anything unparseable is "done, code unknown".
pub fn parse_done_exit(payload: &[u8]) -> Option<i32> {
let rest = payload.strip_prefix(b"D")?;
let rest = rest.strip_prefix(b";")?;
std::str::from_utf8(rest).ok()?.trim().parse().ok()
}
/// What a recognized `OSC 133` mark means for the outline.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MarkEvent {
/// `A` — the shell is about to print a prompt.
Prompt,
/// `C;<cmd>` — the command was submitted and its output starts here. tty7's
/// own shell integration always includes the command line, so the outline
/// never has to guess it back out of the grid (where it would be tangled up
/// with the user's prompt string).
Command(String),
/// `D[;exit]` — the command finished.
Done(Option<i32>),
}
/// Finds `OSC 133` marks in the output stream and reports *where* each one lands
/// — the byte offset just past the sequence — so the caller can advance the
/// emulator up to exactly that point and read the grid position there.
///
/// Separate from [`OscTokenizer`](crate::core::osc::OscTokenizer), which reports
/// payloads but not offsets. Carries its state across feeds, so a mark split over
/// two socket reads is still recognized (and attributed to the batch its
/// terminator lands in, which is the correct row either way).
#[derive(Default)]
pub struct MarkScanner {
state: ScanState,
/// Payload bytes collected so far, possibly spanning feeds. Bounded: a
/// "payload" that runs past any plausible command line is a desync, not a
/// mark, so it's abandoned rather than grown without limit.
payload: Vec<u8>,
}
#[derive(Default, PartialEq, Eq)]
enum ScanState {
/// Ordinary output.
#[default]
Text,
/// Saw `ESC`, waiting to see whether `]` follows.
Esc,
/// Inside an OSC payload, collecting until BEL or ST.
Osc,
/// Saw `ESC` inside an OSC payload — an ST (`ESC \`) if `\` follows.
OscEsc,
}
/// Ceiling on a collected OSC payload. Long enough for any real command line,
/// short enough that a stream that never terminates its OSC can't grow a buffer
/// unboundedly.
const MAX_PAYLOAD: usize = 64 * 1024;
impl MarkScanner {
pub fn new() -> Self {
Self::default()
}
/// Feed one batch. `on_mark(offset, event)` fires for each recognized mark,
/// where `offset` is an index into `bytes` just past the mark's terminator.
pub fn feed(&mut self, bytes: &[u8], mut on_mark: impl FnMut(usize, MarkEvent)) {
for (i, &b) in bytes.iter().enumerate() {
match self.state {
ScanState::Text => {
if b == 0x1b {
self.state = ScanState::Esc;
}
}
ScanState::Esc => {
if b == b']' {
self.state = ScanState::Osc;
self.payload.clear();
} else {
// Some other escape sequence; `ESC ESC` restarts.
self.state = if b == 0x1b {
ScanState::Esc
} else {
ScanState::Text
};
}
}
ScanState::Osc => match b {
0x07 => {
if let Some(ev) = self.take() {
on_mark(i + 1, ev);
}
self.state = ScanState::Text;
}
0x1b => self.state = ScanState::OscEsc,
_ => {
if self.payload.len() < MAX_PAYLOAD {
self.payload.push(b);
} else {
// Runaway payload: give up on this sequence rather
// than buffer the rest of the stream into it.
self.state = ScanState::Text;
self.payload.clear();
}
}
},
ScanState::OscEsc => {
if b == b'\\' {
if let Some(ev) = self.take() {
on_mark(i + 1, ev);
}
self.state = ScanState::Text;
} else {
// Not an ST after all — the ESC was payload. Bounded like
// the ordinary payload byte below it: a stream of bare
// ESCs inside an unterminated OSC would otherwise grow the
// buffer a byte at a time, never reaching the check there.
if self.payload.len() < MAX_PAYLOAD {
self.payload.push(0x1b);
self.state = ScanState::Osc;
} else {
self.state = ScanState::Text;
self.payload.clear();
}
}
}
}
}
}
/// Interpret the collected payload, clearing it either way.
fn take(&mut self) -> Option<MarkEvent> {
let payload = std::mem::take(&mut self.payload);
let body = payload.strip_prefix(b"133;")?;
match body.first()? {
b'A' => Some(MarkEvent::Prompt),
b'C' => {
// `C` alone (no command) still marks output start; the shells
// that can't report the line send it bare.
let cmd = body
.strip_prefix(b"C;")
.map(|c| String::from_utf8_lossy(c).into_owned())
.unwrap_or_default();
Some(MarkEvent::Command(cmd))
}
b'D' => Some(MarkEvent::Done(parse_done_exit(body))),
// `B` (prompt end) and `V` (tty7's edit-mode extension) carry no
// position the outline cares about.
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_prompt_with_no_command_is_not_an_entry() {
let marks = Marks::new();
marks.begin(10, String::new());
assert!(
marks.list().is_empty(),
"an empty prompt the user walked away from isn't a command"
);
marks.set_text("cargo build".into());
assert_eq!(marks.list().len(), 1);
}
#[test]
fn a_prompt_redraw_updates_in_place() {
let marks = Marks::new();
marks.begin(10, String::new());
marks.set_text("cargo t".into());
// zle repaints the prompt on the same row (a resize, a completion menu
// closing) and the shell re-emits `A`.
marks.begin(10, "cargo test".into());
let got = marks.list();
assert_eq!(got.len(), 1, "a redraw is the same prompt, not a new one");
assert_eq!(got[0].text, "cargo test");
}
#[test]
fn a_new_prompt_after_a_finished_command_is_a_new_entry() {
let marks = Marks::new();
marks.begin(10, String::new());
marks.set_text("ls".into());
marks.finish(Some(0));
// Same row is possible after a `clear`.
marks.begin(10, String::new());
marks.set_text("pwd".into());
let got = marks.list();
assert_eq!(got.len(), 2);
assert_eq!(got[0].exit, Some(0));
assert!(!got[1].done);
}
#[test]
fn marks_are_capped_from_the_front() {
let marks = Marks::new();
for i in 0..(MAX_MARKS + 10) {
marks.begin(i as i64, format!("cmd{i}"));
marks.finish(Some(0));
}
let got = marks.list();
assert_eq!(got.len(), MAX_MARKS);
assert_eq!(got[0].text, "cmd10", "the oldest aged out, not the newest");
}
/// Collect `(offset, event)` pairs from feeding `chunks` in order, so a test
/// can assert on a stream split at arbitrary boundaries.
fn scan(chunks: &[&[u8]]) -> Vec<(usize, MarkEvent)> {
let mut scanner = MarkScanner::new();
let mut out = Vec::new();
for chunk in chunks {
scanner.feed(chunk, |off, ev| out.push((off, ev)));
}
out
}
#[test]
fn reports_marks_just_past_their_terminator() {
let got = scan(&[b"ab\x1b]133;A\x07cd"]);
assert_eq!(got, vec![(10, MarkEvent::Prompt)]);
// The offset must point past the BEL, so advancing `bytes[..offset]`
// consumes the whole sequence and nothing of what follows.
assert_eq!(&b"ab\x1b]133;A\x07cd"[10..], b"cd");
}
#[test]
fn carries_a_mark_split_across_two_feeds() {
let got = scan(&[b"out\x1b]13", b"3;C;cargo build\x07more"]);
assert_eq!(
got,
vec![(16, MarkEvent::Command("cargo build".into()))],
"the mark is attributed to the batch its terminator lands in"
);
}
#[test]
fn accepts_st_terminated_marks() {
// `ESC \` instead of BEL — both are legal OSC terminators and the
// integrations use ST on some shells.
let got = scan(&[b"\x1b]133;D;130\x1b\\"]);
assert_eq!(got, vec![(13, MarkEvent::Done(Some(130)))]);
}
#[test]
fn ignores_other_osc_sequences() {
let got = scan(&[b"\x1b]0;a title\x07\x1b]7;file://h/x\x07\x1b]133;B\x07"]);
assert!(
got.is_empty(),
"titles, cwd reports and prompt-end carry nothing the outline wants"
);
}
#[test]
fn a_command_containing_semicolons_survives_intact() {
let got = scan(&[b"\x1b]133;C;for i in a b; do echo $i; done\x07"]);
assert_eq!(
got,
vec![(
39,
MarkEvent::Command("for i in a b; do echo $i; done".into())
)],
"only the first two fields are structure; the rest is the command"
);
}
#[test]
fn an_unterminated_payload_cannot_grow_without_bound() {
let mut scanner = MarkScanner::new();
let mut fired = 0;
scanner.feed(b"\x1b]133;C;", |_, _| fired += 1);
for _ in 0..40 {
scanner.feed(&vec![b'x'; 4096], |_, _| fired += 1);
}
assert_eq!(fired, 0, "never terminated, so never reported");
assert!(scanner.payload.len() <= MAX_PAYLOAD);
}
#[test]
fn parses_done_payloads() {
assert_eq!(parse_done_exit(b"D;0"), Some(0));
assert_eq!(parse_done_exit(b"D;130"), Some(130));
assert_eq!(parse_done_exit(b"D"), None, "done, code unknown");
assert_eq!(parse_done_exit(b"D;aborted"), None);
assert_eq!(parse_done_exit(b"C"), None, "not a done mark at all");
}
}
+1
View File
@@ -28,6 +28,7 @@ mod history;
mod hold;
pub mod input;
mod loopback;
pub(crate) mod marks;
pub mod palette;
mod remote;
mod reverse_search;
+128 -3
View File
@@ -33,6 +33,8 @@ use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle};
use crate::terminal::marks::{MarkEvent, MarkScanner};
use std::collections::VecDeque;
use crate::core::cli_agent::{AgentSessionState, CLIAgent};
@@ -41,8 +43,8 @@ use crate::core::osc::OscTokenizer;
use crate::daemon::protocol::{
AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId,
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest,
ManagedForward, NativeSshSpec, RemoteContext, SftpEntry, SftpJobProgress, SftpOp, SftpOpResult,
SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize,
ManagedForward, NativeSshSpec, PaneProcs, RemoteContext, SftpEntry, SftpJobProgress, SftpOp,
SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize,
};
use crate::daemon::transport::{self, Stream};
@@ -130,6 +132,8 @@ struct ReaderSignals {
/// Latest native-SSH spawn phase from `DaemonMsg::SshStatus`, for the status
/// line. `None` until the first status frame (a plain shell pane never sets it).
phase: Arc<Mutex<Option<SshPhase>>>,
/// Command marks (OSC 133 prompt positions) for the details panel's Outline.
marks: crate::terminal::marks::Marks,
}
/// A terminal whose PTY lives in the daemon. Mirrors `backend::Terminal`'s public
@@ -208,6 +212,10 @@ pub struct RemoteTerminal {
/// session id), last reported by the daemon via `AgentStatus`. Drives the
/// status dot, "needs your input" notifications, and session resume.
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
/// Command marks recorded by the reader thread from OSC 133, for the details
/// panel's Outline. Positions are grid rows, so they can only be taken here
/// on the client — the daemon has no grid.
marks: crate::terminal::marks::Marks,
reader_thread: Option<JoinHandle<()>>,
}
@@ -333,6 +341,7 @@ impl RemoteTerminal {
let auth_prompts: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>> =
Arc::new(Mutex::new(VecDeque::new()));
let ssh_phase: Arc<Mutex<Option<SshPhase>>> = Arc::new(Mutex::new(None));
let marks = crate::terminal::marks::Marks::new();
let reader_thread = Self::spawn_reader(
term.clone(),
@@ -350,6 +359,7 @@ impl RemoteTerminal {
shell_vi_mode: shell_vi_mode.clone(),
auth: auth_prompts.clone(),
phase: ssh_phase.clone(),
marks: marks.clone(),
},
);
@@ -374,6 +384,7 @@ impl RemoteTerminal {
auto_supplied_password: false,
agent,
agent_session,
marks,
reader_thread: Some(reader_thread),
})
}
@@ -416,6 +427,7 @@ impl RemoteTerminal {
shell_vi_mode,
auth,
phase,
marks,
} = signals;
// The client end of the visible-output path: keep it off the
// efficiency cores (see `core::threads`).
@@ -441,6 +453,10 @@ impl RemoteTerminal {
// zle is reading (see the `zle_reading` field docs). Historical
// Snapshot replays deliberately do not feed this tokenizer.
let mut zle_tok = OscTokenizer::new(&[b"133"]);
// Positional OSC 133 marks for the details panel's Outline. Unlike
// the tokenizers above this one reports byte *offsets*, because a
// mark's value is the grid row it lands on — see `terminal::marks`.
let mut mark_scan = MarkScanner::new();
// Bytes read but not yet framed, plus the recorded geometry
// waiting for its paired Snapshot: the attach replay is a
// `Size` → `Snapshot` pair per ring segment, and each pair
@@ -498,11 +514,29 @@ impl RemoteTerminal {
macro_rules! flush_batch {
() => {
if !out_batch.is_empty() {
// Where the batch's OSC 133 marks land, so the
// advance can stop at each one and read the grid
// row it fell on. Scanned before the lock (it's a
// pure byte pass) and normally empty — a batch
// with no marks takes the single-advance path
// below, exactly as before.
let mut cuts: Vec<(usize, MarkEvent)> = Vec::new();
mark_scan.feed(&out_batch, |off, ev| cuts.push((off, ev)));
{
let t0 = trace.then(std::time::Instant::now);
let mut term = term.lock();
let t1 = trace.then(std::time::Instant::now);
processor.advance(&mut *term, &out_batch);
if cuts.is_empty() {
processor.advance(&mut *term, &out_batch);
} else {
let mut at = 0usize;
for (off, ev) in cuts {
processor.advance(&mut *term, &out_batch[at..off]);
at = off;
record_mark(&term, &marks, ev);
}
processor.advance(&mut *term, &out_batch[at..]);
}
if let (Some(t0), Some(t1)) = (t0, t1) {
tr_lock_t += t1 - t0;
tr_adv_t += t1.elapsed();
@@ -952,6 +986,12 @@ impl RemoteTerminal {
self.agent.lock().ok().and_then(|g| *g)
}
/// Command marks recorded from OSC 133, oldest first — the Outline's source.
/// Cheap clone of a shared handle; the caller snapshots via `Marks::list`.
pub fn marks(&self) -> crate::terminal::marks::Marks {
self.marks.clone()
}
/// The rich agent-session status (idle/working/waiting/done + native
/// session id), as last reported by the daemon, or `None` when no agent
/// session is live. Cheap cache read — sniffing runs daemon-side.
@@ -1352,6 +1392,42 @@ impl RemoteTerminal {
}
query(pane_id).unwrap_or_default()
}
/// A pane's process tree and listening ports, for the details panel. One-shot
/// over a short-lived control connection, like the forward queries — this is
/// polled only while the panel is open, so it never rides the pane's hot
/// output connection.
pub fn query_procs(pane_id: u64) -> PaneProcs {
fn query(pane_id: u64) -> anyhow::Result<PaneProcs> {
let mut stream = connect()?;
ClientMsg::QueryProcs { pane_id }.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::Procs(procs) => Ok(procs),
other => Err(anyhow::anyhow!("unexpected reply to QueryProcs: {other:?}")),
}
}
query(pane_id).unwrap_or_default()
}
}
/// Apply one OSC 133 mark at the emulator's current position.
///
/// Called with the terminal lock held and the parser advanced to exactly the
/// mark's byte, so `cursor.point.line` is the row the mark fell on. That row is
/// converted to an index from the top of the scrollback, which is stable as long
/// as history hasn't saturated — see the `terminal::marks` module docs.
fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, event: MarkEvent) {
use alacritty_terminal::grid::Dimensions as _;
match event {
MarkEvent::Prompt => {
let grid = term.grid();
let row = grid.history_size() as i64 - grid.display_offset() as i64
+ i64::from(grid.cursor.point.line.0);
marks.begin(row, String::new());
}
MarkEvent::Command(cmd) => marks.set_text(cmd),
MarkEvent::Done(exit) => marks.finish(exit),
}
}
fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool {
@@ -2379,6 +2455,55 @@ mod tests {
assert!(poll(&|s| s.is_none()), "a None report clears the session");
}
/// End-to-end check of the Outline's data path: OSC 133 marks arriving in the
/// output stream must land in `Marks` with the *grid row they fell on*, not
/// the row at the end of the batch. This is the whole reason the reader
/// splits its advance at mark offsets, so it's worth an integration test —
/// a regression here looks fine (marks appear) but scrolls to the wrong place.
#[test]
fn marks_record_the_row_each_one_landed_on() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let poll = |want: usize| {
for _ in 0..200 {
if term.marks().list().len() == want {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
// Two full prompt cycles in ONE batch, separated by output lines. If the
// reader advanced the batch in a single pass and read the cursor after,
// both marks would report the same (final) row.
let mut stream = Vec::new();
stream.extend_from_slice(b"\x1b]133;A\x07"); // prompt 1 at row 0
stream.extend_from_slice(b"\x1b]133;C;echo one\x07");
stream.extend_from_slice(b"one\r\n");
stream.extend_from_slice(b"\x1b]133;D;0\x07");
stream.extend_from_slice(b"\x1b]133;A\x07"); // prompt 2, two rows down
stream.extend_from_slice(b"\x1b]133;C;false\x07");
stream.extend_from_slice(b"\r\n");
stream.extend_from_slice(b"\x1b]133;D;1\x07");
DaemonMsg::Output(stream).encode(&mut daemon_side).unwrap();
daemon_side.flush().unwrap();
assert!(poll(2), "both commands recorded");
let marks = term.marks().list();
assert_eq!(marks[0].text, "echo one");
assert_eq!(marks[0].exit, Some(0));
assert_eq!(marks[1].text, "false");
assert_eq!(marks[1].exit, Some(1), "a failure keeps its exit code");
assert!(
marks[1].row > marks[0].row,
"the second prompt is further down the scrollback ({} vs {}) — equal rows \
would mean the advance wasn't split at the marks",
marks[0].row,
marks[1].row
);
}
/// The typeahead wipe (^U) may only be written once zle actually reads the
/// keyboard; the client learns that from a *live* `133;B` (prompt end) in
/// the output stream. `133;D` (command done, but precmd hooks still running
+38
View File
@@ -2490,6 +2490,10 @@ impl TerminalView {
pub fn clear_scrollback(&mut self, cx: &mut Context<Self>) {
self.terminal.term.lock().grid_mut().clear_history();
self.scroll_frac = 0.;
// Every mark's row indexed into the history that just went away, so the
// Outline's positions are now meaningless. Drop them rather than leave
// rows that scroll somewhere arbitrary.
self.terminal.marks().clear();
self.terminal.write(vec![0x0c_u8]); // Ctrl+L
cx.notify();
}
@@ -4094,6 +4098,40 @@ impl TerminalView {
self.smooth_scroll(delta, cx);
}
/// The pane's OSC 133 command marks, newest last — the Outline's rows.
pub fn command_marks(&self) -> Vec<crate::terminal::marks::CommandMark> {
self.terminal.marks().list()
}
/// Scroll so the command recorded at `row` sits near the top of the viewport.
/// Returns `false` when the mark has aged out of the scrollback, so the
/// caller can say so rather than leaving the user staring at an unchanged
/// screen wondering whether the click registered.
///
/// `row` is an index from the top of history, which drifts once the
/// scrollback saturates (see the `terminal::marks` docs). A drifted mark
/// still scrolls *somewhere* — it just may not be the exact prompt — so the
/// only failure reported here is a row that has fallen off entirely.
pub fn scroll_to_mark(&mut self, row: i64, cx: &mut Context<Self>) -> bool {
use alacritty_terminal::grid::Dimensions as _;
let mut term = self.terminal.term.lock();
let history = term.grid().history_size() as i64;
if row < 0 || row > history + term.grid().screen_lines() as i64 {
return false;
}
// `display_offset` counts *up* from the bottom of history, so the offset
// that puts `row` at the viewport's top line is its distance from there.
let target = (history - row).max(0);
let current = term.grid().display_offset() as i64;
term.scroll_display(Scroll::Delta((target - current) as i32));
drop(term);
// A jump lands wherever it lands; the fractional offset is a smooth-scroll
// artifact and would otherwise shift the paint off the line boundary.
self.scroll_frac = 0.;
cx.notify();
true
}
/// Scroll the local scrollback by a possibly-fractional number of lines,
/// pixel-smooth: whole lines go to the emulator's `display_offset`, the
/// remainder stays in `scroll_frac` and shifts the paint. The position may
+286 -15
View File
@@ -81,6 +81,45 @@ const RECORD_COMMIT_DELAY_MS: u64 = 650;
/// they all line up (and reach the very top of the window).
pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
/// The chrome tile rhythm: a 30px hit box around a 15px glyph, so the glyph sits
/// [`TILE_PAD`] inside the box on every edge. Alignment is a property of what you
/// can *see*, so anything lining a tile up with text or with the window edge
/// subtracts `TILE_PAD` from the inset it wants — otherwise the invisible hit box
/// lands on the line and the glyph reads 7.5px short of it.
pub(crate) const TILE_SIZE: f32 = 30.;
pub(crate) const TILE_GLYPH: f32 = 15.;
pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.;
/// The one content inset the whole window aligns to: the rail's text and icons,
/// the title bar's chrome glyphs, and the side panels all start (or end) here, so
/// every vertical edge in the chrome falls on one of two lines rather than the
/// five slightly different ones each surface used to pick for itself.
pub(crate) const CONTENT_INSET: f32 = 12.;
/// What gpui-component's `TitleBar` already insets its content by, to clear the
/// window controls: 80px on macOS (traffic lights on the left), 12px elsewhere
/// (controls on the right). Anything laid out *inside* the bar therefore starts
/// here, not at the window edge.
pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. };
/// Left offset for the tile group that sits beside the window controls.
///
/// On macOS the thing that can collide with the traffic lights is the tile's
/// *hit box* — it paints a background on hover and when selected, 7.5px wider
/// than the glyph on each side — so this aligns the box, not the glyph, and the
/// bar's own 80px lead is already exactly the clearance macOS defines for that.
/// Hence zero: pulling back into the reserve to "hug" the lights only made the
/// hover capsule touch them. Off macOS the controls are on the right, nothing is
/// there to clear, and the group aligns its glyph to the content inset like the
/// rest of the chrome.
pub(crate) fn title_bar_hug_offset() -> f32 {
if cfg!(target_os = "macos") {
0.
} else {
CONTENT_INSET - TILE_PAD - TITLE_BAR_LEAD
}
}
/// One tab: a split-pane tree plus an optional user-assigned name. Settings is
/// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every
/// tab is a real terminal tab.
@@ -101,6 +140,13 @@ pub struct Tab {
/// switching back restores it; closing the tab drops it. Only the active
/// tab's overlay is rendered. See [`crate::ui::diff_overlay`].
pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>,
/// This tab's code panel (file tree + editor overlay): open files, tree
/// roots/expansion, and visibility. Same per-tab contract as
/// `diff_overlay` — switching away hides it, switching back restores it,
/// closing the tab drops it. Shared caches (directory listings, gitignore
/// matchers, language servers, watchers) live on [`Tty7App`]. `None` until
/// the panel is first opened in this tab.
pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>,
/// The sidebar group this tab last *definitively* belonged to: the
/// repository home of its first pane's cwd — the main checkout's root, so
/// linked worktrees of one repo share a group (deliberately not the
@@ -114,6 +160,21 @@ pub struct Tab {
/// flickering through the Scratch group and back. A `RefCell` because the
/// sidebar refreshes it during render, which only has `&Tab`.
pub(crate) sidebar_group: std::cell::RefCell<Option<std::path::PathBuf>>,
/// Which of the two full-column overlays (code panel, diff) was raised last.
/// They deliberately have no fixed precedence: whichever the user just acted
/// on paints on top, so opening a diff over the editor shows the diff, and
/// clicking a file in the tree behind it brings the editor back — the same
/// "click it, it comes forward" rule as window stacking.
pub(crate) overlay_top: OverlayTop,
}
/// Stacking order for the two overlays that cover the whole column. See
/// [`Tab::overlay_top`].
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum OverlayTop {
#[default]
Code,
Diff,
}
impl Tab {
@@ -123,6 +184,8 @@ impl Tab {
name: None,
last_focused: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
sidebar_group: std::cell::RefCell::new(None),
}
}
@@ -136,6 +199,23 @@ impl Tab {
}
}
/// The pane the right panel's detail should describe. Not simply the
/// focused leaf: opening the panel, the diff overlay or the editor moves
/// focus off the terminal entirely, and `focused_or_first` would then fall
/// back to the *first* pane — so a split's second pane would silently swap
/// the panel's cwd the moment you interacted with the panel. Falling back to
/// `focus_target` uses the pane that held focus when it left instead, which
/// is the one the user still thinks of as active.
pub(crate) fn detail_pane(
&self,
window: &Window,
cx: &gpui::App,
) -> Option<Entity<TerminalView>> {
self.pane
.focused_leaf(window, cx)
.or_else(|| self.focus_target())
}
/// The title used to derive the tab label: the pane the tab is working in.
/// Only the *active* tab has a live focused pane, so for an inactive tab
/// (which holds no window focus) we fall back to the pane it last had
@@ -350,6 +430,12 @@ pub struct Tty7App {
pub(crate) loopback_panel: LoopbackForwardPanelState,
/// Pane-contextual SFTP file panel (WS5), bound to a focused native-SSH pane.
pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState,
/// Right detail panel (info / changes / files) docked beside the terminal.
pub(crate) right_panel: crate::ui::right_panel::RightPanelState,
/// Local project file tree (left column of the body).
pub(crate) file_tree: crate::ui::file_tree::FileTreeState,
/// Code-editor panel (right column of the body).
pub(crate) editor: crate::ui::code_editor::EditorPanelState,
/// Vertical tab sidebar width (px), held in a shared `Cell` so the resize
/// drag's window-level mouse listener can mutate it without the entity handle
/// (mirrors the split divider's `ratio`). Seeded from `Config::sidebar_width`
@@ -357,6 +443,10 @@ pub struct Tty7App {
pub(crate) sidebar_width: Rc<Cell<f32>>,
/// Whether the sidebar's resize handle is currently held.
pub(crate) sidebar_dragging: Rc<Cell<bool>>,
/// Right detail panel width (px) and drag state, held in shared `Cell`s for
/// exactly the reason `sidebar_width` is — see there.
pub(crate) right_panel_width: Rc<Cell<f32>>,
pub(crate) right_panel_dragging: Rc<Cell<bool>>,
/// Scroll handle for the sidebar's row list, so activating a tab scrolls its
/// row into view.
pub(crate) sidebar_scroll: gpui::ScrollHandle,
@@ -369,8 +459,12 @@ pub struct Tty7App {
/// Filter box in the sidebar's top control bar ("Search tabs…"); its text
/// narrows the visible rows by fuzzy-ish substring match on the tab label.
pub(crate) sidebar_search: Entity<InputState>,
/// Live filter for the detail panel's Files tab (its own box, so filtering
/// the tree never disturbs the tab list's filter and vice versa).
pub(crate) file_search: Entity<InputState>,
/// Re-renders the sidebar on each search keystroke so results narrow live.
_sidebar_search_sub: Subscription,
_file_search_sub: Subscription,
/// `Some` while the settings page is open. Settings is a full-window overlay
/// (not a tab), so it covers the tab rail / title bar and never clutters the
/// tab list. Holds all the settings widget state + its subscriptions.
@@ -498,6 +592,8 @@ impl Tty7App {
)
};
let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx);
let file_tree = crate::ui::file_tree::FileTreeState::new(window, cx);
let editor = crate::ui::code_editor::EditorPanelState::new(window, cx);
// Managed-forward add-form inputs (native-SSH panes).
let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1"));
let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080"));
@@ -505,6 +601,7 @@ impl Tty7App {
let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80"));
let mf_description = cx.new(|cx| InputState::new(window, cx).placeholder("description"));
let sidebar_width = cx.global::<Config>().sidebar_width;
let right_panel_width = cx.global::<Config>().right_panel_width;
// Live-apply hot-reloaded config: the watcher in `main.rs` swaps the
// `Config` global on every `config.json` change, which fires this. The
// window-aware variant so the reload can re-run `apply_theme` with the
@@ -522,6 +619,10 @@ impl Tty7App {
let git_status_watch =
cx.observe_global::<crate::terminal::git_status::GitStatusCache>(|this, cx| {
this.maybe_refresh_diff_overlay(cx);
// Same trigger, same freshness: the right panel's Changes list is
// the sidebar's `+N M` expanded, so it re-probes whenever those
// numbers do rather than going stale behind them.
this.right_panel_refresh_changes(cx);
cx.notify();
});
// Any real keypress means "chord, not a bare hold": cancel the held-⌘
@@ -600,6 +701,12 @@ impl Tty7App {
cx.notify();
}
});
let file_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search files…"));
let file_search_sub = cx.subscribe_in(&file_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
});
let app = Self {
tabs,
active,
@@ -639,12 +746,19 @@ impl Tty7App {
mf_editing: None,
},
sftp_panel,
right_panel: Default::default(),
file_tree,
editor,
sidebar_width: Rc::new(Cell::new(sidebar_width)),
sidebar_dragging: Rc::new(Cell::new(false)),
right_panel_width: Rc::new(Cell::new(right_panel_width)),
right_panel_dragging: Rc::new(Cell::new(false)),
sidebar_scroll: gpui::ScrollHandle::new(),
reorder: Rc::new(RefCell::new(None)),
sidebar_search,
_sidebar_search_sub: sidebar_search_sub,
file_search,
_file_search_sub: file_search_sub,
settings: None,
ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx),
ssh_close_confirm: None,
@@ -798,6 +912,8 @@ impl Tty7App {
name: st.name,
last_focused: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
// Keep the group it had when closed — the row reappears where
// it lived instead of flashing through Scratch.
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
@@ -1933,6 +2049,30 @@ impl Tty7App {
self.set_tab_bar_position(next, cx);
}
/// `ToggleLeftPanel` (⌘B): collapse/expand the left rail in place, persisting
/// the choice. In `Top` mode there is no rail to collapse, so this switches to
/// `Left` and shows it — the shortcut always means "give me the sidebar".
pub(crate) fn toggle_left_panel(&mut self, cx: &mut Context<Self>) {
let cfg = cx.global::<Config>();
let (pos, collapsed) = match cfg.tab_bar_position {
TabBarPosition::Top => (TabBarPosition::Left, false),
TabBarPosition::Left => (TabBarPosition::Left, !cfg.sidebar_collapsed),
};
self.update_config(cx, |cfg| {
cfg.tab_bar_position = pos;
cfg.sidebar_collapsed = collapsed;
});
}
/// Whether the left rail is actually on screen: `Left` mode, not collapsed,
/// and at least one tab (the home page has no rail). The layout, the title
/// strip and the collapse button all derive from this one predicate.
pub(crate) fn left_panel_open(&self, cx: &gpui::App) -> bool {
matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
&& !cx.global::<Config>().sidebar_collapsed
&& !self.tabs.is_empty()
}
pub(crate) fn set_notify_mode(
&mut self,
mode: crate::core::config::NotifyMode,
@@ -2472,7 +2612,14 @@ impl Tty7App {
// In sidebar mode, pull the newly active row into view (a no-op when
// the strip is horizontal — the handle tracks no painted list then).
self.sidebar_scroll.scroll_to_item(index);
self.focus_active(window, cx);
if self.code_panel_visible() {
// The incoming tab has its own panel open: refresh its roots
// (pane cwds may have changed) and keep focus on the panel.
self.file_tree_refresh_roots(window, cx);
self.file_tree.focus_handle.focus(window, cx);
} else {
self.focus_active(window, cx);
}
self.save_session(cx);
cx.notify();
}
@@ -2977,6 +3124,9 @@ impl Tty7App {
ToggleMaximizePane => self.toggle_maximize(window, cx),
ToggleFullscreen => window.toggle_fullscreen(),
ToggleTabSidebar => self.toggle_tab_sidebar(cx),
ToggleLeftPanel => self.toggle_left_panel(cx),
ToggleRightPanel => self.toggle_right_panel(cx),
ShowRightPanel(tab) => self.set_right_panel_tab(tab, cx),
ResetFontSize => self.reset_font_size(cx),
FindInTerminal => {
// Open the search bar on the pane focus just returned to (the
@@ -3004,6 +3154,7 @@ impl Tty7App {
OpenSettings => self.toggle_settings(window, cx),
RestartDaemon => self.restart_daemon(window, cx),
ToggleSftp => self.toggle_sftp(window, cx),
ToggleCodePanel => self.toggle_code_panel(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()) {
@@ -3034,7 +3185,7 @@ impl Tty7App {
/// The pane the agent-feed commands deliver to: the first leaf running a
/// recognized coding agent, preferring the active tab, then any tab. `None`
/// when no agent runs anywhere.
fn agent_target_leaf(&self, cx: &App) -> Option<Entity<TerminalView>> {
pub(crate) fn agent_target_leaf(&self, cx: &App) -> Option<Entity<TerminalView>> {
let runs_agent = |leaf: &Entity<TerminalView>| leaf.read(cx).agent().is_some();
if let Some(tab) = self.tabs.get(self.active)
&& let Some(leaf) = tab.pane.leaves().into_iter().find(runs_agent)
@@ -3638,6 +3789,8 @@ impl Tty7App {
// Keep the runtime sidebar width in step with the config (an external
// edit to `config.json`, or our own drag-end persist which re-fires this).
self.sidebar_width.set(cx.global::<Config>().sidebar_width);
self.right_panel_width
.set(cx.global::<Config>().right_panel_width);
if font_size != self.font_size {
self.font_size = font_size;
let px_size = px(font_size);
@@ -4313,8 +4466,13 @@ impl Render for Tty7App {
// rail never appears.
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
&& !self.tabs.is_empty();
// The rail can be collapsed away without leaving `Left` mode. When it is,
// the layout below has no left column, so the title strip takes over the
// rail's jobs: it reserves the traffic lights and carries the sidebar's
// own controls (new tab + expand) at its left edge.
let rail = vertical && !cx.global::<Config>().sidebar_collapsed;
let strip = self.tab_strip(!vertical, window, cx);
let sidebar = vertical.then(|| self.tab_sidebar(window, cx));
let sidebar = rail.then(|| self.tab_sidebar(window, cx));
// Gate the pane action buttons (tunnel / SFTP) + their panels to a
// connected native-SSH pane; a foreground `ssh` or a still-connecting
// session shows only the top-left status strip, no action buttons.
@@ -4410,11 +4568,40 @@ impl Render for Tty7App {
// "New Worktree Tab" confirmation sheet (from the tab context menu).
.when_some(self.render_worktree_prompt_overlay(cx), |this, el| {
this.child(el)
})
// Working-tree diff overlay (clicked from a sidebar git line) —
// last child, so it paints over every pane-contextual element
// above. It covers only the body: the sidebar stays interactive.
.when_some(self.render_diff_overlay(cx), |this, el| this.child(el));
});
// Working-tree diff overlay — mounted on the *column*, not on
// `body_area`, so it covers the title strip too and reads as one
// surface the way the code overlay does. Like that overlay it stops at
// the rail and the right panel (both are siblings), which is the point:
// the sidebar's git lines stay clickable to switch repo, and the
// Changes list stays put so you can walk down it file by file.
let diff_overlay = self.render_diff_overlay(cx);
// Code panel: an immersive overlay ([file tree | editor], IDE-style)
// covering the title strip *and* the terminal — the whole column right
// of the tab sidebar — so nothing of the terminal chrome distracts.
// The terminal underneath keeps its size (no PTY resize/reflow), and
// the sidebar stays visible: switching tabs re-roots the tree.
let code_overlay = self.render_code_overlay(window, cx);
// The two column overlays, ordered so the one the user last acted on is
// the later child and therefore paints on top. Neither outranks the
// other by construction.
let overlays: Vec<gpui::AnyElement> = {
let mut pair = vec![
(OverlayTop::Diff, diff_overlay),
(OverlayTop::Code, code_overlay),
];
if self
.tabs
.get(self.active)
.is_some_and(|t| t.overlay_top == OverlayTop::Diff)
{
pair.reverse();
}
pair.into_iter().filter_map(|(_, el)| el).collect()
};
// The two layouts. Horizontal (default): a column of [title bar / body].
// Vertical: the rail is a full-height *left column* that reaches the very
@@ -4422,6 +4609,12 @@ impl Render for Tty7App {
// title strip and terminal stacked in the right column. That way the rail
// surface has no seam with the title bar and reads as one continuous
// panel.
// The right detail panel is a full-height column, not a box under the title
// bar: it carries its own title-bar-height top zone (tab row + the window's
// corner chrome) exactly like the rail does on the left, so its surface
// runs unbroken from the very top of the window. Anything less leaves a
// horizontal seam where the panel's grey starts under the terminal's bar.
let right_panel = self.render_right_panel(window, cx);
let main_layout = match sidebar {
Some(sidebar) => div()
.flex_1()
@@ -4436,18 +4629,40 @@ impl Render for Tty7App {
.min_w_0()
.flex()
.flex_col()
// Anchor for the code overlay: it fills this column
// (title strip + body) — and, since the panel is a sibling
// rather than a child, stops short of the panel for free.
.relative()
.child(title_bar)
.child(body_area),
.child(body_area)
.children(overlays),
)
.when_some(right_panel, |this, panel| this.child(panel))
.into_any_element(),
// Horizontal-tabs mode has no rail, but the panel is still a column
// beside the stacked [title bar / body], for the same reason: it has to
// own its own top zone to read as one surface.
None => div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_col()
.child(title_bar)
.child(body_area)
.flex_row()
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.relative()
.child(title_bar)
.child(body_area)
// Both overlays cover the whole window face here (their
// content pads down past the traffic lights — see
// `render_code_overlay` and `diff_header`).
.children(overlays),
)
.when_some(right_panel, |this, panel| this.child(panel))
.into_any_element(),
};
@@ -4490,9 +4705,13 @@ impl Render for Tty7App {
.text_color(cx.theme().foreground)
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
.on_action(cx.listener(|this, _: &NewTab, window, cx| this.new_tab(window, cx)))
.on_action(
cx.listener(|this, _: &CloseActiveTab, window, cx| this.close_pane(window, cx)),
)
.on_action(cx.listener(|this, _: &CloseActiveTab, window, cx| {
// With focus in the editor panel, ⌘W closes the active file
// tab instead of the terminal pane/tab.
if !this.editor_close_active_if_focused(window, cx) {
this.close_pane(window, cx)
}
}))
.on_action(cx.listener(|this, _: &SplitRight, window, cx| {
this.split(Axis::Horizontal, window, cx)
}))
@@ -4614,6 +4833,24 @@ impl Render for Tty7App {
.on_action(
cx.listener(|this, _: &ToggleTabSidebar, _window, cx| this.toggle_tab_sidebar(cx)),
)
.on_action(
cx.listener(|this, _: &ToggleLeftPanel, _window, cx| this.toggle_left_panel(cx)),
)
.on_action(
cx.listener(|this, _: &ToggleRightPanel, _window, cx| this.toggle_right_panel(cx)),
)
.on_action(cx.listener(|this, _: &ShowRightPanelInfo, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelOutline, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Outline, cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelChanges, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Changes, cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelFiles, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx)
}))
.on_action(
cx.listener(|this, _: &OpenSettings, window, cx| this.toggle_settings(window, cx)),
)
@@ -4621,6 +4858,18 @@ impl Render for Tty7App {
cx.listener(|this, _: &RestartDaemon, window, cx| this.restart_daemon(window, cx)),
)
.on_action(cx.listener(|this, _: &ToggleSftp, window, cx| this.toggle_sftp(window, cx)))
.on_action(cx.listener(|this, _: &ToggleCodePanel, window, cx| {
this.toggle_code_panel(window, cx)
}))
.on_action(
cx.listener(|this, _: &EditorSave, window, cx| this.editor_save_active(window, cx)),
)
.on_action(cx.listener(|this, _: &EditorGotoDefinition, window, cx| {
this.editor_goto_definition(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorFindReferences, window, cx| {
this.editor_find_references(window, cx)
}))
// Quit lives on the same element-tree action path as every other Cmd
// shortcut above, so a focused terminal routes `cmd-q` here rather
// than relying solely on the global handler (which the keystroke
@@ -4760,6 +5009,8 @@ fn tabs_from_session(
name: st.name.clone(),
last_focused: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
// Seed the sticky group from the saved session so the sidebar
// renders grouped on the first frame; the first landed probe
// corrects it if the tab's repo changed while we were gone.
@@ -4874,9 +5125,28 @@ fn new_terminal(
},
)
.detach();
watch_pane_focus(&view, window, cx);
view
}
/// Re-render the app whenever `view` takes focus. Nothing else does this: a
/// pane owns its own focus handle, so clicking between splits notifies the
/// *pane*, not us, and any chrome that describes "the active pane" — the right
/// panel's Info and Changes tabs — would keep showing the pane you left until
/// some unrelated notify happened to repaint. Focus changes are user-paced, so
/// the extra frames are free.
fn watch_pane_focus(view: &Entity<TerminalView>, window: &mut Window, cx: &mut Context<Tty7App>) {
let handle = view.read(cx).focus_handle.clone();
let app = cx.weak_entity();
window
.on_focus_in(&handle, cx, move |_window, cx| {
if let Some(app) = app.upgrade() {
app.update(cx, |_, cx| cx.notify());
}
})
.detach();
}
/// Build a native (russh) SSH terminal view for `spec`, wiring the same
/// per-pane subscriptions (`ChildExited`, `AuthPromptReady`) as [`new_terminal`]
/// so it participates in auto-close and the in-pane auth sheets. Mirrors
@@ -4908,6 +5178,7 @@ pub(crate) fn new_terminal_native(
},
)
.detach();
watch_pane_focus(&view, window, cx);
Ok(view)
}
+13
View File
@@ -44,6 +44,19 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
// Lucide's `git-branch`, for the sidebar row's branch line (gpui-component
// doesn't bundle a git glyph).
"icons/git-branch.svg" => include_bytes!("../../assets/icons/git-branch.svg"),
// Filled-panel dock glyphs for the window chrome: an outlined frame with
// one inset solid block marking which dock is open (the "commercial-app"
// look). gpui-component only ships the hollow line `panel-left/right`, so
// tty7 carries its own filled variants for the title bar controls.
"icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"),
"icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"),
// Lucide's `list`, for the detail panel's Outline tab: the recorded
// commands are a plain list, not a terminal (`square-terminal` read as
// "this is a shell", which every pane already is).
"icons/list.svg" => include_bytes!("../../assets/icons/list.svg"),
// A refined `info` glyph for the Info tab: a solid dot over a round-capped
// stem in a slightly inset circle, cleaner than the stock hairline `i`.
"icons/info.svg" => include_bytes!("../../assets/icons/info.svg"),
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
"icons/agents/gemini.svg" => include_bytes!("../../assets/icons/agents/gemini.svg"),
File diff suppressed because it is too large Load Diff
+198 -49
View File
@@ -22,7 +22,7 @@ use std::collections::HashSet;
use std::path::PathBuf;
use gpui::{AnyElement, FocusHandle, FontWeight, KeyDownEvent, Window, div, prelude::*, px};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::button::Button;
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use crate::terminal::git_diff::{
@@ -57,6 +57,13 @@ pub(crate) struct DiffOverlayState {
/// files default open, big/binary ones closed). Keyed by path so the set
/// survives a background refresh of the snapshot.
pub(crate) toggled: HashSet<String>,
/// When set, the overlay shows only this file (repo-relative path), always
/// expanded — the "click a row in the Changes panel" entry point. `None` is
/// the whole-tree view the git line opens. Kept as a path rather than an
/// index so a background re-probe that reorders files doesn't swap which
/// file is on screen; a path that vanishes from the diff falls back to the
/// full list rather than showing an empty overlay.
pub(crate) focus: Option<String>,
}
impl Tty7App {
@@ -69,19 +76,63 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self
self.toggle_diff_overlay_at(cwd, None, window, cx)
}
/// The same toggle, scoped to one file: opens the overlay showing only
/// `focus` (repo-relative), which is what the Changes panel's rows do. The
/// toggle key is the pair — re-clicking the row that's already on screen
/// closes, while clicking a *different* row swaps the shown file in place
/// without the overlay blinking shut and re-probing.
pub(crate) fn toggle_diff_overlay_at(
&mut self,
cwd: PathBuf,
focus: Option<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let active = self.active;
// Was the diff already the front overlay? If it was buried under the
// code panel, this click means "bring it up", not "close it" — closing
// something the user can't currently see would read as the click doing
// nothing.
let was_front = self.tabs.get(active).is_some_and(|t| {
t.overlay_top == crate::ui::app::OverlayTop::Diff || !self.code_panel_visible()
});
// Acting on the diff raises it over the code panel, whether it was
// already open or not.
if let Some(tab) = self.tabs.get_mut(active) {
tab.overlay_top = crate::ui::app::OverlayTop::Diff;
}
match self
.tabs
.get(self.active)
.and_then(|t| t.diff_overlay.as_ref())
.is_some_and(|o| o.cwd == cwd)
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
.filter(|o| o.cwd == cwd)
{
self.close_diff_overlay(window, cx);
return;
// Already open on this repo showing this exact thing, and already on
// top — toggle off.
Some(o) if o.focus == focus && was_front => {
self.close_diff_overlay(window, cx);
return;
}
// Open on this repo, different file: retarget. The snapshot is
// already loaded and covers every file, so there is nothing to
// re-probe — this is a pure re-render.
Some(o) => {
o.focus = focus;
// Take focus too, so Esc closes the diff rather than whatever
// was focused before it came forward (often the editor).
let handle = o.focus_handle.clone();
window.focus(&handle, cx);
cx.notify();
return;
}
None => {}
}
// The overlay steals focus (it needs Esc); snapshot the active pane so
// closing lands back on the same terminal — same discipline as Settings.
self.remember_active_pane(window, cx);
let active = self.active;
let Some(tab) = self.tabs.get_mut(active) else {
return; // home page — no tab body to overlay
};
@@ -92,12 +143,21 @@ impl Tty7App {
load: DiffLoad::Loading,
loading: false,
toggled: HashSet::new(),
focus,
});
window.focus(&focus_handle, cx);
self.spawn_diff_probe(cx);
cx.notify();
}
/// The file the active tab's overlay is currently scoped to, if any — the
/// Changes panel reads it to mark the matching row as selected, so panel and
/// overlay can't disagree about what's on screen.
pub(crate) fn diff_overlay_focus(&self, cwd: &std::path::Path) -> Option<&str> {
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
(overlay.cwd == cwd).then(|| overlay.focus.as_deref())?
}
/// Close the active tab's overlay (Esc, ✕, or the toggle) and give focus
/// back to the active terminal.
pub(crate) fn close_diff_overlay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -208,7 +268,9 @@ impl Tty7App {
DiffLoad::Ready(snap) if snap.files.is_empty() && snap.untracked.is_empty() => {
self.diff_message("Working tree clean", cx)
}
DiffLoad::Ready(snap) => self.diff_file_list(snap, &overlay.toggled, cx),
DiffLoad::Ready(snap) => {
self.diff_file_list(snap, &overlay.toggled, focused_file(snap, overlay), cx)
}
};
let header = self.diff_header(overlay, cx);
@@ -256,10 +318,21 @@ impl Tty7App {
}
_ => (String::new(), 0, 0, 0, 0),
};
// The overlay now covers the title strip, so its header *is* the title
// bar for as long as it's up: same height, and the same left inset the
// editor header uses — content clears the traffic lights whenever the
// rail isn't there to hold that space for us.
let lead = if self.left_panel_open(cx) {
crate::ui::app::CONTENT_INSET
} else {
crate::ui::app::TITLE_BAR_LEAD
};
h_flex()
.flex_shrink_0()
.h(px(40.))
.px_3()
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
.pl(px(lead))
// Trailing tile aligns on its glyph, like every other corner control.
.pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD))
.gap_2()
.items_center()
.border_b_1()
@@ -277,38 +350,80 @@ impl Tty7App {
.font_weight(FontWeight::MEDIUM)
.child(branch),
)
.when(matches!(overlay.load, DiffLoad::Ready(_)), |bar| {
let mut summary = format!(
"{} changed file{}",
files,
if files == 1 { "" } else { "s" }
);
if untracked > 0 {
summary.push_str(&format!(" · {untracked} untracked"));
}
// Scoped to one file: the branch stays (it's still what we diff
// against) but the totals give way to the file's own name, with a
// click target back to the whole tree — otherwise the only way out
// of a focused view would be to close and re-open the overlay.
.when_some(focused_name(overlay), |bar, name| {
bar.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(summary),
h_flex()
.id("diff-overlay-unfocus")
.items_center()
.gap_1()
.px_1p5()
.py_0p5()
.rounded_md()
.cursor_pointer()
.hover(|s| s.bg(cx.theme().list_hover))
.on_click(cx.listener(|this, _, _window, cx| {
let active = this.active;
if let Some(overlay) = this
.tabs
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
{
overlay.focus = None;
cx.notify();
}
}))
.child(
Icon::new(IconName::ChevronLeft)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_xs()
.font_family(self.font_family.clone())
.child(name),
),
)
.when(added > 0, |bar| {
bar.child(
div()
.text_xs()
.text_color(cx.theme().success)
.child(format!("+{added}")),
)
})
.when(removed > 0, |bar| {
bar.child(
div()
.text_xs()
.text_color(cx.theme().danger)
.child(format!("{removed}")),
)
})
})
.when(
matches!(overlay.load, DiffLoad::Ready(_)) && overlay.focus.is_none(),
|bar| {
let mut summary = format!(
"{} changed file{}",
files,
if files == 1 { "" } else { "s" }
);
if untracked > 0 {
summary.push_str(&format!(" · {untracked} untracked"));
}
bar.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(summary),
)
.when(added > 0, |bar| {
bar.child(
div()
.text_xs()
.text_color(cx.theme().success)
.child(format!("+{added}")),
)
})
.when(removed > 0, |bar| {
bar.child(
div()
.text_xs()
.text_color(cx.theme().danger)
.child(format!("{removed}")),
)
})
},
)
// A quiet "refreshing" hint while a re-probe flies over stale data.
.when(
overlay.loading && matches!(overlay.load, DiffLoad::Ready(_)),
@@ -323,13 +438,16 @@ impl Tty7App {
)
.child(div().flex_1())
.child(
Button::new("diff-overlay-close")
.icon(IconName::Close)
.ghost()
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.close_diff_overlay(window, cx);
})),
crate::ui::tab_strip::chrome_tile(
Button::new("diff-overlay-close").icon(IconName::Close),
false,
cx,
)
.small()
.tooltip("Close Diff (Esc)")
.on_click(cx.listener(|this, _, window, cx| {
this.close_diff_overlay(window, cx);
})),
)
}
@@ -351,14 +469,26 @@ impl Tty7App {
&self,
snap: &DiffSnapshot,
toggled: &HashSet<String>,
focused: Option<usize>,
cx: &mut Context<Self>,
) -> AnyElement {
let mut list = v_flex().gap_3().p_4().w_full();
for (idx, file) in snap.files.iter().enumerate() {
let expanded = file_expanded(file, toggled);
if focused.is_some_and(|f| f != idx) {
continue;
}
// A file opened by name was asked for explicitly — show its body
// even when it's over the auto-collapse threshold. The header still
// toggles, so a huge file can be folded back down.
let expanded = if focused == Some(idx) {
!toggled.contains(&file.path)
} else {
file_expanded(file, toggled)
};
list = list.child(self.diff_file_card(idx, file, expanded, cx));
}
if !snap.untracked.is_empty() {
// Untracked files are a property of the tree, not of the focused file.
if focused.is_none() && !snap.untracked.is_empty() {
list = list.child(self.diff_untracked_section(&snap.untracked, cx));
}
div()
@@ -620,6 +750,25 @@ impl Tty7App {
}
}
/// Resolve the overlay's focused path to an index into `snap.files`. `None`
/// means "show everything" — either nothing is focused, or the focused path is
/// no longer in the diff (the user reverted it while the overlay was open), in
/// which case falling back to the full list beats an empty screen.
fn focused_file(snap: &DiffSnapshot, overlay: &DiffOverlayState) -> Option<usize> {
let path = overlay.focus.as_deref()?;
snap.files.iter().position(|f| f.path == path)
}
/// The focused file's name for the header, only once it's known to be in the
/// snapshot — so a stale focus doesn't label a list that shows every file.
fn focused_name(overlay: &DiffOverlayState) -> Option<String> {
let DiffLoad::Ready(snap) = &overlay.load else {
return None;
};
let idx = focused_file(snap, overlay)?;
Some(snap.files[idx].path.clone())
}
/// Whether a file's body shows: small text diffs default open, big ones (and
/// anything the user explicitly flipped) invert via the `toggled` set.
fn file_expanded(file: &FileDiff, toggled: &HashSet<String>) -> bool {
+1213
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -159,6 +159,19 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
// this steers clear of collisions) — reachable from the command palette
// and Settings → Window & Tabs, and bindable there like any other action.
("ToggleTabSidebar", ""),
// Collapse/expand the left rail, on the ⌘B every editor uses for it.
// Off macOS `secondary-b` is Ctrl+B, which is the tmux preset's default
// prefix — leave it unbound there rather than fight the prefix.
(
"ToggleLeftPanel",
if cfg!(target_os = "macos") {
"secondary-b"
} else {
""
},
),
// The right detail panel, on the ⌘J every editor uses for a dock.
("ToggleRightPanel", "secondary-j"),
// Buffer search. ⌘F on macOS; elsewhere `secondary-f` (Ctrl+F) is
// readline's forward-char, so follow the GUI-terminal convention and open
// find on Ctrl+Shift+F, leaving Ctrl+F to the shell. Find-again is ⌘G/⌘⇧G
@@ -193,6 +206,14 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
// No default chord — reachable from the command palette ("SFTP Panel") and
// bindable in Settings like any other action.
("ToggleSftp", ""),
// The code panel (file tree + editor overlay), on VS Code's explorer
// chord. ⌘⇧E is free (no existing binding or preset uses it).
("ToggleCodePanel", "secondary-shift-e"),
// Save the editor's active file. ⌘S is free — the terminal has no save.
("EditorSave", "secondary-s"),
// LSP navigation in the editor panel, on the VS Code chords.
("EditorGotoDefinition", "f12"),
("EditorFindReferences", "shift-f12"),
// No default chord — reachable from the command palette ("SSH: Manage
// Profiles…") and bindable in Settings.
("OpenSshProfiles", ""),
@@ -471,6 +492,15 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"ToggleMaximizePane" => KeyBinding::new(keystroke, ToggleMaximizePane, None),
"ToggleFullscreen" => KeyBinding::new(keystroke, ToggleFullscreen, None),
"ToggleTabSidebar" => KeyBinding::new(keystroke, ToggleTabSidebar, None),
"ToggleLeftPanel" => KeyBinding::new(keystroke, ToggleLeftPanel, None),
"ToggleRightPanel" => KeyBinding::new(keystroke, ToggleRightPanel, None),
// Right-panel tab jumps. No entry in the default table above — they ship
// unbound and exist so a user *can* bind them; the palette reaches them
// either way.
"ShowRightPanelInfo" => KeyBinding::new(keystroke, ShowRightPanelInfo, None),
"ShowRightPanelOutline" => KeyBinding::new(keystroke, ShowRightPanelOutline, None),
"ShowRightPanelChanges" => KeyBinding::new(keystroke, ShowRightPanelChanges, None),
"ShowRightPanelFiles" => KeyBinding::new(keystroke, ShowRightPanelFiles, None),
// Terminal-scoped (the handler lives on the terminal surface): the "Terminal"
// context keeps ⌘K inert in the settings tab / home page instead of binding a
// dead global chord there.
@@ -483,6 +513,10 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"ClearScrollback" => KeyBinding::new(keystroke, ClearScrollback, Some("Terminal")),
"OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None),
"ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None),
"ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None),
"EditorSave" => KeyBinding::new(keystroke, EditorSave, None),
"EditorGotoDefinition" => KeyBinding::new(keystroke, EditorGotoDefinition, None),
"EditorFindReferences" => KeyBinding::new(keystroke, EditorFindReferences, None),
"OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None),
"RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None),
"Quit" => KeyBinding::new(keystroke, Quit, None),
+642
View File
@@ -0,0 +1,642 @@
//! Minimal LSP client for the code-editor panel.
//!
//! One `LspClient` per (server, workspace root): a spawned server process
//! speaking JSON-RPC over stdio. A std reader thread parses `Content-Length`
//! frames and routes responses to per-request channels, `publishDiagnostics`
//! notifications to a UI-side loop, and answers the handful of server→client
//! requests (`workspace/configuration` &co.) with benign defaults so servers
//! like rust-analyzer don't stall waiting on us.
//!
//! The editor integrates through gpui-component's provider traits: completion
//! (popup menu), hover (popover) and definition (⌘-hover underline + ⌘-click)
//! are handled by [`FileLsp`], one per open file. The component's ⌘-click jump
//! only works within the current buffer, so `definitions` filters to same-file
//! links; the app-level Go to Definition action (F12, `code_editor.rs`) does
//! the full cross-file open + jump itself.
//!
//! Servers are discovered on PATH per language (rust-analyzer, gopls, pyright,
//! typescript-language-server, clangd); a missing binary just means no LSP for
//! that language — the editor works fine without it.
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{Context as _, Result, anyhow};
use gpui::{App, Context, Task, Window};
use gpui_component::input::InputState;
use gpui_component::input::{CompletionProvider, DefinitionProvider, HoverProvider, RopeExt as _};
use ropey::Rope;
use serde_json::{Value, json};
use crate::ui::app::Tty7App;
/// How long a single request may wait on the server before the provider gives
/// up (a hung server must not wedge hover/completion forever).
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
/// The command line and LSP `languageId` for a tree-sitter language name.
/// `None` → no server configured for that language.
pub(crate) fn server_for_language(lang: &str) -> Option<(&'static [&'static str], &'static str)> {
Some(match lang {
"rust" => (&["rust-analyzer"], "rust"),
"go" => (&["gopls"], "go"),
"python" => (&["pyright-langserver", "--stdio"], "python"),
"typescript" => (&["typescript-language-server", "--stdio"], "typescript"),
"tsx" => (
&["typescript-language-server", "--stdio"],
"typescriptreact",
),
"javascript" => (&["typescript-language-server", "--stdio"], "javascript"),
"c" => (&["clangd"], "c"),
"cpp" => (&["clangd"], "cpp"),
_ => return None,
})
}
/// `file://` URI for a local path (percent-encoded via the `url` crate).
pub(crate) fn uri_for_path(path: &Path) -> Option<String> {
url::Url::from_file_path(path).ok().map(|u| u.to_string())
}
/// Local path for a `file://` URI string; `None` for non-file schemes.
pub(crate) fn path_for_uri(uri: &str) -> Option<PathBuf> {
url::Url::parse(uri).ok()?.to_file_path().ok()
}
// ---------------------------------------------------------------------------
// Client.
// ---------------------------------------------------------------------------
/// Thread-shared client internals (UI thread + reader thread).
struct Inner {
name: String,
stdin: Mutex<ChildStdin>,
/// In-flight requests by id; the reader thread resolves them.
pending: Mutex<HashMap<i64, smol::channel::Sender<Value>>>,
/// Flips true when the `initialize` response lands; frames sent before
/// that wait in `queued`.
ready: AtomicBool,
queued: Mutex<Vec<String>>,
next_id: AtomicI64,
}
impl Inner {
fn write_frame(stdin: &mut ChildStdin, body: &str) {
let _ = write!(stdin, "Content-Length: {}\r\n\r\n{body}", body.len());
let _ = stdin.flush();
}
/// Send a frame now, or park it until the server finished initializing.
///
/// The `ready` check happens under the `queued` lock, and `flush_queued`
/// takes the same lock — otherwise a frame could read `ready == false`,
/// lose the race to the reader thread flipping it and draining the queue,
/// and then park itself behind a handshake that already finished, where
/// nothing would ever send it (a `didOpen` lost that way costs the file its
/// diagnostics for the whole session).
fn send(&self, body: String) {
let mut queued = self.queued.lock().unwrap();
if self.ready.load(Ordering::SeqCst) {
drop(queued);
let mut stdin = self.stdin.lock().unwrap();
Self::write_frame(&mut stdin, &body);
} else {
queued.push(body);
}
}
/// Initialize finished: mark ready and flush everything parked behind the
/// handshake. `ready` flips under the `queued` lock, so no sender can slip a
/// frame out ahead of the ones already parked (a `didChange` overtaking its
/// `didOpen` desynchronizes the server for the rest of the session).
fn mark_ready_and_flush(&self) {
let mut queued = self.queued.lock().unwrap();
self.ready.store(true, Ordering::SeqCst);
let parked: Vec<String> = std::mem::take(&mut *queued);
let mut stdin = self.stdin.lock().unwrap();
for body in parked {
Self::write_frame(&mut stdin, &body);
}
}
}
/// A running language server bound to one workspace root.
pub(crate) struct LspClient {
inner: Arc<Inner>,
child: std::cell::RefCell<Child>,
#[allow(dead_code)] // identifies the client in future workspace-level requests
pub(crate) root: PathBuf,
/// didOpen version counter per document.
versions: std::cell::RefCell<HashMap<PathBuf, i64>>,
}
impl Drop for LspClient {
fn drop(&mut self) {
// No graceful shutdown round-trip — the app is closing the panel or
// exiting; killing the child reaps it without waiting on a wedged one.
let _ = self.child.borrow_mut().kill();
}
}
impl LspClient {
/// Spawn `cmd` rooted at `root` and start the handshake + reader thread.
/// Diagnostics flow out through `diag_tx` as `(path, diagnostics)`.
pub(crate) fn spawn(
cmd: &[&str],
root: &Path,
diag_tx: smol::channel::Sender<(PathBuf, Vec<lsp_types::Diagnostic>)>,
) -> Result<Self> {
let mut child = Command::new(cmd[0])
.args(&cmd[1..])
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("spawning {}", cmd[0]))?;
let stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
let inner = Arc::new(Inner {
name: cmd[0].to_string(),
stdin: Mutex::new(stdin),
pending: Mutex::new(HashMap::new()),
ready: AtomicBool::new(false),
queued: Mutex::new(Vec::new()),
next_id: AtomicI64::new(2), // 1 is reserved for `initialize`
});
// The initialize request goes out immediately (bypassing the queue).
let root_uri = uri_for_path(root).unwrap_or_else(|| "file:///".into());
let init = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": std::process::id(),
"rootUri": root_uri,
"workspaceFolders": [{
"uri": root_uri,
"name": root.file_name().map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "root".into()),
}],
"capabilities": {
"textDocument": {
"synchronization": { "didSave": true },
"publishDiagnostics": { "relatedInformation": false },
"hover": { "contentFormat": ["markdown", "plaintext"] },
"completion": {
"completionItem": {
"snippetSupport": false,
"documentationFormat": ["markdown", "plaintext"],
}
},
"definition": { "linkSupport": true },
"references": {},
},
"workspace": { "configuration": true, "workspaceFolders": true },
"window": { "workDoneProgress": true },
},
},
});
{
let mut stdin = inner.stdin.lock().unwrap();
Inner::write_frame(&mut stdin, &init.to_string());
}
// Reader thread: frame parser + dispatcher.
let reader_inner = inner.clone();
std::thread::Builder::new()
.name(format!("lsp-{}", cmd[0]))
.spawn(move || reader_loop(stdout, reader_inner, diag_tx))
.context("spawning lsp reader thread")?;
Ok(Self {
inner,
child: std::cell::RefCell::new(child),
root: root.to_path_buf(),
versions: std::cell::RefCell::new(HashMap::new()),
})
}
/// The server binary's name, for the status bar.
pub(crate) fn name(&self) -> &str {
&self.inner.name
}
fn notify(&self, method: &str, params: Value) {
let body = json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string();
self.inner.send(body);
}
/// Fire a request; the returned channel yields the `result` value (or
/// `Null` on a server-side error). Await it on a background executor.
pub(crate) fn request(&self, method: &str, params: Value) -> smol::channel::Receiver<Value> {
let id = self.inner.next_id.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = smol::channel::bounded(1);
self.inner.pending.lock().unwrap().insert(id, tx);
let body =
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
self.inner.send(body);
rx
}
pub(crate) fn did_open(&self, path: &Path, language_id: &str, text: &str) {
let Some(uri) = uri_for_path(path) else {
return;
};
self.versions.borrow_mut().insert(path.to_path_buf(), 1);
self.notify(
"textDocument/didOpen",
json!({ "textDocument": {
"uri": uri, "languageId": language_id, "version": 1, "text": text,
}}),
);
}
/// Full-document sync (the simplest correct thing at this scale).
pub(crate) fn did_change(&self, path: &Path, text: &str) {
let Some(uri) = uri_for_path(path) else {
return;
};
let mut versions = self.versions.borrow_mut();
let v = versions.entry(path.to_path_buf()).or_insert(1);
*v += 1;
self.notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": uri, "version": *v },
"contentChanges": [{ "text": text }],
}),
);
}
pub(crate) fn did_save(&self, path: &Path) {
let Some(uri) = uri_for_path(path) else {
return;
};
self.notify(
"textDocument/didSave",
json!({ "textDocument": { "uri": uri } }),
);
}
pub(crate) fn did_close(&self, path: &Path) {
let Some(uri) = uri_for_path(path) else {
return;
};
self.versions.borrow_mut().remove(path);
self.notify(
"textDocument/didClose",
json!({ "textDocument": { "uri": uri } }),
);
}
/// Standard text-document position params for a rope offset.
pub(crate) fn position_params(path: &Path, text: &Rope, offset: usize) -> Option<Value> {
let uri = uri_for_path(path)?;
let pos = text.offset_to_position(offset);
Some(json!({
"textDocument": { "uri": uri },
"position": { "line": pos.line, "character": pos.character },
}))
}
}
/// Parse `Content-Length`-framed JSON-RPC from the server and dispatch.
fn reader_loop(
stdout: std::process::ChildStdout,
inner: Arc<Inner>,
diag_tx: smol::channel::Sender<(PathBuf, Vec<lsp_types::Diagnostic>)>,
) {
let mut reader = BufReader::new(stdout);
loop {
// Headers.
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => return, // EOF: server exited
Ok(_) => {}
Err(_) => return,
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(rest) = line.strip_prefix("Content-Length:") {
content_length = rest.trim().parse().ok();
}
}
let Some(len) = content_length else { continue };
let mut buf = vec![0u8; len];
if reader.read_exact(&mut buf).is_err() {
return;
}
let Ok(msg) = serde_json::from_slice::<Value>(&buf) else {
continue;
};
let id = msg.get("id").and_then(|v| v.as_i64());
let method = msg.get("method").and_then(|v| v.as_str());
match (id, method) {
// Server → client request: answer with a benign default so the
// server never blocks on us.
(Some(id), Some(method)) => {
let result = match method {
"workspace/configuration" => {
let n = msg
.pointer("/params/items")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
Value::Array(vec![Value::Null; n])
}
_ => Value::Null,
};
let body = json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string();
let mut stdin = inner.stdin.lock().unwrap();
Inner::write_frame(&mut stdin, &body);
}
// Response.
(Some(id), None) => {
if id == 1 {
// The initialize response: complete the handshake, then
// release everything parked behind it.
let initialized =
json!({ "jsonrpc": "2.0", "method": "initialized", "params": {} })
.to_string();
{
let mut stdin = inner.stdin.lock().unwrap();
Inner::write_frame(&mut stdin, &initialized);
}
inner.mark_ready_and_flush();
log::info!("lsp: {} initialized", inner.name);
continue;
}
if let Some(tx) = inner.pending.lock().unwrap().remove(&id) {
let result = msg.get("result").cloned().unwrap_or(Value::Null);
let _ = tx.try_send(result);
}
}
// Notification.
(None, Some("textDocument/publishDiagnostics")) => {
let uri = msg
.pointer("/params/uri")
.and_then(|v| v.as_str())
.unwrap_or_default();
let Some(path) = path_for_uri(uri) else {
continue;
};
let diags: Vec<lsp_types::Diagnostic> = msg
.pointer("/params/diagnostics")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let _ = diag_tx.try_send((path, diags));
}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Registry.
// ---------------------------------------------------------------------------
/// Lazily-spawned clients keyed by (server binary, workspace root). A spawn
/// failure is cached as `None` so a missing binary logs once, not per file.
pub(crate) struct LspRegistry {
clients: HashMap<(String, PathBuf), Option<Rc<LspClient>>>,
diag_tx: smol::channel::Sender<(PathBuf, Vec<lsp_types::Diagnostic>)>,
}
impl LspRegistry {
pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self {
// Diagnostics loop: reader threads push (path, diags); this applies
// them to the matching open editor on the UI thread.
let (tx, rx) = smol::channel::unbounded::<(PathBuf, Vec<lsp_types::Diagnostic>)>();
cx.spawn_in(window, async move |app, cx| {
while let Ok((path, diags)) = rx.recv().await {
let ok = app.update_in(cx, |app, window, cx| {
app.editor_apply_diagnostics(&path, diags, window, cx);
});
if ok.is_err() {
break;
}
}
})
.detach();
Self {
clients: HashMap::new(),
diag_tx: tx,
}
}
/// The client for a language at a workspace root, spawning on first use.
/// Returns the client plus the LSP `languageId` for didOpen.
pub(crate) fn client_for(
&mut self,
language: &str,
root: &Path,
) -> Option<(Rc<LspClient>, &'static str)> {
let (cmd, language_id) = server_for_language(language)?;
let key = (cmd[0].to_string(), root.to_path_buf());
let slot = self.clients.entry(key).or_insert_with(|| {
match LspClient::spawn(cmd, root, self.diag_tx.clone()) {
Ok(client) => Some(Rc::new(client)),
Err(e) => {
log::info!("lsp: {} unavailable: {e:#}", cmd[0]);
None
}
}
});
slot.clone().map(|c| (c, language_id))
}
}
// ---------------------------------------------------------------------------
// Per-file provider bridging to gpui-component's LSP traits.
// ---------------------------------------------------------------------------
/// The provider object installed on an open file's `InputState`.
pub(crate) struct FileLsp {
pub(crate) client: Rc<LspClient>,
pub(crate) path: PathBuf,
}
/// Await one LSP response with a timeout, off the UI thread.
async fn recv_with_timeout(rx: smol::channel::Receiver<Value>) -> Result<Value> {
let timeout = async {
smol::Timer::after(REQUEST_TIMEOUT).await;
Err(anyhow!("lsp request timed out"))
};
let recv = async { rx.recv().await.map_err(|_| anyhow!("lsp server gone")) };
smol::future::or(recv, timeout).await
}
impl CompletionProvider for FileLsp {
fn completions(
&self,
text: &Rope,
offset: usize,
trigger: lsp_types::CompletionContext,
_window: &mut Window,
cx: &mut Context<InputState>,
) -> Task<Result<lsp_types::CompletionResponse>> {
let Some(mut params) = LspClient::position_params(&self.path, text, offset) else {
return Task::ready(Err(anyhow!("bad path")));
};
params["context"] = serde_json::to_value(&trigger).unwrap_or(Value::Null);
let rx = self.client.request("textDocument/completion", params);
cx.background_executor().spawn(async move {
let v = recv_with_timeout(rx).await?;
if v.is_null() {
return Ok(lsp_types::CompletionResponse::Array(vec![]));
}
Ok(serde_json::from_value(v)?)
})
}
fn is_completion_trigger(
&self,
_offset: usize,
new_text: &str,
_cx: &mut Context<InputState>,
) -> bool {
new_text
.chars()
.last()
.is_some_and(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | ':'))
}
}
impl HoverProvider for FileLsp {
fn hover(
&self,
text: &Rope,
offset: usize,
_window: &mut Window,
cx: &mut App,
) -> Task<Result<Option<lsp_types::Hover>>> {
let Some(params) = LspClient::position_params(&self.path, text, offset) else {
return Task::ready(Ok(None));
};
let rx = self.client.request("textDocument/hover", params);
cx.background_executor().spawn(async move {
let v = recv_with_timeout(rx).await?;
if v.is_null() {
return Ok(None);
}
Ok(serde_json::from_value(v).ok())
})
}
}
impl DefinitionProvider for FileLsp {
fn definitions(
&self,
text: &Rope,
offset: usize,
_window: &mut Window,
cx: &mut App,
) -> Task<Result<Vec<lsp_types::LocationLink>>> {
let Some(params) = LspClient::position_params(&self.path, text, offset) else {
return Task::ready(Ok(vec![]));
};
let Some(this_uri) = uri_for_path(&self.path) else {
return Task::ready(Ok(vec![]));
};
let rx = self.client.request("textDocument/definition", params);
cx.background_executor().spawn(async move {
let v = recv_with_timeout(rx).await?;
let links = normalize_definitions(v);
// The component's ⌘-click jump applies target offsets to the
// *current* buffer, so only same-file links are safe to hand it;
// cross-file jumps go through the F12 action instead.
Ok(links
.into_iter()
.filter(|l| l.target_uri.as_str() == this_uri)
.collect())
})
}
}
/// `textDocument/definition` may answer `Location`, `Location[]` or
/// `LocationLink[]`; normalize all three to links.
pub(crate) fn normalize_definitions(v: Value) -> Vec<lsp_types::LocationLink> {
if v.is_null() {
return vec![];
}
if let Ok(links) = serde_json::from_value::<Vec<lsp_types::LocationLink>>(v.clone()) {
return links;
}
let to_link = |loc: lsp_types::Location| lsp_types::LocationLink {
origin_selection_range: None,
target_uri: loc.uri,
target_range: loc.range,
target_selection_range: loc.range,
};
if let Ok(locs) = serde_json::from_value::<Vec<lsp_types::Location>>(v.clone()) {
return locs.into_iter().map(to_link).collect();
}
if let Ok(loc) = serde_json::from_value::<lsp_types::Location>(v) {
return vec![to_link(loc)];
}
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uri_round_trips_paths_with_spaces() {
// `Url::from_file_path` requires an *absolute* path in the host's own
// shape, so a POSIX literal here would simply fail to convert on
// Windows rather than exercise the percent-encoding under test.
let p = if cfg!(windows) {
Path::new(r"C:\tmp\a dir\file.rs")
} else {
Path::new("/tmp/a dir/file.rs")
};
let uri = uri_for_path(p).unwrap();
assert!(uri.starts_with("file:///"));
assert!(uri.contains("a%20dir"));
assert_eq!(path_for_uri(&uri), Some(p.to_path_buf()));
}
#[test]
fn definition_responses_normalize_all_three_shapes() {
let loc = json!({ "uri": "file:///a.rs", "range": {
"start": { "line": 1, "character": 2 },
"end": { "line": 1, "character": 5 } } });
// Single Location.
assert_eq!(normalize_definitions(loc.clone()).len(), 1);
// Location[].
assert_eq!(
normalize_definitions(json!([loc.clone(), loc.clone()])).len(),
2
);
// LocationLink[].
let link = json!([{ "targetUri": "file:///b.rs",
"targetRange": loc["range"], "targetSelectionRange": loc["range"] }]);
let links = normalize_definitions(link);
assert_eq!(links.len(), 1);
assert_eq!(links[0].target_uri.as_str(), "file:///b.rs");
// Null.
assert!(normalize_definitions(Value::Null).is_empty());
}
#[test]
fn server_map_covers_the_big_five() {
for lang in ["rust", "go", "python", "typescript", "cpp"] {
assert!(server_for_language(lang).is_some(), "{lang}");
}
assert!(server_for_language("markdown").is_none());
}
}
+4
View File
@@ -7,16 +7,20 @@
pub mod app;
pub mod assets;
pub mod code_editor;
pub mod diff_overlay;
pub mod file_tree;
pub mod forwards;
pub mod hints;
pub mod home;
pub mod keymap;
pub mod lsp;
pub mod palette;
pub mod pane;
pub mod perf;
pub mod presets;
pub mod reorder;
pub mod right_panel;
pub mod settings;
pub mod sftp;
pub mod ssh_connect;
+38
View File
@@ -50,6 +50,11 @@ pub enum CommandKind {
ToggleMaximizePane,
ToggleFullscreen,
ToggleTabSidebar,
ToggleLeftPanel,
ToggleRightPanel,
/// Switch the right panel to a specific tab, opening it if it was closed —
/// so the palette can land you on Changes without a toggle-then-click.
ShowRightPanel(crate::core::config::RightPanelTab),
ClearTerminal,
FindInTerminal,
ReopenClosedTab,
@@ -57,6 +62,8 @@ pub enum CommandKind {
RestartDaemon,
/// Toggle the SFTP file panel for the focused native-SSH pane (WS5).
ToggleSftp,
/// Toggle the code panel (file tree + editor overlay over the terminal).
ToggleCodePanel,
/// Reconnect a dead native-SSH pane in place (WS6, FR-E4).
RestartSshSession,
/// Send the focused pane's selection to a running CLI coding agent's pane
@@ -130,11 +137,23 @@ impl CommandKind {
ToggleMaximizePane => "ToggleMaximizePane",
ToggleFullscreen => "ToggleFullscreen",
ToggleTabSidebar => "ToggleTabSidebar",
ToggleLeftPanel => "ToggleLeftPanel",
ToggleRightPanel => "ToggleRightPanel",
ShowRightPanel(tab) => {
use crate::core::config::RightPanelTab as T;
match tab {
T::Info => "ShowRightPanelInfo",
T::Outline => "ShowRightPanelOutline",
T::Changes => "ShowRightPanelChanges",
T::Files => "ShowRightPanelFiles",
}
}
ClearTerminal => "ClearScrollback",
ReopenClosedTab => "ReopenClosedTab",
OpenSettings => "OpenSettings",
RestartDaemon => "RestartDaemon",
ToggleSftp => "ToggleSftp",
ToggleCodePanel => "ToggleCodePanel",
RestartSshSession => "RestartSshSession",
SendSelectionToAgent
| SendGitDiffToAgent
@@ -210,6 +229,24 @@ impl Command {
Command::new("Toggle Maximize Pane", ToggleMaximizePane),
Command::new("Toggle Fullscreen", ToggleFullscreen),
Command::new("Toggle Tab Sidebar", ToggleTabSidebar),
Command::new("Toggle Left Sidebar", ToggleLeftPanel),
Command::new("Toggle Right Panel", ToggleRightPanel),
Command::new(
"Right Panel: Info",
ShowRightPanel(crate::core::config::RightPanelTab::Info),
),
Command::new(
"Right Panel: Outline",
ShowRightPanel(crate::core::config::RightPanelTab::Outline),
),
Command::new(
"Right Panel: Changes",
ShowRightPanel(crate::core::config::RightPanelTab::Changes),
),
Command::new(
"Right Panel: Files",
ShowRightPanel(crate::core::config::RightPanelTab::Files),
),
Command::new("Clear", ClearTerminal),
Command::new("Find in Terminal…", FindInTerminal),
Command::new("Reopen Closed Tab", ReopenClosedTab),
@@ -217,6 +254,7 @@ impl Command {
Command::new("SSH: Manage Profiles…", OpenSshProfiles),
Command::new("Reconnect SSH Session", RestartSshSession),
Command::new("SFTP Panel", ToggleSftp),
Command::new("Code Panel", ToggleCodePanel),
Command::new("Change Theme…", OpenThemePicker),
Command::new("Open Settings", OpenSettings),
Command::new("Reset Font Size", ResetFontSize),
File diff suppressed because it is too large Load Diff
+61 -28
View File
@@ -101,7 +101,12 @@ impl Tty7App {
.flex_1()
.min_h_0()
.overflow_y_scroll()
.p_1p5()
// 4px horizontal, so a row's own `pl_2` puts its content on the rail's
// 12px content inset — the same line the search row and the top
// controls use. The 8px the capsule stops short of the rail edge is
// what makes the active row read as inset rather than full-bleed.
.px_1()
.py_1p5()
// Tight row-to-row spacing so the tabs read as one dense list, not a
// set of far-apart cards (each row already has its own padding).
.gap_0p5();
@@ -390,7 +395,7 @@ impl Tty7App {
.justify_between()
.gap_2()
.pl_2()
.pr_1p5()
.pr_2()
.rounded_lg()
// Sidebar-surface token scheme (gpui-component's Sidebar
// semantics), so the rows sit cohesively on the sunk rail rather
@@ -735,20 +740,47 @@ impl Tty7App {
});
}
// Top control bar: a right-aligned "+" new-tab button (the same shell
// picker the strip uses), with new-tab at the top of the rail rather than
// in a bottom button. A hairline under it separates the control row from
// the tab list.
let add_button = self.attach_new_tab_menu(
Button::new("sidebar-add")
.icon(Icon::new(IconName::Plus).size(px(15.)))
.ghost()
// The rail's own controls — new tab, and collapse — live in the top zone
// beside the traffic lights, right-aligned to the rail's content edge
// rather than sitting in the search row. Two reasons: the search row is
// for searching, and a collapse button that lives *inside* the rail would
// disappear along with it (its counterpart then appears in the title
// strip, see `tab_strip`). Right-aligned, they ride the rail's right edge,
// which is what says "these belong to this panel" when it's resized.
let controls = h_flex()
.flex_shrink_0()
.h(px(TITLE_BAR_HEIGHT))
.items_center()
.justify_end()
.gap(px(2.))
// Glyph, not hit box, on the content edge — see `TILE_PAD`.
.pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD))
.child(
self.attach_new_tab_menu(
Button::new("sidebar-add")
.icon(Icon::new(IconName::Plus).size(px(18.)))
.ghost()
.xsmall()
.w(px(32.))
.h(px(32.))
.rounded_lg(),
cx,
),
)
.child(
crate::ui::tab_strip::chrome_tile(
Button::new("sidebar-collapse")
.icon(Icon::empty().path("icons/panel-left.svg").size(px(18.))),
false,
cx,
)
.xsmall()
.w(px(30.))
.h(px(30.))
.rounded_lg(),
cx,
);
.w(px(32.))
.h(px(32.))
.rounded_lg()
.tooltip("Hide Sidebar")
.on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))),
);
// Borderless "Search tabs…" that sits directly on the sunk surface: a
// leading magnifier + an appearance-less input, no box and no divider
// under the bar, so the control row and list read as one continuous rail
@@ -758,7 +790,7 @@ impl Tty7App {
.items_center()
.gap_1()
.h(px(44.))
.px_3()
.px(px(crate::ui::app::CONTENT_INSET))
.child(
Icon::new(IconName::Search)
.small()
@@ -769,8 +801,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.child(Input::new(&self.sidebar_search).appearance(false)),
)
.child(add_button);
);
// ── Resize drag (mirrors the split divider in `pane.rs`) ──────────────
// A backing canvas measures the rail's bounds into a per-frame cell and,
@@ -883,22 +914,24 @@ impl Tty7App {
// A title-bar-height top zone: on macOS the traffic lights
// sit on the rail's surface here, and it aligns the search box
// with the terminal's top (which starts below the title bar),
// so the rail reads as one panel from the very top edge.
// so the rail reads as one panel from the very top edge. The
// rail's controls ride its right end, on the title bar's own
// center line — same row as the "⋯" across the window.
//
// The real `TitleBar` — which carries the window's drag region
// — only spans the *right* column in this layout, so this strip
// would be dead space you can't grab the window by. Make it act
// like the title bar it sits level with: drag to move,
// double-click to zoom. Driven exactly like `TitleBar` does it
// (and the settings overlay's stand-in strip): a press arms a
// flag and the first *move* starts the window move, so a plain
// click — and a double-click — still lands intact.
// would be dead space you can't grab the window by. Make the
// controls' own row act like the title bar it sits level with:
// drag to move, double-click to zoom. Driven exactly like
// `TitleBar` does it (and the settings overlay's stand-in
// strip): a press arms a flag and the first *move* starts the
// window move, so a plain click — and a double-click — still
// lands intact, while the buttons on the right keep taking
// their own clicks.
.child({
let should_move = Rc::new(Cell::new(false));
div()
controls
.id("sidebar-titlebar-drag")
.h(px(TITLE_BAR_HEIGHT))
.flex_shrink_0()
.window_control_area(WindowControlArea::Drag)
.on_mouse_down(MouseButton::Left, {
let should_move = should_move.clone();
+226 -53
View File
@@ -5,19 +5,19 @@
//! orchestration rather than chrome rendering.
use gpui::{
Animation, AnimationExt as _, App, Axis, Bounds, Context, FontWeight, MouseButton,
Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, FontWeight, MouseButton,
MouseDownEvent, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint,
linear_color_stop, linear_gradient, prelude::*, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex};
use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex};
use std::cell::RefCell;
use std::rc::Rc;
use crate::core::actions::{OpenSettings, TogglePalette};
use crate::core::config::Config;
use crate::core::config::{Config, RightPanelTab};
use crate::daemon::protocol::ShellSpec;
use crate::ui::app::{Tab, Tty7App};
use crate::ui::hints::tab_badge_label;
@@ -150,7 +150,165 @@ impl Render for DragTab {
}
}
/// The shared styling for every icon tile in the window's chrome — title bar,
/// rail controls, detail-panel tabs, the editor's close button.
///
/// `ghost()` can't be used: its hover is `secondary_hover` and its selected state
/// `secondary_active`, both solid mid-greys that read far heavier than anything
/// else here. So this spells out all four states in the tab rail's language —
/// nothing at rest, a soft grey capsule on hover, the same grey opaque when
/// selected — which is the same "inset soft-grey capsule" the sidebar rows and
/// the popups use. (Overriding just the hover from outside doesn't work: `Button`
/// applies its own `.hover()` during render, after any the caller set.)
pub(crate) fn chrome_tile_variant(cx: &gpui::App) -> ButtonCustomVariant {
let accent = cx.theme().sidebar_accent;
ButtonCustomVariant::new(cx)
.color(cx.theme().transparent)
// Full `foreground`, not the softer `secondary_foreground`: the chrome
// glyphs read as deliberate controls rather than faint hints — the
// "commercial-app" weight, paired with the filled dock icons below.
.foreground(cx.theme().foreground)
.hover(accent.opacity(0.55))
.active(accent)
}
pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> Button {
button.custom(chrome_tile_variant(cx)).selected(selected)
}
impl Tty7App {
/// The window's right-corner chrome: the detail-panel toggle and the overflow
/// "⋯". Built here rather than inline because it has two hosts — the title
/// strip while the panel is closed, and the panel's own top zone while it's
/// open, since whichever of the two reaches the window's right edge should
/// carry it. (Same arrangement as the rail: its controls sit on the rail when
/// it's out, and move into the strip when it's collapsed.)
pub(crate) fn window_chrome(
&self,
window: &Window,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let panel_open = self.right_panel_open(cx);
// `.menu(label, Action)` dispatches the real action, so a click and the
// shortcut travel one path and the row auto-renders the shortcut hint; it
// needs an `action_context` inside the app's element tree to land on the
// root `on_action` handlers, so we hand it the focused pane (falling back
// to the home page's handle when no tab is open).
let action_ctx = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.map(|leaf| leaf.read(cx).focus_handle.clone())
.unwrap_or_else(|| self.home_focus.clone());
h_flex()
.flex_shrink_0()
.items_center()
.gap(px(2.))
// The "⋯" glyph ends on the window's content inset like every other
// right edge in the chrome — hence `inset - TILE_PAD`, which puts the
// *glyph* there instead of its 30px hit box.
.pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD))
// On Windows/Linux the window controls (─ ▢ ✕) sit on the right, right
// where the "⋯" lands; give it extra breathing room there so it reads
// as a menu affordance, not a fourth window control.
.when(!cfg!(target_os = "macos"), |this| this.pr_3())
.child(
div().occlude().flex_shrink_0().child(
chrome_tile(
Button::new("titlebar-right-panel")
.icon(Icon::empty().path("icons/panel-right.svg").size(px(18.))),
panel_open,
cx,
)
.xsmall()
.w(px(32.))
.h(px(32.))
.rounded_lg()
.tooltip("Detail Panel")
.on_click(cx.listener(|this, _, _window, cx| {
this.toggle_right_panel(cx);
})),
),
)
.child(
div().occlude().flex_shrink_0().child(
chrome_tile(
Button::new("titlebar-menu")
.icon(Icon::new(IconName::Ellipsis).size(px(18.))),
false,
cx,
)
.xsmall()
.w(px(32.))
.h(px(32.))
.rounded_lg()
.dropdown_menu_with_anchor(
gpui::Anchor::TopRight,
move |menu, _window, _cx| {
menu.min_w(px(220.))
.action_context(action_ctx.clone())
.menu("Command Palette", Box::new(TogglePalette))
.menu("Settings…", Box::new(OpenSettings))
},
),
),
)
}
/// The detail panel's tab tiles — icon-only, one per view. Lives here beside
/// the rest of the chrome tiles so all of them share one styling helper.
pub(crate) fn right_panel_tabs(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let active_tab = cx.global::<Config>().right_panel_tab;
[
(
RightPanelTab::Info,
Icon::empty().path("icons/info.svg"),
"Info",
),
(
RightPanelTab::Outline,
Icon::empty().path("icons/list.svg"),
"Outline",
),
// git-branch (from tty7's own assets) instead of the abstract
// `Replace` glyph: Changes is a working-tree diff, and the branch
// mark reads as version control at a glance — matching the mockup.
(
RightPanelTab::Changes,
Icon::empty().path("icons/git-branch.svg"),
"Changes",
),
(
RightPanelTab::Files,
Icon::new(IconName::FolderClosed),
"Files",
),
]
.into_iter()
.map(|(tab, icon, label)| {
div()
.occlude()
.flex_shrink_0()
.child(
chrome_tile(
Button::new(("right-panel-tab", tab as usize)).icon(icon.size(px(18.))),
active_tab == tab,
cx,
)
.xsmall()
.w(px(32.))
.h(px(32.))
.rounded_lg()
.tooltip(label)
.on_click(cx.listener(move |this, _, _window, cx| {
this.set_right_panel_tab(tab, cx);
})),
)
.into_any_element()
})
.collect()
}
/// The status dot pinned to a tab avatar's bottom-right corner (an agent's
/// live status, or an SSH pane's connection phase): a solid
/// `rgb` disc with a surface-colored separator ring so it reads as sitting
@@ -922,51 +1080,74 @@ impl Tty7App {
// area doesn't swallow the click.
div().occlude().flex_shrink_0().child(
self.attach_new_tab_menu(
Button::new("tab-add")
.icon(Icon::new(IconName::Plus).size(px(15.)))
.ghost()
chrome_tile(
Button::new("tab-add")
.icon(Icon::new(IconName::Plus).size(px(18.))),
false,
cx,
)
.xsmall()
.w(px(30.))
.h(px(30.))
.w(px(32.))
.h(px(32.))
.rounded_lg(),
cx,
),
);
// Right-edge overflow menu: the low-frequency *global* entries (command
// palette, settings) that until now had no on-screen affordance at all —
// only keyboard shortcuts. Same ghost 30px tile as the "+", but anchored
// to the title bar's otherwise-empty right edge and opening from its
// top-right corner so the popup never spills off-screen.
//
// `.menu(label, Action)` dispatches the real action, so a click and the
// shortcut travel one path and the row auto-renders the shortcut hint; it
// needs an `action_context` inside the app's element tree to land on the
// root `on_action` handlers, so we hand it the focused pane (falling back
// to the home page's handle when no tab is open).
// (Settings is a full-window overlay now, so it simply covers this menu
// while open — no need to conditionally hide it.)
let action_ctx = self
.tabs
.get(active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.map(|leaf| leaf.read(cx).focus_handle.clone())
.unwrap_or_else(|| self.home_focus.clone());
let menu_button = div().occlude().flex_shrink_0().child(
Button::new("titlebar-menu")
.icon(Icon::new(IconName::Ellipsis).size(px(15.)))
.ghost()
.xsmall()
.w(px(30.))
.h(px(30.))
.rounded_lg()
.dropdown_menu_with_anchor(gpui::Anchor::TopRight, move |menu, _window, _cx| {
menu.min_w(px(220.))
.action_context(action_ctx.clone())
.menu("Command Palette", Box::new(TogglePalette))
.menu("Settings…", Box::new(OpenSettings))
}),
);
// Sidebar mode with the rail collapsed: the rail's own controls move here
// rather than vanishing with it, so collapsing is never a one-way door.
// They keep the rail's order and spacing and just re-anchor from the rail's
// right edge to the window's left one, landing beside the traffic lights.
let rail_collapsed = !show_chips && !self.left_panel_open(cx);
let left_group = rail_collapsed.then(|| {
h_flex()
.flex_shrink_0()
.items_center()
.gap(px(2.))
// Negative off macOS only: the bar already inset us past the window
// controls, and there the reserve *is* the clearance.
.ml(px(crate::ui::app::title_bar_hug_offset()))
.child(
div().occlude().flex_shrink_0().child(
self.attach_new_tab_menu(
chrome_tile(
Button::new("titlebar-add-collapsed")
.icon(Icon::new(IconName::Plus).size(px(18.))),
false,
cx,
)
.xsmall()
.w(px(32.))
.h(px(32.))
.rounded_lg(),
cx,
),
),
)
.child(
div().occlude().flex_shrink_0().child(
chrome_tile(
Button::new("titlebar-expand-sidebar")
.icon(Icon::empty().path("icons/panel-left.svg").size(px(18.))),
false,
cx,
)
.xsmall()
.w(px(32.))
.h(px(32.))
.rounded_lg()
.tooltip("Show Sidebar")
.on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))),
),
)
});
let panel_open = self.right_panel_open(cx);
// The window's right-corner chrome. When the panel is open it lives on the
// *panel's* top zone (the panel is what reaches the window's right edge
// then) exactly like the rail's controls live on the rail; the strip only
// carries it while the panel is closed.
let right_chrome = (!panel_open).then(|| self.window_chrome(window, cx));
// Outer strip: the clipping chip row and the always-visible "+" anchored
// left, the overflow "⋯" pushed to the right edge by a flexible spacer.
@@ -989,23 +1170,15 @@ impl Tty7App {
// — the original tight inset, which now holds steady on resize since
// `strip_w` keeps the right edge tracking the window.
.pl_0()
.pr_2()
// On Windows/Linux the window controls (─ ▢ ✕) sit on the right, right
// where the "⋯" lands, so its inset has to match *their* rhythm: the
// 34px tiles put consecutive glyph centres 34px apart, and the "⋯"
// centre sits `15 + pr + 17` from the minimise glyph. `pr_1` (4px)
// lands at 36px — reading as part of the same row, with just enough
// slack to not be mistaken for a fourth window control. The old
// `pr_3` (12px) made it 44px, visibly adrift from the group.
.when(!cfg!(target_os = "macos"), |this| this.pr_1())
.min_w_0()
.when_some(left_group, |this, g| this.child(g))
.child(chips)
// In sidebar mode the rail owns "New Tab" (a "+" in its own top bar),
// so the title bar drops its "+" to avoid a redundant second one —
// leaving just the "⋯" overflow menu on a thin strip.
.when(show_chips, move |this| this.child(add_button))
.child(div().flex_1())
.child(menu_button)
.when_some(right_chrome, |this, chrome| this.child(chrome))
}
}