desktop: tray menu panels and pinnable context menus

The tray's right-click menu is drawn by the app: one tree from tray.rs
(stable submenu keys), one frameless always-on-top menu-N window per
panel rendered by tray-menu.html with the design system, placed inside
the monitor's work area. A pure panel book keeps transient and pinned
panels apart; pinned panels (pin button, Ctrl+click, middle click) are
draggable, step aside from each other and are saved in state.json
(menuPins) and restored at start. tray_update pushes the tree to the
open panels, which update in place; closed panels are destroyed. The
native menu, rendered from the same tree, stays as the fallback and on
Linux. Only menu-* windows get the menu_* commands (menu.json).

In-app context menus get the same behaviour through a pure menu stack
(ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels
survive the next menu, are draggable and re-read their entries from
live sources so data changes update them in place.

Also drops a stray diff3 marker line from CHANGELOG.md.
This commit is contained in:
CRTkafa 2026-10-24 13:44:08 +08:00
parent 445ab1adbc
commit 5236f002f1
35 changed files with 2308 additions and 123 deletions

View file

@ -49,6 +49,11 @@ fn guard_card(w: &WebviewWindow) -> Res<()> {
policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
}
fn guard_menu(w: &WebviewWindow) -> Res<()> {
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
policy::require_menu(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
}
fn saved(s: &AppState) -> Res<std::sync::MutexGuard<'_, Saved>> {
s.saved.lock().map_err(|_| Fail::host("state poisoned"))
}
@ -1665,3 +1670,79 @@ pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<Strin
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
}
// ---- the tray menu panels (menu_panel.rs; only `menu-*` windows) ----
/// A panel: what it shows (the whole tree and its path into it).
#[tauri::command]
pub fn menu_state(window: WebviewWindow) -> Res<Option<crate::menu_panel::PanelInfo>> {
guard_menu(&window)?;
Ok(crate::menu_panel::panel_state(&window))
}
/// A panel measured itself (CSS px): sized, placed inside the work area and shown.
#[tauri::command]
pub fn menu_ready(window: WebviewWindow, width: f64, height: f64) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::ready(&window, width, height);
Ok(())
}
/// Opens a submenu beside this panel at the row whose top is `top` (CSS px).
#[tauri::command]
pub fn menu_open_sub(window: WebviewWindow, key: String, top: f64, focus: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::open_sub(&window, &key, top, focus, false);
Ok(())
}
/// Ctrl / middle click on a submenu row: opens it as its own pinned panel.
#[tauri::command]
pub fn menu_pin_sub(window: WebviewWindow, key: String, top: f64) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::open_sub(&window, &key, top, false, true);
Ok(())
}
/// The pointer moved to a plain row: this panel's submenus close.
#[tauri::command]
pub fn menu_close_subs(window: WebviewWindow) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::close_subs(&window);
Ok(())
}
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
#[tauri::command]
pub fn menu_close(window: WebviewWindow, refocus_parent: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::close(&window, refocus_parent);
Ok(())
}
/// Esc on the root menu: every transient panel closes.
#[tauri::command]
pub fn menu_dismiss(window: WebviewWindow) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::dismiss(window.app_handle());
Ok(())
}
/// Pins or unpins this panel.
#[tauri::command]
pub fn menu_pin(window: WebviewWindow, pinned: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::pin(&window, pinned);
Ok(())
}
/// A pick: the same action as the native tray menu (an id tray.rs knows; anything else is refused).
#[tauri::command]
pub fn menu_select(window: WebviewWindow, id: String) -> Res<()> {
guard_menu(&window)?;
if id.len() > 300 || crate::tray::action_of(&id).is_none() {
return Err(Fail::host("unknown menu item"));
}
crate::menu_panel::select(&window, id);
Ok(())
}