mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
Found by driving a dev instance and measuring what an idle window costs. Two of them were frame loops that never stopped. A `Head` diff overlay calls itself stale when the cached git status disagrees with the snapshot on screen, and every landed probe wakes that check by touching the cache — but the read published its counts and left the branch behind, so a branch switched outside tty7 made the disagreement permanent: two `git` processes a lap, forever, with `refreshing…` pinned to the header and an idle window at 7% of a core. And the home page asked for a frame sixty times a second to change one glyph's opacity twice, which made a window with nothing open in it eight times more expensive than one running a shell. Both now settle: the diff read publishes the branch it found, and the home cursor flips a bool on a timer the way the terminal's own does. The rest: - The tab sidebar and the right panel each capped themselves at half the window and knew nothing about the other, so together they could take all of it — 260 points of terminal on a 720-point window. Both now cap at whichever binds harder: half the window, or what is left after the terminal's floor and the other panel's floor. The same cap bounds the drag, so a panel dragged to its limit stays where it was dropped. - Only a HEAD diff may correct the sidebar's counts. Those numbers mean `git diff --numstat HEAD`; an unstaged or staged patch answers a smaller question, so opening an untracked file from the Source Control panel took the staged lines off the total on the click. - An untracked row in the diff overlay had no click target, and once focused could not be left — the breadcrumb looks the path up in `files`, where an untracked file has no entry. Both ends fixed. - A new pane keeps the name its directory was reached by. `cwd()` alone loses it: the shell falls back to `getcwd()`, so `/tmp/x` became `/private/tmp/x` in every tab opened from the first. `PWD` carries it, and POSIX has the shell discard a `PWD` that names the wrong directory, so this can correct the name and cannot invent one. - The settings search now sees into the Keybindings page, which is generated from the binding table rather than the static index — so searching for a feature finds its shortcut, and the page filters to the matches. Closes #444. - The settings reading column is centred rather than pinned to the nav: on a window as wide as the display it was made for, 640 points of settings sat beside 1600 points of nothing. - `New Workspace…` takes the ellipsis its three sibling actions already carry — it opens a form asking for a name and a host. Every fix has a test. The re-probe loop is pinned end-to-end with `render_probe::draws() == 0` against a real repository, confirmed to fail on the old behaviour before it was kept.
1647 lines
68 KiB
Rust
1647 lines
68 KiB
Rust
use gpui::{AnyElement, Context, Window, div, prelude::*, px, rems};
|
||
use gpui_component::button::Button;
|
||
use gpui_component::input::Input;
|
||
use gpui_component::{
|
||
ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex,
|
||
};
|
||
use std::path::PathBuf;
|
||
|
||
use crate::core::config::{Config, RightPanelTab};
|
||
use crate::daemon::protocol::PaneProcs;
|
||
use crate::ui::app::{
|
||
CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App, tile_trailing_inset,
|
||
tile_trailing_inset_sm,
|
||
};
|
||
use crate::ui::i18n::{L10nKey, t};
|
||
use crate::ui::scrollbar::with_vertical_scrollbar;
|
||
|
||
pub(crate) const MIN_WIDTH: f32 = 216.;
|
||
|
||
/// How wide a panel edge is to grab. Both edges a window can drag — the tab
|
||
/// sidebar's and this panel's — are the same target, so they are one number.
|
||
pub(crate) const RESIZE_HANDLE_WIDTH: f32 = 8.;
|
||
|
||
/// The panel's type scale, in rems, on the same ladder as the rest of the
|
||
/// window.
|
||
///
|
||
/// This panel used to carry its own run of pixel sizes — 12 for body, 11.5/11
|
||
/// under it — which put its *primary* text at the size everything else uses
|
||
/// for *secondary* text, so the panel read a step smaller than the sidebar
|
||
/// beside it, and stayed that size when `ui_font_size` moved. In rems `TEXT`
|
||
/// and `META` are exactly `text_sm()` and `text_xs()`; they are spelled out
|
||
/// only because the mono variants have to be derived from them.
|
||
///
|
||
/// Mono sits a notch under the sans it pairs with: at an equal size its
|
||
/// x-height and stems read a size larger, which turns a label and its value
|
||
/// into two sizes instead of one line. The notch is a rem fraction rather than
|
||
/// a fixed pixel, so the correction scales with the text it is correcting.
|
||
const STEP: f32 = 1. / 16.;
|
||
pub(crate) const TEXT: f32 = 14. * STEP;
|
||
pub(crate) const TEXT_MONO: f32 = TEXT - STEP;
|
||
pub(crate) const META: f32 = 12. * STEP;
|
||
pub(crate) const META_MONO: f32 = META - STEP;
|
||
|
||
/// Uppercase section headings. Deliberately below `META` — it matches the tab
|
||
/// sidebar's group headings, which are the same thing one panel over.
|
||
pub(crate) const HEADING: f32 = 11. * STEP;
|
||
|
||
/// The leading glyph on a panel row — the file tree's folder and file marks.
|
||
///
|
||
/// Pixels, not rems, because glyphs in this window are sized off the tile
|
||
/// ladder in `app.rs` (`TILE_GLYPH` 13, `TILE_GLYPH_XS` 11) rather than off the
|
||
/// text ramp above. A row that reached for gpui-component's rem sizes instead
|
||
/// could never agree with the tab tiles it sits under: at the default
|
||
/// `ui_font_size` of 16 that ladder offers `xsmall` 12 and `small` 14 and
|
||
/// nothing between, so the tree's glyph came out either a step under the
|
||
/// chrome — reading as a speck beside a 14px name — or a step over it, which
|
||
/// puts a row of content above the navigation that owns it. 13 is the tab
|
||
/// tile's own glyph size, so the two agree by construction.
|
||
pub(crate) const ROW_GLYPH: f32 = crate::ui::app::TILE_GLYPH;
|
||
|
||
// The right panel's type ramp: four steps, a point apart, that the Info and
|
||
// Source Control tabs both draw from so switching between them does not change
|
||
// the apparent size of the panel. The Files tab, in `file_tree.rs`, reaches the
|
||
// same 14px through `text_sm()`, which is the same rem under another name. The
|
||
// steps are close together on purpose: the panel is a dense aside next to the
|
||
// terminal, and the differences between them are meant to be felt as hierarchy
|
||
// rather than seen as different type sizes.
|
||
//
|
||
// Every tab of the panel is on this ladder now. The px constants that used to
|
||
// live here — PANEL_TEXT and its steps — went when the interface font scale
|
||
// landed, and `scm/` followed a branch later: it had been cut from main hours
|
||
// before that commit and had copied the ladder as it stood, which left the
|
||
// Source Control tab frozen at the *old* 12/11/10.5 while its neighbours moved
|
||
// to 14/13/12/11 and started tracking `ui_font_size`. Nothing in git conflicted
|
||
// — the two touched different files — so the only thing that would have caught
|
||
// it was a reader noticing that one tab was a step smaller than the rest.
|
||
//
|
||
// Which is the reason to keep reaching for these names rather than spelling a
|
||
// number: a size written as `px(12.)` anywhere in this panel is either a
|
||
// mistake or something that is not type.
|
||
|
||
/// Rows are laid out inside this inset and then pad themselves back out, so a
|
||
/// hovered row's background is wider than its text on both sides.
|
||
///
|
||
/// The text lands on `CONTENT_INSET` whatever this is — a list subtracts it
|
||
/// outside the row and the row adds it back inside — so all this number sets
|
||
/// is how far the hover fill bleeds past the text. It lives here rather than
|
||
/// in one tab because every tab of this panel is the same list of rows seen
|
||
/// from a different angle, and a fill that bleeds 4px under Source Control and
|
||
/// 6px under Info is a panel whose rows visibly do not belong to each other.
|
||
pub(crate) const ROW_INSET: f32 = 4.;
|
||
|
||
/// The strip the row and group action buttons live in, revealed by hovering
|
||
/// `row`.
|
||
///
|
||
/// Absolutely positioned and opaque, so it covers the tail of the row's text
|
||
/// rather than pushing it aside: hovering a row must not move a single pixel
|
||
/// of it, or the list crawls under the pointer.
|
||
///
|
||
/// It stops the mouse-down by hand instead of calling `occlude()`, which is
|
||
/// the obvious way to keep a click off the row underneath and was what made
|
||
/// the buttons vanish the moment the pointer reached them. `occlude()` is a
|
||
/// *hitbox* behaviour, and gpui inserts hitboxes in prepaint, which never
|
||
/// looks at `visibility` — so the strip blocked the mouse even while it was
|
||
/// invisible. Blocking cuts the hit test short at the blocking hitbox, and the
|
||
/// row's hitbox is behind this one because a parent prepaints before its
|
||
/// children; `group_hover` is nothing more than "is the group's hitbox
|
||
/// hovered", so the row stopped counting as hovered and the strip hid itself
|
||
/// — background, buttons and all — with the pointer sitting right on it. The
|
||
/// buttons' own hitboxes came from prepaint and outlived the paint, so they
|
||
/// went on answering tooltips for glyphs that were no longer drawn.
|
||
///
|
||
/// Stopping propagation buys the same "this click is ours, not the row's"
|
||
/// without lying to the hit test: children register their handlers after this
|
||
/// one and gpui bubbles back to front, so a button still gets its click first.
|
||
pub(crate) fn action_strip(row: &gpui::SharedString, backing: u32) -> gpui::Div {
|
||
h_flex()
|
||
.absolute()
|
||
.right(px(ROW_INSET))
|
||
.top_0()
|
||
.bottom_0()
|
||
.items_center()
|
||
.gap(px(1.))
|
||
.bg(gpui::rgb(backing))
|
||
.invisible()
|
||
.group_hover(row.clone(), |s| s.visible())
|
||
.on_any_mouse_down(|_, _, cx| cx.stop_propagation())
|
||
}
|
||
|
||
/// Height of the search strip.
|
||
///
|
||
/// gpui-component sizes an `Input` border-box, and `.xsmall()` is
|
||
/// `input_h(Size::XSmall)` = `h_5()` = 20px: one `LINE_HEIGHT` of `Rems(1.25)`
|
||
/// = 20px with `input_py(Size::XSmall)` = 0 above and below. (`.appearance(false)`
|
||
/// only drops the background, border and radius; the padding and the height
|
||
/// stay.) Thirty leaves that field 5px of slack top and bottom.
|
||
///
|
||
/// Load-bearing beyond this file: `scm/panel.rs` pins its commit box to the
|
||
/// same height with a `const _: () = assert!(…)`, so the two tabs' top strips
|
||
/// line up.
|
||
pub(crate) const SEARCH_H: f32 = 30.;
|
||
|
||
#[derive(Default)]
|
||
pub(crate) struct RightPanelState {
|
||
pub(crate) procs_pane: Option<u64>,
|
||
pub(crate) procs: Option<PaneProcs>,
|
||
pub(crate) procs_loading: bool,
|
||
pub(crate) procs_gen: u64,
|
||
pub(crate) procs_forwards: Option<crate::ui::app::ForwardRoute>,
|
||
pub(crate) scroll: gpui::ScrollHandle,
|
||
pub(crate) tree_scroll: gpui::ScrollHandle,
|
||
/// A path the tree should scroll onto, and how many more renders it may
|
||
/// take to get there. The row is usually not drawn yet when the request is
|
||
/// made — its parents were only just expanded and their listings are still
|
||
/// on their way — so the index has to be recomputed until it appears. The
|
||
/// countdown is what stops a path that never arrives from being compared
|
||
/// against every row forever.
|
||
pub(crate) tree_reveal: Option<(PathBuf, u8)>,
|
||
}
|
||
|
||
/// How many renders a reveal waits for its row to show up. Generous: it costs
|
||
/// one path comparison per row, and a cold directory listing over SSH can take
|
||
/// a moment.
|
||
pub(crate) const TREE_REVEAL_RENDERS: u8 = 60;
|
||
|
||
const PROCS_POLL: std::time::Duration = std::time::Duration::from_millis(2000);
|
||
|
||
/// What a session row draws in its value column.
|
||
///
|
||
/// Every row used to be a `(&str, String)` pair rendered identically, and the
|
||
/// column paid for it twice: `changes` came out as an inert mono `+0 −0` —
|
||
/// the same fact the sidebar draws in green and red and opens the diff overlay
|
||
/// from — and the agent's state came out as a word where the sidebar has a
|
||
/// coloured dot. A row carries its own shape now, so one pane's facts read the
|
||
/// same whichever surface is showing them.
|
||
enum InfoValue {
|
||
/// Mono text, truncated from the tail.
|
||
Text(String),
|
||
/// A filesystem path, shrunk from the head so the leaf survives.
|
||
Path(String),
|
||
/// `+N −M` in the sidebar's two colours, and a click into the diff
|
||
/// overlay when the setting that governs the sidebar's counts allows it.
|
||
Diff {
|
||
added: u32,
|
||
removed: u32,
|
||
open: Option<(crate::ui::host_ops::HostId, PathBuf)>,
|
||
},
|
||
/// An agent and what it is doing, behind the status dot the sidebar draws
|
||
/// on the tab — `hollow` for Waiting, which is a different *shape* rather
|
||
/// than one more hue, for the same reason the tab's dot is.
|
||
Agent {
|
||
text: String,
|
||
dot: Option<u32>,
|
||
hollow: bool,
|
||
},
|
||
}
|
||
|
||
/// One label/value line of the Session section.
|
||
struct InfoRow {
|
||
label: &'static str,
|
||
value: InfoValue,
|
||
/// What this row's copy tile puts on the clipboard, where copying it is
|
||
/// plausibly what someone wants — a path, a host, a branch. `None` on the
|
||
/// rows where it is not ("zsh"), because a hover affordance that appears
|
||
/// on every row teaches nothing about which rows can do something.
|
||
copy: Option<String>,
|
||
/// Set on the working-directory row when the path is on the machine the
|
||
/// file manager can see, which is the only case Reveal means anything in.
|
||
reveal: Option<PathBuf>,
|
||
}
|
||
|
||
impl InfoRow {
|
||
fn text(label: &'static str, value: String) -> Self {
|
||
Self {
|
||
label,
|
||
value: InfoValue::Text(value),
|
||
copy: None,
|
||
reveal: None,
|
||
}
|
||
}
|
||
|
||
fn copyable(mut self) -> Self {
|
||
self.copy = match &self.value {
|
||
InfoValue::Text(v) | InfoValue::Path(v) => Some(v.clone()),
|
||
_ => None,
|
||
};
|
||
self
|
||
}
|
||
|
||
/// Whether the row does anything if you click or hover it. It is what
|
||
/// decides the hover fill, so the fill never promises an action the row
|
||
/// does not have.
|
||
fn interactive(&self) -> bool {
|
||
self.copy.is_some()
|
||
|| self.reveal.is_some()
|
||
|| matches!(self.value, InfoValue::Diff { open: Some(_), .. })
|
||
}
|
||
}
|
||
|
||
/// Widest of the labels actually on screen, so the values line up without a
|
||
/// fixed width guessing at them.
|
||
///
|
||
/// A hardcoded 46px fitted "cwd" and "shell" and nothing else: English
|
||
/// "changes" wrapped mid-word to "change / s", and in Chinese and Japanese
|
||
/// almost every label wrapped — ja "作業ディレクトリ" is eight glyphs. The
|
||
/// clamp keeps the longest of those from eating the panel; anything past it
|
||
/// runs into the gap rather than folding, which `whitespace_nowrap` on the
|
||
/// label guarantees.
|
||
fn info_label_column(rows: &[InfoRow], window: &mut Window, cx: &gpui::App) -> gpui::Pixels {
|
||
// Shaping needs real pixels, so this is the one place the rem has to be
|
||
// resolved by hand. Both bounds were measured against a 12px label, so
|
||
// they are carried as multiples of it rather than as pixels — otherwise
|
||
// raising `ui_font_size` grows the labels into a clamp fitted to a
|
||
// smaller face, and every one of them wraps.
|
||
let label_px = TEXT * window.rem_size().as_f32();
|
||
let min = 46. / 12. * label_px;
|
||
let max = 108. / 12. * label_px;
|
||
let font = gpui::Font {
|
||
family: cx.theme().font_family.clone(),
|
||
features: Default::default(),
|
||
fallbacks: None,
|
||
weight: Default::default(),
|
||
style: Default::default(),
|
||
};
|
||
let widest = rows
|
||
.iter()
|
||
.map(|row| {
|
||
let k = row.label;
|
||
window
|
||
.text_system()
|
||
.shape_line(
|
||
gpui::SharedString::from(k),
|
||
px(label_px),
|
||
&[gpui::TextRun {
|
||
len: k.len(),
|
||
font: font.clone(),
|
||
color: gpui::Hsla::default(),
|
||
background_color: None,
|
||
underline: None,
|
||
strikethrough: None,
|
||
}],
|
||
None,
|
||
)
|
||
.width
|
||
.as_f32()
|
||
})
|
||
.fold(min, f32::max);
|
||
px(widest.clamp(min, max).ceil())
|
||
}
|
||
|
||
impl Tty7App {
|
||
pub(crate) fn right_panel_open(&self, _cx: &gpui::App) -> bool {
|
||
self.right_panel_visible && !self.tabs.is_empty()
|
||
}
|
||
|
||
/// What the sidebar has reserved, from this panel's point of view.
|
||
pub(crate) fn sidebar_floor(&self, cx: &gpui::App) -> f32 {
|
||
if self.sidebar_open(cx) {
|
||
crate::ui::tab_sidebar::MIN_SIDEBAR_WIDTH
|
||
} else {
|
||
0.
|
||
}
|
||
}
|
||
|
||
pub(crate) fn right_panel_max_px(&self, window: &Window, cx: &gpui::App) -> f32 {
|
||
crate::ui::app::side_panel_max(
|
||
window.viewport_size().width.as_f32(),
|
||
MIN_WIDTH,
|
||
self.sidebar_floor(cx),
|
||
)
|
||
}
|
||
|
||
pub(crate) fn right_panel_px(&self, window: &Window, cx: &gpui::App) -> f32 {
|
||
self.right_panel_width
|
||
.get()
|
||
.clamp(MIN_WIDTH, self.right_panel_max_px(window, cx))
|
||
}
|
||
|
||
pub(crate) fn toggle_right_panel(&mut self, cx: &mut Context<Self>) {
|
||
let next = !self.right_panel_visible;
|
||
self.right_panel_visible = next;
|
||
self.update_config(cx, |cfg| cfg.right_panel_visible = next);
|
||
cx.notify();
|
||
}
|
||
|
||
pub(crate) fn set_right_panel_tab(&mut self, tab: RightPanelTab, cx: &mut Context<Self>) {
|
||
self.right_panel_tab = tab;
|
||
self.right_panel_visible = true;
|
||
self.update_config(cx, |cfg| {
|
||
cfg.right_panel_tab = tab;
|
||
cfg.right_panel_visible = true;
|
||
});
|
||
cx.notify();
|
||
}
|
||
|
||
pub(crate) fn render_right_panel(
|
||
&mut self,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> Option<AnyElement> {
|
||
let panel_open = self.right_panel_open(cx);
|
||
if let Some(open) = self.sftp_panel.open_pane_id
|
||
&& (!panel_open || self.remote_files_pane(window, cx).map(|(id, _)| id) != Some(open))
|
||
{
|
||
self.sftp_close_browser(cx);
|
||
}
|
||
if !panel_open {
|
||
return None;
|
||
}
|
||
let width = self.right_panel_px(window, cx);
|
||
let tab = self.right_panel_tab;
|
||
|
||
let body = match tab {
|
||
RightPanelTab::Info => self.render_panel_info(window, cx),
|
||
RightPanelTab::Scm => self.render_panel_scm(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)
|
||
.bg(crate::ui::theme::workspace_surface_color(cx))
|
||
.border_l_1()
|
||
.border_color(cx.theme().sidebar_border)
|
||
.children(cfg!(target_os = "macos").then(|| {
|
||
let row = h_flex()
|
||
.id("right-panel-titlebar-drag")
|
||
.flex_none()
|
||
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
|
||
.border_b_1()
|
||
.border_color(cx.theme().transparent);
|
||
crate::ui::app::window_move_gesture(
|
||
row,
|
||
"right-panel-titlebar-drag",
|
||
window,
|
||
cx,
|
||
)
|
||
.on_double_click(|_, window, _| window.titlebar_double_click())
|
||
.items_center()
|
||
.gap(px(2.))
|
||
.pl(px(tile_trailing_inset()))
|
||
.children(self.right_panel_tabs(cx))
|
||
.child(div().flex_1())
|
||
.child(self.window_chrome(window, cx))
|
||
}))
|
||
.child(body)
|
||
.children(self.sftp_transfers_footer(cx))
|
||
.child(handle)
|
||
.into_any_element(),
|
||
)
|
||
}
|
||
|
||
fn right_panel_resize(&self, cx: &mut Context<Self>) -> (AnyElement, AnyElement) {
|
||
use gpui::{Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, canvas};
|
||
use std::cell::Cell as StdCell;
|
||
use std::rc::Rc;
|
||
|
||
let container: Rc<StdCell<Option<Bounds<Pixels>>>> = Rc::new(StdCell::new(None));
|
||
// Read while there is still a `cx` to read it from: the drag handler
|
||
// below only ever sees a `Window`, and the cap it clamps against has to
|
||
// be the same one the layout applies or the panel springs back from
|
||
// wherever it was dropped.
|
||
let sidebar_floor = self.sidebar_floor(cx);
|
||
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 = crate::ui::app::side_panel_max(
|
||
window.viewport_size().width.as_f32(),
|
||
MIN_WIDTH,
|
||
sidebar_floor,
|
||
);
|
||
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::<Config>();
|
||
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")
|
||
.occlude()
|
||
.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)
|
||
}
|
||
|
||
pub(crate) fn panel_title(
|
||
&self,
|
||
text: &str,
|
||
count: Option<String>,
|
||
trailing: Option<AnyElement>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let tabs = (!cfg!(target_os = "macos")).then(|| self.right_panel_tabs(cx));
|
||
let has_trailing = trailing.is_some();
|
||
if tabs.is_none() && !has_trailing {
|
||
return div().flex_none().into_any_element();
|
||
}
|
||
let row = crate::ui::app::window_move_gesture(
|
||
h_flex().id("panel-title"),
|
||
"panel-title-drag",
|
||
window,
|
||
cx,
|
||
);
|
||
row.flex_none()
|
||
.h(px(if tabs.is_some() {
|
||
crate::ui::app::TITLE_BAR_HEIGHT
|
||
} else {
|
||
32.
|
||
}))
|
||
.items_center()
|
||
.pl(px(CONTENT_INSET))
|
||
.pr(px(match (&tabs, has_trailing) {
|
||
(Some(_), _) => tile_trailing_inset(),
|
||
(None, true) => tile_trailing_inset_sm(),
|
||
(None, false) => CONTENT_INSET,
|
||
}))
|
||
.when(tabs.is_some(), |this| {
|
||
this.border_b_1().border_color(cx.theme().sidebar_border)
|
||
})
|
||
.child(
|
||
h_flex()
|
||
.flex_shrink_0()
|
||
.items_baseline()
|
||
.gap(px(7.))
|
||
.child(
|
||
// The title step of the panel ramp, SEMIBOLD and
|
||
// uppercased. It reads as a label rather than as
|
||
// content because of the weight and the caps.
|
||
div()
|
||
.text_size(rems(META))
|
||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||
.text_color(cx.theme().secondary_foreground)
|
||
.child(text.to_uppercase()),
|
||
)
|
||
.when_some(count, |this, c| {
|
||
this.child(
|
||
// A count is a token hanging off the heading, not
|
||
// part of it: one step down, mono, regular weight.
|
||
div()
|
||
.text_size(rems(META_MONO))
|
||
.font_family(cx.theme().mono_font_family.clone())
|
||
.text_color(cx.theme().muted_foreground.opacity(0.75))
|
||
.child(c),
|
||
)
|
||
}),
|
||
)
|
||
.child(div().flex_1().min_w_0())
|
||
.when_some(trailing, |this, t| this.child(t))
|
||
.when_some(tabs, |this, tiles| {
|
||
this.child(
|
||
h_flex()
|
||
.flex_shrink_0()
|
||
.items_center()
|
||
.gap(px(2.))
|
||
.when(has_trailing, |this| this.ml(px(6.)))
|
||
.children(tiles),
|
||
)
|
||
})
|
||
.into_any_element()
|
||
}
|
||
|
||
pub(crate) fn panel_search(
|
||
&self,
|
||
input: &gpui::Entity<gpui_component::input::InputState>,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
h_flex()
|
||
.flex_none()
|
||
.items_center()
|
||
// 8 here plus the `.xsmall()` field's own 4px of leading padding
|
||
// is 12px of daylight between the glyph and the first character.
|
||
.gap(px(8.))
|
||
.h(px(SEARCH_H))
|
||
.px(px(CONTENT_INSET))
|
||
.child(
|
||
Icon::new(IconName::Search)
|
||
.small()
|
||
.text_color(cx.theme().muted_foreground),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
// A filter with no way out of it but selecting the text
|
||
// and deleting it is a filter people leave on and then
|
||
// wonder where their files went. The button only exists
|
||
// while there is something to clear, so an empty field
|
||
// still reads as one line of chrome.
|
||
.child(Input::new(input).appearance(false).xsmall().cleanable(true)),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
pub(crate) fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement {
|
||
let body = div()
|
||
.id("right-panel-body")
|
||
.flex_1()
|
||
.min_h_0()
|
||
.overflow_y_scroll()
|
||
.track_scroll(&self.right_panel.scroll)
|
||
.child(inner);
|
||
v_flex()
|
||
.flex_1()
|
||
.min_h_0()
|
||
.child(title)
|
||
.child(with_vertical_scrollbar(
|
||
"right-panel-body-scrollbar",
|
||
body,
|
||
&self.right_panel.scroll,
|
||
))
|
||
.into_any_element()
|
||
}
|
||
|
||
pub(crate) fn panel_empty(
|
||
&self,
|
||
text: &str,
|
||
hint: Option<&str>,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let muted = cx.theme().muted_foreground;
|
||
v_flex()
|
||
.px(px(CONTENT_INSET))
|
||
.py(px(4.))
|
||
.gap(px(3.))
|
||
.text_size(rems(TEXT))
|
||
.text_color(muted)
|
||
.child(text.to_string())
|
||
.children(hint.map(|h| {
|
||
div()
|
||
.text_size(rems(META))
|
||
.text_color(muted.opacity(0.75))
|
||
.child(h.to_string())
|
||
}))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_panel_info(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||
let title = self.panel_title(t(L10nKey::PanelInfoTitle), None, None, window, cx);
|
||
let mut rows: Vec<InfoRow> = Vec::new();
|
||
let mut pane_id: Option<u64> = None;
|
||
let mut forwards_pane: Option<u64> = None;
|
||
// Whether the ports below are this machine's. They are listed by the
|
||
// daemon that owns the pane, so what decides it is which machine that
|
||
// daemon runs on — not whether the shell has since ssh'd somewhere,
|
||
// which would hide the browser tile on a `ssh -L` pane whose forwarded
|
||
// listener is on this machine and reachable.
|
||
let mut local_pane = false;
|
||
// Where the `changes` row's counts lead. Same source as the sidebar's,
|
||
// and gated on the same setting, so turning the preview off turns it
|
||
// off in both places rather than in one of them.
|
||
let mut diff_target: Option<(crate::ui::host_ops::HostId, PathBuf)> = None;
|
||
let mut git: Option<crate::terminal::git_status::GitStatus> = None;
|
||
// The agent row's name and status, read off one leaf (see below).
|
||
let mut agent_row: Option<(
|
||
crate::core::cli_agent::CLIAgent,
|
||
crate::core::cli_agent::AgentStatus,
|
||
)> = 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);
|
||
local_pane = view.host_id().is_local();
|
||
diff_target = crate::ui::tab_sidebar::diff_click_cwd(
|
||
cx.global::<Config>(),
|
||
view.git_status_cwd()
|
||
.map(|cwd| (view.host_id(), cwd.to_path_buf())),
|
||
);
|
||
if let Some(cwd) = view.effective_cwd() {
|
||
let home = view.display_home(cx);
|
||
rows.push(InfoRow {
|
||
label: t(L10nKey::PanelCwd),
|
||
value: InfoValue::Path(compact_path(&cwd, home.as_deref())),
|
||
// The compacted `~/…` spelling is for reading; what
|
||
// goes on the clipboard is the path a shell can use.
|
||
copy: Some(cwd.display().to_string()),
|
||
// Reveal only means anything when the path is on the
|
||
// machine the file manager can see.
|
||
reveal: view.local_cwd().is_some().then(|| cwd.clone()),
|
||
});
|
||
}
|
||
let shell = match view.shell_spec().map(|s| s.program.clone()) {
|
||
Some(program) => crate::core::shells::default_shell_name(Some(&program)),
|
||
None => self.default_shell_label(cx),
|
||
};
|
||
rows.push(InfoRow::text(t(L10nKey::PanelShell), shell));
|
||
if let Some(ssh) = view.ssh_spec() {
|
||
rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable());
|
||
}
|
||
let connected_ssh = view
|
||
.remote_context()
|
||
.is_some_and(|c| c.kind == crate::daemon::protocol::RemoteKind::NativeSsh)
|
||
&& matches!(
|
||
view.ssh_phase(),
|
||
Some(crate::daemon::protocol::SshPhase::Connected)
|
||
);
|
||
if connected_ssh || view.workspace().is_some() {
|
||
forwards_pane = Some(view.pane_id);
|
||
}
|
||
git = view.git_status(cx);
|
||
// Name and status come from the *same* leaf: the detail pane's
|
||
// own agent when it has one, and otherwise the tab's most
|
||
// urgent agent leaf — which still holds the row while focus
|
||
// sits on a plain shell, and still colours its dot the way the
|
||
// tab strip's badge does, but names the pane it took the
|
||
// status from. Pairing `tab.agent` with `tab.agent_status`
|
||
// would splice one pane's name onto another pane's status — a
|
||
// row no leaf ever had — because the two resolve
|
||
// independently (#543). Read here, where `view` is in scope;
|
||
// pushed beside the other rows below.
|
||
agent_row = match view.agent() {
|
||
Some(agent) => {
|
||
let status = view
|
||
.agent_session()
|
||
.map(|s| s.status)
|
||
.unwrap_or(crate::core::cli_agent::AgentStatus::Idle);
|
||
Some((agent, status))
|
||
}
|
||
None => tab.agent_row(cx),
|
||
};
|
||
}
|
||
// Read off the same pane the rows above describe, rather than off
|
||
// `Tab::git_status`, which resolves a split tab to its *first* leaf
|
||
// while `detail_pane` resolves it to the *last focused* one. The
|
||
// two agreed while the row was inert text; now that the counts open
|
||
// a diff, disagreeing means a click that opens a repository other
|
||
// than the one whose numbers were clicked.
|
||
if let Some(git) = git {
|
||
rows.push(InfoRow::text(t(L10nKey::PanelBranch), git.branch.clone()).copyable());
|
||
rows.push(InfoRow {
|
||
label: t(L10nKey::PanelChangesRow),
|
||
value: InfoValue::Diff {
|
||
added: git.added,
|
||
removed: git.removed,
|
||
// A clean tree has no diff to open, so the row keeps
|
||
// its place in the table but stops being a button.
|
||
open: (git.added > 0 || git.removed > 0)
|
||
.then_some(diff_target.clone())
|
||
.flatten(),
|
||
},
|
||
copy: None,
|
||
reveal: None,
|
||
});
|
||
}
|
||
// Name and status were read off one leaf above; push the row.
|
||
if let Some((agent, status)) = agent_row {
|
||
let name = agent.display_name();
|
||
rows.push(InfoRow {
|
||
label: t(L10nKey::PanelAgent),
|
||
value: InfoValue::Agent {
|
||
text: format!("{name} · {}", agent_status_label(status)),
|
||
dot: status.dot_rgb(),
|
||
hollow: status == crate::core::cli_agent::AgentStatus::Waiting,
|
||
},
|
||
copy: None,
|
||
reveal: None,
|
||
});
|
||
}
|
||
}
|
||
|
||
if rows.is_empty() {
|
||
return self.panel_scroll(
|
||
self.panel_empty(
|
||
t(L10nKey::PanelNoSession),
|
||
Some(t(L10nKey::PanelNoSessionHint)),
|
||
cx,
|
||
),
|
||
title,
|
||
);
|
||
}
|
||
|
||
let route = forwards_pane.map(|id| self.forward_route(id, cx));
|
||
self.sync_procs(pane_id, route, cx);
|
||
|
||
let label_w = info_label_column(&rows, window, cx);
|
||
// Rows pad themselves back out to `CONTENT_INSET`, so their hover fill
|
||
// bleeds past the text on both sides — the geometry the Source Control
|
||
// tab's rows are on, one tab over.
|
||
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.));
|
||
for (i, row) in rows.into_iter().enumerate() {
|
||
list = list.child(self.info_row(i, row, label_w, cx));
|
||
}
|
||
|
||
let inner = v_flex()
|
||
.child(self.panel_subtitle(t(L10nKey::PanelSessionSubtitle), false, None, cx))
|
||
.child(list)
|
||
.children(self.procs_section(pane_id, cx))
|
||
.children(self.ports_section(pane_id, local_pane, cx))
|
||
.children(self.forwards_section(forwards_pane, cx))
|
||
.into_any_element();
|
||
self.panel_scroll(inner, title)
|
||
}
|
||
|
||
/// One label/value line, with whatever it can do revealed on hover.
|
||
///
|
||
/// The two cwd buttons used to sit in a strip of their own under the whole
|
||
/// table, unlabelled, four rows below the path they acted on and closer to
|
||
/// the Processes heading than to it — "copy" and "open" with no stated
|
||
/// object. Hanging them off the row they belong to is what makes them
|
||
/// answerable, and it buys the panel the hover feedback it had none of.
|
||
fn info_row(
|
||
&self,
|
||
i: usize,
|
||
row: InfoRow,
|
||
label_w: gpui::Pixels,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||
let mono = cx.theme().mono_font_family.clone();
|
||
let id = gpui::SharedString::from(format!("panel-info-row-{i}"));
|
||
let interactive = row.interactive();
|
||
let tiles_wide = usize::from(row.reveal.is_some()) + usize::from(row.copy.is_some());
|
||
// "Copy" is honest on a branch or a host, but on the working directory
|
||
// it is the file tree's *Copy Path*, and the two live a right-click
|
||
// apart from each other. Say the same words for the same act.
|
||
let copy_label = match row.value {
|
||
InfoValue::Path(_) => t(L10nKey::FileTreeContextCopyPath),
|
||
_ => t(L10nKey::CmdCopy),
|
||
};
|
||
|
||
let value = match row.value {
|
||
// A path identifies a pane by its last segment, and plain
|
||
// truncation eats exactly that: a deep checkout read
|
||
// "/private/tmp/claude-501…" and told you nothing. Let the head
|
||
// absorb the shrinking so the leaf survives, the way a file
|
||
// manager shows a path.
|
||
InfoValue::Path(v) => {
|
||
let (head, leaf) = split_path_leaf(&v);
|
||
h_flex()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.text_size(rems(TEXT_MONO))
|
||
.font_family(mono.clone())
|
||
.text_color(cx.theme().foreground)
|
||
.child(div().min_w_0().flex_shrink(999.).truncate().child(head))
|
||
.child(div().min_w_0().flex_shrink(1.).truncate().child(leaf))
|
||
.into_any_element()
|
||
}
|
||
InfoValue::Text(v) => div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_size(rems(TEXT_MONO))
|
||
.font_family(mono.clone())
|
||
.text_color(cx.theme().foreground)
|
||
.child(v)
|
||
.into_any_element(),
|
||
InfoValue::Diff {
|
||
added,
|
||
removed,
|
||
open,
|
||
} => {
|
||
let clean = added == 0 && removed == 0;
|
||
// Sized to the two numbers, not to the row: `flex_1` here made
|
||
// the whole rest of the line a button, so a click on the empty
|
||
// half of the row opened the overlay and a pointer crossing it
|
||
// underlined counts it was nowhere near. The slack belongs to
|
||
// the value slot around this, which is what holds it.
|
||
let counts = h_flex()
|
||
.flex_none()
|
||
.items_baseline()
|
||
.gap(px(6.))
|
||
.text_size(rems(TEXT_MONO))
|
||
.font_family(mono.clone())
|
||
// A clean tree said "+0 −0", which is two numbers to read
|
||
// before learning there was nothing to read. The dash is
|
||
// the table convention for an empty cell, and it needs no
|
||
// translating.
|
||
.when(clean, |this| {
|
||
this.child(
|
||
div()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child("—".to_string()),
|
||
)
|
||
})
|
||
.when(added > 0, |this| {
|
||
this.child(
|
||
div()
|
||
.text_color(cx.theme().success)
|
||
.child(format!("+{added}")),
|
||
)
|
||
})
|
||
.when(removed > 0, |this| {
|
||
this.child(
|
||
div()
|
||
.text_color(cx.theme().danger)
|
||
.child(format!("−{removed}")),
|
||
)
|
||
});
|
||
match open {
|
||
// The row's hover fill says the line reacts; the underline
|
||
// says where the button inside it starts — the same pair
|
||
// the sidebar's counts wear.
|
||
Some((host, cwd)) => counts
|
||
.id(("panel-info-diff", i))
|
||
.cursor_pointer()
|
||
.hover(|s| s.underline())
|
||
.on_click(cx.listener(move |this, _, window, cx| {
|
||
cx.stop_propagation();
|
||
this.toggle_diff_overlay(host, cwd.clone(), window, cx);
|
||
}))
|
||
.into_any_element(),
|
||
None => counts.into_any_element(),
|
||
}
|
||
}
|
||
// The dot hangs out of the flow rather than sitting in it. A
|
||
// childless box has no baseline of its own, so as a flex item it
|
||
// offers up its bottom edge instead — and the row, which aligns
|
||
// its label and its value on their shared baseline, then hoisted
|
||
// the whole value six pixels and left "agent" sitting under its
|
||
// own value. Out of flow it cannot be mistaken for the thing that
|
||
// sets the line.
|
||
InfoValue::Agent { text, dot, hollow } => div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.relative()
|
||
.child(
|
||
div()
|
||
.min_w_0()
|
||
.truncate()
|
||
.when(dot.is_some(), |d| d.pl(rems(PIP_SIZE + PIP_GAP)))
|
||
.text_size(rems(TEXT_MONO))
|
||
.font_family(mono.clone())
|
||
.text_color(cx.theme().foreground)
|
||
.child(text),
|
||
)
|
||
.children(dot.map(|rgb| {
|
||
status_pip(rgb, hollow, crate::ui::theme::workspace_surface_color(cx))
|
||
}))
|
||
.into_any_element(),
|
||
};
|
||
|
||
// The strip is opaque and pinned to the row's right edge, so whatever
|
||
// sits under it is unreadable for as long as the pointer is on the row
|
||
// — and on the working-directory row what sits there is the leaf, the
|
||
// one segment the head-first elision exists to keep. Hold that much
|
||
// width back from the value for good rather than only while hovered:
|
||
// taking it on hover would re-elide the path under the pointer, which
|
||
// is the pixel-shifting the strip is absolutely positioned to avoid.
|
||
let value = h_flex()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.items_baseline()
|
||
.when(tiles_wide > 0, |this| {
|
||
this.pr(px(tiles_wide as f32 * (TILE_SIZE_XS + 1.) + 4.))
|
||
})
|
||
.child(value);
|
||
|
||
let mut tiles = action_strip(&id, sf.hover);
|
||
let mut has_tiles = false;
|
||
if let Some(cwd) = row.reveal {
|
||
has_tiles = true;
|
||
tiles = tiles.child(
|
||
self.info_tile(
|
||
"panel-info-reveal",
|
||
IconName::FolderOpen,
|
||
reveal_label(),
|
||
cx,
|
||
)
|
||
.on_click(move |_, _window, cx| cx.reveal_path(&cwd)),
|
||
);
|
||
}
|
||
if let Some(text) = row.copy {
|
||
has_tiles = true;
|
||
tiles = tiles.child(
|
||
self.info_tile(("panel-info-copy", i), IconName::Copy, copy_label, cx)
|
||
.on_click(move |_, _window, cx| {
|
||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
|
||
}),
|
||
);
|
||
}
|
||
// A row with nothing to reveal gets no strip at all, rather than an
|
||
// empty one carrying a hover subscription for a set of buttons that
|
||
// does not exist.
|
||
let actions = has_tiles.then_some(tiles);
|
||
|
||
h_flex()
|
||
.id(id.clone())
|
||
.group(id)
|
||
.relative()
|
||
.items_baseline()
|
||
.gap(px(9.))
|
||
.px(px(ROW_INSET))
|
||
.py(px(2.))
|
||
.rounded(px(5.))
|
||
.text_size(rems(TEXT))
|
||
// Only rows that can do something light up, so the fill is never a
|
||
// promise the row cannot keep.
|
||
.when(interactive, |this| {
|
||
this.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||
})
|
||
.child(
|
||
div()
|
||
.flex_none()
|
||
.w(label_w)
|
||
.whitespace_nowrap()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(row.label),
|
||
)
|
||
.child(value)
|
||
.children(actions)
|
||
.into_any_element()
|
||
}
|
||
|
||
/// The tile an Info row's hover strip is made of — the [`TILE_SIZE_XS`]
|
||
/// box the Source Control rows use, because three `TILE_SIZE_SM` squares
|
||
/// would eat a quarter of the width a path has to live in.
|
||
fn info_tile(
|
||
&self,
|
||
id: impl Into<gpui::ElementId>,
|
||
icon: IconName,
|
||
tooltip: &'static str,
|
||
cx: &mut Context<Self>,
|
||
) -> Button {
|
||
crate::ui::tab_strip::chrome_tile_sized(
|
||
Button::new(id).icon(Icon::new(icon)),
|
||
TILE_SIZE_XS,
|
||
TILE_GLYPH_XS,
|
||
false,
|
||
cx,
|
||
)
|
||
.rounded(px(4.))
|
||
.tooltip(tooltip)
|
||
}
|
||
|
||
pub(crate) fn panel_subtitle(
|
||
&self,
|
||
text: &str,
|
||
divider: bool,
|
||
trailing: Option<AnyElement>,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
h_flex()
|
||
.when(divider, |d| {
|
||
d.mt(px(6.)).border_t_1().border_color(cx.theme().border)
|
||
})
|
||
.items_center()
|
||
.justify_between()
|
||
.pl(px(CONTENT_INSET))
|
||
.pr(px(if trailing.is_some() {
|
||
CONTENT_INSET - crate::ui::app::TILE_PAD
|
||
} else {
|
||
CONTENT_INSET
|
||
}))
|
||
.pt(px(match (divider, trailing.is_some()) {
|
||
(true, false) => 12.,
|
||
(true, true) => 8.,
|
||
(false, false) => 10.,
|
||
(false, true) => 6.,
|
||
}))
|
||
.pb(px(if trailing.is_some() { 0. } else { 4. }))
|
||
.child(
|
||
// A group header sits below the panel's own title in the
|
||
// hierarchy, so it sits below it in the ramp too: the smallest
|
||
// step, carried by weight and caps rather than by size.
|
||
div()
|
||
.text_size(rems(HEADING))
|
||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(text.to_uppercase()),
|
||
)
|
||
.when_some(trailing, |this, t| this.child(t))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn procs_section(&self, pane_id: Option<u64>, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||
let procs = &self.procs(pane_id)?.procs;
|
||
if procs.len() < 2 {
|
||
return None;
|
||
}
|
||
let mono = cx.theme().mono_font_family.clone();
|
||
let mut list = v_flex().px(px(CONTENT_INSET)).py(px(1.)).gap(px(2.));
|
||
for p in procs {
|
||
list = list.child(
|
||
h_flex()
|
||
.items_center()
|
||
.gap(px(8.))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.pl(px(f32::from(p.depth) * 10.))
|
||
.text_size(rems(TEXT_MONO))
|
||
.font_family(mono.clone())
|
||
// Which of these has the terminal is the one thing
|
||
// the list is read for, and a hue apart from its
|
||
// neighbours was carrying it alone — a difference
|
||
// a light theme flattens and colour vision can
|
||
// miss. Weight says it a second way.
|
||
.when(p.foreground, |d| {
|
||
d.font_weight(gpui::FontWeight::MEDIUM)
|
||
.text_color(cx.theme().foreground)
|
||
})
|
||
.when(!p.foreground, |d| d.text_color(cx.theme().muted_foreground))
|
||
.child(p.name.clone()),
|
||
)
|
||
.child(info_chip(
|
||
&p.pid.to_string(),
|
||
cx.theme().accent,
|
||
cx.theme().muted_foreground,
|
||
&mono,
|
||
)),
|
||
);
|
||
}
|
||
Some(
|
||
v_flex()
|
||
.child(self.panel_subtitle(t(L10nKey::PanelProcessesSubtitle), true, None, cx))
|
||
.child(list)
|
||
.into_any_element(),
|
||
)
|
||
}
|
||
|
||
/// The listening ports of the pane's processes.
|
||
///
|
||
/// `local` is whether the daemon that listed these ports is this machine's,
|
||
/// and it is what decides whether the browser tile appears: a port on a
|
||
/// remote host is not this machine's port, and opening it here is not a
|
||
/// near miss, it is a different service. It is deliberately about the
|
||
/// *host* and not about whether the shell has ssh'd somewhere — the ports
|
||
/// come from the pane's own process tree either way, so a `ssh -L` pane's
|
||
/// forwarded listener really is on this machine and really does open.
|
||
fn ports_section(
|
||
&self,
|
||
pane_id: Option<u64>,
|
||
local: bool,
|
||
cx: &mut Context<Self>,
|
||
) -> Option<AnyElement> {
|
||
let ports = &self.procs(pane_id)?.ports;
|
||
if ports.is_empty() {
|
||
return None;
|
||
}
|
||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||
let mono = cx.theme().mono_font_family.clone();
|
||
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(1.));
|
||
for (i, p) in ports.iter().enumerate() {
|
||
// "What is this pane serving, and where" is the question the
|
||
// section answers, and the next thing anyone does with the answer
|
||
// is go there — so the row hands over an address instead of making
|
||
// it something to read off the screen and retype.
|
||
let authority = p.authority();
|
||
// Keyed by the row, not by the port: `listening_ports` drops a
|
||
// duplicate only when the port *and* the pid match, so a
|
||
// pre-forking server — nginx, gunicorn, a node cluster — puts one
|
||
// row per worker on screen, all on port 8000. Sharing an id makes
|
||
// gpui hand them one interactive state between them, and a click on
|
||
// the last row lights up the tooltip and the pressed fill on all
|
||
// the others.
|
||
let id = gpui::SharedString::from(format!("panel-port-{}-{}", p.port, p.pid));
|
||
let mut tiles_wide = 1;
|
||
let mut actions = action_strip(&id, sf.hover);
|
||
if local {
|
||
tiles_wide += 1;
|
||
let url = format!("http://{authority}");
|
||
actions = actions.child(
|
||
self.info_tile(
|
||
("panel-port-open", i),
|
||
IconName::Globe,
|
||
t(L10nKey::PanelOpenInBrowser),
|
||
cx,
|
||
)
|
||
.on_click(move |_, _window, cx| cx.open_url(&url)),
|
||
);
|
||
}
|
||
actions = actions.child(
|
||
self.info_tile(
|
||
("panel-port-copy", i),
|
||
IconName::Copy,
|
||
t(L10nKey::CmdCopy),
|
||
cx,
|
||
)
|
||
.on_click({
|
||
let authority = authority.clone();
|
||
move |_, _window, cx| {
|
||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(authority.clone()));
|
||
}
|
||
}),
|
||
);
|
||
list = list.child(
|
||
h_flex()
|
||
.id(id.clone())
|
||
.group(id)
|
||
.relative()
|
||
.items_center()
|
||
.gap(px(8.))
|
||
.px(px(ROW_INSET))
|
||
.py(px(1.))
|
||
.rounded(px(5.))
|
||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||
.child(info_chip(
|
||
&p.port.to_string(),
|
||
cx.theme().accent,
|
||
cx.theme().foreground,
|
||
&mono,
|
||
))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
// Room held back for the strip, so the process name
|
||
// ends where the buttons begin instead of under
|
||
// them. Same reservation the Info rows make.
|
||
.pr(px(tiles_wide as f32 * (TILE_SIZE_XS + 1.) + 4.))
|
||
.text_size(rems(TEXT_MONO))
|
||
.font_family(mono.clone())
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(p.name.clone()),
|
||
)
|
||
.child(actions),
|
||
);
|
||
}
|
||
Some(
|
||
v_flex()
|
||
.child(self.panel_subtitle(t(L10nKey::PanelPortsSubtitle), true, None, cx))
|
||
.child(list)
|
||
.into_any_element(),
|
||
)
|
||
}
|
||
|
||
fn procs(&self, pane_id: Option<u64>) -> Option<&PaneProcs> {
|
||
(pane_id.is_some() && self.right_panel.procs_pane == pane_id)
|
||
.then_some(self.right_panel.procs.as_ref())?
|
||
}
|
||
|
||
fn sync_procs(
|
||
&mut self,
|
||
pane_id: Option<u64>,
|
||
forwards: Option<crate::ui::app::ForwardRoute>,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let Some(pane_id) = pane_id else { return };
|
||
self.right_panel.procs_forwards = forwards.clone();
|
||
if self.right_panel.procs_pane != Some(pane_id) {
|
||
self.right_panel.procs_pane = Some(pane_id);
|
||
self.right_panel.procs = None;
|
||
self.loopback_panel.managed.clear();
|
||
self.right_panel.procs_gen += 1;
|
||
self.right_panel.procs_loading = false;
|
||
}
|
||
if !self.right_panel.procs_loading {
|
||
self.right_panel.procs_loading = true;
|
||
let generation = self.right_panel.procs_gen;
|
||
self.spawn_procs_query(pane_id, generation, forwards, cx);
|
||
}
|
||
}
|
||
|
||
fn spawn_procs_query(
|
||
&mut self,
|
||
pane_id: u64,
|
||
generation: u64,
|
||
forwards: Option<crate::ui::app::ForwardRoute>,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
cx.spawn(async move |this, cx| {
|
||
let route = forwards.clone();
|
||
let (procs, managed) = cx
|
||
.background_executor()
|
||
.spawn(async move {
|
||
let procs = crate::terminal::RemoteTerminal::query_procs(pane_id);
|
||
let managed = route.map(|r| r.list()).unwrap_or_default();
|
||
(procs, managed)
|
||
})
|
||
.await;
|
||
let keep_polling = this
|
||
.update(cx, |app, cx| {
|
||
if app.right_panel.procs_gen != generation {
|
||
return false;
|
||
}
|
||
app.right_panel.procs = Some(procs);
|
||
if forwards.is_some() {
|
||
app.loopback_panel.managed = managed;
|
||
}
|
||
cx.notify();
|
||
let wanted =
|
||
app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info;
|
||
if !wanted {
|
||
app.right_panel.procs_loading = false;
|
||
}
|
||
wanted
|
||
})
|
||
.unwrap_or(false);
|
||
if !keep_polling {
|
||
return;
|
||
}
|
||
cx.background_executor().timer(PROCS_POLL).await;
|
||
let _ = this.update(cx, |app, cx| {
|
||
if app.right_panel.procs_gen != generation {
|
||
return;
|
||
}
|
||
let wanted = app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info;
|
||
if wanted {
|
||
let forwards = app.right_panel.procs_forwards.clone();
|
||
app.spawn_procs_query(pane_id, generation, forwards, cx);
|
||
} else {
|
||
app.right_panel.procs_loading = false;
|
||
}
|
||
});
|
||
})
|
||
.detach();
|
||
}
|
||
|
||
fn render_panel_files(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||
let remote = self.remote_files_pane(window, cx);
|
||
let host = remote.as_ref().map(|(_, host)| host.clone());
|
||
if self.sftp_sync_pane(remote.map(|(id, _)| id), window, cx) {
|
||
return self.render_panel_sftp(host.unwrap_or_default(), window, cx);
|
||
}
|
||
|
||
let title = self.panel_title(t(L10nKey::PanelFilesTitle), None, None, window, cx);
|
||
let search = self.panel_search(&self.file_search.clone(), 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 host label for whatever the Files panel is currently showing over
|
||
/// SFTP, for copy that has to name the machine it is about to change.
|
||
pub(crate) fn remote_files_host(
|
||
&self,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> Option<String> {
|
||
self.remote_files_pane(window, cx).map(|(_, host)| host)
|
||
}
|
||
|
||
fn remote_files_pane(
|
||
&self,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> Option<(u64, String)> {
|
||
use crate::daemon::protocol::{RemoteKind, SshPhase};
|
||
let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?;
|
||
let view = leaf.read(cx);
|
||
let remote = view.remote_context()?;
|
||
if remote.kind != RemoteKind::NativeSsh
|
||
|| !matches!(view.ssh_phase(), Some(SshPhase::Connected))
|
||
{
|
||
return None;
|
||
}
|
||
Some((view.pane_id, remote.target))
|
||
}
|
||
}
|
||
|
||
/// Width of the fixed cell a git status letter is centred in.
|
||
///
|
||
/// Load-bearing beyond this function: `scm/panel.rs` gives its group-header
|
||
/// chevron box exactly this width so the group arrows and the status letters
|
||
/// stack into one vertical line down the right edge of the panel, and it keeps
|
||
/// its own `BADGE_W` in step. Changing it here without changing it there
|
||
/// breaks that column.
|
||
pub(crate) const BADGE_W: f32 = 14.;
|
||
|
||
/// A single-letter git status marker in a fixed-width cell.
|
||
///
|
||
/// Mono and SEMIBOLD so `M`, `A`, `D` and `U` all read as the same kind of
|
||
/// mark at a glance, and centred in a cell wide enough for the widest of them
|
||
/// at [`PANEL_TEXT_META`] — that is what makes a column of them line up
|
||
/// instead of drifting with the glyph widths.
|
||
pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement {
|
||
div()
|
||
.flex_none()
|
||
.w(px(BADGE_W))
|
||
.text_center()
|
||
.text_size(rems(META_MONO))
|
||
.font_family(mono.clone())
|
||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||
.text_color(color)
|
||
.child(letter.to_string())
|
||
.into_any_element()
|
||
}
|
||
|
||
/// Diameter of the agent dot in the Info panel, and the gap between it and the
|
||
/// word it qualifies.
|
||
///
|
||
/// Seven sixteenths of a rem — seven pixels at the default interface size,
|
||
/// because a dot on a line of text has to survive being read at a glance
|
||
/// without becoming a bullet, and a rem rather than a pixel because the line it
|
||
/// sits in is sized in rems: pinned in pixels it slid towards the cap height of
|
||
/// its own row the moment the interface font scale moved off 100%.
|
||
const PIP_SIZE: f32 = 7. * STEP;
|
||
const PIP_GAP: f32 = 7. * STEP;
|
||
|
||
/// How far down the value box the dot starts, again as a fraction of the text
|
||
/// it is centred in rather than a pixel count.
|
||
const PIP_TOP: f32 = 6. * STEP;
|
||
|
||
/// The dot a tab wears for its agent's state, at the size a line of panel text
|
||
/// can carry it.
|
||
///
|
||
/// Same colours and the same hollow-for-Waiting rule as the sidebar's, because
|
||
/// it is the same fact: a reader who has learned that amber-with-a-hole means
|
||
/// "it wants you" on a tab must not have to learn it a second time here. Same
|
||
/// *shape*, too — [`Tty7App::status_dot`] punches a small hole out of a filled
|
||
/// dot, so drawing this one as a thin ring would have been a second dialect of
|
||
/// the one rule the doc above promises is shared. `hole` is the colour behind
|
||
/// the dot, which is what a hole in it has to be painted in; the agent row is
|
||
/// never interactive, so that colour is the panel's own and does not move
|
||
/// under the pointer.
|
||
fn status_pip(rgb: u32, hollow: bool, hole: gpui::Hsla) -> AnyElement {
|
||
div()
|
||
.absolute()
|
||
.left_0()
|
||
.top(rems(PIP_TOP))
|
||
.size(rems(PIP_SIZE))
|
||
.rounded_full()
|
||
.bg(gpui::rgb(rgb))
|
||
.when(hollow, |dot| {
|
||
dot.flex()
|
||
.items_center()
|
||
.justify_center()
|
||
.child(div().size(rems(PIP_SIZE * 0.36)).rounded_full().bg(hole))
|
||
})
|
||
.into_any_element()
|
||
}
|
||
|
||
/// A small filled pill around a mono token — a pid, a port number.
|
||
///
|
||
/// The padding and the radius are derived from the text size: at
|
||
/// [`PANEL_TEXT_META`] the line box is `round(10.5 × 1.618) = 17px`, so 1.5px
|
||
/// of vertical padding makes the pill 20px tall — one pixel more than the 19px
|
||
/// line of [`PANEL_TEXT`] beside it, which is what sets the height of a ports
|
||
/// row. Horizontal padding of 5px is about half an em of breathing room on
|
||
/// each side, and radius 4 is a fifth of the pill's height.
|
||
pub(crate) 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(rems(META_MONO))
|
||
.font_family(mono.clone())
|
||
.text_color(fg)
|
||
.child(text.to_string())
|
||
.into_any_element()
|
||
}
|
||
|
||
pub fn reveal_label() -> &'static str {
|
||
if cfg!(target_os = "macos") {
|
||
t(L10nKey::PanelRevealInFinder)
|
||
} else {
|
||
t(L10nKey::PanelOpenFolder)
|
||
}
|
||
}
|
||
|
||
fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static str {
|
||
use crate::core::cli_agent::AgentStatus::*;
|
||
match status {
|
||
Idle => t(L10nKey::PanelAgentIdle),
|
||
Working => t(L10nKey::PanelAgentWorking),
|
||
Waiting => t(L10nKey::PanelAgentWaiting),
|
||
Done => t(L10nKey::PanelAgentDone),
|
||
}
|
||
}
|
||
|
||
/// Splits a path into everything-but-the-last-segment and the last segment,
|
||
/// so a row can shrink the first and keep the second.
|
||
fn split_path_leaf(s: &str) -> (String, String) {
|
||
// The larger of the two separator positions, not cfg-gated by platform:
|
||
// the Info panel shows remote paths too, so a Windows build describes
|
||
// Unix paths and vice versa — and a mixed-spelling path (`C:\Users\dev/
|
||
// project`, which agent-reported cwds arrive as) still cuts at its true
|
||
// leaf (#544). A Unix filename containing a literal `\` loses a shorter
|
||
// leaf; head + leaf still rejoins exactly, so the cost is decorative.
|
||
let leaf_at = s.rfind('/').max(s.rfind('\\'));
|
||
match leaf_at {
|
||
// Keep the separator with the head: "~/a/b/" + "c" rejoins exactly.
|
||
Some(i) if i + 1 < s.len() => (s[..=i].to_string(), s[i + 1..].to_string()),
|
||
_ => (String::new(), s.to_string()),
|
||
}
|
||
}
|
||
|
||
/// `home` is the home directory of the machine `path` lives on. A remote
|
||
/// pane's cwd is measured against *its* host's home, never this machine's
|
||
/// (#580) — and against nothing at all while the host has not said.
|
||
fn compact_path(path: &std::path::Path, home: Option<&std::path::Path>) -> String {
|
||
crate::ui::path_display::abbreviate_home(&path.to_string_lossy(), home).into_owned()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{InfoRow, InfoValue, split_path_leaf};
|
||
|
||
fn diff(added: u32, removed: u32, open: bool) -> InfoRow {
|
||
InfoRow {
|
||
label: "changes",
|
||
value: InfoValue::Diff {
|
||
added,
|
||
removed,
|
||
open: open.then(|| {
|
||
(
|
||
crate::ui::host_ops::HostId::LOCAL,
|
||
std::path::PathBuf::from("/w/repo"),
|
||
)
|
||
}),
|
||
},
|
||
copy: None,
|
||
reveal: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_row_lights_up_only_when_there_is_something_behind_it() {
|
||
// The hover fill is the panel's only "this line does something", so a
|
||
// row that cannot do anything must not draw one.
|
||
assert!(
|
||
!InfoRow::text("shell", "zsh".into()).interactive(),
|
||
"a plain readout is not a control"
|
||
);
|
||
assert!(
|
||
InfoRow::text("branch", "main".into())
|
||
.copyable()
|
||
.interactive(),
|
||
"a copy tile is something to hover for"
|
||
);
|
||
assert!(
|
||
InfoRow {
|
||
reveal: Some(std::path::PathBuf::from("/w/repo")),
|
||
..InfoRow::text("cwd", "/w/repo".into())
|
||
}
|
||
.interactive(),
|
||
"so is Reveal, even with nothing else on the row"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn counts_are_a_button_only_when_there_is_a_diff_to_open() {
|
||
assert!(
|
||
diff(3, 1, true).interactive(),
|
||
"changes with somewhere to go open the overlay"
|
||
);
|
||
// Both halves have to hold: a clean tree has no diff to show, and the
|
||
// setting that governs the sidebar's counts can take the target away
|
||
// from a dirty one.
|
||
assert!(
|
||
!diff(0, 0, false).interactive(),
|
||
"a clean tree is a readout, not a link"
|
||
);
|
||
assert!(
|
||
!diff(3, 1, false).interactive(),
|
||
"no target means no link, however dirty the tree"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn copyable_takes_the_text_the_row_shows_and_nothing_else() {
|
||
// `copyable()` reads the value it was given; rows built with an
|
||
// explicit clipboard string (the cwd, which copies the real path
|
||
// rather than the `~/…` spelling) set `copy` themselves.
|
||
assert_eq!(
|
||
InfoRow::text("ssh", "box".into())
|
||
.copyable()
|
||
.copy
|
||
.as_deref(),
|
||
Some("box")
|
||
);
|
||
assert_eq!(
|
||
diff(3, 1, true).copyable().copy,
|
||
None,
|
||
"there is no sensible clipboard form of two coloured numbers"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_head_and_leaf_rejoin_into_the_path_they_came_from() {
|
||
for p in [
|
||
"~/repo/tty7",
|
||
"/private/tmp/claude-501/a-very-long-directory/and-another-level",
|
||
"/",
|
||
"relative",
|
||
"",
|
||
"C:\\Users\\dev\\project",
|
||
"C:\\Users\\dev/project",
|
||
"\\\\server\\share\\dir",
|
||
] {
|
||
let (head, leaf) = split_path_leaf(p);
|
||
assert_eq!(format!("{head}{leaf}"), p, "rejoining {p:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_leaf_is_the_segment_that_names_the_directory() {
|
||
let (head, leaf) = split_path_leaf("/a/b/c");
|
||
assert_eq!((head.as_str(), leaf.as_str()), ("/a/b/", "c"));
|
||
// A trailing slash has no leaf to keep, so the whole thing is head.
|
||
let (head, leaf) = split_path_leaf("/a/b/");
|
||
assert_eq!((head.as_str(), leaf.as_str()), ("", "/a/b/"));
|
||
// Root is one segment with nothing before it.
|
||
let (head, leaf) = split_path_leaf("/");
|
||
assert_eq!((head.as_str(), leaf.as_str()), ("", "/"));
|
||
}
|
||
|
||
#[test]
|
||
fn the_leaf_survives_windows_and_mixed_spellings() {
|
||
// Backslash-native, the shape an agent-reported cwd arrives in.
|
||
let (head, leaf) = split_path_leaf("C:\\Users\\dev\\project");
|
||
assert_eq!(
|
||
(head.as_str(), leaf.as_str()),
|
||
("C:\\Users\\dev\\", "project")
|
||
);
|
||
// Mixed separators cut at the *last* one of either kind.
|
||
let (head, leaf) = split_path_leaf("C:\\Users\\dev/project");
|
||
assert_eq!(
|
||
(head.as_str(), leaf.as_str()),
|
||
("C:\\Users\\dev/", "project")
|
||
);
|
||
let (head, leaf) = split_path_leaf("C:/Users/dev\\project");
|
||
assert_eq!(
|
||
(head.as_str(), leaf.as_str()),
|
||
("C:/Users/dev\\", "project")
|
||
);
|
||
// A drive root has no leaf to keep.
|
||
let (head, leaf) = split_path_leaf("C:\\");
|
||
assert_eq!((head.as_str(), leaf.as_str()), ("", "C:\\"));
|
||
// A UNC path splits at its last component, head keeping the share.
|
||
let (head, leaf) = split_path_leaf("\\\\server\\share\\dir");
|
||
assert_eq!(
|
||
(head.as_str(), leaf.as_str()),
|
||
("\\\\server\\share\\", "dir")
|
||
);
|
||
}
|
||
}
|