Give the side panel's Info tab rows that do what they show (#531)

* feat(right-panel): give the Info tab rows that do what they show

The panel's Session table rendered every fact the same inert way, and its
two actions sat in a strip of their own under the whole list — unlabelled,
four rows below the path they acted on. Rows now carry their own shape:
`changes` is the sidebar's green-and-red `+N −M` and opens the same diff
overlay under the same setting, an agent wears the same status dot its tab
does, and what a row can do appears at the end of it on hover, in the strip
Source Control rows already use. A port row hands over its address instead
of leaving it to be retyped, and the lit panel tile closes the panel the way
every other activity bar does.

* fix(right-panel): answer for the row the pointer is actually on

Review of #531 found the new rows promising more than they could keep.

Port rows keyed their element id on the port alone, but a port is only
unique with its pid — a pre-forking server puts one row per worker on
screen, and gpui handed them a single interactive state, so a click on
one lit the tooltip and the pressed fill on all of them.

The `changes` counts were read off `Tab::git_status`, which resolves a
split tab to its *first* leaf, while the click target came from
`detail_pane`, which resolves it to the *last focused* one. Inert text
could disagree harmlessly; a button could not, and clicking `+2 −0`
opened another pane's repository. Both now come from the pane the rest
of the rows describe.

A port is only `localhost` if localhost reaches it. `lsof`'s bind
address was parsed and dropped, so a server on `172.17.0.1:8080` was
offered as `localhost:8080` — a refused connection, or somebody else's
service. `PortEntry` carries the address (`serde(default)`, so an older
daemon still answers), and the wildcard and loopback binds keep the
`localhost` spelling anyone would type.

The browser tile hung off `remote_context()` — where the *shell* is —
though the ports come from the pane's own process tree either way. It
hid the tile on the one pane where it works, a `ssh -L` forward listening
on this machine. It is about the host now.

The action strip is opaque and pinned to the row's right edge, so on the
working-directory row it covered the leaf that the head-first elision
exists to preserve. The value holds that width back for good rather than
on hover: taking it on hover would re-elide the path under the pointer,
which is the pixel-shifting the strip is absolutely positioned to avoid.

