feat(client/windows): the tile menu grows real submenus

The flyout CAN nest at the current reactor rev (the "no submenus" comment predated the
bump), so the per-profile families move into MenuFlyoutSubItems: one "Connect with"
(bare profile names + Default settings as leaves) and one "Pin tiles" (verb-prefixed
leaves). The top level stays a fixed handful whatever the catalog grows to. The backend
wires submenu clicks recursively and reports LEAF text, so the connect-with leaves are
matched as the fall-through arm against the catalog, and the pin leaves keep their
prefix — that prefix is what tells the two families apart in the shared callback.
UIA-verified through the nesting: Pin tiles -> Pin tile: Work persists and the pinned
tile appears in the same gesture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 18:33:11 +02:00
co-authored by Claude Fable 5
parent a9d3ca2fb1
commit 6edd7c3377
+53 -39
View File
@@ -20,14 +20,17 @@ const MENU_WAKE: &str = "Wake host";
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter.
const MENU_EDIT: &str = "Edit\u{2026}";
/// Dynamic menu-item prefixes: this flyout has no submenus, so the profile entries are flat
/// items matched by prefix. The trailing space keeps them readable AND keeps a profile named
/// e.g. "Copy link" from colliding with a fixed entry.
const MENU_WITH: &str = "Connect with: ";
/// The per-profile families nest in submenus. Submenu LEAVES are what the shared click
/// callback reports (the backend wires clicks recursively and hands back the leaf text):
/// "Connect with"'s leaves are the bare profile names + [`SUB_WITH_DEFAULT`]; "Pin tiles"'s
/// leaves keep a verb prefix, which is what tells the two families apart in the callback.
/// (A profile literally named like a fixed entry, e.g. "Connect", is shadowed by it — the
/// same last-wins rule the scope dropdown documents.)
const SUB_WITH: &str = "Connect with";
const SUB_WITH_DEFAULT: &str = "Default settings";
const SUB_PIN: &str = "Pin tiles";
const MENU_COPY_LINK: &str = "Copy link";
const MENU_SHORTCUT: &str = "Create shortcut\u{2026}";
/// Pin/unpin a profile's one-click tile for this host — dynamic per profile, prefix-matched
/// like [`MENU_WITH`] (same flat-flyout constraint, same collision-proof trailing space).
const MENU_PIN: &str = "Pin tile: ";
const MENU_UNPIN: &str = "Unpin tile: ";
const MENU_FORGET: &str = "Forget\u{2026}";
@@ -707,19 +710,19 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.menu_flyout({
// Kept short deliberately, and in sections. It had grown into a list of
// everything, with the entries you actually reach for (connect, library,
// speed) buried in list management. Two rules thin it: anything that
// CONFIGURES the host lives in the editor — the default profile and the
// pinned tiles are properties of the record, and the editor is where you
// already go to change its name or address — and what is left is
// grouped, so a glance lands on the right third of the menu.
// speed) buried in list management. The per-profile families nest in
// SUBMENUS — one "Connect with" and one "Pin tiles" — so the top level
// stays a fixed handful whatever the catalog grows to.
let mut items = vec![menu_item(MENU_CONNECT)];
// One-off connects, flat: this flyout has no submenus, so each profile
// is its own item. "Connect with" NEVER rebinds the host.
for (_, name, _) in profiles.iter() {
items.push(menu_item(format!("{MENU_WITH}{name}")));
}
// One-off connects: "Connect with" NEVER rebinds the host. Submenu
// leaves report their own text, so the leaf names stay bare.
if !profiles.is_empty() {
items.push(menu_item(format!("{MENU_WITH}Default settings")));
let mut leaves: Vec<MenuItemDef> = profiles
.iter()
.map(|(_, name, _)| menu_item(name.clone()))
.collect();
leaves.push(menu_item(SUB_WITH_DEFAULT));
items.push(menu_sub_item(SUB_WITH, leaves));
}
items.push(menu_separator());
@@ -739,13 +742,21 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
items.push(menu_item(MENU_COPY_LINK));
items.push(menu_item(MENU_SHORTCUT));
// Pin/unpin a profile's one-click tile, beside the other tile-shaped
// shortcuts (the review moved these here from the editor).
for (id, name, _) in profiles.iter() {
let pinned = pinned_now.iter().any(|x| x == id);
items.push(menu_item(format!(
"{}{name}",
if pinned { MENU_UNPIN } else { MENU_PIN }
)));
// shortcuts. The verb prefixes stay on the leaves: "Connect with"'s
// leaves are bare names, and the shared click callback only gets the
// leaf text — the prefix is what keeps the two families apart.
if !profiles.is_empty() {
let leaves: Vec<MenuItemDef> = profiles
.iter()
.map(|(id, name, _)| {
let pinned = pinned_now.iter().any(|x| x == id);
menu_item(format!(
"{}{name}",
if pinned { MENU_UNPIN } else { MENU_PIN }
))
})
.collect();
items.push(menu_sub_item(SUB_PIN, leaves));
}
items.push(menu_separator());
@@ -782,20 +793,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
// (or vanish) NOW, not on the next discovery tick.
set_hosts_rev.call(hosts_rev + 1);
}
_ if item.starts_with(MENU_WITH) => {
let name = item.trim_start_matches(MENU_WITH);
let mut target = target.clone();
// `Some("")` — not `None` — so "Default settings" really does
// override a bound host for this one connect.
target.profile = Some(
menu_profiles
.iter()
.find(|(_, n, _)| n == name)
.map(|(id, _, _)| id.clone())
.unwrap_or_default(),
);
initiate(&svc.ctx, target, &svc.set_screen, &svc.set_status)
}
MENU_SHORTCUT => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&shortcut_host,
@@ -838,7 +835,24 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
}
MENU_EDIT => sr.call(Some((fp.clone(), name.clone()))),
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
_ => {}
// "Connect with"'s submenu leaves: a bare profile name, or
// SUB_WITH_DEFAULT. `Some("")` — not `None` — so Default settings
// really does override a bound host for this one connect.
other => {
let profile_id = if other == SUB_WITH_DEFAULT {
Some(String::new())
} else {
menu_profiles
.iter()
.find(|(_, n, _)| n == other)
.map(|(id, _, _)| id.clone())
};
if let Some(id) = profile_id {
let mut target = target.clone();
target.profile = Some(id);
initiate(&svc.ctx, target, &svc.set_screen, &svc.set_status)
}
}
})
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());