diff --git a/Cargo.lock b/Cargo.lock index 69c3f731..026be7fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 5b1d5cb3..34cd6409 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/assets/icons/info.svg b/assets/icons/info.svg new file mode 100644 index 00000000..ec30e25e --- /dev/null +++ b/assets/icons/info.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/list.svg b/assets/icons/list.svg new file mode 100644 index 00000000..981d0c33 --- /dev/null +++ b/assets/icons/list.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-left.svg b/assets/icons/panel-left.svg new file mode 100644 index 00000000..2d61872f --- /dev/null +++ b/assets/icons/panel-left.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-right.svg b/assets/icons/panel-right.svg new file mode 100644 index 00000000..a975c9ec --- /dev/null +++ b/assets/icons/panel-right.svg @@ -0,0 +1 @@ + diff --git a/src/core/actions.rs b/src/core/actions.rs index dd338417..724a42ab 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -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). diff --git a/src/core/config.rs b/src/core/config.rs index 1d63a1b8..897610fa 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -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 { diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 01810c97..c83f7ac3 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -23,6 +23,7 @@ pub mod pane; pub mod pidfile; +pub mod procinfo; pub mod protocol; pub(crate) mod remote; pub mod server; diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 23e50b15..161aee9f 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -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`. diff --git a/src/daemon/procinfo.rs b/src/daemon/procinfo.rs new file mode 100644 index 00000000..71635eff --- /dev/null +++ b/src/daemon/procinfo.rs @@ -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) -> 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, shell_pid: u32, fg_pgid: Option) -> Vec { + // 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> = 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 { + 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::()) + 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::()) as libc::c_int, + ) + }; + if written <= 0 { + return table; + } + let n = written as usize / std::mem::size_of::(); + 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::() 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 = buf + .iter() + .take_while(|c| **c != 0) + .map(|c| *c as u8) + .collect(); + String::from_utf8_lossy(&bytes).into_owned() +} + +/// Linux: `/proc//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 { + 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::().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::(), pgid.parse::()) 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 { + 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 { + HashMap::new() +} + +/// Executable basename of `pid` (macOS). +#[cfg(target_os = "macos")] +fn proc_name(pid: i32) -> Option { + 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//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 { + 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 { + use std::process::{Command, Stdio}; + + if procs.is_empty() { + return Vec::new(); + } + let pid_list = procs + .iter() + .map(|p| p.pid.to_string()) + .collect::>() + .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 = procs.iter().map(|p| (p.pid, p.name.as_str())).collect(); + let mut ports: Vec = 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(¤t) + .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 { + 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 { + 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 = [ + (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 = [(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 = [(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); + } +} diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index aa533ee8..f6f3707f 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -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, + /// Ascending by port; deduped, since one listener can bind several addresses. + pub ports: Vec, +} + 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), + /// 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 16–19, WS4 20–24, SFTP 30–36). 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 => { diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 96749f5b..d58d5795 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -509,6 +509,16 @@ fn handle_conn(stream: Stream, registry: Arc) -> 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); diff --git a/src/terminal/marks.rs b/src/terminal/marks.rs new file mode 100644 index 00000000..9508a632 --- /dev/null +++ b/src/terminal/marks.rs @@ -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, + /// 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>>); + +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) { + 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 { + 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 { + 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;` — 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), +} + +/// 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, +} + +#[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 { + 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"); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index f2294ce3..94bda0fe 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -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; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index d2a8627f..4c83f628 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -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>>, + /// 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>>, + /// 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>, } @@ -333,6 +341,7 @@ impl RemoteTerminal { let auth_prompts: Arc>> = Arc::new(Mutex::new(VecDeque::new())); let ssh_phase: Arc>> = 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 { + 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, 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 diff --git a/src/terminal/view.rs b/src/terminal/view.rs index c9e953c0..519e5f3d 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2490,6 +2490,10 @@ impl TerminalView { pub fn clear_scrollback(&mut self, cx: &mut Context) { 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 { + 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) -> 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 diff --git a/src/ui/app.rs b/src/ui/app.rs index 66ec2dd4..6e7212c6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, + /// 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>, /// 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>, + /// 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> { + 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>, /// Whether the sidebar's resize handle is currently held. pub(crate) sidebar_dragging: Rc>, + /// 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>, + pub(crate) right_panel_dragging: Rc>, /// 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, + /// 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, /// 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::().sidebar_width; + let right_panel_width = cx.global::().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::(|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) { + let cfg = cx.global::(); + 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::().tab_bar_position, TabBarPosition::Left) + && !cx.global::().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> { + pub(crate) fn agent_target_leaf(&self, cx: &App) -> Option> { let runs_agent = |leaf: &Entity| 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::().sidebar_width); + self.right_panel_width + .set(cx.global::().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::().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::().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 = { + 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, window: &mut Window, cx: &mut Context) { + 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) } diff --git a/src/ui/assets.rs b/src/ui/assets.rs index ef3f0f1c..af8c1cd9 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -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"), diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs new file mode 100644 index 00000000..f7ab91ed --- /dev/null +++ b/src/ui/code_editor.rs @@ -0,0 +1,1356 @@ +//! The code panel: a full-body overlay of `[file tree | editor]` that covers +//! the terminal, settings-overlay style. +//! +//! A lightweight "look at / touch up code without leaving the terminal" +//! editor, not a full IDE. The text engine is `gpui_component::input:: +//! InputState` in CodeEditor mode, which brings rope storage, tree-sitter +//! syntax highlighting, line numbers, indent guides, code folding, +//! auto-indent, undo/redo and an in-buffer search/replace bar. This module +//! owns everything around that engine: the open-file set and tab strip, dirty +//! tracking and save, external-modification reload (via `notify`), the +//! unsaved-close confirmation, and the overlay chrome itself (the file-tree +//! column comes from `ui::file_tree`). +//! +//! Layout: overlaying the body (like Settings and the diff overlay) rather +//! than docking a side column means toggling never resizes the terminal — no +//! PTY resize, no reflow — and the editor gets the full body width. The tab +//! sidebar stays visible; switching tabs re-roots the tree. One entry point: +//! the title-bar tile in `tab_strip` (`ToggleCodePanel`, ⌘⇧E; Esc closes). + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use gpui::prelude::*; +use gpui::{ + AnyElement, Context, Entity, Focusable as _, MouseButton, PromptLevel, SharedString, + Subscription, Window, div, px, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{Input, InputEvent, InputState, TabSize}; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex, +}; + +use crate::ui::app::Tty7App; + +/// Refuse to open files larger than this: the component's code editor is rated +/// to ~50K lines, and a multi-megabyte blob is almost never what a terminal +/// user meant to open in a side panel. +const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; + +/// Debounce for external-change reloads, matching the config hot-reload: a +/// save is often a truncate→write→rename burst that should collapse to one. +const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); + +/// One open file: the component editor state plus the bookkeeping that turns +/// it into a *file* editor (path, dirty flag, on-disk snapshot identity). +pub(crate) struct OpenFile { + pub(crate) path: PathBuf, + pub(crate) input: Entity, + /// The buffer has edits not yet written to `path`. + pub(crate) dirty: bool, + /// mtime of the content we last loaded from / saved to disk; used to drop + /// watcher echoes of our own saves. + disk_mtime: Option, + /// Disk changed under unsaved edits: show the reload/keep banner instead + /// of silently clobbering either side. + pub(crate) conflict: bool, + /// Markdown files can flip the buffer into a rendered preview. + pub(crate) preview: bool, + /// Soft-wrap state (mirrored here — the input's own flag isn't readable). + pub(crate) wrap: bool, + /// The language server serving this file (spawned per workspace root), + /// with the LSP `languageId` used for document sync. `None` when no + /// server is configured/available for the language. + lsp: Option<(std::rc::Rc, &'static str)>, + /// Debounced full-document `didChange`; replaced (cancelling the old + /// timer) on every keystroke. + change_task: Option>, + _sub: Subscription, + /// Repaints the app when the input notifies (cursor moves, scrolls…) so + /// the status bar's Ln/Col stays live. + _observe: Subscription, +} + +impl OpenFile { + /// Tab label: the file name (the path differentiates in the tooltip). + fn label(&self) -> SharedString { + self.path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.path.display().to_string()) + .into() + } +} + +/// Per-tab code-panel state, hung on [`Tab::code`](crate::ui::app::Tab) with +/// the same lifecycle contract as the diff overlay: only the active tab's +/// panel renders, switching away hides it, closing the tab drops it. The +/// shared caches (directory listings, gitignore matchers, language servers, +/// filesystem watchers) live on [`Tty7App`] — this holds only what is truly +/// this tab's: its open files and its tree view state. +pub(crate) struct TabCode { + /// Whether the overlay is currently shown for this tab. The open-file set + /// survives hiding (Esc) — only closing the tab drops it. + pub(crate) visible: bool, + pub(crate) files: Vec, + pub(crate) active: usize, + /// Find-references results, shown as a drawer under the editor. + pub(crate) references: Option>, + /// File-tree roots: this tab's pane cwds resolved to repo roots. + pub(crate) roots: Vec, + pub(crate) expanded: std::collections::HashSet, + pub(crate) selected: Option, +} + +impl TabCode { + pub(crate) fn new() -> Self { + Self { + // Born hidden. This state used to be created only by opening the + // overlay, so defaulting to visible was harmless; now the right + // panel's Files tab creates it just to hold the tree's roots and + // expansion, and a default of `true` popped an empty editor open + // ("No file open") the moment you looked at the tree. Every path + // that actually wants the overlay sets `visible` itself. + visible: false, + files: Vec::new(), + active: 0, + references: None, + roots: Vec::new(), + expanded: std::collections::HashSet::new(), + selected: None, + } + } + + pub(crate) fn active_file(&self) -> Option<&OpenFile> { + self.files.get(self.active) + } +} + +/// App-global editor infrastructure shared by every tab's panel. +pub(crate) struct EditorPanelState { + /// Watches the parent directories of open files (across all tabs) for + /// external changes. Rebuilt whenever any open set changes; `None` while + /// nothing is open anywhere. + watcher: Option, + /// Feeds changed paths from the watcher thread into the UI-side reload + /// loop spawned in [`EditorPanelState::new`]. + events_tx: smol::channel::Sender, + /// Language-server registry (one client per server × workspace root). + pub(crate) lsp: crate::ui::lsp::LspRegistry, +} + +/// One row in the find-references drawer. +pub(crate) struct ReferenceItem { + pub path: PathBuf, + /// 0-based target position. + pub line: u32, + pub character: u32, + /// The referenced line's text, for the row preview. + pub preview: SharedString, +} + +impl EditorPanelState { + pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { + // The reload loop lives for the app: it debounces watcher pings and + // routes them to `handle_external_change` on the UI thread. + let (tx, rx) = smol::channel::unbounded::(); + cx.spawn_in(window, async move |app, cx| { + while let Ok(first) = rx.recv().await { + cx.background_executor().timer(RELOAD_DEBOUNCE).await; + let mut changed: HashSet = HashSet::from([first]); + while let Ok(more) = rx.try_recv() { + changed.insert(more); + } + let ok = app.update_in(cx, |app, window, cx| { + for path in changed { + app.editor_handle_external_change(&path, window, cx); + } + }); + if ok.is_err() { + break; // app dropped; stop the loop + } + } + }) + .detach(); + Self { + watcher: None, + events_tx: tx, + lsp: crate::ui::lsp::LspRegistry::new(window, cx), + } + } +} + +// --------------------------------------------------------------------------- +// Pure helpers (tested). +// --------------------------------------------------------------------------- + +/// The tree-sitter language name for a path, matching the grammars compiled +/// into gpui-component's `tree-sitter-languages` feature. Falls back to +/// `"text"` (plain, no highlighting) for anything unknown. +pub(crate) fn language_for_path(path: &Path) -> &'static str { + // Whole-filename matches first (no useful extension). + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + let lowered = name.to_ascii_lowercase(); + match lowered.as_str() { + "makefile" | "gnumakefile" => return "make", + "cmakelists.txt" => return "cmake", + _ => {} + } + // Dotfile shell rc's: .zshrc, .bashrc, .profile… + if lowered.starts_with('.') && (lowered.contains("shrc") || lowered.ends_with("profile")) { + return "bash"; + } + } + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + return "text"; + }; + match ext.to_ascii_lowercase().as_str() { + "rs" => "rust", + "go" => "go", + "py" | "pyi" => "python", + "js" | "mjs" | "cjs" | "jsx" => "javascript", + "ts" | "mts" | "cts" => "typescript", + "tsx" => "tsx", + "json" | "jsonc" => "json", + "toml" => "toml", + "yaml" | "yml" => "yaml", + "html" | "htm" => "html", + "css" => "css", + "md" | "markdown" => "markdown", + "sh" | "bash" | "zsh" => "bash", + "c" | "h" => "c", + "cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp", + "java" => "java", + "kt" | "kts" => "kotlin", + "lua" => "lua", + "rb" => "ruby", + "php" => "php", + "sql" => "sql", + "swift" => "swift", + "scala" => "scala", + "zig" => "zig", + "proto" => "proto", + "diff" | "patch" => "diff", + "ex" | "exs" => "elixir", + "erb" => "erb", + "ejs" => "ejs", + "svelte" => "svelte", + "astro" => "astro", + "graphql" | "gql" => "graphql", + "cs" => "csharp", + "cmake" => "cmake", + _ => "text", + } +} + +/// Quick binary sniff: a NUL byte in the head of the file. Text files never +/// contain NULs; this catches executables/images before `from_utf8` chokes on +/// them with a less helpful error. +fn looks_binary(bytes: &[u8]) -> bool { + bytes.iter().take(8192).any(|b| *b == 0) +} + +// --------------------------------------------------------------------------- +// Tty7App: open / save / close / external reload. +// --------------------------------------------------------------------------- + +impl Tty7App { + /// The active tab's code-panel state, if the panel was ever opened there. + pub(crate) fn tab_code(&self) -> Option<&TabCode> { + self.tabs.get(self.active)?.code.as_deref() + } + + pub(crate) fn tab_code_mut(&mut self) -> Option<&mut TabCode> { + self.tabs.get_mut(self.active)?.code.as_deref_mut() + } + + /// Like [`tab_code_mut`], but creates the state instead of returning `None`. + /// The panel state used to be born with the code overlay, so anything that + /// needed it could assume the overlay had been opened at least once — no + /// longer true now that the right panel's Files tab renders the same tree + /// without ever opening the overlay. + pub(crate) fn tab_code_mut_or_init(&mut self) -> Option<&mut TabCode> { + let tab = self.tabs.get_mut(self.active)?; + Some(tab.code.get_or_insert_with(|| Box::new(TabCode::new()))) + } + + /// Whether the active tab's code panel is currently shown. + pub(crate) fn code_panel_visible(&self) -> bool { + self.tab_code().is_some_and(|c| c.visible) + } + + /// Rebuild the external-change watcher over every tab's open files. + /// Watches each file's *parent directory* (non-recursively): editors that + /// save via rename replace the inode, which a direct file watch loses. + fn editor_rebuild_watcher(&mut self) { + use notify::{RecursiveMode, Watcher}; + self.editor.watcher = None; + let watched: HashSet = self + .tabs + .iter() + .filter_map(|t| t.code.as_deref()) + .flat_map(|c| c.files.iter().map(|f| f.path.clone())) + .collect(); + if watched.is_empty() { + return; + } + let dirs: HashSet = watched + .iter() + .filter_map(|p| p.parent().map(Path::to_path_buf)) + .collect(); + let tx = self.editor.events_tx.clone(); + let handler = move |res: notify::Result| { + let Ok(event) = res else { return }; + for p in &event.paths { + if watched.contains(p) { + let _ = tx.try_send(p.clone()); + } + } + }; + let mut watcher = match notify::recommended_watcher(handler) { + Ok(w) => w, + Err(e) => { + log::warn!("editor: external-change watcher unavailable: {e}"); + return; + } + }; + for dir in dirs { + if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) { + log::warn!("editor: failed to watch {}: {e}", dir.display()); + } + } + self.editor.watcher = Some(watcher); + } + + /// Open `path` in the active tab's editor (activating an existing file tab + /// when it is already open) and reveal the panel. Errors surface as window + /// notifications rather than a half-open tab. + pub(crate) fn open_file_in_editor( + &mut self, + path: &Path, + window: &mut Window, + cx: &mut Context, + ) { + if self.tabs.get(self.active).is_none() { + return; + } + // Opening a file is an act on the editor, so it comes forward — the + // file tree lives in the right panel and stays clickable even while the + // diff overlay covers the column. + self.raise_code_overlay(); + let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if let Some(code) = self.tab_code_mut() + && let Some(ix) = code.files.iter().position(|f| f.path == path) + { + code.visible = true; + // Activating always surfaces to the front of the strip: the strip + // is MRU-ordered and only its head fits on screen (see + // `render_editor_tabs`), so the active file must live there. + let f = code.files.remove(ix); + code.files.insert(0, f); + code.active = 0; + self.focus_editor(window, cx); + cx.notify(); + return; + } + match std::fs::metadata(&path) { + Ok(meta) if meta.len() > MAX_FILE_BYTES => { + window.push_notification( + format!( + "\"{}\" is too large for the editor ({} MB)", + path.display(), + meta.len() / (1024 * 1024) + ), + cx, + ); + return; + } + Err(e) => { + window.push_notification(format!("Can't open {}: {e}", path.display()), cx); + return; + } + Ok(_) => {} + } + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + window.push_notification(format!("Can't read {}: {e}", path.display()), cx); + return; + } + }; + if looks_binary(&bytes) { + window.push_notification( + format!("\"{}\" looks like a binary file", path.display()), + cx, + ); + return; + } + let text = match String::from_utf8(bytes) { + Ok(t) => t, + Err(_) => { + window.push_notification(format!("\"{}\" is not valid UTF-8", path.display()), cx); + return; + } + }; + let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); + let language = language_for_path(&path); + let input = cx.new(|cx| { + InputState::new(window, cx) + .code_editor(language) + .multi_line(true) + .tab_size(TabSize { + tab_size: 4, + hard_tabs: false, + }) + .line_number(true) + .searchable(true) + .replaceable(true) + .folding(true) + .soft_wrap(false) + .default_value(text.clone()) + }); + // Language server: spawn (or reuse) the server for this language at + // the file's workspace root, open the document, and install the + // completion / hover / definition providers on the input. + let root = crate::ui::file_tree::repo_root_for(&path) + .or_else(|| path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from("/")); + let lsp = self.editor.lsp.client_for(language, &root); + if let Some((client, language_id)) = &lsp { + client.did_open(&path, language_id, &text); + let provider = std::rc::Rc::new(crate::ui::lsp::FileLsp { + client: client.clone(), + path: path.clone(), + }); + input.update(cx, |st, _| { + st.lsp.completion_provider = Some(provider.clone()); + st.lsp.hover_provider = Some(provider.clone()); + st.lsp.definition_provider = Some(provider); + }); + } + // Dirty tracking: `set_value` suppresses events, so every Change here + // is a real user edit. Each edit also (re)arms the debounced LSP + // didChange sync. Files may be open in any tab, not just the active + // one, so the lookup scans all tabs. + let sub = cx.subscribe_in(&input, window, { + let path = path.clone(); + move |this: &mut Tty7App, _input, ev, window, cx| { + if matches!(ev, InputEvent::Change) { + let path = path.clone(); + let Some(f) = this + .tabs + .iter_mut() + .filter_map(|t| t.code.as_deref_mut()) + .flat_map(|c| c.files.iter_mut()) + .find(|f| f.path == path) + else { + return; + }; + if !f.dirty { + f.dirty = true; + } + if f.lsp.is_some() { + f.change_task = Some(cx.spawn_in(window, async move |app, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(150)) + .await; + let _ = app.update(cx, |app, cx| { + app.editor_sync_lsp_document(&path, cx); + }); + })); + } + cx.notify(); + } + } + }); + let tab = self + .tabs + .get_mut(self.active) + .expect("checked at function entry"); + let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new())); + let observe = cx.observe(&input, |_, _, cx| cx.notify()); + // New files join at the front of the MRU strip (always visible). + code.files.insert( + 0, + OpenFile { + path, + input, + dirty: false, + disk_mtime: mtime, + conflict: false, + preview: false, + wrap: false, + lsp, + change_task: None, + _sub: sub, + _observe: observe, + }, + ); + code.active = 0; + code.visible = true; + self.editor_rebuild_watcher(); + self.focus_editor(window, cx); + cx.notify(); + } + + /// `ToggleCodePanel` (⌘⇧E / the title-bar tree icon / Esc): flip the + /// active tab's code overlay. First open creates the tab's panel state; + /// hiding keeps it (open files survive Esc), and only closing the tab + /// drops it. Opening re-roots the file tree from the tab's panes and + /// focuses the panel; closing hands focus back to the terminal. + pub(crate) fn toggle_code_panel(&mut self, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get_mut(self.active) else { + return; + }; + // Buried under the diff overlay, this shortcut means "come forward" — + // hiding a panel the user can't see would look like it did nothing. + let buried = tab.overlay_top == crate::ui::app::OverlayTop::Diff + && tab.diff_overlay.is_some() + && tab.code.as_ref().is_some_and(|c| c.visible); + tab.overlay_top = crate::ui::app::OverlayTop::Code; + if buried { + self.focus_editor(window, cx); + cx.notify(); + return; + } + let Some(tab) = self.tabs.get_mut(self.active) else { + return; + }; + let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new())); + if code.visible { + code.visible = false; + self.file_tree.editing = None; + self.focus_active(window, cx); + cx.notify(); + return; + } + code.visible = true; + self.file_tree_refresh_roots(window, cx); + if self.tab_code().is_some_and(|c| c.active_file().is_some()) { + self.focus_editor(window, cx); + } else { + self.file_tree.focus_handle.focus(window, cx); + } + cx.notify(); + } + + /// Bring the code overlay in front of the diff overlay. See + /// [`Tab::overlay_top`](crate::ui::app::Tab). + fn raise_code_overlay(&mut self) { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.overlay_top = crate::ui::app::OverlayTop::Code; + } + } + + /// Focus the active file's text input (e.g. right after opening a file). + fn focus_editor(&self, window: &mut Window, cx: &mut Context) { + if let Some(f) = self.tab_code().and_then(|c| c.active_file()) { + f.input.update(cx, |input, cx| input.focus(window, cx)); + } + } + + /// Whether keyboard focus currently sits inside the editor panel. Lets + /// shared shortcuts (⌘S, ⌘W) route here before their terminal meaning. + pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context) -> bool { + self.code_panel_visible() + && self + .tab_code() + .and_then(|c| c.active_file()) + .is_some_and(|f| { + f.input + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + }) + } + + /// `EditorSave` (⌘S): write the active buffer back to its path. + pub(crate) fn editor_save_active(&mut self, window: &mut Window, cx: &mut Context) { + let Some(code) = self.tab_code_mut() else { + return; + }; + let active = code.active; + let Some(f) = code.files.get_mut(active) else { + return; + }; + let text = f.input.read(cx).text().to_string(); + match std::fs::write(&f.path, &text) { + Ok(()) => { + f.dirty = false; + f.conflict = false; + f.disk_mtime = std::fs::metadata(&f.path).and_then(|m| m.modified()).ok(); + if let Some((client, _)) = &f.lsp { + // Make sure the server saw the final text before the save + // notification (the debounced didChange may still be + // pending), so on-save diagnostics match the disk state. + client.did_change(&f.path, &text); + client.did_save(&f.path); + } + cx.notify(); + } + Err(e) => { + window.push_notification(format!("Save failed: {e}"), cx); + } + } + } + + /// Close the file tab at `ix`. Dirty buffers get a native three-way prompt + /// (save / discard / cancel) before anything is lost. + pub(crate) fn editor_close_file( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(f) = self.tab_code().and_then(|c| c.files.get(ix)) else { + return; + }; + if !f.dirty { + self.editor_remove_file(ix, cx); + return; + } + let name = f.label(); + let answer = window.prompt( + PromptLevel::Warning, + &format!("\"{name}\" has unsaved changes"), + None, + &["Save", "Discard", "Cancel"], + cx, + ); + cx.spawn_in(window, async move |app, cx| { + let Ok(choice) = answer.await else { return }; + let _ = app.update_in(cx, |app, window, cx| match choice { + 0 => { + // Save, then close. Save failure keeps the tab open. + let prev_active = app.tab_code().map(|c| c.active); + if let Some(code) = app.tab_code_mut() { + code.active = ix; + } + app.editor_save_active(window, cx); + if let (Some(code), Some(prev)) = (app.tab_code_mut(), prev_active) { + code.active = prev; + } + if app + .tab_code() + .and_then(|c| c.files.get(ix)) + .is_some_and(|f| !f.dirty) + { + app.editor_remove_file(ix, cx); + } + } + 1 => app.editor_remove_file(ix, cx), + _ => {} + }); + }) + .detach(); + } + + /// If focus is in the editor, close the active file tab and report `true` + /// (so ⌘W routes here instead of closing the terminal tab). + pub(crate) fn editor_close_active_if_focused( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.editor_has_focus(window, cx) { + return false; + } + let Some(code) = self.tab_code_mut() else { + return false; + }; + if code.files.is_empty() { + code.visible = false; + cx.notify(); + return true; + } + let active = code.active; + self.editor_close_file(active, window, cx); + true + } + + fn editor_remove_file(&mut self, ix: usize, cx: &mut Context) { + let Some(code) = self.tab_code_mut() else { + return; + }; + if ix >= code.files.len() { + return; + } + let f = code.files.remove(ix); + if let Some((client, _)) = &f.lsp { + client.did_close(&f.path); + } + if code.active >= ix && code.active > 0 { + code.active -= 1; + } + self.editor_rebuild_watcher(); + cx.notify(); + } + + /// Every open buffer for `path`, across all tabs (a file can be open in + /// more than one tab's panel; each has its own buffer). + fn editor_files_for_path<'a>(&'a self, path: &'a Path) -> impl Iterator { + self.tabs + .iter() + .filter_map(|t| t.code.as_deref()) + .flat_map(|c| c.files.iter()) + .filter(move |f| f.path == *path) + } + + /// Push the buffer's current text to the language server (the debounced + /// tail of a typing burst). + pub(crate) fn editor_sync_lsp_document(&mut self, path: &Path, cx: &mut Context) { + for f in self.editor_files_for_path(path) { + if let Some((client, _)) = &f.lsp { + client.did_change(&f.path, &f.input.read(cx).text().to_string()); + break; // one didChange per path — the server sees one document + } + } + } + + /// Apply `publishDiagnostics` for `path` to its open buffers (any tab). + pub(crate) fn editor_apply_diagnostics( + &mut self, + path: &Path, + diags: Vec, + _window: &mut Window, + cx: &mut Context, + ) { + let inputs: Vec> = self + .editor_files_for_path(path) + .map(|f| f.input.clone()) + .collect(); + for input in inputs { + input.update(cx, |st, cx| { + let text = st.text().clone(); + if let Some(set) = st.diagnostics_mut() { + set.reset(&text); + set.extend(diags.iter().cloned()); + cx.notify(); + } + }); + } + } + + /// `EditorGotoDefinition` (F12): resolve the definition at the cursor and + /// jump — opening the target file first when it lives elsewhere (the + /// in-buffer ⌘-click path can't cross files; this one can). + pub(crate) fn editor_goto_definition(&mut self, window: &mut Window, cx: &mut Context) { + let Some(f) = self.tab_code().and_then(|c| c.active_file()) else { + return; + }; + let Some((client, _)) = &f.lsp else { return }; + let st = f.input.read(cx); + let (text, offset) = (st.text().clone(), st.cursor()); + let Some(params) = crate::ui::lsp::LspClient::position_params(&f.path, &text, offset) + else { + return; + }; + let rx = client.request("textDocument/definition", params); + cx.spawn_in(window, async move |app, cx| { + let Ok(v) = rx.recv().await else { return }; + let links = crate::ui::lsp::normalize_definitions(v); + let Some(link) = links.first() else { return }; + let Some(target) = crate::ui::lsp::path_for_uri(link.target_uri.as_str()) else { + return; + }; + let pos = link.target_selection_range.start; + let _ = app.update_in(cx, |app, window, cx| { + app.editor_jump_to(&target, pos, window, cx); + }); + }) + .detach(); + } + + /// `EditorFindReferences` (⇧F12): list every reference to the symbol at + /// the cursor in a drawer under the editor. + pub(crate) fn editor_find_references(&mut self, window: &mut Window, cx: &mut Context) { + let Some(f) = self.tab_code().and_then(|c| c.active_file()) else { + return; + }; + let Some((client, _)) = &f.lsp else { return }; + let st = f.input.read(cx); + let (text, offset) = (st.text().clone(), st.cursor()); + let Some(mut params) = crate::ui::lsp::LspClient::position_params(&f.path, &text, offset) + else { + return; + }; + params["context"] = serde_json::json!({ "includeDeclaration": true }); + let rx = client.request("textDocument/references", params); + cx.spawn_in(window, async move |app, cx| { + let Ok(v) = rx.recv().await else { return }; + let locations: Vec = serde_json::from_value(v).unwrap_or_default(); + // Read each referenced line once per file for the row previews. On + // the background executor: `spawn_in` runs on the *main* thread, and + // a couple of hundred `read_to_string`s there would stall a frame. + let items: Vec = cx + .background_executor() + .spawn(async move { + let mut items: Vec = Vec::new(); + let mut file_lines: std::collections::HashMap> = + std::collections::HashMap::new(); + for loc in locations.into_iter().take(200) { + let Some(path) = crate::ui::lsp::path_for_uri(loc.uri.as_str()) else { + continue; + }; + let lines = file_lines.entry(path.clone()).or_insert_with(|| { + std::fs::read_to_string(&path) + .map(|t| t.lines().map(|l| l.to_string()).collect()) + .unwrap_or_default() + }); + let line = loc.range.start.line; + let preview = lines + .get(line as usize) + .map(|l| l.trim().to_string()) + .unwrap_or_default(); + items.push(ReferenceItem { + path, + line, + character: loc.range.start.character, + preview: preview.into(), + }); + } + items + }) + .await; + let _ = app.update(cx, |app, cx| { + if let Some(code) = app.tab_code_mut() { + code.references = Some(items); + } + cx.notify(); + }); + }) + .detach(); + } + + /// Open `path` (if needed) and place the cursor at an LSP position. + pub(crate) fn editor_jump_to( + &mut self, + path: &Path, + pos: lsp_types::Position, + window: &mut Window, + cx: &mut Context, + ) { + self.open_file_in_editor(path, window, cx); + if let Some(f) = self.tab_code().and_then(|c| c.active_file()) + && f.path == *path + { + f.input.clone().update(cx, |st, cx| { + st.set_cursor_position(pos, window, cx); + }); + } + } + + /// A watched file changed on disk. Clean buffers reload silently; dirty + /// ones raise the conflict banner and let the user pick a side. The file + /// may be open in several tabs — each buffer is handled on its own. + pub(crate) fn editor_handle_external_change( + &mut self, + path: &Path, + window: &mut Window, + cx: &mut Context, + ) { + let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok(); + let mut reload: Vec<(usize, usize)> = Vec::new(); + let mut changed = false; + for (tab_ix, tab) in self.tabs.iter_mut().enumerate() { + let Some(code) = tab.code.as_deref_mut() else { + continue; + }; + for (ix, f) in code.files.iter_mut().enumerate() { + if f.path != *path { + continue; + } + // Our own save's echo: mtime matches what we just wrote. + if mtime.is_some() && mtime == f.disk_mtime { + continue; + } + if f.dirty { + f.conflict = true; + changed = true; + } else { + reload.push((tab_ix, ix)); + } + } + } + for (tab_ix, ix) in reload { + self.editor_reload_from_disk(tab_ix, ix, window, cx); + } + if changed { + cx.notify(); + } + } + + /// Replace one buffer with the on-disk content (used by the silent reload + /// and the conflict banner's "Reload" choice). A vanished file just keeps + /// the buffer and marks it dirty — saving will recreate it. + pub(crate) fn editor_reload_from_disk( + &mut self, + tab_ix: usize, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(f) = self + .tabs + .get_mut(tab_ix) + .and_then(|t| t.code.as_deref_mut()) + .and_then(|c| c.files.get_mut(ix)) + else { + return; + }; + let Ok(text) = std::fs::read_to_string(&f.path) else { + f.dirty = true; + f.conflict = false; + cx.notify(); + return; + }; + f.disk_mtime = std::fs::metadata(&f.path).and_then(|m| m.modified()).ok(); + f.dirty = false; + f.conflict = false; + if let Some((client, _)) = &f.lsp { + client.did_change(&f.path, &text); + } + let input = f.input.clone(); + input.update(cx, |input, cx| input.set_value(text, window, cx)); + cx.notify(); + } +} + +// --------------------------------------------------------------------------- +// Rendering. +// --------------------------------------------------------------------------- + +impl Tty7App { + /// The code panel: a full-body overlay of `[file tree | editor]` covering + /// the terminal (settings/diff-overlay style), or `None` while closed. + /// The terminal underneath keeps its size — toggling never reflows it. + pub(crate) fn render_code_overlay( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if !self.code_panel_visible() { + return None; + } + let body = match self.tab_code().and_then(|c| c.active_file()) { + None => self.render_editor_empty(cx).into_any_element(), + // Markdown preview replaces the buffer with a rendered view. + Some(f) if f.preview => { + let markdown = f.input.read(cx).text().to_string(); + div() + .id("editor-md-preview") + .size_full() + .overflow_y_scroll() + .px_4() + .py_3() + .child(gpui_component::text::TextView::markdown( + "editor-md-preview-body", + markdown, + )) + .into_any_element() + } + Some(f) => { + let input = f.input.clone(); + // `appearance(false)`: no border/background of its own — the + // buffer sits flush in the panel instead of in a rounded box. + Input::new(&input) + .appearance(false) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(cx.theme().mono_font_size) + .size_full() + .into_any_element() + } + }; + let conflict_banner = self + .tab_code() + .and_then(|c| c.active_file()) + .filter(|f| f.conflict) + .map(|_| self.render_editor_conflict_banner(cx)); + let references = self.render_editor_references(cx); + + let editor_col = v_flex() + .flex_1() + .min_w_0() + .h_full() + .child(self.render_editor_header(cx)) + .when_some(conflict_banner, |this, b| this.child(b)) + .child(div().flex_1().min_h_0().child(body)) + .when_some(references, |this, drawer| this.child(drawer)); + + Some( + v_flex() + .id("code-panel") + .absolute() + // Fills its column, which is now everything *except* the detail + // panel — the panel is a sibling of that column, not a child of it, + // so the tree that opens files stays visible beside the editor + // without the overlay needing to know the panel's width. + .inset_0() + // The overlay must swallow input to the terminal behind it. + .occlude() + .bg(cx.theme().background) + // Escape (not consumed by the editor's own search/completion + // handling, which stops propagation) drops back to the terminal. + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { + if ev.keystroke.key == "escape" { + this.toggle_code_panel(window, cx); + } + })) + // No top inset: the header row below *is* the title bar's row, and + // it clears the window controls itself (see `render_editor_header`). + // Padding the whole overlay down would cost a blank 40px band and + // still misalign the editor's top edge with the panel's tab row. + // No tree column here: the right panel owns the file tree now, and + // the overlay stops short of it (see the `right` inset above), so + // the tree stays visible beside the editor instead of being + // duplicated inside it. + .child(h_flex().flex_1().min_h_0().w_full().child(editor_col)) + .child(self.render_code_status_bar(window, cx)) + .into_any_element(), + ) + } + + /// The editor's one header row: which file is open, and a way back to the + /// terminal. Not a tab strip — the file tree is the switcher now, so this only + /// has to answer "what am I looking at" without earning a row of chrome for + /// every buffer that was ever opened. Sits on the title bar's line and matches + /// its height, so the editor's top edge lines up with the panel's tab row and + /// the rail's controls across the window. + fn render_editor_header(&self, cx: &mut Context) -> gpui::Div { + let active = self.tab_code().and_then(|c| c.active_file()); + let name = active.map(|f| f.label()); + let dirty = active.is_some_and(|f| f.dirty); + // The overlay fills the column left of the detail panel. With the rail out + // that column starts after it, and the traffic lights sit on the rail's + // surface — but with the rail collapsed (or in horizontal-tabs mode) the + // column starts at the window's left edge and the lights are right where + // the filename would go, so the header takes the window controls' reserve + // as its inset instead. + let lead = if self.left_panel_open(cx) { + crate::ui::app::CONTENT_INSET + } else { + crate::ui::app::TITLE_BAR_LEAD + }; + h_flex() + .flex_none() + .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) + .items_center() + .gap_1p5() + .pl(px(lead)) + .pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD)) + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .flex_1() + .min_w_0() + .text_ellipsis() + .text_sm() + .when(name.is_none(), |d| { + d.text_color(cx.theme().muted_foreground) + }) + .child(name.unwrap_or_else(|| SharedString::from("No file open"))), + ) + // Same amber dot the tree marks unsaved files with. + .when(dirty, |d| { + d.child( + div() + .flex_none() + .size(px(6.)) + .rounded_full() + .bg(cx.theme().warning), + ) + }) + .child( + crate::ui::tab_strip::chrome_tile( + Button::new("editor-panel-close") + .icon(Icon::new(IconName::Close).size(px(15.))), + false, + cx, + ) + .xsmall() + .w(px(30.)) + .h(px(30.)) + .rounded_lg() + .tooltip("Back to Terminal (Esc)") + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_code_panel(window, cx); + })), + ) + } + + /// The Zed-style status bar along the panel bottom: repo-relative path on + /// the left; preview/wrap toggles, cursor position, and the language + /// server's presence on the right. + fn render_code_status_bar(&self, _window: &Window, cx: &mut Context) -> gpui::Div { + let code = self.tab_code(); + let muted = cx.theme().muted_foreground; + // `repo › relative/path` for the active file; just the repo otherwise. + let path_text: Option = code.map(|c| { + let repo = c + .roots + .first() + .and_then(|r| r.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + match c.active_file() { + Some(f) => { + let rel = c + .roots + .iter() + .find_map(|r| f.path.strip_prefix(r).ok()) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| f.label().to_string()); + format!("{repo} › {rel}").into() + } + None => repo.into(), + } + }); + let active = code.and_then(|c| c.active_file()); + let cursor: Option = active.map(|f| { + let pos = f.input.read(cx).cursor_position(); + format!("Ln {}, Col {}", pos.line + 1, pos.character + 1).into() + }); + let wrap: Option = active.map(|f| f.wrap); + let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown"); + let preview = active.is_some_and(|f| f.preview); + let lsp_name: Option = active + .and_then(|f| f.lsp.as_ref()) + .map(|(client, _)| format!("{} ✓", client.name()).into()); + + h_flex() + .flex_none() + .w_full() + .h(px(26.)) + .items_center() + .gap_3() + .px_3() + .border_t_1() + .border_color(cx.theme().border) + .text_xs() + .text_color(muted) + .when_some(path_text, |this, t| { + this.child(div().min_w_0().text_ellipsis().child(t)) + }) + .child(div().flex_1()) + .when(is_markdown, |this| { + this.child( + Button::new("status-md-preview") + .label(if preview { "Edit" } else { "Preview" }) + .custom(crate::ui::tab_strip::chrome_tile_variant(cx)) + .xsmall() + .on_click(cx.listener(|this, _, _w, cx| { + if let Some(code) = this.tab_code_mut() { + let ix = code.active; + if let Some(f) = code.files.get_mut(ix) { + f.preview = !f.preview; + cx.notify(); + } + } + })), + ) + }) + .when_some(wrap, |this, wrap| { + this.child( + Button::new("status-wrap") + .label(if wrap { "Wrap: on" } else { "Wrap: off" }) + .custom(crate::ui::tab_strip::chrome_tile_variant(cx)) + .xsmall() + .on_click(cx.listener(|this, _, window, cx| { + let Some(code) = this.tab_code_mut() else { + return; + }; + let ix = code.active; + if let Some(f) = code.files.get_mut(ix) { + f.wrap = !f.wrap; + let wrap = f.wrap; + f.input.clone().update(cx, |st, cx| { + st.set_soft_wrap(wrap, window, cx); + }); + } + })), + ) + }) + .when_some(cursor, |this, t| this.child(div().child(t))) + .when_some(lsp_name, |this, t| this.child(div().child(t))) + } + + /// Empty state: the panel is open with nothing loaded. + fn render_editor_empty(&self, cx: &Context) -> gpui::Div { + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child( + Icon::new(IconName::File) + .large() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Open a file from the file tree"), + ) + } + + /// The find-references drawer (⇧F12 results) under the editor body. + fn render_editor_references(&self, cx: &mut Context) -> Option { + let refs = self.tab_code()?.references.as_ref()?; + let muted = cx.theme().muted_foreground; + let rows = refs.iter().enumerate().map(|(ix, r)| { + let name = r + .path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let (path, line, character) = (r.path.clone(), r.line, r.character); + h_flex() + .id(("editor-ref", ix)) + .items_center() + .gap_2() + .px_2() + .py_0p5() + .text_sm() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().accent.opacity(0.5))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.editor_jump_to( + &path, + lsp_types::Position::new(line, character), + window, + cx, + ); + }), + ) + .child( + div() + .flex_none() + .text_color(muted) + .child(format!("{name}:{}", r.line + 1)), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_ellipsis() + .child(r.preview.clone()), + ) + }); + Some( + v_flex() + .flex_none() + .max_h(gpui::relative(0.4)) + .border_t_1() + .border_color(cx.theme().border) + .child( + h_flex() + .items_center() + .px_2() + .py_1() + .text_sm() + .child(div().flex_1().child(format!("{} references", refs.len()))) + .child( + crate::ui::tab_strip::chrome_tile( + Button::new("editor-refs-close").icon(IconName::Close), + false, + cx, + ) + .xsmall() + .on_click(cx.listener( + |this, _, _w, cx| { + if let Some(code) = this.tab_code_mut() { + code.references = None; + } + cx.notify(); + }, + )), + ), + ) + .child( + v_flex() + .id("editor-refs-list") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .children(rows), + ) + .into_any_element(), + ) + } + + /// Banner shown when the file changed on disk while the buffer is dirty. + fn render_editor_conflict_banner(&self, cx: &mut Context) -> AnyElement { + let tab_ix = self.active; + let ix = self.tab_code().map(|c| c.active).unwrap_or(0); + h_flex() + .flex_none() + .w_full() + .items_center() + .gap_2() + .px_2() + .py_1() + .bg(cx.theme().warning.opacity(0.15)) + .border_b_1() + .border_color(cx.theme().border) + .text_sm() + .child(div().flex_1().child("File changed on disk")) + .child( + Button::new("editor-conflict-reload") + .label("Reload") + .small() + .on_click(cx.listener(move |this, _, window, cx| { + this.editor_reload_from_disk(tab_ix, ix, window, cx); + })), + ) + .child( + Button::new("editor-conflict-keep") + .label("Keep mine") + .ghost() + .small() + .on_click(cx.listener(move |this, _, _w, cx| { + if let Some(f) = this.tab_code_mut().and_then(|c| c.files.get_mut(ix)) { + f.conflict = false; + cx.notify(); + } + })), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_map_covers_common_extensions() { + for (path, lang) in [ + ("a/b/main.rs", "rust"), + ("x.tsx", "tsx"), + ("x.jsx", "javascript"), + ("x.yml", "yaml"), + ("Makefile", "make"), + ("CMakeLists.txt", "cmake"), + (".zshrc", "bash"), + ("notes.md", "markdown"), + ("query.SQL", "sql"), + ("unknown.xyz", "text"), + ("no_ext", "text"), + ] { + assert_eq!(language_for_path(Path::new(path)), lang, "path {path}"); + } + } + + #[test] + fn binary_sniff_flags_nul_bytes_only() { + assert!(looks_binary(b"\x7fELF\x00\x01")); + assert!(!looks_binary("plain text\nwith lines".as_bytes())); + assert!(!looks_binary("中文 UTF-8 内容".as_bytes())); + } +} diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 65e0d413..2f086ec2 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -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, + /// 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, } impl Tty7App { @@ -69,19 +76,63 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - 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, + window: &mut Window, + cx: &mut Context, + ) { + 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) { @@ -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, + focused: Option, cx: &mut Context, ) -> 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 { + 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 { + 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) -> bool { diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs new file mode 100644 index 00000000..605925aa --- /dev/null +++ b/src/ui/file_tree.rs @@ -0,0 +1,1213 @@ +//! Local project file tree (the code overlay's left column — see +//! `code_editor::render_code_overlay` for the panel that hosts it). +//! +//! Modelled on Warp's Project Explorer: lazily-loaded directories, gitignore +//! awareness (ignored entries render dimmed, not hidden), a filesystem watcher +//! that keeps listings fresh, keyboard navigation, inline new-file / rename +//! editing, and a per-row context menu (open / cd / reveal / copy path / +//! delete / attach to a coding agent). Roots come from the active tab's panes: +//! each pane's cwd resolves to its repository root (walk up to `.git`), so a +//! tab whose panes sit in two repos shows both as top-level roots. +//! +//! The panel is a plain lazy tree over `std::fs` — no daemon round-trips (the +//! SFTP panel covers the remote case). Listings are cached per directory and +//! invalidated by `notify` events, so a huge repo only ever pays for the +//! directories actually expanded. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use gpui::prelude::*; +use gpui::{ + AnyElement, Context, Entity, ExternalPaths, FocusHandle, KeyDownEvent, MouseButton, + PromptLevel, SharedString, Subscription, Window, div, px, +}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; +use ignore::gitignore::Gitignore; + +use crate::ui::app::Tty7App; + +/// Per-level indent (px) for nested rows. +const INDENT: f32 = 14.0; + +/// Debounce for watcher-driven refreshes (same rationale as the config +/// hot-reload: coalesce a save burst into one reload). +const REFRESH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); + +/// One directory entry in a cached listing. +#[derive(Clone)] +pub(crate) struct TreeEntry { + pub name: String, + pub path: PathBuf, + pub is_dir: bool, + /// Matched by the gitignore chain (or is `.git` itself): rendered dimmed. + pub ignored: bool, +} + +/// A flattened visible row: what the list renders and what keyboard +/// navigation walks. Roots are rows too (depth 0, always expanded-looking). +pub(crate) struct TreeRow { + pub entry: TreeEntry, + pub depth: usize, + pub is_root: bool, + pub expanded: bool, +} + +/// One in-progress inline edit (new file / new folder / rename). +pub(crate) enum TreeEdit { + NewFile { + dir: PathBuf, + input: Entity, + }, + NewFolder { + dir: PathBuf, + input: Entity, + }, + Rename { + path: PathBuf, + input: Entity, + }, +} + +impl TreeEdit { + fn input(&self) -> &Entity { + match self { + TreeEdit::NewFile { input, .. } + | TreeEdit::NewFolder { input, .. } + | TreeEdit::Rename { input, .. } => input, + } + } + + /// The directory whose listing hosts the edit row. + fn host_dir(&self) -> &Path { + match self { + TreeEdit::NewFile { dir, .. } | TreeEdit::NewFolder { dir, .. } => dir, + TreeEdit::Rename { path, .. } => path.parent().unwrap_or(path), + } + } +} + +/// App-global file-tree infrastructure, held on [`Tty7App`]. The per-tab view +/// state (roots, expansion, selection) lives in +/// [`TabCode`](crate::ui::code_editor::TabCode); everything here is path-keyed +/// cache or chrome shared by every tab's panel — one panel shows at a time. +pub(crate) struct FileTreeState { + /// Lazily-loaded listing per directory; invalidated by watcher events. + children: HashMap>, + /// Compiled `.gitignore` per directory (`None` = the dir has none). + /// Invalidated when a `.gitignore` changes. + gitignore: HashMap>>, + pub(crate) show_hidden: bool, + pub(crate) editing: Option, + editing_subs: Vec, + /// One recursive watcher over the union of every tab's roots; rebuilt + /// when any root set changes. Events invalidate the path-keyed caches + /// above, which are tab-agnostic. + watcher: Option, + events_tx: smol::channel::Sender, + pub(crate) focus_handle: FocusHandle, +} + +impl FileTreeState { + pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { + let (tx, rx) = smol::channel::unbounded::(); + cx.spawn_in(window, async move |app, cx| { + while let Ok(first) = rx.recv().await { + cx.background_executor().timer(REFRESH_DEBOUNCE).await; + let mut changed: HashSet = HashSet::from([first]); + while let Ok(more) = rx.try_recv() { + changed.insert(more); + } + let ok = app.update(cx, |app, cx| { + app.file_tree_apply_fs_events(&changed, cx); + }); + if ok.is_err() { + break; + } + } + }) + .detach(); + Self { + children: HashMap::new(), + gitignore: HashMap::new(), + show_hidden: false, + editing: None, + editing_subs: Vec::new(), + watcher: None, + events_tx: tx, + focus_handle: cx.focus_handle(), + } + } + + /// (Re)attach the recursive watcher to `roots` (the union across tabs). + fn rebuild_watcher(&mut self, roots: &HashSet) { + use notify::{RecursiveMode, Watcher}; + self.watcher = None; + if roots.is_empty() { + return; + } + let tx = self.events_tx.clone(); + let handler = move |res: notify::Result| { + let Ok(event) = res else { return }; + for p in event.paths { + let _ = tx.try_send(p); + } + }; + let mut watcher = match notify::recommended_watcher(handler) { + Ok(w) => w, + Err(e) => { + log::warn!("file tree: watcher unavailable: {e}"); + return; + } + }; + for root in roots { + if let Err(e) = watcher.watch(root, RecursiveMode::Recursive) { + log::warn!("file tree: failed to watch {}: {e}", root.display()); + } + } + self.watcher = Some(watcher); + } + + /// Load any expanded directory whose listing isn't cached yet. Called once + /// per render pass so `visible_rows` can stay `&self`. + fn ensure_loaded(&mut self, roots: &[PathBuf], expanded: &HashSet) { + // Roots always list; expanded dirs list on demand. Collect first: the + // borrow checker won't let us mutate `children` while iterating it. + let mut todo: Vec<(PathBuf, PathBuf)> = Vec::new(); // (dir, its root) + for root in roots { + if !self.children.contains_key(root) { + todo.push((root.clone(), root.clone())); + } + for dir in expanded { + if dir.starts_with(root) && !self.children.contains_key(dir) { + todo.push((dir.clone(), root.clone())); + } + } + } + for (dir, root) in todo { + let listing = self.list_dir(&dir, &root); + self.children.insert(dir, listing); + } + } + + /// Read one directory into sorted entries, tagging gitignored ones. + fn list_dir(&mut self, dir: &Path, root: &Path) -> Vec { + let Ok(read) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + for e in read.flatten() { + let path = e.path(); + let name = e.file_name().to_string_lossy().to_string(); + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + let ignored = name == ".git" || self.is_gitignored(&path, is_dir, root); + out.push(TreeEntry { + name, + path, + is_dir, + ignored, + }); + } + sort_entries(&mut out); + out + } + + /// Walk the `.gitignore` chain from `root` down to the entry's directory; + /// the deepest match wins (whitelist `!patterns` un-ignore). + fn is_gitignored(&mut self, path: &Path, is_dir: bool, root: &Path) -> bool { + let Some(parent) = path.parent() else { + return false; + }; + let mut state = false; + // Ancestor chain root → parent, in order. + let mut chain: Vec<&Path> = parent + .ancestors() + .take_while(|a| a.starts_with(root)) + .collect(); + chain.reverse(); + for dir in chain { + let gi = self + .gitignore + .entry(dir.to_path_buf()) + .or_insert_with(|| { + let file = dir.join(".gitignore"); + file.is_file().then(|| { + let (gi, _err) = Gitignore::new(&file); + Rc::new(gi) + }) + }) + .clone(); + let Some(gi) = gi else { continue }; + let Ok(rel) = path.strip_prefix(dir) else { + continue; + }; + match gi.matched(rel, is_dir) { + ignore::Match::Ignore(_) => state = true, + ignore::Match::Whitelist(_) => state = false, + ignore::Match::None => {} + } + } + state + } + + /// Flat, bounded search across the whole tree — not a filter over the rows + /// that happen to be expanded, which would answer "no matches" for anything + /// the user hasn't already drilled into. Walks breadth-first from the roots + /// so shallow hits (the ones you usually mean) come first, skips ignored + /// directories entirely — `.git`, `target`, `node_modules` are where the file + /// count explodes and never where you're searching — and stops at `LIMIT` + /// hits so a query like "e" can't walk a whole monorepo. + fn search_rows(&mut self, roots: &[PathBuf], query: &str) -> Vec { + const LIMIT: usize = 200; + /// Directories visited even if nothing matches, so a typo can't turn into + /// a full-disk crawl. + const MAX_DIRS: usize = 2000; + + let needle = query.to_lowercase(); + let mut out: Vec = Vec::new(); + let mut visited = 0usize; + for root in roots { + // A deque, not a `Vec` + `remove(0)`: the breadth-first frontier of a + // wide tree gets long, and shifting it down per pop is quadratic. + let mut queue: std::collections::VecDeque = + std::collections::VecDeque::from([root.clone()]); + while let Some(dir) = queue.pop_front() { + if out.len() >= LIMIT || visited >= MAX_DIRS { + break; + } + visited += 1; + if !self.children.contains_key(&dir) { + let listed = self.list_dir(&dir, root); + self.children.insert(dir.clone(), listed); + } + let entries = self.children.get(&dir).cloned().unwrap_or_default(); + for e in entries { + if e.ignored && !self.show_hidden { + continue; + } + if !self.show_hidden && e.name.starts_with('.') { + continue; + } + if e.is_dir { + queue.push_back(e.path.clone()); + } + if e.name.to_lowercase().contains(&needle) { + out.push(TreeRow { + entry: e, + // Flat: a match's own indentation would be meaningless + // without its ancestors on screen. + depth: 0, + is_root: false, + expanded: false, + }); + if out.len() >= LIMIT { + break; + } + } + } + } + } + out + } + + /// Flatten `roots` + `expanded` directories into display order (both come + /// from the active tab's panel state). + pub(crate) fn visible_rows( + &self, + roots: &[PathBuf], + expanded: &HashSet, + ) -> Vec { + let mut rows = Vec::new(); + for root in roots { + let name = root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| root.display().to_string()); + rows.push(TreeRow { + entry: TreeEntry { + name, + path: root.clone(), + is_dir: true, + ignored: false, + }, + depth: 0, + is_root: true, + expanded: true, + }); + self.flatten_dir(root, 1, expanded, &mut rows); + } + rows + } + + fn flatten_dir( + &self, + dir: &Path, + depth: usize, + expanded: &HashSet, + out: &mut Vec, + ) { + let Some(entries) = self.children.get(dir) else { + return; + }; + for e in entries { + if !self.show_hidden && e.name.starts_with('.') { + continue; + } + let is_expanded = e.is_dir && expanded.contains(&e.path); + out.push(TreeRow { + entry: e.clone(), + depth, + is_root: false, + expanded: is_expanded, + }); + if is_expanded { + self.flatten_dir(&e.path, depth + 1, expanded, out); + } + } + } + + /// Drop cached listings after a filesystem change under `dir`. + fn invalidate_dir(&mut self, dir: &Path) { + self.children.remove(dir); + } +} + +// --------------------------------------------------------------------------- +// Pure helpers (tested). +// --------------------------------------------------------------------------- + +/// Directories first, then case-insensitive by name (dotfiles keep their +/// leading-dot position in that ordering — they sort before letters). +pub(crate) fn sort_entries(entries: &mut [TreeEntry]) { + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); +} + +/// The repository root for a path: the nearest ancestor containing `.git` +/// (dir or worktree file), or `None` outside any repo. +pub(crate) fn repo_root_for(path: &Path) -> Option { + path.ancestors() + .find(|p| p.join(".git").exists()) + .map(Path::to_path_buf) +} + +/// Single-quote a path for the shell; embedded `'` becomes `'\''`. +pub(crate) fn shell_quote(path: &Path) -> String { + let s = path.to_string_lossy(); + if !s.is_empty() + && s.chars() + .all(|c| c.is_alphanumeric() || "/.-_~+".contains(c)) + { + return s.into_owned(); + } + format!("'{}'", s.replace('\'', r"'\''")) +} + +// --------------------------------------------------------------------------- +// Tty7App: toggling, fs events, row operations. +// --------------------------------------------------------------------------- + +impl Tty7App { + /// Derive the root set from the active tab's panes: each pane cwd maps to + /// its repo root (or itself outside a repo); home as the last resort. + pub(crate) fn file_tree_refresh_roots(&mut self, window: &mut Window, cx: &mut Context) { + let mut roots: Vec = Vec::new(); + if let Some(tab) = self.tabs.get(self.active) { + for leaf in tab.pane.leaves() { + let Some(cwd) = leaf.read(cx).cwd() else { + continue; + }; + let root = repo_root_for(&cwd).unwrap_or(cwd); + if !roots.contains(&root) { + roots.push(root); + } + } + } + if roots.is_empty() + && let Some(home) = std::env::var_os("HOME") + { + roots.push(PathBuf::from(home)); + } + let _ = window; + let Some(code) = self.tab_code_mut_or_init() else { + return; + }; + if roots != code.roots { + code.roots = roots; + } + // Refresh listings but keep expansion state; the caches are shared + // (path-keyed), so a stale entry only costs a relist. + self.file_tree.children.clear(); + self.file_tree.gitignore.clear(); + // One watcher over every tab's roots. + let union: HashSet = self + .tabs + .iter() + .filter_map(|t| t.code.as_deref()) + .flat_map(|c| c.roots.iter().cloned()) + .collect(); + self.file_tree.rebuild_watcher(&union); + cx.notify(); + } + + /// Watcher callback (debounced): drop affected listing caches so the next + /// render relists. A `.gitignore` change resets ignore state wholesale — + /// its patterns can affect any depth below it. + pub(crate) fn file_tree_apply_fs_events( + &mut self, + paths: &HashSet, + cx: &mut Context, + ) { + // The caches are shared across tabs, so invalidate unconditionally — + // a hidden tab's stale listing would otherwise survive until reopened. + let gitignore_touched = paths + .iter() + .any(|p| p.file_name().is_some_and(|n| n == ".gitignore")); + if gitignore_touched { + self.file_tree.gitignore.clear(); + self.file_tree.children.clear(); + } else { + for p in paths { + if let Some(parent) = p.parent() { + self.file_tree.invalidate_dir(parent); + } + // A changed dir itself (e.g. a mkdir) also invalidates its own + // listing if cached. + self.file_tree.invalidate_dir(p); + } + } + cx.notify(); + } + + fn file_tree_toggle_expand(&mut self, dir: &Path, cx: &mut Context) { + let Some(code) = self.tab_code_mut() else { + return; + }; + if !code.expanded.remove(dir) { + code.expanded.insert(dir.to_path_buf()); + } + cx.notify(); + } + + /// Row activation (click / Enter): directories toggle, files open in the + /// editor panel. + fn file_tree_activate( + &mut self, + row_path: &Path, + is_dir: bool, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(code) = self.tab_code_mut() { + code.selected = Some(row_path.to_path_buf()); + } + // Search results are a flat list, so "expand" there has nothing to show. + // Clicking a directory in them means "take me to it": drop the query and + // open the real tree down to that directory, which is the only way the + // click can produce a visible result. + let searching = !self.file_search.read(cx).value().trim().is_empty(); + if is_dir && searching { + self.file_tree_reveal(row_path, cx); + self.file_search + .update(cx, |st, cx| st.set_value("", window, cx)); + cx.notify(); + return; + } + if is_dir { + self.file_tree_toggle_expand(row_path, cx); + } else { + self.open_file_in_editor(row_path, window, cx); + } + cx.notify(); + } + + /// Expand `dir` and every ancestor of it up to its root, so a path buried + /// several levels down becomes visible in one step. + fn file_tree_reveal(&mut self, dir: &Path, cx: &mut Context) { + let roots = self.tab_code().map(|c| c.roots.clone()).unwrap_or_default(); + let Some(root) = roots.iter().find(|r| dir.starts_with(r)).cloned() else { + return; + }; + let Some(code) = self.tab_code_mut() else { + return; + }; + for a in dir.ancestors().take_while(|a| a.starts_with(&root)) { + code.expanded.insert(a.to_path_buf()); + } + cx.notify(); + } + + /// Keyboard navigation over the flattened rows. + fn file_tree_key_down( + &mut self, + ev: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(code) = self.tab_code() else { + return; + }; + let rows = self.file_tree.visible_rows(&code.roots, &code.expanded); + if rows.is_empty() { + return; + } + let sel_ix = code + .selected + .as_ref() + .and_then(|s| rows.iter().position(|r| r.entry.path == *s)); + let key = ev.keystroke.key.as_str(); + match key { + "up" | "down" => { + let next = match (sel_ix, key) { + (None, _) => 0, + (Some(i), "up") => i.saturating_sub(1), + (Some(i), _) => (i + 1).min(rows.len() - 1), + }; + let path = rows[next].entry.path.clone(); + if let Some(code) = self.tab_code_mut() { + code.selected = Some(path); + } + cx.notify(); + } + "left" => { + let Some(i) = sel_ix else { return }; + let row = &rows[i]; + let (path, is_dir, expanded, is_root) = ( + row.entry.path.clone(), + row.entry.is_dir, + row.expanded, + row.is_root, + ); + let parent_in_rows = path + .parent() + .is_some_and(|p| rows.iter().any(|r| r.entry.path == p)); + if let Some(code) = self.tab_code_mut() { + if is_dir && expanded && !is_root { + code.expanded.remove(&path); + } else if parent_in_rows && let Some(parent) = path.parent() { + // Jump to the parent row (stay put at a root). + code.selected = Some(parent.to_path_buf()); + } + } + cx.notify(); + } + "right" => { + let Some(i) = sel_ix else { return }; + let row = &rows[i]; + if row.entry.is_dir && !row.expanded && !row.is_root { + let path = row.entry.path.clone(); + if let Some(code) = self.tab_code_mut() { + code.expanded.insert(path); + } + cx.notify(); + } + } + "enter" => { + let Some(i) = sel_ix else { return }; + let (path, is_dir) = (rows[i].entry.path.clone(), rows[i].entry.is_dir); + self.file_tree_activate(&path, is_dir, window, cx); + } + _ => {} + } + } + + // ----- Inline edits (new file / new folder / rename) -------------------- + + fn file_tree_begin_edit( + &mut self, + edit_for: TreeEditKind, + target: &Path, + window: &mut Window, + cx: &mut Context, + ) { + let initial = match edit_for { + TreeEditKind::Rename => target + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + _ => String::new(), + }; + let input = cx.new(|cx| { + let mut st = InputState::new(window, cx).placeholder(match edit_for { + TreeEditKind::NewFile => "file name", + TreeEditKind::NewFolder => "folder name", + TreeEditKind::Rename => "new name", + }); + st.set_value(initial, window, cx); + st + }); + input.update(cx, |st, cx| st.focus(window, cx)); + let sub = cx.subscribe_in( + &input, + window, + |this: &mut Tty7App, _input, ev, window, cx| match ev { + InputEvent::PressEnter { .. } => this.file_tree_commit_edit(window, cx), + InputEvent::Blur => this.file_tree_cancel_edit(cx), + _ => {} + }, + ); + self.file_tree.editing_subs = vec![sub]; + // New entries land in the target dir (or the file's parent), which + // must be expanded for the inline input row to show. + let host_dir = if target.is_dir() { + target.to_path_buf() + } else { + target.parent().unwrap_or(target).to_path_buf() + }; + if !matches!(edit_for, TreeEditKind::Rename) + && let Some(code) = self.tab_code_mut() + { + code.expanded.insert(host_dir.clone()); + } + self.file_tree.editing = Some(match edit_for { + TreeEditKind::NewFile => TreeEdit::NewFile { + dir: host_dir, + input, + }, + TreeEditKind::NewFolder => TreeEdit::NewFolder { + dir: host_dir, + input, + }, + TreeEditKind::Rename => TreeEdit::Rename { + path: target.to_path_buf(), + input, + }, + }); + cx.notify(); + } + + fn file_tree_cancel_edit(&mut self, cx: &mut Context) { + self.file_tree.editing = None; + self.file_tree.editing_subs.clear(); + cx.notify(); + } + + fn file_tree_commit_edit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(edit) = self.file_tree.editing.take() else { + return; + }; + self.file_tree.editing_subs.clear(); + let name = edit.input().read(cx).value().trim().to_string(); + if name.is_empty() || name.contains('/') { + cx.notify(); + return; + } + let result: std::io::Result = match &edit { + TreeEdit::NewFile { dir, .. } => { + let path = dir.join(&name); + std::fs::File::create_new(&path).map(|_| path) + } + TreeEdit::NewFolder { dir, .. } => { + let path = dir.join(&name); + std::fs::create_dir(&path).map(|_| path) + } + TreeEdit::Rename { path, .. } => { + let to = path.with_file_name(&name); + if to.exists() { + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "target exists", + )) + } else { + std::fs::rename(path, &to).map(|_| to) + } + } + }; + match result { + Ok(new_path) => { + self.file_tree.invalidate_dir(edit.host_dir()); + if let Some(code) = self.tab_code_mut() { + code.selected = Some(new_path.clone()); + } + // A freshly created file opens straight into the editor. + if matches!(edit, TreeEdit::NewFile { .. }) { + self.open_file_in_editor(&new_path, window, cx); + } + } + Err(e) => { + use gpui_component::WindowExt as _; + window.push_notification(format!("{e}"), cx); + } + } + cx.notify(); + } + + /// Context-menu delete, with a native confirm (recursive for dirs). + fn file_tree_delete(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + let is_dir = path.is_dir(); + let detail = if is_dir { + "The folder and everything inside it will be deleted." + } else { + "The file will be deleted." + }; + let answer = window.prompt( + PromptLevel::Warning, + &format!("Delete \"{name}\"?"), + Some(detail), + &["Delete", "Cancel"], + cx, + ); + cx.spawn_in(window, async move |app, cx| { + let Ok(0) = answer.await else { return }; + let _ = app.update_in(cx, |app, window, cx| { + let result = if is_dir { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + match result { + Ok(()) => { + if let Some(parent) = path.parent() { + app.file_tree.invalidate_dir(parent); + } + if let Some(code) = app.tab_code_mut() + && code.selected.as_deref() == Some(&path) + { + code.selected = None; + } + cx.notify(); + } + Err(e) => { + use gpui_component::WindowExt as _; + window.push_notification(format!("Delete failed: {e}"), cx); + } + } + }); + }) + .detach(); + } + + /// "cd here": type `cd ` + Enter into the focused pane's PTY. + fn file_tree_cd(&mut self, dir: &Path, window: &mut Window, cx: &mut Context) { + let Some(leaf) = self + .tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + else { + return; + }; + leaf.read(cx) + .run_command_line(&format!("cd {}", shell_quote(dir))); + self.focus_active(window, cx); + } + + /// "Attach to agent": paste an `@path` reference into the pane running a + /// coding agent (unsubmitted, so the user can keep typing the prompt). + fn file_tree_attach_to_agent(&mut self, path: &Path, cx: &mut Context) { + let Some(target) = self.agent_target_leaf(cx) else { + crate::terminal::notify_desktop( + Some("tty7"), + "No running coding agent found — start one (claude, codex, …) in a pane first.", + ); + return; + }; + // Prefer a repo-relative path (what agents resolve best) when the file + // sits under one of the tree's roots. + let rel = self + .tab_code() + .into_iter() + .flat_map(|c| c.roots.iter()) + .find_map(|r| path.strip_prefix(r).ok()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| path.to_path_buf()); + target.update(cx, |view, cx| { + view.paste(format!("@{} ", rel.display()), cx); + }); + } +} + +/// Which inline edit a context-menu entry starts. +#[derive(Clone, Copy)] +enum TreeEditKind { + NewFile, + NewFolder, + Rename, +} + +// --------------------------------------------------------------------------- +// Rendering. +// --------------------------------------------------------------------------- + +impl Tty7App { + /// The file-tree column: the code overlay's left side (the overlay + /// renders the divider and the editor to its right). + /// Just the scrolling rows of the tree — no header, no fixed width, no + /// surface of its own — so a host that already has those (the right detail + /// panel) can drop the tree into its own column. Shares every bit of state + /// with [`render_file_tree_column`]: same roots, same expand set, same + /// click-to-open, so the panel and the code overlay are two views of one tree. + pub(crate) fn render_file_tree_rows( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let roots_empty = self.tab_code().map(|c| c.roots.is_empty()).unwrap_or(true); + // The tree is normally rooted when the code panel opens; the right panel + // can be the first thing to ask for it, so root it here too when empty. + if roots_empty { + self.file_tree_refresh_roots(window, cx); + } + let (roots, expanded) = match self.tab_code() { + Some(code) => (code.roots.clone(), code.expanded.clone()), + None => (Vec::new(), std::collections::HashSet::new()), + }; + let query = self.file_search.read(cx).value().trim().to_lowercase(); + let rows = if query.is_empty() { + self.file_tree.ensure_loaded(&roots, &expanded); + self.file_tree.visible_rows(&roots, &expanded) + } else { + self.file_tree.search_rows(&roots, &query) + }; + v_flex() + .id("right-panel-tree-rows") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px_1() + .pb_1() + // Keyboard nav (arrows / enter / rename) followed the tree out of the + // overlay: the rows still own the focus handle its key handler reads. + .track_focus(&self.file_tree.focus_handle) + .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { + this.file_tree_key_down(ev, window, cx); + })) + .children( + rows.iter() + .flat_map(|row| self.render_tree_row(row, window, cx)), + ) + .into_any_element() + } + + /// One row (plus, when an inline edit targets it, the edit input row). + fn render_tree_row( + &self, + row: &TreeRow, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + let path = row.entry.path.clone(); + let is_dir = row.entry.is_dir; + let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path); + let muted = cx.theme().muted_foreground; + // Unsaved edits used to be visible on the editor's file tabs; with those + // gone the tree is the only place an open buffer is represented, so it has + // to carry the dirty marker or unsaved work becomes invisible. + let dirty = self + .tab_code() + .is_some_and(|c| c.files.iter().any(|f| f.dirty && f.path == *path)); + + // Inline rename replaces the row's label with an input. + let renaming = matches!( + &self.file_tree.editing, + Some(TreeEdit::Rename { path: p, .. }) if *p == path + ); + + let icon = if row.is_root { + IconName::FolderOpen + } else if is_dir { + if row.expanded { + IconName::FolderOpen + } else { + IconName::Folder + } + } else { + IconName::File + }; + + let label: AnyElement = if renaming { + let input = self.file_tree.editing.as_ref().unwrap().input().clone(); + Input::new(&input).xsmall().into_any_element() + } else { + div() + .flex_1() + .min_w_0() + .text_ellipsis() + .text_sm() + .when(row.entry.ignored, |d| { + d.italic().text_color(muted.opacity(0.7)) + }) + .when(row.is_root, |d| d.font_weight(gpui::FontWeight::MEDIUM)) + .child(SharedString::from(row.entry.name.clone())) + .into_any_element() + }; + + let row_el = h_flex() + .id(SharedString::from(format!("tree-{}", path.display()))) + .items_center() + .gap_1() + .pl(px(6.0 + row.depth as f32 * INDENT)) + .pr_1() + .py_1() + .rounded(cx.theme().radius) + .cursor_pointer() + // Soft inset-pill highlight on the content surface. + .when(selected, |d| d.bg(cx.theme().accent)) + .when(!selected, |d| { + d.hover(|s| s.bg(cx.theme().accent.opacity(0.5))) + }) + // Folders take the full foreground, files the muted tone — a neutral + // weight difference, no hue, so the tree keeps the terminal's calm. + .child(Icon::new(icon).xsmall().text_color(if is_dir { + cx.theme().foreground + } else { + muted + })) + .child(label) + .when(dirty, |d| { + d.child( + div() + .flex_none() + .size(px(6.)) + .rounded_full() + .bg(cx.theme().warning), + ) + }) + .on_mouse_down( + MouseButton::Left, + cx.listener({ + let path = path.clone(); + move |this, _, window, cx| { + this.file_tree.focus_handle.focus(window, cx); + this.file_tree_activate(&path, is_dir, window, cx); + } + }), + ) + // Drag the row as external paths — the terminal's existing drop + // handler shell-escapes and inserts them. + .on_drag(ExternalPaths(vec![path.clone()].into()), { + let name = row.entry.name.clone(); + move |_, _, _, cx| { + let name = name.clone(); + cx.new(|_| DragGhost { name }) + } + }) + .context_menu({ + let app = cx.entity().downgrade(); + let path = path.clone(); + let is_root = row.is_root; + move |menu, _window, cx| { + let danger = cx.theme().danger; + Self::tree_row_context_menu(menu, &path, is_dir, is_root, danger, &app) + } + }); + + let mut out: Vec = vec![row_el.into_any_element()]; + + // New-file/new-folder edit input renders as a pseudo-child row of its + // host directory (right after the dir's own row). + if let Some(edit) = &self.file_tree.editing { + let host_matches = match edit { + TreeEdit::NewFile { dir, .. } | TreeEdit::NewFolder { dir, .. } => *dir == path, + TreeEdit::Rename { .. } => false, + }; + if host_matches { + let input = edit.input().clone(); + out.push( + h_flex() + .items_center() + .gap_1() + .pl(px(6.0 + (row.depth + 1) as f32 * INDENT)) + .pr_1() + .py_0p5() + .child(Input::new(&input).xsmall()) + .into_any_element(), + ); + } + } + out + } + + /// The per-row right-click menu, mirroring Warp's Project Explorer set. + fn tree_row_context_menu( + menu: PopupMenu, + path: &Path, + is_dir: bool, + is_root: bool, + danger: gpui::Hsla, + app: &gpui::WeakEntity, + ) -> PopupMenu { + let mut menu = menu.min_w(px(200.)); + let p = path.to_path_buf(); + + if !is_dir { + menu = menu.item(PopupMenuItem::new("Open").on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.open_file_in_editor(&p, window, cx)); + } + })); + } + if is_dir { + menu = menu.item(PopupMenuItem::new("cd Here").on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.file_tree_cd(&p, window, cx)); + } + })); + } + menu = menu + .item(PopupMenuItem::new("Insert Path in Terminal").on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + if let Some(leaf) = this + .tabs + .get(this.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + { + leaf.update(cx, |view, cx| view.paste(shell_quote(&p), cx)); + } + }); + } + })) + .item(PopupMenuItem::new("Attach to Agent").on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.file_tree_attach_to_agent(&p, cx)); + } + })) + .separator() + .item(PopupMenuItem::new("New File").on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.file_tree_begin_edit(TreeEditKind::NewFile, &p, window, cx) + }); + } + })) + .item(PopupMenuItem::new("New Folder").on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.file_tree_begin_edit(TreeEditKind::NewFolder, &p, window, cx) + }); + } + })); + + if !is_root { + menu = menu.item(PopupMenuItem::new("Rename").on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.file_tree_begin_edit(TreeEditKind::Rename, &p, window, cx) + }); + } + })); + } + + menu = menu + .separator() + .item(PopupMenuItem::new("Copy Path").on_click({ + let p = p.clone(); + move |_, _window, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(p.display().to_string())); + } + })) + .item(PopupMenuItem::new("Reveal in Finder").on_click({ + let p = p.clone(); + move |_, _window, cx| { + cx.reveal_path(&p); + } + })); + + if !is_root { + menu = menu.separator().item( + PopupMenuItem::element(move |_window, _cx| { + div().text_color(danger).child("Delete") + }) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let p = p.clone(); + let _ = app.update(cx, |this, cx| this.file_tree_delete(p, window, cx)); + } + }), + ); + } + menu + } +} + +/// The little drag ghost shown while a row is dragged toward a terminal. +struct DragGhost { + name: String, +} + +impl gpui::Render for DragGhost { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .items_center() + .gap_1() + .px_2() + .py_1() + .rounded(cx.theme().radius) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .text_sm() + .child(Icon::new(IconName::File).xsmall()) + .child(SharedString::from(self.name.clone())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str, is_dir: bool) -> TreeEntry { + TreeEntry { + name: name.to_string(), + path: PathBuf::from(format!("/x/{name}")), + is_dir, + ignored: false, + } + } + + #[test] + fn sort_puts_dirs_first_then_case_insensitive_names() { + let mut v = vec![ + entry("zeta.rs", false), + entry("Alpha", true), + entry("beta", true), + entry("Apple.rs", false), + ]; + sort_entries(&mut v); + let names: Vec<&str> = v.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["Alpha", "beta", "Apple.rs", "zeta.rs"]); + } + + #[test] + fn shell_quote_leaves_safe_paths_and_quotes_the_rest() { + assert_eq!(shell_quote(Path::new("/a/b.txt")), "/a/b.txt"); + assert_eq!(shell_quote(Path::new("/a dir/f")), "'/a dir/f'"); + assert_eq!(shell_quote(Path::new("/a'b")), r"'/a'\''b'"); + } + + #[test] + fn repo_root_walks_up_to_git() { + let tmp = std::env::temp_dir().join(format!("tty7-tree-test-{}", std::process::id())); + let nested = tmp.join("repo/src/deep"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(tmp.join("repo/.git")).unwrap(); + assert_eq!(repo_root_for(&nested), Some(tmp.join("repo"))); + assert_eq!(repo_root_for(&tmp), None); + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index c55a9f8c..33a84c2f 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -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 { "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 { "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), diff --git a/src/ui/lsp.rs b/src/ui/lsp.rs new file mode 100644 index 00000000..439c599a --- /dev/null +++ b/src/ui/lsp.rs @@ -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 { + 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 { + url::Url::parse(uri).ok()?.to_file_path().ok() +} + +// --------------------------------------------------------------------------- +// Client. +// --------------------------------------------------------------------------- + +/// Thread-shared client internals (UI thread + reader thread). +struct Inner { + name: String, + stdin: Mutex, + /// In-flight requests by id; the reader thread resolves them. + pending: Mutex>>, + /// Flips true when the `initialize` response lands; frames sent before + /// that wait in `queued`. + ready: AtomicBool, + queued: Mutex>, + 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 = 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, + child: std::cell::RefCell, + #[allow(dead_code)] // identifies the client in future workspace-level requests + pub(crate) root: PathBuf, + /// didOpen version counter per document. + versions: std::cell::RefCell>, +} + +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)>, + ) -> Result { + 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 { + 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 { + 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, + diag_tx: smol::channel::Sender<(PathBuf, Vec)>, +) { + let mut reader = BufReader::new(stdout); + loop { + // Headers. + let mut content_length: Option = 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::(&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 = 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>>, + diag_tx: smol::channel::Sender<(PathBuf, Vec)>, +} + +impl LspRegistry { + pub(crate) fn new(window: &mut Window, cx: &mut Context) -> 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)>(); + 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, &'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, + pub(crate) path: PathBuf, +} + +/// Await one LSP response with a timeout, off the UI thread. +async fn recv_with_timeout(rx: smol::channel::Receiver) -> Result { + 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, + ) -> Task> { + 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, + ) -> 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>> { + 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>> { + 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 { + if v.is_null() { + return vec![]; + } + if let Ok(links) = serde_json::from_value::>(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::>(v.clone()) { + return locs.into_iter().map(to_link).collect(); + } + if let Ok(loc) = serde_json::from_value::(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()); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 28ccf95b..5d5757fd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 84b8045d..906c1253 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -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), diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs new file mode 100644 index 00000000..aae89639 --- /dev/null +++ b/src/ui/right_panel.rs @@ -0,0 +1,1173 @@ +//! The right detail panel: a docked column showing what the active pane *is*, +//! rather than what it's printing — session facts, its working-tree diff, and +//! its file tree. +//! +//! It splits across two hosts on purpose. The **tab row lives in the title bar** +//! (built in [`tab_strip`](crate::ui::tab_strip)), so the panel's controls sit on +//! the same line as the window's own chrome instead of stacking a second 40px bar +//! under it; the **body** is this module's column inside `body_area`. The two are +//! kept in register by both measuring from `Config::right_panel_width`, so the +//! tabs sit exactly over the content they switch. +//! +//! No new source of truth: Info reads the same `TerminalView`/`Tab` accessors the +//! sidebar row does, Changes probes the same `git_diff` the diff overlay does, and +//! Files renders the same rows as the code panel's tree. + +use gpui::{AnyElement, Context, MouseButton, Window, WindowControlArea, div, prelude::*, px}; +use gpui_component::button::Button; +use gpui_component::input::Input; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex, +}; +use std::cell::Cell; +use std::path::PathBuf; +use std::rc::Rc; + +use crate::core::config::{Config, RightPanelTab}; +use crate::daemon::protocol::PaneProcs; +use crate::terminal::git_diff::{self, DiffSnapshot}; +use crate::ui::app::{CONTENT_INSET, Tty7App}; + +/// Bounds for the panel's width, mirroring the rail's: a floor so the tree never +/// becomes an ellipsis parade, and a ceiling as a fraction of the window so a +/// persisted value can't swallow the terminal. +pub(crate) const MIN_WIDTH: f32 = 200.; +pub(crate) const MAX_WIDTH_RATIO: f32 = 0.5; + +/// Width (px) of the resize handle's invisible hit-area, centered on the panel's +/// left border — same geometry as the tab rail's. +const RESIZE_HANDLE_WIDTH: f32 = 8.; + +/// Panel state that isn't a user preference (those live in `Config`): the cached +/// diff for the Changes tab and the body's scroll position. +#[derive(Default)] +pub(crate) struct RightPanelState { + /// The cwd `diff` was probed from — compared against the active pane's cwd to + /// decide whether the cached snapshot is still about the right repository. + pub(crate) diff_cwd: Option, + /// Last completed probe. `Some(None)` and `None` are different answers: + /// "probed, not a work tree" versus "never probed". + pub(crate) diff: Option>, + /// A probe is in flight; keeps the render path from spawning a second one. + pub(crate) diff_loading: bool, + /// The pane `procs` describes, so a pane switch invalidates it rather than + /// showing the previous pane's processes under the new pane's name. + pub(crate) procs_pane: Option, + /// Last completed process/port query for `procs_pane`. + pub(crate) procs: Option, + /// A poll cycle is live — a query is in flight *or* the inter-tick timer is + /// waiting between ticks. The render path checks this before starting the + /// loop, so a re-render never starts a second chain. It must stay set across + /// the timer too: clearing it the instant a query returned let every repaint + /// in the 2s gap kick off another query, collapsing the interval into a tight + /// query→notify→repaint→query loop that made the list flicker. + pub(crate) procs_loading: bool, + /// Bumped on every pane switch to retire the in-flight poll loop: a tick whose + /// generation no longer matches drops its result and stops rescheduling, so the + /// freshly started loop for the new pane is the only one left running. + pub(crate) procs_gen: u64, +} + +/// How often the Info tab re-queries processes and ports while it's open. Fast +/// enough that starting a dev server shows up as you tab over, slow enough that +/// the process-table walk stays off the profile. +const PROCS_POLL: std::time::Duration = std::time::Duration::from_millis(2000); + +impl Tty7App { + /// Whether the right panel is docked open. The title bar's tab row, the body + /// column and the code overlay's right inset all derive from this. + pub(crate) fn right_panel_open(&self, cx: &gpui::App) -> bool { + cx.global::().right_panel_visible && !self.tabs.is_empty() + } + + /// The panel's live width, re-clamped to the window the same way the rail's + /// is, so a persisted value from a larger display can't take over. + /// Named `_px` rather than `_width` because the field it reads is + /// `right_panel_width`; a method of the same name would shadow it awkwardly + /// at every call site. + pub(crate) fn right_panel_px(&self, window: &Window, _cx: &gpui::App) -> f32 { + let max = (window.viewport_size().width.as_f32() * MAX_WIDTH_RATIO).max(MIN_WIDTH); + // The live cell, not the config: a drag in progress writes only here, and + // persists to the config on release. + self.right_panel_width.get().clamp(MIN_WIDTH, max) + } + + /// `ToggleRightPanel` (⌘J). + pub(crate) fn toggle_right_panel(&mut self, cx: &mut Context) { + let next = !cx.global::().right_panel_visible; + self.update_config(cx, |cfg| cfg.right_panel_visible = next); + } + + /// Select a tab. Opens the panel if it was closed, so the title bar's tab + /// tiles double as "show me this" rather than being inert while hidden. + pub(crate) fn set_right_panel_tab(&mut self, tab: RightPanelTab, cx: &mut Context) { + self.update_config(cx, |cfg| { + cfg.right_panel_tab = tab; + cfg.right_panel_visible = true; + }); + } + + /// The docked column, or `None` while the panel is closed. + pub(crate) fn render_right_panel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if !self.right_panel_open(cx) { + return None; + } + let width = self.right_panel_px(window, cx); + let tab = cx.global::().right_panel_tab; + + let body = match tab { + RightPanelTab::Info => self.render_panel_info(window, cx), + RightPanelTab::Outline => self.render_panel_outline(window, cx), + RightPanelTab::Changes => self.render_panel_changes(window, cx), + RightPanelTab::Files => self.render_panel_files(window, cx), + }; + let (backing, handle) = self.right_panel_resize(cx); + + Some( + v_flex() + .id("right-panel") + .relative() + .flex_none() + .w(px(width)) + .h_full() + .child(backing) + // The sunk sidebar surface, like the tab rail: both are chrome + // around the terminal, so they read as the same material. + .bg(cx.theme().sidebar) + .border_l_1() + .border_color(cx.theme().sidebar_border) + // A title-bar-height top zone of its own, exactly like the rail's. + // This is what makes the panel read as one column instead of a box + // bolted under the title bar: its surface runs the full height of + // the window, and the tab row sits *on* it rather than on the + // terminal's bar above a seam. + .child({ + // The top zone sits level with the real `TitleBar`, but the + // bar only spans the terminal column — so, exactly like the + // rail's top strip (`tab_sidebar`), make this one act like the + // title bar it aligns with: drag to move, double-click to zoom. + // A press arms a flag and the first *move* starts the window + // move, so a plain click on a tab — and a double-click — still + // lands intact; the tabs and corner chrome take their own. + let should_move = Rc::new(Cell::new(false)); + h_flex() + .id("right-panel-titlebar-drag") + .flex_none() + .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) + // gpui-component's `TitleBar` centres its content inside a + // `border_b_1` box — border-box shrinks the content height + // by that 1px, nudging its centred glyphs up half a pixel. + // The corner chrome (⋯, panel toggle) lives in *both* the + // title bar and here, so mirror that hidden border to keep + // its centre line identical; without it the glyphs jump + // down a physical pixel the moment the panel opens. + .border_b_1() + .border_color(cx.theme().transparent) + .window_control_area(WindowControlArea::Drag) + .on_mouse_down(MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(true) + }) + .on_mouse_up(MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(false) + }) + .on_mouse_move(move |_, window, _| { + if should_move.replace(false) { + window.start_window_move(); + } + }) + .on_double_click(|_, window, _| window.titlebar_double_click()) + .items_center() + .gap(px(2.)) + .pl(px(CONTENT_INSET - crate::ui::app::TILE_PAD)) + .children(self.right_panel_tabs(cx)) + .child(div().flex_1()) + // The panel is what reaches the window's right edge while + // it's open, so it carries the corner chrome. + .child(self.window_chrome(window, cx)) + }) + .child(body) + .child(handle) + .into_any_element(), + ) + } + + /// The panel's resize drag: a measuring canvas that installs window-level + /// mouse listeners while held, plus the handle itself. Mirrors the tab rail's + /// (`tab_sidebar.rs`) with the axis flipped — this panel is anchored to the + /// window's right edge, so width grows as the pointer moves *left*, measured + /// from the panel's own right edge rather than its origin. + fn right_panel_resize(&self, cx: &mut Context) -> (AnyElement, AnyElement) { + use gpui::{Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, canvas}; + use std::cell::Cell as StdCell; + use std::rc::Rc; + + let container: Rc>>> = Rc::new(StdCell::new(None)); + let backing = canvas( + { + let container = container.clone(); + move |bounds, _window, _cx| container.set(Some(bounds)) + }, + { + let container = container.clone(); + let width_cell = self.right_panel_width.clone(); + let dragging = self.right_panel_dragging.clone(); + move |_bounds, _state, window, _cx| { + window.on_mouse_event({ + let container = container.clone(); + let width_cell = width_cell.clone(); + let dragging = dragging.clone(); + move |ev: &MouseMoveEvent, _phase, window, _cx| { + if !dragging.get() { + return; + } + let Some(b) = container.get() else { + return; + }; + let right = b.origin.x + b.size.width; + let raw = (right - ev.position.x).as_f32(); + let max = (window.viewport_size().width.as_f32() * MAX_WIDTH_RATIO) + .max(MIN_WIDTH); + width_cell.set(raw.clamp(MIN_WIDTH, max)); + window.refresh(); + } + }); + window.on_mouse_event({ + let width_cell = width_cell.clone(); + let dragging = dragging.clone(); + move |_ev: &MouseUpEvent, _phase, window, cx| { + if !dragging.get() { + return; + } + dragging.set(false); + let w = width_cell.get(); + let cfg = cx.global_mut::(); + if cfg.right_panel_width != w { + cfg.right_panel_width = w; + cfg.save(); + } + window.refresh(); + } + }); + } + }, + ) + .absolute() + .size_full() + .into_any_element(); + + let active = self.right_panel_dragging.get(); + let handle = div() + .group("right-panel-resize") + .absolute() + .top_0() + .left(px(-(RESIZE_HANDLE_WIDTH / 2.))) + .w(px(RESIZE_HANDLE_WIDTH)) + .h_full() + .flex() + .items_center() + .justify_center() + .cursor_col_resize() + .child( + div() + .w(px(1.)) + .h_full() + .when(active, |d| d.bg(cx.theme().drag_border)) + .group_hover("right-panel-resize", |s| s.bg(cx.theme().drag_border)), + ) + .on_mouse_down(MouseButton::Left, { + let dragging = self.right_panel_dragging.clone(); + move |_ev, window, _cx| { + dragging.set(true); + window.refresh(); + } + }) + .into_any_element(); + + (backing, handle) + } + + /// A section label inside the panel body — the small caps line that names + /// what the icon-only tab row can't. `trailing` carries a tab's own controls + /// where it has any, so they sit on the label's line rather than earning a + /// second header row. + /// A tab's header: the name in a weightier small-caps than the old faint + /// label, plus an optional live count trailing it (files, commands, changed + /// files) so the header states scale at a glance, and an optional control on + /// the right. The count is the quiet mono tally the sidebar group headers use. + fn panel_title( + &self, + text: &str, + count: Option, + trailing: Option, + cx: &mut Context, + ) -> AnyElement { + h_flex() + .flex_none() + .h(px(32.)) + .items_center() + .justify_between() + .pl(px(CONTENT_INSET)) + // Trailing tiles align on the glyph like every other control in the + // window; a label-only header just takes the plain inset. + .pr(px(if trailing.is_some() { + CONTENT_INSET - crate::ui::app::TILE_PAD + } else { + CONTENT_INSET + })) + .child( + h_flex() + .items_baseline() + .gap(px(7.)) + .child( + div() + .text_size(px(11.5)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().secondary_foreground) + .child(text.to_uppercase()), + ) + .when_some(count, |this, c| { + this.child( + div() + .text_size(px(11.)) + .font_family(cx.theme().mono_font_family.clone()) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(c), + ) + }), + ) + .when_some(trailing, |this, t| this.child(t)) + .into_any_element() + } + + /// The Files header's one control. No refresh button: the tree runs a + /// recursive filesystem watcher over its roots and invalidates its own caches, + /// so a manual refresh is a button that does what already happened. + fn files_controls(&self, cx: &mut Context) -> AnyElement { + let show_hidden = self.file_tree.show_hidden; + crate::ui::tab_strip::chrome_tile( + Button::new("panel-tree-hidden").icon(Icon::new(IconName::Eye).size(px(13.))), + show_hidden, + cx, + ) + .xsmall() + .w(px(24.)) + .h(px(24.)) + .rounded_md() + .tooltip(if show_hidden { + "Hide dotfiles" + } else { + "Show dotfiles" + }) + .on_click(cx.listener(|this, _, _w, cx| { + this.file_tree.show_hidden = !this.file_tree.show_hidden; + cx.notify(); + })) + .into_any_element() + } + + /// The Files tab's filter box — the same borderless magnifier + input the tab + /// rail uses, so the two panels search the same way. Sits under the header + /// rather than in it: it's a full-width control, not a trailing tile. + fn files_search(&self, cx: &mut Context) -> AnyElement { + h_flex() + .flex_none() + .items_center() + .gap(px(8.)) + .h(px(30.)) + .px(px(CONTENT_INSET)) + .child( + Icon::new(IconName::Search) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .flex_1() + .min_w_0() + .child(Input::new(&self.file_search).appearance(false).xsmall()), + ) + .into_any_element() + } + + /// The body's scrolling area, so every tab shares one scroll container and + /// one content inset. + fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement { + v_flex() + .flex_1() + .min_h_0() + .child(title) + .child( + div() + .id("right-panel-body") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child(inner), + ) + .into_any_element() + } + + /// A quiet "nothing to show" line, used wherever a tab has no data yet. + fn panel_empty(&self, text: &str, cx: &mut Context) -> AnyElement { + div() + .px(px(CONTENT_INSET)) + .py(px(4.)) + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child(text.to_string()) + .into_any_element() + } + + // ── Info ──────────────────────────────────────────────────────────────── + + /// Session facts for the active pane, as a two-column key/value list. Every + /// row comes from an accessor the sidebar already uses, so the panel can + /// never disagree with the row that spawned it. + fn render_panel_info(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let title = self.panel_title("Info", None, None, cx); + let mut rows: Vec<(&'static str, String)> = Vec::new(); + // Held aside from `rows` because they're not key/value lines: the actions + // hang off the cwd, and the two lists get their own sub-headers below. + let mut cwd_for_actions: Option = None; + let mut pane_id: Option = None; + + if let Some(tab) = self.tabs.get(self.active) { + if let Some(leaf) = tab.detail_pane(window, cx) { + let view = leaf.read(cx); + pane_id = Some(view.pane_id); + if let Some(cwd) = view + .git_status_cwd() + .map(|p| p.to_path_buf()) + .or_else(|| view.cwd()) + { + rows.push(("cwd", compact_path(&cwd))); + cwd_for_actions = Some(cwd); + } + let shell = view.shell_spec().map(|s| s.program.clone()); + rows.push(( + "shell", + crate::core::shells::default_shell_name(shell.as_deref()), + )); + if let Some(ssh) = view.ssh_spec() { + rows.push(("ssh", ssh.host.clone())); + } + } + if let Some(git) = tab.git_status(Some(window), cx) { + rows.push(("branch", git.branch.clone())); + rows.push(("changes", format!("+{} −{}", git.added, git.removed))); + } + if let Some(agent) = tab.agent(cx) { + let name = agent.display_name(); + let status = match tab.agent_status(cx) { + Some(s) => format!("{name} · {}", agent_status_label(s)), + None => name.to_string(), + }; + rows.push(("agent", status)); + } + } + + if rows.is_empty() { + return self.panel_scroll(self.panel_empty("No active session.", cx), title); + } + + // Keep the process/port query pointed at the pane on screen, and keep it + // ticking while this tab is the one being looked at. + self.sync_procs(pane_id, cx); + + let mono = cx.theme().mono_font_family.clone(); + let mut list = v_flex().px(px(CONTENT_INSET)).py(px(2.)).gap(px(3.)); + for (k, v) in rows { + list = list.child( + h_flex() + .items_baseline() + .gap(px(9.)) + .py(px(1.)) + .text_size(px(12.)) + .child( + div() + .flex_none() + .w(px(46.)) + .text_color(cx.theme().muted_foreground) + .child(k), + ) + .child( + // The value is the datum — a path, a branch, a host, a + // count — so it takes the mono face, set apart from the + // sans key beside it. + div() + .flex_1() + .min_w_0() + .truncate() + .font_family(mono.clone()) + .text_color(cx.theme().foreground) + .child(v), + ), + ); + } + + let inner = v_flex() + // Three labelled bands — Session / Processes / Ports — instead of one + // flat column, so the pane's facts, what it's running, and what it's + // listening on read as distinct groups. + .child(self.panel_subtitle("Session", false, cx)) + .child(list) + .when_some(cwd_for_actions, |this, cwd| { + this.child(self.cwd_actions(cwd, cx)) + }) + .children(self.procs_section(pane_id, cx)) + .children(self.ports_section(pane_id, cx)) + .into_any_element(); + self.panel_scroll(inner, title) + } + + /// The "open this cwd in…" row under the Info list. Deliberately only the + /// destinations that need no configuration — a system reveal and the + /// clipboard. An "open in $EDITOR" button would need a picker, a stored + /// choice and a settings page to change it; that's a feature, not a row. + fn cwd_actions(&self, cwd: PathBuf, cx: &mut Context) -> AnyElement { + let reveal_label = if cfg!(target_os = "macos") { + "Reveal in Finder" + } else { + "Open Folder" + }; + h_flex() + .gap(px(2.)) + .px(px(CONTENT_INSET - crate::ui::app::TILE_PAD)) + .pt(px(6.)) + .child( + crate::ui::tab_strip::chrome_tile( + Button::new("panel-info-reveal") + .icon(Icon::new(IconName::FolderOpen).size(px(13.))), + false, + cx, + ) + .xsmall() + .w(px(24.)) + .h(px(24.)) + .rounded_md() + .tooltip(reveal_label) + .on_click({ + let cwd = cwd.clone(); + move |_, _window, cx| cx.reveal_path(&cwd) + }), + ) + .child( + crate::ui::tab_strip::chrome_tile( + Button::new("panel-info-copy-path") + .icon(Icon::new(IconName::Copy).size(px(13.))), + false, + cx, + ) + .xsmall() + .w(px(24.)) + .h(px(24.)) + .rounded_md() + .tooltip("Copy Path") + .on_click(move |_, _window, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + cwd.display().to_string(), + )); + }), + ) + .into_any_element() + } + + /// A small-caps band label inside a tab's body, for the sub-lists that hang + /// off the Info tab. Lighter than [`panel_title`], which is the tab's own + /// header. `divider` draws a hairline above it, so the second and third bands + /// separate from the one before; the first band passes `false`. + fn panel_subtitle(&self, text: &str, divider: bool, cx: &mut Context) -> AnyElement { + div() + .when(divider, |d| { + d.mt(px(6.)).border_t_1().border_color(cx.theme().border) + }) + .px(px(CONTENT_INSET)) + .pt(px(if divider { 12. } else { 10. })) + .pb(px(4.)) + .text_size(px(10.5)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(text.to_uppercase()) + .into_any_element() + } + + /// The pane's process tree, indented by depth. Returns nothing at all when + /// the pane is just a shell sitting at its prompt: a one-row "processes" + /// section that always says `zsh` is a header earning its keep zero times. + fn procs_section(&self, pane_id: Option, cx: &mut Context) -> Option { + let procs = &self.procs(pane_id)?.procs; + if procs.len() < 2 { + return None; + } + let mono = cx.theme().mono_font_family.clone(); + let mut list = v_flex().px(px(CONTENT_INSET)).py(px(1.)).gap(px(2.)); + for p in procs { + list = list.child( + h_flex() + .items_center() + .gap(px(8.)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + // Indent by depth so the tree reads without drawing + // connector glyphs into a 260px column. + .pl(px(f32::from(p.depth) * 10.)) + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(if p.foreground { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }) + .child(p.name.clone()), + ) + .child(info_chip( + &p.pid.to_string(), + cx.theme().accent, + cx.theme().muted_foreground, + &mono, + )), + ); + } + Some( + v_flex() + .child(self.panel_subtitle("Processes", true, cx)) + .child(list) + .into_any_element(), + ) + } + + /// TCP ports the pane's processes are listening on — the answer to "what + /// port did that dev server pick?", next to the pane that started it. + fn ports_section(&self, pane_id: Option, cx: &mut Context) -> Option { + let ports = &self.procs(pane_id)?.ports; + if ports.is_empty() { + return None; + } + let mono = cx.theme().mono_font_family.clone(); + let mut list = v_flex().px(px(CONTENT_INSET)).py(px(1.)).gap(px(2.)); + for p in ports { + list = list.child( + h_flex() + .items_center() + .gap(px(8.)) + .child(info_chip( + &p.port.to_string(), + cx.theme().accent, + cx.theme().foreground, + &mono, + )) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(cx.theme().muted_foreground) + .child(p.name.clone()), + ), + ); + } + Some( + v_flex() + .child(self.panel_subtitle("Ports", true, cx)) + .child(list) + .into_any_element(), + ) + } + + /// The cached query, but only when it describes `pane_id` — the pane the + /// Info tab is currently rendering. `sync_procs` already drops the answer on + /// a pane switch, so this is belt-and-braces; without the argument the doc + /// claimed a guarantee the body didn't actually make. + fn procs(&self, pane_id: Option) -> Option<&PaneProcs> { + (pane_id.is_some() && self.right_panel.procs_pane == pane_id) + .then(|| self.right_panel.procs.as_ref())? + } + + /// Point the process query at `pane_id` and make sure the poll is running. + /// Called from the Info tab's render, so the loop starts when the tab is + /// looked at and dies when it isn't — see [`spawn_procs_query`]. + fn sync_procs(&mut self, pane_id: Option, cx: &mut Context) { + let Some(pane_id) = pane_id else { return }; + if self.right_panel.procs_pane != Some(pane_id) { + self.right_panel.procs_pane = Some(pane_id); + // Drop the previous pane's answer rather than showing it under the new + // pane's heading until the first tick lands. + self.right_panel.procs = None; + // Retire the old pane's loop and free the guard so the new pane's loop + // can start below; the retired tick bows out on the generation check. + self.right_panel.procs_gen += 1; + self.right_panel.procs_loading = false; + } + if !self.right_panel.procs_loading { + self.right_panel.procs_loading = true; + let generation = self.right_panel.procs_gen; + self.spawn_procs_query(pane_id, generation, cx); + } + } + + /// One query, then reschedule — the poll loop. It reschedules only while the + /// panel is open on Info, so the loop is self-terminating: close the panel or + /// switch tabs and the next completion simply doesn't queue another. + fn spawn_procs_query(&mut self, pane_id: u64, generation: u64, cx: &mut Context) { + // `procs_loading` is set by the caller (`sync_procs`) and deliberately + // stays set across the whole cycle, including the timer wait below. + cx.spawn(async move |this, cx| { + let procs = cx + .background_executor() + .spawn(async move { crate::terminal::RemoteTerminal::query_procs(pane_id) }) + .await; + let keep_polling = this + .update(cx, |app, cx| { + // A pane switch while we flew bumped the generation: drop this + // answer and leave the guard to whoever owns the new one. + if app.right_panel.procs_gen != generation { + return false; + } + app.right_panel.procs = Some(procs); + cx.notify(); + let cfg = cx.global::(); + let wanted = + cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info; + if !wanted { + // Loop ends here; release the guard so reopening restarts it. + app.right_panel.procs_loading = false; + } + wanted + }) + .unwrap_or(false); + if !keep_polling { + return; + } + cx.background_executor().timer(PROCS_POLL).await; + let _ = this.update(cx, |app, cx| { + // Re-check rather than trusting the pre-sleep decision: two seconds + // is plenty of time to switch panes or close the panel. + if app.right_panel.procs_gen != generation { + return; + } + let cfg = cx.global::(); + let wanted = cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info; + if wanted { + app.spawn_procs_query(pane_id, generation, cx); + } else { + app.right_panel.procs_loading = false; + } + }); + }) + .detach(); + } + + // ── Outline ───────────────────────────────────────────────────────────── + + /// The pane's commands, newest first, each scrolling the terminal back to + /// where it ran. Positions come from the OSC 133 marks the reader thread + /// records — see [`crate::terminal::marks`]. + /// + /// Newest first because that's the end you came from: you scrolled past the + /// thing you want, and the list should start where your attention is. + fn render_panel_outline(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(leaf) = self + .tabs + .get(self.active) + .and_then(|t| t.detail_pane(window, cx)) + else { + let title = self.panel_title("Outline", None, None, cx); + return self.panel_scroll(self.panel_empty("No active session.", cx), title); + }; + // Count first (a cheap getter) so the borrow ends before `panel_title` + // needs `&mut cx`; the list re-borrows the marks below. + let count = leaf.read(cx).command_marks().len(); + if count == 0 { + // Two very different causes, one honest sentence: nothing has run + // yet, or this shell never reported OSC 133 (no integration, a bare + // `sh`, a nested PTY that eats the marks). + let title = self.panel_title("Outline", None, None, cx); + return self.panel_scroll( + self.panel_empty("No commands recorded for this pane.", cx), + title, + ); + } + let title = self.panel_title("Outline", Some(count.to_string()), None, cx); + + let mono = cx.theme().mono_font_family.clone(); + let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); + let marks = leaf.read(cx).command_marks(); + for mark in marks.iter().rev() { + let row = mark.row; + let leaf = leaf.clone(); + let failed = mark.exit.is_some_and(|c| c != 0); + let running = !mark.done; + // A leading status marker reads as a shape first: a hollow ring for a + // clean finish, a filled dot while it runs, and — the only tinted one + // — a danger dot for a nonzero exit. The failure is what you scan for. + let dot = { + let d = div().flex_none().size(px(7.)).rounded_full(); + if failed { + d.bg(cx.theme().danger) + } else if running { + d.bg(cx.theme().muted_foreground) + } else { + d.border_1() + .border_color(cx.theme().muted_foreground.opacity(0.55)) + } + }; + list = list.child( + h_flex() + .id(gpui::SharedString::from(format!("panel-mark-{row}"))) + .items_center() + .gap(px(8.)) + .px(px(4.)) + .py(px(3.)) + .rounded(px(5.)) + .cursor_pointer() + .hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55))) + .on_click(cx.listener(move |_this, _, _window, cx| { + leaf.update(cx, |view, cx| { + view.scroll_to_mark(row, cx); + }); + })) + .child(dot) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + // Commands are code: the mono face sets them apart from + // the sans labels and lines the list up like a log. + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(if failed { + cx.theme().danger + } else { + cx.theme().foreground + }) + .child(one_line(&mark.text)), + ) + // Only nonzero exits earn a badge. Annotating every success + // with a `0` would make the failures harder to spot, not + // easier — the whole point of the column. + .when_some(mark.exit.filter(|c| *c != 0), |this, code| { + this.child( + div() + .flex_none() + .text_size(px(10.5)) + .font_family(mono.clone()) + .text_color(cx.theme().danger) + .child(code.to_string()), + ) + }), + ); + } + self.panel_scroll(list.into_any_element(), title) + } + + // ── Changes ───────────────────────────────────────────────────────────── + + /// The working-tree diff as a compact file list — path plus `+N −M` — not the + /// diff overlay's hunk cards, which need far more than 260px to be readable. + /// Clicking a row opens the full overlay on that repo. + fn render_panel_changes(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let cwd = self + .tabs + .get(self.active) + .and_then(|t| t.detail_pane(window, cx)) + .and_then(|leaf| { + let v = leaf.read(cx); + v.git_status_cwd() + .map(|p| p.to_path_buf()) + .or_else(|| v.cwd()) + }); + + let Some(cwd) = cwd else { + let title = self.panel_title("Changes", None, None, cx); + return self.panel_scroll(self.panel_empty("No working directory.", cx), title); + }; + // Probe on first paint for this cwd, and whenever the pane moves to a + // different repository. Refreshes ride the same git-status observer the + // sidebar counts do (see `right_panel_refresh_changes`), which re-probes + // *in place* — the list only blanks when the repository itself changes. + if self.right_panel.diff_cwd.as_ref() != Some(&cwd) { + self.right_panel.diff_cwd = Some(cwd.clone()); + self.right_panel.diff = None; + self.spawn_right_panel_diff(cwd.clone(), cx); + } else if self.right_panel.diff.is_none() && !self.right_panel.diff_loading { + // Nothing cached and nothing in flight: a probe for a previous cwd + // landed after we had already moved on and dropped its result, so + // no one is left to answer for this one. Without this the tab would + // sit on "Loading…" until some unrelated event nudged it. + self.spawn_right_panel_diff(cwd.clone(), cx); + } + + // Count of changed files for the header tally — computed before the title + // so the diff borrow ends before `panel_title` takes `&mut cx`. + let count = match &self.right_panel.diff { + Some(Some(snap)) => { + let n = snap.files.len() + snap.untracked.len(); + (n > 0).then(|| n.to_string()) + } + _ => None, + }; + let title = self.panel_title("Changes", count, None, cx); + let mono = cx.theme().mono_font_family.clone(); + + let inner = match &self.right_panel.diff { + None => self.panel_empty("Loading…", cx), + Some(None) => self.panel_empty("Not a git work tree.", cx), + Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => { + self.panel_empty("No changes.", cx) + } + Some(Some(snap)) => { + let files: Vec<(String, u32, u32)> = snap + .files + .iter() + .map(|f| (f.path.clone(), f.added, f.removed)) + .collect(); + let untracked = snap.untracked.clone(); + let focused = self.diff_overlay_focus(&cwd).map(str::to_string); + // Rows inset themselves rather than the list, so the hover and + // selected capsules bleed a little past the text into the same + // 12px gutter the tab rail's rows use. + let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); + for (path, added, removed) in files { + let selected = focused.as_deref() == Some(path.as_str()); + list = list.child( + h_flex() + .id(gpui::SharedString::from(format!("panel-change-{path}"))) + .items_center() + .gap(px(8.)) + .px(px(4.)) + .py(px(3.)) + .rounded(px(5.)) + .cursor_pointer() + .hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55))) + .when(selected, |s| s.bg(cx.theme().sidebar_accent)) + .on_click({ + let cwd = cwd.clone(); + let path = path.clone(); + cx.listener(move |this, _, window, cx| { + // Toggling on the same row closes the overlay, + // so a row is a switch for "show me this diff", + // not a one-way door. + this.toggle_diff_overlay_at( + cwd.clone(), + Some(path.clone()), + window, + cx, + ); + }) + }) + // A neutral status letter, kind by glyph not by hue — + // tracked edits are `M`; untracked get `U` below. + .child(git_badge("M", cx.theme().muted_foreground, &mono)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(cx.theme().foreground) + .child(path), + ) + // +N / −M keep the terminal-git greens and reds, the + // one place hue earns its keep; a zero side is dropped + // rather than shown as `+0`. + .when(added > 0, |this| { + this.child( + div() + .flex_none() + .text_size(px(11.)) + .font_family(mono.clone()) + .text_color(cx.theme().success) + .child(format!("+{added}")), + ) + }) + .when(removed > 0, |this| { + this.child( + div() + .flex_none() + .text_size(px(11.)) + .font_family(mono.clone()) + .text_color(cx.theme().danger) + .child(format!("−{removed}")), + ) + }), + ); + } + if !untracked.is_empty() { + list = list.child( + h_flex() + .items_center() + .gap(px(8.)) + .px(px(4.)) + .py(px(3.)) + .child(git_badge( + "U", + cx.theme().muted_foreground.opacity(0.75), + &mono, + )) + .child( + div() + .text_size(px(11.5)) + .text_color(cx.theme().muted_foreground) + .child(format!("{} untracked", untracked.len())), + ), + ); + } + list.into_any_element() + } + }; + self.panel_scroll(inner, title) + } + + /// Off-thread `git diff` for the panel, mirroring the diff overlay's probe. + fn spawn_right_panel_diff(&mut self, cwd: PathBuf, cx: &mut Context) { + if self.right_panel.diff_loading { + return; + } + self.right_panel.diff_loading = true; + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn({ + let cwd = cwd.clone(); + async move { git_diff::probe(&cwd) } + }) + .await; + let _ = this.update(cx, |app, cx| { + app.right_panel.diff_loading = false; + // Drop the result if the panel moved on to another repo while we + // flew — otherwise a slow probe would overwrite a newer one. + if app.right_panel.diff_cwd.as_ref() == Some(&cwd) { + app.right_panel.diff = Some(result); + cx.notify(); + } + }); + }) + .detach(); + } + + /// Re-probe the Changes list when the shared status cache learned something + /// newer than what's shown — called from the app's + /// `observe_global::` hook, the same trigger that refreshes + /// the sidebar's `+N −M` and the diff overlay. + /// + /// Deliberately *not* "drop the cache and let the next paint re-probe": + /// that observer fires on every landed probe, including unrelated repos', so + /// dropping the cache blanked the list to "Loading…" and spawned a fresh + /// `git diff` several times a second while a pane was producing output. + /// Comparing branch + totals first keeps the quiet case free, and re-probing + /// in place leaves the rows on screen until the new snapshot lands. + pub(crate) fn right_panel_refresh_changes(&mut self, cx: &mut Context) { + if self.right_panel.diff_loading { + return; + } + let Some(cwd) = self.right_panel.diff_cwd.clone() else { + return; // never probed — the render path owns the first one + }; + // `Some(None)` (probed, not a work tree) stays put: a status entry for a + // non-repo can't appear, so there's nothing to disagree with. + let Some(Some(snap)) = &self.right_panel.diff else { + return; + }; + let Some(status) = cx + .try_global::() + .and_then(|cache| cache.status_for(&cwd)) + else { + return; + }; + let stale = status.branch != snap.branch || (status.added, status.removed) != snap.totals(); + if stale { + self.spawn_right_panel_diff(cwd, cx); + } + } + + // ── Files ─────────────────────────────────────────────────────────────── + + /// The project tree, reusing the code panel's rows verbatim — same expand + /// state, same click-to-open, so the panel and the editor overlay are two + /// views of one tree rather than two trees. + fn render_panel_files(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let controls = self.files_controls(cx); + let title = self.panel_title("Files", None, Some(controls), cx); + let search = self.files_search(cx); + let rows = self.render_file_tree_rows(window, cx); + v_flex() + .flex_1() + .min_h_0() + .child(title) + .child(search) + .child(rows) + .into_any_element() + } +} + +/// A small status letter (`M`/`U`/…) for a change row. The *kind* is told by the +/// glyph in the mono face, not by colour, so the list stays monochrome; callers +/// pass a muted tone and reserve real hue for the `+N −M` counts beside it. +fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { + div() + .flex_none() + .w(px(14.)) + .text_center() + .text_size(px(10.5)) + .font_family(mono.clone()) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(color) + .child(letter.to_string()) + .into_any_element() +} + +/// A pid / port pill: a mono number on the soft-grey capsule the rest of the +/// chrome uses, so a numeric datum reads as a tag rather than loose text. +fn info_chip(text: &str, bg: gpui::Hsla, fg: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { + div() + .flex_none() + .px(px(5.)) + .py(px(1.5)) + .rounded(px(4.)) + .bg(bg) + .text_size(px(10.5)) + .font_family(mono.clone()) + .text_color(fg) + .child(text.to_string()) + .into_any_element() +} + +/// The one-word status the Info row shows next to the agent's name. +fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static str { + use crate::core::cli_agent::AgentStatus::*; + match status { + Idle => "idle", + Working => "working", + Waiting => "waiting", + Done => "done", + } +} + +/// Flatten a possibly-multiline command to one row: newlines and tabs become +/// spaces, runs of whitespace collapse. A heredoc or a `for` loop typed across +/// lines is still recognizable, and the list keeps one row per command. +fn one_line(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +/// `~`-shorten a path for the Info list, which has ~180px to play with. +fn compact_path(path: &std::path::Path) -> String { + let s = path.to_string_lossy().to_string(); + match std::env::var("HOME") { + Ok(home) if !home.is_empty() && s.starts_with(&home) => s.replacen(&home, "~", 1), + _ => s, + } +} diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 231163a4..fb50e850 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -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(); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 568d2fee..37930a88 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -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, + ) -> 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) -> Vec { + let active_tab = cx.global::().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)) } }