Also: the counts were `flex_1`, so the whole rest of the line was the
button and empty space underlined numbers it was nowhere near; the agent
pip was pinned in pixels inside rem-sized text and slid off its line at
any interface scale but 100%, and drew Waiting as a thin ring where the
tab strip punches a hole in a filled dot — one rule, two dialects; the
panel-toggle chrome tile, which on macOS lives inside the panel it
closes, still dropped focus into the destroyed element and left ⌘J
dead; and `scm/detail.rs` kept a third copy of `ROW_INSET`.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-08-12 09:58:47 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 4b7719ba5f
commit b7196ae49a
13 changed files with 794 additions and 195 deletions
+1
View File
@@ -506,6 +506,7 @@ mod tests {
ports: vec![PortEntry {
port: 3000,
pid: 200,
addr: "*".into(),
name: "node".into(),
}],
};
+58 -10
View File
@@ -231,15 +231,29 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
match tag {
"p" => current = rest.parse().unwrap_or(0),
"n" => {
let Some(port) = parse_listen_port(rest) else {
let Some((addr, port)) = parse_listen_addr(rest) else {
continue;
};
if ports.iter().any(|e| e.port == port && e.pid == current) {
// One process listening on the same port over IPv4 and IPv6 is
// one port to show. Which of the two lines survives used to be
// whichever lsof printed first; now that the address is carried
// through to a clickable URL, the reachable one wins — a
// process bound to both `192.168.1.5` and `*` is on localhost,
// and the row should say so.
if let Some(seen) = ports
.iter_mut()
.find(|e| e.port == port && e.pid == current)
{
if !PortEntry::reaches_loopback(&seen.addr) && PortEntry::reaches_loopback(addr)
{
seen.addr = addr.to_string();
}
continue;
}
ports.push(PortEntry {
port,
pid: current,
addr: addr.to_string(),
name: by_pid
.get(&current)
.copied()
@@ -259,10 +273,19 @@ fn listening_ports(_procs: &[ProcEntry]) -> Vec<PortEntry> {
Vec::new()
}
fn parse_listen_port(name: &str) -> Option<u16> {
/// The address and port `lsof -Fn` reports a listener on — `*:3000`,
/// `127.0.0.1:8080`, `[::1]:5173`.
///
/// The address used to be dropped on the floor, which was harmless while the
/// port was a number to read. It stopped being harmless when the panel started
/// handing the port over as an address to open: a server bound only to
/// `172.17.0.1` or a LAN address is not on `localhost`, and offering it as one
/// sends the browser to a refused connection or, worse, to whatever else holds
/// that port on loopback.
fn parse_listen_addr(name: &str) -> Option<(&str, u16)> {
let name = name.split_whitespace().next()?;
let (_, port) = name.rsplit_once(':')?;
port.parse().ok()
let (addr, port) = name.rsplit_once(':')?;
Some((addr, port.parse().ok()?))
}
#[cfg(test)]
@@ -325,10 +348,35 @@ mod tests {
#[test]
fn parses_lsof_listen_addresses() {
assert_eq!(parse_listen_port("*:3000"), Some(3000));
assert_eq!(parse_listen_port("127.0.0.1:8080"), Some(8080));
assert_eq!(parse_listen_port("[::1]:5173"), Some(5173));
assert_eq!(parse_listen_port("*:5432 (LISTEN)"), Some(5432));
assert_eq!(parse_listen_port("/tmp/some.sock"), None);
assert_eq!(parse_listen_addr("*:3000"), Some(("*", 3000)));
assert_eq!(
parse_listen_addr("127.0.0.1:8080"),
Some(("127.0.0.1", 8080))
);
assert_eq!(parse_listen_addr("[::1]:5173"), Some(("[::1]", 5173)));
assert_eq!(parse_listen_addr("*:5432 (LISTEN)"), Some(("*", 5432)));
assert_eq!(parse_listen_addr("/tmp/some.sock"), None);
}
#[test]
fn an_address_only_becomes_localhost_when_localhost_reaches_it() {
// What the panel copies and opens. A wildcard or a loopback bind is
// spelled the way anyone would type it; an interface-specific bind is
// kept, because `localhost` is not that server.
let entry = |addr: &str| PortEntry {
port: 8080,
pid: 1,
addr: addr.into(),
name: "server".into(),
};
for addr in ["", "*", "0.0.0.0", "::", "[::]", "127.0.0.1", "[::1]"] {
assert_eq!(
entry(addr).authority(),
"localhost:8080",
"{addr} is reachable on loopback"
);
}
assert_eq!(entry("172.17.0.1").authority(), "172.17.0.1:8080");
assert_eq!(entry("192.168.1.20").authority(), "192.168.1.20:8080");
}
}
+33
View File
@@ -393,6 +393,39 @@ pub struct PortEntry {
pub port: u16,
pub pid: u32,
pub name: String,
/// The address the socket is bound to, as `lsof` spells it — `*`,
/// `0.0.0.0`, `127.0.0.1`, `[::1]`, or a specific interface.
///
/// `serde(default)` because a daemon from before this field existed
/// answers `QueryProcs` without it, and an empty address is read the same
/// way that daemon's callers read every address: as localhost.
#[serde(default)]
pub addr: String,
}
impl PortEntry {
/// Whether a bound address can be reached on this machine's loopback —
/// true for the wildcards and the loopback addresses themselves, false for
/// a socket pinned to one specific non-loopback interface.
pub fn reaches_loopback(addr: &str) -> bool {
matches!(
addr,
"" | "*" | "0.0.0.0" | "::" | "[::]" | "127.0.0.1" | "::1" | "[::1]" | "localhost"
)
}
/// What to copy, and what to open in a browser: `host:port`.
///
/// Loopback and wildcard binds are spelled `localhost`, which is what
/// anyone typing the address by hand would write. A socket bound to one
/// specific interface keeps that interface — the panel presents this as
/// the address of the pane's server, and `localhost` would not be it.
pub fn authority(&self) -> String {
match Self::reaches_loopback(&self.addr) {
true => format!("localhost:{}", self.port),
false => format!("{}:{}", self.addr, self.port),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+14 -4
View File
@@ -6,6 +6,9 @@ description: "Info, Source Control, and Files — plus the built-in editor."
<kbd>⌘ J</kbd> opens a panel on the right of the window with three tabs. It is
hidden by default; whichever tab you leave it on is where it opens next time.
The three icons at the top switch tabs, and clicking the lit one puts the panel
away again.
<Frame caption="Placeholder — screenshot: the side panel showing the Info tab beside a terminal">
<img src="/images/placeholder.svg" alt="The tty7 side panel" />
</Frame>
@@ -20,10 +23,16 @@ Everything tty7 knows about the focused pane, in one column:
| **Processes** | the process tree inside the pane, with the foreground process marked |
| **Ports** | every port those processes are listening on |
On a local pane, the working directory row has **Reveal in Finder** / **Open
Folder** beside it.
Hovering a row shows what it can do at the right-hand end of it: **Copy** on the
working directory, the SSH host and the branch, and **Reveal in Finder** / **Open
Folder** alongside it on a working directory this machine can see. The `+N M`
counts open the [diff overlay](/git/diffs), the same click the sidebar's counts
answer to — and the same **Settings → Window & Tabs → Open diff preview from
sidebar counts** turns both of them back into plain text.
The ports section is the quickest answer to "what is this pane serving, and
where" — the same data `tty7 procs` prints.
where" — the same data `tty7 procs` prints. A port row copies `localhost:PORT`,
and on a local pane opens it in your browser.
## Source Control
@@ -36,7 +45,8 @@ commit, and push without leaving the window.
## Files
A file tree rooted at the pane's working directory, with git status decorations
on every row and a search box at the top.
on every row and a search box at the top. The search box carries a clear button
while there is something in it.
- **Click a file** to open it in the built-in editor.
- **Drag a file out** to Finder or Explorer to copy it there.
+1
View File
@@ -962,6 +962,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::PanelAgentDone => "done",
L10nKey::PanelRevealInFinder => "Reveal in Finder",
L10nKey::PanelOpenFolder => "Open Folder",
L10nKey::PanelOpenInBrowser => "Open in Browser",
L10nKey::ScmGroupMerge => "Merge Changes",
L10nKey::ScmGroupStaged => "Staged Changes",
L10nKey::ScmGroupChanges => "Changes",
+1
View File
@@ -1013,6 +1013,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelAgentDone => "完了",
L10nKey::PanelRevealInFinder => "Finder で表示",
L10nKey::PanelOpenFolder => "フォルダを開く",
L10nKey::PanelOpenInBrowser => "ブラウザで開く",
L10nKey::ScmGroupMerge => "マージの競合",
L10nKey::ScmGroupStaged => "ステージされた変更",
L10nKey::ScmGroupChanges => "変更",
+2
View File
@@ -738,6 +738,7 @@ l10n_keys! {
PanelAgentDone,
PanelRevealInFinder,
PanelOpenFolder,
PanelOpenInBrowser,
ScmGroupMerge,
ScmGroupStaged,
ScmGroupChanges,
@@ -1613,6 +1614,7 @@ mod tests {
L10nKey::PanelAgentDone,
L10nKey::PanelRevealInFinder,
L10nKey::PanelOpenFolder,
L10nKey::PanelOpenInBrowser,
L10nKey::WindowStop,
L10nKey::WindowDelete,
L10nKey::WindowThisWorkspace,
+1
View File
@@ -913,6 +913,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelAgentDone => "已完成",
L10nKey::PanelRevealInFinder => "在 Finder 中显示",
L10nKey::PanelOpenFolder => "打开文件夹",
L10nKey::PanelOpenInBrowser => "在浏览器中打开",
L10nKey::ScmGroupMerge => "合并冲突",
L10nKey::ScmGroupStaged => "暂存的更改",
L10nKey::ScmGroupChanges => "更改",
+636 -122
View File
@@ -9,7 +9,7 @@ use std::path::PathBuf;
use crate::core::config::{Config, RightPanelTab};
use crate::daemon::protocol::PaneProcs;
use crate::ui::app::{
CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset,
CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App, tile_trailing_inset,
tile_trailing_inset_sm,
};
use crate::ui::i18n::{L10nKey, t};
@@ -58,6 +58,54 @@ const HEADING: f32 = 11. * STEP;
// superseded by the interface font scale's rems tokens; the Source Control
// panel still names its own px steps locally until it moves onto that scale.)
/// 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
@@ -84,6 +132,78 @@ pub(crate) struct RightPanelState {
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.
///
@@ -93,11 +213,7 @@ const PROCS_POLL: std::time::Duration = std::time::Duration::from_millis(2000);
/// 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: &[(&'static str, String)],
window: &mut Window,
cx: &gpui::App,
) -> gpui::Pixels {
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
@@ -115,11 +231,12 @@ fn info_label_column(
};
let widest = rows
.iter()
.map(|(k, _)| {
.map(|row| {
let k = row.label;
window
.text_system()
.shape_line(
gpui::SharedString::from(*k),
gpui::SharedString::from(k),
px(label_px),
&[gpui::TextRun {
len: k.len(),
@@ -418,7 +535,12 @@ impl Tty7App {
div()
.flex_1()
.min_w_0()
.child(Input::new(input).appearance(false).xsmall()),
// 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()
}
@@ -468,28 +590,50 @@ impl Tty7App {
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<(&'static str, String)> = Vec::new();
let mut cwd_for_actions: Option<(PathBuf, bool)> = None;
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;
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() {
rows.push((t(L10nKey::PanelCwd), compact_path(&cwd)));
// Copy Path is right either way; Reveal only means anything
// when the path is on the machine the file manager can see.
cwd_for_actions = Some((cwd, view.local_cwd().is_some()));
rows.push(InfoRow {
label: t(L10nKey::PanelCwd),
value: InfoValue::Path(compact_path(&cwd)),
// 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((t(L10nKey::PanelShell), shell));
rows.push(InfoRow::text(t(L10nKey::PanelShell), shell));
if let Some(ssh) = view.ssh_spec() {
rows.push((t(L10nKey::PanelSsh), ssh.host.clone()));
rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable());
}
let connected_ssh = view
.remote_context()
@@ -501,21 +645,47 @@ impl Tty7App {
if connected_ssh || view.workspace().is_some() {
forwards_pane = Some(view.pane_id);
}
git = view.git_status(cx);
}
if let Some(git) = tab.git_status(Some(window), cx) {
rows.push((t(L10nKey::PanelBranch), git.branch.clone()));
rows.push((
t(L10nKey::PanelChangesRow),
format!("+{} {}", git.added, git.removed),
));
// 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 let Some(agent) = tab.agent(cx) {
let name = agent.display_name();
let status = match tab.agent_status(cx) {
Some(s) => format!("{name} · {}", agent_status_label(s)),
None => name.to_string(),
};
rows.push((t(L10nKey::PanelAgent), status));
let status = tab.agent_status(cx);
rows.push(InfoRow {
label: t(L10nKey::PanelAgent),
value: InfoValue::Agent {
text: match status {
Some(s) => format!("{name} · {}", agent_status_label(s)),
None => name.to_string(),
},
dot: status.and_then(|s| s.dot_rgb()),
hollow: status == Some(crate::core::cli_agent::AgentStatus::Waiting),
},
copy: None,
reveal: None,
});
}
}
@@ -533,111 +703,257 @@ impl Tty7App {
let route = forwards_pane.map(|id| self.forward_route(id, cx));
self.sync_procs(pane_id, route, cx);
let mono = cx.theme().mono_font_family.clone();
let cwd_label = t(L10nKey::PanelCwd);
let label_w = info_label_column(&rows, window, cx);
let mut list = v_flex().px(px(CONTENT_INSET)).py(px(2.)).gap(px(3.));
for (k, v) in rows {
list = list.child(
h_flex()
.items_baseline()
.gap(px(9.))
.py(px(1.))
.text_size(rems(TEXT))
.child(
div()
.flex_none()
.w(label_w)
.whitespace_nowrap()
.text_color(cx.theme().muted_foreground)
.child(k),
)
.child(match k == cwd_label {
// 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.
true => {
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()
}
false => 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(),
}),
);
// 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)
.when_some(cwd_for_actions, |this, (cwd, local)| {
this.child(self.cwd_actions(cwd, local, cx))
})
.children(self.procs_section(pane_id, cx))
.children(self.ports_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)
}
fn cwd_actions(&self, cwd: PathBuf, local: bool, cx: &mut Context<Self>) -> AnyElement {
let reveal_label = reveal_label();
h_flex()
.gap(px(2.))
.px(px(tile_trailing_inset_sm()))
.pt(px(6.))
.when(local, |this| {
this.child(
crate::ui::tab_strip::chrome_tile_sized(
Button::new("panel-info-reveal").icon(Icon::new(IconName::FolderOpen)),
TILE_SIZE_SM,
TILE_GLYPH_SM,
false,
cx,
)
.rounded_md()
.tooltip(reveal_label)
.on_click({
let cwd = cwd.clone();
move |_, _window, cx| cx.reveal_path(&cwd)
}),
/// 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(
crate::ui::tab_strip::chrome_tile_sized(
Button::new("panel-info-copy-path").icon(Icon::new(IconName::Copy)),
TILE_SIZE_SM,
TILE_GLYPH_SM,
false,
.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,
)
.rounded_md()
.tooltip(t(L10nKey::FileTreeContextCopyPath))
.on_click(move |_, _window, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(
cwd.display().to_string(),
));
}),
.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,
@@ -698,11 +1014,16 @@ impl Tty7App {
.pl(px(f32::from(p.depth) * 10.))
.text_size(rems(TEXT_MONO))
.font_family(mono.clone())
.text_color(if p.foreground {
cx.theme().foreground
} else {
cx.theme().muted_foreground
// 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(
@@ -721,18 +1042,82 @@ impl Tty7App {
)
}
fn ports_section(&self, pane_id: Option<u64>, cx: &mut Context<Self>) -> Option<AnyElement> {
/// 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)).py(px(1.)).gap(px(2.));
for p in ports {
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,
@@ -744,11 +1129,16 @@ impl Tty7App {
.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(
@@ -916,6 +1306,50 @@ pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedStri
.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
@@ -981,7 +1415,87 @@ fn compact_path(path: &std::path::Path) -> String {
#[cfg(test)]
mod tests {
use super::split_path_leaf;
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() {
+5 -8
View File
@@ -39,7 +39,7 @@ use tty7_core::core::git::status::DecoStatus;
use crate::terminal::git_diff::DiffSource;
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::i18n::{L10nKey, t, t_plural};
use crate::ui::right_panel::{git_badge, info_chip};
use crate::ui::right_panel::{ROW_INSET, git_badge, info_chip};
use crate::ui::scm::path::{relative_time, split_display_path};
use crate::ui::scm::state::{CommitDetailView, RepoKey};
use crate::ui::scm::status::{status_color, status_glyph};
@@ -50,14 +50,11 @@ use crate::ui::scm::status::{status_color, status_glyph};
/// Both numbers are a 12px row's. gpui leads a plain `div` at phi, so the row's
/// mono name occupies `round(12 × 1.618) = 19px`, and 24 gives that line 2.5px
/// of air on each side — dense, which is what a 260px column of paths wants.
/// The text lands on `CONTENT_INSET` whatever `ROW_INSET` is, since the list
/// subtracts it outside the row and the row adds it back inside.
///
/// Both have to equal `panel.rs`'s pair. That file carries the same two
/// constants for the same reason, and a reader who opens a commit must not
/// feel the pitch change under them.
/// The text lands on `CONTENT_INSET` whatever [`ROW_INSET`] is, since the list
/// subtracts it outside the row and the row adds it back inside — which is why
/// the inset itself is the panel-wide constant rather than a third copy here: a
/// reader who opens a commit must not feel the pitch change under them.
const ROW_H: f32 = 24.;
const ROW_INSET: f32 = 4.;
/// How much of the body is shown before it folds. Four lines is a paragraph;
/// past that it is a changelog, and the file list is what the reader came for.
+1 -47
View File
@@ -29,7 +29,7 @@ use crate::terminal::git_diff::DiffSource;
use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App};
use crate::ui::host_ops::{HostId, SharedHost};
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::right_panel::{SEARCH_H, git_badge, info_chip};
use crate::ui::right_panel::{ROW_INSET, SEARCH_H, action_strip, git_badge, info_chip};
use crate::ui::rounding::{CARD_RADIUS, HAIRLINE, RoundedCorners as _, segment_corners};
use crate::ui::scm::ScmIntent;
use crate::ui::scm::path::{elide_middle, split_display_path};
@@ -53,15 +53,6 @@ const ROW_H: f32 = 24.;
/// different numbers.
const BADGE_W: f32 = 14.;
/// 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 — the 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. `scm/detail.rs` carries the
/// same pair for the same reason.
const ROW_INSET: f32 = 4.;
/// The key context the message box installs, and the one `ScmCommit` is
/// bound inside. The two are the same string on purpose: a binding whose
/// context nothing attaches is a binding that never fires.
@@ -1651,43 +1642,6 @@ impl Tty7App {
}
}
/// 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 directory
/// 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.
fn action_strip(row: &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())
}
/// Which sections an entry shows up in.
///
/// A file can be staged and unstaged at once (`XY == "MM"`), and then it
+8 -1
View File
@@ -1337,7 +1337,14 @@ fn group_names(roots: &[&PathBuf]) -> Vec<String> {
}
}
fn diff_click_cwd<T>(cfg: &Config, target: Option<T>) -> Option<T> {
/// Whether a `+N M` is a button, and what it opens if it is.
///
/// One function because the setting is one setting: the sidebar's counts and
/// the Info panel's `changes` row are the same number about the same working
/// tree, and "Open diff preview from sidebar counts" turning one of them into
/// plain text while the other stayed clickable would be a setting that half
/// works.
pub(crate) fn diff_click_cwd<T>(cfg: &Config, target: Option<T>) -> Option<T> {
cfg.sidebar_diff_preview.then_some(target).flatten()
}
+33 -3
View File
@@ -740,8 +740,18 @@ impl Tty7App {
"ToggleRightPanel",
cx,
))
.on_click(cx.listener(|this, _, _window, cx| {
// On macOS this tile is drawn inside the panel's own
// titlebar while the panel is open, so closing from it
// destroys the element holding the focus — and a keymap
// scoped to a focused thing goes quiet with it, leaving the
// ⌘J that would undo this doing nothing. Hand the terminal
// back what it lost, the same way the tab tiles below do.
.on_click(cx.listener(|this, _, window, cx| {
let closing = this.right_panel_open(cx);
this.toggle_right_panel(cx);
if closing {
this.focus_active(window, cx);
}
})),
),
)
@@ -796,8 +806,28 @@ impl Tty7App {
}
_ => SharedString::from(t(label_key)),
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.set_right_panel_tab(tab, cx);
// A tile for another tab switches to it; the lit one puts
// the panel away, the way an activity bar behaves
// everywhere else. Pressing it used to do nothing at all
// — a dead click on the one control in the row that looks
// like it should undo itself. (These tiles only exist
// while the panel is open, so `ToggleRightPanel` and the
// chrome tile beside them are still what brings it back.)
.on_click(cx.listener(move |this, _, window, cx| {
match this.right_panel_open(cx) && this.right_panel_tab == tab {
true => {
this.toggle_right_panel(cx);
// These tiles live inside the panel, so
// closing from one destroys the element that
// holds the focus and leaves it nowhere —
// and a keymap whose bindings are scoped to a
// focused thing goes quiet with it, so the
// ⌘J that would undo this did nothing at all.
// Hand the terminal back what it lost.
this.focus_active(window, cx);
}
false => this.set_right_panel_tab(tab, cx),
}
})),
)
.into_any_element()