Files
tolaria/src-tauri/src/menu.rs

568 lines
17 KiB
Rust
Raw Normal View History

use serde::{Deserialize, Deserializer};
use std::{
collections::{BTreeMap, HashSet},
error::Error,
sync::OnceLock,
};
feat: auto-build, GitHub Release, and in-app updater (#14) * ci: auto-release workflow on merge to main Rewrite .github/workflows/release.yml to trigger on every push to main instead of manual tag pushes. The workflow now: - Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER - Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel - Merges them into a universal binary using lipo - Creates a universal .dmg and signed updater tarball - Generates latest.json with per-arch and universal platform entries - Publishes a GitHub Release with auto-generated release notes - Updates a GitHub Pages release history site (gh-pages branch) Product decisions: - Universal binary approach: copy arm64 .app as base, lipo the main executable, keep everything else from arm64 (shared frameworks are architecture-independent). This is the standard Tauri pattern. - Per-arch updater tarballs are also uploaded so the Tauri updater can download the correct arch-specific build (smaller download). - Release notes are auto-generated from git log since last tag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: github pages with release history Use peaceiris/actions-gh-pages@v4 to deploy a release history site. The page fetches releases.json (also deployed) and renders each release with date, notes, and download links for .dmg files. This handles the gh-pages branch creation automatically on first run. The page is available at https://refactoringhq.github.io/laputa-app/ Product decision: used fetch() to load releases.json at runtime instead of inlining it, which is cleaner and avoids shell escaping issues with release note content. The releases.json is deployed alongside index.html. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: in-app update notification UI Replace the old window.confirm updater with a proper React-based update notification system: - useUpdater hook now exposes state machine (idle → available → downloading → ready) and actions (startDownload, openReleaseNotes, dismiss) - UpdateBanner component renders at the top of the app shell: - "Available" state: shows version, Release Notes link, Update Now button, dismiss X - "Downloading" state: animated spinner, progress bar with percentage - "Ready" state: Restart Now button to apply the update - Silently checks on startup after 3s delay; fails silently on network errors or 404 - Release Notes link opens the GitHub Pages release history site Product decisions: - Banner at top of app (not a modal) — non-intrusive, visible but not blocking. User can dismiss and continue working. - Progress bar shows during download so user knows it's working. - Separate "Restart Now" state after download so user controls when the app restarts (they may have unsaved work). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: updater component tests Rewrite useUpdater hook tests and add UpdateBanner component tests: Hook tests (10 cases): - Starts in idle state - Does nothing when not in Tauri - Checks for updates after 3s delay - Stays idle when no update available - Transitions to available when update found - Handles missing release body gracefully - Stays idle on network error (fails silently) - Dismiss returns to idle - openReleaseNotes opens correct URL - startDownload transitions through downloading to ready Component tests (10 cases): - Renders nothing when idle - Renders nothing on error - Shows version and buttons when available - Update Now calls startDownload - Release Notes calls openReleaseNotes - Dismiss button works - Shows progress bar during download - Shows 0% at start of download - Shows restart button when ready - Restart button calls restartApp All 457 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: auto-build-release wireframes Copy ui-design.pen as base. Frames to be added for: 1. Update notification banner (visible state) — horizontal bar at top of app shell with version text, Release Notes link, Update Now button, and dismiss X 2. Update download progress state — spinner icon, progress bar with percentage, downloading text 3. "Restart to apply" state — green accent, version text, Restart Now button Note: Pencil editor was not available during this session. The base design file is committed; frames will be added when the editor is accessible. The implemented component (UpdateBanner.tsx) serves as the source of truth for the design. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update ARCHITECTURE.md with release/update system Add comprehensive documentation for: - Release pipeline (4-phase workflow: version → build → release → pages) - Versioning scheme (0.YYYYMMDD.RUN_NUMBER) - Universal binary strategy (lipo merge) - Updater endpoint and latest.json manifest - In-app update UI state machine - GitHub Pages release history site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: rustfmt formatting * fix: rustfmt build.rs --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
use tauri::{
menu::{MenuBuilder, MenuItem, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder},
App, AppHandle, Emitter,
feat: auto-build, GitHub Release, and in-app updater (#14) * ci: auto-release workflow on merge to main Rewrite .github/workflows/release.yml to trigger on every push to main instead of manual tag pushes. The workflow now: - Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER - Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel - Merges them into a universal binary using lipo - Creates a universal .dmg and signed updater tarball - Generates latest.json with per-arch and universal platform entries - Publishes a GitHub Release with auto-generated release notes - Updates a GitHub Pages release history site (gh-pages branch) Product decisions: - Universal binary approach: copy arm64 .app as base, lipo the main executable, keep everything else from arm64 (shared frameworks are architecture-independent). This is the standard Tauri pattern. - Per-arch updater tarballs are also uploaded so the Tauri updater can download the correct arch-specific build (smaller download). - Release notes are auto-generated from git log since last tag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: github pages with release history Use peaceiris/actions-gh-pages@v4 to deploy a release history site. The page fetches releases.json (also deployed) and renders each release with date, notes, and download links for .dmg files. This handles the gh-pages branch creation automatically on first run. The page is available at https://refactoringhq.github.io/laputa-app/ Product decision: used fetch() to load releases.json at runtime instead of inlining it, which is cleaner and avoids shell escaping issues with release note content. The releases.json is deployed alongside index.html. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: in-app update notification UI Replace the old window.confirm updater with a proper React-based update notification system: - useUpdater hook now exposes state machine (idle → available → downloading → ready) and actions (startDownload, openReleaseNotes, dismiss) - UpdateBanner component renders at the top of the app shell: - "Available" state: shows version, Release Notes link, Update Now button, dismiss X - "Downloading" state: animated spinner, progress bar with percentage - "Ready" state: Restart Now button to apply the update - Silently checks on startup after 3s delay; fails silently on network errors or 404 - Release Notes link opens the GitHub Pages release history site Product decisions: - Banner at top of app (not a modal) — non-intrusive, visible but not blocking. User can dismiss and continue working. - Progress bar shows during download so user knows it's working. - Separate "Restart Now" state after download so user controls when the app restarts (they may have unsaved work). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: updater component tests Rewrite useUpdater hook tests and add UpdateBanner component tests: Hook tests (10 cases): - Starts in idle state - Does nothing when not in Tauri - Checks for updates after 3s delay - Stays idle when no update available - Transitions to available when update found - Handles missing release body gracefully - Stays idle on network error (fails silently) - Dismiss returns to idle - openReleaseNotes opens correct URL - startDownload transitions through downloading to ready Component tests (10 cases): - Renders nothing when idle - Renders nothing on error - Shows version and buttons when available - Update Now calls startDownload - Release Notes calls openReleaseNotes - Dismiss button works - Shows progress bar during download - Shows 0% at start of download - Shows restart button when ready - Restart button calls restartApp All 457 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: auto-build-release wireframes Copy ui-design.pen as base. Frames to be added for: 1. Update notification banner (visible state) — horizontal bar at top of app shell with version text, Release Notes link, Update Now button, and dismiss X 2. Update download progress state — spinner icon, progress bar with percentage, downloading text 3. "Restart to apply" state — green accent, version text, Restart Now button Note: Pencil editor was not available during this session. The base design file is committed; frames will be added when the editor is accessible. The implemented component (UpdateBanner.tsx) serves as the source of truth for the design. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update ARCHITECTURE.md with release/update system Add comprehensive documentation for: - Release pipeline (4-phase workflow: version → build → release → pages) - Versioning scheme (0.YYYYMMDD.RUN_NUMBER) - Universal binary strategy (lipo merge) - Updater endpoint and latest.json manifest - In-app update UI state machine - GitHub Pages release history site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: rustfmt formatting * fix: rustfmt build.rs --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
};
const APP_COMMAND_MANIFEST_JSON: &str = include_str!("../../src/shared/appCommandManifest.json");
const NOTE_DEPENDENT_GROUP: &str = "noteDependent";
const EDITOR_FIND_DEPENDENT_GROUP: &str = "editorFindDependent";
const NOTE_LIST_SEARCH_DEPENDENT_GROUP: &str = "noteListSearchDependent";
const RESTORE_DELETED_DEPENDENT_GROUP: &str = "restoreDeletedDependent";
const GIT_COMMIT_DEPENDENT_GROUP: &str = "gitCommitDependent";
const GIT_CONFLICT_DEPENDENT_GROUP: &str = "gitConflictDependent";
const GIT_NO_REMOTE_DEPENDENT_GROUP: &str = "gitNoRemoteDependent";
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn Error>>;
type AppSubmenuBuilder<'a> = SubmenuBuilder<'a, tauri::Wry, App>;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AppCommandManifest {
commands: BTreeMap<String, ManifestCommand>,
menus: Vec<ManifestMenuSection>,
app_menu: Vec<ManifestMenuItem>,
menu_state_groups: BTreeMap<String, Vec<MenuStateGroupReference>>,
}
#[derive(Debug, Deserialize)]
struct ManifestCommand {
id: String,
shortcut: Option<ManifestShortcut>,
}
#[derive(Debug, Deserialize)]
struct ManifestShortcut {
accelerator: String,
}
#[derive(Debug, Deserialize)]
struct ManifestMenuSection {
label: String,
items: Vec<ManifestMenuItem>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind")]
enum ManifestMenuItem {
#[serde(rename = "separator")]
Separator,
#[serde(rename = "command")]
Command {
command: String,
id: Option<String>,
label: PlatformLabel,
#[serde(default, deserialize_with = "deserialize_accelerator")]
accelerator: ManifestAccelerator,
enabled: Option<bool>,
},
#[serde(rename = "menu-event")]
MenuEvent {
id: String,
label: PlatformLabel,
#[serde(default, deserialize_with = "deserialize_accelerator")]
accelerator: ManifestAccelerator,
enabled: Option<bool>,
},
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PlatformLabel {
Plain(String),
Platform {
macos: Option<String>,
windows: Option<String>,
linux: Option<String>,
default: String,
},
}
#[derive(Debug, Default)]
enum ManifestAccelerator {
#[default]
Inherit,
Suppressed,
Explicit(String),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum MenuStateGroupReference {
Command { command: String },
Id { id: String },
}
fn deserialize_accelerator<'de, D>(deserializer: D) -> Result<ManifestAccelerator, D::Error>
where
D: Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(|accelerator| match accelerator {
Some(accelerator) => ManifestAccelerator::Explicit(accelerator),
None => ManifestAccelerator::Suppressed,
})
}
impl PlatformLabel {
fn resolve(&self, target_os: &str) -> &str {
match self {
Self::Plain(label) => label.as_str(),
Self::Platform {
macos,
windows,
linux,
default,
} => match target_os {
"macos" => macos.as_deref().unwrap_or(default.as_str()),
"windows" => windows.as_deref().unwrap_or(default.as_str()),
"linux" => linux.as_deref().unwrap_or(default.as_str()),
_ => default.as_str(),
},
}
}
}
impl ManifestMenuItem {
fn command_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command { command, .. } => manifest
.commands
.get(command)
.map(|command| command.id.as_str()),
Self::MenuEvent { id, .. } => Some(id.as_str()),
Self::Separator => None,
}
}
fn menu_item_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command { command, id, .. } => id.as_deref().or_else(|| {
manifest
.commands
.get(command)
.map(|command| command.id.as_str())
}),
Self::MenuEvent { id, .. } => Some(id.as_str()),
Self::Separator => None,
}
}
fn label(&self, target_os: &str) -> Option<&str> {
match self {
Self::Command { label, .. } | Self::MenuEvent { label, .. } => {
Some(label.resolve(target_os))
}
Self::Separator => None,
}
}
fn accelerator<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command {
command,
accelerator,
..
} => match accelerator {
ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()),
ManifestAccelerator::Suppressed => None,
ManifestAccelerator::Inherit => manifest
.commands
.get(command)
.and_then(|command| command.shortcut.as_ref())
.map(|shortcut| shortcut.accelerator.as_str()),
},
Self::MenuEvent { accelerator, .. } => match accelerator {
ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()),
ManifestAccelerator::Suppressed | ManifestAccelerator::Inherit => None,
},
Self::Separator => None,
}
}
fn enabled(&self) -> bool {
match self {
Self::Command { enabled, .. } | Self::MenuEvent { enabled, .. } => {
enabled.unwrap_or(true)
}
Self::Separator => true,
}
}
}
static APP_COMMAND_MANIFEST: OnceLock<AppCommandManifest> = OnceLock::new();
static CUSTOM_MENU_IDS: OnceLock<HashSet<String>> = OnceLock::new();
fn manifest() -> &'static AppCommandManifest {
APP_COMMAND_MANIFEST.get_or_init(|| {
serde_json::from_str(APP_COMMAND_MANIFEST_JSON)
.expect("shared app command manifest must be valid JSON")
})
}
fn manifest_menu_items() -> impl Iterator<Item = &'static ManifestMenuItem> {
let manifest = manifest();
manifest
.menus
.iter()
.flat_map(|section| section.items.iter())
.chain(manifest.app_menu.iter())
}
fn custom_menu_ids() -> &'static HashSet<String> {
CUSTOM_MENU_IDS.get_or_init(|| {
manifest_menu_items()
.filter_map(|item| item.menu_item_id(manifest()))
.map(str::to_owned)
.collect()
})
}
fn manifest_section(label: &str) -> Result<&'static ManifestMenuSection, Box<dyn Error>> {
manifest()
.menus
.iter()
.find(|section| section.label == label)
.ok_or_else(|| format!("Missing menu section in command manifest: {label}").into())
}
fn app_menu_includes_services(target_os: &str) -> bool {
target_os == "macos"
}
fn build_manifest_menu_item(
app: &App,
item: &ManifestMenuItem,
) -> Result<Option<MenuItem<tauri::Wry>>, Box<dyn Error>> {
let Some(id) = item.menu_item_id(manifest()) else {
return Ok(None);
};
let Some(label) = item.label(std::env::consts::OS) else {
return Ok(None);
};
let mut builder = MenuItemBuilder::new(label).id(id).enabled(item.enabled());
if let Some(accelerator) = item.accelerator(manifest()) {
builder = builder.accelerator(accelerator);
}
Ok(Some(builder.build(app)?))
}
fn append_manifest_item<'a>(
app: &'a App,
builder: AppSubmenuBuilder<'a>,
item: &ManifestMenuItem,
) -> Result<AppSubmenuBuilder<'a>, Box<dyn Error>> {
if matches!(item, ManifestMenuItem::Separator) {
return Ok(builder.separator());
}
let Some(item) = build_manifest_menu_item(app, item)? else {
return Ok(builder);
};
Ok(builder.item(&item))
}
fn build_manifest_menu(app: &App, label: &str) -> MenuResult {
let section = manifest_section(label)?;
let mut builder = SubmenuBuilder::new(app, section.label.as_str());
for item in &section.items {
builder = append_manifest_item(app, builder, item)?;
}
Ok(builder.build()?)
}
fn build_app_menu(app: &App) -> MenuResult {
let mut builder = SubmenuBuilder::new(app, "Tolaria").about(None).separator();
for item in &manifest().app_menu {
builder = append_manifest_item(app, builder, item)?;
}
builder = builder.separator();
if app_menu_includes_services(std::env::consts::OS) {
builder = builder
.services()
.separator()
.hide()
.hide_others()
.show_all()
.separator();
}
Ok(builder.quit().build()?)
}
fn build_file_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "File")
}
fn build_edit_menu(app: &App) -> MenuResult {
let section = manifest_section("Edit")?;
let mut items = section.items.iter();
let mut builder = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste();
if let Some(paste_plain_text) = items.next() {
builder = append_manifest_item(app, builder, paste_plain_text)?;
}
builder = builder.separator().select_all().separator();
if matches!(items.clone().next(), Some(ManifestMenuItem::Separator)) {
items.next();
}
for item in items {
builder = append_manifest_item(app, builder, item)?;
}
Ok(builder.build()?)
}
fn build_view_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "View")
}
fn build_go_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "Go")
}
fn build_note_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "Note")
}
fn build_vault_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "Vault")
}
fn build_window_menu(app: &App) -> MenuResult {
Ok(SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?)
}
pub fn setup_menu(app: &App) -> Result<(), Box<dyn Error>> {
let app_menu = build_app_menu(app)?;
let file_menu = build_file_menu(app)?;
let edit_menu = build_edit_menu(app)?;
let view_menu = build_view_menu(app)?;
let go_menu = build_go_menu(app)?;
let note_menu = build_note_menu(app)?;
let vault_menu = build_vault_menu(app)?;
let window_menu = build_window_menu(app)?;
let menu = MenuBuilder::new(app)
.item(&app_menu)
.item(&file_menu)
.item(&edit_menu)
.item(&view_menu)
.item(&go_menu)
.item(&note_menu)
.item(&vault_menu)
.item(&window_menu)
.build()?;
app.set_menu(menu)?;
app.on_menu_event(|app_handle, event| {
let id = event.id().0.as_str();
let _ = emit_custom_menu_event(app_handle, id);
});
Ok(())
}
fn emitted_menu_event_id(id: &str) -> Option<&'static str> {
manifest_menu_items().find_map(|item| {
if item.menu_item_id(manifest()) == Some(id) {
item.command_id(manifest())
} else {
None
}
})
}
pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), String> {
if !custom_menu_ids().contains(id) {
return Err(format!("Unknown custom menu event: {id}"));
}
let emitted_id = emitted_menu_event_id(id)
.ok_or_else(|| format!("Missing emitted command for custom menu event: {id}"))?;
app_handle
2026-04-19 13:47:02 +02:00
.emit("menu-event", emitted_id)
.map_err(|err| format!("Failed to emit menu-event {emitted_id}: {err}"))
}
fn menu_state_group_ids(group_name: &str) -> Vec<&'static str> {
manifest()
.menu_state_groups
.get(group_name)
.into_iter()
.flatten()
.filter_map(|reference| match reference {
MenuStateGroupReference::Command { command } => manifest()
.commands
.get(command)
.map(|command| command.id.as_str()),
MenuStateGroupReference::Id { id } => Some(id.as_str()),
})
.collect()
}
fn set_items_enabled<'a>(
app_handle: &AppHandle,
ids: impl IntoIterator<Item = &'a str>,
enabled: bool,
) {
let Some(menu) = app_handle.menu() else {
return;
};
for id in ids {
if let Some(MenuItemKind::MenuItem(mi)) = menu.get(id) {
let _ = mi.set_enabled(enabled);
}
}
}
fn set_menu_state_group_enabled(app_handle: &AppHandle, group_name: &str, enabled: bool) {
set_items_enabled(app_handle, menu_state_group_ids(group_name), enabled);
}
/// Enable or disable menu items that depend on having an active note tab.
pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, NOTE_DEPENDENT_GROUP, enabled);
}
2026-04-27 10:12:13 +02:00
/// Enable or disable menu items that depend on the editor being the active surface.
pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, EDITOR_FIND_DEPENDENT_GROUP, enabled);
2026-04-27 10:12:13 +02:00
}
2026-04-21 00:47:45 +02:00
/// Enable or disable menu items that depend on the note list being the active surface.
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_GROUP, enabled);
2026-04-21 00:47:45 +02:00
}
/// Enable or disable menu items that depend on having uncommitted changes.
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, GIT_COMMIT_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on having merge conflicts.
pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, GIT_CONFLICT_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on the active vault having no remote.
pub fn set_git_no_remote_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on a deleted note preview being active.
pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, RESTORE_DELETED_DEPENDENT_GROUP, enabled);
}
#[cfg(test)]
mod tests {
use super::*;
fn menu_item_by_id(id: &str) -> &'static ManifestMenuItem {
manifest_menu_items()
.find(|item| item.menu_item_id(manifest()) == Some(id))
.unwrap_or_else(|| panic!("missing menu item {id}"))
}
#[test]
fn custom_ids_are_manifest_menu_item_ids() {
let expected: HashSet<_> = manifest_menu_items()
.filter_map(|item| item.menu_item_id(manifest()))
.map(str::to_owned)
.collect();
assert_eq!(custom_menu_ids(), &expected);
assert!(custom_menu_ids().contains("file-quick-open-alias"));
}
#[test]
fn manifest_command_items_reference_known_commands() {
for item in manifest_menu_items() {
if let ManifestMenuItem::Command { command, .. } = item {
assert!(
manifest().commands.contains_key(command),
"menu item references missing command key {command}"
);
}
}
}
2026-04-21 00:47:45 +02:00
#[test]
fn overridden_menu_item_ids_emit_their_primary_command() {
assert_eq!(
emitted_menu_event_id("file-quick-open-alias"),
Some("file-quick-open")
);
assert_eq!(
emitted_menu_event_id("edit-toggle-note-list-search"),
Some("edit-toggle-note-list-search")
);
assert_eq!(emitted_menu_event_id("file-save"), Some("file-save"));
2026-04-21 00:47:45 +02:00
}
2026-04-27 10:12:13 +02:00
#[test]
fn state_group_ids_are_manifest_menu_items() {
for (group, references) in &manifest().menu_state_groups {
for reference in references {
let id = match reference {
MenuStateGroupReference::Command { command } => manifest()
.commands
.get(command)
.map(|command| command.id.as_str())
.unwrap_or_else(|| panic!("state group {group} references {command}")),
MenuStateGroupReference::Id { id } => id.as_str(),
};
assert!(
custom_menu_ids().contains(id),
"state group {group} references non-menu item {id}"
);
}
2026-04-27 10:12:13 +02:00
}
}
#[test]
fn view_toggle_properties_keeps_renderer_owned_accelerator() {
let item = menu_item_by_id("view-toggle-properties");
assert_eq!(item.accelerator(manifest()), None);
}
#[test]
fn no_duplicate_custom_ids() {
let mut seen = HashSet::new();
for id in manifest_menu_items().filter_map(|item| item.menu_item_id(manifest())) {
assert!(seen.insert(id), "duplicate custom ID: {id}");
}
}
#[test]
fn app_services_menu_is_macos_only() {
assert!(app_menu_includes_services("macos"));
assert!(!app_menu_includes_services("windows"));
assert!(!app_menu_includes_services("linux"));
}
}