Merge origin/main into fix/prompt-handover-drops-the-line-433

Catches the branch up with #782, #789, #792, #793 and #794. No conflicts:
none of them touch the two files this branch changes, and the delta against
main is still `src/terminal/typeahead.rs` and `src/terminal/view.rs` alone.
This commit is contained in:
l0ng-ai
2026-09-07 23:49:40 +08:00
19 changed files with 3134 additions and 160 deletions
+1
View File
@@ -17,6 +17,7 @@ it shows what *your* copy is bound to. This is the shipped default.
| Go to Tab 19 | <kbd>⌘ 1</kbd>…<kbd>⌘ 9</kbd> | <kbd>Alt 1</kbd>…<kbd>Alt 9</kbd> |
| New Workspace | <kbd>⌘ ⇧ N</kbd> | <kbd>Ctrl ⇧ N</kbd> |
| Switch Workspace | <kbd>⌘ ⇧ O</kbd> | <kbd>Ctrl ⇧ O</kbd> |
| New Window | <kbd>⌘ N</kbd> | — |
## Panes
+1
View File
@@ -18,6 +18,7 @@ actions!(
SelectWorkspace7,
SelectWorkspace8,
SelectWorkspace9,
NewWindow,
CloseWindow,
CloseActiveTab,
RenameTab,
+622 -13
View File
@@ -136,11 +136,24 @@ impl PaneWorkspace {
RouteHeader::local_stdio(program.clone(), &argv)
}
(_, Some(spec)) => RouteHeader::ssh((**spec).clone()),
(target, None) => {
return Err(anyhow::anyhow!(
"this workspace has no SSH connection details ({target:?}), so its panes \
cannot be routed"
));
(_, None) => {
// Deliberately not the target: a `Profile` spells itself as
// its config UUID in `Display` and in `Debug` alike, and a
// deleted profile is exactly what empties `spec` here. This
// sentence is not only logged — `land_pane` hands it to the
// pending pane, which prints the reason verbatim under
// "could not reach {machine}", so the UUID reached the screen
// (#485). The workspace's own name is what every other
// surface calls this thing.
return Err(match self.label.as_deref() {
Some(label) => anyhow::anyhow!(
"{label} has no SSH connection details, so its panes cannot be routed"
),
None => anyhow::anyhow!(
"this workspace has no SSH connection details, so its panes \
cannot be routed"
),
});
}
};
Ok(header.for_pane())
@@ -705,7 +718,7 @@ impl RemoteTerminal {
/// here wins: it describes where the shell actually landed, which is not
/// always where we asked (a missing directory sends the daemon home, an
/// rc file may `cd` on its own).
fn seed_cwd(&self, cwd: Option<PathBuf>) {
pub(crate) fn seed_cwd(&self, cwd: Option<PathBuf>) {
let Some(cwd) = cwd else { return };
if let Ok(mut guard) = self.cwd.lock() {
guard.get_or_insert(cwd);
@@ -2859,17 +2872,557 @@ fn win_size(size: TermSize, cell_w: u16, cell_h: u16) -> WinSize {
}
}
/// What a re-attach's replay actually leaves in the grid.
///
/// Switching workspaces tears every pane down and attaches to the same daemon
/// pane again (#711), so the whole of a pane's screen has to survive one trip
/// through the replay — several ring segments, each preceded by the geometry it
/// was written at, and a snapshot frame far larger than one socket read. These
/// drive that over a real socket pair rather than a mock, because the loss
/// being hunted is between the wire and the grid; and they are not `cfg(unix)`
/// like their neighbours because nothing about the path is.
#[cfg(test)]
mod replay_tests {
use super::*;
use crate::daemon::protocol::DaemonMsg;
use crate::daemon::transport::Stream;
pub(super) fn socket_pair() -> (Stream, Stream) {
#[cfg(unix)]
{
std::os::unix::net::UnixStream::pair().unwrap()
}
#[cfg(windows)]
{
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let client_side = std::net::TcpStream::connect(addr).unwrap();
let (daemon_side, _) = listener.accept().unwrap();
(client_side, daemon_side)
}
}
fn ws(cols: u16, rows: u16) -> WinSize {
WinSize {
cols,
rows,
cell_w: 8,
cell_h: 17,
}
}
/// Everything the grid holds, scrollback included, one row per line.
fn all_text(term: &RemoteTerminal) -> String {
use alacritty_terminal::grid::Dimensions as _;
use alacritty_terminal::index::{Column, Line};
let t = term.term.lock();
let grid = t.grid();
let mut out = String::new();
let top = -(grid.history_size() as i32);
for line in top..grid.screen_lines() as i32 {
let row = &grid[Line(line)];
let mut text = String::new();
for col in 0..grid.columns() {
text.push(row[Column(col)].c);
}
let text = text.trim_end();
if !text.is_empty() {
out.push_str(text);
out.push('\n');
}
}
out
}
/// The visible screen alone.
fn screen_text(term: &RemoteTerminal) -> String {
use alacritty_terminal::grid::Dimensions as _;
use alacritty_terminal::index::{Column, Line};
let t = term.term.lock();
let grid = t.grid();
let mut out = String::new();
for line in 0..grid.screen_lines() as i32 {
let row = &grid[Line(line)];
let mut text = String::new();
for col in 0..grid.columns() {
text.push(row[Column(col)].c);
}
let text = text.trim_end();
if !text.is_empty() {
out.push_str(text);
out.push('\n');
}
}
out
}
/// The grid's width. A grid is born at the size its `RemoteTerminal` was
/// built with and only ever leaves it on a `DaemonMsg::Size`, so this is
/// what says which geometry frames were applied.
fn columns(term: &RemoteTerminal) -> usize {
use alacritty_terminal::grid::Dimensions as _;
term.term.lock().grid().columns()
}
/// Waits for the reader thread to have applied everything named, then
/// returns the whole grid either way so a failure can print what landed.
fn settled(term: &RemoteTerminal, needles: &[&str]) -> String {
for _ in 0..600 {
let text = all_text(term);
if needles.iter().all(|n| text.contains(n)) {
return text;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
all_text(term)
}
/// The shape a re-attach takes: the client's grid is born at the hardcoded
/// attach size, and the daemon replays every ring segment at the geometry
/// it was recorded at.
#[test]
fn every_replayed_segment_reaches_the_grid() {
crate::core::config::pin_test_config_dir();
let (client_side, mut daemon) = socket_pair();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Size(ws(80, 24)).encode(&mut daemon).unwrap();
DaemonMsg::Snapshot(b"BIRTH-BANNER\r\n".to_vec())
.encode(&mut daemon)
.unwrap();
DaemonMsg::Size(ws(120, 40)).encode(&mut daemon).unwrap();
DaemonMsg::Snapshot(b"SECOND-SEGMENT\r\n".to_vec())
.encode(&mut daemon)
.unwrap();
DaemonMsg::Size(ws(120, 40)).encode(&mut daemon).unwrap();
DaemonMsg::Snapshot(b"THIRD-SEGMENT\r\n".to_vec())
.encode(&mut daemon)
.unwrap();
let text = settled(&term, &["BIRTH-BANNER", "SECOND-SEGMENT", "THIRD-SEGMENT"]);
assert!(text.contains("BIRTH-BANNER"), "grid held:\n{text}");
assert!(text.contains("SECOND-SEGMENT"), "grid held:\n{text}");
assert!(text.contains("THIRD-SEGMENT"), "grid held:\n{text}");
// Each segment's geometry too, or this would pass on a replay that
// dropped every `Size`. The unix-gated
// `segmented_ring_replay_reproduces_live_rendering` asserts that half
// already; nothing did on the platforms this module exists for.
assert_eq!(
columns(&term),
120,
"the geometry each segment was recorded at never reached the grid, \
so it is still at the attach size"
);
drop(daemon);
}
/// A real pane's ring is megabytes; one `Snapshot` frame is far larger than
/// the reader's 256 KiB read buffer, so the frame is assembled across many
/// reads — several of which time out at `QUIT_POLL` while the sender is
/// still pushing.
#[test]
fn a_snapshot_larger_than_one_read_still_lands_whole() {
crate::core::config::pin_test_config_dir();
let (client_side, daemon) = socket_pair();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let mut bulk = Vec::new();
bulk.extend_from_slice(b"HEAD-OF-THE-RING\r\n");
for i in 0..40_000 {
bulk.extend_from_slice(format!("line {i} of the pane's history\r\n").as_bytes());
}
bulk.extend_from_slice(b"TAIL-OF-THE-RING\r\n");
let feeder = std::thread::spawn(move || {
let mut daemon = daemon;
DaemonMsg::Size(ws(120, 40)).encode(&mut daemon).unwrap();
DaemonMsg::Snapshot(bulk).encode(&mut daemon).unwrap();
DaemonMsg::Size(ws(120, 40)).encode(&mut daemon).unwrap();
DaemonMsg::Snapshot(b"AFTER-THE-BULK\r\n".to_vec())
.encode(&mut daemon)
.unwrap();
daemon
});
let text = settled(&term, &["AFTER-THE-BULK"]);
assert!(
text.contains("AFTER-THE-BULK"),
"the frame after a multi-megabyte snapshot never arrived; grid tail:\n{}",
screen_text(&term)
);
assert!(
text.contains("TAIL-OF-THE-RING"),
"the snapshot itself was truncated"
);
drop(feeder.join().unwrap());
}
/// The view resizes to its real geometry the first time it paints, which
/// happens while the replay is still arriving. Against a resize-echoing
/// daemon the grid must not reflow when the request goes out — only when
/// the daemon echoes the `Size` back, which is the stream position where
/// the bytes stop being old-width — and neither step may cost the
/// replayed screen.
///
/// The two geometries are deliberately different: at identical dimensions
/// a reflow and a deferred reflow look the same, so the invariant would be
/// unobservable.
#[test]
fn a_resize_racing_the_replay_keeps_the_replayed_screen() {
crate::core::config::pin_test_config_dir();
let (client_side, mut daemon) = socket_pair();
let mut term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
term.route = echoing_route();
DaemonMsg::Size(ws(120, 40)).encode(&mut daemon).unwrap();
DaemonMsg::Snapshot(b"REPLAYED-SCREEN\r\n".to_vec())
.encode(&mut daemon)
.unwrap();
let text = settled(&term, &["REPLAYED-SCREEN"]);
assert!(text.contains("REPLAYED-SCREEN"), "grid held:\n{text}");
assert_eq!(columns(&term), 120, "the replay set the grid's geometry");
// First paint: the pane is 100x30 on screen, not the 120x40 the ring
// was recorded at. The request goes down the link and the grid stays
// where the replay left it.
term.resize(TermSize::new(100, 30), 8, 17);
assert_eq!(
columns(&term),
120,
"the grid reflowed at request time instead of waiting for the echo"
);
let _ = daemon.set_read_timeout(Some(std::time::Duration::from_secs(5)));
match ClientMsg::read(&mut daemon) {
Ok(ClientMsg::Resize(size)) => assert_eq!((size.cols, size.rows), (100, 30)),
other => panic!("the resize never reached the daemon: {other:?}"),
}
// The echo is the stream position the reflow belongs at.
DaemonMsg::Size(ws(100, 30)).encode(&mut daemon).unwrap();
for _ in 0..600 {
if columns(&term) == 100 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let text = all_text(&term);
assert_eq!(columns(&term), 100, "the echo never reflowed the grid");
assert!(
text.contains("REPLAYED-SCREEN"),
"the first paint's resize cost the replay; grid held:\n{text}"
);
drop(daemon);
}
/// The attach handshake reads off the same socket the reader will, far
/// enough to tell an `Error` frame from a replay, and hands what it read
/// on as the reader's starting buffer. A whole replay can already be
/// sitting in the socket when it looks, so the prefix it takes is several
/// frames wide and the split lands mid-frame — every byte of it has to
/// reach the grid.
#[test]
fn the_attach_handshakes_prefix_carries_the_replay_it_swallowed() {
crate::core::config::pin_test_config_dir();
let (mut client_side, mut daemon) = socket_pair();
// The whole replay before the client looks: this is the daemon that
// answered instantly, which is the daemon a local attach meets.
DaemonMsg::Size(ws(80, 24)).encode(&mut daemon).unwrap();
DaemonMsg::Snapshot(b"BIRTH-BANNER\r\n".to_vec())
.encode(&mut daemon)
.unwrap();
DaemonMsg::Size(ws(120, 40)).encode(&mut daemon).unwrap();
let mut bulk = Vec::new();
for i in 0..400 {
bulk.extend_from_slice(format!("scrollback row {i}\r\n").as_bytes());
}
bulk.extend_from_slice(b"LAST-ROW-OF-THE-RING\r\n");
DaemonMsg::Snapshot(bulk).encode(&mut daemon).unwrap();
let buffered =
attach_reply_prefix(&mut client_side, 1, std::time::Duration::from_secs(2)).unwrap();
assert!(
!buffered.is_empty(),
"the handshake read nothing, so it proves nothing"
);
let term =
RemoteTerminal::from_stream_with(client_side, TermSize::new(80, 24), buffered).unwrap();
let text = settled(&term, &["BIRTH-BANNER", "LAST-ROW-OF-THE-RING"]);
assert!(text.contains("BIRTH-BANNER"), "grid held:\n{text}");
assert!(
text.contains("LAST-ROW-OF-THE-RING"),
"the replay the handshake swallowed never reached the grid"
);
drop(daemon);
}
// ---- The switch, end to end, with a real daemon pane ----------------
//
// Everything above drives the client half against a scripted daemon. This
// drives the real one: a live `DaemonPane` over a real pty, in this
// process, with its `DaemonMsg` stream forwarded onto a socket pair the
// way `spawn_writer` forwards it, so an attach here is the same attach a
// re-attach makes. It is the switch (#711) minus gpui: attach, resize to
// the geometry the window actually has, produce output, drop the client,
// produce more, attach again — and read the grid the second client ends up
// with.
/// A client hung off `pane`, the way `stream_pane_with_attach` hangs one
/// off it: subscribe, forward every queued message onto the wire, and read
/// the client's own frames back the way `run_stream` does — epoch guard
/// included, so a displaced client's resize is dropped here exactly as the
/// daemon drops it.
///
/// The route is a resize-echoing one, which is what the local daemon is:
/// the grid must not reflow until the `Size` the daemon echoes back.
fn attach_client(
pane: &std::sync::Arc<tty7_core::daemon::pane::DaemonPane>,
) -> (u64, RemoteTerminal, std::thread::JoinHandle<()>) {
let (client_side, daemon_side) = socket_pair();
let mut daemon_write = daemon_side.try_clone().expect("clone the daemon half");
let (tx, rx) = std::sync::mpsc::channel::<DaemonMsg>();
let epoch = pane.attach(tx);
let gate = pane.gate();
let forward = std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
let drained = match &msg {
DaemonMsg::Output(b) | DaemonMsg::Image(b) => b.len(),
_ => 0,
};
let ok = msg.encode(&mut daemon_write).is_ok();
if drained > 0 {
gate.sub(drained);
}
if !ok {
break;
}
}
});
{
let pane = pane.clone();
let mut daemon_read = daemon_side;
std::thread::spawn(move || {
use std::io::Read as _;
let mut pending: Vec<u8> = Vec::new();
let mut chunk = [0u8; 65536];
loop {
while let Ok(Some((kind, payload))) =
crate::daemon::protocol::take_frame(&mut pending)
{
match ClientMsg::from_frame(kind, payload) {
Ok(ClientMsg::Input(bytes)) if pane.controls(epoch) => {
pane.write_input(&bytes)
}
Ok(ClientMsg::Resize(size)) if pane.controls(epoch) => {
pane.resize(size)
}
Ok(_) => {}
Err(_) => return,
}
}
match daemon_read.read(&mut chunk) {
Ok(0) | Err(_) => return,
Ok(n) => pending.extend_from_slice(&chunk[..n]),
}
}
});
}
let mut term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24))
.expect("a client over the pair");
term.route = echoing_route();
(epoch, term, forward)
}
/// A route whose daemon echoes `Size` when it applies a resize — which is
/// what `PaneRoute::Local` is against a current daemon, and what the
/// harness above implements.
///
/// Built the way the neighbouring resize tests build one, rather than
/// through a `PaneWorkspace`: routing a workspace is not what any of these
/// cover, and going that way would have a change to `NativeSshSpec`'s
/// serde shape fail them on an `unwrap` that has nothing to do with
/// replay.
fn echoing_route() -> PaneRoute {
PaneRoute::Remote {
header: Box::new(crate::daemon::router::RouteHeader::wsl("Ubuntu-22.04")),
resize_echo: true,
}
}
fn wait_for(term: &RemoteTerminal, needle: &str) -> bool {
for _ in 0..600 {
if all_text(term).contains(needle) {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
false
}
/// Switching workspaces drops every pane and attaches to the same daemon
/// panes again. Whatever the pane put on screen while the window was
/// elsewhere — and whatever it had already — has to come back with it.
#[test]
fn a_pane_re_attached_after_a_switch_gets_its_screen_back() {
crate::core::config::pin_test_config_dir();
let pane = tty7_core::daemon::pane::DaemonPane::spawn(
7711,
std::env::current_dir().ok(),
ws(80, 24),
None,
None,
None,
None,
false,
|| {},
)
.expect("a pty-backed pane");
// The window this pane is shown in, attaching for the first time.
let (epoch, mut first, forward) = attach_client(&pane);
// What the first paint does, from the same side it does it on: the
// pane is not 80x24 on screen, so the real geometry goes down the link
// and the grid waits for the daemon to echo it back.
first.resize(TermSize::new(120, 40), 8, 17);
pane.write_input(b"echo BEFORE-THE-SWITCH\r");
assert!(
wait_for(&first, "BEFORE-THE-SWITCH"),
"the pane never echoed the first command; grid held:\n{}",
all_text(&first)
);
// Switching away: the view is dropped, which closes the link, and the
// daemon gives up the seat.
drop(first);
pane.detach(epoch);
drop(forward);
// The pane keeps working while the window is showing another workspace.
pane.write_input(b"echo DURING-THE-SWITCH\r");
std::thread::sleep(std::time::Duration::from_millis(600));
// Switching back: a brand new view, attaching at the hardcoded size.
//
// It deliberately never resizes. Resizing is the workaround #711's
// reporter found — it makes the daemon `TIOCSWINSZ` the pty and the
// *child* repaint, which would put the screen back whether or not the
// replay ever arrived, and would also feed the grid a `Size` echo that
// `resize_state` sends unconditionally. Everything asserted below has
// to come from the replay itself.
let (_epoch, second, forward) = attach_client(&pane);
let landed = wait_for(&second, "DURING-THE-SWITCH");
let text = all_text(&second);
let width = columns(&second);
pane.kill();
drop(forward);
assert!(
landed,
"the re-attached pane never got the output produced while it was hidden; \
grid held:\n{text}"
);
assert!(
text.contains("BEFORE-THE-SWITCH"),
"the re-attached pane lost the screen it had before the switch; grid held:\n{text}"
);
// Nothing resized this client, so 120 can only have come from the
// `Size` frame the replay sends ahead of the segment recorded at it.
assert_eq!(
width, 120,
"the replay's geometry never reached the grid, so it is still at the attach size"
);
}
/// A switch can rebuild a window twice — a second hydration lands while the
/// first one's panes are still draining their replay — so the same pane is
/// attached to twice in quick succession and the window keeps the later
/// view. That view must hold the screen, and it must still be the one the
/// daemon obeys: the displaced attach's teardown must not take the seat
/// with it.
///
/// The needle is weaker here than in the switch test above, and knowingly
/// so: the pane is attached to throughout, and a ConPTY that owns the whole
/// viewport reprints what is on screen as ordinary live output, so
/// `RACED-OUTPUT` can reach the second view without the replay. What only
/// the replay can supply is the geometry — nothing resizes this client —
/// and what only the seat can supply is `AFTER-THE-RACE`. Those two are the
/// assertions that discriminate.
#[test]
fn the_later_of_two_racing_attaches_keeps_the_screen_and_the_seat() {
crate::core::config::pin_test_config_dir();
let pane = tty7_core::daemon::pane::DaemonPane::spawn(
7712,
std::env::current_dir().ok(),
ws(80, 24),
None,
None,
None,
None,
false,
|| {},
)
.expect("a pty-backed pane");
let (first_epoch, mut first, first_forward) = attach_client(&pane);
first.resize(TermSize::new(120, 40), 8, 17);
pane.write_input(b"echo RACED-OUTPUT\r");
assert!(wait_for(&first, "RACED-OUTPUT"), "the pane never echoed");
// The second rebuild attaches before the first one's view is dropped.
let (second_epoch, second, second_forward) = attach_client(&pane);
drop(first);
drop(first_forward);
// The displaced connection detaches on its way out, epoch-guarded.
pane.detach(first_epoch);
// Never resized, for the reason the switch test is not: a resize would
// repaint the pane from the child and echo a `Size` back, and both are
// exactly what must not be allowed to stand in for the replay.
let landed = wait_for(&second, "RACED-OUTPUT");
let width = columns(&second);
// Two halves of "the seat survived", and only one of them can catch a
// detach that took it. `controls` answers from `subscriber_epoch`
// alone, and `DaemonPane::detach` clears `subscriber` without ever
// touching that counter — so it says the pane would obey this view's
// input and resizes, but it would keep saying so with the epoch guard
// stripped out of `detach`. `live` is what notices that: a detach that
// dropped the wrong subscriber silences the surviving view.
let controls = pane.controls(second_epoch);
pane.write_input(b"echo AFTER-THE-RACE\r");
let live = wait_for(&second, "AFTER-THE-RACE");
let text = all_text(&second);
pane.kill();
drop(second_forward);
assert!(
landed,
"the second attach's replay was lost; grid held:\n{text}"
);
assert_eq!(
width, 120,
"the second attach's replay carried no geometry, so the grid is still at the \
attach size"
);
assert!(
controls,
"the pane no longer answers to the surviving view, so nothing it \
types or resizes would reach the pty"
);
assert!(
live,
"the displaced attach's detach took the live seat: the surviving \
view stopped receiving output"
);
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let client_side = std::net::TcpStream::connect(addr).unwrap();
let (daemon_side, _) = listener.accept().unwrap();
(client_side, daemon_side)
}
use super::replay_tests::socket_pair as tcp_pair;
/// On Windows, `shutdown()` does not wake a thread parked in a blocking
/// `read` on the same socket (it does on unix). `detach_link`,
@@ -3073,6 +3626,62 @@ mod windows_tests {
}
}
/// Ungated on purpose: what a workspace can build a route out of is the same
/// on every platform, and so is the name the refusal carries.
#[cfg(test)]
mod route_header_tests {
use super::*;
use crate::core::session::{RemoteTarget, WorkspaceId};
fn unroutable(target: RemoteTarget, label: Option<&str>) -> PaneWorkspace {
PaneWorkspace {
workspace: WorkspaceId::new(),
target,
spec: None,
label: label.map(str::to_string),
resize_echo: false,
}
}
/// A deleted profile is what empties `spec`, and the refusal built here is
/// what the pending pane prints verbatim under "could not reach
/// {machine}" — so this is one of the screens #485 is about. It used to
/// carry `{target:?}`, which for a `Profile` is its config UUID and
/// nothing else.
#[test]
fn an_unroutable_workspace_is_not_named_by_its_profile_uuid() {
let id = uuid::Uuid::new_v4();
let gone = RemoteTarget::Profile { id };
let named = unroutable(gone.clone(), Some("lager"));
let e = named
.route_header()
.expect_err("no spec, no route")
.to_string();
assert!(
!e.contains(&id.to_string()),
"a bare profile UUID reached the UI: {e}"
);
assert!(
e.contains("lager"),
"the entry's own name is what it is called: {e}"
);
assert!(e.contains("cannot be routed"), "{e}");
// Nothing to call it by is still no reason to print the UUID.
let bare = unroutable(gone, None);
let e = bare
.route_header()
.expect_err("no spec, no route")
.to_string();
assert!(
!e.contains(&id.to_string()),
"a bare profile UUID reached the UI: {e}"
);
assert!(e.contains("cannot be routed"), "{e}");
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
+53
View File
@@ -163,6 +163,24 @@ pub struct NativeSshParts {
/// What a pane is called when nothing running in it has said otherwise.
pub(crate) const DEFAULT_TITLE: &str = "tty7";
/// What a pane is *saying* about itself, if anything — the reading behind
/// [`TerminalView::stated_title`], split out so it can be pinned without a
/// live pane.
///
/// Anything but the placeholder counts. That is wider than "arrived over OSC
/// 0/2" on purpose: an SSH pane answers to the host it dialled and a workspace
/// pane to its workspace's name, and those are names tty7 gave the pane
/// deliberately (#438) rather than the absence of one. The literal string
/// `tty7` is the only title that says nothing, because it is the app's own
/// name standing in for a pane that has never introduced itself.
pub(crate) fn stated_title(title: &str) -> Option<&str> {
match title.trim() {
"" => None,
t if t == DEFAULT_TITLE => None,
t => Some(t),
}
}
pub struct ShellParts {
terminal: RemoteTerminal,
pub(crate) pane_id: u64,
@@ -1563,6 +1581,16 @@ impl TerminalView {
self.terminal.foreground_cwd()
}
/// The title this pane is showing, or `None` while it is still answering
/// to the app's own name — see [`stated_title`]. The label ladder reads
/// this where the machine tree reads
/// [`PaneRecord::osc_title`](tty7_core::core::machine::PaneRecord::osc_title),
/// which is what lets the tab strip and the switcher name a tab the same
/// way.
pub(crate) fn stated_title(&self) -> Option<&str> {
stated_title(&self.title)
}
/// Sets how opaque the pane wants this terminal painted; the pane leaf
/// calls this every frame while rendering, and the terminal element
/// blends its colours toward the window background during paint (see
@@ -7403,6 +7431,31 @@ fn drag_scroll_step(overshoot: f32) -> i32 {
#[cfg(test)]
mod tests {
/// What the label ladder asks a pane: are you showing a name of your own,
/// or still standing under the app's? (#740)
#[test]
fn a_pane_states_a_title_whenever_it_is_not_the_placeholder() {
use super::stated_title;
// Nothing has spoken — this is the pane a directory stands in for.
assert_eq!(stated_title("tty7"), None);
assert_eq!(stated_title(" tty7 "), None);
assert_eq!(stated_title(" "), None);
// A title from the program running in it.
assert_eq!(stated_title("vim — main.rs"), Some("vim — main.rs"));
assert_eq!(stated_title(" user@host:~/repo "), Some("user@host:~/repo"));
// A default tty7 chose for the pane itself is a name, not the absence
// of one: an SSH pane answers to its host (#438) and a workspace pane
// to its workspace, and neither gives way to a directory.
assert_eq!(stated_title("prod-web"), Some("prod-web"));
// So does the state a finished pane is left showing.
assert_eq!(
stated_title("tty7 — process exited"),
Some("tty7 — process exited")
);
}
#[test]
fn an_unfocused_input_caret_is_always_a_steady_outline() {
use super::{InputCaretPaint, input_caret_paint};
+305 -1
View File
@@ -536,6 +536,67 @@ impl Tab {
(leaf.title.clone(), leaf.display_home(cx))
}
/// This tab as the shared label ladder reads it, together with what a `~`
/// in whatever it ends up named would mean.
///
/// [`TabView`](tty7_core::core::tab_view::TabView) is how a tab looks to
/// someone who is *not* the window showing it — the switcher listing
/// another window's workspace, `tty7 tab ls` on the far side of a socket.
/// Building one here from the live pane is what stops this window having a
/// second opinion: both sides then rank a given name, a title, an agent and
/// a directory through
/// [`TabView::label`](tty7_core::core::tab_view::TabView::label), so the
/// strip's answer to "which repo is this?" is the switcher's answer too.
///
/// Everything comes off the one leaf the tab names itself after, so the
/// title and the directory standing in for it can never describe different
/// panes (#580).
pub(crate) fn label_view(
&self,
window: Option<&Window>,
cx: &App,
) -> (
tty7_core::core::tab_view::TabView,
Option<std::path::PathBuf>,
) {
let name = self.name.clone();
let Some(leaf) = self.title_leaf(window, cx) else {
return (
tty7_core::core::tab_view::TabView {
id: self.tree_id.get(),
name,
title: String::new(),
osc_title: None,
cwd: None,
agent: None,
status: None,
live: false,
panes: 0,
},
None,
);
};
let leaf = leaf.read(cx);
let view = tty7_core::core::tab_view::TabView {
id: self.tree_id.get(),
name,
// The tree's `title` is the foreground process name — what it falls
// back on once a pane has said nothing about itself. A live pane's
// equivalent is the placeholder it answers to unprompted: any
// *other* default it was given (an SSH host, a workspace name) is a
// name tty7 chose for it deliberately, and `stated_title` hands
// those up as the title the pane is showing.
title: crate::terminal::view::DEFAULT_TITLE.to_string(),
osc_title: leaf.stated_title().map(str::to_string),
cwd: leaf.cwd().map(|p| p.display().to_string()),
agent: leaf.agent(),
status: leaf.agent_session().map(|s| s.status),
live: !leaf.terminal.exited,
panes: self.pane.terminals().len(),
};
(view, leaf.display_home(cx))
}
pub(crate) fn git_status(
&self,
window: Option<&Window>,
@@ -1447,6 +1508,22 @@ impl Tty7App {
crate::ui::windows::refresh_menu(cx);
}
/// Opens a second window, on a workspace of its own.
///
/// A window on *this* workspace is not the other reading of "new window";
/// it is a thing the app cannot hold. `WindowRegistry` is keyed by
/// workspace — `window_for`, `app_for`, `unregister` and `rebind` all
/// address a window by the workspace it shows — and `windows::open`
/// answers a workspace that already has a window by activating it. Asking
/// for the current one here would raise the window you are already in.
///
/// So this is the same call the switcher makes for "Open in New Window",
/// with no workspace named: a fresh one, which is also what a new window
/// holds everywhere else it is offered.
pub(crate) fn new_window(&self, cx: &mut App) {
crate::ui::windows::open(cx, None);
}
fn prepare_window_close(&self, cx: &mut App) {
let last_window = crate::ui::windows::WindowRegistry::count(cx) <= 1;
self.detach_workspace(cx);
@@ -4134,6 +4211,27 @@ impl Tty7App {
}
}
/// Whether tab `index` has a pane zoomed over hidden siblings — what the
/// chrome marks so the state is readable without toggling it (#752).
///
/// Zoom rides with its tab (#599): the active tab's lives in
/// `self.maximized`, every other tab's is parked in `Tab::zoomed`. Either
/// can name a pane that exited while nobody was looking, which is why the
/// answer is asked of the layout rather than of the handle alone.
pub(crate) fn tab_is_zoomed(&self, index: usize) -> bool {
let Some(tab) = self.tabs.get(index) else {
return false;
};
let zoom = match index == self.active {
true => self.maximized.as_ref(),
false => tab.zoomed.as_ref(),
};
zoom.is_some_and(|zoom| {
tab.pane
.zoom_hides_siblings(|slot| slot.entity_id() == zoom.entity_id())
})
}
fn toggle_maximize(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.maximized.is_some() {
self.maximized = None;
@@ -4901,6 +4999,7 @@ impl Tty7App {
match kind {
NewTab => self.new_tab(window, cx),
NewWorkspace => self.open_workspace_form(window, cx),
NewWindow => self.new_window(cx),
OpenWorkspacePicker => self.open_switcher(window, cx),
StopWorkspace => self.stop_workspace(self.workspace, window, cx),
DeleteWorkspace => self.delete_workspace(self.workspace, window, cx),
@@ -6669,10 +6768,15 @@ impl Tty7App {
}
fn assign_keybinding(&mut self, action: String, spec: String, cx: &mut Context<Self>) {
// Compared as chords, not as spellings: a recorded `secondary-}` and a
// config's `secondary-shift-]` are one keystroke written two ways, and
// only `same_chord` sees it. Compared as text, the displacement never
// fires and both bindings survive onto that keystroke, where which one
// wins is arbitrary (#750).
let displaced = crate::ui::keymap::effective_bindings(cx)
.into_iter()
.chain(crate::ui::keymap::extra_bindings(cx))
.find(|(a, k)| *k == spec && *a != action)
.find(|(a, k)| *a != action && crate::ui::keymap::same_chord(k, &spec))
.map(|(a, _)| a);
// A trailing "…" on an action name marks a command that opens
// something; it is not punctuation, and inside a sentence it reads as
@@ -7401,6 +7505,9 @@ impl Render for Tty7App {
.on_action(cx.listener(|this, _: &NewWorkspace, window, cx| {
this.open_workspace_form(window, cx);
}))
.on_action(cx.listener(|this, _: &NewWindow, _window, cx| {
this.new_window(cx);
}))
.on_action(
cx.listener(|this, _: &CloseWindow, window, cx| this.close_window(window, cx)),
)
@@ -10162,6 +10269,86 @@ mod zoom_gpui_tests {
);
});
}
/// The mark the chrome wears (#752) has to go out again by every road the
/// zoom itself leaves by, and it has to name the right tab while several
/// tabs are each holding one.
#[gpui::test]
fn the_zoom_mark_is_on_whichever_tabs_are_hiding_panes(cx: &mut TestAppContext) {
use crate::terminal::view::quiet_test_pane;
use crate::ui::pane::{Pane, PaneSlot};
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
app.update_in(&mut vcx, |app, window, cx| {
// A zoom only hides something where there is a sibling to hide, so
// tabs 0 and 1 get a second pane and tab 2 stays single.
let mut held = Vec::new();
for tab in 0..2 {
let (view, stream) = quiet_test_pane(90 + tab as u64, window, cx);
held.push(stream);
let first = app.tabs[tab].pane.first_leaf().expect("tab has a pane");
app.tabs[tab].pane = Pane::split_node(
gpui::Axis::Horizontal,
0.5,
Pane::leaf(first),
Pane::leaf(PaneSlot::Ready(view)),
);
}
for i in 0..app.tabs.len() {
assert!(!app.tab_is_zoomed(i), "nothing is zoomed yet");
}
// Zooming marks the tab it happened in, and only that one.
app.toggle_maximize(window, cx);
assert!(app.tab_is_zoomed(0), "the zoomed tab wears the mark");
assert!(!app.tab_is_zoomed(1));
assert!(!app.tab_is_zoomed(2));
// And un-zooming takes it away again.
app.toggle_maximize(window, cx);
assert!(!app.tab_is_zoomed(0), "un-zooming clears the mark");
// The mark rides with its tab across a switch (#599) — an inactive
// tab holding a zoom still wears it — and two tabs can wear one at
// the same time, each reading its own handle.
app.toggle_maximize(window, cx);
app.activate(1, window, cx);
assert!(app.tab_is_zoomed(0), "the parked zoom is still a zoom");
assert!(!app.tab_is_zoomed(1));
app.toggle_maximize(window, cx);
assert!(app.tab_is_zoomed(0) && app.tab_is_zoomed(1));
assert!(!app.tab_is_zoomed(2), "a single-pane tab hides nothing");
// A parked zoom naming a pane that has since left the tab is no
// zoom: it would not come back on a switch, so it is not marked.
let parked = app.tabs[0].zoomed.clone().expect("tab 0 parked a zoom");
let elsewhere = app.tabs[2]
.pane
.first_leaf()
.and_then(|slot| slot.terminal().cloned());
app.tabs[0].zoomed = elsewhere;
assert!(
!app.tab_is_zoomed(0),
"a zoom over a pane this tab does not hold is not marked"
);
app.tabs[0].zoomed = Some(parked.clone());
assert!(app.tab_is_zoomed(0));
// Nor is a zoom over the last pane standing: its siblings closed
// while the tab was away, and the tab now looks like — and draws
// as — an ordinary single pane.
app.tabs[0].pane = Pane::leaf(PaneSlot::Ready(parked));
assert!(
!app.tab_is_zoomed(0),
"a zoom that covers nothing stops being marked"
);
assert!(!app.tab_is_zoomed(9), "there is no tab 9 to mark");
drop(held);
});
}
}
// A test window has no daemon behind it — its socket path is under the pinned
@@ -10258,6 +10445,123 @@ mod managed_forward_gpui_tests {
}
}
#[cfg(test)]
mod new_window_action_tests {
use crate::core::actions::NewWindow;
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use crate::ui::windows::WindowRegistry;
use gpui::{AppContext as _, TestAppContext, VisualTestContext};
/// `NewWindow` has to open a window, not merely exist.
///
/// Everything else about the action is a table entry — the `actions!`
/// row, the keymap slot, the palette command — and every one of those can
/// be there while the action reaches nothing. This drives the real
/// dispatch path and then asks the registry, so the assertion is "a second
/// window is open, on a workspace of its own, and the first one is still
/// here": the same `windows::open` the switcher calls for "Open in New
/// Window", with no workspace named.
#[gpui::test]
fn dispatching_new_window_opens_a_second_window_beside_the_first(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
WindowRegistry::init(cx);
});
let window = cx.add_window(|window, cx| {
let app =
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.ok()
.expect("window root wraps a Tty7App")
})
.unwrap();
// Registered the way an opened window registers itself; without it the
// registry cannot tell the two windows apart afterwards.
let handle = window.into();
let weak = app.downgrade();
app.update(cx, |app, cx| {
WindowRegistry::register(cx, app.workspace, handle, weak);
});
let mut vcx = VisualTestContext::from_window(handle, cx);
vcx.background_executor.run_until_parked();
let first = app.update(&mut vcx, |app, _| app.workspace);
assert_eq!(
vcx.update(|_, cx| WindowRegistry::count(cx)),
1,
"the harness starts with exactly the one window"
);
vcx.dispatch_action(NewWindow);
vcx.background_executor.run_until_parked();
let open = vcx.update(|_, cx| WindowRegistry::open_windows(cx));
assert_eq!(
open.len(),
2,
"NewWindow has to reach windows::open; it opened {} window(s)",
open.len()
);
assert!(
open.iter().any(|(id, _)| *id == first),
"the window the action was fired from must survive it"
);
// The registry is keyed by workspace, so a second window on the
// current one is not a thing it could tell apart from the first.
assert!(
open.iter().any(|(id, _)| *id != first),
"the new window belongs on a workspace of its own"
);
}
/// The windowless state is the one `NewWindow` exists for.
///
/// `show_tray_icon` is on by default, so closing the last window retires
/// tty7 to the tray rather than quitting it: the process is alive, the
/// menu bar is still tty7's, and there is nothing on screen. A listener
/// that lives only on `Tty7App`'s render root reaches nothing there, and
/// the chord that means "give me a window" is the one chord that has to
/// answer. `App::dispatch_action` falls through to the global listeners
/// when no window is active, which is where `keymap::init` puts this one.
#[gpui::test]
fn new_window_answers_with_no_window_to_dispatch_it(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
WindowRegistry::init(cx);
assert_eq!(
WindowRegistry::count(cx),
0,
"the retired-to-tray state this covers has no window in it"
);
cx.dispatch_action(&NewWindow);
});
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(
WindowRegistry::count(cx),
1,
"NewWindow has to reach windows::open with no window to bubble through"
);
});
}
}
#[cfg(test)]
mod close_window_action_tests {
use crate::core::actions::CloseWindow;
+102 -11
View File
@@ -14,11 +14,13 @@
use std::collections::HashMap;
use gpui::SharedString;
use crate::core::config::DiffViewMode;
use crate::terminal::git_diff::{
AUTO_COLLAPSE_LINES, DiffSnapshot, FileDiff, FileStatus, MAX_RENDERED_FILES, Truncation,
};
use crate::ui::diff_rows::{SplitRow, UnifiedRow, split_hunk, unified_rows};
use crate::ui::diff_rows::{RowId, SplitRow, UnifiedRow, split_hunk, unified_rows};
/// Everything the file header row draws, lifted out of its [`FileDiff`].
///
@@ -39,6 +41,21 @@ pub(crate) struct FileHead {
pub(crate) expanded: bool,
}
/// Where a drawn line sits in the patch: which file's rows it belongs to, and
/// its place among them.
///
/// Carried on the row rather than read off its position in the list. The list
/// is spliced — collapsing a file above this one moves every index below it —
/// so a range keyed on list positions would go on naming the same slots while
/// the code under them changed.
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct RowAt {
/// One of these rides on every line of a patch, so the path is shared
/// rather than copied per row.
pub(crate) path: SharedString,
pub(crate) id: RowId,
}
#[derive(PartialEq, Eq)]
pub(crate) enum DiffRow {
/// The space that used to be the `gap_3` of a flex column.
@@ -54,8 +71,14 @@ pub(crate) enum DiffRow {
/// the hunk before.
leads: bool,
},
Split(SplitRow),
Unified(UnifiedRow),
Split {
row: SplitRow,
at: RowAt,
},
Unified {
row: UnifiedRow,
at: RowAt,
},
Truncated(Truncation),
MoreFiles {
rest: usize,
@@ -152,18 +175,29 @@ fn file_rows(index: usize, file: &FileDiff, expanded: bool, mode: DiffViewMode)
})];
if expanded && (!file.hunks.is_empty() || file.truncated.is_some()) {
let path = SharedString::from(file.path.clone());
for (h, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::HunkHeader {
text: hunk.header.clone(),
leads: h == 0,
});
let at = |row| RowAt {
path: path.clone(),
id: RowId { hunk: h, row },
};
match mode {
DiffViewMode::Split => {
rows.extend(split_hunk(&hunk.lines).into_iter().map(DiffRow::Split))
}
DiffViewMode::Unified => {
rows.extend(unified_rows(&hunk.lines).into_iter().map(DiffRow::Unified))
}
DiffViewMode::Split => rows.extend(
split_hunk(&hunk.lines)
.into_iter()
.enumerate()
.map(|(r, row)| DiffRow::Split { row, at: at(r) }),
),
DiffViewMode::Unified => rows.extend(
unified_rows(&hunk.lines)
.into_iter()
.enumerate()
.map(|(r, row)| DiffRow::Unified { row, at: at(r) }),
),
}
}
if let Some(reason) = file.truncated {
@@ -252,6 +286,63 @@ mod tests {
}
}
/// `(path, hunk, row)` for each line row, in list order.
fn line_coords(rows: &[DiffRow]) -> Vec<(String, usize, usize)> {
rows.iter()
.filter_map(|row| match row {
DiffRow::Split { at, .. } | DiffRow::Unified { at, .. } => {
Some((at.path.to_string(), at.id.hunk, at.id.row))
}
_ => None,
})
.collect()
}
/// A row says where it is in the *patch*, not where it is in the list.
/// Collapsing a file splices the rows below it up the list; a range keyed
/// on list positions would stay where it was and come away naming other
/// code.
#[test]
fn a_line_row_names_its_file_and_its_place_in_the_hunk() {
let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]);
let open = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
let coords = line_coords(&open);
assert_eq!(
coords,
vec![
("a.rs".to_string(), 0, 0),
("a.rs".to_string(), 0, 1),
("a.rs".to_string(), 0, 2),
("a.rs".to_string(), 0, 3),
("b.rs".to_string(), 0, 0),
("b.rs".to_string(), 0, 1),
("b.rs".to_string(), 0, 2),
("b.rs".to_string(), 0, 3),
]
);
let collapsed = HashMap::from([("a.rs".to_string(), false)]);
let after = build_rows(&snap, &collapsed, None, DiffViewMode::Unified, false);
assert_eq!(
line_coords(&after),
coords[4..].to_vec(),
"b.rs's lines moved up the list and kept the coordinates they had"
);
}
/// Every hunk restarts the row count, so a range that spans two of them is
/// read hunk by hunk rather than as one run.
#[test]
fn each_hunk_numbers_its_own_rows() {
let snap = snapshot(vec![file("a.rs", 2)]);
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Split, false);
let hunks: Vec<_> = line_coords(&rows)
.into_iter()
.map(|(_, hunk, row)| (hunk, row))
.collect();
assert_eq!(hunks, vec![(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]);
}
fn shape(rows: &[DiffRow]) -> Vec<&'static str> {
rows.iter()
.map(|row| match row {
@@ -259,8 +350,8 @@ mod tests {
DiffRow::Oversized => "oversized",
DiffRow::FileHeader(_) => "file",
DiffRow::HunkHeader { .. } => "hunk",
DiffRow::Split(_) => "split",
DiffRow::Unified(_) => "unified",
DiffRow::Split { .. } => "split",
DiffRow::Unified { .. } => "unified",
DiffRow::Truncated(_) => "truncated",
DiffRow::MoreFiles { .. } => "more-files",
DiffRow::UntrackedHeader { .. } => "untracked-header",
+674 -17
View File
@@ -4,11 +4,11 @@ use std::rc::Rc;
use std::sync::Arc;
use gpui::{
AnyElement, Background, FocusHandle, FontWeight, Hsla, KeyDownEvent, Pixels, SharedString,
Window, div, prelude::*, px,
AnyElement, Background, FocusHandle, FontWeight, Hsla, KeyDownEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, Pixels, SharedString, Window, div, prelude::*, px,
};
use gpui_component::button::Button;
use gpui_component::menu::ContextMenuExt as _;
use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use crate::core::config::{Config, DiffViewMode};
@@ -23,8 +23,8 @@ use crate::terminal::git_diff::{
/// line budget below cuts rendering long before this does anyway.
const MAX_PREVIEW_BYTES: u64 = 4 * 1024 * 1024;
use crate::ui::app::Tty7App;
use crate::ui::diff_list::{DiffRow, FileHead};
use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow};
use crate::ui::diff_list::{DiffRow, FileHead, RowAt};
use crate::ui::diff_rows::{DiffSelection, Side, SplitCell, SplitRow, UnifiedRow};
use crate::ui::document_column::DocumentChrome;
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::right_panel::info_chip;
@@ -57,6 +57,15 @@ pub(crate) struct DiffOverlayState {
/// cadence a tracked file's does.
pub(crate) preview: Option<(String, Option<Arc<FileDiff>>)>,
pub(crate) preview_loading: Option<String>,
/// The rows the pointer has dragged over, and whether it is still down.
///
/// A diff is read in order to be copied out of, and until this existed the
/// text on screen was unreachable — no selection, no clipboard, nothing
/// but retyping it (#721). Line-granular on purpose: the rows are a grid
/// of independent elements, not one text run, so a range of them is the
/// selection this layout can honestly offer.
pub(crate) selection: Option<DiffSelection>,
pub(crate) selecting: bool,
/// The virtualised list the rows scroll in. Held across frames: it owns
/// the scroll position, and the row heights gpui has measured.
pub(crate) list: gpui::ListState,
@@ -142,6 +151,10 @@ impl Tty7App {
}
Some(o) => {
o.focus = focus;
// Another file is on screen now; the range belonged to the
// one that left.
o.selection = None;
o.selecting = false;
let handle = o.focus_handle.clone();
window.focus(&handle, cx);
cx.notify();
@@ -168,6 +181,8 @@ impl Tty7App {
focus,
preview: None,
preview_loading: None,
selection: None,
selecting: false,
list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(256.))
.with_size_hint(DIFF_LINE_H),
rows: Rc::new(Vec::new()),
@@ -206,6 +221,127 @@ impl Tty7App {
}
}
/// Begin a drag at `at`, in the column it was pressed in.
fn start_diff_selection(
&mut self,
at: &RowAt,
mode: DiffViewMode,
side: Option<Side>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let active = self.active;
let Some(overlay) = self
.tabs
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
else {
return;
};
overlay.selection = Some(DiffSelection {
path: at.path.to_string(),
mode,
side,
anchor: at.id,
head: at.id,
});
overlay.selecting = true;
let handle = overlay.focus_handle.clone();
// Copying needs the overlay to hold the keyboard. Docked beside a
// shell it often does not, and Ctrl+C would otherwise reach the pane
// and interrupt whatever is running in it.
window.focus(&handle, cx);
cx.notify();
}
/// Extend the drag in flight to `at`.
///
/// `held` is what the pointer is still pressing. A move with nothing held
/// means the button came up somewhere the overlay never saw — over a pane,
/// or outside the window — so the drag ends here rather than resuming the
/// next time the pointer wanders back over a row.
fn extend_diff_selection(
&mut self,
at: &RowAt,
side: Option<Side>,
held: Option<MouseButton>,
cx: &mut Context<Self>,
) {
let active = self.active;
let Some(overlay) = self
.tabs
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
else {
return;
};
if !overlay.selecting {
return;
}
if held != Some(MouseButton::Left) {
overlay.selecting = false;
cx.notify();
return;
}
let Some(sel) = overlay.selection.as_mut() else {
return;
};
if sel.path != at.path.as_ref() || sel.side != side || sel.head == at.id {
return;
}
sel.head = at.id;
cx.notify();
}
fn end_diff_selection(&mut self, cx: &mut Context<Self>) {
let active = self.active;
if let Some(overlay) = self
.tabs
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
&& overlay.selecting
{
overlay.selecting = false;
cx.notify();
}
}
/// Put the selected rows on the clipboard, as the file spells them.
fn copy_diff_selection(&self, cx: &mut Context<Self>) {
let Some(overlay) = self
.tabs
.get(self.active)
.and_then(|t| t.diff_overlay.as_ref())
else {
return;
};
let Some(sel) = overlay.selection.as_ref() else {
return;
};
let hunks = match &overlay.load {
DiffLoad::Ready(snap) => snap
.files
.iter()
.find(|f| f.path == sel.path)
.map(|f| f.hunks.as_slice()),
_ => None,
}
// An untracked file has no patch in the snapshot — its rows are
// synthesized from the file's own bytes, and so is its text.
.or_else(|| match &overlay.preview {
Some((held, Some(file))) if *held == sel.path => Some(file.hunks.as_slice()),
_ => None,
});
let Some(hunks) = hunks else {
return;
};
let text = sel.text(hunks);
if text.is_empty() {
return;
}
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
}
fn spawn_diff_probe(&mut self, cx: &mut Context<Self>) {
let active = self.active;
let Some(overlay) = self.tabs.get(active).and_then(|t| t.diff_overlay.as_ref()) else {
@@ -326,6 +462,10 @@ impl Tty7App {
// A new snapshot restarts any untracked preview: the file may
// have changed with the tree, and the re-read costs one file.
overlay.preview = None;
// The rows it was drawn against are gone. A range that survived
// would keep its coordinates and quietly cover other code.
overlay.selection = None;
overlay.selecting = false;
landed = true;
}
if landed {
@@ -429,7 +569,22 @@ impl Tty7App {
if ev.keystroke.key.as_str() == "escape" {
this.close_diff_overlay(window, cx);
}
// The overlay takes focus when a row is dragged, so this is
// the copy key for the selection that drag made — and only
// then: with nothing selected it falls through to whatever
// else the window binds it to.
let mods = ev.keystroke.modifiers;
if ev.keystroke.key.as_str() == "c" && mods.secondary() && !mods.alt {
this.copy_diff_selection(cx);
}
}))
// A drag that ends anywhere in the overlay ends here; one that
// ends outside it is caught by the next move over a row, which
// sees no button held.
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _, _window, cx| this.end_diff_selection(cx)),
)
.children(header)
.child(content)
.into_any_element(),
@@ -811,6 +966,16 @@ impl Tty7App {
let active = self.active;
let overlay = self.tabs.get_mut(active)?.diff_overlay.as_mut()?;
// Forget a range the rows under it no longer answer to. The two views
// pair the same lines differently, so a row range drawn in one of them
// points at other code in the other. Here rather than beside the
// switch that flips the mode: this is the one place that knows which
// rows are about to be drawn.
if overlay.selection.as_ref().is_some_and(|s| s.mode != mode) {
overlay.selection = None;
overlay.selecting = false;
}
let snap = match &overlay.load {
DiffLoad::Loading => return Some(DiffBody::Message(t(L10nKey::DiffReading))),
DiffLoad::NotARepo => return Some(DiffBody::Message(t(L10nKey::DiffNotARepo))),
@@ -881,11 +1046,22 @@ impl Tty7App {
let font = SharedString::from(self.font_family.clone());
let app = cx.entity().downgrade();
let list = overlay.list.clone();
// The selection is read here, once a frame, rather than keyed into
// `RowsKey`: it changes what a row *looks like*, not which rows there
// are, and re-flattening the patch for every step of a drag is the
// cost this list exists to avoid. `extend_diff_selection` notifies,
// the view renders, and the list rebuilds the rows on screen from the
// `Drag` this frame carries.
let drag = Drag {
sel: overlay.selection.clone().map(Rc::new),
selecting: overlay.selecting,
mode: view_mode(cx),
};
let body = gpui::list(list.clone(), move |ix, _window, cx| {
#[cfg(test)]
row_probe::record();
match rows.get(ix) {
Some(row) => diff_row_element(row, &font, &snap, &app, cx),
Some(row) => diff_row_element(row, ix, &drag, &font, &snap, &app, cx),
// The list is spliced in step with `rows`, so this is
// unreachable — and an empty row is a better answer to a bug
// than an index panic in a paint.
@@ -931,6 +1107,40 @@ pub(crate) mod row_probe {
}
}
/// What a row needs to take part in a drag, for the frame it is drawn in.
///
/// Read off the overlay once and moved into the list's item builder, so a step
/// of a drag costs a refcount bump rather than a walk of the patch.
struct Drag {
sel: Option<Rc<DiffSelection>>,
/// Whether a drag is in flight. Rows only listen for pointer movement
/// while one is: a diff runs to thousands of rows, and a listener each is
/// worth paying for during a drag and not otherwise.
selecting: bool,
/// The view the rows on screen are drawn in, which is the view a press
/// starts its selection in.
mode: DiffViewMode,
}
impl Drag {
/// Whether the selection covers this cell. `side` names the column a split
/// cell sits in, and is `None` for a unified row — a selection made in one
/// column never lights up the other.
fn covers(&self, at: &RowAt, side: Option<Side>) -> bool {
self.sel
.as_ref()
.is_some_and(|sel| sel.covers(at.path.as_ref(), at.id, side))
}
/// Whether this row is inside the selection at all, whichever column the
/// drag ran down. What decides whether the row offers to copy it.
fn holds(&self, at: &RowAt) -> bool {
self.sel
.as_ref()
.is_some_and(|sel| sel.covers(at.path.as_ref(), at.id, sel.side))
}
}
/// What the overlay's scrolling area holds this frame.
enum DiffBody {
Message(&'static str),
@@ -1077,6 +1287,8 @@ fn hunk_rule(cx: &gpui::App) -> Hsla {
/// One row, inset the way every row in the list is.
fn diff_row_element(
row: &DiffRow,
ix: usize,
drag: &Drag,
font: &SharedString,
snap: &Arc<DiffSnapshot>,
app: &gpui::WeakEntity<Tty7App>,
@@ -1106,8 +1318,20 @@ fn diff_row_element(
// The lines run the full width of the list. A diff is read as a
// column of code, and code that is inset from both sides reads as a
// quotation of itself.
DiffRow::Split(row) => diff_split_row(row, font, cx).into_any_element(),
DiffRow::Unified(row) => diff_unified_row(row, font, cx).into_any_element(),
DiffRow::Split { row, at } => copy_menu(
diff_split_row(row, at, drag, font, app, cx),
ix,
at,
drag,
app,
),
DiffRow::Unified { row, at } => copy_menu(
diff_unified_row(row, at, drag, font, app, cx),
ix,
at,
drag,
app,
),
DiffRow::Truncated(reason) => {
let note = match reason {
Truncation::PerFile => t_fmt(
@@ -1144,6 +1368,72 @@ fn diff_row_element(
}
}
/// The one place a copy is offered by name, on the rows that would be copied.
///
/// A drag says what will be copied; the menu says that copying is a thing you
/// can do. It hangs on the selected rows themselves because with the cards
/// gone there is no longer an element that owns a file's lines, and the
/// overlay root already carries the header's own menu — two of them over one
/// right-click would open two popups.
fn copy_menu(
row: gpui::Div,
ix: usize,
at: &RowAt,
drag: &Drag,
app: &gpui::WeakEntity<Tty7App>,
) -> AnyElement {
if !drag.holds(at) {
return row.into_any_element();
}
let app = app.clone();
row.id(("diff-row-menu", ix))
.context_menu(move |menu, _window, _cx| {
menu.item(PopupMenuItem::new(t(L10nKey::DiffCopySelection)).on_click({
let app = app.clone();
move |_, _window, cx| {
app.update(cx, |this, cx| this.copy_diff_selection(cx)).ok();
}
}))
})
.into_any_element()
}
/// Wire one drawn row into the drag: a press starts a selection there, and
/// while one is in flight a move across the row extends it.
fn diff_row_drag<E: InteractiveElement + Styled>(
el: E,
at: &RowAt,
side: Option<Side>,
drag: &Drag,
app: &gpui::WeakEntity<Tty7App>,
) -> E {
let mode = drag.mode;
// The I-beam is the only standing sign that this text can be taken;
// nothing else about a row says so until one is dragged.
let el = el.cursor_text().on_mouse_down(MouseButton::Left, {
let (app, at) = (app.clone(), at.clone());
move |_: &MouseDownEvent, window, cx| {
app.update(cx, |this, cx| {
this.start_diff_selection(&at, mode, side, window, cx);
})
.ok();
}
});
if !drag.selecting {
return el;
}
el.on_mouse_move({
let (app, at) = (app.clone(), at.clone());
move |ev: &MouseMoveEvent, _window, cx| {
let held = ev.pressed_button;
app.update(cx, |this, cx| {
this.extend_diff_selection(&at, side, held, cx);
})
.ok();
}
})
}
/// The margin the file rows keep from the edge of the list.
fn padded(row: AnyElement) -> AnyElement {
div().w_full().px_2().child(row).into_any_element()
@@ -1329,29 +1619,76 @@ fn diff_untracked_row(
.into_any_element()
}
fn diff_split_row(row: &SplitRow, font: &SharedString, cx: &gpui::App) -> impl IntoElement {
fn diff_split_row(
row: &SplitRow,
at: &RowAt,
drag: &Drag,
font: &SharedString,
app: &gpui::WeakEntity<Tty7App>,
cx: &gpui::App,
) -> gpui::Div {
h_flex()
.w_full()
.h(DIFF_LINE_H)
.items_stretch()
.text_xs()
.font_family(font.clone())
.child(diff_split_cell(row.left.as_ref(), Side::Old, cx))
.child(diff_split_cell(
row.left.as_ref(),
Side::Old,
at,
drag,
app,
cx,
))
.child(div().flex_shrink_0().w(px(1.)).bg(hunk_rule(cx)))
.child(diff_split_cell(row.right.as_ref(), Side::New, cx))
.child(diff_split_cell(
row.right.as_ref(),
Side::New,
at,
drag,
app,
cx,
))
}
fn diff_split_cell(cell: Option<&SplitCell>, side: Side, cx: &gpui::App) -> AnyElement {
fn diff_split_cell(
cell: Option<&SplitCell>,
side: Side,
at: &RowAt,
drag: &Drag,
app: &gpui::WeakEntity<Tty7App>,
cx: &gpui::App,
) -> AnyElement {
let base = h_flex().flex_1().min_w_0().h_full().items_center();
let Some(cell) = cell else {
return base.bg(cx.theme().muted.opacity(0.3)).into_any_element();
// Blank, but still this row's half of this column. Left inert it is a
// dead band under the pointer — no I-beam, a press that starts
// nothing, and a range that visibly stops at the padding and resumes
// below it. A one-sided change is the ordinary shape of a diff, so
// that band runs down most of one column.
let fill = match drag.covers(at, Some(side)) {
true => cx.theme().selection,
false => cx.theme().muted.opacity(0.3),
};
return diff_row_drag(base, at, Some(side), drag, app)
.bg(fill)
.into_any_element();
};
let (marker, tint) = match (cell.changed, side) {
(true, Side::Old) => ("", Some(cx.theme().danger.opacity(0.12))),
(true, Side::New) => ("+", Some(cx.theme().success.opacity(0.12))),
(false, _) => (" ", None),
};
base.when_some(tint, |row, bg| row.bg(bg))
// A selected cell wears the theme's selection colour in place of its own
// wash, the way selected text does anywhere else. The `+`/`` in front of
// the code still says which side of the change it is.
let fill = match drag.covers(at, Some(side)) {
true => Some(cx.theme().selection),
false => tint,
};
diff_row_drag(base, at, Some(side), drag, app)
.when_some(fill, |row, bg| row.bg(bg))
.child(
h_flex()
.flex_shrink_0()
@@ -1384,7 +1721,14 @@ fn diff_split_cell(cell: Option<&SplitCell>, side: Side, cx: &gpui::App) -> AnyE
/// than riding in the text: with three kinds of line stacked in one column, an
/// inlined marker would leave the context lines' code starting two characters
/// left of everything else.
fn diff_unified_row(row: &UnifiedRow, font: &SharedString, cx: &gpui::App) -> impl IntoElement {
fn diff_unified_row(
row: &UnifiedRow,
at: &RowAt,
drag: &Drag,
font: &SharedString,
app: &gpui::WeakEntity<Tty7App>,
cx: &gpui::App,
) -> gpui::Div {
let (marker_color, tint) = match row.kind {
LineKind::Added => (cx.theme().success, Some(cx.theme().success.opacity(0.12))),
LineKind::Removed => (cx.theme().danger, Some(cx.theme().danger.opacity(0.12))),
@@ -1399,13 +1743,17 @@ fn diff_unified_row(row: &UnifiedRow, font: &SharedString, cx: &gpui::App) -> im
.text_color(cx.theme().muted_foreground.opacity(0.7))
.child(no.map(|n| n.to_string()).unwrap_or_default())
};
h_flex()
let fill = match drag.covers(at, None) {
true => Some(cx.theme().selection),
false => tint,
};
diff_row_drag(h_flex(), at, None, drag, app)
.w_full()
.h(DIFF_LINE_H)
.items_center()
.text_xs()
.font_family(font.clone())
.when_some(tint, |line, bg| line.bg(bg))
.when_some(fill, |line, bg| line.bg(bg))
.child(gutter(row.old))
.child(gutter(row.new))
// The split view's centre rule, in the one place it still means the
@@ -2899,3 +3247,312 @@ mod render_idle_gpui_tests {
let _ = std::fs::remove_dir_all(&root);
}
}
/// The drag itself, in a window: a press, a move, and what lands on the
/// clipboard. The row geometry is `diff_rows`' business and tested there —
/// what these check is the wiring the list hangs on it.
#[cfg(test)]
mod selection_gpui_tests {
use super::*;
use crate::terminal::git_diff::{DiffLine, LineKind};
use crate::ui::app::test_window;
use crate::ui::diff_rows::RowId;
use crate::ui::pane::{Pane, PaneSlot};
use crate::ui::pending_pane::{PendingPane, PendingSpawn};
use gpui::{Entity, MouseButton, TestAppContext, VisualTestContext};
const PATH: &str = "src/a.rs";
fn line(kind: LineKind, old: Option<u32>, new: Option<u32>, text: &str) -> DiffLine {
DiffLine {
kind,
old_no: old,
new_no: new,
text: text.to_string(),
}
}
/// `a` kept, `b`/`c` replaced by `B`, `d` kept — four split rows, five
/// unified ones.
fn patched_file() -> FileDiff {
FileDiff {
path: PATH.to_string(),
old_path: None,
status: FileStatus::Modified,
added: 1,
removed: 2,
binary: false,
truncated: None,
hunks: vec![git_diff::Hunk {
header: "@@ -1,4 +1,3 @@".to_string(),
lines: vec![
line(LineKind::Context, Some(1), Some(1), "a"),
line(LineKind::Removed, Some(2), None, "b"),
line(LineKind::Removed, Some(3), None, "c"),
line(LineKind::Added, None, Some(2), "B"),
line(LineKind::Context, Some(4), Some(3), "d"),
],
}],
}
}
/// The coordinate the list carries on the row `row` of the only hunk.
fn at(row: usize) -> RowAt {
RowAt {
path: PATH.into(),
id: RowId { hunk: 0, row },
}
}
/// A window with one tab, showing that patch. Built by hand rather than
/// through `open_diff_overlay`: that dispatches a probe, and a probe
/// landing mid-test would drop the selection under it.
fn window(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
let (app, mut vcx) = test_window::harness(cx);
app.update_in(&mut vcx, |app, _, cx| {
let pending = cx.new(|cx| {
PendingPane::new(
"test-box",
PendingSpawn {
workspace: None,
working_directory: None,
restore_pane: None,
shell: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
owner: None,
font_size: 14.0,
},
cx,
)
});
app.tabs
.push(crate::ui::app::Tab::new(Pane::leaf(PaneSlot::Connecting(
pending,
))));
app.active = 0;
app.tabs[0].diff_overlay = Some(DiffOverlayState {
host_id: crate::ui::host_ops::HostId::LOCAL,
cwd: PathBuf::from("/repo"),
source: DiffSource::Head,
focus_handle: cx.focus_handle(),
load: DiffLoad::Ready(Arc::new(DiffSnapshot {
files: vec![patched_file()],
..Default::default()
})),
loading: false,
expanded: HashMap::new(),
focus: None,
preview: None,
preview_loading: None,
selection: None,
selecting: false,
list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(256.))
.with_size_hint(DIFF_LINE_H),
rows: Rc::new(Vec::new()),
rows_key: None,
epoch: None,
});
});
(app, vcx)
}
fn drag(
app: &Entity<Tty7App>,
vcx: &mut VisualTestContext,
mode: DiffViewMode,
side: Option<Side>,
from: usize,
to: usize,
) {
// The view mode is a window-wide setting, and the overlay drops a
// selection whose rows the current view never drew — so a drag in the
// unified view has to happen with the unified view on.
vcx.update(|_, cx| {
let mut cfg = cx.global::<Config>().clone();
cfg.diff_view = mode;
cx.set_global(cfg);
});
app.update_in(vcx, |this, window, cx| {
this.start_diff_selection(&at(from), mode, side, window, cx);
this.extend_diff_selection(&at(to), side, Some(MouseButton::Left), cx);
});
}
fn copied(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> Option<String> {
app.update_in(vcx, |this, _, cx| this.copy_diff_selection(cx));
vcx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()))
}
fn selection(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> Option<DiffSelection> {
app.update_in(vcx, |this, _, _| {
this.tabs[0]
.diff_overlay
.as_ref()
.and_then(|o| o.selection.clone())
})
}
#[gpui::test]
fn a_drag_down_a_column_copies_that_column(cx: &mut TestAppContext) {
let (app, mut vcx) = window(cx);
drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 3);
assert_eq!(copied(&app, &mut vcx).as_deref(), Some("a\nB\nd"));
drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::Old), 0, 3);
assert_eq!(copied(&app, &mut vcx).as_deref(), Some("a\nb\nc\nd"));
drag(&app, &mut vcx, DiffViewMode::Unified, None, 1, 3);
assert_eq!(copied(&app, &mut vcx).as_deref(), Some("b\nc\nB"));
}
/// A drag that runs up the list leaves the head above the anchor. The
/// range is read in drawn order either way, so it copies what the same two
/// rows copy dragged the other way round.
#[gpui::test]
fn a_drag_up_a_column_copies_the_same_rows(cx: &mut TestAppContext) {
let (app, mut vcx) = window(cx);
drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 3, 0);
let sel = selection(&app, &mut vcx).expect("the drag this test just made");
assert!(
sel.head < sel.anchor,
"the drag ended above where it started"
);
assert_eq!(copied(&app, &mut vcx).as_deref(), Some("a\nB\nd"));
drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::Old), 3, 0);
assert_eq!(copied(&app, &mut vcx).as_deref(), Some("a\nb\nc\nd"));
drag(&app, &mut vcx, DiffViewMode::Unified, None, 3, 1);
assert_eq!(copied(&app, &mut vcx).as_deref(), Some("b\nc\nB"));
}
/// The overlay is often drawn beside a live shell that holds the keyboard.
/// Ctrl+C is the copy key only once the overlay has taken focus — until
/// then the same keystroke would reach the pane and interrupt whatever is
/// running in it.
#[gpui::test]
fn starting_a_drag_takes_the_keyboard(cx: &mut TestAppContext) {
let (app, mut vcx) = window(cx);
drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 1);
let focused = app.update_in(&mut vcx, |this, window, _| {
this.tabs[0]
.diff_overlay
.as_ref()
.expect("the overlay this window was built with")
.focus_handle
.is_focused(window)
});
assert!(focused);
}
/// A move with no button held is a release the overlay never saw — over a
/// pane, or outside the window. The drag ends there rather than resuming
/// the next time the pointer crosses a row.
#[gpui::test]
fn a_release_the_overlay_missed_ends_the_drag(cx: &mut TestAppContext) {
let (app, mut vcx) = window(cx);
drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 1);
app.update_in(&mut vcx, |this, _, cx| {
this.extend_diff_selection(&at(3), Some(Side::New), None, cx);
});
assert_eq!(
copied(&app, &mut vcx).as_deref(),
Some("a\nB"),
"the range stops where the pointer was last seen holding the button"
);
}
/// The two views pair the same lines into different rows, so a range drawn
/// in one of them points at other code in the other. `sync_diff_rows` is
/// where the rows for a frame are settled, so it is where the range that
/// no longer names any of them is dropped.
#[gpui::test]
fn switching_the_view_drops_the_selection(cx: &mut TestAppContext) {
let (app, mut vcx) = window(cx);
drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 3);
vcx.update(|_, cx| {
let mut cfg = cx.global::<Config>().clone();
cfg.diff_view = DiffViewMode::Unified;
cx.set_global(cfg);
});
app.update_in(&mut vcx, |this, _, cx| {
let _ = this.sync_diff_rows(cx);
});
assert!(selection(&app, &mut vcx).is_none());
}
/// A fresh read re-cuts the hunks. A range that survived one would keep
/// its coordinates and quietly cover other code.
#[gpui::test]
fn a_fresh_snapshot_drops_the_selection(cx: &mut TestAppContext) {
let (app, mut vcx) = window(cx);
drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 3);
app.update_in(&mut vcx, |this, _, cx| {
this.install_diff_snapshot(
crate::ui::host_ops::HostId::LOCAL,
&PathBuf::from("/repo"),
&DiffSource::Head,
Some(Arc::new(DiffSnapshot {
files: vec![patched_file()],
..Default::default()
})),
cx,
);
let overlay = this.tabs[0].diff_overlay.as_ref().unwrap();
assert!(overlay.selection.is_none());
assert!(!overlay.selecting);
});
}
#[gpui::test]
fn a_copy_with_nothing_selected_leaves_the_clipboard_alone(cx: &mut TestAppContext) {
let (app, mut vcx) = window(cx);
vcx.update(|_, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string("untouched".into()))
});
assert_eq!(copied(&app, &mut vcx).as_deref(), Some("untouched"));
}
/// Collapsing a file above the selection re-cuts the list, but not the
/// rows the range names. A selection keyed on list positions would come
/// away pointing at whatever slid into those slots.
#[gpui::test]
fn collapsing_a_file_above_the_range_leaves_it_on_the_same_lines(cx: &mut TestAppContext) {
let (app, mut vcx) = window(cx);
app.update_in(&mut vcx, |this, _, _| {
let overlay = this.tabs[0].diff_overlay.as_mut().unwrap();
overlay.load = DiffLoad::Ready(Arc::new(DiffSnapshot {
files: vec![
FileDiff {
path: "src/above.rs".to_string(),
..patched_file()
},
patched_file(),
],
..Default::default()
}));
});
drag(&app, &mut vcx, DiffViewMode::Unified, None, 1, 3);
app.update_in(&mut vcx, |this, _, cx| {
let active = this.active;
{
let overlay = this.tabs[active].diff_overlay.as_mut().unwrap();
overlay.expanded.insert("src/above.rs".to_string(), false);
}
let _ = this.sync_diff_rows(cx);
});
assert_eq!(
copied(&app, &mut vcx).as_deref(),
Some("b\nc\nB"),
"the range still names the same three lines of the same file"
);
}
}
+335 -10
View File
@@ -4,7 +4,8 @@
//! the pairing logic lives here — outside either renderer — and is unit tested
//! without a window.
use crate::terminal::git_diff::{DiffLine, LineKind};
use crate::core::config::DiffViewMode;
use crate::terminal::git_diff::{DiffLine, Hunk, LineKind};
/// A tab is worth this many columns. Not configurable: a diff is read next to
/// the file's other lines, not on its own, and the grid has to line up.
@@ -28,6 +29,10 @@ pub(crate) struct SplitCell {
pub(crate) no: Option<u32>,
pub(crate) text: String,
pub(crate) changed: bool,
/// Which of the hunk's own lines this cell draws. `text` is the tab-
/// expanded copy the grid needs; a copy to the clipboard has to reach past
/// it to the line as the file wrote it.
pub(crate) line: usize,
}
#[derive(PartialEq, Eq)]
@@ -40,18 +45,24 @@ pub(crate) struct SplitRow {
/// rewritten line sits opposite the line it replaced. Whichever run is shorter
/// leaves empty cells at the bottom of the pair.
pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec<SplitRow> {
fn flush(rows: &mut Vec<SplitRow>, rem: &mut Vec<&DiffLine>, add: &mut Vec<&DiffLine>) {
fn flush(
rows: &mut Vec<SplitRow>,
rem: &mut Vec<(usize, &DiffLine)>,
add: &mut Vec<(usize, &DiffLine)>,
) {
for i in 0..rem.len().max(add.len()) {
rows.push(SplitRow {
left: rem.get(i).map(|l| SplitCell {
left: rem.get(i).map(|(idx, l)| SplitCell {
no: l.old_no,
text: expand_tabs(&l.text),
changed: true,
line: *idx,
}),
right: add.get(i).map(|l| SplitCell {
right: add.get(i).map(|(idx, l)| SplitCell {
no: l.new_no,
text: expand_tabs(&l.text),
changed: true,
line: *idx,
}),
});
}
@@ -60,12 +71,12 @@ pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec<SplitRow> {
}
let mut rows = Vec::new();
let mut rem: Vec<&DiffLine> = Vec::new();
let mut add: Vec<&DiffLine> = Vec::new();
for line in lines {
let mut rem: Vec<(usize, &DiffLine)> = Vec::new();
let mut add: Vec<(usize, &DiffLine)> = Vec::new();
for (idx, line) in lines.iter().enumerate() {
match line.kind {
LineKind::Removed => rem.push(line),
LineKind::Added => add.push(line),
LineKind::Removed => rem.push((idx, line)),
LineKind::Added => add.push((idx, line)),
LineKind::Context => {
flush(&mut rows, &mut rem, &mut add);
rows.push(SplitRow {
@@ -73,11 +84,13 @@ pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec<SplitRow> {
no: line.old_no,
text: expand_tabs(&line.text),
changed: false,
line: idx,
}),
right: Some(SplitCell {
no: line.new_no,
text: expand_tabs(&line.text),
changed: false,
line: idx,
}),
});
}
@@ -93,6 +106,9 @@ pub(crate) struct UnifiedRow {
pub(crate) new: Option<u32>,
pub(crate) kind: LineKind,
pub(crate) text: String,
/// Which of the hunk's own lines this row draws — the same reach past
/// `text` a [`SplitCell`] needs, and one row is one line here.
pub(crate) line: usize,
}
/// One row per line, in git's own order — every removal in a run first, then
@@ -102,15 +118,134 @@ pub(crate) struct UnifiedRow {
pub(crate) fn unified_rows(lines: &[DiffLine]) -> Vec<UnifiedRow> {
lines
.iter()
.map(|line| UnifiedRow {
.enumerate()
.map(|(idx, line)| UnifiedRow {
old: line.old_no,
new: line.new_no,
kind: line.kind,
text: expand_tabs(&line.text),
line: idx,
})
.collect()
}
/// One hunk, already turned into whichever kind of row the current view draws.
pub(crate) enum HunkRows {
Split(Vec<SplitRow>),
Unified(Vec<UnifiedRow>),
}
impl HunkRows {
pub(crate) fn build(mode: DiffViewMode, lines: &[DiffLine]) -> Self {
match mode {
DiffViewMode::Split => Self::Split(split_hunk(lines)),
DiffViewMode::Unified => Self::Unified(unified_rows(lines)),
}
}
}
/// Where a row sits in a file's card: which hunk, and which row inside it.
///
/// Ordered the way the rows are drawn, so a drag is nothing more than the
/// range between the two of these the pointer touched.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub(crate) struct RowId {
pub(crate) hunk: usize,
pub(crate) row: usize,
}
/// The rows a drag has run over, in one file.
#[derive(Clone, Debug)]
pub(crate) struct DiffSelection {
/// The file the drag started in. A selection never spans two cards: two
/// files are two documents, and a range across them would copy code from
/// one into the middle of another.
pub(crate) path: String,
/// The view the rows were drawn in when the drag happened. The two views
/// pair the same lines into different rows, so a selection means nothing
/// in the other one — the overlay drops it when the mode changes rather
/// than pretending to translate it.
pub(crate) mode: DiffViewMode,
/// Which column of the split view the drag started in; that column alone
/// is copied, so dragging down the left gets the code as it was and down
/// the right gets it as it is. `None` in the unified view, where a row
/// *is* the line.
pub(crate) side: Option<Side>,
pub(crate) anchor: RowId,
pub(crate) head: RowId,
}
impl DiffSelection {
fn range(&self) -> (RowId, RowId) {
match self.anchor <= self.head {
true => (self.anchor, self.head),
false => (self.head, self.anchor),
}
}
/// Whether the selection covers this row. `side` names the column a split
/// cell sits in, and is `None` for a unified row — a selection made in one
/// column never lights up the other.
pub(crate) fn covers(&self, path: &str, id: RowId, side: Option<Side>) -> bool {
if self.path != path || self.side != side {
return false;
}
let (start, end) = self.range();
start <= id && id <= end
}
/// The code the drag ran over, spelled the way the file spells it.
///
/// No `+`/`` marker and no line numbers: what lands on the clipboard has
/// to compile when it is pasted back, and the gutter is the diff talking
/// about the code rather than the code itself. Tabs are left alone for the
/// same reason — [`expand_tabs`] is how the grid draws a tab, not how the
/// file stores one, and four spaces pasted into a tab-indented file is a
/// whitespace bug the copier did not ask for.
///
/// A split row with nothing on the selected side contributes nothing: the
/// blank half of a pair is padding the layout invented, not an empty line
/// in the file.
pub(crate) fn text(&self, hunks: &[Hunk]) -> String {
let (start, end) = self.range();
let mut out: Vec<&str> = Vec::new();
for (h, hunk) in hunks.iter().enumerate() {
if h < start.hunk || h > end.hunk {
continue;
}
let first = if h == start.hunk { start.row } else { 0 };
let last = if h == end.hunk { end.row } else { usize::MAX };
let mut push = |line: usize| {
if let Some(l) = hunk.lines.get(line) {
out.push(&l.text);
}
};
match HunkRows::build(self.mode, &hunk.lines) {
HunkRows::Split(rows) => {
for row in rows.iter().take(last.saturating_add(1)).skip(first) {
let cell = match self.side.unwrap_or(Side::New) {
Side::Old => row.left.as_ref(),
Side::New => row.right.as_ref(),
};
if let Some(cell) = cell {
push(cell.line);
}
}
}
HunkRows::Unified(rows) => {
for row in rows.iter().take(last.saturating_add(1)).skip(first) {
push(row.line);
}
}
}
}
out.join(
"
",
)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -242,4 +377,194 @@ mod tests {
"a context line fills two cells, a change fills one"
);
}
fn patch(lines: Vec<DiffLine>) -> Hunk {
Hunk {
header: "@@ -1,4 +1,3 @@".to_string(),
lines,
}
}
fn drag(
mode: DiffViewMode,
side: Option<Side>,
anchor: (usize, usize),
head: (usize, usize),
) -> DiffSelection {
DiffSelection {
path: "src/a.rs".to_string(),
mode,
side,
anchor: RowId {
hunk: anchor.0,
row: anchor.1,
},
head: RowId {
hunk: head.0,
row: head.1,
},
}
}
#[test]
fn a_drag_down_the_new_column_copies_the_file_as_it_now_reads() {
let hunks = [patch(hunk())];
let text = drag(DiffViewMode::Split, Some(Side::New), (0, 0), (0, 3)).text(&hunks);
assert_eq!(
text, "a\nB\nd",
"the removed-only row is padding on this side, not an empty line"
);
}
#[test]
fn a_drag_down_the_old_column_copies_the_file_as_it_was() {
let hunks = [patch(hunk())];
let text = drag(DiffViewMode::Split, Some(Side::Old), (0, 0), (0, 3)).text(&hunks);
assert_eq!(text, "a\nb\nc\nd");
}
#[test]
fn the_unified_view_copies_the_rows_in_the_order_it_drew_them() {
let hunks = [patch(hunk())];
let text = drag(DiffViewMode::Unified, None, (0, 1), (0, 3)).text(&hunks);
assert_eq!(
text, "b\nc\nB",
"both removals then the addition — the order on screen"
);
}
#[test]
fn a_drag_the_other_way_round_copies_the_same_rows() {
let hunks = [patch(hunk())];
let forwards = drag(DiffViewMode::Unified, None, (0, 1), (0, 3)).text(&hunks);
let backwards = drag(DiffViewMode::Unified, None, (0, 3), (0, 1)).text(&hunks);
assert_eq!(forwards, backwards);
}
#[test]
fn one_row_copies_one_line() {
let hunks = [patch(hunk())];
assert_eq!(
drag(DiffViewMode::Unified, None, (0, 2), (0, 2)).text(&hunks),
"c"
);
}
/// What lands on the clipboard has to compile when it is pasted back, so
/// none of the diff's own furniture may ride along with it.
#[test]
fn nothing_the_gutter_draws_is_copied() {
let lines = vec![
line(LineKind::Removed, Some(9), None, "let old = 1;"),
line(LineKind::Added, None, Some(9), "let new = 2;"),
];
let hunks = [patch(lines)];
for (mode, side) in [
(DiffViewMode::Split, Some(Side::Old)),
(DiffViewMode::Split, Some(Side::New)),
(DiffViewMode::Unified, None),
] {
let text = drag(mode, side, (0, 0), (0, 9)).text(&hunks);
assert!(!text.contains('+'), "marker in {text:?}");
assert!(!text.contains(''), "marker in {text:?}");
assert!(!text.contains('9'), "line number in {text:?}");
}
}
/// [`expand_tabs`] is how the grid draws a tab, not how the file stores
/// one. Copying the drawn text would paste four spaces into a tab-indented
/// file — a whitespace change nobody asked for.
#[test]
fn copying_keeps_the_tabs_the_file_was_written_with() {
let hunks = [patch(vec![line(
LineKind::Added,
None,
Some(1),
"\tindented",
)])];
assert_eq!(
unified_rows(&hunks[0].lines)[0].text,
" indented",
"drawn with the tab expanded"
);
assert_eq!(
drag(DiffViewMode::Unified, None, (0, 0), (0, 0)).text(&hunks),
"\tindented",
"copied with the tab intact"
);
}
#[test]
fn a_drag_across_hunks_copies_every_row_between_its_ends() {
let hunks = [
patch(vec![
line(LineKind::Context, Some(1), Some(1), "one"),
line(LineKind::Context, Some(2), Some(2), "two"),
]),
patch(vec![
line(LineKind::Context, Some(9), Some(9), "nine"),
line(LineKind::Context, Some(10), Some(10), "ten"),
]),
];
let text = drag(DiffViewMode::Unified, None, (0, 1), (1, 0)).text(&hunks);
assert_eq!(
text, "two\nnine",
"the tail of the first hunk and the head of the second, nothing else"
);
let all = drag(DiffViewMode::Split, Some(Side::New), (0, 0), (1, 1)).text(&hunks);
assert_eq!(all, "one\ntwo\nnine\nten");
}
#[test]
fn a_selection_lights_only_the_column_the_drag_started_in() {
let sel = drag(DiffViewMode::Split, Some(Side::New), (0, 0), (0, 2));
let inside = RowId { hunk: 0, row: 1 };
assert!(sel.covers("src/a.rs", inside, Some(Side::New)));
assert!(!sel.covers("src/a.rs", inside, Some(Side::Old)));
assert!(
!sel.covers("src/b.rs", inside, Some(Side::New)),
"a selection belongs to one file's card"
);
assert!(!sel.covers("src/a.rs", RowId { hunk: 0, row: 3 }, Some(Side::New)));
assert!(!sel.covers("src/a.rs", RowId { hunk: 1, row: 0 }, Some(Side::New)));
}
#[test]
fn a_unified_selection_never_lights_a_split_cell() {
let sel = drag(DiffViewMode::Unified, None, (0, 0), (0, 2));
let inside = RowId { hunk: 0, row: 1 };
assert!(sel.covers("src/a.rs", inside, None));
assert!(!sel.covers("src/a.rs", inside, Some(Side::New)));
}
/// Rows are ordered the way they are drawn, so the range between two of
/// them is exactly what the pointer crossed.
#[test]
fn rows_order_by_hunk_before_row() {
assert!(RowId { hunk: 0, row: 9 } < RowId { hunk: 1, row: 0 });
assert!(RowId { hunk: 1, row: 0 } < RowId { hunk: 1, row: 1 });
}
#[test]
fn a_selection_pointing_past_the_patch_copies_nothing() {
let hunks = [patch(hunk())];
assert_eq!(
drag(DiffViewMode::Unified, None, (4, 0), (4, 2)).text(&hunks),
""
);
assert_eq!(
drag(DiffViewMode::Unified, None, (0, 0), (0, 2)).text(&[]),
""
);
}
/// The one place a view mode turns into rows, so a copy is read off the
/// rows the list drew rather than a second guess at them.
#[test]
fn each_view_builds_the_rows_its_renderer_draws() {
let lines = hunk();
let split = HunkRows::build(DiffViewMode::Split, &lines);
assert!(matches!(split, HunkRows::Split(rows) if rows.len() == 4));
let unified = HunkRows::build(DiffViewMode::Unified, &lines);
assert!(matches!(unified, HunkRows::Unified(rows) if rows.len() == 5));
}
}
+3
View File
@@ -1205,6 +1205,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::DiffUntrackedSummary => "{count} untracked",
L10nKey::DiffViewSplit => "Side by Side",
L10nKey::DiffViewUnified => "Unified",
L10nKey::DiffCopySelection => "Copy Selected Lines",
L10nKey::PendingConnecting => "Connecting to {machine}…",
L10nKey::PendingUnreachable => "Could not reach {machine}",
L10nKey::WorktreePromptNeedsName => "The worktree needs a name",
@@ -1404,6 +1405,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdGroupAgents => "Agents",
L10nKey::CmdGroupApplication => "Application",
L10nKey::CmdNewTab => "New Tab",
L10nKey::CmdNewWindow => "New Window",
L10nKey::CmdNewWorktreeTab => "New Worktree Tab…",
L10nKey::CmdNewWorktreeTabSubtitle => "isolated checkout on a fresh branch",
L10nKey::CmdRenameTab => "Rename Tab…",
@@ -1775,6 +1777,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::TabTooltipHideSidebar => "Hide Sidebar",
L10nKey::TabTooltipHideDetailPanel => "Hide Detail Panel",
L10nKey::TabTooltipShowDetailPanel => "Show Detail Panel",
L10nKey::TabTooltipZoomed => "Pane zoomed — other panes hidden",
L10nKey::TabMenuLocalShells => "Local",
L10nKey::TabMenuAddHost => "Add SSH Host…",
L10nKey::TabMenuAllHosts => "All SSH Hosts…",
+3
View File
@@ -1271,6 +1271,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::DiffUntrackedSummary => "未追跡 {count}",
L10nKey::DiffViewSplit => "左右分割",
L10nKey::DiffViewUnified => "統合",
L10nKey::DiffCopySelection => "選択した行をコピー",
L10nKey::PendingConnecting => "{machine} に接続中…",
L10nKey::PendingUnreachable => "{machine} に到達できませんでした",
L10nKey::WorktreePromptNeedsName => "ワークツリーには名前が必要です",
@@ -1461,6 +1462,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdGroupAgents => "エージェント",
L10nKey::CmdGroupApplication => "アプリケーション",
L10nKey::CmdNewTab => "新しいタブ",
L10nKey::CmdNewWindow => "新しいウィンドウ",
L10nKey::CmdNewWorktreeTab => "新しいワークツリータブ…",
L10nKey::CmdNewWorktreeTabSubtitle => "新しいブランチでの独立したチェックアウト",
L10nKey::CmdRenameTab => "タブの名前を変更…",
@@ -1846,6 +1848,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::TabTooltipHideSidebar => "サイドバーを非表示",
L10nKey::TabTooltipHideDetailPanel => "詳細パネルを非表示",
L10nKey::TabTooltipShowDetailPanel => "詳細パネルを表示",
L10nKey::TabTooltipZoomed => "ペインを拡大中 — 他のペインは非表示",
L10nKey::TabMenuLocalShells => "ローカル",
L10nKey::TabMenuAddHost => "SSH ホストを追加…",
L10nKey::TabMenuAllHosts => "すべての SSH ホスト…",
+3
View File
@@ -901,6 +901,7 @@ l10n_keys! {
DiffUntrackedSummary,
DiffViewSplit,
DiffViewUnified,
DiffCopySelection,
PendingConnecting,
PendingUnreachable,
WorktreePromptNeedsName,
@@ -997,6 +998,7 @@ l10n_keys! {
TabTooltipHideSidebar,
TabTooltipHideDetailPanel,
TabTooltipShowDetailPanel,
TabTooltipZoomed,
TabMenuLocalShells,
TabMenuAddHost,
TabMenuAllHosts,
@@ -1123,6 +1125,7 @@ l10n_keys! {
CmdGroupAgents,
CmdGroupApplication,
CmdNewTab,
CmdNewWindow,
CmdNewWorktreeTab,
CmdNewWorktreeTabSubtitle,
CmdRenameTab,
+3
View File
@@ -1143,6 +1143,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::DiffUntrackedSummary => "{count} 个未跟踪",
L10nKey::DiffViewSplit => "并排",
L10nKey::DiffViewUnified => "统一",
L10nKey::DiffCopySelection => "复制选中的行",
L10nKey::PendingConnecting => "正在连接 {machine}…",
L10nKey::PendingUnreachable => "无法连接到 {machine}",
L10nKey::WorktreePromptNeedsName => "worktree 需要一个名称",
@@ -1322,6 +1323,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdGroupAgents => "Agents",
L10nKey::CmdGroupApplication => "应用",
L10nKey::CmdNewTab => "新标签页",
L10nKey::CmdNewWindow => "新建窗口",
L10nKey::CmdNewWorktreeTab => "新建 worktree 标签页…",
L10nKey::CmdNewWorktreeTabSubtitle => "在全新分支上独立检出",
L10nKey::CmdRenameTab => "重命名标签页…",
@@ -1685,6 +1687,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::TabTooltipHideSidebar => "隐藏侧栏",
L10nKey::TabTooltipHideDetailPanel => "隐藏详情面板",
L10nKey::TabTooltipShowDetailPanel => "显示详情面板",
L10nKey::TabTooltipZoomed => "窗格已缩放 — 其他窗格已隐藏",
L10nKey::TabMenuLocalShells => "本地",
L10nKey::TabMenuAddHost => "添加 SSH 主机…",
L10nKey::TabMenuAllHosts => "所有 SSH 主机…",
+462 -34
View File
@@ -28,6 +28,16 @@ pub fn init(cx: &mut App) {
}
rebuild_keymap(cx);
cx.on_action(|_: &Quit, cx: &mut App| cx.quit());
// On the app rather than on a window, for the same reason `Quit` is: this
// is the one action in the table whose whole point is the state where no
// window is there to dispatch it. `show_tray_icon` is on by default, so
// closing the last window retires tty7 to the tray instead of quitting —
// process alive, nothing on screen — and a `NewWindow` that only exists on
// a window's render root is dead in exactly the state a New Window chord
// is for. `Tty7App`'s own listener still wins wherever there is a window:
// gpui runs the window's bubble phase first and returns before the global
// one, so the two never both fire.
cx.on_action(|_: &NewWindow, cx: &mut App| crate::ui::windows::open(cx, None));
set_menus(cx);
}
@@ -163,12 +173,31 @@ pub(crate) fn extra_bindings(cx: &App) -> Vec<(String, String)> {
.collect()
}
/// Installs `key` for `action`, and alongside it the chord the platform will
/// actually deliver when the two are spelled differently — `secondary-shift-]`
/// is pressed as `secondary-}`, and only the second ever reaches the
/// dispatcher. Both go in rather than one replacing the other, so a spec that
/// was already live stays live whatever the backend does with Shift (#750).
///
/// Answers whether the action was known at all, which is the caller's cue to
/// warn about a name it cannot bind.
fn push_binding(bindings: &mut Vec<KeyBinding>, action: &str, key: &str) -> bool {
let Some(binding) = make_binding(action, key) else {
return false;
};
bindings.push(binding);
if let Some(folded) = folded_spec(key)
&& let Some(alias) = make_binding(action, &folded)
{
bindings.push(alias);
}
true
}
fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> {
let mut bindings = Vec::new();
for (action, key) in extra_keystrokes(effective) {
if let Some(b) = make_binding(action, key) {
bindings.push(b);
}
push_binding(&mut bindings, action, key);
}
for (action, key) in effective {
if key.is_empty() {
@@ -184,26 +213,36 @@ fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> {
// without saying so — `no_default_binding_sits_on_a_terminal_control_code`
// is the half of it that fails a build. A single chord only, since a
// prefix like `ctrl-b n` is that choice made deliberately.
if !key.contains(' ')
&& steals_a_control_code(key)
&& !control_code_binding_allowed(action, key)
// Over both spellings that reach the keymap, because the fold is what
// decides which of them the shell actually loses. `steals_a_control_code`
// only ever recognises a bare Ctrl, so it waves `ctrl-shift-2` through —
// and then the fold installs `ctrl-@` beside it, which is NUL. The chord
// was dead before it was folded and stole nothing; now that it is live
// the warning has to follow it (#750). At most one of the two can trip:
// a written chord that folds carries Shift, and one that carries Shift
// is never a control code.
let folded = folded_spec(key);
for chord in [Some(key.as_str()), folded.as_deref()]
.into_iter()
.flatten()
{
log::warn!(
"keybinding '{key}' for '{action}' takes a control code away from the shell"
);
}
match make_binding(action, key) {
Some(b) => {
bindings.push(b);
if is_default_insert_newline_binding(action, key) {
bindings.push(KeyBinding::new(
INSERT_NEWLINE_DEFAULT,
InsertNewlineFallback,
Some("Terminal"),
));
}
if !chord.contains(' ')
&& steals_a_control_code(chord)
&& !control_code_binding_allowed(action, chord)
{
log::warn!(
"keybinding '{chord}' for '{action}' takes a control code away from the shell"
);
}
None => log::warn!("ignoring keybinding: unknown action '{action}'"),
}
if !push_binding(&mut bindings, action, key) {
log::warn!("ignoring keybinding: unknown action '{action}'");
} else if is_default_insert_newline_binding(action, key) {
bindings.push(KeyBinding::new(
INSERT_NEWLINE_DEFAULT,
InsertNewlineFallback,
Some("Terminal"),
));
}
}
bindings
@@ -265,6 +304,16 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
vec![
("NewTab", per_platform("secondary-t", "secondary-shift-t")),
("NewWorkspace", "secondary-shift-n"),
// Cmd+N is what "New Window" means on macOS, and nothing else here
// claims it. Off macOS both chords the convention offers are gone:
// Ctrl+N is a C0 byte the shell is owed, which
// `no_default_binding_sits_on_a_terminal_control_code` fails a build
// over, and Ctrl+Shift+N — where that same test says window actions
// belong — has been `NewWorkspace` for far longer than this action has
// existed. Minting an unguessable third chord would be worse than
// shipping unbound: the palette and the Keybindings page both carry
// this, so a key is one line of config away.
("NewWindow", per_platform("secondary-n", "")),
("CloseWindow", ""),
(
"CloseActiveTab",
@@ -734,6 +783,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::Application,
t(L10nKey::AppMenuCommandPalette).to_string(),
),
"NewWindow" => (
CommandGroup::Application,
t(L10nKey::CmdNewWindow).to_string(),
),
"CloseWindow" => (
CommandGroup::Application,
t(L10nKey::CmdCloseWindow).to_string(),
@@ -940,6 +993,126 @@ pub(crate) fn spec_from_keystroke(ks: &Keystroke) -> Option<String> {
Some(spec)
}
/// The glyph a US keyboard prints on the shifted half of every key that is not
/// a letter — `shift-]` types `}`, `shift-2` types `@`.
const SHIFTED_GLYPHS: [(char, char); 21] = [
('`', '~'),
('1', '!'),
('2', '@'),
('3', '#'),
('4', '$'),
('5', '%'),
('6', '^'),
('7', '&'),
('8', '*'),
('9', '('),
('0', ')'),
('-', '_'),
('=', '+'),
('[', '{'),
(']', '}'),
('\\', '|'),
(';', ':'),
('\'', '"'),
(',', '<'),
('.', '>'),
('/', '?'),
];
/// Rewrites one chord into the shape the platform actually delivers, or
/// returns `None` when it is already that shape.
///
/// Every gpui backend carries Shift as a modifier for letters only. Over the
/// digits and the punctuation it spends the modifier on the character instead
/// and clears the flag: that is the `chars_with_shift` branch on macOS,
/// `need_to_convert_to_shifted_key` in the Windows mapper, and Linux's "we
/// only include the shift for upper-case letters by convention". So the chord
/// written `secondary-shift-]` arrives as `secondary-}` and the two never
/// meet — the binding installs and is unreachable, which is why Ctrl+Shift+]
/// stopped cycling panes off macOS and why hand-editing a config to that
/// spelling changed nothing (#750).
///
/// Folding the spec the same way on the lookup side closes the round trip.
/// The recorder already writes `secondary-}`, because that is what
/// `spec_from_keystroke` was handed; a default or a config written the other
/// way now installs the very same binding, so both spellings keep working and
/// nobody's config comes undone. `secondary-shift-}`, which people also reach
/// for, folds onto it too — the flag is simply redundant there.
///
/// The table is US-layout, and a chord it does not know is left alone rather
/// than guessed at. It cannot be layout-aware from here: `?` is Shift+ß on a
/// German keyboard and Shift+, on a French one, and the only mapper that knows
/// which is gpui's, reached through `KeyBinding::load` rather than the
/// `KeyBinding::new` that `make_binding` uses — and it exists on Windows only,
/// where `MacKeyboardMapper` does no shift folding at all. So off a US layout
/// the fold is a widening that may not land: `ctrl-shift-4` picks up `ctrl-$`,
/// which on AZERTY is a key of its own. Harmless, because the fold only ever
/// adds — the spec as written stays bound whatever the layout — but it is why
/// this is not the last word on #750.
///
/// The fold is *added* to a spec's bindings, never substituted for them,
/// because the convention is not universal. Windows leaves Shift intact on
/// the numpad's `/ * + -`: `need_to_convert_to_shifted_key` lists the OEM
/// keys and `VK_0..VK_9` but none of `VK_DIVIDE`/`VK_MULTIPLY`/`VK_ADD`/
/// `VK_SUBTRACT`, so `get_keystroke_key` falls through to `get_key_from_vkey`
/// and Ctrl+Shift+numpad-/ really does arrive as `ctrl-shift-/`. Substituting
/// `ctrl-?` for it would unbind a chord that works today — the same class of
/// bug this is fixing. macOS clears the flag there anyway (the numpad glyph
/// is the same shifted or not, so `chars_with_shift` takes the branch that
/// drops it) and Linux names those keys `divide`/`multiply`/`add`/`subtract`,
/// which are not one character and never reach this.
fn fold_shift_into_glyph(chord: &str) -> Option<String> {
let mut ks = Keystroke::parse(chord).ok()?;
if !ks.modifiers.shift {
return None;
}
let mut chars = ks.key.chars();
let (Some(key), None) = (chars.next(), chars.next()) else {
return None;
};
if let Some((_, shifted)) = SHIFTED_GLYPHS.iter().find(|(plain, _)| *plain == key) {
ks.key = shifted.to_string();
} else if !SHIFTED_GLYPHS.iter().any(|(_, shifted)| *shifted == key) {
return None;
}
ks.modifiers.shift = false;
spec_from_keystroke(&ks)
}
/// `spec` with every chord folded, or `None` when it is already the shape the
/// platform delivers and there is nothing to add.
pub(crate) fn folded_spec(spec: &str) -> Option<String> {
let mut folded = String::with_capacity(spec.len());
let mut changed = false;
for chord in spec.split_whitespace() {
if !folded.is_empty() {
folded.push(' ');
}
match fold_shift_into_glyph(chord) {
Some(chord) => {
folded.push_str(&chord);
changed = true;
}
None => folded.push_str(chord),
}
}
changed.then_some(folded)
}
/// Whether two specs claim the same keystroke — spelled the same way, or one
/// the fold of the other. `secondary-shift-]` and `secondary-}` are one chord
/// written twice, and a rebind that cannot see that leaves both installed on
/// it, where which one fires is arbitrary (#750).
pub(crate) fn same_chord(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let (folded_a, folded_b) = (folded_spec(a), folded_spec(b));
folded_a.as_deref() == Some(b)
|| folded_b.as_deref() == Some(a)
|| (folded_a.is_some() && folded_a == folded_b)
}
pub(crate) fn key_chords(spec: &str) -> Vec<Vec<String>> {
spec.split_whitespace().map(key_tokens).collect()
}
@@ -1041,6 +1214,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"DeleteWorkspace" => KeyBinding::new(keystroke, DeleteWorkspace, None),
"RenameWorkspace" => KeyBinding::new(keystroke, RenameWorkspace, None),
"ToggleSwitcher" => KeyBinding::new(keystroke, ToggleSwitcher, None),
"NewWindow" => KeyBinding::new(keystroke, NewWindow, None),
"CloseWindow" => KeyBinding::new(keystroke, CloseWindow, None),
"CloseActiveTab" => KeyBinding::new(keystroke, CloseActiveTab, None),
"RenameTab" => KeyBinding::new(keystroke, RenameTab, None),
@@ -1164,21 +1338,49 @@ mod tests {
/// table, so a chord the app would not really install, or would install in
/// another context, cannot pass.
fn dispatched(effective: &[(String, String)], keys: &str, context: &str) -> Vec<&'static str> {
let mut keymap = gpui::Keymap::default();
keymap.add_bindings(action_bindings(effective));
let input: Vec<Keystroke> = keys
.split(' ')
.map(|k| Keystroke::parse(k).expect("the typed keystroke parses"))
.collect();
dispatched_keystrokes(effective, &input, context)
}
/// The same lookup for input a spec cannot spell — the shifted-glyph shape
/// a backend hands over for `secondary-shift-]` has no written form that
/// `Keystroke::parse` turns back into it.
fn dispatched_keystrokes(
effective: &[(String, String)],
input: &[Keystroke],
context: &str,
) -> Vec<&'static str> {
let mut keymap = gpui::Keymap::default();
keymap.add_bindings(action_bindings(effective));
let context = [gpui::KeyContext::parse(context).expect("the context parses")];
keymap
.bindings_for_input(&input, &context)
.bindings_for_input(input, &context)
.0
.iter()
.map(|b| b.action().name())
.collect()
}
/// What every gpui backend delivers when the secondary modifier is held
/// over a key whose shifted half is `glyph`: the glyph, and no shift flag —
/// the modifier is already spent on the character.
fn delivered_with_secondary(glyph: &str) -> Keystroke {
Keystroke {
modifiers: gpui::Modifiers {
control: cfg!(not(target_os = "macos")),
platform: cfg!(target_os = "macos"),
shift: false,
alt: false,
function: false,
},
key: glyph.to_string(),
key_char: None,
}
}
#[test]
fn every_dispatchable_action_has_a_slot_to_bind_it_in() {
// `make_binding` is what turns an action name into a real binding, and
@@ -1223,6 +1425,7 @@ mod tests {
// palette and the docs all say Zoom Pane.
assert_eq!(action_entry("ToggleMaximizePane").1, "Zoom Pane");
assert_eq!(action_entry("CloseActiveTab").1, "Close Pane / Tab");
assert_eq!(action_entry("NewWindow").1, "New Window");
assert_eq!(action_entry("CloseWindow").1, "Close Window");
assert_eq!(action_entry("ClearScrollback").1, "Clear Scrollback");
assert_eq!(action_entry("TogglePalette").1, "Command Palette…");
@@ -1237,6 +1440,57 @@ mod tests {
assert_eq!(action_entry("ForkAgentSessionUp").0, CommandGroup::Agents);
}
#[test]
fn new_window_ships_a_chord_only_where_one_is_free() {
let mut effective: Vec<(String, String)> = default_bindings()
.into_iter()
.map(|(a, k)| (a.to_string(), k.to_string()))
.collect();
let default = effective
.iter()
.find(|(action, _)| action == "NewWindow")
.map(|(_, key)| key.clone())
.expect("NewWindow has to be listed here or it cannot be bound at all");
assert_eq!(
default,
if cfg!(target_os = "macos") {
"secondary-n"
} else {
""
},
"macOS gets Cmd+N; off macOS this ships unbound on purpose"
);
// Ask the keymap rather than the table. A default can look bound and
// dispatch nothing — gpui folds shift into the punctuation glyph, so
// `secondary-shift-]` reached no action at all off macOS (#750). An
// exact match is also the conflict check: any other action holding
// this chord would show up in the list.
if !default.is_empty() {
assert_eq!(
dispatched(&effective, &default, "Terminal"),
vec![NewWindow::name_for_type()],
"{default} must reach NewWindow, and nothing else may answer it"
);
}
// Unbound still has to mean bindable. `set_binding` only writes into
// slots this table already has, and `make_binding` is what turns the
// name back into a dispatchable binding; miss either and the action
// sits on the Keybindings page, takes a key, and does nothing — which
// is the whole complaint in #710, not just the missing default.
effective
.iter_mut()
.find(|(action, _)| action == "NewWindow")
.expect("found once already")
.1 = "ctrl-alt-shift-n".to_string();
assert_eq!(
dispatched(&effective, "ctrl-alt-shift-n", "Terminal"),
vec![NewWindow::name_for_type()],
"a chord the user assigns to NewWindow has to reach it"
);
}
#[cfg(target_os = "macos")]
const SECONDARY: &str = "";
#[cfg(not(target_os = "macos"))]
@@ -1596,15 +1850,25 @@ mod tests {
// `control_code_binding_allowed`; anything new needs a fall-through of
// its own to join them.
for (action, spec) in default_bindings() {
for chord in spec.split_whitespace() {
Keystroke::parse(chord).expect("default chords parse");
assert!(
!steals_a_control_code(chord) || control_code_binding_allowed(action, chord),
"{action} is bound to {chord}, which the shell needs as a control code \
(Ctrl+[ is ESC, Ctrl+D is EOF, Ctrl+W deletes a word, \
Ctrl+2..8 are NUL/ESC/FS/GS/RS/US/DEL). \
Window actions belong on ctrl-shift-* off macOS."
);
// Both spellings, because `push_binding` installs both. "Window
// actions belong on ctrl-shift-*" stops being an escape over the
// digits and the punctuation the moment the fold clears the Shift
// again: `ctrl-shift-2` goes into the keymap as `ctrl-@` (#750).
let folded = folded_spec(spec);
for spelling in [Some(spec), folded.as_deref()].into_iter().flatten() {
for chord in spelling.split_whitespace() {
Keystroke::parse(chord).expect("default chords parse");
assert!(
!steals_a_control_code(chord)
|| control_code_binding_allowed(action, chord),
"{action} is bound to {spec}, installed as {chord}, which the shell \
needs as a control code \
(Ctrl+[ is ESC, Ctrl+D is EOF, Ctrl+W deletes a word, \
Ctrl+2..8 are NUL/ESC/FS/GS/RS/US/DEL). \
Window actions belong on ctrl-shift-* off macOS over a letter, \
where the platform keeps the Shift."
);
}
}
}
}
@@ -1661,13 +1925,18 @@ mod tests {
// `ScmCommit` and `ToggleFullscreen` both take secondary-enter on that
// basis. Two bindings sharing a chord *and* a context is still a bug,
// because then which one fires is arbitrary.
// `same_chord`, not string equality: `secondary-shift-]` and
// `secondary-}` are two spellings of one keystroke and both install it.
let mut seen: Vec<(&str, &str, Option<&'static str>)> = Vec::new();
for (action, spec) in default_bindings() {
if spec.is_empty() {
continue;
}
let context = action_context(action);
if let Some((other, _, _)) = seen.iter().find(|(_, s, c)| *s == spec && *c == context) {
if let Some((other, _, _)) = seen
.iter()
.find(|(_, s, c)| same_chord(s, spec) && *c == context)
{
panic!("{action} and {other} both claim {spec} in context {context:?}");
}
seen.push((action, spec, context));
@@ -1694,6 +1963,165 @@ mod tests {
}
}
#[test]
fn a_shifted_punctuation_chord_dispatches_however_it_is_spelled() {
// Recording ⌘⇧] wrote `secondary-}` — correctly, since that is the
// keystroke it was handed — while the default table and every config
// in the wild spell the same chord `secondary-shift-]`. Only one of
// the two used to reach the dispatcher (#750); all three do now.
let typed = delivered_with_secondary("}");
let recorded = spec_from_keystroke(&typed).expect("the recorder spells the chord");
assert_eq!(recorded, "secondary-}");
for spec in [recorded.as_str(), "secondary-shift-]", "secondary-shift-}"] {
let effective = [("NextTab".to_string(), spec.to_string())];
assert_eq!(
dispatched_keystrokes(&effective, std::slice::from_ref(&typed), ""),
vec![NextTab::name_for_type()],
"{spec} never reaches the dispatcher"
);
}
}
#[test]
fn the_default_pane_cycle_chord_reaches_its_action() {
// `secondary-]` on macOS, `secondary-shift-]` everywhere else — and
// either way the backend reports the chord as the bare glyph. The
// second spelling installed a binding nothing could press.
let effective: Vec<(String, String)> = default_bindings()
.into_iter()
.map(|(a, k)| (a.to_string(), k.to_string()))
.collect();
let glyph = if cfg!(target_os = "macos") { "]" } else { "}" };
assert!(
dispatched_keystrokes(&effective, &[delivered_with_secondary(glyph)], "")
.contains(&FocusNextPane::name_for_type()),
"the default pane-cycle chord dispatches nothing"
);
}
#[test]
fn folding_leaves_every_other_spelling_alone() {
// Shift over a letter is a real modifier, and the named keys have no
// shifted half at all — folding either would break far more than it
// fixed.
for spec in [
"secondary-shift-t",
"shift-enter",
"shift-insert",
"shift-tab",
"ctrl-shift-up",
"secondary-t",
"secondary--",
"ctrl-b n",
] {
assert_eq!(folded_spec(spec), None, "{spec} was rewritten");
}
// A prefix chord folds per chord, not as a whole.
assert_eq!(folded_spec("ctrl-b shift-5").as_deref(), Some("ctrl-b %"));
}
#[test]
fn a_chord_the_platform_delivers_with_shift_intact_stays_bound() {
// Windows leaves Shift on the numpad's `/ * + -` — they are absent
// from gpui's `need_to_convert_to_shifted_key`, so Ctrl+Shift+numpad-/
// arrives as `ctrl-shift-/` and the recorder writes exactly that.
// Folding is an addition, never a substitution, so that spec has to
// survive the treatment `secondary-shift-]` gets (#750).
let numpad = Keystroke {
modifiers: gpui::Modifiers {
control: cfg!(not(target_os = "macos")),
platform: cfg!(target_os = "macos"),
shift: true,
alt: false,
function: false,
},
key: "/".to_string(),
key_char: None,
};
assert_eq!(
spec_from_keystroke(&numpad).as_deref(),
Some("secondary-shift-/")
);
let effective = [("NextTab".to_string(), "secondary-shift-/".to_string())];
assert_eq!(
dispatched_keystrokes(&effective, std::slice::from_ref(&numpad), ""),
vec![NextTab::name_for_type()],
"the numpad chord lost the binding written for it"
);
// And the main row's `?`, which the same spec also stands for, still
// reaches the action — the fold added it rather than taking over.
assert_eq!(
dispatched_keystrokes(&effective, &[delivered_with_secondary("?")], ""),
vec![NextTab::name_for_type()],
);
}
#[test]
fn two_spellings_of_one_chord_are_recognised_as_one() {
// What `assign_keybinding` leans on to displace the action already
// holding a chord, whichever way either of them is written down.
assert!(same_chord("secondary-shift-]", "secondary-}"));
assert!(same_chord("secondary-}", "secondary-shift-]"));
assert!(same_chord("secondary-shift-}", "secondary-shift-]"));
assert!(same_chord("secondary-t", "secondary-t"));
assert!(!same_chord("secondary-shift-]", "secondary-shift-["));
assert!(!same_chord("secondary-}", "secondary-{"));
}
#[test]
fn the_control_code_guard_follows_the_folded_chord() {
// The half of the control-code rule that only warns. A chord carrying
// Shift reads as safe on its own — `steals_a_control_code` recognises a
// bare Ctrl and nothing else — and it *was* safe while nothing could
// press it. The fold makes it live, and Ctrl+Shift+2 is delivered as
// Ctrl+@, which is the NUL the program on the far end is waiting for.
// So the guard is asked about the spelling that goes into the keymap
// (#750); `action_bindings` runs exactly this pair.
for (written, folded) in [
("ctrl-shift-2", per_platform("ctrl-@", "secondary-@")),
("ctrl-shift-6", per_platform("ctrl-^", "secondary-^")),
("ctrl-shift--", per_platform("ctrl-_", "secondary-_")),
("ctrl-shift-/", per_platform("ctrl-?", "secondary-?")),
] {
assert!(
!steals_a_control_code(written),
"{written} reads as safe as written, which is why the fold has to be checked"
);
assert_eq!(folded_spec(written).as_deref(), Some(folded));
assert!(
steals_a_control_code(folded),
"{written} is installed as {folded} and takes a control code"
);
}
}
#[test]
fn a_key_the_us_table_does_not_know_is_left_alone() {
// `SHIFTED_GLYPHS` is a US keyboard, and every other layout prints
// something else on the shifted half: `?` is Shift+ß on a German
// keyboard and Shift+, on a French one. The fold cannot know that, so
// it declines rather than guesses — a key it has never seen keeps its
// Shift, and the binding written for it stays exactly as written. That
// is also what keeps the fold additive: it can widen a spec's reach but
// never move it onto a chord the user did not ask for.
for spec in [
"secondary-shift-ß",
"secondary-shift-é",
"secondary-shift-ä",
"secondary-shift-ç",
"secondary-shift-ñ",
] {
assert_eq!(folded_spec(spec), None, "{spec} was guessed at");
}
// And the recorded spelling, which is what a non-US layout actually
// produces, needs no fold to reach the dispatcher in the first place.
let effective = [("NextTab".to_string(), "secondary-?".to_string())];
assert_eq!(
dispatched_keystrokes(&effective, &[delivered_with_secondary("?")], ""),
vec![NextTab::name_for_type()],
);
}
#[test]
fn spec_from_keystroke_ignores_a_lone_modifier() {
let ks = Keystroke::parse("secondary").unwrap();
+4
View File
@@ -22,6 +22,7 @@ pub enum CommandKind {
RenameWorkspace,
StopWorkspace,
DeleteWorkspace,
NewWindow,
CloseWindow,
SplitRight,
SplitDown,
@@ -125,6 +126,7 @@ impl CommandKind {
RenameWorkspace => "rename-workspace",
StopWorkspace => "stop-workspace",
DeleteWorkspace => "delete-workspace",
NewWindow => "new-window",
CloseWindow => "close-window",
SplitRight => "split-right",
SplitDown => "split-down",
@@ -232,6 +234,7 @@ impl CommandKind {
RenameWorkspace => "RenameWorkspace",
StopWorkspace => "StopWorkspace",
DeleteWorkspace => "DeleteWorkspace",
NewWindow => "NewWindow",
CloseWindow => "CloseWindow",
SplitRight => "SplitRight",
SplitDown => "SplitDown",
@@ -568,6 +571,7 @@ impl Command {
];
let application = [
Command::localized(L10nKey::CmdNewWindow, NewWindow),
Command::localized(L10nKey::CmdSettings, OpenSettings),
Command::localized(L10nKey::CmdKeyboardShortcuts, ShowKeyboardShortcuts),
Command::localized(L10nKey::CmdAboutTty7, About),
+29
View File
@@ -183,6 +183,17 @@ impl<L: Clone> Pane<L> {
}
}
/// Whether zooming the leaf `pred` names would actually hide anything.
///
/// The zoom that gets marked in the chrome is the one a reader cannot see
/// for themselves: a zoom naming a pane that has since exited names no
/// leaf here, and a zoom over the last pane standing covers nothing. Both
/// look exactly like an unzoomed single pane, so neither earns a badge.
pub fn zoom_hides_siblings(&self, pred: impl Fn(&L) -> bool) -> bool {
let leaves = self.leaves();
leaves.len() > 1 && leaves.iter().any(pred)
}
pub fn leaf_matching_or_first(&self, pred: impl Fn(&L) -> bool) -> Option<L> {
self.leaves()
.into_iter()
@@ -1279,6 +1290,24 @@ mod tests {
assert_eq!(TestPane::Empty.leaf_matching_or_first(is(0)), None);
}
#[test]
fn a_zoom_is_only_worth_marking_while_it_covers_something() {
let mut pane = TestPane::leaf(0);
assert!(
!pane.zoom_hides_siblings(is(0)),
"zooming the only pane covers nothing"
);
split(&mut pane, 0, Axis::Horizontal, 1);
assert!(pane.zoom_hides_siblings(is(0)));
assert!(pane.zoom_hides_siblings(is(1)));
assert!(
!pane.zoom_hides_siblings(is(99)),
"a zoom whose pane has exited is not a zoom"
);
assert!(!TestPane::Empty.zoom_hides_siblings(is(0)));
}
#[test]
fn closing_the_root_leaf_defers_removal_to_the_caller() {
let mut pane = TestPane::leaf(7);
+42 -5
View File
@@ -69,11 +69,18 @@ pub fn available_hosts(cx: &App) -> Vec<HostChoice> {
/// exists, the target's own spelling when that is human-readable, and the
/// deleted-profile placeholder for the bare-UUID case (#485).
pub fn target_label(cx: &App, target: &RemoteTarget) -> String {
if let Some(choice) = available_hosts(cx)
.into_iter()
.find(|h| h.target == *target)
{
return choice.label;
label_from_hosts(&available_hosts(cx), target)
}
/// `target_label`'s rule applied to a listing the caller already has. The
/// switcher builds `available_hosts` once per frame and names several targets
/// from it; re-listing per target would re-read `~/.ssh/config` off the disk
/// on the render path, which is the cost `route_label` goes out of its way to
/// avoid. One rule, two entry points — so a name shown next to a pane and the
/// same name shown in the switcher cannot drift apart (#485).
pub fn label_from_hosts(hosts: &[HostChoice], target: &RemoteTarget) -> String {
if let Some(choice) = hosts.iter().find(|h| h.target == *target) {
return choice.label.clone();
}
match target {
RemoteTarget::Profile { .. } => t(L10nKey::RemoteProfileGone).to_string(),
@@ -835,6 +842,36 @@ pub fn restart_server_blocking(header: RouteHeader, label: &str) -> Result<(), S
mod tests {
use super::*;
/// The one rule for "what do we call a target we cannot resolve" (#485),
/// pinned where both `target_label` and the switcher's group listing read
/// it from.
#[test]
fn an_unresolvable_profile_is_named_never_spelled_as_its_uuid() {
let id = uuid::Uuid::new_v4();
let gone = RemoteTarget::Profile { id };
let label = label_from_hosts(&[], &gone);
assert!(
!label.contains(&id.to_string()),
"a bare profile UUID reached the UI: {label}"
);
assert_eq!(label, t(L10nKey::RemoteProfileGone));
// While the profile is configured, its own name wins.
let listed = vec![HostChoice {
target: gone.clone(),
label: "lager".into(),
detail: "qhw@222.29.101.16".into(),
}];
assert_eq!(label_from_hosts(&listed, &gone), "lager");
// Targets that spell themselves readably never need the placeholder.
let alias = RemoteTarget::Alias {
alias: "build-box".into(),
};
assert_eq!(label_from_hosts(&[], &alias), "build-box");
}
fn request() -> InstallRequest {
InstallRequest {
host: "me@build-box:22".into(),
+66 -37
View File
@@ -716,6 +716,11 @@ impl Tty7App {
}
}
// Listed once for the whole frame: the pending groups below name
// themselves from it, and so does the pass that settles every group's
// link state further down.
let configured = remote_connect::available_hosts(cx);
for target in self.pending_machines() {
let key = target.to_string();
if index.contains_key(&key) {
@@ -723,7 +728,12 @@ impl Tty7App {
}
index.insert(key.clone(), groups.len());
groups.push(Group {
label: key.clone(),
// Not `key`: a `Profile` target spells itself as its config
// UUID, so a machine whose profile has been deleted would
// announce itself to the banners below by a raw UUID (#485).
// The pass below overwrites this while the profile is still
// configured; this is what is left when it is not.
label: remote_connect::label_from_hosts(&configured, &target),
key,
endpoint: String::new(),
target: Some(target),
@@ -838,7 +848,6 @@ impl Tty7App {
// trouble banners under the list come out in a stable order.
groups.sort_by(|a, b| a.key.is_empty().cmp(&b.key.is_empty()).reverse());
let configured = remote_connect::available_hosts(cx);
for group in &mut groups {
let Some(target) = group.target.clone() else {
group.link = Link::Local;
@@ -2956,47 +2965,20 @@ impl TabRow {
}
}
/// Names a tab of a workspace this window does not own, matching what
/// `Tty7App::tab_label` shows for local ones.
/// Names a tab of a workspace this window does not own.
///
/// The two read different sources and have to be talked into agreeing. A local
/// tab is named by its live terminal's OSC title, which shells set to the
/// working directory and agents overwrite with what they are doing. The tree
/// carries a copy of that title (`PaneRecord::osc_title`), which is what makes
/// the two columns agree; `PaneRecord::title` is the *foreground process name*
/// ("zsh") and only stands in when there is no title at all.
/// The two surfaces used to read different sources and had to be talked into
/// agreeing: a local tab was named by its live terminal's title, this one by
/// the tree's copy of it (`PaneRecord::osc_title`). They now go through the one
/// renderer, [`crate::ui::tab_strip::label_of`] — a local tab is turned into
/// the same [`TabView`](crate::ui::machine_mirror::TabView) this one already
/// is, so neither column can rank the evidence its own way.
fn tab_view_label(
view: &crate::ui::machine_mirror::TabView,
index: usize,
home: Option<&std::path::Path>,
) -> String {
let unnamed = || {
t_fmt(
L10nKey::TabUnnamedShell,
&[("n", &((index + 1).to_string()))],
)
};
// A path can shorten away to nothing (a bare "user@host:"), and the process
// name is still worth more than a number.
let shortened = |raw: &str| match crate::ui::tab_strip::short_title(raw, home) {
shortened if !shortened.trim().is_empty() => shortened,
_ => match view.title.trim() {
"" => unnamed(),
title => title.to_string(),
},
};
match view.label() {
crate::ui::machine_mirror::TabLabel::Named(name) => name.to_string(),
// Through `short_title` because the local strip puts its own titles
// through it too: the shell integration writes `user@host:~/dir`, and a
// tab that spelled that out in full where the strip says "…/dir" would
// be the same disagreement in a new place.
crate::ui::machine_mirror::TabLabel::Osc(title) => shortened(title),
crate::ui::machine_mirror::TabLabel::Agent(agent) => agent.display_name().to_string(),
crate::ui::machine_mirror::TabLabel::Cwd(cwd) => shortened(cwd),
crate::ui::machine_mirror::TabLabel::Process(title) => title.to_string(),
crate::ui::machine_mirror::TabLabel::Unknown => unnamed(),
}
crate::ui::tab_strip::label_of(view, index, home)
}
impl Group {
@@ -3290,6 +3272,53 @@ fn glyph_col(w: f32, child: impl IntoElement) -> impl IntoElement {
mod tests {
use super::*;
/// #485 on the path #645 did not cover. A machine the switcher knows only
/// from a listing snapshot has no store entry to name it, so its group
/// used to be labelled by the target's own spelling — and a `Profile`
/// target spells itself as its config UUID. Delete the profile and every
/// banner under the list announced a raw UUID.
#[gpui::test]
fn a_pending_machine_whose_profile_is_gone_is_not_named_by_its_uuid(
cx: &mut gpui::TestAppContext,
) {
use crate::core::session::RemoteTarget;
let (app, _vcx) = crate::ui::app::test_window::harness(cx);
// A profile id that is in no config: the state left behind when the
// profile a machine was reached through is deleted.
let id = uuid::Uuid::new_v4();
let target = RemoteTarget::Profile { id };
app.update(cx, |app, _| {
app.host_snapshots.insert(
target.host_id(),
super::HostSnapshot {
target: target.clone(),
rows: Vec::new(),
},
);
});
app.update(cx, |app, cx| {
let groups = app.switcher_groups(cx);
let group = groups
.iter()
.find(|g| g.target.as_ref() == Some(&target))
.expect("the snapshot puts its machine in the list");
assert!(
!group.label.contains(&id.to_string()),
"the switcher named a machine by its raw profile UUID: {}",
group.label
);
assert_eq!(
group.label,
t(L10nKey::RemoteProfileGone),
"a gone profile is named here the way a pane's route names it"
);
});
}
/// A wrong hostname or a stale password used to be fixable only by
/// finding the same machine again in Settings (#438). The machine is on
/// screen here, so its host row is too — worded for what the row can
+34 -5
View File
@@ -47,6 +47,8 @@ mod row_metrics {
pub(super) const GAP: f32 = 8.;
/// The ⌘N badge, when one is shown.
pub(super) const BADGE: f32 = 20.;
/// The zoom mark, when the tab has a pane zoomed over the others.
pub(super) const ZOOM: f32 = 16.;
/// `gap_1p5`, between the branch icon and its text and before the counts.
pub(super) const META_GAP: f32 = 6.;
/// The branch icon.
@@ -296,9 +298,16 @@ impl Tty7App {
} else {
0.
};
let zoomed = self.tab_is_zoomed(i);
let zoom_extra = if zoomed {
row_metrics::ZOOM + row_metrics::GAP
} else {
0.
};
// Elision is measured against this budget so the label and
// branch never wrap or overflow into CSS truncation.
let label_avail = (row_metrics::text_budget(width) - badge_extra).max(48.);
let label_avail =
(row_metrics::text_budget(width) - badge_extra - zoom_extra).max(48.);
let title_size = 0.875 * rem;
let meta_size = 0.75 * rem;
let title_font = if is_active { &title_font_active } else { &font };
@@ -326,9 +335,24 @@ impl Tty7App {
);
(shown, Some(full))
} else {
let (raw_title, home) = tab.leaf_title_and_home(Some(window), cx);
let title = strip_host_prefix(raw_title.trim());
let raw = abbreviate_home(title, home.as_deref());
// The ladder the strip and the switcher climb, read
// here for the name and not for the shortening: this
// column measures in pixels and lets a card expand the
// row back to the whole string, so it wants what
// `label_of` would have cut down rather than the cut.
use crate::ui::machine_mirror::TabLabel;
let (view, home) = tab.label_view(Some(window), cx);
let raw = match view.label() {
TabLabel::Osc(title) | TabLabel::Cwd(title) => {
abbreviate_home(strip_host_prefix(title.trim()), home.as_deref())
.into_owned()
}
TabLabel::Agent(agent) => agent.display_name().to_string(),
// A tab holding a name got one above.
TabLabel::Named(name) => name.to_string(),
TabLabel::Process(title) => title.to_string(),
TabLabel::Unknown => String::new(),
};
if raw.trim().is_empty() {
// Nothing to expand: the row is naming an unnamed
// shell, not hiding a title behind an ellipsis.
@@ -338,7 +362,7 @@ impl Tty7App {
));
(placeholder, None)
} else {
let full = SharedString::from(raw.as_ref());
let full = SharedString::from(raw);
let shown = elide_label(
&window.text_system(),
title_font,
@@ -723,6 +747,11 @@ impl Tty7App {
22.,
cx,
))
// Leading, like the chip's: the trailing end of a row is
// the badge's, and the close button fades in over it.
.when(zoomed, |row| {
row.child(self.zoom_mark(("sidebar-zoom", i), cx))
})
.child(label_region)
.when(show_badges && badge_pos < 9, |row| {
row.child(
+392 -27
View File
@@ -140,6 +140,94 @@ pub(crate) fn short_title(raw: &str, home: Option<&std::path::Path>) -> String {
label
}
/// The one place a tab gets its displayed name, whichever surface is asking.
///
/// `label()` ranks the evidence — a given name, then the title the pane is
/// showing, then an agent, then the working directory, then the process it is
/// running — and this renders whatever came back. Both callers arrive with a
/// [`TabView`](crate::ui::machine_mirror::TabView): the switcher reads one out
/// of the machine tree for a window it does not own, and the strip builds one
/// from its own live panes in
/// [`Tab::label_view`](crate::ui::app::Tab::label_view).
///
/// They used to rank their own evidence, and disagreed where it mattered most:
/// a pane with a working directory and no title — every non-PowerShell shell
/// tty7 ships integration for reports OSC 7 and no OSC 0 — was listed by the
/// switcher as `~/repo/tty7` and by the strip that owned it as "tty7", the
/// app's own name (#740).
pub(crate) fn label_of(
view: &crate::ui::machine_mirror::TabView,
index: usize,
home: Option<&std::path::Path>,
) -> String {
use crate::ui::machine_mirror::TabLabel;
let unnamed = || {
t_fmt(
L10nKey::TabUnnamedShell,
&[("n", &((index + 1).to_string()))],
)
};
// A path can shorten away to nothing (a bare "user@host:"), and the process
// name the tree carries ("zsh") is still worth more than a number.
//
// Through `stated_title` because a tab of *this* window has no process name
// to offer: `Tab::label_view` fills that slot with the placeholder a pane
// answers to before anything has spoken, and printing the app's own name
// here is the one thing #740 exists to stop. Nothing to say falls to the
// number, which is what the strip showed before it shared this renderer.
let shortened = |raw: &str| match short_title(raw, home) {
shortened if !shortened.trim().is_empty() => shortened,
_ => match crate::terminal::view::stated_title(&view.title) {
Some(title) => title.to_string(),
None => unnamed(),
},
};
match view.label() {
TabLabel::Named(name) => name.to_string(),
// Through `short_title` because a title is so often a path: the shell
// integration writes `user@host:~/dir`, and a tab spelling that out in
// full where the one beside it says "…/dir" would be the same
// disagreement in a new place.
TabLabel::Osc(title) => shortened(title),
TabLabel::Agent(agent) => agent.display_name().to_string(),
TabLabel::Cwd(cwd) => shortened(cwd),
TabLabel::Process(title) => title.to_string(),
TabLabel::Unknown => unnamed(),
}
}
/// What a row can add on hover: the name behind the one [`label_of`] cut down,
/// or `None` when it cut nothing and the tooltip would only repeat the row.
///
/// The comparison has to happen on the *same* spelling, which is the whole
/// trick here. `label_of` abbreviates a path under the home before it elides
/// it, and this returns the abbreviated form too, so a raw `/Users/x/repo`
/// measured against a label of `~/repo` looks like a difference that isn't
/// one — and every tab named after a directory inside the home would hang a
/// tooltip saying exactly what it already says. Abbreviate first, compare
/// after.
fn tooltip_of(
view: &crate::ui::machine_mirror::TabView,
index: usize,
home: Option<&std::path::Path>,
) -> Option<SharedString> {
use crate::ui::machine_mirror::TabLabel;
// The other rungs are never shortened: a given name and a process name are
// printed whole, and an agent's is a word.
let raw = match view.label() {
TabLabel::Osc(title) => title,
TabLabel::Cwd(cwd) => cwd,
_ => return None,
};
let full = abbreviate_home(raw.trim(), home);
if full.trim().is_empty() || full.as_ref() == label_of(view, index, home).as_str() {
return None;
}
Some(SharedString::from(full.into_owned()))
}
/// Width of `text` shaped in `font` at `size`, in pixels.
///
/// The window's text system caches shaped runs, so measuring the same labels
@@ -1222,6 +1310,30 @@ impl Tty7App {
}
}
/// The mark a tab wears while one of its panes is zoomed over the others
/// (#752). Without it a zoomed tab is pixel-for-pixel a tab that only ever
/// had one pane, and the only way to tell was to toggle the zoom off.
///
/// Drawn in the tab entry rather than on the pane so it reads from either
/// tab surface, and so it says something about the tabs you are *not*
/// looking at — the zoom outlives a switch away from them.
pub(crate) fn zoom_mark(&self, id: impl Into<gpui::ElementId>, cx: &App) -> gpui::AnyElement {
let tip = chord_hint(t(L10nKey::TabTooltipZoomed), "ToggleMaximizePane", cx);
div()
.id(id)
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.size(px(16.))
.text_color(cx.theme().muted_foreground)
.child(Icon::new(IconName::Maximize).size(px(11.)))
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(tip.clone()).build(window, cx)
})
.into_any_element()
}
/// The full title behind a shortened one, for the row to name on hover.
///
/// `tab_label` hands back a path elided to its last three segments and then
@@ -1229,6 +1341,11 @@ impl Tty7App {
/// read `…/a/b/c` with no way to find out which `a` that was. `None` when
/// nothing was dropped, so tabs that already show their whole name stay
/// quiet under the pointer.
///
/// It has to unshorten whatever the label was *made of*, which is why it
/// reads the same [`TabView`](crate::ui::machine_mirror::TabView) the label
/// did: a tab named after its directory wants that directory spelled out,
/// not the title it never had. See [`tooltip_of`].
pub(crate) fn tab_title_tooltip(
&self,
tab: &Tab,
@@ -1236,19 +1353,13 @@ impl Tty7App {
window: Option<&Window>,
cx: &App,
) -> Option<SharedString> {
if tab.name.as_ref().is_some_and(|n| !n.trim().is_empty()) {
return None;
}
let (raw, home) = tab.leaf_title_and_home(window, cx);
let raw = raw.trim();
if raw.is_empty() || raw == self.tab_label(tab, index, window, cx) {
return None;
}
Some(SharedString::from(
abbreviate_home(raw, home.as_deref()).into_owned(),
))
let (view, home) = tab.label_view(window, cx);
tooltip_of(&view, index, home.as_deref())
}
/// What this window puts on a tab of its own — the same ladder, through the
/// same renderer, as the switcher uses for a tab of somebody else's window.
/// See [`label_of`].
pub(crate) fn tab_label(
&self,
tab: &Tab,
@@ -1256,22 +1367,8 @@ impl Tty7App {
window: Option<&Window>,
cx: &App,
) -> String {
if let Some(name) = tab.name.as_ref() {
let trimmed = name.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
let (raw, home) = tab.leaf_title_and_home(window, cx);
let label = short_title(&raw, home.as_deref());
if label.trim().is_empty() {
t_fmt(
L10nKey::TabUnnamedShell,
&[("n", &((index + 1).to_string()))],
)
} else {
label
}
let (view, home) = tab.label_view(window, cx);
label_of(&view, index, home.as_deref())
}
/// The New Tab control: one `+` that drops the list of everything it could
@@ -1632,6 +1729,7 @@ impl Tty7App {
let agent = tab.agent(cx);
let agent_status = tab.agent_status(cx);
let agent_unread = tab.agent_unread_count(cx);
let zoomed = self.tab_is_zoomed(i);
let rename_input = self
.renaming
@@ -1759,6 +1857,13 @@ impl Tty7App {
cx,
))
})
// Leading, beside the other state marks: the trailing end of a
// chip belongs to the badge and to the close button that fades
// in over it, and a mark parked there would vanish under the
// pointer that came to read it.
.when(zoomed, |chip| {
chip.child(self.zoom_mark(("tab-zoom", i), cx))
})
.child(label_region)
.when(show_badges && i < 9, |chip| {
chip.child(
@@ -2688,4 +2793,264 @@ mod tests {
assert_eq!(spec.args, ["--login"]);
assert!(!spec.args_are_tty7_defaults);
}
/// A tab of this window as the strip reads it: `tab_label` is nothing but
/// [`label_of`] over the [`TabView`](crate::ui::machine_mirror::TabView)
/// that [`Tab::label_view`](crate::ui::app::Tab::label_view) builds from
/// the live leaf, so naming one here climbs the same ladder a real tab
/// climbs. `title` is the placeholder `label_view` fills that slot with —
/// the machine tree puts a process name there, a live pane has only the
/// name it answers to before anything has spoken.
fn strip_tab() -> crate::ui::machine_mirror::TabView {
crate::ui::machine_mirror::TabView {
id: tty7_core::core::machine::TabId::new(),
name: None,
title: crate::terminal::view::DEFAULT_TITLE.to_string(),
osc_title: None,
cwd: None,
agent: None,
status: None,
live: true,
panes: 1,
}
}
/// The home the paths below are measured against — named rather than read
/// off this machine, so the assertions do not depend on who is running
/// them (#580).
fn home() -> &'static Path {
Path::new("/Users/x")
}
#[test]
fn a_renamed_tab_keeps_its_name_over_every_other_answer() {
let mut tab = strip_tab();
tab.name = Some(" build ".into());
tab.osc_title = Some("vim — main.rs".into());
tab.cwd = Some("/Users/x/repo/tty7".into());
assert_eq!(label_of(&tab, 0, Some(home())), "build");
}
#[test]
fn a_pane_showing_a_title_is_named_by_it_and_not_by_its_directory() {
let mut tab = strip_tab();
tab.osc_title = Some("vim — main.rs".into());
tab.cwd = Some("/Users/x/repo/tty7".into());
assert_eq!(label_of(&tab, 0, Some(home())), "vim — main.rs");
// Including the title an SSH pane answers to before the far shell has
// said anything (#438): `label_view` hands that up here, so a window
// full of them still reads as hosts rather than as directories.
tab.osc_title = Some("prod-web".into());
assert_eq!(label_of(&tab, 0, Some(home())), "prod-web");
}
/// #740: every shell tty7 ships integration for except PowerShell reports
/// its directory over OSC 7 and never sets a title, which left the tab
/// reading "tty7" — the app's own name — while the switcher listing the
/// very same tab showed the directory.
#[test]
fn a_pane_that_has_only_said_where_it_is_is_named_after_that() {
let mut tab = strip_tab();
tab.cwd = Some("/Users/x/repo/tty7".into());
assert_eq!(label_of(&tab, 0, Some(home())), "~/repo/tty7");
// Through the same shortener as a title, so a deep directory is cut
// where a deep path in a title would be.
tab.cwd = Some("/Users/x/repo/tty7/crates/tty7-core/src".into());
assert_eq!(
label_of(&tab, 0, Some(home())),
super::short_title("/Users/x/repo/tty7/crates/tty7-core/src", Some(home())),
);
}
/// A tooltip exists to say what the row had to leave out. One that repeats
/// the row is worse than none, and the label and the raw string it came
/// from are not comparable until both have been abbreviated: `~/repo` and
/// `/Users/x/repo` are the same name spelled two ways, and reading them as
/// a difference hung a tooltip on every tab named after a directory under
/// the home — which, after this change, is most of them.
#[test]
fn a_tab_named_after_a_directory_says_nothing_more_on_hover_unless_it_was_cut() {
let mut tab = strip_tab();
tab.cwd = Some("/Users/x/repo".into());
assert_eq!(label_of(&tab, 0, Some(home())), "~/repo");
assert_eq!(
tooltip_of(&tab, 0, Some(home())),
None,
"the row is already showing the whole directory"
);
// Cut down to its last three segments, so the head is worth having.
tab.cwd = Some("/Users/x/repo/crates/tty7-core/src".into());
assert_eq!(label_of(&tab, 0, Some(home())), "…/crates/tty7-core/src");
assert_eq!(
tooltip_of(&tab, 0, Some(home())).as_deref(),
Some("~/repo/crates/tty7-core/src")
);
// The same holds for a title that happens to be a path — the rung this
// guard was already getting wrong before a directory could reach it.
let mut titled = strip_tab();
titled.osc_title = Some("/Users/x/repo".into());
assert_eq!(tooltip_of(&titled, 0, Some(home())), None);
// A shell integration's `user@host:` head is not in the label, so it
// is still worth spelling out.
titled.osc_title = Some("me@box:/Users/x/repo".into());
assert_eq!(
tooltip_of(&titled, 0, Some(home())).as_deref(),
Some("me@box:/Users/x/repo")
);
}
/// The one test that fails if any of the wiring is put back: a real tab,
/// built the way the window builds one, named through `tab_label` — and
/// checked against what the switcher renders from the machine tree's view
/// of that very same pane. Before this change the strip said "tty7" and
/// the switcher said the directory (#740).
#[gpui::test]
fn the_strip_names_a_titleless_pane_exactly_as_the_switcher_does(cx: &mut TestAppContext) {
use crate::ui::pane::{Pane, PaneSlot};
let (app, mut vcx) = crate::ui::app::test_window::harness(cx);
let _stream = app.update_in(&mut vcx, |app, window, cx| {
let (view, stream) = crate::terminal::view::quiet_test_pane(1, window, cx);
// A pane that has reported where it is over OSC 7 and has never
// titled itself — every shell tty7 ships integration for except
// PowerShell.
view.read(cx)
.terminal
.seed_cwd(Some(std::path::PathBuf::from("/work/repo")));
app.tabs
.push(crate::ui::app::Tab::new(Pane::leaf(PaneSlot::Ready(view))));
app.active = app.tabs.len() - 1;
stream
});
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |app, window, cx| {
let index = app.active;
let tab = &app.tabs[index];
let (view, home) = tab.label_view(Some(window), cx);
assert_eq!(view.osc_title, None, "the pane never titled itself");
assert_eq!(view.cwd.as_deref(), Some("/work/repo"));
let strip = app.tab_label(tab, index, Some(window), cx);
assert_eq!(strip, "/work/repo");
assert_ne!(
strip,
crate::terminal::view::DEFAULT_TITLE,
"and is not named after the app any more"
);
// The machine tree's reading of the same pane, which is all the
// switcher ever has: no title was seen, the cwd is the one above,
// and `title` is the foreground process name.
let from_tree = crate::ui::machine_mirror::TabView {
id: tab.tree_id.get(),
name: None,
title: "zsh".into(),
osc_title: None,
cwd: Some("/work/repo".into()),
agent: None,
status: None,
live: true,
panes: 1,
};
assert_eq!(
strip,
label_of(&from_tree, index, home.as_deref()),
"the two columns name the same tab the same way"
);
assert_eq!(
app.tab_title_tooltip(tab, index, Some(window), cx),
None,
"and the row is showing the whole path, so it stays quiet"
);
});
}
#[test]
fn a_pane_with_nothing_to_say_falls_back_the_way_it_always_did() {
// No title and no directory: the placeholder, exactly as before.
let tab = strip_tab();
assert_eq!(label_of(&tab, 0, Some(home())), "tty7");
// And a tab holding no live pane at all is still numbered.
let mut empty = strip_tab();
empty.title = String::new();
assert!(label_of(&empty, 2, Some(home())).contains('3'));
}
/// The rung under the shortener, which the two surfaces reach holding
/// different things. A shell that has said who and where it is but not
/// *where* — `user@host:` with nothing after the colon — leaves nothing to
/// show, and whatever stands in has to be something the tab does not
/// already say: the switcher has the foreground process name, and a tab of
/// this window has only the placeholder, which is the answer #740 removed.
#[test]
fn a_title_that_shortens_away_never_puts_the_app_name_back_on_the_tab() {
let mut strip = strip_tab();
strip.osc_title = Some("user@host:".into());
assert_ne!(
label_of(&strip, 0, Some(home())),
crate::terminal::view::DEFAULT_TITLE
);
assert!(
label_of(&strip, 0, Some(home())).contains('1'),
"the numbered placeholder, which is what the strip showed here \
before it shared this renderer"
);
// The switcher arrives with a real process name in that slot, and it
// is still worth more than a number.
let from_tree = crate::ui::machine_mirror::TabView {
title: "zsh".into(),
osc_title: Some("user@host:".into()),
..strip_tab()
};
assert_eq!(label_of(&from_tree, 0, Some(home())), "zsh");
}
/// A path is spelled the way the machine it is on spells it, and which
/// machine that is has nothing to do with which one tty7 is running on: a
/// remote pane reports POSIX to a Windows client, and a Windows pane
/// reports backslashes to a client that has never seen one (#580).
#[test]
fn a_cwd_is_cut_in_its_own_spelling_whichever_client_is_reading_it() {
let windows_home = Path::new(r"C:\Users\x");
// A Windows pane: shortened under its own home, and a path too deep to
// fit is rejoined with its own separator rather than with `/`.
let mut win = strip_tab();
win.cwd = Some(r"C:\Users\x\repo".into());
assert_eq!(label_of(&win, 0, Some(windows_home)), "~/repo");
win.cwd = Some(r"D:\work\a\b\proj".into());
assert_eq!(label_of(&win, 0, Some(windows_home)), r"…\a\b\proj");
// A remote pane's cwd is POSIX even when the client reading it is the
// Windows one: no drive to hang it off, no `~` borrowed from this
// machine's home, and no backslash anywhere in the answer.
let mut remote = strip_tab();
remote.cwd = Some("/srv/app".into());
assert_eq!(label_of(&remote, 0, Some(windows_home)), "/srv/app");
remote.cwd = Some("/home/deploy/app".into());
assert_eq!(
label_of(&remote, 0, Some(Path::new("/home/deploy"))),
"~/app",
"measured against the home of the host it is on, not of this one"
);
// The root of a filesystem is a directory like any other: a tab
// sitting in it says so, and says nothing more on hover.
let mut root = strip_tab();
root.cwd = Some("/".into());
assert_eq!(label_of(&root, 0, Some(home())), "/");
assert_eq!(tooltip_of(&root, 0, Some(home())), None);
}
}