mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
feat(remote): keep the machine a window is on visible, and stop offering SSH commands that do nothing
A window on another computer looked exactly like one on this computer for as long as nothing went wrong: the home screen's status strip stays silent while the link is Attached, the workspace head shows a name someone made up, and a connected pane's row is titled by the remote shell's own cwd. The only trace was a 6px dot on the tab avatar, and the host behind it was hover-only. - The sidebar carries a machine badge under the workspace head — the same dot-plus-name the switcher's rows use — whenever the workspace is remote. - A row names its own machine when that contradicts the badge: an ssh pane inside a local workspace, or a wsl shell beside Windows ones. It stays quiet when it would only echo the badge. - The home screen, which has no sidebar to carry the badge, names the machine a new tab would open on even while the link is healthy. - The pane shown while a connection is being made asked the target for its own Display, which spells a saved profile as a bare uuid: "Connecting to 51d32f65-e669-496b-8f7d-e09cb4991cb6…". It asks the host listing now, the same fix #485 made in the switcher. - SSH: Reconnect, SSH: Remote Files and SSH: Port Forwarding were offered from every pane and returned without doing anything on most of them. They are offered where they would act, the way #549 made Save Connection as Host earn its row. - Port Forwarding also disagreed with the panel it opens: the Info panel draws a Forwards section for a remote-workspace pane, and the command only accepted a native ssh one. Both read the same rule now.
This commit is contained in:
+38
-6
@@ -2615,8 +2615,17 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The focused pane, when it has a connection forwards can be opened over.
|
||||
/// The palette asks this to decide whether to offer the command at all, so
|
||||
/// the row and what pressing it does cannot drift apart.
|
||||
pub(crate) fn forwardable_pane(&self, window: &Window, cx: &App) -> Option<u64> {
|
||||
let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?;
|
||||
let view = leaf.read(cx);
|
||||
crate::ui::right_panel::pane_holds_forwards(view).then_some(view.pane_id)
|
||||
}
|
||||
|
||||
pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some((pane_id, _)) = self.active_connected_native_ssh_pane(window, cx) else {
|
||||
let Some(pane_id) = self.forwardable_pane(window, cx) else {
|
||||
return;
|
||||
};
|
||||
self.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx);
|
||||
@@ -4174,10 +4183,29 @@ impl Tty7App {
|
||||
},
|
||||
);
|
||||
|
||||
// Offered only where it would do something. A connection opened from a
|
||||
// saved host has nothing to save, and a pane that is not an SSH one has
|
||||
// no connection at all — either would be a row that quietly did nothing
|
||||
// (#549).
|
||||
// The four commands that act on *the connection in the focused pane*.
|
||||
// Every one of them used to be offered from every pane, and every one
|
||||
// of them returned without doing anything when the pane had no
|
||||
// connection — a row that looks like it works, does nothing, and says
|
||||
// nothing about why. They are offered where they would act (#549).
|
||||
if self.ssh_session_to_restart(window, cx).is_some() {
|
||||
commands.push(
|
||||
Command::localized(L10nKey::CmdSshReconnect, CommandKind::RestartSshSession)
|
||||
.in_group(CommandGroup::Ssh),
|
||||
);
|
||||
}
|
||||
if self.active_connected_native_ssh_pane(window, cx).is_some() {
|
||||
commands.push(
|
||||
Command::localized(L10nKey::CmdSshRemoteFiles, CommandKind::ToggleSftp)
|
||||
.in_group(CommandGroup::Ssh),
|
||||
);
|
||||
}
|
||||
if self.forwardable_pane(window, cx).is_some() {
|
||||
commands.push(
|
||||
Command::localized(L10nKey::CmdSshPortForwarding, CommandKind::ShowSshForwards)
|
||||
.in_group(CommandGroup::Ssh),
|
||||
);
|
||||
}
|
||||
if self.unsaved_ssh_session(window, cx).is_some() {
|
||||
commands.push(
|
||||
Command::localized(
|
||||
@@ -7267,10 +7295,14 @@ pub(crate) fn new_terminal(
|
||||
owner,
|
||||
font_size,
|
||||
};
|
||||
// Asked of the host listing, not of the target's own `Display`: a
|
||||
// `Profile` target spells itself as a bare uuid, and this pane fills the
|
||||
// whole window while a connection is being made — "Connecting to
|
||||
// 51d32f65-e669-…" is the same thing #485 took out of the switcher.
|
||||
let machine = spawn
|
||||
.workspace
|
||||
.as_ref()
|
||||
.map(|w| w.target.to_string())
|
||||
.map(|w| crate::ui::remote_connect::target_label(cx, &w.target))
|
||||
.unwrap_or_else(|| t(L10nKey::AppLocalServerName).to_string());
|
||||
let pending = cx.new(|cx| crate::ui::pending_pane::PendingPane::new(machine, spawn, cx));
|
||||
cx.subscribe_in(
|
||||
|
||||
+9
-1
@@ -258,7 +258,15 @@ impl Tty7App {
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
let machine = self.remote_machine_label(cx);
|
||||
let status = self.remote_status(cx)?;
|
||||
let message = status.strip_message(&machine)?;
|
||||
// A healthy link says nothing over a *working* window — the sidebar
|
||||
// banner is already carrying it there. Here there are no tabs and so no
|
||||
// sidebar, and the screen is a logo and four shortcuts that look exactly
|
||||
// the same whichever machine they will run on. Say which one, before ⌘T
|
||||
// opens a shell somewhere the reader did not mean.
|
||||
let message = match status.strip_message(&machine) {
|
||||
Some(message) => message,
|
||||
None => t_fmt(L10nKey::HomeRemoteConnected, &[("machine", &machine)]),
|
||||
};
|
||||
// An install in flight replaces both halves of the strip: its own line
|
||||
// instead of the complaint that is being answered, and no button, since
|
||||
// pressing Update Server again would start a second one on top of it.
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::NewFileName => "New file name",
|
||||
L10nKey::HomeNewTab => "New Tab",
|
||||
L10nKey::HomeReopenClosedTab => "Reopen Closed Tab",
|
||||
L10nKey::HomeRemoteConnected => "Connected to {machine}",
|
||||
L10nKey::HomeSwitchWorkspace => "Switch Workspace…",
|
||||
L10nKey::HomeCommandPalette => "Command Palette…",
|
||||
L10nKey::HomeSplitRight => "Split Right",
|
||||
@@ -1193,6 +1194,13 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::RemoteStripRouteLost => {
|
||||
"The connection profile for {machine} no longer exists — it cannot reconnect"
|
||||
}
|
||||
L10nKey::RemoteStateConnecting => "connecting",
|
||||
L10nKey::RemoteStateReconnecting => "reconnecting",
|
||||
L10nKey::RemoteStatePreempted => "open elsewhere",
|
||||
L10nKey::RemoteStateDisconnected => "not connected",
|
||||
L10nKey::RemoteStateFailed => "unreachable",
|
||||
L10nKey::RemoteStateServerMismatch => "server mismatch",
|
||||
L10nKey::RemoteStateRouteLost => "profile gone",
|
||||
L10nKey::RemoteRouteParkedHint => {
|
||||
"Its connection profile no longer exists, so it will not reconnect on its own. \
|
||||
The remote session is still there — connect to the machine with a new profile \
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::NewFileName => "新しいファイル名",
|
||||
L10nKey::HomeNewTab => "新規タブ",
|
||||
L10nKey::HomeReopenClosedTab => "閉じたタブをもう一度開く",
|
||||
L10nKey::HomeRemoteConnected => "{machine} に接続済み",
|
||||
L10nKey::HomeSwitchWorkspace => "ワークスペースを切り替える…",
|
||||
L10nKey::HomeCommandPalette => "コマンドパレット…",
|
||||
L10nKey::HomeSplitRight => "右に分割",
|
||||
@@ -1246,6 +1247,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::RemoteStripPreempted => "このワークスペースは {by} で開かれました",
|
||||
L10nKey::RemoteStripFailed => "{machine} に未接続です — {error}",
|
||||
L10nKey::RemoteStripRouteLost => "{machine} の接続設定は存在しません — 再接続できません",
|
||||
L10nKey::RemoteStateConnecting => "接続中",
|
||||
L10nKey::RemoteStateReconnecting => "再接続中",
|
||||
L10nKey::RemoteStatePreempted => "他で開いています",
|
||||
L10nKey::RemoteStateDisconnected => "未接続",
|
||||
L10nKey::RemoteStateFailed => "接続できません",
|
||||
L10nKey::RemoteStateServerMismatch => "サーバー不一致",
|
||||
L10nKey::RemoteStateRouteLost => "プロファイル消失",
|
||||
L10nKey::RemoteRouteParkedHint => {
|
||||
"接続設定が存在しないため、自動再接続しません。リモートのセッションは残っています — \
|
||||
新しいプロファイルでこのマシンに接続すると、ワークスペース一覧に再表示されます。"
|
||||
|
||||
@@ -87,6 +87,7 @@ l10n_keys! {
|
||||
HomeNewTab,
|
||||
HomeReopenClosedTab,
|
||||
HomeSwitchWorkspace,
|
||||
HomeRemoteConnected,
|
||||
HomeCommandPalette,
|
||||
HomeSplitRight,
|
||||
HomeSplitDown,
|
||||
@@ -991,6 +992,13 @@ l10n_keys! {
|
||||
RemoteStripPreempted,
|
||||
RemoteStripFailed,
|
||||
RemoteStripRouteLost,
|
||||
RemoteStateConnecting,
|
||||
RemoteStateReconnecting,
|
||||
RemoteStatePreempted,
|
||||
RemoteStateDisconnected,
|
||||
RemoteStateFailed,
|
||||
RemoteStateServerMismatch,
|
||||
RemoteStateRouteLost,
|
||||
RemoteRouteParkedHint,
|
||||
RemoteNoticePreempted,
|
||||
RemoteNoticeDisconnected,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::NewFileName => "新文件名",
|
||||
L10nKey::HomeNewTab => "新标签页",
|
||||
L10nKey::HomeReopenClosedTab => "重新打开已关闭的标签页",
|
||||
L10nKey::HomeRemoteConnected => "已连接到 {machine}",
|
||||
L10nKey::HomeSwitchWorkspace => "切换工作区…",
|
||||
L10nKey::HomeCommandPalette => "命令面板…",
|
||||
L10nKey::HomeSplitRight => "向右分屏",
|
||||
@@ -1121,6 +1122,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::RemoteStripPreempted => "此工作区已在 {by} 上打开",
|
||||
L10nKey::RemoteStripFailed => "未连接到 {machine}——{error}",
|
||||
L10nKey::RemoteStripRouteLost => "{machine} 的连接配置已不存在,无法重连",
|
||||
L10nKey::RemoteStateConnecting => "连接中",
|
||||
L10nKey::RemoteStateReconnecting => "重连中",
|
||||
L10nKey::RemoteStatePreempted => "已在别处打开",
|
||||
L10nKey::RemoteStateDisconnected => "未连接",
|
||||
L10nKey::RemoteStateFailed => "无法连接",
|
||||
L10nKey::RemoteStateServerMismatch => "服务端版本不匹配",
|
||||
L10nKey::RemoteStateRouteLost => "配置已丢失",
|
||||
L10nKey::RemoteRouteParkedHint => {
|
||||
"其连接配置已不存在,不会再自动重连。远端会话仍在——\
|
||||
新建配置连上该机器后,可在工作区列表中找回。"
|
||||
|
||||
+44
-4
@@ -14,7 +14,7 @@ use crate::core::config::{Config, RightPanelTab, TabBarPosition};
|
||||
use crate::core::ssh_profile::parse_quick_connect;
|
||||
use crate::ui::i18n::{L10nKey, alias_translations, t, t_fmt};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CommandKind {
|
||||
NewTab,
|
||||
NewWorkspace,
|
||||
@@ -527,12 +527,14 @@ impl Command {
|
||||
Command::localized(L10nKey::CmdSelectAll, SelectAllText),
|
||||
];
|
||||
|
||||
// Reconnect, Remote Files and Port Forwarding are not here: each of
|
||||
// them acts on the connection in the focused pane, and each returned
|
||||
// without doing anything when there was not one. `palette_commands`
|
||||
// adds them where they would do something, the way #549 made Save
|
||||
// Connection as Host earn its row.
|
||||
let ssh = [
|
||||
Command::localized(L10nKey::CmdSshAddConnection, OpenSshConnectInput),
|
||||
Command::localized(L10nKey::CmdSshManageProfiles, OpenSshProfiles),
|
||||
Command::localized(L10nKey::CmdSshReconnect, RestartSshSession),
|
||||
Command::localized(L10nKey::CmdSshRemoteFiles, ToggleSftp),
|
||||
Command::localized(L10nKey::CmdSshPortForwarding, ShowSshForwards),
|
||||
];
|
||||
|
||||
let agents = [
|
||||
@@ -1327,6 +1329,44 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The commands that act on the connection in the focused pane are added by
|
||||
/// `Tty7App::palette_commands`, which can see whether there *is* one. Any
|
||||
/// of them left in the static list is offered from every pane, and returns
|
||||
/// without doing anything on most of them — the row that looks like it
|
||||
/// works, does nothing, and says nothing about why (#549).
|
||||
#[gpui::test]
|
||||
fn the_static_list_offers_nothing_that_needs_a_live_connection(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
cx.set_global(crate::core::config::Config::default());
|
||||
let kinds: Vec<CommandKind> = Command::base_commands(
|
||||
cx,
|
||||
ChromeState {
|
||||
rail_collapsed: false,
|
||||
right_panel_visible: false,
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.map(|c| c.kind)
|
||||
.collect();
|
||||
|
||||
for needs_connection in [
|
||||
CommandKind::RestartSshSession,
|
||||
CommandKind::ToggleSftp,
|
||||
CommandKind::ShowSshForwards,
|
||||
CommandKind::SaveSshSessionAsHost,
|
||||
] {
|
||||
assert!(
|
||||
!kinds.contains(&needs_connection),
|
||||
"{needs_connection:?} acts on a connection, so only a pane that has one may offer it"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
kinds.contains(&CommandKind::OpenSshConnectInput),
|
||||
"the commands that make a connection are always available"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frecency_nudges_without_overruling_the_match() {
|
||||
assert_eq!(frecency_bonus(0.0), 0, "an unused command gets nothing");
|
||||
|
||||
@@ -1262,4 +1262,34 @@ mod tests {
|
||||
adopt_probe(&mut state, Some(vec!["Arch".to_string()]));
|
||||
assert_eq!(state.names, vec!["Arch".to_string()]);
|
||||
}
|
||||
|
||||
/// The pane that fills a window while a connection is being made names the
|
||||
/// machine it is waiting on, and a `Profile` target spells itself as a bare
|
||||
/// uuid. Reading `target.to_string()` there put "Connecting to
|
||||
/// 51d32f65-e669-496b-8f7d-e09cb4991cb6…" on screen — the same leak #485
|
||||
/// took out of the switcher. Every caller that shows a target to a reader
|
||||
/// goes through here instead, so this pins the two answers it can give.
|
||||
#[gpui::test]
|
||||
fn a_profile_target_never_labels_itself_with_its_uuid(cx: &mut gpui::TestAppContext) {
|
||||
let mut profile = crate::core::ssh_profile::SshProfile::new("build-box");
|
||||
profile.host = "10.0.0.5".into();
|
||||
profile.user = "deploy".into();
|
||||
let saved = RemoteTarget::Profile { id: profile.id };
|
||||
let deleted = RemoteTarget::Profile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
};
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut cfg = Config::default();
|
||||
cfg.ssh_profiles = vec![profile.clone()];
|
||||
cx.set_global(cfg);
|
||||
|
||||
assert_eq!(target_label(cx, &saved), "build-box");
|
||||
assert_eq!(
|
||||
target_label(cx, &deleted),
|
||||
t(L10nKey::RemoteProfileGone),
|
||||
"a profile that is gone still may not be spelled as its uuid"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,55 @@ impl RemoteStatus {
|
||||
pub fn accepts_input(&self) -> bool {
|
||||
matches!(self, RemoteStatus::Attached)
|
||||
}
|
||||
|
||||
/// One or two words for a badge that has already spent its width on the
|
||||
/// machine's name.
|
||||
///
|
||||
/// `None` for `Attached`, which is the state every reader assumes: the dot
|
||||
/// beside it is green, and spelling "connected" out on every healthy frame
|
||||
/// is how a status line stops being read. Everything else is worth a word,
|
||||
/// and the reason behind that word is a sentence the strip already carries.
|
||||
pub fn short_label(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
RemoteStatus::Attached => None,
|
||||
RemoteStatus::Connecting => Some(t(L10nKey::RemoteStateConnecting)),
|
||||
RemoteStatus::Reconnecting { .. } => Some(t(L10nKey::RemoteStateReconnecting)),
|
||||
RemoteStatus::Preempted { .. } => Some(t(L10nKey::RemoteStatePreempted)),
|
||||
RemoteStatus::Disconnected => Some(t(L10nKey::RemoteStateDisconnected)),
|
||||
RemoteStatus::Failed(_) => Some(t(L10nKey::RemoteStateFailed)),
|
||||
RemoteStatus::ServerMismatch(_) => Some(t(L10nKey::RemoteStateServerMismatch)),
|
||||
RemoteStatus::RouteLost => Some(t(L10nKey::RemoteStateRouteLost)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour a status dot wears for this state.
|
||||
///
|
||||
/// The same ramp the switcher paints its machine rows with — green while
|
||||
/// the link is up, amber while it is being made or remade, red once it has
|
||||
/// given up, grey for a machine nobody is talking to. One machine has to
|
||||
/// read the same in the switcher and in the window it is open in, so both
|
||||
/// ask here.
|
||||
pub fn dot(&self, theme: &gpui_component::Theme) -> gpui::Hsla {
|
||||
match self {
|
||||
RemoteStatus::Attached => gpui::rgb(crate::ui::tab_strip::LIVE_DOT).into(),
|
||||
RemoteStatus::Connecting | RemoteStatus::Reconnecting { .. } => theme.warning,
|
||||
RemoteStatus::Preempted { .. } => theme.warning,
|
||||
RemoteStatus::Failed(_) | RemoteStatus::ServerMismatch(_) | RemoteStatus::RouteLost => {
|
||||
theme.danger
|
||||
}
|
||||
RemoteStatus::Disconnected => gpui::rgb(crate::ui::tab_strip::UNKNOWN_DOT).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine a window is on, when that is not this computer.
|
||||
///
|
||||
/// `None` for a local workspace on purpose: a badge shown everywhere means
|
||||
/// nothing anywhere, and every surface that asks here is asking "is there
|
||||
/// something the reader does not already assume?".
|
||||
pub(crate) struct WindowMachine {
|
||||
pub label: String,
|
||||
pub status: RemoteStatus,
|
||||
}
|
||||
|
||||
/// Which way the one button points.
|
||||
@@ -366,6 +415,32 @@ impl Tty7App {
|
||||
pane_workspace_for(cx, self.workspace)
|
||||
}
|
||||
|
||||
/// The machine this window is on, when it is not this computer — for the
|
||||
/// surfaces that have to keep saying so while everything is going *right*.
|
||||
///
|
||||
/// `remote_status` already answers `None` for a local workspace, which is
|
||||
/// the whole condition; the label costs a config read, so it is only taken
|
||||
/// once that has passed.
|
||||
pub(crate) fn window_machine(&self, cx: &gpui::App) -> Option<WindowMachine> {
|
||||
let status = self.remote_status(cx)?;
|
||||
Some(WindowMachine {
|
||||
label: self.remote_machine_label(cx),
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
/// The address behind the machine's name — `deploy@10.0.0.5:2222` — for
|
||||
/// the surfaces that show the name and can spare a tooltip for the rest.
|
||||
///
|
||||
/// `None` when the snapshot spells the endpoint the same way the label
|
||||
/// does, which is every host saved without a name: repeating a string
|
||||
/// under itself is not an explanation.
|
||||
pub(crate) fn remote_endpoint_label(&self, cx: &gpui::App) -> Option<String> {
|
||||
let host = WorkspaceStore::remote_ref(cx, self.workspace)?;
|
||||
let endpoint = host.via.as_ref()?.endpoint();
|
||||
(endpoint != remote_connect::route_label(cx, &host)).then_some(endpoint)
|
||||
}
|
||||
|
||||
pub(crate) fn remote_machine_label(&self, cx: &gpui::App) -> String {
|
||||
match WorkspaceStore::remote_ref(cx, self.workspace) {
|
||||
Some(host) => remote_connect::route_label(cx, &host),
|
||||
@@ -2414,6 +2489,37 @@ mod tests {
|
||||
assert!(!RemoteStatus::Failed("x".into()).accepts_input());
|
||||
}
|
||||
|
||||
/// The badge under the workspace head has already spent its width on the
|
||||
/// machine's name, so the state beside it is one or two words — and the
|
||||
/// state every reader assumes is no words at all. A green dot next to
|
||||
/// "java-box · connected" says the same thing twice.
|
||||
#[test]
|
||||
fn only_a_state_worth_reading_earns_a_word() {
|
||||
assert_eq!(RemoteStatus::Attached.short_label(), None);
|
||||
for status in [
|
||||
RemoteStatus::Connecting,
|
||||
RemoteStatus::Reconnecting {
|
||||
attempt: 2,
|
||||
last_error: None,
|
||||
},
|
||||
RemoteStatus::Preempted {
|
||||
by: "laptop".into(),
|
||||
},
|
||||
RemoteStatus::Disconnected,
|
||||
RemoteStatus::Failed("connection refused".into()),
|
||||
RemoteStatus::ServerMismatch("speaks control v9".into()),
|
||||
RemoteStatus::RouteLost,
|
||||
] {
|
||||
let word = status
|
||||
.short_label()
|
||||
.unwrap_or_else(|| panic!("{status:?} has nothing to say"));
|
||||
assert!(
|
||||
word.split_whitespace().count() <= 2,
|
||||
"{status:?} said {word:?}, which is a sentence, not a badge"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_flow_state_names_its_machine() {
|
||||
let choice = HostChoice {
|
||||
|
||||
+19
-8
@@ -700,14 +700,7 @@ impl Tty7App {
|
||||
if let Some(ssh) = view.ssh_spec() {
|
||||
rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable());
|
||||
}
|
||||
let connected_ssh = view
|
||||
.remote_context()
|
||||
.is_some_and(|c| c.kind == crate::daemon::protocol::RemoteKind::NativeSsh)
|
||||
&& matches!(
|
||||
view.ssh_phase(),
|
||||
Some(crate::daemon::protocol::SshPhase::Connected)
|
||||
);
|
||||
if connected_ssh || view.workspace().is_some() {
|
||||
if pane_holds_forwards(view) {
|
||||
forwards_pane = Some(view.pane_id);
|
||||
}
|
||||
git = view.git_status(cx);
|
||||
@@ -1360,6 +1353,24 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a pane has a connection that forwards can be opened over: a native
|
||||
/// ssh session that finished authenticating, or a pane the daemon reaches
|
||||
/// through a remote workspace's link.
|
||||
///
|
||||
/// The Info panel decides whether to draw the Forwards section from this, and
|
||||
/// the palette decides whether to offer *SSH: Port Forwarding* from it too.
|
||||
/// They used to answer separately and disagree: the panel showed the section
|
||||
/// for a remote-workspace pane, and the command — which asked only for a native
|
||||
/// ssh pane — returned without doing anything on exactly those panes.
|
||||
pub(crate) fn pane_holds_forwards(view: &crate::terminal::view::TerminalView) -> bool {
|
||||
use crate::daemon::protocol::{RemoteKind, SshPhase};
|
||||
let connected_ssh = view
|
||||
.remote_context()
|
||||
.is_some_and(|c| c.kind == RemoteKind::NativeSsh)
|
||||
&& matches!(view.ssh_phase(), Some(SshPhase::Connected));
|
||||
connected_ssh || view.workspace().is_some()
|
||||
}
|
||||
|
||||
/// Width of the fixed cell a git status letter is centred in.
|
||||
///
|
||||
/// Load-bearing beyond this function: `scm/panel.rs` gives its group-header
|
||||
|
||||
+18
-9
@@ -83,22 +83,31 @@ impl Tty7App {
|
||||
self.open_native_ssh_tab(spec, window, cx);
|
||||
}
|
||||
|
||||
/// The connection Reconnect would put back: one in the focused pane that
|
||||
/// has dropped and still remembers what it was dialled with.
|
||||
///
|
||||
/// What the command *does* and whether the palette offers it are the same
|
||||
/// question, so both read it here — a Reconnect row on a pane with nothing
|
||||
/// to reconnect is a button that does nothing and does not say so.
|
||||
pub(crate) fn ssh_session_to_restart(
|
||||
&self,
|
||||
window: &gpui::Window,
|
||||
cx: &gpui::App,
|
||||
) -> Option<Box<crate::daemon::protocol::NativeSshSpec>> {
|
||||
let view = self.focused_pane_view(window, cx)?;
|
||||
let view = view.read(cx);
|
||||
view.ssh_disconnected().then(|| view.ssh_spec()).flatten()
|
||||
}
|
||||
|
||||
pub(crate) fn restart_ssh_session(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
let Some(view) = self.focused_pane_view(window, cx) else {
|
||||
let Some(spec) = self.ssh_session_to_restart(window, cx) else {
|
||||
return;
|
||||
};
|
||||
let dead_spec = {
|
||||
let v = view.read(cx);
|
||||
if !v.ssh_disconnected() {
|
||||
return;
|
||||
}
|
||||
v.ssh_spec()
|
||||
};
|
||||
let Some(spec) = dead_spec else {
|
||||
let Some(view) = self.focused_pane_view(window, cx) else {
|
||||
return;
|
||||
};
|
||||
let resolved = self.resolve_restart_spec(spec, cx);
|
||||
|
||||
+170
-21
@@ -69,6 +69,21 @@ struct SidebarRowShown {
|
||||
title: Option<(SharedString, SharedString)>,
|
||||
branch: Option<(SharedString, SharedString, u32, u32)>,
|
||||
cwd: Option<(SharedString, SharedString)>,
|
||||
/// The machine the pane is on, when the row named it because the window's
|
||||
/// own banner does not.
|
||||
machine: Option<(SharedString, SharedString)>,
|
||||
}
|
||||
|
||||
/// Whether a sidebar row names the machine its pane is on.
|
||||
///
|
||||
/// The window's own machine is already on the banner under the workspace head,
|
||||
/// so a row only speaks up when it would be *contradicting* that: an ssh pane
|
||||
/// opened inside a local workspace, or a `wsl` shell beside Windows ones.
|
||||
/// Repeating the workspace's machine on every row of a remote window is how a
|
||||
/// badge stops being read.
|
||||
fn row_machine(pane: Option<String>, window: Option<&str>) -> Option<String> {
|
||||
let pane = pane?;
|
||||
(!pane.trim().is_empty() && Some(pane.as_str()) != window).then_some(pane)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -192,6 +207,9 @@ impl Tty7App {
|
||||
.collect();
|
||||
|
||||
let pointer = window.mouse_position();
|
||||
// What the banner under the workspace head already says, so a row can
|
||||
// tell whether naming its own machine would be news or an echo.
|
||||
let window_machine = self.window_machine(cx).map(|m| m.label);
|
||||
// The row text is measured against real glyphs before it is elided:
|
||||
// `text_sm` is 0.875rem and `text_xs` 0.75rem, resolved here so the
|
||||
// measurement and the render use the same sizes and family.
|
||||
@@ -442,10 +460,36 @@ impl Tty7App {
|
||||
}
|
||||
line
|
||||
});
|
||||
// A pane somewhere other than where the rest of the window is
|
||||
// says so on its own line. The window's machine is already on
|
||||
// the banner under the workspace head, so a row only speaks up
|
||||
// when it would be *contradicting* that — an ssh pane opened
|
||||
// inside a local workspace, or a `wsl` shell beside Windows
|
||||
// ones. Repeating the workspace's own machine on every row of a
|
||||
// remote window is how a badge stops being read.
|
||||
let machine_shown = row_machine(
|
||||
tab.pane
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).remote_context())
|
||||
.map(|remote| remote.target),
|
||||
window_machine.as_deref(),
|
||||
)
|
||||
.map(|target| {
|
||||
let full = SharedString::from(target);
|
||||
let shown = elide_keep_edges(
|
||||
&window.text_system(),
|
||||
&font,
|
||||
meta_size,
|
||||
&full,
|
||||
(label_avail - row_metrics::BRANCH_ICON - row_metrics::META_GAP).max(0.),
|
||||
);
|
||||
(shown, full)
|
||||
});
|
||||
// Outside a repo there is no branch line; the second line then
|
||||
// carries the compressed cwd with its root marker, so a tab
|
||||
// whose title is just a shell name still says where it lives.
|
||||
if git_line.is_none() {
|
||||
// A machine line has already answered "where", and better.
|
||||
if git_line.is_none() && machine_shown.is_none() {
|
||||
cwd_shown = tab
|
||||
.pane
|
||||
.focused_or_first(window, cx)
|
||||
@@ -481,8 +525,9 @@ impl Tty7App {
|
||||
title: full_title.map(|full| (shown_title.clone(), full)),
|
||||
branch: branch_shown.clone(),
|
||||
cwd: cwd_shown.clone(),
|
||||
machine: machine_shown.clone(),
|
||||
};
|
||||
let info = self.sidebar_info(tab, window, cx, &shown);
|
||||
let info = Self::sidebar_info(&shown);
|
||||
// Colors are captured by value so the tooltip builder (which
|
||||
// borrows no app state) can style the card on its own.
|
||||
let muted = cx.theme().muted_foreground;
|
||||
@@ -596,6 +641,25 @@ impl Tty7App {
|
||||
.when(is_active, |d| d.font_weight(FontWeight::MEDIUM))
|
||||
.child(shown_title),
|
||||
)
|
||||
.when_some(machine_shown, |col, (machine, _)| {
|
||||
col.child(
|
||||
h_flex()
|
||||
.id(("sidebar-machine-row", i))
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
gpui::svg()
|
||||
.path("icons/machine-remote.svg")
|
||||
.flex_shrink_0()
|
||||
.size(px(row_metrics::BRANCH_ICON))
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child(machine)),
|
||||
)
|
||||
})
|
||||
.children(git_line)
|
||||
.when_some(cwd_shown, |col, (cwd, _)| {
|
||||
col.child(
|
||||
@@ -980,6 +1044,8 @@ impl Tty7App {
|
||||
.pt(px(4.))
|
||||
.child(self.workspace_head(cx));
|
||||
|
||||
let machine_banner = self.remote_head_banner(cx);
|
||||
|
||||
let chip_inset = crate::ui::app::CONTENT_INSET - 7. + 4.;
|
||||
let top_bar = h_flex()
|
||||
.flex_shrink_0()
|
||||
@@ -1115,6 +1181,7 @@ impl Tty7App {
|
||||
cx,
|
||||
))
|
||||
.child(workspace_head)
|
||||
.children(machine_banner)
|
||||
.child(top_bar)
|
||||
.child(crate::ui::scrollbar::with_vertical_scrollbar(
|
||||
"tab-sidebar-scrollbar",
|
||||
@@ -1125,24 +1192,71 @@ impl Tty7App {
|
||||
.child(handle)
|
||||
}
|
||||
|
||||
/// The line under the workspace head naming the machine this window is on.
|
||||
///
|
||||
/// A window on another computer used to look exactly like one on this one
|
||||
/// for as long as nothing went wrong: the home screen's strip stays silent
|
||||
/// while the link is `Attached`, the workspace head shows a name someone
|
||||
/// made up, and a connected pane's row is titled by the remote shell's own
|
||||
/// cwd. The only trace was a 6px dot on the tab avatar. Running a command
|
||||
/// on the wrong machine is the mistake this product can make that costs the
|
||||
/// most, so the machine is stated where the window's identity already lives
|
||||
/// and left there — the same dot-plus-name the switcher's rows use, so one
|
||||
/// machine reads the same in both places.
|
||||
///
|
||||
/// `None` on a local workspace: a badge that is always there stops being
|
||||
/// read.
|
||||
fn remote_head_banner(&self, cx: &mut Context<Self>) -> Option<impl IntoElement + use<>> {
|
||||
let machine = self.window_machine(cx)?;
|
||||
let theme = cx.theme();
|
||||
let muted = theme.muted_foreground;
|
||||
let dot = machine.status.dot(theme);
|
||||
// Only when it is not the state a reader already assumes. "Connected"
|
||||
// spelled out on every frame of a healthy link is the noise this
|
||||
// banner is trying not to be; the dot carries that case alone.
|
||||
let state = machine.status.short_label();
|
||||
let endpoint = self.remote_endpoint_label(cx);
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.id("sidebar-machine")
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.gap(px(6.))
|
||||
.w_full()
|
||||
.px(px(crate::ui::app::CONTENT_INSET - 3.))
|
||||
.pt(px(5.))
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.when_some(endpoint, |row, endpoint| {
|
||||
row.tooltip(move |window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(endpoint.clone()).build(window, cx)
|
||||
})
|
||||
})
|
||||
.child(div().flex_shrink_0().size(px(6.)).rounded_full().bg(dot))
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.child(SharedString::from(machine.label)),
|
||||
)
|
||||
.when_some(state, |row, state| {
|
||||
row.child(div().flex_shrink_0().child("·"))
|
||||
.child(div().flex_shrink_0().child(state))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// What the sidebar row hid: the full title, the full branch and diff
|
||||
/// counts, the working directory, and the remote host the avatar only
|
||||
/// dots. `None` when the row showed everything — a card would add noise,
|
||||
/// not information. The host is included even for an untruncated row,
|
||||
/// because the title strips the `user@host:` prefix the avatar cannot
|
||||
/// spell out.
|
||||
/// counts, the working directory, and the machine the pane is on.
|
||||
/// `None` when the row showed everything — a card would add noise, not
|
||||
/// information.
|
||||
///
|
||||
/// Every line is decided by comparing what the row rendered against the
|
||||
/// string it was elided from. Both come from the row itself: deriving
|
||||
/// them here a second time is how a renamed tab ended up with a name the
|
||||
/// row shortened and the card refused to expand.
|
||||
fn sidebar_info(
|
||||
&self,
|
||||
tab: &crate::ui::app::Tab,
|
||||
window: &mut Window,
|
||||
cx: &gpui::App,
|
||||
shown: &SidebarRowShown,
|
||||
) -> Option<SidebarInfo> {
|
||||
fn sidebar_info(shown: &SidebarRowShown) -> Option<SidebarInfo> {
|
||||
let elided = |pair: &Option<(SharedString, SharedString)>| {
|
||||
pair.as_ref()
|
||||
.filter(|(shown, full)| shown != full)
|
||||
@@ -1164,13 +1278,16 @@ impl Tty7App {
|
||||
// The host is read off the same leaf the title and cwd came from; a
|
||||
// split tab whose panes sit on different machines would otherwise
|
||||
// name whichever one happens to be first.
|
||||
if let Some(target) = tab.pane.focused_or_first(window, cx).and_then(|leaf| {
|
||||
leaf.read(cx)
|
||||
.remote_context()
|
||||
.map(|r| SharedString::from(r.target.clone()))
|
||||
}) {
|
||||
info.host = Some(target);
|
||||
}
|
||||
//
|
||||
// The row now carries the machine itself whenever it differs from the
|
||||
// window's, so the card only takes it when the row could not: either it
|
||||
// had to elide the name, or the row stayed quiet because the banner
|
||||
// over the sidebar already says it — and a card that repeats what is
|
||||
// two rows above it is noise.
|
||||
info.host = match &shown.machine {
|
||||
Some((shown, full)) => (shown != full).then(|| full.clone()),
|
||||
None => None,
|
||||
};
|
||||
(info.title.is_some() || info.branch.is_some() || info.cwd.is_some() || info.host.is_some())
|
||||
.then_some(info)
|
||||
}
|
||||
@@ -1611,4 +1728,36 @@ mod tests {
|
||||
let names = group_names(&[&short, &long]);
|
||||
assert_eq!(names, vec!["app", "x/app"]);
|
||||
}
|
||||
|
||||
/// The banner under the workspace head names the window's machine, so the
|
||||
/// only rows worth a machine line are the ones that would contradict it.
|
||||
/// Repeating "java-box" down every row of a java-box window is how the
|
||||
/// badge stops being read; leaving an ssh pane inside a *local* workspace
|
||||
/// unlabelled is how a command lands on the wrong computer.
|
||||
#[test]
|
||||
fn a_row_names_its_machine_only_when_the_window_does_not() {
|
||||
let some = |s: &str| Some(s.to_string());
|
||||
|
||||
assert_eq!(
|
||||
row_machine(some("java-box"), None),
|
||||
some("java-box"),
|
||||
"an ssh pane in a local workspace has to say so"
|
||||
);
|
||||
assert_eq!(
|
||||
row_machine(some("java-box"), Some("java-box")),
|
||||
None,
|
||||
"every row of a java-box window would just echo the banner"
|
||||
);
|
||||
assert_eq!(
|
||||
row_machine(some("build-box"), Some("java-box")),
|
||||
some("build-box"),
|
||||
"a pane ssh'd on somewhere else still contradicts the banner"
|
||||
);
|
||||
assert_eq!(row_machine(None, Some("java-box")), None);
|
||||
assert_eq!(
|
||||
row_machine(some(" "), None),
|
||||
None,
|
||||
"a remote context with no target names nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user