From 709243f9604963a4201724e0c0e243ccc6df2cdf Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:37:08 +0700 Subject: [PATCH] fix(switcher): stop opening context menus with every row greyed out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two menus in the switcher could open with nothing to press and no word about why, which reads as broken rather than as not-yet. A remote workspace this client has never adopted has no local id, and all four row verbs address a workspace by id — so right-clicking one greyed the whole menu. A machine reached over WSL or stdio that has never connected has no home directory for New Workspace, no live link for Disconnect, and no Restart Server, which is offered to SSH alone; its menu greyed out the same way. Both now say what would make the verbs work, in the register the tab pane already uses for adopted rows ("Open this workspace to see its tabs"). The machine case gets a named predicate so the one state that reaches it stays pinned while the verbs move around. --- src/ui/i18n/en.rs | 2 + src/ui/i18n/ja.rs | 2 + src/ui/i18n/mod.rs | 2 + src/ui/i18n/zh.rs | 2 + src/ui/switcher.rs | 124 +++++++++++++++++++++++++++++++++++++-------- 5 files changed, 110 insertions(+), 22 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index c939673f..5280705d 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1054,6 +1054,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SwitcherNoTabs => "No tabs in this workspace.", L10nKey::SwitcherNoTabMatch => "No tab matches.", L10nKey::SwitcherTabsAfterOpening => "Open this workspace to see its tabs.", + L10nKey::SwitcherOpenToManage => "Open this workspace to rename or stop it.", + L10nKey::SwitcherConnectToUse => "Connect to this machine to open a workspace on it.", L10nKey::SwitcherTabCount => "{n} tabs", L10nKey::SwitcherTabCountOne => "1 tab", L10nKey::SwitcherActiveTab => "active", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 1745ffae..21716f19 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1082,6 +1082,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SwitcherNoTabs => "このワークスペースにタブはありません", L10nKey::SwitcherNoTabMatch => "一致するタブがありません", L10nKey::SwitcherTabsAfterOpening => "このワークスペースを開くとタブが表示されます", + L10nKey::SwitcherOpenToManage => "このワークスペースを開くと名前の変更や停止ができます", + L10nKey::SwitcherConnectToUse => "このマシンに接続するとワークスペースを作成できます", L10nKey::SwitcherTabCount => "{n} 個のタブ", L10nKey::SwitcherTabCountOne => "1 個のタブ", L10nKey::SwitcherActiveTab => "アクティブ", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index b44f2c25..6797ec47 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -872,6 +872,8 @@ l10n_keys! { SwitcherNoTabs, SwitcherNoTabMatch, SwitcherTabsAfterOpening, + SwitcherOpenToManage, + SwitcherConnectToUse, SwitcherTabCount, SwitcherTabCountOne, SwitcherActiveTab, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 108bbbac..7ff829b2 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -994,6 +994,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SwitcherNoTabs => "这个工作区没有标签页。", L10nKey::SwitcherNoTabMatch => "没有匹配的标签页。", L10nKey::SwitcherTabsAfterOpening => "打开这个工作区后才能看到它的标签页。", + L10nKey::SwitcherOpenToManage => "打开这个工作区后才能重命名或停止它。", + L10nKey::SwitcherConnectToUse => "连接这台机器后才能在上面新建工作区。", L10nKey::SwitcherTabCount => "{n} 个标签页", L10nKey::SwitcherTabCountOne => "1 个标签页", L10nKey::SwitcherActiveTab => "当前", diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 96117ca0..7c930841 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -2327,6 +2327,21 @@ impl RowRef { } } +/// Whether a machine's menu would open with every verb greyed out. +/// +/// New Workspace needs the machine's home directory, which only a link that +/// has come up once supplies; Disconnect needs a live link; Restart Server is +/// offered to SSH alone. A WSL or stdio machine that has never connected fails +/// all three, and the menu opened with every row greyed and nothing to say for +/// itself. One line about the link beats three dead verbs. +fn group_menu_is_empty_handed(group: &GroupRef) -> bool { + let Some(target) = group.target.as_ref() else { + // A local machine can always take a new workspace. + return false; + }; + group.home.is_none() && group.link != Link::Connected && !target.is_ssh() +} + fn group_menu( menu: gpui_component::menu::PopupMenu, group: &GroupRef, @@ -2335,6 +2350,9 @@ fn group_menu( let (a1, a2, a3) = (app.clone(), app.clone(), app); let gref = group.clone(); let can_create = group.target.is_none() || group.home.is_some(); + if group_menu_is_empty_handed(group) { + return menu.item(PopupMenuItem::label(t(L10nKey::SwitcherConnectToUse))); + } let menu = menu.item( PopupMenuItem::new(t(L10nKey::AppMenuNewWorkspace)) .disabled(!can_create) @@ -2375,27 +2393,31 @@ fn row_menu( let (a1, a2, a3, a4) = (app.clone(), app.clone(), app.clone(), app); let (id, adopt) = (row.id, row.adopt.is_some()); let stoppable = row.live; + // Every verb below addresses a workspace by its local id, and a remote + // this client has never adopted has none yet. That greyed all four out at + // once: the menu opened with nothing to press and no word about why, which + // reads as broken rather than as not-yet. Say what would make them work, + // the way the tab pane already says it for the same rows. + if adopt { + return menu.item(PopupMenuItem::label(t(L10nKey::SwitcherOpenToManage))); + } menu.item( - PopupMenuItem::new(t(L10nKey::SwitcherRename)) - .disabled(adopt) - .on_click(move |_, window, cx| { - let _ = a1.update(cx, |this, cx| this.switcher_rename(id, window, cx)); - }), + PopupMenuItem::new(t(L10nKey::SwitcherRename)).on_click(move |_, window, cx| { + let _ = a1.update(cx, |this, cx| this.switcher_rename(id, window, cx)); + }), ) .item( - PopupMenuItem::new(t(L10nKey::SwitcherOpenInNewWindow)) - .disabled(adopt) - .on_click(move |_, window, cx| { - let _ = a2.update(cx, |this, cx| { - this.close_switcher(window, cx); - crate::ui::windows::open(cx, Some(id)); - }); - }), + PopupMenuItem::new(t(L10nKey::SwitcherOpenInNewWindow)).on_click(move |_, window, cx| { + let _ = a2.update(cx, |this, cx| { + this.close_switcher(window, cx); + crate::ui::windows::open(cx, Some(id)); + }); + }), ) .separator() .item( PopupMenuItem::new(t(L10nKey::AppMenuStopWorkspace)) - .disabled(adopt || !stoppable) + .disabled(!stoppable) .on_click(move |_, window, cx| { let _ = a3.update(cx, |this, cx| { this.close_switcher(window, cx); @@ -2404,14 +2426,12 @@ fn row_menu( }), ) .item( - PopupMenuItem::new(t(L10nKey::AppMenuDeleteWorkspace)) - .disabled(adopt) - .on_click(move |_, window, cx| { - let _ = a4.update(cx, |this, cx| { - this.close_switcher(window, cx); - this.delete_workspace(id, window, cx); - }); - }), + PopupMenuItem::new(t(L10nKey::AppMenuDeleteWorkspace)).on_click(move |_, window, cx| { + let _ = a4.update(cx, |this, cx| { + this.close_switcher(window, cx); + this.delete_workspace(id, window, cx); + }); + }), ) } @@ -2500,6 +2520,66 @@ fn glyph_col(w: f32, child: impl IntoElement) -> impl IntoElement { mod tests { use super::*; + fn group_ref(target: Option, home: Option<&str>, link: Link) -> GroupRef { + GroupRef { + key: "k".into(), + label: "l".into(), + target, + home: home.map(PathBuf::from), + link, + } + } + + /// A menu that opens with every row greyed and no word about why reads as + /// broken rather than as not-yet, so the one state that reaches it has to + /// stay pinned as the verbs and their conditions move around. + #[test] + fn only_an_unconnected_non_ssh_machine_has_nothing_to_offer() { + let wsl = || { + Some(RemoteTarget::Wsl { + distro: "Ubuntu".into(), + }) + }; + let ssh = || RemoteTarget::direct("me", "host", 22); + + // The case: never connected, so no home, and not SSH, so no restart. + assert!(group_menu_is_empty_handed(&group_ref( + wsl(), + None, + Link::Offline + ))); + assert!(group_menu_is_empty_handed(&group_ref( + wsl(), + None, + Link::Failed + ))); + + // A home from an earlier link still allows a new workspace. + assert!(!group_menu_is_empty_handed(&group_ref( + wsl(), + Some("/home/me"), + Link::Offline + ))); + // A live link still allows Disconnect. + assert!(!group_menu_is_empty_handed(&group_ref( + wsl(), + None, + Link::Connected + ))); + // SSH always keeps Restart Server. + assert!(!group_menu_is_empty_handed(&group_ref( + Some(ssh()), + None, + Link::Offline + ))); + // This machine is never short of verbs. + assert!(!group_menu_is_empty_handed(&group_ref( + None, + None, + Link::Local + ))); + } + fn tab(label: &str, path: &str) -> TabRow { TabRow { id: TabId::new(),