From acb461a0949f192f175b50e7cb4f50b65389d3e6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:25:45 +0800 Subject: [PATCH 1/8] feat(code-panel): local file tree, code editor panel, and LSP client - File tree (left column): lazy per-directory listing with notify-driven refresh, gitignore chain matching (dimmed italics), keyboard nav, inline new-file/new-folder/rename, context menu (open / cd / insert path / attach-to-agent / copy path / reveal / delete), multi-root from the active tab's pane cwds, rows draggable into the terminal as ExternalPaths. - Code editor (right column): gpui-component CodeEditor mode (tree-sitter highlighting, line numbers, folding, find/replace), file tabs with dirty markers, cmd-S save, external-change reload with conflict banner, markdown preview, soft-wrap toggle. - LSP: stdio JSON-RPC client per (server, workspace root) for rust-analyzer / gopls / pyright / tsserver / clangd; completions, hover, diagnostics, same-file cmd-click definitions, F12 cross-file goto, shift-F12 references drawer. --- Cargo.lock | 367 +++++++++++++ Cargo.toml | 16 +- src/core/actions.rs | 10 + src/ui/app.rs | 61 ++- src/ui/code_editor.rs | 1169 +++++++++++++++++++++++++++++++++++++++ src/ui/file_tree.rs | 1210 +++++++++++++++++++++++++++++++++++++++++ src/ui/keymap.rs | 16 + src/ui/lsp.rs | 617 +++++++++++++++++++++ src/ui/mod.rs | 3 + src/ui/palette.rs | 8 + 10 files changed, 3471 insertions(+), 6 deletions(-) create mode 100644 src/ui/code_editor.rs create mode 100644 src/ui/file_tree.rs create mode 100644 src/ui/lsp.rs diff --git a/Cargo.lock b/Cargo.lock index 246c256f..e0003a50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3193,7 +3193,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", @@ -8978,6 +9011,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" @@ -8988,12 +9181,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" @@ -9031,12 +9394,14 @@ dependencies = [ "gpui-component", "gpui-component-assets", "gpui_platform", + "ignore", "image", "keyring", "ksni", "libc", "libgssapi", "log", + "lsp-types", "memchr", "notify 8.2.0", "notify-rust", @@ -9046,6 +9411,7 @@ dependencies = [ "portable-pty", "reqwest_client", "resvg", + "ropey", "russh", "russh-sftp", "serde", @@ -9056,6 +9422,7 @@ dependencies = [ "smol", "tokio", "tray-icon", + "url", "uuid", "windows-sys 0.59.0", "winresource", diff --git a/Cargo.toml b/Cargo.toml index a81e7ef1..8c5a857e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,9 +73,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. @@ -211,7 +223,7 @@ edition = "2024" # `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" } +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/src/core/actions.rs b/src/core/actions.rs index dd338417..59142311 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -59,6 +59,16 @@ actions!( RestartDaemon, // Toggle the SFTP file panel for the focused native-SSH pane (WS5). ToggleSftp, + // Toggle the local project file tree (left column of the body). + ToggleFileTree, + // Toggle the code-editor panel (right column of the body). + ToggleEditor, + // 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/ui/app.rs b/src/ui/app.rs index 448d89b0..8e5fd16a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -345,6 +345,10 @@ 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, + /// 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` @@ -487,6 +491,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")); @@ -600,6 +606,8 @@ impl Tty7App { mf_editing: None, }, sftp_panel, + file_tree, + editor, sidebar_width: Rc::new(Cell::new(sidebar_width)), sidebar_dragging: Rc::new(Cell::new(false)), sidebar_scroll: gpui::ScrollHandle::new(), @@ -2800,6 +2808,8 @@ impl Tty7App { OpenSettings => self.toggle_settings(window, cx), RestartDaemon => self.restart_daemon(window, cx), ToggleSftp => self.toggle_sftp(window, cx), + ToggleFileTree => self.toggle_file_tree(window, cx), + ToggleEditor => self.toggle_editor_panel(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()) { @@ -2830,7 +2840,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) @@ -4115,6 +4125,28 @@ impl Render for Tty7App { // above. It covers only the body: the sidebar stays interactive. .when_some(self.render_diff_overlay(cx), |this, el| this.child(el)); + // The code-panel row: [file tree | terminal body | editor]. The two + // side columns are optional (toggled); the terminal keeps the middle. + let file_tree_col = self.render_file_tree(window, cx); + let editor_col = self.render_editor_panel(window, cx); + let body_area = div() + .flex_1() + .min_h_0() + .w_full() + .flex() + .flex_row() + .when_some(file_tree_col, |this, el| this.child(el)) + .child( + div() + .flex_1() + .min_w_0() + .h_full() + .flex() + .flex_col() + .child(body_area), + ) + .when_some(editor_col, |this, el| this.child(el)); + // The two layouts. Horizontal (default): a column of [title bar / body]. // Vertical: the rail is a full-height *left column* that reaches the very // top of the window — the traffic lights sit on its surface — with the @@ -4189,9 +4221,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) })) @@ -4320,6 +4356,23 @@ 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, _: &ToggleFileTree, window, cx| { + this.toggle_file_tree(window, cx) + }), + ) + .on_action( + cx.listener(|this, _: &ToggleEditor, _window, cx| this.toggle_editor_panel(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 diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs new file mode 100644 index 00000000..1a579913 --- /dev/null +++ b/src/ui/code_editor.rs @@ -0,0 +1,1169 @@ +//! Docked code-editor panel (right side of the terminal body). +//! +//! A lightweight "look at / touch up code without leaving the terminal" editor, +//! modelled on Warp's code panel rather than 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`), and +//! the panel chrome (resizable divider, unsaved-close confirmation). +//! +//! Layout: the panel is a right-hand column of the terminal body (a sibling of +//! the pane tree, like the file tree on the left), so terminals and code stay +//! side by side — the Warp mental model, without generalizing the split-pane +//! tree away from `Entity`. + +use std::cell::Cell; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::SystemTime; + +use gpui::prelude::*; +use gpui::{ + AnyElement, Bounds, Context, Entity, Focusable as _, MouseButton, MouseMoveEvent, MouseUpEvent, + Pixels, PromptLevel, SharedString, Subscription, Window, canvas, 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; + +/// Width band for the editor column, as a fraction of the window body. The +/// divider drag clamps into this so neither the terminal nor the editor can be +/// squeezed into an unusable sliver. +const MIN_WIDTH_FRAC: f32 = 0.2; +const MAX_WIDTH_FRAC: f32 = 0.8; +const DEFAULT_WIDTH_FRAC: f32 = 0.45; + +/// 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, +} + +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() + } +} + +/// State for the editor panel, held on [`Tty7App`]. +pub(crate) struct EditorPanelState { + pub(crate) open: bool, + pub(crate) files: Vec, + pub(crate) active: usize, + /// Editor column width as a fraction of the body; shared cell so the + /// divider drag closure can update it mid-render (same pattern as the + /// split-pane ratio and the tab sidebar width). + pub(crate) width_frac: Rc>, + dragging: Rc>, + /// Watches the parent directories of open files for external changes. + /// Rebuilt whenever the open set changes; `None` while nothing is open. + 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, + /// Find-references results, shown as a drawer under the editor. + pub(crate) references: Option>, +} + +/// 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 { + open: false, + files: Vec::new(), + active: 0, + width_frac: Rc::new(Cell::new(DEFAULT_WIDTH_FRAC)), + dragging: Rc::new(Cell::new(false)), + watcher: None, + events_tx: tx, + lsp: crate::ui::lsp::LspRegistry::new(window, cx), + references: None, + } + } + + pub(crate) fn active_file(&self) -> Option<&OpenFile> { + self.files.get(self.active) + } + + /// Rebuild the external-change watcher over the current open set. Watches + /// each file's *parent directory* (non-recursively): editors that save via + /// rename replace the inode, which a direct file watch loses track of. + fn rebuild_watcher(&mut self) { + use notify::{RecursiveMode, Watcher}; + self.watcher = None; + if self.files.is_empty() { + return; + } + let watched: HashSet = self.files.iter().map(|f| f.path.clone()).collect(); + let dirs: HashSet = watched + .iter() + .filter_map(|p| p.parent().map(Path::to_path_buf)) + .collect(); + let tx = self.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.watcher = Some(watcher); + } +} + +// --------------------------------------------------------------------------- +// 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 { + /// Open `path` in the editor panel (activating an existing tab when the + /// file 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, + ) { + let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if let Some(ix) = self.editor.files.iter().position(|f| f.path == path) { + self.editor.active = ix; + self.editor.open = true; + 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. + 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(); + if let Some(f) = this.editor.files.iter_mut().find(|f| f.path == path) { + 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(); + } + } + } + }); + self.editor.files.push(OpenFile { + path, + input, + dirty: false, + disk_mtime: mtime, + conflict: false, + preview: false, + wrap: false, + lsp, + change_task: None, + _sub: sub, + }); + self.editor.active = self.editor.files.len() - 1; + self.editor.open = true; + self.editor.rebuild_watcher(); + self.focus_editor(window, cx); + cx.notify(); + } + + /// Show/hide the editor column. Opening with no files is allowed — the + /// empty state points at the file tree. + pub(crate) fn toggle_editor_panel(&mut self, cx: &mut Context) { + self.editor.open = !self.editor.open; + cx.notify(); + } + + /// 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.editor.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.editor.open + && self.editor.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(f) = self.editor.files.get_mut(self.editor.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.editor.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.editor.active; + app.editor.active = ix; + app.editor_save_active(window, cx); + app.editor.active = prev_active; + if app.editor.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; + } + if self.editor.files.is_empty() { + self.editor.open = false; + cx.notify(); + return true; + } + self.editor_close_file(self.editor.active, window, cx); + true + } + + fn editor_remove_file(&mut self, ix: usize, cx: &mut Context) { + if ix >= self.editor.files.len() { + return; + } + let f = self.editor.files.remove(ix); + if let Some((client, _)) = &f.lsp { + client.did_close(&f.path); + } + if self.editor.active >= ix && self.editor.active > 0 { + self.editor.active -= 1; + } + self.editor.rebuild_watcher(); + cx.notify(); + } + + /// 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) { + let Some(f) = self.editor.files.iter().find(|f| f.path == *path) else { + return; + }; + if let Some((client, _)) = &f.lsp { + client.did_change(&f.path, &f.input.read(cx).text().to_string()); + } + } + + /// Apply `publishDiagnostics` for `path` to its open buffer, if any. + pub(crate) fn editor_apply_diagnostics( + &mut self, + path: &Path, + diags: Vec, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(f) = self.editor.files.iter().find(|f| f.path == *path) else { + return; + }; + f.input.clone().update(cx, |st, cx| { + let text = st.text().clone(); + if let Some(set) = st.diagnostics_mut() { + set.reset(&text); + set.extend(diags); + 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.editor.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.editor.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; + // off the UI thread, since this touches the filesystem. + 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(), + }); + } + let _ = app.update(cx, |app, cx| { + app.editor.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.editor.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. + pub(crate) fn editor_handle_external_change( + &mut self, + path: &Path, + window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.editor.files.iter().position(|f| f.path == *path) else { + return; + }; + let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok(); + { + let f = &self.editor.files[ix]; + // Our own save's echo: mtime matches what we just wrote. + if mtime.is_some() && mtime == f.disk_mtime { + return; + } + } + if self.editor.files[ix].dirty { + self.editor.files[ix].conflict = true; + cx.notify(); + return; + } + self.editor_reload_from_disk(ix, window, cx); + } + + /// Replace the 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, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(f) = self.editor.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 editor column (right of the terminal body), or `None` when hidden. + /// Rendered as `[divider | tabs / banner / editor]`. + pub(crate) fn render_editor_panel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if !self.editor.open { + return None; + } + let width = self + .editor + .width_frac + .get() + .clamp(MIN_WIDTH_FRAC, MAX_WIDTH_FRAC); + let body = match self.editor.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(); + Input::new(&input) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(cx.theme().mono_font_size) + .size_full() + .into_any_element() + } + }; + let conflict_banner = self + .editor + .active_file() + .filter(|f| f.conflict) + .map(|_| self.render_editor_conflict_banner(cx)); + let references = self.render_editor_references(cx); + + let panel = v_flex() + .id("code-editor-panel") + .flex_none() + .h_full() + .w(gpui::relative(width)) + .min_w(px(240.)) + .bg(cx.theme().background) + .border_l_1() + .border_color(cx.theme().border) + .child(self.render_editor_tabs(window, 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( + h_flex() + .flex_none() + .h_full() + .child(self.render_editor_divider(cx)) + .child(panel) + .into_any_element(), + ) + } + + /// 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 file tab strip along the panel top. + fn render_editor_tabs(&self, _window: &Window, cx: &mut Context) -> gpui::Div { + let active = self.editor.active; + let tabs = self.editor.files.iter().enumerate().map(|(ix, f)| { + let is_active = ix == active; + let title = f.label(); + h_flex() + .id(("editor-tab", ix)) + .flex_none() + .items_center() + .gap_1() + .px_2() + .py_1() + .rounded(cx.theme().radius) + .text_sm() + .cursor_pointer() + .when(is_active, |d| d.bg(cx.theme().accent)) + .when(!is_active, |d| { + d.text_color(cx.theme().muted_foreground) + .hover(|s| s.bg(cx.theme().accent.opacity(0.5))) + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.editor.active = ix; + this.focus_editor(window, cx); + cx.notify(); + }), + ) + .child(div().child(title)) + .when(f.dirty, |d| { + d.child(div().size(px(7.)).rounded_full().bg(cx.theme().warning)) + }) + .child( + Button::new(("editor-tab-close", ix)) + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _, window, cx| { + this.editor_close_file(ix, window, cx); + })), + ) + }); + h_flex() + .flex_none() + .w_full() + .items_center() + .gap_1() + .px_1() + .py_1() + .border_b_1() + .border_color(cx.theme().border) + .overflow_x_hidden() + .children(tabs) + .child(div().flex_1()) + // Markdown files get a preview toggle. + .when_some( + self.editor + .active_file() + .filter(|f| language_for_path(&f.path) == "markdown"), + |this, f| { + let preview = f.preview; + this.child( + Button::new("editor-md-preview-toggle") + .label(if preview { "Edit" } else { "Preview" }) + .ghost() + .xsmall() + .on_click(cx.listener(|this, _, _w, cx| { + let ix = this.editor.active; + if let Some(f) = this.editor.files.get_mut(ix) { + f.preview = !f.preview; + cx.notify(); + } + })), + ) + }, + ) + // Soft-wrap toggle for the active buffer. + .when(self.editor.active_file().is_some(), |this| { + this.child( + Button::new("editor-wrap-toggle") + .label("Wrap") + .ghost() + .xsmall() + .tooltip("Toggle soft wrap") + .on_click(cx.listener(|this, _, window, cx| { + let ix = this.editor.active; + if let Some(f) = this.editor.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); + }); + } + })), + ) + }) + .child( + Button::new("editor-panel-close") + .icon(IconName::Close) + .ghost() + .small() + .tooltip("Close editor panel") + .on_click(cx.listener(|this, _, _w, cx| { + this.editor.open = false; + cx.notify(); + })), + ) + } + + /// The find-references drawer (⇧F12 results) under the editor body. + fn render_editor_references(&self, cx: &mut Context) -> Option { + let refs = self.editor.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( + Button::new("editor-refs-close") + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(cx.listener(|this, _, _w, cx| { + this.editor.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 ix = self.editor.active; + 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(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.editor.files.get_mut(ix) { + f.conflict = false; + cx.notify(); + } + })), + ) + .into_any_element() + } + + /// The draggable divider between the terminal body and the editor column. + /// Same canvas + window-mouse-listener pattern as the split-pane divider. + fn render_editor_divider(&self, cx: &mut Context) -> AnyElement { + let width_frac = self.editor.width_frac.clone(); + let dragging = self.editor.dragging.clone(); + let idle = cx.theme().border; + let active = cx.theme().drag_border; + + // Measured bounds of the whole body row (terminal + editor), captured + // by the backing canvas so the drag maps pointer → width fraction. + let container: Rc>>> = Rc::new(Cell::new(None)); + let backing = canvas( + { + let container = container.clone(); + move |bounds, _window, _cx| container.set(Some(bounds)) + }, + { + let container = container.clone(); + let width_frac = width_frac.clone(); + let dragging = dragging.clone(); + move |_bounds, _state, window, _cx| { + window.on_mouse_event({ + let container = container.clone(); + let width_frac = width_frac.clone(); + let dragging = dragging.clone(); + move |ev: &MouseMoveEvent, _phase, window, _cx| { + if !dragging.get() { + return; + } + let Some(b) = container.get() else { return }; + // The canvas only spans the divider itself; use the + // window width as the drag reference instead. + let total = window.viewport_size().width; + if total.as_f32() <= 0.0 { + return; + } + let from_right = (total - ev.position.x).max(px(0.)); + let frac = (from_right / total).clamp(MIN_WIDTH_FRAC, MAX_WIDTH_FRAC); + let _ = b; + width_frac.set(frac); + window.refresh(); + } + }); + window.on_mouse_event({ + let dragging = dragging.clone(); + move |_ev: &MouseUpEvent, _phase, window, _cx| { + if dragging.get() { + dragging.set(false); + window.refresh(); + } + } + }); + } + }, + ) + .absolute() + .size_full(); + + let line = if dragging.get() { active } else { idle }; + div() + .id("editor-divider") + .relative() + .flex_none() + .w(px(5.)) + .h_full() + .flex() + .items_center() + .justify_center() + .cursor_col_resize() + .child(backing) + .child(div().w(px(1.)).h_full().bg(line)) + .on_mouse_down(MouseButton::Left, { + let dragging = dragging.clone(); + move |_ev, window, _cx| { + dragging.set(true); + window.refresh(); + } + }) + .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/file_tree.rs b/src/ui/file_tree.rs new file mode 100644 index 00000000..dc38220b --- /dev/null +++ b/src/ui/file_tree.rs @@ -0,0 +1,1210 @@ +//! Local project file tree (left column of the terminal body). +//! +//! 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::cell::Cell; +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, + MouseMoveEvent, MouseUpEvent, PromptLevel, SharedString, Subscription, Window, div, px, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +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; + +/// Width band (px) for the tree column. +const MIN_WIDTH: f32 = 160.0; +const MAX_WIDTH: f32 = 480.0; +const DEFAULT_WIDTH: f32 = 240.0; + +/// 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), + } + } +} + +/// State for the file-tree panel, held on [`Tty7App`]. +pub(crate) struct FileTreeState { + pub(crate) open: bool, + pub(crate) roots: Vec, + expanded: HashSet, + /// 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) selected: Option, + pub(crate) show_hidden: bool, + pub(crate) width: Rc>, + dragging: Rc>, + pub(crate) editing: Option, + editing_subs: Vec, + /// Recursive watcher per root; rebuilt when the root set changes. + 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 { + open: false, + roots: Vec::new(), + expanded: HashSet::new(), + children: HashMap::new(), + gitignore: HashMap::new(), + selected: None, + show_hidden: false, + width: Rc::new(Cell::new(DEFAULT_WIDTH)), + dragging: Rc::new(Cell::new(false)), + editing: None, + editing_subs: Vec::new(), + watcher: None, + events_tx: tx, + focus_handle: cx.focus_handle(), + } + } + + /// (Re)attach the recursive watcher to the current roots. + fn rebuild_watcher(&mut self) { + use notify::{RecursiveMode, Watcher}; + self.watcher = None; + if self.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 &self.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 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 &self.roots { + if !self.children.contains_key(root) { + todo.push((root.clone(), root.clone())); + } + for dir in &self.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 + } + + /// Flatten roots + expanded directories into display order. + pub(crate) fn visible_rows(&self) -> Vec { + let mut rows = Vec::new(); + for root in &self.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, &mut rows); + } + rows + } + + fn flatten_dir(&self, dir: &Path, depth: usize, 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 expanded = e.is_dir && self.expanded.contains(&e.path); + out.push(TreeRow { + entry: e.clone(), + depth, + is_root: false, + expanded, + }); + if expanded { + self.flatten_dir(&e.path, depth + 1, 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 { + /// `ToggleFileTree`: show/hide the panel, re-deriving roots on open. + pub(crate) fn toggle_file_tree(&mut self, window: &mut Window, cx: &mut Context) { + if self.file_tree.open { + self.file_tree.open = false; + self.file_tree.editing = None; + self.file_tree.editing_subs.clear(); + cx.notify(); + return; + } + self.file_tree.open = true; + self.file_tree_refresh_roots(window, cx); + self.file_tree.focus_handle.focus(window, cx); + cx.notify(); + } + + /// 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; + if roots != self.file_tree.roots { + self.file_tree.roots = roots; + self.file_tree.children.clear(); + self.file_tree.gitignore.clear(); + self.file_tree.rebuild_watcher(); + } else { + // Same roots: refresh listings but keep expansion state. + self.file_tree.children.clear(); + } + 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, + ) { + if !self.file_tree.open { + return; + } + 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) { + if !self.file_tree.expanded.remove(dir) { + self.file_tree.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, + ) { + self.file_tree.selected = Some(row_path.to_path_buf()); + if is_dir { + self.file_tree_toggle_expand(row_path, cx); + } else { + self.open_file_in_editor(row_path, window, cx); + } + cx.notify(); + } + + /// Keyboard navigation over the flattened rows. + fn file_tree_key_down( + &mut self, + ev: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let rows = self.file_tree.visible_rows(); + if rows.is_empty() { + return; + } + let sel_ix = self + .file_tree + .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), + }; + self.file_tree.selected = Some(rows[next].entry.path.clone()); + cx.notify(); + } + "left" => { + let Some(i) = sel_ix else { return }; + let row = &rows[i]; + if row.entry.is_dir && row.expanded && !row.is_root { + self.file_tree.expanded.remove(&row.entry.path); + } else if let Some(parent) = row.entry.path.parent() { + // Jump to the parent row (stay put at a root). + if rows.iter().any(|r| r.entry.path == parent) { + self.file_tree.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 { + self.file_tree.expanded.insert(row.entry.path.clone()); + 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]; + self.file_tree.editing = Some(match edit_for { + TreeEditKind::NewFile => { + // New entries land in the target dir (or the file's parent). + let dir = if target.is_dir() { + target.to_path_buf() + } else { + target.parent().unwrap_or(target).to_path_buf() + }; + self.file_tree.expanded.insert(dir.clone()); + TreeEdit::NewFile { dir, input } + } + TreeEditKind::NewFolder => { + let dir = if target.is_dir() { + target.to_path_buf() + } else { + target.parent().unwrap_or(target).to_path_buf() + }; + self.file_tree.expanded.insert(dir.clone()); + TreeEdit::NewFolder { 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()); + self.file_tree.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 app.file_tree.selected.as_deref() == Some(&path) { + app.file_tree.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 + .file_tree + .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 tree column (left of the terminal body), or `None` when hidden. + pub(crate) fn render_file_tree( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if !self.file_tree.open { + return None; + } + self.file_tree.ensure_loaded(); + let width = self.file_tree.width.get().clamp(MIN_WIDTH, MAX_WIDTH); + let rows = self.file_tree.visible_rows(); + + let list = v_flex() + .id("file-tree-rows") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px_1() + .py_1() + .children( + rows.iter() + .flat_map(|row| self.render_tree_row(row, window, cx)), + ); + + let panel = v_flex() + .id("file-tree-panel") + .flex_none() + .h_full() + .w(px(width)) + .bg(cx.theme().background) + .border_r_1() + .border_color(cx.theme().border) + .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); + })) + .child(self.render_tree_header(cx)) + .child(list); + + Some( + h_flex() + .flex_none() + .h_full() + .child(panel) + .child(self.render_tree_divider(cx)) + .into_any_element(), + ) + } + + /// Panel header: title + refresh / new-file / hidden-files toggle. + fn render_tree_header(&self, cx: &mut Context) -> gpui::Div { + let show_hidden = self.file_tree.show_hidden; + h_flex() + .flex_none() + .items_center() + .gap_0p5() + .px_2() + .py_1() + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .flex_1() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .child("Files"), + ) + .child( + Button::new("tree-refresh") + .icon(IconName::LoaderCircle) + .ghost() + .xsmall() + .tooltip("Refresh") + .on_click(cx.listener(|this, _, window, cx| { + this.file_tree_refresh_roots(window, cx); + })), + ) + .child( + Button::new("tree-toggle-hidden") + .icon(IconName::Eye) + .ghost() + .xsmall() + .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(); + })), + ) + .child( + Button::new("tree-close") + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip("Close file tree") + .on_click(cx.listener(|this, _, _w, cx| { + this.file_tree.open = false; + cx.notify(); + })), + ) + } + + /// 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.file_tree.selected.as_deref() == Some(&*path); + let muted = cx.theme().muted_foreground; + + // 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_0p5() + .rounded(cx.theme().radius) + .cursor_pointer() + .when(selected, |d| d.bg(cx.theme().accent)) + .when(!selected, |d| { + d.hover(|s| s.bg(cx.theme().accent.opacity(0.5))) + }) + .child(Icon::new(icon).xsmall().text_color(if is_dir { + cx.theme().primary + } else { + muted + })) + .child(label) + .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 draggable divider on the tree's right edge. + fn render_tree_divider(&self, cx: &mut Context) -> AnyElement { + let width = self.file_tree.width.clone(); + let dragging = self.file_tree.dragging.clone(); + let idle = cx.theme().border; + let active = cx.theme().drag_border; + let line = if dragging.get() { active } else { idle }; + + div() + .id("file-tree-divider") + .relative() + .flex_none() + .w(px(5.)) + .h_full() + .flex() + .items_center() + .justify_center() + .cursor_col_resize() + .child( + gpui::canvas(|_, _, _| (), { + let width = width.clone(); + let dragging = dragging.clone(); + move |bounds, _, window, _cx| { + let divider_x = bounds.origin.x; + window.on_mouse_event({ + let width = width.clone(); + let dragging = dragging.clone(); + move |ev: &MouseMoveEvent, _phase, window, _cx| { + if !dragging.get() { + return; + } + // The tree's left edge = divider left minus + // the current width; new width follows the + // pointer from that fixed edge. + let left = divider_x - px(width.get()); + let w = (ev.position.x - left).max(px(0.)); + width.set(w.as_f32().clamp(MIN_WIDTH, MAX_WIDTH)); + window.refresh(); + } + }); + window.on_mouse_event({ + let dragging = dragging.clone(); + move |_ev: &MouseUpEvent, _phase, window, _cx| { + if dragging.get() { + dragging.set(false); + window.refresh(); + } + } + }); + } + }) + .absolute() + .size_full(), + ) + .child(div().w(px(1.)).h_full().bg(line)) + .on_mouse_down(MouseButton::Left, { + move |_ev, window, _cx| { + dragging.set(true); + window.refresh(); + } + }) + .into_any_element() + } +} + +/// 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..e55e9983 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -193,6 +193,17 @@ 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", ""), + // Project file tree, VS Code's explorer chord. ⌘⇧E is free (no existing + // binding or preset uses it). + ("ToggleFileTree", "secondary-shift-e"), + // No default chord — the panel opens itself when a file is opened from + // the tree; reachable from the palette and bindable in Settings. + ("ToggleEditor", ""), + // 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", ""), @@ -483,6 +494,11 @@ 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), + "ToggleFileTree" => KeyBinding::new(keystroke, ToggleFileTree, None), + "ToggleEditor" => KeyBinding::new(keystroke, ToggleEditor, 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..bd76f6d7 --- /dev/null +++ b/src/ui/lsp.rs @@ -0,0 +1,617 @@ +//! 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. + fn send(&self, body: String) { + if self.ready.load(Ordering::SeqCst) { + let mut stdin = self.stdin.lock().unwrap(); + Self::write_frame(&mut stdin, &body); + } else { + self.queued.lock().unwrap().push(body); + } + } + + /// Initialize finished: flush everything parked behind the handshake. + fn flush_queued(&self) { + let queued: Vec = std::mem::take(&mut self.queued.lock().unwrap()); + let mut stdin = self.stdin.lock().unwrap(); + for body in queued { + 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()), + }) + } + + 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.ready.store(true, Ordering::SeqCst); + inner.flush_queued(); + 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() { + let p = 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 5f6d9a63..8a4761ca 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,11 +7,14 @@ 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; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index be95557f..214f0eac 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -58,6 +58,10 @@ pub enum CommandKind { RestartDaemon, /// Toggle the SFTP file panel for the focused native-SSH pane (WS5). ToggleSftp, + /// Toggle the local project file tree (left column of the body). + ToggleFileTree, + /// Toggle the code-editor panel (right column of the body). + ToggleEditor, /// 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 @@ -136,6 +140,8 @@ impl CommandKind { OpenSettings => "OpenSettings", RestartDaemon => "RestartDaemon", ToggleSftp => "ToggleSftp", + ToggleFileTree => "ToggleFileTree", + ToggleEditor => "ToggleEditor", RestartSshSession => "RestartSshSession", SendSelectionToAgent | SendGitDiffToAgent @@ -218,6 +224,8 @@ impl Command { Command::new("SSH: Manage Profiles…", OpenSshProfiles), Command::new("Reconnect SSH Session", RestartSshSession), Command::new("SFTP Panel", ToggleSftp), + Command::new("Toggle File Tree", ToggleFileTree), + Command::new("Toggle Editor Panel", ToggleEditor), Command::new("Change Theme…", OpenThemePicker), Command::new("Open Settings", OpenSettings), Command::new("Reset Font Size", ResetFontSize), From f9ed31c0afc2c449299d28d58c4850e00380ab09 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:52:21 +0800 Subject: [PATCH 2/8] refactor(code-panel): full-body overlay instead of docked side columns The file tree + editor now render as one overlay covering the terminal (settings/diff-overlay style): toggling never resizes the terminal (no PTY resize/reflow) and the editor gets the full body width. The tab sidebar stays visible and switching tabs re-roots the tree; focus follows the panel. - Merge ToggleFileTree/ToggleEditor into one ToggleCodePanel action (cmd-shift-e, Esc closes, palette "Code Panel"). - Add the one on-screen entry point: a title-bar tile next to the overflow menu, lit while the overlay is up (present in both tab-bar modes). - Drop the editor width divider and the file tree's standalone open flag. --- src/core/actions.rs | 7 +- src/ui/app.rs | 53 +++++------ src/ui/code_editor.rs | 202 +++++++++++++----------------------------- src/ui/file_tree.rs | 63 +++---------- src/ui/keymap.rs | 12 +-- src/ui/palette.rs | 12 +-- src/ui/tab_strip.rs | 24 ++++- 7 files changed, 129 insertions(+), 244 deletions(-) diff --git a/src/core/actions.rs b/src/core/actions.rs index 59142311..107ad9d3 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -59,10 +59,9 @@ actions!( RestartDaemon, // Toggle the SFTP file panel for the focused native-SSH pane (WS5). ToggleSftp, - // Toggle the local project file tree (left column of the body). - ToggleFileTree, - // Toggle the code-editor panel (right column of the body). - ToggleEditor, + // 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). diff --git a/src/ui/app.rs b/src/ui/app.rs index cab1a4b7..365ae184 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2308,7 +2308,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.editor.open { + // Code panel up: the tree follows the incoming tab's repos, and + // focus stays on the panel (the terminal is covered). + 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(); } @@ -2809,8 +2816,7 @@ impl Tty7App { OpenSettings => self.toggle_settings(window, cx), RestartDaemon => self.restart_daemon(window, cx), ToggleSftp => self.toggle_sftp(window, cx), - ToggleFileTree => self.toggle_file_tree(window, cx), - ToggleEditor => self.toggle_editor_panel(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()) { @@ -4121,33 +4127,19 @@ impl Render for Tty7App { .when_some(self.render_worktree_prompt_overlay(cx), |this, el| { this.child(el) }) + // Code panel: a full-body overlay ([file tree | editor], IDE-style) + // that covers the terminal like the settings/diff overlays do — the + // terminal underneath keeps its size, so toggling never reflows it. + // Covers only the body: the tab sidebar stays visible and switches + // tabs (which re-roots the tree) while the panel is up. + .when_some(self.render_code_overlay(window, 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)); - // The code-panel row: [file tree | terminal body | editor]. The two - // side columns are optional (toggled); the terminal keeps the middle. - let file_tree_col = self.render_file_tree(window, cx); - let editor_col = self.render_editor_panel(window, cx); - let body_area = div() - .flex_1() - .min_h_0() - .w_full() - .flex() - .flex_row() - .when_some(file_tree_col, |this, el| this.child(el)) - .child( - div() - .flex_1() - .min_w_0() - .h_full() - .flex() - .flex_col() - .child(body_area), - ) - .when_some(editor_col, |this, el| this.child(el)); - // The two layouts. Horizontal (default): a column of [title bar / body]. // Vertical: the rail is a full-height *left column* that reaches the very // top of the window — the traffic lights sit on its surface — with the @@ -4357,14 +4349,9 @@ 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, _: &ToggleFileTree, window, cx| { - this.toggle_file_tree(window, cx) - }), - ) - .on_action( - cx.listener(|this, _: &ToggleEditor, _window, cx| this.toggle_editor_panel(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)), ) diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 1a579913..530e89c6 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -1,29 +1,30 @@ -//! Docked code-editor panel (right side of the terminal body). +//! 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, -//! modelled on Warp's code panel rather than 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`), and -//! the panel chrome (resizable divider, unsaved-close confirmation). +//! 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: the panel is a right-hand column of the terminal body (a sibling of -//! the pane tree, like the file tree on the left), so terminals and code stay -//! side by side — the Warp mental model, without generalizing the split-pane -//! tree away from `Entity`. +//! 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::cell::Cell; use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::rc::Rc; use std::time::SystemTime; use gpui::prelude::*; use gpui::{ - AnyElement, Bounds, Context, Entity, Focusable as _, MouseButton, MouseMoveEvent, MouseUpEvent, - Pixels, PromptLevel, SharedString, Subscription, Window, canvas, div, px, + 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}; @@ -33,13 +34,6 @@ use gpui_component::{ use crate::ui::app::Tty7App; -/// Width band for the editor column, as a fraction of the window body. The -/// divider drag clamps into this so neither the terminal nor the editor can be -/// squeezed into an unusable sliver. -const MIN_WIDTH_FRAC: f32 = 0.2; -const MAX_WIDTH_FRAC: f32 = 0.8; -const DEFAULT_WIDTH_FRAC: f32 = 0.45; - /// 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. @@ -92,11 +86,6 @@ pub(crate) struct EditorPanelState { pub(crate) open: bool, pub(crate) files: Vec, pub(crate) active: usize, - /// Editor column width as a fraction of the body; shared cell so the - /// divider drag closure can update it mid-render (same pattern as the - /// split-pane ratio and the tab sidebar width). - pub(crate) width_frac: Rc>, - dragging: Rc>, /// Watches the parent directories of open files for external changes. /// Rebuilt whenever the open set changes; `None` while nothing is open. watcher: Option, @@ -146,8 +135,6 @@ impl EditorPanelState { open: false, files: Vec::new(), active: 0, - width_frac: Rc::new(Cell::new(DEFAULT_WIDTH_FRAC)), - dragging: Rc::new(Cell::new(false)), watcher: None, events_tx: tx, lsp: crate::ui::lsp::LspRegistry::new(window, cx), @@ -411,10 +398,24 @@ impl Tty7App { cx.notify(); } - /// Show/hide the editor column. Opening with no files is allowed — the - /// empty state points at the file tree. - pub(crate) fn toggle_editor_panel(&mut self, cx: &mut Context) { - self.editor.open = !self.editor.open; + /// `ToggleCodePanel` (⌘⇧E / the title-bar tree icon / Esc): flip the + /// code overlay. Opening re-roots the file tree from the active 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) { + if self.editor.open { + self.editor.open = false; + self.file_tree.editing = None; + self.focus_active(window, cx); + cx.notify(); + return; + } + self.editor.open = true; + self.file_tree_refresh_roots(window, cx); + if self.editor.active_file().is_some() { + self.focus_editor(window, cx); + } else { + self.file_tree.focus_handle.focus(window, cx); + } cx.notify(); } @@ -734,9 +735,10 @@ impl Tty7App { // --------------------------------------------------------------------------- impl Tty7App { - /// The editor column (right of the terminal body), or `None` when hidden. - /// Rendered as `[divider | tabs / banner / editor]`. - pub(crate) fn render_editor_panel( + /// 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, @@ -744,11 +746,6 @@ impl Tty7App { if !self.editor.open { return None; } - let width = self - .editor - .width_frac - .get() - .clamp(MIN_WIDTH_FRAC, MAX_WIDTH_FRAC); let body = match self.editor.active_file() { None => self.render_editor_empty(cx).into_any_element(), // Markdown preview replaces the buffer with a rendered view. @@ -782,15 +779,10 @@ impl Tty7App { .map(|_| self.render_editor_conflict_banner(cx)); let references = self.render_editor_references(cx); - let panel = v_flex() - .id("code-editor-panel") - .flex_none() + let editor_col = v_flex() + .flex_1() + .min_w_0() .h_full() - .w(gpui::relative(width)) - .min_w(px(240.)) - .bg(cx.theme().background) - .border_l_1() - .border_color(cx.theme().border) .child(self.render_editor_tabs(window, cx)) .when_some(conflict_banner, |this, b| this.child(b)) .child(div().flex_1().min_h_0().child(body)) @@ -798,10 +790,22 @@ impl Tty7App { Some( h_flex() - .flex_none() - .h_full() - .child(self.render_editor_divider(cx)) - .child(panel) + .id("code-panel") + .absolute() + .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); + } + })) + .child(self.render_file_tree_column(window, cx)) + .child(self.render_tree_divider(cx)) + .child(editor_col) .into_any_element(), ) } @@ -928,10 +932,9 @@ impl Tty7App { .icon(IconName::Close) .ghost() .small() - .tooltip("Close editor panel") - .on_click(cx.listener(|this, _, _w, cx| { - this.editor.open = false; - cx.notify(); + .tooltip("Back to Terminal (Esc)") + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_code_panel(window, cx); })), ) } @@ -1054,87 +1057,6 @@ impl Tty7App { ) .into_any_element() } - - /// The draggable divider between the terminal body and the editor column. - /// Same canvas + window-mouse-listener pattern as the split-pane divider. - fn render_editor_divider(&self, cx: &mut Context) -> AnyElement { - let width_frac = self.editor.width_frac.clone(); - let dragging = self.editor.dragging.clone(); - let idle = cx.theme().border; - let active = cx.theme().drag_border; - - // Measured bounds of the whole body row (terminal + editor), captured - // by the backing canvas so the drag maps pointer → width fraction. - let container: Rc>>> = Rc::new(Cell::new(None)); - let backing = canvas( - { - let container = container.clone(); - move |bounds, _window, _cx| container.set(Some(bounds)) - }, - { - let container = container.clone(); - let width_frac = width_frac.clone(); - let dragging = dragging.clone(); - move |_bounds, _state, window, _cx| { - window.on_mouse_event({ - let container = container.clone(); - let width_frac = width_frac.clone(); - let dragging = dragging.clone(); - move |ev: &MouseMoveEvent, _phase, window, _cx| { - if !dragging.get() { - return; - } - let Some(b) = container.get() else { return }; - // The canvas only spans the divider itself; use the - // window width as the drag reference instead. - let total = window.viewport_size().width; - if total.as_f32() <= 0.0 { - return; - } - let from_right = (total - ev.position.x).max(px(0.)); - let frac = (from_right / total).clamp(MIN_WIDTH_FRAC, MAX_WIDTH_FRAC); - let _ = b; - width_frac.set(frac); - window.refresh(); - } - }); - window.on_mouse_event({ - let dragging = dragging.clone(); - move |_ev: &MouseUpEvent, _phase, window, _cx| { - if dragging.get() { - dragging.set(false); - window.refresh(); - } - } - }); - } - }, - ) - .absolute() - .size_full(); - - let line = if dragging.get() { active } else { idle }; - div() - .id("editor-divider") - .relative() - .flex_none() - .w(px(5.)) - .h_full() - .flex() - .items_center() - .justify_center() - .cursor_col_resize() - .child(backing) - .child(div().w(px(1.)).h_full().bg(line)) - .on_mouse_down(MouseButton::Left, { - let dragging = dragging.clone(); - move |_ev, window, _cx| { - dragging.set(true); - window.refresh(); - } - }) - .into_any_element() - } } #[cfg(test)] diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index dc38220b..53da08a5 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1,4 +1,5 @@ -//! Local project file tree (left column of the terminal body). +//! 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 @@ -98,7 +99,6 @@ impl TreeEdit { /// State for the file-tree panel, held on [`Tty7App`]. pub(crate) struct FileTreeState { - pub(crate) open: bool, pub(crate) roots: Vec, expanded: HashSet, /// Lazily-loaded listing per directory; invalidated by watcher events. @@ -138,7 +138,6 @@ impl FileTreeState { }) .detach(); Self { - open: false, roots: Vec::new(), expanded: HashSet::new(), children: HashMap::new(), @@ -356,21 +355,6 @@ pub(crate) fn shell_quote(path: &Path) -> String { // --------------------------------------------------------------------------- impl Tty7App { - /// `ToggleFileTree`: show/hide the panel, re-deriving roots on open. - pub(crate) fn toggle_file_tree(&mut self, window: &mut Window, cx: &mut Context) { - if self.file_tree.open { - self.file_tree.open = false; - self.file_tree.editing = None; - self.file_tree.editing_subs.clear(); - cx.notify(); - return; - } - self.file_tree.open = true; - self.file_tree_refresh_roots(window, cx); - self.file_tree.focus_handle.focus(window, cx); - cx.notify(); - } - /// 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) { @@ -412,7 +396,9 @@ impl Tty7App { paths: &HashSet, cx: &mut Context, ) { - if !self.file_tree.open { + // The tree only renders inside the code overlay; skip churn while it + // is closed (the caches rebuild lazily on the next open anyway). + if !self.editor.open { return; } let gitignore_touched = paths @@ -733,15 +719,13 @@ enum TreeEditKind { // --------------------------------------------------------------------------- impl Tty7App { - /// The tree column (left of the terminal body), or `None` when hidden. - pub(crate) fn render_file_tree( + /// The file-tree column: the code overlay's left side (the overlay + /// renders the divider and the editor to its right). + pub(crate) fn render_file_tree_column( &mut self, window: &mut Window, cx: &mut Context, - ) -> Option { - if !self.file_tree.open { - return None; - } + ) -> AnyElement { self.file_tree.ensure_loaded(); let width = self.file_tree.width.get().clamp(MIN_WIDTH, MAX_WIDTH); let rows = self.file_tree.visible_rows(); @@ -758,29 +742,19 @@ impl Tty7App { .flat_map(|row| self.render_tree_row(row, window, cx)), ); - let panel = v_flex() + v_flex() .id("file-tree-panel") .flex_none() .h_full() .w(px(width)) .bg(cx.theme().background) - .border_r_1() - .border_color(cx.theme().border) .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); })) .child(self.render_tree_header(cx)) - .child(list); - - Some( - h_flex() - .flex_none() - .h_full() - .child(panel) - .child(self.render_tree_divider(cx)) - .into_any_element(), - ) + .child(list) + .into_any_element() } /// Panel header: title + refresh / new-file / hidden-files toggle. @@ -826,17 +800,6 @@ impl Tty7App { cx.notify(); })), ) - .child( - Button::new("tree-close") - .icon(IconName::Close) - .ghost() - .xsmall() - .tooltip("Close file tree") - .on_click(cx.listener(|this, _, _w, cx| { - this.file_tree.open = false; - cx.notify(); - })), - ) } /// One row (plus, when an inline edit targets it, the edit input row). @@ -1078,7 +1041,7 @@ impl Tty7App { } /// The draggable divider on the tree's right edge. - fn render_tree_divider(&self, cx: &mut Context) -> AnyElement { + pub(crate) fn render_tree_divider(&self, cx: &mut Context) -> AnyElement { let width = self.file_tree.width.clone(); let dragging = self.file_tree.dragging.clone(); let idle = cx.theme().border; diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e55e9983..272fe37c 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -193,12 +193,9 @@ 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", ""), - // Project file tree, VS Code's explorer chord. ⌘⇧E is free (no existing - // binding or preset uses it). - ("ToggleFileTree", "secondary-shift-e"), - // No default chord — the panel opens itself when a file is opened from - // the tree; reachable from the palette and bindable in Settings. - ("ToggleEditor", ""), + // 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. @@ -494,8 +491,7 @@ 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), - "ToggleFileTree" => KeyBinding::new(keystroke, ToggleFileTree, None), - "ToggleEditor" => KeyBinding::new(keystroke, ToggleEditor, 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), diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 214f0eac..8c35c183 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -58,10 +58,8 @@ pub enum CommandKind { RestartDaemon, /// Toggle the SFTP file panel for the focused native-SSH pane (WS5). ToggleSftp, - /// Toggle the local project file tree (left column of the body). - ToggleFileTree, - /// Toggle the code-editor panel (right column of the body). - ToggleEditor, + /// 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 @@ -140,8 +138,7 @@ impl CommandKind { OpenSettings => "OpenSettings", RestartDaemon => "RestartDaemon", ToggleSftp => "ToggleSftp", - ToggleFileTree => "ToggleFileTree", - ToggleEditor => "ToggleEditor", + ToggleCodePanel => "ToggleCodePanel", RestartSshSession => "RestartSshSession", SendSelectionToAgent | SendGitDiffToAgent @@ -224,8 +221,7 @@ impl Command { Command::new("SSH: Manage Profiles…", OpenSshProfiles), Command::new("Reconnect SSH Session", RestartSshSession), Command::new("SFTP Panel", ToggleSftp), - Command::new("Toggle File Tree", ToggleFileTree), - Command::new("Toggle Editor Panel", ToggleEditor), + 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/tab_strip.rs b/src/ui/tab_strip.rs index 39ef8d12..e4f6ac5a 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -11,7 +11,7 @@ use gpui::{ use gpui_component::button::{Button, 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 crate::core::actions::{OpenSettings, TogglePalette}; use crate::core::config::Config; @@ -812,6 +812,27 @@ impl Tty7App { .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()); + // Code-panel toggle: the one on-screen entry point for the file-tree + + // editor overlay (⌘⇧E). A global title-bar tile rather than a per-pane + // affordance, so heavy split layouts don't grow a forest of icons; lit + // (selected) while the overlay is up. Present in both tab-bar modes — + // the sidebar layout keeps this strip as the right column's chrome. + let code_open = self.editor.open; + let code_button = div().occlude().flex_shrink_0().child( + Button::new("titlebar-code-panel") + .icon(Icon::new(IconName::FolderClosed).size(px(15.))) + .ghost() + .xsmall() + .w(px(30.)) + .h(px(30.)) + .rounded_lg() + .selected(code_open) + .tooltip("Code Panel") + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_code_panel(window, cx); + })), + ); + let menu_button = div().occlude().flex_shrink_0().child( Button::new("titlebar-menu") .icon(Icon::new(IconName::Ellipsis).size(px(15.))) @@ -860,6 +881,7 @@ impl Tty7App { // leaving just the "⋯" overflow menu on a thin strip. .when(show_chips, move |this| this.child(add_button)) .child(div().flex_1()) + .child(code_button) .child(menu_button) } } From 2f1978618dadd11c1a2c051180723c12dc67775a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:18:25 +0800 Subject: [PATCH 3/8] refactor(code-panel): per-tab panel state, diff-overlay style The panel's open files, tree roots/expansion/selection, and visibility now live on Tab.code (same contract as Tab.diff_overlay): only the active tab's panel renders, switching tabs shows that tab's own panel (or none), and closing the tab drops its state. Hiding via Esc keeps the tab's open files. Shared infrastructure stays app-global: directory-listing and gitignore caches (path-keyed, tab-agnostic), the LSP registry, and single watchers over the union of every tab's roots / open files. External-change reloads and diagnostics now fan out to every buffer of the path across tabs. --- Cargo.lock | 3 - Cargo.toml | 4 +- src/ui/app.rs | 16 +- src/ui/code_editor.rs | 497 +++++++++++++++++++++++++++--------------- src/ui/file_tree.rs | 205 ++++++++++------- src/ui/tab_strip.rs | 2 +- 6 files changed, 460 insertions(+), 267 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f08417ee..da8718df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3153,7 +3153,6 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5" dependencies = [ "aho-corasick", "anyhow", @@ -3236,7 +3235,6 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5" dependencies = [ "anyhow", "gpui", @@ -3250,7 +3248,6 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 69023700..929936bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -223,8 +223,8 @@ edition = "2024" # `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", features = ["tree-sitter-languages"] } -gpui-component-assets = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.1" } +gpui-component = { path = "../../../../gpui-component/crates/ui", version = "0.5.2", features = ["tree-sitter-languages"] } +gpui-component-assets = { path = "../../../../gpui-component/crates/assets", version = "0.5.1" } gpui = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe" } # Base features are cross-platform (`font-kit`, `runtime_shaders` only map to the diff --git a/src/ui/app.rs b/src/ui/app.rs index 365ae184..554bc904 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -101,6 +101,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 @@ -123,6 +130,7 @@ impl Tab { name: None, last_focused: None, diff_overlay: None, + code: None, sidebar_group: std::cell::RefCell::new(None), } } @@ -758,6 +766,7 @@ impl Tty7App { name: st.name, last_focused: None, diff_overlay: None, + code: None, // 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), @@ -2308,9 +2317,9 @@ 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); - if self.editor.open { - // Code panel up: the tree follows the incoming tab's repos, and - // focus stays on the panel (the terminal is covered). + 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 { @@ -4490,6 +4499,7 @@ fn tabs_from_session( name: st.name.clone(), last_focused: None, diff_overlay: None, + code: None, // 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. diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 530e89c6..a7a5931d 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -81,21 +81,55 @@ impl OpenFile { } } -/// State for the editor panel, held on [`Tty7App`]. -pub(crate) struct EditorPanelState { - pub(crate) open: bool, +/// 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, - /// Watches the parent directories of open files for external changes. - /// Rebuilt whenever the open set changes; `None` while nothing is open. + /// 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 { + visible: true, + 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, - /// Find-references results, shown as a drawer under the editor. - pub(crate) references: Option>, } /// One row in the find-references drawer. @@ -132,57 +166,11 @@ impl EditorPanelState { }) .detach(); Self { - open: false, - files: Vec::new(), - active: 0, watcher: None, events_tx: tx, lsp: crate::ui::lsp::LspRegistry::new(window, cx), - references: None, } } - - pub(crate) fn active_file(&self) -> Option<&OpenFile> { - self.files.get(self.active) - } - - /// Rebuild the external-change watcher over the current open set. Watches - /// each file's *parent directory* (non-recursively): editors that save via - /// rename replace the inode, which a direct file watch loses track of. - fn rebuild_watcher(&mut self) { - use notify::{RecursiveMode, Watcher}; - self.watcher = None; - if self.files.is_empty() { - return; - } - let watched: HashSet = self.files.iter().map(|f| f.path.clone()).collect(); - let dirs: HashSet = watched - .iter() - .filter_map(|p| p.parent().map(Path::to_path_buf)) - .collect(); - let tx = self.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.watcher = Some(watcher); - } } // --------------------------------------------------------------------------- @@ -260,8 +248,65 @@ fn looks_binary(bytes: &[u8]) -> bool { // --------------------------------------------------------------------------- impl Tty7App { - /// Open `path` in the editor panel (activating an existing tab when the - /// file is already open) and reveal the panel. Errors surface as window + /// 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() + } + + /// 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, @@ -269,10 +314,15 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { + if self.tabs.get(self.active).is_none() { + return; + } let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - if let Some(ix) = self.editor.files.iter().position(|f| f.path == path) { - self.editor.active = ix; - self.editor.open = true; + if let Some(code) = self.tab_code_mut() + && let Some(ix) = code.files.iter().position(|f| f.path == path) + { + code.active = ix; + code.visible = true; self.focus_editor(window, cx); cx.notify(); return; @@ -354,32 +404,45 @@ impl Tty7App { } // 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. + // 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(); - if let Some(f) = this.editor.files.iter_mut().find(|f| f.path == path) { - 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 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(); } } }); - self.editor.files.push(OpenFile { + 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())); + code.files.push(OpenFile { path, input, dirty: false, @@ -391,27 +454,33 @@ impl Tty7App { change_task: None, _sub: sub, }); - self.editor.active = self.editor.files.len() - 1; - self.editor.open = true; - self.editor.rebuild_watcher(); + code.active = code.files.len() - 1; + code.visible = true; + self.editor_rebuild_watcher(); self.focus_editor(window, cx); cx.notify(); } /// `ToggleCodePanel` (⌘⇧E / the title-bar tree icon / Esc): flip the - /// code overlay. Opening re-roots the file tree from the active tab's - /// panes and focuses the panel; closing hands focus back to the terminal. + /// 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) { - if self.editor.open { - self.editor.open = false; + 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; } - self.editor.open = true; + code.visible = true; self.file_tree_refresh_roots(window, cx); - if self.editor.active_file().is_some() { + 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); @@ -421,7 +490,7 @@ impl Tty7App { /// 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.editor.active_file() { + if let Some(f) = self.tab_code().and_then(|c| c.active_file()) { f.input.update(cx, |input, cx| input.focus(window, cx)); } } @@ -429,18 +498,25 @@ impl Tty7App { /// 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.editor.open - && self.editor.active_file().is_some_and(|f| { - f.input - .read(cx) - .focus_handle(cx) - .contains_focused(window, cx) - }) + 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(f) = self.editor.files.get_mut(self.editor.active) else { + 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(); @@ -472,7 +548,7 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let Some(f) = self.editor.files.get(ix) else { + let Some(f) = self.tab_code().and_then(|c| c.files.get(ix)) else { return; }; if !f.dirty { @@ -492,11 +568,19 @@ impl Tty7App { let _ = app.update_in(cx, |app, window, cx| match choice { 0 => { // Save, then close. Save failure keeps the tab open. - let prev_active = app.editor.active; - app.editor.active = ix; + 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); - app.editor.active = prev_active; - if app.editor.files.get(ix).is_some_and(|f| !f.dirty) { + 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); } } @@ -517,42 +601,59 @@ impl Tty7App { if !self.editor_has_focus(window, cx) { return false; } - if self.editor.files.is_empty() { - self.editor.open = false; + let Some(code) = self.tab_code_mut() else { + return false; + }; + if code.files.is_empty() { + code.visible = false; cx.notify(); return true; } - self.editor_close_file(self.editor.active, window, cx); + let active = code.active; + self.editor_close_file(active, window, cx); true } fn editor_remove_file(&mut self, ix: usize, cx: &mut Context) { - if ix >= self.editor.files.len() { + let Some(code) = self.tab_code_mut() else { + return; + }; + if ix >= code.files.len() { return; } - let f = self.editor.files.remove(ix); + let f = code.files.remove(ix); if let Some((client, _)) = &f.lsp { client.did_close(&f.path); } - if self.editor.active >= ix && self.editor.active > 0 { - self.editor.active -= 1; + if code.active >= ix && code.active > 0 { + code.active -= 1; } - self.editor.rebuild_watcher(); + 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) { - let Some(f) = self.editor.files.iter().find(|f| f.path == *path) else { - return; - }; - if let Some((client, _)) = &f.lsp { - client.did_change(&f.path, &f.input.read(cx).text().to_string()); + 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 buffer, if any. + /// Apply `publishDiagnostics` for `path` to its open buffers (any tab). pub(crate) fn editor_apply_diagnostics( &mut self, path: &Path, @@ -560,24 +661,27 @@ impl Tty7App { _window: &mut Window, cx: &mut Context, ) { - let Some(f) = self.editor.files.iter().find(|f| f.path == *path) else { - return; - }; - f.input.clone().update(cx, |st, cx| { - let text = st.text().clone(); - if let Some(set) = st.diagnostics_mut() { - set.reset(&text); - set.extend(diags); - cx.notify(); - } - }); + 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.editor.active_file() else { + let Some(f) = self.tab_code().and_then(|c| c.active_file()) else { return; }; let Some((client, _)) = &f.lsp else { return }; @@ -606,7 +710,7 @@ impl Tty7App { /// `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.editor.active_file() else { + let Some(f) = self.tab_code().and_then(|c| c.active_file()) else { return; }; let Some((client, _)) = &f.lsp else { return }; @@ -648,7 +752,9 @@ impl Tty7App { }); } let _ = app.update(cx, |app, cx| { - app.editor.references = Some(items); + if let Some(code) = app.tab_code_mut() { + code.references = Some(items); + } cx.notify(); }); }) @@ -664,7 +770,7 @@ impl Tty7App { cx: &mut Context, ) { self.open_file_in_editor(path, window, cx); - if let Some(f) = self.editor.active_file() + if let Some(f) = self.tab_code().and_then(|c| c.active_file()) && f.path == *path { f.input.clone().update(cx, |st, cx| { @@ -674,42 +780,61 @@ impl Tty7App { } /// A watched file changed on disk. Clean buffers reload silently; dirty - /// ones raise the conflict banner and let the user pick a side. + /// 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 Some(ix) = self.editor.files.iter().position(|f| f.path == *path) else { - return; - }; let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok(); - { - let f = &self.editor.files[ix]; - // Our own save's echo: mtime matches what we just wrote. - if mtime.is_some() && mtime == f.disk_mtime { - return; + 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)); + } } } - if self.editor.files[ix].dirty { - self.editor.files[ix].conflict = true; + for (tab_ix, ix) in reload { + self.editor_reload_from_disk(tab_ix, ix, window, cx); + } + if changed { cx.notify(); - return; } - self.editor_reload_from_disk(ix, window, cx); } - /// Replace the buffer with the on-disk content (used by the silent reload + /// 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.editor.files.get_mut(ix) else { + 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 { @@ -743,10 +868,10 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) -> Option { - if !self.editor.open { + if !self.code_panel_visible() { return None; } - let body = match self.editor.active_file() { + 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 => { @@ -773,8 +898,8 @@ impl Tty7App { } }; let conflict_banner = self - .editor - .active_file() + .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); @@ -832,8 +957,9 @@ impl Tty7App { /// The file tab strip along the panel top. fn render_editor_tabs(&self, _window: &Window, cx: &mut Context) -> gpui::Div { - let active = self.editor.active; - let tabs = self.editor.files.iter().enumerate().map(|(ix, f)| { + let active = self.tab_code().map(|c| c.active).unwrap_or(0); + let files: &[OpenFile] = self.tab_code().map(|c| c.files.as_slice()).unwrap_or(&[]); + let tabs = files.iter().enumerate().map(|(ix, f)| { let is_active = ix == active; let title = f.label(); h_flex() @@ -854,7 +980,9 @@ impl Tty7App { .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, window, cx| { - this.editor.active = ix; + if let Some(code) = this.tab_code_mut() { + code.active = ix; + } this.focus_editor(window, cx); cx.notify(); }), @@ -887,8 +1015,8 @@ impl Tty7App { .child(div().flex_1()) // Markdown files get a preview toggle. .when_some( - self.editor - .active_file() + self.tab_code() + .and_then(|c| c.active_file()) .filter(|f| language_for_path(&f.path) == "markdown"), |this, f| { let preview = f.preview; @@ -898,35 +1026,43 @@ impl Tty7App { .ghost() .xsmall() .on_click(cx.listener(|this, _, _w, cx| { - let ix = this.editor.active; - if let Some(f) = this.editor.files.get_mut(ix) { - f.preview = !f.preview; - cx.notify(); + 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(); + } } })), ) }, ) // Soft-wrap toggle for the active buffer. - .when(self.editor.active_file().is_some(), |this| { - this.child( - Button::new("editor-wrap-toggle") - .label("Wrap") - .ghost() - .xsmall() - .tooltip("Toggle soft wrap") - .on_click(cx.listener(|this, _, window, cx| { - let ix = this.editor.active; - if let Some(f) = this.editor.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( + self.tab_code().is_some_and(|c| c.active_file().is_some()), + |this| { + this.child( + Button::new("editor-wrap-toggle") + .label("Wrap") + .ghost() + .xsmall() + .tooltip("Toggle soft wrap") + .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); + }); + } + })), + ) + }, + ) .child( Button::new("editor-panel-close") .icon(IconName::Close) @@ -941,7 +1077,7 @@ impl Tty7App { /// The find-references drawer (⇧F12 results) under the editor body. fn render_editor_references(&self, cx: &mut Context) -> Option { - let refs = self.editor.references.as_ref()?; + 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 @@ -1003,7 +1139,9 @@ impl Tty7App { .ghost() .xsmall() .on_click(cx.listener(|this, _, _w, cx| { - this.editor.references = None; + if let Some(code) = this.tab_code_mut() { + code.references = None; + } cx.notify(); })), ), @@ -1022,7 +1160,8 @@ impl Tty7App { /// Banner shown when the file changed on disk while the buffer is dirty. fn render_editor_conflict_banner(&self, cx: &mut Context) -> AnyElement { - let ix = self.editor.active; + let tab_ix = self.active; + let ix = self.tab_code().map(|c| c.active).unwrap_or(0); h_flex() .flex_none() .w_full() @@ -1040,7 +1179,7 @@ impl Tty7App { .label("Reload") .small() .on_click(cx.listener(move |this, _, window, cx| { - this.editor_reload_from_disk(ix, window, cx); + this.editor_reload_from_disk(tab_ix, ix, window, cx); })), ) .child( @@ -1049,7 +1188,7 @@ impl Tty7App { .ghost() .small() .on_click(cx.listener(move |this, _, _w, cx| { - if let Some(f) = this.editor.files.get_mut(ix) { + if let Some(f) = this.tab_code_mut().and_then(|c| c.files.get_mut(ix)) { f.conflict = false; cx.notify(); } diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 53da08a5..9451408d 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -97,22 +97,24 @@ impl TreeEdit { } } -/// State for the file-tree panel, held on [`Tty7App`]. +/// 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 { - pub(crate) roots: Vec, - expanded: HashSet, /// 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) selected: Option, pub(crate) show_hidden: bool, pub(crate) width: Rc>, dragging: Rc>, pub(crate) editing: Option, editing_subs: Vec, - /// Recursive watcher per root; rebuilt when the root set changes. + /// 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, @@ -138,11 +140,8 @@ impl FileTreeState { }) .detach(); Self { - roots: Vec::new(), - expanded: HashSet::new(), children: HashMap::new(), gitignore: HashMap::new(), - selected: None, show_hidden: false, width: Rc::new(Cell::new(DEFAULT_WIDTH)), dragging: Rc::new(Cell::new(false)), @@ -154,11 +153,11 @@ impl FileTreeState { } } - /// (Re)attach the recursive watcher to the current roots. - fn rebuild_watcher(&mut self) { + /// (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 self.roots.is_empty() { + if roots.is_empty() { return; } let tx = self.events_tx.clone(); @@ -175,7 +174,7 @@ impl FileTreeState { return; } }; - for root in &self.roots { + for root in roots { if let Err(e) = watcher.watch(root, RecursiveMode::Recursive) { log::warn!("file tree: failed to watch {}: {e}", root.display()); } @@ -185,15 +184,15 @@ impl FileTreeState { /// 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) { + 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 &self.roots { + for root in roots { if !self.children.contains_key(root) { todo.push((root.clone(), root.clone())); } - for dir in &self.expanded { + for dir in expanded { if dir.starts_with(root) && !self.children.contains_key(dir) { todo.push((dir.clone(), root.clone())); } @@ -265,10 +264,11 @@ impl FileTreeState { state } - /// Flatten roots + expanded directories into display order. - pub(crate) fn visible_rows(&self) -> Vec { + /// 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 &self.roots { + for root in roots { let name = root .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -284,12 +284,18 @@ impl FileTreeState { is_root: true, expanded: true, }); - self.flatten_dir(root, 1, &mut rows); + self.flatten_dir(root, 1, expanded, &mut rows); } rows } - fn flatten_dir(&self, dir: &Path, depth: usize, out: &mut Vec) { + fn flatten_dir( + &self, + dir: &Path, + depth: usize, + expanded: &HashSet, + out: &mut Vec, + ) { let Some(entries) = self.children.get(dir) else { return; }; @@ -297,15 +303,15 @@ impl FileTreeState { if !self.show_hidden && e.name.starts_with('.') { continue; } - let expanded = e.is_dir && self.expanded.contains(&e.path); + let is_expanded = e.is_dir && expanded.contains(&e.path); out.push(TreeRow { entry: e.clone(), depth, is_root: false, - expanded, + expanded: is_expanded, }); - if expanded { - self.flatten_dir(&e.path, depth + 1, out); + if is_expanded { + self.flatten_dir(&e.path, depth + 1, expanded, out); } } } @@ -376,15 +382,24 @@ impl Tty7App { roots.push(PathBuf::from(home)); } let _ = window; - if roots != self.file_tree.roots { - self.file_tree.roots = roots; - self.file_tree.children.clear(); - self.file_tree.gitignore.clear(); - self.file_tree.rebuild_watcher(); - } else { - // Same roots: refresh listings but keep expansion state. - self.file_tree.children.clear(); + let Some(code) = self.tab_code_mut() 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(); } @@ -396,11 +411,8 @@ impl Tty7App { paths: &HashSet, cx: &mut Context, ) { - // The tree only renders inside the code overlay; skip churn while it - // is closed (the caches rebuild lazily on the next open anyway). - if !self.editor.open { - return; - } + // 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")); @@ -421,8 +433,11 @@ impl Tty7App { } fn file_tree_toggle_expand(&mut self, dir: &Path, cx: &mut Context) { - if !self.file_tree.expanded.remove(dir) { - self.file_tree.expanded.insert(dir.to_path_buf()); + let Some(code) = self.tab_code_mut() else { + return; + }; + if !code.expanded.remove(dir) { + code.expanded.insert(dir.to_path_buf()); } cx.notify(); } @@ -436,7 +451,9 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - self.file_tree.selected = Some(row_path.to_path_buf()); + if let Some(code) = self.tab_code_mut() { + code.selected = Some(row_path.to_path_buf()); + } if is_dir { self.file_tree_toggle_expand(row_path, cx); } else { @@ -452,12 +469,16 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let rows = self.file_tree.visible_rows(); + 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 = self - .file_tree + let sel_ix = code .selected .as_ref() .and_then(|s| rows.iter().position(|r| r.entry.path == *s)); @@ -469,18 +490,32 @@ impl Tty7App { (Some(i), "up") => i.saturating_sub(1), (Some(i), _) => (i + 1).min(rows.len() - 1), }; - self.file_tree.selected = Some(rows[next].entry.path.clone()); + 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]; - if row.entry.is_dir && row.expanded && !row.is_root { - self.file_tree.expanded.remove(&row.entry.path); - } else if let Some(parent) = row.entry.path.parent() { - // Jump to the parent row (stay put at a root). - if rows.iter().any(|r| r.entry.path == parent) { - self.file_tree.selected = Some(parent.to_path_buf()); + 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(); @@ -489,7 +524,10 @@ impl Tty7App { let Some(i) = sel_ix else { return }; let row = &rows[i]; if row.entry.is_dir && !row.expanded && !row.is_root { - self.file_tree.expanded.insert(row.entry.path.clone()); + let path = row.entry.path.clone(); + if let Some(code) = self.tab_code_mut() { + code.expanded.insert(path); + } cx.notify(); } } @@ -538,26 +576,27 @@ impl Tty7App { }, ); 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 => { - // New entries land in the target dir (or the file's parent). - let dir = if target.is_dir() { - target.to_path_buf() - } else { - target.parent().unwrap_or(target).to_path_buf() - }; - self.file_tree.expanded.insert(dir.clone()); - TreeEdit::NewFile { dir, input } - } - TreeEditKind::NewFolder => { - let dir = if target.is_dir() { - target.to_path_buf() - } else { - target.parent().unwrap_or(target).to_path_buf() - }; - self.file_tree.expanded.insert(dir.clone()); - TreeEdit::NewFolder { dir, input } - } + 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, @@ -606,7 +645,9 @@ impl Tty7App { match result { Ok(new_path) => { self.file_tree.invalidate_dir(edit.host_dir()); - self.file_tree.selected = Some(new_path.clone()); + 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); @@ -652,8 +693,10 @@ impl Tty7App { if let Some(parent) = path.parent() { app.file_tree.invalidate_dir(parent); } - if app.file_tree.selected.as_deref() == Some(&path) { - app.file_tree.selected = None; + if let Some(code) = app.tab_code_mut() + && code.selected.as_deref() == Some(&path) + { + code.selected = None; } cx.notify(); } @@ -694,9 +737,9 @@ impl Tty7App { // Prefer a repo-relative path (what agents resolve best) when the file // sits under one of the tree's roots. let rel = self - .file_tree - .roots - .iter() + .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()); @@ -726,9 +769,13 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) -> AnyElement { - self.file_tree.ensure_loaded(); + let (roots, expanded) = match self.tab_code() { + Some(code) => (code.roots.clone(), code.expanded.clone()), + None => (Vec::new(), std::collections::HashSet::new()), + }; + self.file_tree.ensure_loaded(&roots, &expanded); let width = self.file_tree.width.get().clamp(MIN_WIDTH, MAX_WIDTH); - let rows = self.file_tree.visible_rows(); + let rows = self.file_tree.visible_rows(&roots, &expanded); let list = v_flex() .id("file-tree-rows") @@ -811,7 +858,7 @@ impl Tty7App { ) -> Vec { let path = row.entry.path.clone(); let is_dir = row.entry.is_dir; - let selected = self.file_tree.selected.as_deref() == Some(&*path); + let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path); let muted = cx.theme().muted_foreground; // Inline rename replaces the row's label with an input. diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index e4f6ac5a..05eef581 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -817,7 +817,7 @@ impl Tty7App { // affordance, so heavy split layouts don't grow a forest of icons; lit // (selected) while the overlay is up. Present in both tab-bar modes — // the sidebar layout keeps this strip as the right column's chrome. - let code_open = self.editor.open; + let code_open = self.code_panel_visible(); let code_button = div().occlude().flex_shrink_0().child( Button::new("titlebar-code-panel") .icon(Icon::new(IconName::FolderClosed).size(px(15.))) From 403cfd47a1f739db1133d72cb48006d47e01b20f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:07:35 +0800 Subject: [PATCH 4/8] feat(right-panel): docked detail panel with Info, Changes and Files tabs Add a right-hand detail column showing what the active pane is, not what it prints: session facts plus its process tree and listening ports (daemon-side procinfo, pull-based via QueryProcs), the working-tree diff, and the file tree. Tab row lives in the title bar, body in right_panel. Also record OSC 133 command marks client-side so the panel's Outline can list a pane's commands and scroll back to one, keyed on row text since absolute scrollback indices drift once history fills. --- src/core/actions.rs | 16 + src/core/config.rs | 48 +++ src/daemon/mod.rs | 1 + src/daemon/pane.rs | 14 + src/daemon/procinfo.rs | 412 ++++++++++++++++++ src/daemon/protocol.rs | 58 +++ src/daemon/server.rs | 10 + src/terminal/marks.rs | 411 ++++++++++++++++++ src/terminal/mod.rs | 1 + src/terminal/remote.rs | 131 +++++- src/terminal/view.rs | 38 ++ src/ui/app.rs | 257 ++++++++++- src/ui/code_editor.rs | 411 +++++++++++------- src/ui/diff_overlay.rs | 247 ++++++++--- src/ui/file_tree.rs | 289 ++++++------- src/ui/keymap.rs | 22 + src/ui/lsp.rs | 5 + src/ui/mod.rs | 1 + src/ui/palette.rs | 34 ++ src/ui/right_panel.rs | 954 +++++++++++++++++++++++++++++++++++++++++ src/ui/tab_sidebar.rs | 71 ++- src/ui/tab_strip.rs | 270 +++++++++--- 22 files changed, 3248 insertions(+), 453 deletions(-) create mode 100644 src/daemon/procinfo.rs create mode 100644 src/terminal/marks.rs create mode 100644 src/ui/right_panel.rs diff --git a/src/core/actions.rs b/src/core/actions.rs index 107ad9d3..724a42ab 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -55,6 +55,22 @@ 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). diff --git a/src/core/config.rs b/src/core/config.rs index 7acd1d40..6a94b371 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -97,6 +97,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`). @@ -453,6 +472,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 @@ -557,6 +580,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); } /// Write the current config back to disk, creating the parent directory if @@ -756,6 +783,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 da802527..1981aa34 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -1013,6 +1013,20 @@ impl DaemonPane { /// 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`. + /// 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)) + } + fn pty(&self) -> Option<&PtyBackend> { match &self.backend { PaneBackend::Pty(p) => Some(p), diff --git a/src/daemon/procinfo.rs b/src/daemon/procinfo.rs new file mode 100644 index 00000000..fea6a2d0 --- /dev/null +++ b/src/daemon/procinfo.rs @@ -0,0 +1,412 @@ +//! 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 { + ppid: p.ppid, + 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 fc99bcf0..19cb4e81 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -445,6 +445,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() } @@ -773,6 +807,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 @@ -853,6 +896,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). @@ -908,6 +953,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; @@ -943,6 +991,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]`. @@ -1084,6 +1134,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)?) } @@ -1149,6 +1202,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 } @@ -1227,6 +1283,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)?), } @@ -1274,6 +1331,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..8fe44e9b --- /dev/null +++ b/src/terminal/marks.rs @@ -0,0 +1,411 @@ +//! 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. + self.payload.push(0x1b); + self.state = ScanState::Osc; + } + } + } + } + } + + /// 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 34f06527..db252d5a 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 cf5500bb..ea79dc93 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}; @@ -122,6 +124,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 @@ -200,6 +204,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>, } @@ -325,6 +333,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(), @@ -342,6 +351,7 @@ impl RemoteTerminal { shell_vi_mode: shell_vi_mode.clone(), auth: auth_prompts.clone(), phase: ssh_phase.clone(), + marks: marks.clone(), }, ); @@ -366,6 +376,7 @@ impl RemoteTerminal { auto_supplied_password: false, agent, agent_session, + marks, reader_thread: Some(reader_thread), }) } @@ -408,6 +419,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`). @@ -433,6 +445,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 @@ -490,11 +506,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(); @@ -920,6 +954,12 @@ impl RemoteTerminal { /// The third-party CLI coding agent (Claude Code, Codex, …) running in the /// pane's foreground, as last reported by the daemon, or `None`. Cheap cache /// read — detection runs daemon-side. See [`crate::core::cli_agent`]. + /// 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() + } + pub fn foreground_agent(&self) -> Option { self.agent.lock().ok().and_then(|g| *g) } @@ -1324,6 +1364,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 { @@ -2347,6 +2423,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 08c5fb50..dc1d018a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2368,6 +2368,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(); } @@ -3785,6 +3789,40 @@ impl TerminalView { /// pixel-smooth: whole lines go to the emulator's `display_offset`, the /// remainder stays in `scroll_frac` and shifts the paint. The position may /// come to rest between line boundaries, like a native scroll view. + /// 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 + } + fn smooth_scroll(&mut self, delta: f32, cx: &mut Context) { let mut term = self.terminal.term.lock(); let offset = term.grid().display_offset(); diff --git a/src/ui/app.rs b/src/ui/app.rs index 554bc904..c0fd2536 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. @@ -121,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 { @@ -131,6 +185,7 @@ impl Tab { last_focused: None, diff_overlay: None, code: None, + overlay_top: OverlayTop::default(), sidebar_group: std::cell::RefCell::new(None), } } @@ -144,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 @@ -354,6 +426,8 @@ 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). @@ -365,14 +439,22 @@ 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, /// 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. @@ -509,6 +591,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 @@ -526,6 +609,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_invalidate(); cx.notify(); }); // Any real keypress means "chord, not a bare hold": cancel the held-⌘ @@ -577,6 +664,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, @@ -615,13 +708,18 @@ 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(), 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, @@ -767,6 +865,7 @@ impl Tty7App { 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), @@ -1809,6 +1908,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, @@ -2798,6 +2921,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 @@ -3401,6 +3527,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); @@ -4038,8 +4166,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. @@ -4135,19 +4268,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) - }) - // Code panel: a full-body overlay ([file tree | editor], IDE-style) - // that covers the terminal like the settings/diff overlays do — the - // terminal underneath keeps its size, so toggling never reflows it. - // Covers only the body: the tab sidebar stays visible and switches - // tabs (which re-roots the tree) while the panel is up. - .when_some(self.render_code_overlay(window, 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 @@ -4155,6 +4309,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() @@ -4169,18 +4329,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(), }; @@ -4351,6 +4533,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)), ) @@ -4500,6 +4700,7 @@ fn tabs_from_session( 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. @@ -4613,9 +4814,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 @@ -4647,6 +4867,7 @@ pub(crate) fn new_terminal_native( }, ) .detach(); + watch_pane_focus(&view, window, cx); Ok(view) } diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index a7a5931d..61bcf895 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -68,6 +68,9 @@ pub(crate) struct OpenFile { /// 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 { @@ -104,7 +107,13 @@ pub(crate) struct TabCode { impl TabCode { pub(crate) fn new() -> Self { Self { - visible: true, + // 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, @@ -257,6 +266,16 @@ impl Tty7App { 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) @@ -317,12 +336,21 @@ impl Tty7App { 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.active = ix; 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; @@ -442,19 +470,25 @@ impl Tty7App { .get_mut(self.active) .expect("checked at function entry"); let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new())); - code.files.push(OpenFile { - path, - input, - dirty: false, - disk_mtime: mtime, - conflict: false, - preview: false, - wrap: false, - lsp, - change_task: None, - _sub: sub, - }); - code.active = code.files.len() - 1; + 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); @@ -467,6 +501,20 @@ impl Tty7App { /// 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; }; @@ -488,6 +536,14 @@ impl Tty7App { 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()) { @@ -890,7 +946,10 @@ impl Tty7App { } 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() @@ -908,15 +967,19 @@ impl Tty7App { .flex_1() .min_w_0() .h_full() - .child(self.render_editor_tabs(window, cx)) + .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( - h_flex() + 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() @@ -928,13 +991,185 @@ impl Tty7App { this.toggle_code_panel(window, cx); } })) - .child(self.render_file_tree_column(window, cx)) - .child(self.render_tree_divider(cx)) - .child(editor_col) + // 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() @@ -955,126 +1190,6 @@ impl Tty7App { ) } - /// The file tab strip along the panel top. - fn render_editor_tabs(&self, _window: &Window, cx: &mut Context) -> gpui::Div { - let active = self.tab_code().map(|c| c.active).unwrap_or(0); - let files: &[OpenFile] = self.tab_code().map(|c| c.files.as_slice()).unwrap_or(&[]); - let tabs = files.iter().enumerate().map(|(ix, f)| { - let is_active = ix == active; - let title = f.label(); - h_flex() - .id(("editor-tab", ix)) - .flex_none() - .items_center() - .gap_1() - .px_2() - .py_1() - .rounded(cx.theme().radius) - .text_sm() - .cursor_pointer() - .when(is_active, |d| d.bg(cx.theme().accent)) - .when(!is_active, |d| { - d.text_color(cx.theme().muted_foreground) - .hover(|s| s.bg(cx.theme().accent.opacity(0.5))) - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, window, cx| { - if let Some(code) = this.tab_code_mut() { - code.active = ix; - } - this.focus_editor(window, cx); - cx.notify(); - }), - ) - .child(div().child(title)) - .when(f.dirty, |d| { - d.child(div().size(px(7.)).rounded_full().bg(cx.theme().warning)) - }) - .child( - Button::new(("editor-tab-close", ix)) - .icon(IconName::Close) - .ghost() - .xsmall() - .on_click(cx.listener(move |this, _, window, cx| { - this.editor_close_file(ix, window, cx); - })), - ) - }); - h_flex() - .flex_none() - .w_full() - .items_center() - .gap_1() - .px_1() - .py_1() - .border_b_1() - .border_color(cx.theme().border) - .overflow_x_hidden() - .children(tabs) - .child(div().flex_1()) - // Markdown files get a preview toggle. - .when_some( - self.tab_code() - .and_then(|c| c.active_file()) - .filter(|f| language_for_path(&f.path) == "markdown"), - |this, f| { - let preview = f.preview; - this.child( - Button::new("editor-md-preview-toggle") - .label(if preview { "Edit" } else { "Preview" }) - .ghost() - .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(); - } - } - })), - ) - }, - ) - // Soft-wrap toggle for the active buffer. - .when( - self.tab_code().is_some_and(|c| c.active_file().is_some()), - |this| { - this.child( - Button::new("editor-wrap-toggle") - .label("Wrap") - .ghost() - .xsmall() - .tooltip("Toggle soft wrap") - .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); - }); - } - })), - ) - }, - ) - .child( - Button::new("editor-panel-close") - .icon(IconName::Close) - .ghost() - .small() - .tooltip("Back to Terminal (Esc)") - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_code_panel(window, cx); - })), - ) - } - /// 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()?; @@ -1134,16 +1249,20 @@ impl Tty7App { .text_sm() .child(div().flex_1().child(format!("{} references", refs.len()))) .child( - Button::new("editor-refs-close") - .icon(IconName::Close) - .ghost() - .xsmall() - .on_click(cx.listener(|this, _, _w, cx| { + 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( diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 65e0d413..28a65f8f 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() @@ -622,6 +752,25 @@ impl Tty7App { /// Whether a file's body shows: small text diffs default open, big ones (and /// anything the user explicitly flipped) invert via the `toggled` set. +/// 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()) +} + fn file_expanded(file: &FileDiff, toggled: &HashSet) -> bool { let default_open = file.added + file.removed <= AUTO_COLLAPSE_LINES; default_open != toggled.contains(&file.path) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 9451408d..96926c32 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -14,7 +14,6 @@ //! invalidated by `notify` events, so a huge repo only ever pays for the //! directories actually expanded. -use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -22,9 +21,8 @@ use std::rc::Rc; use gpui::prelude::*; use gpui::{ AnyElement, Context, Entity, ExternalPaths, FocusHandle, KeyDownEvent, MouseButton, - MouseMoveEvent, MouseUpEvent, PromptLevel, SharedString, Subscription, Window, div, px, + PromptLevel, SharedString, Subscription, Window, div, px, }; -use gpui_component::button::{Button, ButtonVariants as _}; 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}; @@ -32,11 +30,6 @@ use ignore::gitignore::Gitignore; use crate::ui::app::Tty7App; -/// Width band (px) for the tree column. -const MIN_WIDTH: f32 = 160.0; -const MAX_WIDTH: f32 = 480.0; -const DEFAULT_WIDTH: f32 = 240.0; - /// Per-level indent (px) for nested rows. const INDENT: f32 = 14.0; @@ -108,8 +101,6 @@ pub(crate) struct FileTreeState { /// Invalidated when a `.gitignore` changes. gitignore: HashMap>>, pub(crate) show_hidden: bool, - pub(crate) width: Rc>, - dragging: Rc>, pub(crate) editing: Option, editing_subs: Vec, /// One recursive watcher over the union of every tab's roots; rebuilt @@ -143,8 +134,6 @@ impl FileTreeState { children: HashMap::new(), gitignore: HashMap::new(), show_hidden: false, - width: Rc::new(Cell::new(DEFAULT_WIDTH)), - dragging: Rc::new(Cell::new(false)), editing: None, editing_subs: Vec::new(), watcher: None, @@ -264,9 +253,71 @@ impl FileTreeState { 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 { + let mut queue: Vec = vec![root.clone()]; + while let Some(dir) = queue.first().cloned() { + queue.remove(0); + 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(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 { + pub(crate) fn visible_rows( + &self, + roots: &[PathBuf], + expanded: &HashSet, + ) -> Vec { let mut rows = Vec::new(); for root in roots { let name = root @@ -382,7 +433,7 @@ impl Tty7App { roots.push(PathBuf::from(home)); } let _ = window; - let Some(code) = self.tab_code_mut() else { + let Some(code) = self.tab_code_mut_or_init() else { return; }; if roots != code.roots { @@ -454,6 +505,18 @@ impl Tty7App { 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 { @@ -462,6 +525,22 @@ impl Tty7App { 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, @@ -472,9 +551,7 @@ impl Tty7App { let Some(code) = self.tab_code() else { return; }; - let rows = self - .file_tree - .visible_rows(&code.roots, &code.expanded); + let rows = self.file_tree.visible_rows(&code.roots, &code.expanded); if rows.is_empty() { return; } @@ -511,9 +588,7 @@ impl Tty7App { 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() - { + } 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()); } @@ -764,91 +839,53 @@ enum TreeEditKind { impl Tty7App { /// The file-tree column: the code overlay's left side (the overlay /// renders the divider and the editor to its right). - pub(crate) fn render_file_tree_column( + /// 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()), }; - self.file_tree.ensure_loaded(&roots, &expanded); - let width = self.file_tree.width.get().clamp(MIN_WIDTH, MAX_WIDTH); - let rows = self.file_tree.visible_rows(&roots, &expanded); - - let list = v_flex() - .id("file-tree-rows") + 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() - .py_1() - .children( - rows.iter() - .flat_map(|row| self.render_tree_row(row, window, cx)), - ); - - v_flex() - .id("file-tree-panel") - .flex_none() - .h_full() - .w(px(width)) - .bg(cx.theme().background) + .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); })) - .child(self.render_tree_header(cx)) - .child(list) + .children( + rows.iter() + .flat_map(|row| self.render_tree_row(row, window, cx)), + ) .into_any_element() } - /// Panel header: title + refresh / new-file / hidden-files toggle. - fn render_tree_header(&self, cx: &mut Context) -> gpui::Div { - let show_hidden = self.file_tree.show_hidden; - h_flex() - .flex_none() - .items_center() - .gap_0p5() - .px_2() - .py_1() - .border_b_1() - .border_color(cx.theme().border) - .child( - div() - .flex_1() - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .child("Files"), - ) - .child( - Button::new("tree-refresh") - .icon(IconName::LoaderCircle) - .ghost() - .xsmall() - .tooltip("Refresh") - .on_click(cx.listener(|this, _, window, cx| { - this.file_tree_refresh_roots(window, cx); - })), - ) - .child( - Button::new("tree-toggle-hidden") - .icon(IconName::Eye) - .ghost() - .xsmall() - .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(); - })), - ) - } - /// One row (plus, when an inline edit targets it, the edit input row). fn render_tree_row( &self, @@ -860,6 +897,12 @@ impl Tty7App { let is_dir = row.entry.is_dir; let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path); let muted = cx.theme().muted_foreground; + // 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!( @@ -905,6 +948,7 @@ impl Tty7App { .py_0p5() .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))) @@ -915,6 +959,15 @@ impl Tty7App { 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({ @@ -1086,70 +1139,6 @@ impl Tty7App { } menu } - - /// The draggable divider on the tree's right edge. - pub(crate) fn render_tree_divider(&self, cx: &mut Context) -> AnyElement { - let width = self.file_tree.width.clone(); - let dragging = self.file_tree.dragging.clone(); - let idle = cx.theme().border; - let active = cx.theme().drag_border; - let line = if dragging.get() { active } else { idle }; - - div() - .id("file-tree-divider") - .relative() - .flex_none() - .w(px(5.)) - .h_full() - .flex() - .items_center() - .justify_center() - .cursor_col_resize() - .child( - gpui::canvas(|_, _, _| (), { - let width = width.clone(); - let dragging = dragging.clone(); - move |bounds, _, window, _cx| { - let divider_x = bounds.origin.x; - window.on_mouse_event({ - let width = width.clone(); - let dragging = dragging.clone(); - move |ev: &MouseMoveEvent, _phase, window, _cx| { - if !dragging.get() { - return; - } - // The tree's left edge = divider left minus - // the current width; new width follows the - // pointer from that fixed edge. - let left = divider_x - px(width.get()); - let w = (ev.position.x - left).max(px(0.)); - width.set(w.as_f32().clamp(MIN_WIDTH, MAX_WIDTH)); - window.refresh(); - } - }); - window.on_mouse_event({ - let dragging = dragging.clone(); - move |_ev: &MouseUpEvent, _phase, window, _cx| { - if dragging.get() { - dragging.set(false); - window.refresh(); - } - } - }); - } - }) - .absolute() - .size_full(), - ) - .child(div().w(px(1.)).h_full().bg(line)) - .on_mouse_down(MouseButton::Left, { - move |_ev, window, _cx| { - dragging.set(true); - window.refresh(); - } - }) - .into_any_element() - } } /// The little drag ghost shown while a row is dragged toward a terminal. diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 272fe37c..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 @@ -479,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. diff --git a/src/ui/lsp.rs b/src/ui/lsp.rs index bd76f6d7..63870456 100644 --- a/src/ui/lsp.rs +++ b/src/ui/lsp.rs @@ -210,6 +210,11 @@ impl LspClient { }) } + /// 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); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8a4761ca..c84b8d59 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,6 +19,7 @@ pub mod palette; pub mod pane; pub mod perf; pub mod presets; +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 8c35c183..ed8b4cbe 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -51,6 +51,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, @@ -133,6 +138,17 @@ 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", @@ -214,6 +230,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), diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs new file mode 100644 index 00000000..7762fd91 --- /dev/null +++ b/src/ui/right_panel.rs @@ -0,0 +1,954 @@ +//! 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, Window, div, prelude::*, px}; +use gpui_component::button::Button; +use gpui_component::input::Input; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; +use std::path::PathBuf; + +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 query is in flight. Also the poll loop's own guard: the loop reschedules + /// itself from the completion handler, so this being set means "a tick is + /// already on the way" and a re-render must not start a second chain. + pub(crate) procs_loading: bool, +} + +/// 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( + h_flex() + .flex_none() + .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) + .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. + fn panel_title( + &self, + text: &str, + trailing: Option, + cx: &mut Context, + ) -> AnyElement { + h_flex() + .flex_none() + .h(px(28.)) + .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( + div() + .text_size(px(10.)) + .text_color(cx.theme().muted_foreground) + .child(text.to_uppercase()), + ) + .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, 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 mut list = v_flex().px(px(CONTENT_INSET)).py(px(2.)).gap(px(5.)); + for (k, v) in rows { + list = list.child( + h_flex() + .items_baseline() + .gap(px(8.)) + .text_size(px(11.5)) + .child( + div() + .flex_none() + .w(px(52.)) + .text_color(cx.theme().muted_foreground) + .child(k), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().foreground) + .child(v), + ), + ); + } + + let inner = v_flex() + .child(list) + .when_some(cwd_for_actions, |this, cwd| { + this.child(self.cwd_actions(cwd, cx)) + }) + .children(self.procs_section(cx)) + .children(self.ports_section(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 divider 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. + fn panel_subtitle(&self, text: &str, cx: &mut Context) -> AnyElement { + div() + .px(px(CONTENT_INSET)) + .pt(px(12.)) + .pb(px(3.)) + .text_size(px(10.)) + .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, cx: &mut Context) -> Option { + let procs = &self.procs()?.procs; + if procs.len() < 2 { + return None; + } + let mut list = v_flex().px(px(CONTENT_INSET)).gap(px(1.)); + for p in procs { + list = list.child( + h_flex() + .items_baseline() + .gap(px(6.)) + .text_size(px(11.5)) + .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_color(if p.foreground { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }) + .child(p.name.clone()), + ) + .child( + div() + .flex_none() + .text_size(px(10.5)) + .text_color(cx.theme().muted_foreground) + .child(p.pid.to_string()), + ), + ); + } + Some( + v_flex() + .child(self.panel_subtitle("Processes", 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, cx: &mut Context) -> Option { + let ports = &self.procs()?.ports; + if ports.is_empty() { + return None; + } + let mut list = v_flex().px(px(CONTENT_INSET)).gap(px(1.)); + for p in ports { + list = list.child( + h_flex() + .items_baseline() + .gap(px(8.)) + .text_size(px(11.5)) + .child( + div() + .flex_none() + .w(px(52.)) + .text_color(cx.theme().foreground) + .child(p.port.to_string()), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(p.name.clone()), + ), + ); + } + Some( + v_flex() + .child(self.panel_subtitle("Ports", cx)) + .child(list) + .into_any_element(), + ) + } + + /// The cached query, but only when it describes the pane currently on screen. + fn procs(&self) -> Option<&PaneProcs> { + 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; + } + if !self.right_panel.procs_loading { + self.spawn_procs_query(pane_id, 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, cx: &mut Context) { + self.right_panel.procs_loading = true; + 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| { + app.right_panel.procs_loading = false; + // A pane switch while we flew makes this answer stale; drop it + // and let the new pane's own query land. + if app.right_panel.procs_pane != Some(pane_id) { + return false; + } + app.right_panel.procs = Some(procs); + cx.notify(); + let cfg = cx.global::(); + cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info + }) + .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 close the panel. + let cfg = cx.global::(); + let wanted = cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info; + if wanted && app.right_panel.procs_pane == Some(pane_id) { + app.spawn_procs_query(pane_id, cx); + } + }); + }) + .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 title = self.panel_title("Outline", None, cx); + let Some(leaf) = self + .tabs + .get(self.active) + .and_then(|t| t.detail_pane(window, cx)) + else { + return self.panel_scroll(self.panel_empty("No active session.", cx), title); + }; + let marks = leaf.read(cx).command_marks(); + if marks.is_empty() { + // 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). + return self.panel_scroll( + self.panel_empty("No commands recorded for this pane.", cx), + title, + ); + } + + let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); + for mark in marks.iter().rev() { + let row = mark.row; + let leaf = leaf.clone(); + // A command that failed is the one you're most often looking for, so + // it gets the only color in the list. + let failed = mark.exit.is_some_and(|c| c != 0); + list = list.child( + h_flex() + .id(gpui::SharedString::from(format!("panel-mark-{row}"))) + .items_baseline() + .gap(px(6.)) + .px(px(4.)) + .py(px(2.)) + .rounded(px(4.)) + .text_size(px(11.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( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(if failed { + cx.theme().danger + } else { + cx.theme().foreground + }) + // Commands wrap in the shell but must not here: one + // row per command is what makes the list scannable. + .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)) + .text_color(cx.theme().danger) + .child(code.to_string()), + ) + }) + // A command still running is worth marking: it's why the + // pane is busy. + .when(!mark.done, |this| { + this.child( + div() + .flex_none() + .text_size(px(10.5)) + .text_color(cx.theme().muted_foreground) + .child("…"), + ) + }), + ); + } + 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 title = self.panel_title("Changes", None, cx); + 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 { + 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, which clears the cache (see `right_panel_invalidate`). + 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); + } + + 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_baseline() + .gap(px(8.)) + .px(px(4.)) + .py(px(2.)) + .rounded(px(4.)) + .text_size(px(11.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, + ); + }) + }) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().foreground) + .child(path), + ) + .child( + div() + .flex_none() + .text_color(cx.theme().success) + .child(format!("+{added}")), + ) + .child( + div() + .flex_none() + .text_color(cx.theme().danger) + .child(format!("−{removed}")), + ), + ); + } + if !untracked.is_empty() { + list = list.child( + div() + .pt(px(4.)) + .px(px(4.)) + .text_size(px(11.)) + .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(); + } + + /// Drop the cached diff so the next paint re-probes. Called from the same + /// git-status observer that refreshes the sidebar's `+N −M`. + pub(crate) fn right_panel_invalidate(&mut self) { + self.right_panel.diff_cwd = None; + } + + // ── 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", 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() + } +} + +/// 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 304f7dae..837bd8fc 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -74,7 +74,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(); @@ -272,7 +277,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 @@ -381,7 +386,7 @@ impl Tty7App { .items_center() .gap_1p5() .pl_2() - .pr_1p5() + .pr_2() .pt_1p5() .pb_0p5() .text_size(px(11.)) @@ -410,20 +415,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(15.))) + .ghost() + .xsmall() + .w(px(30.)) + .h(px(30.)) + .rounded_lg(), + cx, + ), + ) + .child( + crate::ui::tab_strip::chrome_tile( + Button::new("sidebar-collapse") + .icon(Icon::new(IconName::PanelLeft).size(px(15.))), + false, + cx, + ) .xsmall() .w(px(30.)) .h(px(30.)) - .rounded_lg(), - cx, - ); + .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 @@ -433,7 +465,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() @@ -444,8 +476,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, @@ -558,8 +589,10 @@ 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. - .child(div().h(px(TITLE_BAR_HEIGHT)).flex_shrink_0()) + // 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. + .child(controls) .child(top_bar) .child(list), ) diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 05eef581..b4156b48 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -5,16 +5,16 @@ //! orchestration rather than chrome rendering. use gpui::{ - App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, div, - prelude::*, px, + AnyElement, App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, + div, 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, Selectable as _, Sizable as _, h_flex}; 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; @@ -146,7 +146,144 @@ 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) + .foreground(cx.theme().secondary_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::new(IconName::PanelRight).size(px(15.))), + panel_open, + cx, + ) + .xsmall() + .w(px(30.)) + .h(px(30.)) + .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(15.))), + false, + cx, + ) + .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)) + }, + ), + ), + ) + } + + /// 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, IconName::Info, "Info"), + (RightPanelTab::Outline, IconName::SquareTerminal, "Outline"), + (RightPanelTab::Changes, IconName::Replace, "Changes"), + (RightPanelTab::Files, 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::new(icon).size(px(15.))), + active_tab == tab, + cx, + ) + .xsmall() + .w(px(30.)) + .h(px(30.)) + .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 @@ -782,9 +919,12 @@ 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(15.))), + false, + cx, + ) .xsmall() .w(px(30.)) .h(px(30.)) @@ -793,61 +933,60 @@ impl Tty7App { ), ); - // 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()); - // Code-panel toggle: the one on-screen entry point for the file-tree + - // editor overlay (⌘⇧E). A global title-bar tile rather than a per-pane - // affordance, so heavy split layouts don't grow a forest of icons; lit - // (selected) while the overlay is up. Present in both tab-bar modes — - // the sidebar layout keeps this strip as the right column's chrome. - let code_open = self.code_panel_visible(); - let code_button = div().occlude().flex_shrink_0().child( - Button::new("titlebar-code-panel") - .icon(Icon::new(IconName::FolderClosed).size(px(15.))) - .ghost() - .xsmall() - .w(px(30.)) - .h(px(30.)) - .rounded_lg() - .selected(code_open) - .tooltip("Code Panel") - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_code_panel(window, cx); - })), - ); + // 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(15.))), + false, + cx, + ) + .xsmall() + .w(px(30.)) + .h(px(30.)) + .rounded_lg(), + cx, + ), + ), + ) + .child( + div().occlude().flex_shrink_0().child( + chrome_tile( + Button::new("titlebar-expand-sidebar") + .icon(Icon::new(IconName::PanelLeft).size(px(15.))), + false, + cx, + ) + .xsmall() + .w(px(30.)) + .h(px(30.)) + .rounded_lg() + .tooltip("Show Sidebar") + .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), + ), + ) + }); - 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)) - }), - ); + 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. @@ -869,20 +1008,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; give it extra right breathing room there so it - // reads as a menu affordance, not a fourth window control. - .when(!cfg!(target_os = "macos"), |this| this.pr_3()) .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(code_button) - .child(menu_button) + .when_some(right_chrome, |this, chrome| this.child(chrome)) } } From de3896cbdf6e1a737efb87861adc478305764122 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:43:06 +0800 Subject: [PATCH 5/8] feat(right-panel): redesign the detail panel and chrome icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the detail panel's four tabs and the window chrome to read as a deliberate, commercial-grade surface while keeping the terminal's calm. - Chrome icons: filled-block panel toggles; Outline gets a list glyph, Changes a git-branch, Info a redrawn mark; glyphs go to 18px on the full foreground instead of the faint 15px secondary tone. - Panel body: labelled Session / Processes / Ports bands, mono values, pid/port pills, status dots on Outline, neutral M/U badges on Changes, live counts in the headers, and roomier rows. Hue only ever lands on the git add / remove / fail semantics, everything else stays neutral grey. - Fixes: the corner "…" no longer jumps a pixel when the panel opens (the top strip mirrors the TitleBar's hidden bottom border); that strip now drags the window and double-click zooms, like the rail's; and the process list stops flickering — the poll guard now spans the whole 2s cycle instead of being restarted by every repaint between ticks. --- assets/icons/info.svg | 1 + assets/icons/list.svg | 1 + assets/icons/panel-left.svg | 1 + assets/icons/panel-right.svg | 1 + src/ui/assets.rs | 13 ++ src/ui/file_tree.rs | 6 +- src/ui/right_panel.rs | 387 +++++++++++++++++++++++++---------- src/ui/tab_sidebar.rs | 12 +- src/ui/tab_strip.rs | 69 ++++--- 9 files changed, 355 insertions(+), 136 deletions(-) create mode 100644 assets/icons/info.svg create mode 100644 assets/icons/list.svg create mode 100644 assets/icons/panel-left.svg create mode 100644 assets/icons/panel-right.svg 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/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/file_tree.rs b/src/ui/file_tree.rs index 96926c32..47f92fa5 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -945,7 +945,7 @@ impl Tty7App { .gap_1() .pl(px(6.0 + row.depth as f32 * INDENT)) .pr_1() - .py_0p5() + .py_1() .rounded(cx.theme().radius) .cursor_pointer() // Soft inset-pill highlight on the content surface. @@ -953,8 +953,10 @@ impl Tty7App { .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().primary + cx.theme().foreground } else { muted })) diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 7762fd91..fdb1b63d 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -13,11 +13,15 @@ //! 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, Window, div, prelude::*, px}; +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, Sizable as _, h_flex, v_flex}; +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; @@ -51,10 +55,17 @@ pub(crate) struct RightPanelState { pub(crate) procs_pane: Option, /// Last completed process/port query for `procs_pane`. pub(crate) procs: Option, - /// A query is in flight. Also the poll loop's own guard: the loop reschedules - /// itself from the completion handler, so this being set means "a tick is - /// already on the way" and a re-render must not start a second chain. + /// 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 @@ -134,10 +145,43 @@ impl Tty7App { // 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( + .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)) @@ -145,8 +189,8 @@ impl Tty7App { .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(self.window_chrome(window, cx)) + }) .child(body) .child(handle) .into_any_element(), @@ -252,15 +296,20 @@ impl Tty7App { /// 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(28.)) + .h(px(32.)) .items_center() .justify_between() .pl(px(CONTENT_INSET)) @@ -272,10 +321,25 @@ impl Tty7App { CONTENT_INSET })) .child( - div() - .text_size(px(10.)) - .text_color(cx.theme().muted_foreground) - .child(text.to_uppercase()), + 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() @@ -366,7 +430,7 @@ impl Tty7App { /// 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, cx); + 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. @@ -416,25 +480,31 @@ impl Tty7App { // ticking while this tab is the one being looked at. self.sync_procs(pane_id, cx); - let mut list = v_flex().px(px(CONTENT_INSET)).py(px(2.)).gap(px(5.)); + 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(8.)) - .text_size(px(11.5)) + .gap(px(9.)) + .py(px(1.)) + .text_size(px(12.)) .child( div() .flex_none() - .w(px(52.)) + .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), ), @@ -442,6 +512,10 @@ impl Tty7App { } 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)) @@ -504,14 +578,20 @@ impl Tty7App { .into_any_element() } - /// A small caps divider 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. - fn panel_subtitle(&self, text: &str, cx: &mut Context) -> AnyElement { + /// 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(12.)) - .pb(px(3.)) - .text_size(px(10.)) + .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() @@ -525,13 +605,13 @@ impl Tty7App { if procs.len() < 2 { return None; } - let mut list = v_flex().px(px(CONTENT_INSET)).gap(px(1.)); + 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_baseline() - .gap(px(6.)) - .text_size(px(11.5)) + .items_center() + .gap(px(8.)) .child( div() .flex_1() @@ -540,6 +620,8 @@ impl Tty7App { // 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 { @@ -547,18 +629,17 @@ impl Tty7App { }) .child(p.name.clone()), ) - .child( - div() - .flex_none() - .text_size(px(10.5)) - .text_color(cx.theme().muted_foreground) - .child(p.pid.to_string()), - ), + .child(info_chip( + &p.pid.to_string(), + cx.theme().accent, + cx.theme().muted_foreground, + &mono, + )), ); } Some( v_flex() - .child(self.panel_subtitle("Processes", cx)) + .child(self.panel_subtitle("Processes", true, cx)) .child(list) .into_any_element(), ) @@ -571,25 +652,26 @@ impl Tty7App { if ports.is_empty() { return None; } - let mut list = v_flex().px(px(CONTENT_INSET)).gap(px(1.)); + 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_baseline() + .items_center() .gap(px(8.)) - .text_size(px(11.5)) - .child( - div() - .flex_none() - .w(px(52.)) - .text_color(cx.theme().foreground) - .child(p.port.to_string()), - ) + .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()), ), @@ -597,7 +679,7 @@ impl Tty7App { } Some( v_flex() - .child(self.panel_subtitle("Ports", cx)) + .child(self.panel_subtitle("Ports", true, cx)) .child(list) .into_any_element(), ) @@ -618,17 +700,24 @@ impl Tty7App { // 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.spawn_procs_query(pane_id, cx); + 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, cx: &mut Context) { - self.right_panel.procs_loading = true; + 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() @@ -636,16 +725,21 @@ impl Tty7App { .await; let keep_polling = this .update(cx, |app, cx| { - app.right_panel.procs_loading = false; - // A pane switch while we flew makes this answer stale; drop it - // and let the new pane's own query land. - if app.right_panel.procs_pane != Some(pane_id) { + // 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::(); - cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info + 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 { @@ -654,11 +748,16 @@ impl Tty7App { 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 close the panel. + // 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.right_panel.procs_pane == Some(pane_id) { - app.spawn_procs_query(pane_id, cx); + if wanted { + app.spawn_procs_query(pane_id, generation, cx); + } else { + app.right_panel.procs_loading = false; } }); }) @@ -674,41 +773,59 @@ impl Tty7App { /// 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 title = self.panel_title("Outline", None, cx); 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); }; - let marks = leaf.read(cx).command_marks(); - if marks.is_empty() { + // 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(); - // A command that failed is the one you're most often looking for, so - // it gets the only color in the list. 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_baseline() - .gap(px(6.)) + .items_center() + .gap(px(8.)) .px(px(4.)) - .py(px(2.)) - .rounded(px(4.)) - .text_size(px(11.5)) + .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| { @@ -716,18 +833,21 @@ impl Tty7App { 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 }) - // Commands wrap in the shell but must not here: one - // row per command is what makes the list scannable. .child(one_line(&mark.text)), ) // Only nonzero exits earn a badge. Annotating every success @@ -738,20 +858,10 @@ impl Tty7App { div() .flex_none() .text_size(px(10.5)) + .font_family(mono.clone()) .text_color(cx.theme().danger) .child(code.to_string()), ) - }) - // A command still running is worth marking: it's why the - // pane is busy. - .when(!mark.done, |this| { - this.child( - div() - .flex_none() - .text_size(px(10.5)) - .text_color(cx.theme().muted_foreground) - .child("…"), - ) }), ); } @@ -764,7 +874,6 @@ impl Tty7App { /// 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 title = self.panel_title("Changes", None, cx); let cwd = self .tabs .get(self.active) @@ -777,6 +886,7 @@ impl Tty7App { }); 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 @@ -788,6 +898,18 @@ impl Tty7App { 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), @@ -811,12 +933,11 @@ impl Tty7App { list = list.child( h_flex() .id(gpui::SharedString::from(format!("panel-change-{path}"))) - .items_baseline() + .items_center() .gap(px(8.)) .px(px(4.)) - .py(px(2.)) - .rounded(px(4.)) - .text_size(px(11.5)) + .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)) @@ -835,36 +956,62 @@ impl Tty7App { ); }) }) + // 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), ) - .child( - div() - .flex_none() - .text_color(cx.theme().success) - .child(format!("+{added}")), - ) - .child( - div() - .flex_none() - .text_color(cx.theme().danger) - .child(format!("−{removed}")), - ), + // +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( - div() - .pt(px(4.)) + h_flex() + .items_center() + .gap(px(8.)) .px(px(4.)) - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - .child(format!("{} untracked", untracked.len())), + .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() @@ -913,7 +1060,7 @@ impl Tty7App { /// 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", Some(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() @@ -926,6 +1073,38 @@ impl Tty7App { } } +/// 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::*; diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 0cea4dfd..e17b82cd 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -748,11 +748,11 @@ impl Tty7App { .child( self.attach_new_tab_menu( Button::new("sidebar-add") - .icon(Icon::new(IconName::Plus).size(px(15.))) + .icon(Icon::new(IconName::Plus).size(px(18.))) .ghost() .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg(), cx, ), @@ -760,13 +760,13 @@ impl Tty7App { .child( crate::ui::tab_strip::chrome_tile( Button::new("sidebar-collapse") - .icon(Icon::new(IconName::PanelLeft).size(px(15.))), + .icon(Icon::empty().path("icons/panel-left.svg").size(px(18.))), false, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .tooltip("Hide Sidebar") .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 1a5925ba..37930a88 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -164,7 +164,10 @@ pub(crate) fn chrome_tile_variant(cx: &gpui::App) -> ButtonCustomVariant { let accent = cx.theme().sidebar_accent; ButtonCustomVariant::new(cx) .color(cx.theme().transparent) - .foreground(cx.theme().secondary_foreground) + // 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) } @@ -213,13 +216,13 @@ impl Tty7App { div().occlude().flex_shrink_0().child( chrome_tile( Button::new("titlebar-right-panel") - .icon(Icon::new(IconName::PanelRight).size(px(15.))), + .icon(Icon::empty().path("icons/panel-right.svg").size(px(18.))), panel_open, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .tooltip("Detail Panel") .on_click(cx.listener(|this, _, _window, cx| { @@ -231,13 +234,13 @@ impl Tty7App { div().occlude().flex_shrink_0().child( chrome_tile( Button::new("titlebar-menu") - .icon(Icon::new(IconName::Ellipsis).size(px(15.))), + .icon(Icon::new(IconName::Ellipsis).size(px(18.))), false, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .dropdown_menu_with_anchor( gpui::Anchor::TopRight, @@ -257,10 +260,29 @@ impl Tty7App { pub(crate) fn right_panel_tabs(&self, cx: &mut Context) -> Vec { let active_tab = cx.global::().right_panel_tab; [ - (RightPanelTab::Info, IconName::Info, "Info"), - (RightPanelTab::Outline, IconName::SquareTerminal, "Outline"), - (RightPanelTab::Changes, IconName::Replace, "Changes"), - (RightPanelTab::Files, IconName::FolderClosed, "Files"), + ( + 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)| { @@ -269,14 +291,13 @@ impl Tty7App { .flex_shrink_0() .child( chrome_tile( - Button::new(("right-panel-tab", tab as usize)) - .icon(Icon::new(icon).size(px(15.))), + Button::new(("right-panel-tab", tab as usize)).icon(icon.size(px(18.))), active_tab == tab, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .tooltip(label) .on_click(cx.listener(move |this, _, _window, cx| { @@ -1061,13 +1082,13 @@ impl Tty7App { self.attach_new_tab_menu( chrome_tile( Button::new("tab-add") - .icon(Icon::new(IconName::Plus).size(px(15.))), + .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, ), @@ -1091,13 +1112,13 @@ impl Tty7App { self.attach_new_tab_menu( chrome_tile( Button::new("titlebar-add-collapsed") - .icon(Icon::new(IconName::Plus).size(px(15.))), + .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, ), @@ -1107,13 +1128,13 @@ impl Tty7App { div().occlude().flex_shrink_0().child( chrome_tile( Button::new("titlebar-expand-sidebar") - .icon(Icon::new(IconName::PanelLeft).size(px(15.))), + .icon(Icon::empty().path("icons/panel-left.svg").size(px(18.))), false, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .tooltip("Show Sidebar") .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), From fd1062f564f4a5d9a793468aa318b7d604a90b07 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:07:07 +0800 Subject: [PATCH 6/8] fix(right-panel,editor): restore the git dependency and address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch had `gpui-component` pointed at a sibling checkout by absolute path, which is why every CI job failed at manifest load. Point it back at the fork's `tty7` branch (now carrying the custom-button label-color fix the chrome tiles depend on) with the `tree-sitter-languages` feature, and re-lock. Review fixes on top: - **Changes tab churned.** `right_panel_invalidate` dropped the cached diff on every `GitStatusCache` notification — including unrelated repos' — so the list blanked to "Loading…" and spawned a fresh `git diff` several times a second while a pane produced output. Replaced by `right_panel_refresh_changes`, which compares branch and totals first and re-probes in place, mirroring the diff overlay. - **Changes tab could wedge on "Loading…".** A probe dropped because the cwd changed mid-flight left `diff_cwd` set and `diff` empty, and the render path only spawns when the cwd *changes* — so nothing re-probed. Spawn when nothing is cached and nothing is in flight. - **Find references blocked the UI thread.** `cx.spawn_in` runs on the main thread; the up-to-200 `read_to_string`s for the row previews now run on the background executor, as the comment already claimed. - **LSP frames could be lost or reordered at startup.** `send` checked `ready` outside the `queued` lock, so a frame could park behind a handshake that had just finished and never go out. `ready` now flips under that lock in `mark_ready_and_flush`. - `MarkScanner`'s ESC-in-payload branch bypassed the payload cap, so a stream of bare ESCs inside an unterminated OSC grew the buffer without bound. - The file tree's search frontier used `Vec::remove(0)`; a wide tree made that quadratic. `VecDeque`. - `procs()` documented a pane check it didn't make; it takes the pane id and makes it. - Four doc comments had been orphaned onto newly inserted functions (`pty`, `smooth_scroll`, `foreground_agent`, `file_expanded`). --- Cargo.lock | 3 ++ Cargo.toml | 16 ++++++---- src/daemon/pane.rs | 6 ++-- src/terminal/marks.rs | 14 +++++++-- src/terminal/remote.rs | 8 ++--- src/terminal/view.rs | 8 ++--- src/ui/app.rs | 2 +- src/ui/code_editor.rs | 59 ++++++++++++++++++++---------------- src/ui/diff_overlay.rs | 4 +-- src/ui/file_tree.rs | 10 ++++--- src/ui/lsp.rs | 27 ++++++++++++----- src/ui/right_panel.rs | 68 +++++++++++++++++++++++++++++++++--------- 12 files changed, 152 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8143651e..026be7fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3180,6 +3180,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9" dependencies = [ "aho-corasick", "anyhow", @@ -3262,6 +3263,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9" dependencies = [ "anyhow", "gpui", @@ -3275,6 +3277,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 65198a07..34cd6409 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -240,11 +240,17 @@ 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 = { path = "../../../../gpui-component/crates/ui", version = "0.5.2", features = ["tree-sitter-languages"] } -gpui-component-assets = { path = "../../../../gpui-component/crates/assets", version = "0.5.1" } +# `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" } # Base features are cross-platform (`font-kit`, `runtime_shaders` only map to the diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 0904d6ee..161aee9f 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -1120,9 +1120,6 @@ impl DaemonPane { } } - /// 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`. /// 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 @@ -1137,6 +1134,9 @@ impl DaemonPane { 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`. fn pty(&self) -> Option<&PtyBackend> { match &self.backend { PaneBackend::Pty(p) => Some(p), diff --git a/src/terminal/marks.rs b/src/terminal/marks.rs index 8fe44e9b..9508a632 100644 --- a/src/terminal/marks.rs +++ b/src/terminal/marks.rs @@ -240,9 +240,17 @@ impl MarkScanner { } self.state = ScanState::Text; } else { - // Not an ST after all — the ESC was payload. - self.payload.push(0x1b); - self.state = ScanState::Osc; + // 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(); + } } } } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 2fd1d2d3..4c83f628 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -982,16 +982,16 @@ impl RemoteTerminal { /// The third-party CLI coding agent (Claude Code, Codex, …) running in the /// pane's foreground, as last reported by the daemon, or `None`. Cheap cache /// read — detection runs daemon-side. See [`crate::core::cli_agent`]. + pub fn foreground_agent(&self) -> Option { + 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() } - pub fn foreground_agent(&self) -> Option { - self.agent.lock().ok().and_then(|g| *g) - } - /// 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. diff --git a/src/terminal/view.rs b/src/terminal/view.rs index c7baa40b..519e5f3d 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -4098,10 +4098,6 @@ impl TerminalView { self.smooth_scroll(delta, cx); } - /// 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 - /// come to rest between line boundaries, like a native scroll view. /// The pane's OSC 133 command marks, newest last — the Outline's rows. pub fn command_marks(&self) -> Vec { self.terminal.marks().list() @@ -4136,6 +4132,10 @@ impl TerminalView { 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 + /// come to rest between line boundaries, like a native scroll view. fn smooth_scroll(&mut self, delta: f32, cx: &mut Context) { let mut term = self.terminal.term.lock(); let offset = term.grid().display_offset(); diff --git a/src/ui/app.rs b/src/ui/app.rs index 58aac0d6..6e7212c6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -622,7 +622,7 @@ impl Tty7App { // 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_invalidate(); + this.right_panel_refresh_changes(cx); cx.notify(); }); // Any real keypress means "chord, not a bare hold": cancel the held-⌘ diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 61bcf895..f7ab91ed 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -781,32 +781,39 @@ impl Tty7App { 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; - // off the UI thread, since this touches the filesystem. - 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(), - }); - } + // 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); diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 28a65f8f..2f086ec2 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -750,8 +750,6 @@ impl Tty7App { } } -/// Whether a file's body shows: small text diffs default open, big ones (and -/// anything the user explicitly flipped) invert via the `toggled` set. /// 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 @@ -771,6 +769,8 @@ fn focused_name(overlay: &DiffOverlayState) -> Option { 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 { let default_open = file.added + file.removed <= AUTO_COLLAPSE_LINES; default_open != toggled.contains(&file.path) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 47f92fa5..605925aa 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -270,9 +270,11 @@ impl FileTreeState { let mut out: Vec = Vec::new(); let mut visited = 0usize; for root in roots { - let mut queue: Vec = vec![root.clone()]; - while let Some(dir) = queue.first().cloned() { - queue.remove(0); + // 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; } @@ -290,7 +292,7 @@ impl FileTreeState { continue; } if e.is_dir { - queue.push(e.path.clone()); + queue.push_back(e.path.clone()); } if e.name.to_lowercase().contains(&needle) { out.push(TreeRow { diff --git a/src/ui/lsp.rs b/src/ui/lsp.rs index 63870456..caabb84b 100644 --- a/src/ui/lsp.rs +++ b/src/ui/lsp.rs @@ -92,20 +92,34 @@ impl Inner { } /// 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 { - self.queued.lock().unwrap().push(body); + queued.push(body); } } - /// Initialize finished: flush everything parked behind the handshake. - fn flush_queued(&self) { - let queued: Vec = std::mem::take(&mut self.queued.lock().unwrap()); + /// 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 queued { + for body in parked { Self::write_frame(&mut stdin, &body); } } @@ -361,8 +375,7 @@ fn reader_loop( let mut stdin = inner.stdin.lock().unwrap(); Inner::write_frame(&mut stdin, &initialized); } - inner.ready.store(true, Ordering::SeqCst); - inner.flush_queued(); + inner.mark_ready_and_flush(); log::info!("lsp: {} initialized", inner.name); continue; } diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index fdb1b63d..aae89639 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -520,8 +520,8 @@ impl Tty7App { .when_some(cwd_for_actions, |this, cwd| { this.child(self.cwd_actions(cwd, cx)) }) - .children(self.procs_section(cx)) - .children(self.ports_section(cx)) + .children(self.procs_section(pane_id, cx)) + .children(self.ports_section(pane_id, cx)) .into_any_element(); self.panel_scroll(inner, title) } @@ -600,8 +600,8 @@ impl Tty7App { /// 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, cx: &mut Context) -> Option { - let procs = &self.procs()?.procs; + fn procs_section(&self, pane_id: Option, cx: &mut Context) -> Option { + let procs = &self.procs(pane_id)?.procs; if procs.len() < 2 { return None; } @@ -647,8 +647,8 @@ impl Tty7App { /// 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, cx: &mut Context) -> Option { - let ports = &self.procs()?.ports; + fn ports_section(&self, pane_id: Option, cx: &mut Context) -> Option { + let ports = &self.procs(pane_id)?.ports; if ports.is_empty() { return None; } @@ -685,9 +685,13 @@ impl Tty7App { ) } - /// The cached query, but only when it describes the pane currently on screen. - fn procs(&self) -> Option<&PaneProcs> { - self.right_panel.procs.as_ref() + /// 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. @@ -891,11 +895,18 @@ impl Tty7App { }; // 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, which clears the cache (see `right_panel_invalidate`). + // 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 @@ -1047,10 +1058,39 @@ impl Tty7App { .detach(); } - /// Drop the cached diff so the next paint re-probes. Called from the same - /// git-status observer that refreshes the sidebar's `+N −M`. - pub(crate) fn right_panel_invalidate(&mut self) { - self.right_panel.diff_cwd = None; + /// 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 ─────────────────────────────────────────────────────────────── From f6ba623af018f3a08e104731535115a729fcf06e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:12:57 +0800 Subject: [PATCH 7/8] fix(procinfo): the Windows process table reads `Proc::parent` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `winproc::Proc` names the parent link `parent`, not `ppid` — the Windows arm of `process_table` had never been compiled, so it took a CI run to find it. --- src/daemon/procinfo.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/daemon/procinfo.rs b/src/daemon/procinfo.rs index fea6a2d0..71635eff 100644 --- a/src/daemon/procinfo.rs +++ b/src/daemon/procinfo.rs @@ -212,7 +212,8 @@ fn process_table() -> HashMap { ( p.pid, Row { - ppid: p.ppid, + // `winproc::Proc` names the parent link `parent`. + ppid: p.parent, pgid: 0, name: p.name, }, From 564f52e76faad1f34b9d8a95c2f36719f3408a6b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:19:21 +0800 Subject: [PATCH 8/8] test(lsp): use a host-shaped absolute path in the URI round-trip `Url::from_file_path` rejects a POSIX literal on Windows, so the test panicked there instead of exercising the percent-encoding it is about. --- src/ui/lsp.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ui/lsp.rs b/src/ui/lsp.rs index caabb84b..439c599a 100644 --- a/src/ui/lsp.rs +++ b/src/ui/lsp.rs @@ -596,7 +596,14 @@ mod tests { #[test] fn uri_round_trips_paths_with_spaces() { - let p = Path::new("/tmp/a dir/file.rs"); + // `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"));