mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-04 08:01:59 +00:00
2025 lines
86 KiB
Rust
2025 lines
86 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 _, WindowExt as _,
|
||
h_flex, v_flex,
|
||
};
|
||
use std::path::PathBuf;
|
||
|
||
use crate::core::config::{Config, RightPanelTab};
|
||
use crate::daemon::protocol::{ManagedForward, PaneProcs, PortProbe};
|
||
use crate::ui::app::{
|
||
CONTENT_INSET, TILE_GLYPH_SM, TILE_GLYPH_XS, TILE_SIZE_SM, TILE_SIZE_XS, Tty7App,
|
||
tile_trailing_inset, tile_trailing_inset_sm,
|
||
};
|
||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||
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.
|
||
///
|
||
/// Keep row glyphs on the same 16px grid as the toolbar.
|
||
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.;
|
||
|
||
/// Whether this forward is the one that reaches `port` on the far side.
|
||
///
|
||
/// Local forwards only, and only those aimed at the far host's own loopback:
|
||
/// a forward to some third machine happens to carry the same number, and
|
||
/// pairing it with the port row would claim it leads somewhere it does not.
|
||
pub(crate) fn forwards_port(m: &ManagedForward, port: u16) -> bool {
|
||
m.kind == crate::daemon::protocol::SshForwardKind::Local
|
||
&& m.target_port == port
|
||
&& crate::daemon::protocol::PortEntry::reaches_loopback(&m.target_host)
|
||
}
|
||
|
||
/// 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>,
|
||
/// Who to ask about `procs_pane` — `None` means this machine's daemon.
|
||
pub(crate) procs_host: Option<crate::ui::host_ops::SharedHost>,
|
||
/// Whether the host that owns this pane cannot describe its processes at
|
||
/// all: an older `tty7-server` on the far end, which does not know the
|
||
/// request. Kept apart from an empty list, because "nothing is listening"
|
||
/// and "nobody could tell us" are different sentences and the panel has to
|
||
/// say which one it means.
|
||
pub(crate) procs_unsupported: bool,
|
||
/// The last round trip measured to the machine `procs_pane` lives on.
|
||
/// `None` before the first ping comes back.
|
||
pub(crate) link_rtt: Option<std::time::Duration>,
|
||
/// Which host `link_rtt` was measured against, and — since only a remote
|
||
/// pane has one — whether the latency row is drawn at all. Held per host
|
||
/// rather than per pane so that moving between two panes of the same
|
||
/// machine keeps the number on screen: it belongs to the link the two
|
||
/// panes share, and blanking it per pane would empty the row for as long
|
||
/// as the next poll takes to cross the network.
|
||
pub(crate) link_host: Option<crate::ui::host_ops::HostId>,
|
||
/// How `procs_pane`'s loopback ports can be reached from this machine.
|
||
/// Read by the Ports list to decide what a click on a port does, and by
|
||
/// the watch to decide whether it has to keep looking with the panel shut.
|
||
pub(crate) port_route: crate::terminal::view::PortRoute,
|
||
/// The remote ports already forwarded unasked, per set of forwards.
|
||
///
|
||
/// Kept so that a forward the user then deletes is not immediately rebuilt
|
||
/// by the next poll — the automatic offer is made once per port, and after
|
||
/// that the port is theirs to forward or not.
|
||
///
|
||
/// Keyed by owner rather than held for the pane in front, because a
|
||
/// workspace's forwards are shared by all of its panes: switching tabs
|
||
/// would otherwise re-announce every port the workspace had already
|
||
/// forwarded, and switching back would re-offer what was just dismissed.
|
||
/// A few `u16` per connection is not worth reclaiming.
|
||
pub(crate) auto_forwarded:
|
||
std::collections::HashMap<crate::ui::app::ForwardOwnerKey, std::collections::HashSet<u16>>,
|
||
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;
|
||
|
||
/// How often the process and port list is re-read while it is on screen —
|
||
/// close enough that a process appearing feels immediate.
|
||
const PROCS_POLL: std::time::Duration = std::time::Duration::from_millis(2000);
|
||
|
||
/// How often it is re-read with the panel shut, where nobody is watching the
|
||
/// list and the only question is whether a new port has appeared. A couple of
|
||
/// extra seconds nobody can feel, against a query that crosses the network on
|
||
/// every remote pane.
|
||
const PORT_WATCH_POLL: std::time::Duration = std::time::Duration::from_millis(5000);
|
||
|
||
/// How many newly-seen ports one poll may forward. A dev server brings up one
|
||
/// or two; a number this side of a dozen is a process opening listeners in a
|
||
/// loop, and forwarding all of them helps nobody.
|
||
const AUTO_FORWARD_BURST: usize = 4;
|
||
|
||
/// What the Ports and Forwards sections are describing.
|
||
#[derive(Clone)]
|
||
pub(crate) struct PaneForwardCtx {
|
||
pub(crate) pane_id: u64,
|
||
/// `Some` when the pane has somewhere to hold managed forwards.
|
||
pub(crate) route: Option<crate::ui::app::ForwardRoute>,
|
||
pub(crate) port_route: crate::terminal::view::PortRoute,
|
||
/// The host that owns this pane's processes, when that is not this
|
||
/// machine. A remote workspace's panes live in the peer's registry, so the
|
||
/// local daemon — which is what `query_procs` asks — has never heard of
|
||
/// them and answers with an empty list.
|
||
pub(crate) host: Option<crate::ui::host_ops::SharedHost>,
|
||
}
|
||
|
||
/// 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)>,
|
||
},
|
||
}
|
||
|
||
/// The table convention for a cell with nothing in it. Needs no translating,
|
||
/// and is shorter to read than any of the sentences it stands in for.
|
||
const EMPTY: &str = "—";
|
||
|
||
/// A round trip, at the precision the number is worth reading to.
|
||
///
|
||
/// Whole milliseconds up to a second: tenths of a millisecond on a link that
|
||
/// varies by whole ones is noise dressed as measurement. Past a second the
|
||
/// millisecond stops mattering and the second is the unit anyone would say it
|
||
/// in.
|
||
fn format_rtt(rtt: std::time::Duration) -> String {
|
||
let ms = rtt.as_secs_f64() * 1000.;
|
||
if ms < 1. {
|
||
// Loopback and a peer on the same LAN both land here. Rounding to
|
||
// "0 ms" would read as a failed measurement rather than a fast one.
|
||
return "<1 ms".to_string();
|
||
}
|
||
// Rounded before the comparison, so 999.6 ms is not shown as "1000 ms" —
|
||
// a millisecond reading that has run past the unit's own range.
|
||
let rounded = ms.round() as u64;
|
||
if rounded < 1000 {
|
||
return format!("{rounded} ms");
|
||
}
|
||
format!("{:.1} s", rtt.as_secs_f64())
|
||
}
|
||
|
||
/// 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) + self.document_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));
|
||
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()))
|
||
.relative()
|
||
.children(self.right_panel_tabs(cx))
|
||
.child(div().flex_1())
|
||
// Navigation controls stay visible on both sidebars.
|
||
.child(self.window_chrome(window, cx))
|
||
}))
|
||
// Separate navigation from content with space, matching the
|
||
// left sidebar's continuous surface.
|
||
.children(cfg!(target_os = "macos").then(|| div().flex_none().h(px(8.))))
|
||
.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 others_floor = self.sidebar_floor(cx) + self.document_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,
|
||
others_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,
|
||
}))
|
||
.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()
|
||
// Full height, so the current tile's underline — pinned
|
||
// to the bottom of its own box — lands on the rule that
|
||
// closes this row, the way it does on macOS. Without it
|
||
// the tiles are only as tall as a glyph and the bar
|
||
// floats a few pixels above the line.
|
||
.h_full()
|
||
.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();
|
||
// Which pane the sections below describe, and what can be done with
|
||
// its ports. Worked out once, by the same call the watch uses.
|
||
let ctx = self.pane_forward_ctx(window, cx);
|
||
let mut pane_id: Option<u64> = None;
|
||
// 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;
|
||
|
||
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);
|
||
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);
|
||
// Whether this pane's paths are this machine's decides
|
||
// both tiles: reveal only means anything on the machine
|
||
// the file manager can see, and only a local path may be
|
||
// re-spelled with this OS's separators — a remote one is
|
||
// already native where it lives.
|
||
let local = view.local_cwd().is_some();
|
||
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(match local {
|
||
true => crate::ui::path_display::native_separators(&cwd)
|
||
.display()
|
||
.to_string(),
|
||
false => cwd.display().to_string(),
|
||
}),
|
||
reveal: local.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());
|
||
}
|
||
// Only where there is a network between here and the shell. On
|
||
// a pane of this machine's own the row would be reporting the
|
||
// round trip to a Unix socket, which is a number with nothing
|
||
// to compare it against.
|
||
if self.right_panel.link_host.is_some() {
|
||
rows.push(InfoRow::text(
|
||
t(L10nKey::PanelLatency),
|
||
// A link whose first ping has not come back yet,
|
||
// rather than one measured at zero. The dash is the
|
||
// table's empty cell, the same one a clean working
|
||
// tree gets.
|
||
self.right_panel
|
||
.link_rtt
|
||
.map(format_rtt)
|
||
.unwrap_or_else(|| EMPTY.to_string()),
|
||
));
|
||
}
|
||
git = view.git_status(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,
|
||
});
|
||
}
|
||
}
|
||
|
||
if rows.is_empty() {
|
||
return self.panel_scroll(
|
||
self.panel_empty(
|
||
t(L10nKey::PanelNoSession),
|
||
Some(t(L10nKey::PanelNoSessionHint)),
|
||
cx,
|
||
),
|
||
title,
|
||
);
|
||
}
|
||
|
||
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(ctx.as_ref(), 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) = crate::ui::path_display::split_path_leaf(&v);
|
||
h_flex()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.text_size(rems(TEXT))
|
||
.text_color(cx.theme().sidebar_foreground)
|
||
.child(
|
||
div()
|
||
.min_w_0()
|
||
.flex_shrink(999.)
|
||
.truncate()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(head),
|
||
)
|
||
.child(
|
||
div()
|
||
.min_w_0()
|
||
.flex_shrink(1.)
|
||
.truncate()
|
||
.font_weight(gpui::FontWeight::MEDIUM)
|
||
.child(leaf),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
InfoValue::Text(v) => div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_size(rems(TEXT))
|
||
.text_color(cx.theme().sidebar_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(EMPTY.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 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(&crate::ui::path_display::native_separators(&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)
|
||
.text_size(rems(META))
|
||
.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 ports this pane is serving, and what it takes to reach them.
|
||
///
|
||
/// One list, not two. Ports and forwards used to be separate sections, so
|
||
/// a remote :3000 and the forward that reaches :3000 sat under different
|
||
/// headings with nothing saying they were the same thing — and the ports
|
||
/// half offered no way to build the forward the other half was for. A row
|
||
/// is a port here, and the forward, when there is one, is where that row
|
||
/// says it comes out.
|
||
fn ports_section(
|
||
&self,
|
||
ctx: Option<&PaneForwardCtx>,
|
||
cx: &mut Context<Self>,
|
||
) -> Option<AnyElement> {
|
||
let ctx = ctx?;
|
||
let pane_id = ctx.pane_id;
|
||
// No answer yet for this pane defaults to a probe that is fine, not a
|
||
// broken one: the panel has nothing to doubt until it has been told
|
||
// something.
|
||
let (ports, probe) = self
|
||
.procs(Some(pane_id))
|
||
.map(|p| (p.ports.clone(), p.probe.clone()))
|
||
.unwrap_or_default();
|
||
let forwards: Vec<ManagedForward> = self
|
||
.loopback_panel
|
||
.managed
|
||
.iter()
|
||
.filter(|m| m.pane_id == pane_id)
|
||
.cloned()
|
||
.collect();
|
||
let form_open = self.loopback_panel.form_pane_id == Some(pane_id);
|
||
// A pane that cannot hold a forward and is serving nothing has no
|
||
// section: the heading alone would be an empty promise.
|
||
//
|
||
// Unless the reason it is serving nothing is that nobody managed to
|
||
// look. Then the heading and one muted line under it are the only
|
||
// place the panel can admit it does not know, and a silently absent
|
||
// section is the bug (#731): someone whose server is plainly up reads
|
||
// the missing section as tty7 saying there is no server.
|
||
if ports.is_empty() && forwards.is_empty() && ctx.route.is_none() && probe.is_ok() {
|
||
return None;
|
||
}
|
||
|
||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||
let mono = cx.theme().mono_font_family.clone();
|
||
let openable = ctx.port_route != crate::terminal::view::PortRoute::Blocked;
|
||
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(1.));
|
||
// Which forwards a port row has already accounted for; whatever is
|
||
// left over gets a row of its own below.
|
||
let mut paired: Vec<u64> = Vec::new();
|
||
|
||
for (i, p) in ports.iter().enumerate() {
|
||
let forward = forwards.iter().find(|m| forwards_port(m, p.port));
|
||
if let Some(f) = forward {
|
||
paired.push(f.id);
|
||
}
|
||
// What a click and a copy are about: the address that works from
|
||
// here. Once a forward exists that is the local end of it, not the
|
||
// far side's own spelling of the port.
|
||
let here = forward.map(|f| f.bind_port);
|
||
let authority = here
|
||
.map(|local| format!("127.0.0.1:{local}"))
|
||
.unwrap_or_else(|| 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 openable {
|
||
tiles_wide += 1;
|
||
let port = p.port;
|
||
let direct = p.authority();
|
||
actions = actions.child(
|
||
self.info_tile(
|
||
("panel-port-open", i),
|
||
IconName::Globe,
|
||
t(L10nKey::PanelOpenInBrowser),
|
||
cx,
|
||
)
|
||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||
this.open_pane_port(port, direct.clone(), cx)
|
||
})),
|
||
);
|
||
}
|
||
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()));
|
||
}
|
||
}),
|
||
);
|
||
if let Some(f) = forward {
|
||
tiles_wide += 1;
|
||
let forward_id = f.id;
|
||
actions = actions.child(
|
||
self.info_tile(
|
||
("panel-port-unforward", i),
|
||
IconName::Close,
|
||
t(L10nKey::ForwardTooltipRemove),
|
||
cx,
|
||
)
|
||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||
this.remove_managed_forward(pane_id, forward_id, cx)
|
||
})),
|
||
);
|
||
}
|
||
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()),
|
||
)
|
||
// Where the port comes out on this machine. Just the
|
||
// number: the host is always this machine's loopback, and
|
||
// spelling it out on every row would bury the one part
|
||
// that differs.
|
||
.children(here.map(|local| {
|
||
div()
|
||
.flex_none()
|
||
.text_size(rems(META_MONO))
|
||
.font_family(mono.clone())
|
||
.text_color(cx.theme().muted_foreground.opacity(0.8))
|
||
.child(format!("→ :{local}"))
|
||
}))
|
||
.child(actions),
|
||
);
|
||
}
|
||
|
||
// Forwards no port row spoke for: the remote and dynamic ones, and any
|
||
// local forward pointed somewhere this pane is not itself serving.
|
||
for forward in forwards.iter().filter(|m| !paired.contains(&m.id)) {
|
||
list = list.child(self.forward_row(forward, &mono, cx));
|
||
}
|
||
|
||
let add = ctx.route.is_some().then(|| {
|
||
crate::ui::tab_strip::chrome_tile_sized(
|
||
Button::new(("ssh-forward-add-toggle", pane_id))
|
||
.icon(Icon::empty().path("icons/plus.svg")),
|
||
TILE_SIZE_SM,
|
||
TILE_GLYPH_SM,
|
||
form_open,
|
||
cx,
|
||
)
|
||
.rounded_md()
|
||
.tooltip(if form_open {
|
||
t(L10nKey::Cancel)
|
||
} else {
|
||
t(L10nKey::ForwardTooltipAdd)
|
||
})
|
||
.on_click(cx.listener(move |this, _, window, cx| {
|
||
this.toggle_managed_forward_form(pane_id, window, cx)
|
||
}))
|
||
.into_any_element()
|
||
});
|
||
|
||
Some(
|
||
v_flex()
|
||
.child(self.panel_subtitle(t(L10nKey::PanelPortsSubtitle), true, add, cx))
|
||
.when(
|
||
ports.is_empty() && forwards.is_empty() && !form_open,
|
||
|this| {
|
||
// "Nothing is listening" and "nobody could tell us"
|
||
// look identical on screen unless the panel says which
|
||
// one it means — and every one of the second kind is a
|
||
// fixable thing: a far end running a server too old to
|
||
// answer, a probe that could not be run at all, a
|
||
// server started under `sudo` whose sockets this user
|
||
// is not allowed to see. Still one muted line in the
|
||
// place the word "None" would have gone; the panel is
|
||
// reporting what it knows, not raising an alarm.
|
||
let key = match (self.right_panel.procs_unsupported, &probe) {
|
||
(true, _) => L10nKey::PanelPortsUnsupported,
|
||
(false, PortProbe::Unavailable(_)) => L10nKey::PanelPortsProbeFailed,
|
||
(false, PortProbe::Restricted) => L10nKey::PanelPortsRestricted,
|
||
(false, PortProbe::Ok) => L10nKey::None,
|
||
};
|
||
this.child(
|
||
div()
|
||
.px(px(CONTENT_INSET))
|
||
.py(px(2.))
|
||
.text_size(rems(TEXT))
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(t(key)),
|
||
)
|
||
},
|
||
)
|
||
.child(list)
|
||
.when(form_open, |this| this.child(self.forward_form(pane_id, cx)))
|
||
.into_any_element(),
|
||
)
|
||
}
|
||
|
||
/// Open one of this pane's ports in a browser, building the forward it
|
||
/// needs first when it needs one.
|
||
///
|
||
/// The forward is the part the user should not have to think about: they
|
||
/// asked to see :3000, and where :3000 has to be tunnelled to be seen,
|
||
/// that is this function's problem and not theirs.
|
||
pub(crate) fn open_pane_port(
|
||
&mut self,
|
||
port: u16,
|
||
direct_authority: String,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
use crate::terminal::view::PortRoute;
|
||
match self.right_panel.port_route {
|
||
PortRoute::Blocked => {}
|
||
PortRoute::Direct => cx.open_url(&format!("http://{direct_authority}")),
|
||
PortRoute::Forward => {
|
||
if let Some(f) = self
|
||
.loopback_panel
|
||
.managed
|
||
.iter()
|
||
.find(|m| forwards_port(m, port))
|
||
{
|
||
cx.open_url(&format!("http://127.0.0.1:{}", f.bind_port));
|
||
return;
|
||
}
|
||
let Some(route) = self.right_panel.procs_forwards.clone() else {
|
||
return;
|
||
};
|
||
let owner = route.owner_key();
|
||
cx.spawn(async move |this, cx| {
|
||
let built = cx
|
||
.background_executor()
|
||
.spawn(async move { route.ensure_loopback("127.0.0.1", port) })
|
||
.await;
|
||
let _ = this.update_in(cx, |app, window, cx| match built {
|
||
Ok(f) => {
|
||
// Claimed, so the watch does not offer this port a
|
||
// second time after the user has just opened it.
|
||
app.right_panel
|
||
.auto_forwarded
|
||
.entry(owner)
|
||
.or_default()
|
||
.insert(port);
|
||
cx.open_url(&format!("http://127.0.0.1:{}", f.local_port));
|
||
if let Some(pane_id) = app.right_panel.procs_pane {
|
||
app.refresh_managed_forwards(pane_id, cx);
|
||
}
|
||
}
|
||
Err(e) => window.push_notification(
|
||
t_fmt(
|
||
L10nKey::LoopbackForwardFailed,
|
||
&[("port", &port.to_string()), ("error", &e.to_string())],
|
||
),
|
||
cx,
|
||
),
|
||
});
|
||
})
|
||
.detach();
|
||
}
|
||
}
|
||
}
|
||
|
||
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())?
|
||
}
|
||
|
||
/// What the Ports and Forwards sections are describing: which pane, how
|
||
/// forward requests about it reach a daemon, and whether the loopback
|
||
/// ports it is serving can be opened from this machine.
|
||
///
|
||
/// One answer for both readers. The panel draws from it and the watch polls
|
||
/// from it, and when they were each working it out for themselves the panel
|
||
/// could offer to open a port the watch had already given up on.
|
||
pub(crate) fn pane_forward_ctx(
|
||
&self,
|
||
window: &Window,
|
||
cx: &gpui::App,
|
||
) -> Option<PaneForwardCtx> {
|
||
let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?;
|
||
let view = leaf.read(cx);
|
||
// A pane holds managed forwards once it has somewhere to hold them:
|
||
// a live native-ssh connection, or the workspace's shared one.
|
||
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)
|
||
);
|
||
Some(PaneForwardCtx {
|
||
pane_id: view.pane_id,
|
||
route: (connected_ssh || view.workspace().is_some()).then(|| view.forward_route()),
|
||
port_route: view.port_route(cx),
|
||
host: (!view.host_id().is_local())
|
||
.then(|| view.host(cx))
|
||
.flatten(),
|
||
})
|
||
}
|
||
|
||
/// Point the port watch at whatever pane is in front, once a frame.
|
||
///
|
||
/// Driven from the app's own render rather than the panel's: the watch has
|
||
/// to run with the panel shut, which is exactly when a new port appearing
|
||
/// is worth saying something about.
|
||
pub(crate) fn sync_port_watch(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
let ctx = self.pane_forward_ctx(window, cx);
|
||
self.right_panel.port_route = ctx
|
||
.as_ref()
|
||
.map_or(crate::terminal::view::PortRoute::Blocked, |c| c.port_route);
|
||
let pane_id = ctx.as_ref().map(|c| c.pane_id);
|
||
let host = ctx.as_ref().and_then(|c| c.host.clone());
|
||
let route = ctx.and_then(|c| c.route);
|
||
self.sync_procs(pane_id, route, host, cx);
|
||
}
|
||
|
||
/// Whether the process/port poll should run at all this round.
|
||
fn procs_wanted(&self) -> bool {
|
||
(self.right_panel_visible && self.right_panel_tab == RightPanelTab::Info)
|
||
|| self.watching_ports()
|
||
}
|
||
|
||
/// Whether the poll has to keep going with the panel closed: on a pane
|
||
/// whose ports need forwarding, noticing a new listener *is* the feature,
|
||
/// and a shut panel is not a reason to stop looking.
|
||
fn watching_ports(&self) -> bool {
|
||
self.right_panel.port_route == crate::terminal::view::PortRoute::Forward
|
||
&& self.right_panel.procs_forwards.is_some()
|
||
}
|
||
|
||
/// Forward the loopback ports this poll saw for the first time, and say so.
|
||
///
|
||
/// Once per port, not once per poll: a forward the user then deletes stays
|
||
/// deleted. Only ports bound somewhere this machine could reach through the
|
||
/// tunnel — a listener pinned to one of the far host's own interfaces is a
|
||
/// different service, and guessing at it would build a forward to nothing.
|
||
fn auto_forward_ports(&mut self, cx: &mut Context<Self>) {
|
||
if !self.watching_ports() {
|
||
return;
|
||
}
|
||
let Some(route) = self.right_panel.procs_forwards.clone() else {
|
||
return;
|
||
};
|
||
// Read out before the ledger is touched: the ports are behind the same
|
||
// borrow the `seen` entry needs.
|
||
let listening: Vec<u16> = match self.right_panel.procs.as_ref() {
|
||
Some(procs) => procs
|
||
.ports
|
||
.iter()
|
||
.filter(|p| crate::daemon::protocol::PortEntry::reaches_loopback(&p.addr))
|
||
.map(|p| p.port)
|
||
.collect(),
|
||
None => return,
|
||
};
|
||
let owner = route.owner_key();
|
||
let seen = self.right_panel.auto_forwarded.entry(owner).or_default();
|
||
let mut fresh: Vec<u16> = Vec::new();
|
||
for port in listening {
|
||
if fresh.len() >= AUTO_FORWARD_BURST {
|
||
break;
|
||
}
|
||
// Two rows may name one port — a pre-forking server puts one per
|
||
// worker on screen — and they want one forward between them.
|
||
if seen.contains(&port) || fresh.contains(&port) {
|
||
continue;
|
||
}
|
||
fresh.push(port);
|
||
}
|
||
if fresh.is_empty() {
|
||
return;
|
||
}
|
||
// Claimed before the request goes out, so the next poll — two seconds
|
||
// away, and this round trip crosses a network — does not ask again.
|
||
seen.extend(fresh.iter().copied());
|
||
cx.spawn(async move |this, cx| {
|
||
let built = cx
|
||
.background_executor()
|
||
.spawn(async move {
|
||
fresh
|
||
.into_iter()
|
||
.map(|port| {
|
||
let local = route
|
||
.ensure_loopback("127.0.0.1", port)
|
||
.map(|f| f.local_port);
|
||
(port, local)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.await;
|
||
let _ = this.update_in(cx, |app, window, cx| {
|
||
for (port, local) in built {
|
||
match local {
|
||
Ok(local) => window.push_notification(
|
||
t_fmt(
|
||
L10nKey::PortAutoForwarded,
|
||
&[("port", &port.to_string()), ("local", &local.to_string())],
|
||
),
|
||
cx,
|
||
),
|
||
Err(e) => {
|
||
// Usually a connection that is not up yet. Let the
|
||
// next poll try again rather than writing the port
|
||
// off for the life of the pane.
|
||
log::debug!("could not auto-forward :{port}: {e}");
|
||
if let Some(seen) = app.right_panel.auto_forwarded.get_mut(&owner) {
|
||
seen.remove(&port);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
cx.notify();
|
||
});
|
||
})
|
||
.detach();
|
||
}
|
||
|
||
fn sync_procs(
|
||
&mut self,
|
||
pane_id: Option<u64>,
|
||
forwards: Option<crate::ui::app::ForwardRoute>,
|
||
host: Option<crate::ui::host_ops::SharedHost>,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let Some(pane_id) = pane_id else { return };
|
||
self.right_panel.procs_forwards = forwards.clone();
|
||
self.right_panel.procs_host = host.clone();
|
||
// Per host, not per pane — see `link_host`. A pane of this machine's
|
||
// own has no host at all, which is what clears the section rather than
|
||
// leaving the last remote pane's numbers under a local one.
|
||
let link_host = host.as_ref().map(|h| h.id());
|
||
if self.right_panel.link_host != link_host {
|
||
self.right_panel.link_host = link_host;
|
||
self.right_panel.link_rtt = None;
|
||
}
|
||
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;
|
||
self.right_panel.procs_unsupported = false;
|
||
}
|
||
// Asked before the first query as well as before every later one. This
|
||
// used to be reached only from the Info panel's own render, where the
|
||
// panel being open was implied; driven from the app's render it is not,
|
||
// and starting a round trip per frame for a pane nobody is watching is
|
||
// both wasted IPC and, under a test executor, a queue that never
|
||
// empties.
|
||
if !self.right_panel.procs_loading && self.procs_wanted() {
|
||
self.right_panel.procs_loading = true;
|
||
let generation = self.right_panel.procs_gen;
|
||
self.spawn_procs_query(pane_id, generation, forwards, host, cx);
|
||
}
|
||
}
|
||
|
||
fn spawn_procs_query(
|
||
&mut self,
|
||
pane_id: u64,
|
||
generation: u64,
|
||
forwards: Option<crate::ui::app::ForwardRoute>,
|
||
host: Option<crate::ui::host_ops::SharedHost>,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
cx.spawn(async move |this, cx| {
|
||
let route = forwards.clone();
|
||
// Only while someone is looking. This poll also runs with the panel
|
||
// shut, watching for ports to forward, and a round trip per round
|
||
// for a row nobody can see is the far end's time spent on nothing.
|
||
let want_link = this
|
||
.read_with(cx, |app, _| {
|
||
app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info
|
||
})
|
||
.unwrap_or(false);
|
||
let (procs, managed, link) = cx
|
||
.background_executor()
|
||
.spawn(async move {
|
||
// A remote workspace's pane runs on the peer, so the peer
|
||
// is the only one that can walk its process tree; the local
|
||
// daemon does not have the pane at all and would answer
|
||
// with an empty list. `None` back from the host means it
|
||
// could not be asked, which is not the same as an empty
|
||
// answer — see `Host::pane_procs`.
|
||
let procs = match &host {
|
||
Some(host) => {
|
||
use crate::ui::host_ops::Host as _;
|
||
host.pane_procs(pane_id)
|
||
}
|
||
None => Some(crate::terminal::RemoteTerminal::query_procs(pane_id)),
|
||
};
|
||
let managed = route.map(|r| r.list()).unwrap_or_default();
|
||
let link = match (want_link, &host) {
|
||
(true, Some(host)) => host.link_rtt(),
|
||
_ => None,
|
||
};
|
||
(procs, managed, link)
|
||
})
|
||
.await;
|
||
let keep_polling = this
|
||
.update(cx, |app, cx| {
|
||
if app.right_panel.procs_gen != generation {
|
||
return false;
|
||
}
|
||
app.right_panel.procs_unsupported = procs.is_none();
|
||
// A host that could not answer leaves the last list it did
|
||
// answer with in place: blanking it on one failed poll
|
||
// would make the panel flicker on a link that hiccups.
|
||
if let Some(procs) = procs {
|
||
app.right_panel.procs = Some(procs);
|
||
}
|
||
if forwards.is_some() {
|
||
app.loopback_panel.managed = managed;
|
||
}
|
||
// Only when this round actually asked. A round that did not
|
||
// leaves the last answer in place, so reopening the panel
|
||
// shows the number it was closed on rather than a dash
|
||
// until the next poll lands.
|
||
if want_link {
|
||
app.right_panel.link_rtt = link;
|
||
}
|
||
cx.notify();
|
||
let wanted = app.procs_wanted();
|
||
if !wanted {
|
||
app.right_panel.procs_loading = false;
|
||
}
|
||
wanted
|
||
})
|
||
.unwrap_or(false);
|
||
// After the list has landed, so the ports it forwards are the ones
|
||
// this poll actually saw.
|
||
let _ = this.update(cx, |app, cx| app.auto_forward_ports(cx));
|
||
if !keep_polling {
|
||
return;
|
||
}
|
||
let gap = this
|
||
.read_with(cx, |app, _| {
|
||
match app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info {
|
||
true => PROCS_POLL,
|
||
false => PORT_WATCH_POLL,
|
||
}
|
||
})
|
||
.unwrap_or(PORT_WATCH_POLL);
|
||
cx.background_executor().timer(gap).await;
|
||
let _ = this.update(cx, |app, cx| {
|
||
if app.right_panel.procs_gen != generation {
|
||
return;
|
||
}
|
||
if app.procs_wanted() {
|
||
let forwards = app.right_panel.procs_forwards.clone();
|
||
let host = app.right_panel.procs_host.clone();
|
||
app.spawn_procs_query(pane_id, generation, forwards, host, 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()
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
|
||
/// `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, format_rtt, forwards_port};
|
||
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
|
||
|
||
fn forward(kind: SshForwardKind, target_host: &str, target_port: u16) -> ManagedForward {
|
||
ManagedForward {
|
||
id: 1,
|
||
pane_id: 7,
|
||
kind,
|
||
bind_host: "127.0.0.1".to_string(),
|
||
bind_port: 51000,
|
||
target_host: target_host.to_string(),
|
||
target_port,
|
||
description: None,
|
||
status: ForwardStatus::Listening,
|
||
}
|
||
}
|
||
|
||
/// A port row and the forward that reaches it are one line, so this is
|
||
/// what decides whether a forward is *that* row's or a line of its own.
|
||
#[test]
|
||
fn a_port_row_claims_only_the_forward_that_reaches_it() {
|
||
assert!(forwards_port(
|
||
&forward(SshForwardKind::Local, "localhost", 3000),
|
||
3000
|
||
));
|
||
assert!(
|
||
forwards_port(&forward(SshForwardKind::Local, "127.0.0.1", 3000), 3000),
|
||
"the far side's loopback spells itself several ways"
|
||
);
|
||
assert!(
|
||
!forwards_port(&forward(SshForwardKind::Local, "localhost", 3000), 8080),
|
||
"a different port is a different row"
|
||
);
|
||
assert!(
|
||
!forwards_port(&forward(SshForwardKind::Local, "10.0.0.5", 3000), 3000),
|
||
"same number, another machine — pairing them would claim it leads \
|
||
somewhere it does not"
|
||
);
|
||
assert!(
|
||
!forwards_port(&forward(SshForwardKind::Remote, "localhost", 3000), 3000),
|
||
"a remote forward listens on the far side, so it is not how this \
|
||
port is reached from here"
|
||
);
|
||
}
|
||
|
||
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 a_round_trip_is_read_at_the_precision_it_is_worth() {
|
||
use std::time::Duration;
|
||
// A peer on the same machine or the same LAN. "0 ms" would read as a
|
||
// measurement that failed rather than one that was fast.
|
||
assert_eq!(format_rtt(Duration::from_micros(120)), "<1 ms");
|
||
assert_eq!(format_rtt(Duration::from_micros(999)), "<1 ms");
|
||
assert_eq!(format_rtt(Duration::from_millis(1)), "1 ms");
|
||
assert_eq!(format_rtt(Duration::from_micros(23_400)), "23 ms");
|
||
assert_eq!(format_rtt(Duration::from_millis(999)), "999 ms");
|
||
// Rounding up out of the millisecond's own range hands the number to
|
||
// the unit above rather than printing a four-digit millisecond.
|
||
assert_eq!(format_rtt(Duration::from_micros(999_600)), "1.0 s");
|
||
// Past a second the millisecond has stopped carrying information, and
|
||
// the second is the unit anyone would say the number in.
|
||
assert_eq!(format_rtt(Duration::from_millis(1_450)), "1.4 s");
|
||
assert_eq!(format_rtt(Duration::from_secs(4)), "4.0 s");
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
}
|