дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(app): update macOS submenus in place when switching projects (#1929)
Switching a merged project tab re-pushed the shared menu, and set_menu tears down and redraws the macOS menu bar, so it flashed even when nothing visible changed (shortcuts for actions not on any menu item also counted as a change). The menu now keeps a snapshot of the visible rows: identical → no-op; otherwise only submenus whose visible items changed are patched in place. set_menu remains for the first install and real top-level changes; the Window menu isn't cleared so AppKit's window list survives. Also keeps the empty "No Recent Projects" item disabled. Co-authored-by: youngledo <[email protected]>
Цей коміт міститься в:
1 джерело
377a8c8379
коміт
32ac603e87
4 files changed
+373
-68
No files matched your search
@@ -243,9 +243,9 @@ pub fn app_cancel_quit(app: AppHandle, windows: State<'_, WindowsState>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the menu's labels / shortcut text and rebuilds it when they changed.
|
||||
/// Focusing another project window re-pushes the same shared menu; an identical
|
||||
/// config must not call `set_menu`, which redraws the macOS menu bar.
|
||||
/// Sets the menu's labels / shortcut text and updates the menu when the visible
|
||||
/// items changed. Shortcuts for actions that are not on the menu must not call
|
||||
/// `set_menu`, which redraws the macOS menu bar.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn menu_configure(
|
||||
@@ -259,7 +259,8 @@ pub fn menu_configure(
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the menu (e.g. after the recent-projects list changed).
|
||||
/// Updates the menu after the recent-projects list changed. Only the Open Recent
|
||||
/// submenu is rewritten when the rest of the menu is unchanged.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn menu_refresh(app: AppHandle, menu_state: State<'_, MenuState>, state: State<'_, AppState>) {
|
||||
|
||||
+359
-59
@@ -961,6 +961,9 @@ pub struct MenuState {
|
||||
pub config: Mutex<MenuConfig>,
|
||||
/// Action ids the focused window reports as disabled (no context / not registered).
|
||||
pub disabled: Mutex<HashSet<String>>,
|
||||
/// Visible menu last installed. Shortcuts for actions that are not on the menu
|
||||
/// are not part of this, so they must not redraw the macOS menu bar.
|
||||
rendered: Mutex<Option<Vec<RenderedMenu>>>,
|
||||
}
|
||||
|
||||
/// Stores `next` when it differs from the config last used to build the menu.
|
||||
@@ -984,7 +987,7 @@ fn item_action(id: &str) -> Option<&str> {
|
||||
|
||||
/// Whether the item `id` is enabled given the disabled action ids.
|
||||
pub fn item_enabled(id: &str, disabled: &HashSet<String>) -> bool {
|
||||
item_action(id).is_none_or(|a| !disabled.contains(a))
|
||||
id != "recent.none" && item_action(id).is_none_or(|a| !disabled.contains(a))
|
||||
}
|
||||
|
||||
/// Updates the enabled state of the installed menu's items in place (no rebuild).
|
||||
@@ -1033,6 +1036,124 @@ pub fn recent_items(recent: &[ide_workspace::RecentProject], home: Option<&Path>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One top-level menu, as it will actually be drawn.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RenderedMenu {
|
||||
id: &'static str,
|
||||
title: String,
|
||||
items: Vec<RenderedItem>,
|
||||
}
|
||||
|
||||
/// A visible menu row. Unused keymap shortcuts are intentionally absent.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum RenderedItem {
|
||||
Submenu {
|
||||
id: &'static str,
|
||||
title: String,
|
||||
items: Vec<RenderedItem>,
|
||||
},
|
||||
/// `accel` is set only for items that own a native accelerator; their shortcut
|
||||
/// is then not copied from [`MenuConfig::shortcuts`].
|
||||
Action {
|
||||
id: String,
|
||||
text: String,
|
||||
accel: Option<&'static str>,
|
||||
},
|
||||
/// Always disabled (`No Recent Projects`).
|
||||
Placeholder {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
Native {
|
||||
kind: Native,
|
||||
text: String,
|
||||
},
|
||||
Separator,
|
||||
}
|
||||
|
||||
fn prepared_spec<R: Runtime>(app: &AppHandle<R>, os: Os) -> Vec<Entry> {
|
||||
let mut entries = spec(os);
|
||||
if !crate::commands::window::updates_configured(app) {
|
||||
entries = without_action(entries, "app.checkForUpdates");
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Visible items for `entries`. Shortcuts that no item displays do not affect the result.
|
||||
fn render_bar(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) -> Vec<RenderedMenu> {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::Submenu { id, label, children } => {
|
||||
Some(RenderedMenu { id, title: config.label(id, label), items: render_items(children, config, recent) })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) -> Vec<RenderedItem> {
|
||||
let mut out = Vec::new();
|
||||
for entry in entries {
|
||||
match entry {
|
||||
Entry::Submenu { id, label, children } => out.push(RenderedItem::Submenu {
|
||||
id,
|
||||
title: config.label(id, label),
|
||||
items: render_items(children, config, recent),
|
||||
}),
|
||||
Entry::Action { id, label, accel, hint } => {
|
||||
let text = match accel {
|
||||
Some(_) => config.label(id, label),
|
||||
None => config.item_text(id, label, *hint),
|
||||
};
|
||||
out.push(RenderedItem::Action { id: (*id).into(), text, accel: *accel });
|
||||
}
|
||||
Entry::Dynamic(slot) => {
|
||||
for item in config.dynamic_items(slot) {
|
||||
out.push(RenderedItem::Action { id: item.item_id(), text: config.dynamic_text(item), accel: None });
|
||||
}
|
||||
}
|
||||
Entry::Native(kind) => {
|
||||
out.push(RenderedItem::Native { kind: *kind, text: config.label(kind.key(), kind.default_label()) })
|
||||
}
|
||||
Entry::Separator => out.push(RenderedItem::Separator),
|
||||
Entry::RecentProjects if recent.is_empty() => out.push(RenderedItem::Placeholder {
|
||||
id: "recent.none".into(),
|
||||
text: config.label("recent.none", "No Recent Projects"),
|
||||
}),
|
||||
Entry::RecentProjects => {
|
||||
for item in recent {
|
||||
out.push(RenderedItem::Action {
|
||||
id: format!("{RECENT_PREFIX}{}", item.path),
|
||||
text: item.label.clone(),
|
||||
accel: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn structure_key(item: &RenderedItem) -> (u8, &str, Option<&str>) {
|
||||
match item {
|
||||
RenderedItem::Submenu { id, .. } => (0, *id, None),
|
||||
RenderedItem::Action { id, accel, .. } => (1, id.as_str(), *accel),
|
||||
RenderedItem::Placeholder { id, .. } => (2, id.as_str(), None),
|
||||
RenderedItem::Native { kind, .. } => (3, kind.key(), None),
|
||||
RenderedItem::Separator => (4, "", None),
|
||||
}
|
||||
}
|
||||
|
||||
fn same_structure(previous: &[RenderedItem], next: &[RenderedItem]) -> bool {
|
||||
previous.len() == next.len()
|
||||
&& previous.iter().zip(next).all(|(left, right)| structure_key(left) == structure_key(right))
|
||||
}
|
||||
|
||||
fn same_toplevel(previous: &[RenderedMenu], next: &[RenderedMenu]) -> bool {
|
||||
previous.len() == next.len() && previous.iter().zip(next).all(|(left, right)| left.id == right.id)
|
||||
}
|
||||
|
||||
/// Renders the spec into a Tauri menu.
|
||||
pub fn build<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
@@ -1040,78 +1161,59 @@ pub fn build<R: Runtime>(
|
||||
config: &MenuConfig,
|
||||
recent: &[RecentItem],
|
||||
disabled: &HashSet<String>,
|
||||
) -> tauri::Result<Menu<R>> {
|
||||
build_rendered(app, &render_bar(&prepared_spec(app, os), config, recent), disabled)
|
||||
}
|
||||
|
||||
fn build_rendered<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
menus: &[RenderedMenu],
|
||||
disabled: &HashSet<String>,
|
||||
) -> tauri::Result<Menu<R>> {
|
||||
let menu = Menu::new(app)?;
|
||||
let mut spec = spec(os);
|
||||
if !crate::commands::window::updates_configured(app) {
|
||||
spec = without_action(spec, "app.checkForUpdates");
|
||||
}
|
||||
for entry in spec {
|
||||
if let Entry::Submenu { id, label, children } = entry {
|
||||
let submenu = build_submenu(app, config, recent, disabled, id, label, &children)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
match id {
|
||||
"window" => submenu.set_as_windows_menu_for_nsapp()?,
|
||||
"help" => submenu.set_as_help_menu_for_nsapp()?,
|
||||
_ => {}
|
||||
}
|
||||
menu.append(&submenu)?;
|
||||
for rendered in menus {
|
||||
let submenu = Submenu::with_id(app, format!("submenu:{}", rendered.id), &rendered.title, true)?;
|
||||
append_rendered(app, &submenu, &rendered.items, disabled)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
match rendered.id {
|
||||
"window" => submenu.set_as_windows_menu_for_nsapp()?,
|
||||
"help" => submenu.set_as_help_menu_for_nsapp()?,
|
||||
_ => {}
|
||||
}
|
||||
menu.append(&submenu)?;
|
||||
}
|
||||
Ok(menu)
|
||||
}
|
||||
|
||||
fn build_submenu<R: Runtime>(
|
||||
fn append_rendered<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
config: &MenuConfig,
|
||||
recent: &[RecentItem],
|
||||
parent: &Submenu<R>,
|
||||
items: &[RenderedItem],
|
||||
disabled: &HashSet<String>,
|
||||
id: &str,
|
||||
label: &str,
|
||||
children: &[Entry],
|
||||
) -> tauri::Result<Submenu<R>> {
|
||||
let submenu = Submenu::with_id(app, format!("submenu:{id}"), config.label(id, label), true)?;
|
||||
for child in children {
|
||||
match child {
|
||||
Entry::Submenu { id, label, children } => {
|
||||
submenu.append(&build_submenu(app, config, recent, disabled, id, label, children)?)?
|
||||
) -> tauri::Result<()> {
|
||||
for item in items {
|
||||
match item {
|
||||
RenderedItem::Submenu { id, title, items } => {
|
||||
let child = Submenu::with_id(app, format!("submenu:{id}"), title, true)?;
|
||||
append_rendered(app, &child, items, disabled)?;
|
||||
parent.append(&child)?;
|
||||
}
|
||||
Entry::Action { id, label, accel, hint } => {
|
||||
RenderedItem::Action { id, text, accel } => {
|
||||
let enabled = item_enabled(id, disabled);
|
||||
let item = match accel {
|
||||
Some(a) => MenuItem::with_id(app, *id, config.label(id, label), enabled, Some(*a))?,
|
||||
None => MenuItem::with_id(app, *id, config.item_text(id, label, *hint), enabled, None::<&str>)?,
|
||||
};
|
||||
submenu.append(&item)?;
|
||||
parent.append(&MenuItem::with_id(app, id, text, enabled, *accel)?)?;
|
||||
}
|
||||
Entry::Dynamic(slot) => {
|
||||
for d in config.dynamic_items(slot) {
|
||||
let id = d.item_id();
|
||||
let enabled = item_enabled(&id, disabled);
|
||||
submenu.append(&MenuItem::with_id(app, id, config.dynamic_text(d), enabled, None::<&str>)?)?;
|
||||
}
|
||||
}
|
||||
Entry::Native(n) => submenu.append(native(app, config, *n)?.as_ref())?,
|
||||
Entry::Separator => submenu.append(&PredefinedMenuItem::separator(app)?)?,
|
||||
Entry::RecentProjects => {
|
||||
if recent.is_empty() {
|
||||
let none = config.label("recent.none", "No Recent Projects");
|
||||
submenu.append(&MenuItem::with_id(app, "recent.none", none, false, None::<&str>)?)?;
|
||||
}
|
||||
for r in recent {
|
||||
let item =
|
||||
MenuItem::with_id(app, format!("{RECENT_PREFIX}{}", r.path), &r.label, true, None::<&str>)?;
|
||||
submenu.append(&item)?;
|
||||
}
|
||||
RenderedItem::Placeholder { id, text } => {
|
||||
parent.append(&MenuItem::with_id(app, id, text, false, None::<&str>)?)?;
|
||||
}
|
||||
RenderedItem::Native { kind, text } => parent.append(native_text(app, *kind, text)?.as_ref())?,
|
||||
RenderedItem::Separator => parent.append(&PredefinedMenuItem::separator(app)?)?,
|
||||
}
|
||||
}
|
||||
Ok(submenu)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn native<R: Runtime>(app: &AppHandle<R>, config: &MenuConfig, n: Native) -> tauri::Result<Box<dyn IsMenuItem<R>>> {
|
||||
let text = config.label(n.key(), n.default_label());
|
||||
let t = Some(text.as_str());
|
||||
fn native_text<R: Runtime>(app: &AppHandle<R>, n: Native, text: &str) -> tauri::Result<Box<dyn IsMenuItem<R>>> {
|
||||
let t = Some(text);
|
||||
let item = match n {
|
||||
Native::Undo => PredefinedMenuItem::undo(app, t)?,
|
||||
Native::Redo => PredefinedMenuItem::redo(app, t)?,
|
||||
@@ -1130,6 +1232,125 @@ fn native<R: Runtime>(app: &AppHandle<R>, config: &MenuConfig, n: Native) -> tau
|
||||
Ok(Box::new(item))
|
||||
}
|
||||
|
||||
fn clear_submenu<R: Runtime>(submenu: &Submenu<R>) -> tauri::Result<()> {
|
||||
let len = submenu.items()?.len();
|
||||
for index in (0..len).rev() {
|
||||
submenu.remove_at(index)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn existing_matches<R: Runtime>(existing: &MenuItemKind<R>, desired: &RenderedItem) -> bool {
|
||||
match (existing, desired) {
|
||||
(MenuItemKind::Submenu(submenu), RenderedItem::Submenu { id, .. }) => {
|
||||
submenu.id().as_ref() == format!("submenu:{id}").as_str()
|
||||
}
|
||||
(MenuItemKind::MenuItem(item), RenderedItem::Action { id, .. } | RenderedItem::Placeholder { id, .. }) => {
|
||||
item.id().as_ref() == id.as_str()
|
||||
}
|
||||
(MenuItemKind::Predefined(_), RenderedItem::Native { .. } | RenderedItem::Separator) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates `submenu` to `next` without replacing the menu bar.
|
||||
///
|
||||
/// `allow_rebuild` is false for the macOS Window menu: AppKit appends the open-window
|
||||
/// list to it, and clearing that menu drops the list. A structural change there falls
|
||||
/// back to installing a new menu bar.
|
||||
fn sync_children<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
submenu: &Submenu<R>,
|
||||
previous: &[RenderedItem],
|
||||
next: &[RenderedItem],
|
||||
disabled: &HashSet<String>,
|
||||
allow_rebuild: bool,
|
||||
) -> bool {
|
||||
if !same_structure(previous, next) {
|
||||
return allow_rebuild
|
||||
&& clear_submenu(submenu).is_ok()
|
||||
&& append_rendered(app, submenu, next, disabled).is_ok();
|
||||
}
|
||||
let Ok(existing) = submenu.items() else { return false };
|
||||
let extras = !allow_rebuild && existing.len() > next.len();
|
||||
if existing.len() != next.len() && !extras {
|
||||
return allow_rebuild
|
||||
&& clear_submenu(submenu).is_ok()
|
||||
&& append_rendered(app, submenu, next, disabled).is_ok();
|
||||
}
|
||||
if existing.len() < next.len() {
|
||||
return false;
|
||||
}
|
||||
for (index, item) in next.iter().enumerate() {
|
||||
if &previous[index] == item {
|
||||
continue;
|
||||
}
|
||||
if !existing_matches(&existing[index], item)
|
||||
|| !sync_item(app, &existing[index], &previous[index], item, disabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn sync_item<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
existing: &MenuItemKind<R>,
|
||||
previous: &RenderedItem,
|
||||
next: &RenderedItem,
|
||||
disabled: &HashSet<String>,
|
||||
) -> bool {
|
||||
match (existing, previous, next) {
|
||||
(
|
||||
MenuItemKind::Submenu(submenu),
|
||||
RenderedItem::Submenu { title: previous_title, items: previous_items, .. },
|
||||
RenderedItem::Submenu { title, items, .. },
|
||||
) => {
|
||||
if previous_title != title && submenu.set_text(title).is_err() {
|
||||
return false;
|
||||
}
|
||||
sync_children(app, submenu, previous_items, items, disabled, true)
|
||||
}
|
||||
(
|
||||
MenuItemKind::MenuItem(item),
|
||||
_,
|
||||
RenderedItem::Action { text, .. } | RenderedItem::Placeholder { text, .. },
|
||||
) => item.set_text(text).is_ok(),
|
||||
(MenuItemKind::Predefined(item), _, RenderedItem::Native { text, .. }) => item.set_text(text).is_ok(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn patch_menu<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
menu: &Menu<R>,
|
||||
previous: &[RenderedMenu],
|
||||
next: &[RenderedMenu],
|
||||
disabled: &HashSet<String>,
|
||||
) -> bool {
|
||||
let Ok(items) = menu.items() else { return false };
|
||||
if items.len() != next.len() {
|
||||
return false;
|
||||
}
|
||||
for (index, rendered) in next.iter().enumerate() {
|
||||
if &previous[index] == rendered {
|
||||
continue;
|
||||
}
|
||||
let Some(submenu) = items[index].as_submenu() else { return false };
|
||||
if submenu.id().as_ref() != format!("submenu:{}", rendered.id).as_str() {
|
||||
return false;
|
||||
}
|
||||
if previous[index].title != rendered.title && submenu.set_text(&rendered.title).is_err() {
|
||||
return false;
|
||||
}
|
||||
if !sync_children(app, submenu, &previous[index].items, &rendered.items, disabled, rendered.id != "window") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A menu entry for a menu bar drawn by the frontend (Windows title bar).
|
||||
/// Labels are the English defaults; the frontend localises them like the native menu.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -1199,7 +1420,12 @@ pub fn native_menu_bar() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
}
|
||||
|
||||
/// Rebuilds and installs the app menu from the current config and recent list.
|
||||
/// Installs the app menu from the current config and recent list.
|
||||
///
|
||||
/// The first install, and a change of the top-level menu ids, calls `set_menu`.
|
||||
/// On macOS that removes the menu bar and draws it again. Later updates compare the
|
||||
/// visible items and edit only the submenus that changed, so switching project tabs
|
||||
/// does not flash the menu bar.
|
||||
pub fn install<R: Runtime>(app: &AppHandle<R>, menu_state: &MenuState, recent: &[ide_workspace::RecentProject]) {
|
||||
if !native_menu_bar() {
|
||||
return;
|
||||
@@ -1208,8 +1434,23 @@ pub fn install<R: Runtime>(app: &AppHandle<R>, menu_state: &MenuState, recent: &
|
||||
let items = recent_items(recent, home.as_deref());
|
||||
let config = menu_state.config.lock().clone();
|
||||
let disabled = menu_state.disabled.lock().clone();
|
||||
match build(app, Os::current(), &config, &items, &disabled).and_then(|m| app.set_menu(m)) {
|
||||
Ok(_) => {}
|
||||
let rendered = render_bar(&prepared_spec(app, Os::current()), &config, &items);
|
||||
let mut slot = menu_state.rendered.lock();
|
||||
if slot.as_ref() == Some(&rendered) {
|
||||
return;
|
||||
}
|
||||
let patched = match slot.as_ref() {
|
||||
Some(previous) if same_toplevel(previous, &rendered) => {
|
||||
app.menu().is_some_and(|menu| patch_menu(app, &menu, previous, &rendered, &disabled))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if patched {
|
||||
*slot = Some(rendered);
|
||||
return;
|
||||
}
|
||||
match build(app, Os::current(), &config, &items, &disabled).and_then(|menu| app.set_menu(menu)) {
|
||||
Ok(_) => *slot = Some(rendered),
|
||||
Err(e) => tracing::warn!("failed to build the app menu: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -1290,6 +1531,63 @@ mod tests {
|
||||
assert!(store_config(&state, config));
|
||||
}
|
||||
|
||||
fn changed_menus(left: &[RenderedMenu], right: &[RenderedMenu]) -> Vec<&'static str> {
|
||||
assert_eq!(
|
||||
left.iter().map(|menu| menu.id).collect::<Vec<_>>(),
|
||||
right.iter().map(|menu| menu.id).collect::<Vec<_>>()
|
||||
);
|
||||
left.iter().zip(right).filter(|(a, b)| a != b).map(|(menu, _)| menu.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_shortcuts_do_not_change_the_rendered_menu() {
|
||||
let entries = spec(Os::Mac);
|
||||
let base = MenuConfig::default();
|
||||
let mut extra = base.clone();
|
||||
// Registered by a large project's language actions, but not a menu item.
|
||||
extra.shortcuts.insert("java.compile".into(), "⌘K".into());
|
||||
// Shown by the native accelerator, not by the keymap text.
|
||||
extra.shortcuts.insert("window.next".into(), "⌘`".into());
|
||||
assert_eq!(render_bar(&entries, &base, &[]), render_bar(&entries, &extra, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_windows_change_only_the_view_menu() {
|
||||
let entries = spec(Os::Mac);
|
||||
let base = MenuConfig::default();
|
||||
let mut with_maven = base.clone();
|
||||
with_maven.shortcuts.insert("toolwindow.maven".into(), "⌘9".into());
|
||||
assert_eq!(render_bar(&entries, &base, &[]), render_bar(&entries, &with_maven, &[]));
|
||||
with_maven.dynamic.insert(DYNAMIC_TOOL_WINDOWS.into(), vec![dynamic_item("toolwindow.maven", None, "Maven")]);
|
||||
assert_eq!(changed_menus(&render_bar(&entries, &base, &[]), &render_bar(&entries, &with_maven, &[])), ["view"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_projects_change_only_the_file_menu() {
|
||||
let entries = spec(Os::Mac);
|
||||
let config = MenuConfig::default();
|
||||
let recent = [RecentItem { path: "/p/a".into(), label: "a — ~/a".into() }];
|
||||
assert_eq!(
|
||||
changed_menus(&render_bar(&entries, &config, &[]), &render_bar(&entries, &config, &recent)),
|
||||
["file"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_label_or_shortcut_changes_only_its_menu() {
|
||||
let entries = spec(Os::Mac);
|
||||
let base = MenuConfig::default();
|
||||
let mut shortcuts = base.clone();
|
||||
shortcuts.shortcuts.insert("file.save".into(), "⌘S".into());
|
||||
assert_eq!(changed_menus(&render_bar(&entries, &base, &[]), &render_bar(&entries, &shortcuts, &[])), ["file"]);
|
||||
|
||||
let mut labels = base.clone();
|
||||
labels.labels.insert("menu.view".into(), "视图".into());
|
||||
let rendered = render_bar(&entries, &labels, &[]);
|
||||
assert_eq!(changed_menus(&render_bar(&entries, &base, &[]), &rendered), ["view"]);
|
||||
assert_eq!(rendered.iter().find(|menu| menu.id == "view").unwrap().title, "视图");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_items_route_to_open_recent_with_path() {
|
||||
let spec = spec(Os::Mac);
|
||||
@@ -1559,6 +1857,8 @@ mod tests {
|
||||
assert!(item_enabled("dyn:toolwindow.git", &disabled));
|
||||
assert!(item_enabled("recent:/p/refactor.rename", &disabled));
|
||||
assert!(item_enabled("submenu:refactor", &disabled));
|
||||
assert!(!item_enabled("recent.none", &disabled));
|
||||
assert!(!item_enabled("recent.none", &HashSet::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -947,12 +947,15 @@ export const commands = {
|
||||
/** A window vetoed the quit (user cancelled its unsaved-changes prompt). */
|
||||
appCancelQuit: () => __TAURI_INVOKE<void>("app_cancel_quit"),
|
||||
/**
|
||||
* Sets the menu's labels / shortcut text and rebuilds it when they changed.
|
||||
* Focusing another project window re-pushes the same shared menu; an identical
|
||||
* config must not call `set_menu`, which redraws the macOS menu bar.
|
||||
* Sets the menu's labels / shortcut text and updates the menu when the visible
|
||||
* items changed. Shortcuts for actions that are not on the menu must not call
|
||||
* `set_menu`, which redraws the macOS menu bar.
|
||||
*/
|
||||
menuConfigure: (config: MenuConfig) => __TAURI_INVOKE<void>("menu_configure", { config }),
|
||||
/** Rebuilds the menu (e.g. after the recent-projects list changed). */
|
||||
/**
|
||||
* Updates the menu after the recent-projects list changed. Only the Open Recent
|
||||
* submenu is rewritten when the rest of the menu is unchanged.
|
||||
*/
|
||||
menuRefresh: () => __TAURI_INVOKE<void>("menu_refresh"),
|
||||
/** The menu bar for a title bar drawn by the frontend (Windows). */
|
||||
menuModel: () => __TAURI_INVOKE<MenuNode[]>("menu_model"),
|
||||
|
||||
@@ -148,7 +148,8 @@ export function installMenuBridge(): () => void {
|
||||
pushEnablement(true);
|
||||
};
|
||||
// The menu bar is shared. A focused window re-pushes labels that can differ per window.
|
||||
// An identical config does not replace the menu, so switching project tabs does not redraw it.
|
||||
// The backend edits only submenus whose visible items changed, so switching project
|
||||
// tabs does not redraw the macOS menu bar.
|
||||
window.addEventListener("focus", onFocus);
|
||||
const unsubs = [
|
||||
useI18n.subscribe((s, prev) => {
|
||||
|
||||
Reference in new issue
Заблокувати користувача