Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions crates/b2-desktop/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,26 @@ Every new surface owes all four. They are cheap while you're building it and exp
the gate is *not*: `conflicts()` asks a same-scope question, and scope buys nothing here — a menu
accelerator is taken before the webview is consulted, so `menuOverlaps` compares across every
scope.

**One section is B2's own.** View ▸ Zoom In / Zoom Out / Actual Size (⌘= ⌘- ⌘0) are the only rows
that are not a `PredefinedMenuItem` restating a chord macOS assigned: they are `Item::Command`,
and B2 *chooses* those keystrokes. They live in the menu rather than in `bindings.ts` because
macOS expects them there with their chords printed beside them, and an accelerator the menu owns
never reaches the webview — a chord spelled in both places is a chord that only ever fires once,
from the menu. Two things follow. The host must now *do* something when a menu item fires, so
`main.rs` has an `on_menu_event` that emits the item's id (`MENU_COMMAND_EVENT`, mirrored in
`ui/src/api.ts`) and stops there — what a size means is `ui/src/zoom.ts`'s, because this crate
holds no logic. And the accelerator must be spelled twice, once for the UI's parser and once for
muda's; `muda_accelerator` **derives** the second from the first, because Tauri parses an
accelerator with `.parse().ok()` and a spelling it can't read becomes a menu line that silently
has no shortcut. If you add another `Item::Command`, that derivation and `menukeys.ts` are what
must move with it.

What that trade costs, stated once so nobody re-discovers it as a bug: ⌘⇧= does **not** zoom in.
Tauri takes the accelerator as a string and splits it on `+`, so `Key::Character("+")` is
unreachable and the item's key equivalent is `=` — which AppKit matches only without ⇧. Zed's
View menu is spelled the same way. The fix, if it is ever wanted, is a webview binding for
`Mod-+` / `Mod-_` alone: different keystrokes from the menu's, so `menuOverlaps` stays empty.
- **`ui/src/chat.ts`** — the chat pane's pure logic, and sidenav.ts's client rather than its rival
([#155](https://github.com/AlteredCraft/B2/issues/155)). Chat is the right column's *third mode*, so its
transcript is emitted as the same `SideRow` shape discovery's cards are, and `sideRows` delegates here
Expand Down
18 changes: 18 additions & 0 deletions crates/b2-desktop/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,24 @@ pub fn menu_chords() -> Vec<crate::menu::MenuChord> {
crate::menu::chords()
}

/// Set the window's **page zoom** — the whole rendering scaled the way ⌘+ does in Safari,
/// which is what B2's ⌘= / ⌘- / ⌘0 mean (`ui/src/zoom.ts`). WebKit's `pageZoom`, and the
/// one thing here that is genuinely the host's: a webview cannot zoom itself, and scaling
/// with CSS would grow the text while the pixel-sized chrome around it stayed put.
///
/// A pass-through by design. The ladder, its walls, the snapping of an off-ladder value
/// and the remembering of the choice are all the UI's, tested there in node — this crate
/// holds no rule about *which* sizes are allowed, exactly as it holds no rule about which
/// column widths are. `factor` is a scale, so it is only ever the positive, bounded number
/// `adoptZoom` produced; the guard here is the type, and anything stranger is a webview
/// the platform refuses to render, which is what the error variant is for.
#[tauri::command]
pub fn set_zoom(window: tauri::WebviewWindow, factor: f64) -> Result<(), CmdError> {
window
.set_zoom(factor)
.map_err(|e| CmdError::ZoomFailed(e.to_string()))
}

/// **Flow ④ — one grounded answer**: condense -> retrieve -> assemble -> stream -> cite,
/// all of it behind `Vault::ask`. The host's whole contribution is the shape of the
/// *delivery*: Tauri runs the `(async)` body on a worker thread, tokens stream to the
Expand Down
11 changes: 11 additions & 0 deletions crates/b2-desktop/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ pub enum CmdError {
/// to the webview.
#[error("clipboard read failed: {0}")]
ClipboardFailed(String),
/// The webview refused a page-zoom change (`set_zoom`, the ⌘= / ⌘- / ⌘0 family).
/// WebKit's `pageZoom` does not fail in practice, so this exists to keep the handler
/// honest rather than to describe something a user is likely to see — the same shape
/// as [`CmdError::OpenFailed`]: the platform's detail, logged in full server-side,
/// generic to the webview.
#[error("could not change the window size: {0}")]
ZoomFailed(String),
/// `import_file` was handed a payload that isn't base64 — the drop transport's own
/// failure, before the façade ever sees bytes. Not something the user did: it means
/// the frontend's encoder produced something the host can't read, so the message
Expand Down Expand Up @@ -148,6 +155,10 @@ pub fn user_message(err: &CmdError) -> String {
CmdError::ClipboardFailed(_) => {
"Couldn't read the clipboard. Copy the text again, then paste.".to_string()
}
CmdError::ZoomFailed(_) => {
"Couldn't change the text size. Try again, or press ⌘0 to go back to the default."
.to_string()
}
CmdError::VaultRequired => {
"No vault open. Launch B2 with a vault path, or set B2_VAULT_PATH to your vault folder."
.to_string()
Expand Down
11 changes: 10 additions & 1 deletion crates/b2-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tauri::Manager;
use tauri::{Emitter, Manager};
use watch::VaultWatcher;

/// How often [`AppState::cancel_and_wait_for_reindex`] re-asserts the cancel flag and
Expand Down Expand Up @@ -406,6 +406,14 @@ fn main() {
// — and AppKit dispatches them before the webview sees a key, so the keyboard
// registry can't observe them either. `menu::MENU` is that list, made data.
.menu(menu::build)
// ...and the handler for the lines in it that are B2's own rather than the
// platform's (the View menu's three sizes). The host forwards the chosen id and
// stops there: what a size *means* — the ladder, its ends, remembering it — is
// `ui/src/zoom.ts`'s, because this crate holds no logic. A predefined item never
// arrives here; those are handled natively, which is the point of them.
.on_menu_event(|app, event| {
let _ = app.emit(menu::MENU_COMMAND_EVENT, event.id().0.as_str());
})
// The dialog plugin backs the native folder picker for `choose_vault`. It is
// driven host-side only; the webview gets no dialog permission (capabilities/
// default.json), so it can never open a dialog itself.
Expand Down Expand Up @@ -473,6 +481,7 @@ fn main() {
commands::embed_device,
commands::embed_stats,
commands::menu_chords,
commands::set_zoom,
commands::ask,
commands::cancel_ask,
commands::chat_setup,
Expand Down
192 changes: 176 additions & 16 deletions crates/b2-desktop/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,20 @@
//! macOS. Neither survives here.

use serde::Serialize;
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
use tauri::menu::{AboutMetadata, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::{AppHandle, Runtime};

/// The event the host emits when one of B2's **own** menu items is chosen — the View
/// menu's three zoom lines, today. The payload is the item's [`ItemSpec::id`], and
/// `ui/src/api.ts` carries the mirror of this string; change the two together.
///
/// Why an event at all, rather than the host simply zooming. The *rule* — the ladder of
/// sizes, its walls, and remembering the choice — lives in `ui/src/zoom.ts`, because a
/// reading size is a viewing preference and this crate holds no logic (the one rule).
/// AppKit just happens to be where the keystroke lands, so the host's whole job is to
/// say which line was chosen and let the frontend decide what that means.
pub const MENU_COMMAND_EVENT: &str = "menu-command";

/// The native behavior an item delegates to — one variant per [`PredefinedMenuItem`]
/// constructor B2 uses. An enum rather than a function pointer so [`MENU`] stays a
/// plain, readable table that the tests below can walk.
Expand All @@ -47,6 +58,13 @@ enum Item {
/// macOS's name for `maximize` — the label the platform itself uses.
Zoom,
Separator,
/// **B2's own**, rather than a native behavior delegated to. The only kind of item
/// here that has no `PredefinedMenuItem` behind it: choosing it emits
/// [`MENU_COMMAND_EVENT`] carrying the row's id, and the frontend decides what it
/// means. Its accelerator is derived from the row's `keys` ([`muda_accelerator`]),
/// so — unlike every predefined row above, whose chord muda assigns and this table
/// merely restates — this is a chord B2 actually chooses.
Command,
}

/// One line of the menu.
Expand Down Expand Up @@ -177,14 +195,42 @@ const MENU: &[SectionSpec] = &[
},
],
},
// The one section with items of B2's own. The three sizes are here rather than in
// `ui/src/bindings.ts` for the reason this module exists at all: a menu accelerator
// is dispatched before the key window's responder chain, so a chord spelled in both
// places is a chord the webview never receives. Since macOS expects Zoom In / Zoom
// Out / Actual Size to *be* in the View menu — with their chords printed beside them,
// which is where most people find them — the menu is the honest owner, and the
// registry stays out of these three keystrokes entirely.
SectionSpec {
title: "View",
items: &[ItemSpec {
id: "view.fullscreen",
item: Item::Fullscreen,
label: "Toggle Full Screen",
keys: Some("Mod-Ctrl-f"),
}],
items: &[
ItemSpec {
id: "view.zoom-in",
item: Item::Command,
label: "Zoom In",
keys: Some("Mod-="),
},
ItemSpec {
id: "view.zoom-out",
item: Item::Command,
label: "Zoom Out",
keys: Some("Mod--"),
},
ItemSpec {
id: "view.zoom-reset",
item: Item::Command,
label: "Actual Size",
keys: Some("Mod-0"),
},
SEPARATOR,
ItemSpec {
id: "view.fullscreen",
item: Item::Fullscreen,
label: "Toggle Full Screen",
keys: Some("Mod-Ctrl-f"),
},
],
},
SectionSpec {
title: "Window",
Expand Down Expand Up @@ -236,20 +282,70 @@ pub fn chords() -> Vec<MenuChord> {
.collect()
}

/// One chord, translated from the registry's spelling into the one Tauri's accelerator
/// parser reads (`Mod-Shift-z` → `CmdOrCtrl+Shift+z`).
///
/// **Derived rather than written down**, and that is the whole point of the function: an
/// [`Item::Command`] row would otherwise carry the same chord twice — once for the UI to
/// mirror and once for muda to bind — with nothing but care keeping them equal. Tauri
/// takes the accelerator as a string and *silently drops one it can't parse*
/// (`.parse().ok()`), so the failure mode of a drifted second spelling is not an error
/// but a menu item that quietly has no shortcut. One source, no drift, no silence.
///
/// The split is CodeMirror's own rule, `-(?!$)`, for the reason `parseChord` gives: `-`
/// is both the separator and a key you can press, so `Mod--` is ⌘ plus the hyphen.
fn muda_accelerator(chord: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
let mut rest = chord;
// Cut at every `-` that isn't the last character; what's left when none remains is
// the key. `split` can't express "not at the end", so this walks it.
while let Some(i) = rest[..rest.len().saturating_sub(1)].find('-') {
parts.push(&rest[..i]);
rest = &rest[i + 1..];
}
let mods = parts.iter().map(|m| match *m {
"Mod" => "CmdOrCtrl",
other => other,
});
mods.chain(std::iter::once(rest))
.collect::<Vec<_>>()
.join("+")
}

/// Build the menu [`MENU`] describes — what `tauri::Builder::menu` installs.
pub fn build<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
let about = about_metadata(app);
let menu = Menu::new(app)?;
for section in MENU {
let submenu = Submenu::new(app, section.title, true)?;
for spec in section.items {
submenu.append(&predefined(app, spec, &about)?)?;
submenu.append(item_for(app, spec, &about)?.as_ref())?;
}
menu.append(&submenu)?;
}
Ok(menu)
}

/// One [`ItemSpec`] as the native item it becomes — predefined for everything the
/// platform already does, and B2's own for [`Item::Command`].
///
/// Boxed because those are two unrelated types and a submenu takes `&dyn IsMenuItem`;
/// it is one allocation per row, once, at launch.
fn item_for<R: Runtime>(
app: &AppHandle<R>,
spec: &ItemSpec,
about: &AboutMetadata<'static>,
) -> tauri::Result<Box<dyn IsMenuItem<R>>> {
if spec.item == Item::Command {
// `spec.id` is the payload the frontend switches on, so the item's menu id and
// the row's id are the same string by construction.
let accel = spec.keys.map(muda_accelerator);
let item = MenuItem::with_id(app, spec.id, spec.label, true, accel)?;
return Ok(Box::new(item));
}
Ok(Box::new(predefined(app, spec, about)?))
}

/// The About panel's contents, from the same sources `Menu::default` reads: the
/// package info and the bundle config.
///
Expand Down Expand Up @@ -294,6 +390,10 @@ fn predefined<R: Runtime>(
Item::Minimize => PredefinedMenuItem::minimize(app, text),
Item::Zoom => PredefinedMenuItem::maximize(app, text),
Item::Separator => PredefinedMenuItem::separator(app),
// Unreachable: `item_for` takes this branch before calling here. Handled rather
// than `unreachable!()` — a panic in the menu builder is a window that never
// opens, and a separator is the harmless thing to draw if the two ever disagree.
Item::Command => PredefinedMenuItem::separator(app),
}
}

Expand All @@ -319,14 +419,18 @@ mod tests {
/// accelerator syntax would want) leaking into a table the UI parses with
/// CodeMirror's. `menukeys.test.ts` runs the real parser over the mirror.
fn is_registry_chord(spec: &str) -> bool {
let mut parts = spec.split('-').collect::<Vec<_>>();
let Some(key) = parts.pop() else {
return false;
};
let key_ok = key.len() == 1
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
// The same `-(?!$)` cut `muda_accelerator` makes, and for the same reason: `-` is
// both the separator and a key, so ⌘- is spelled `Mod--`.
let mut parts: Vec<&str> = Vec::new();
let mut key = spec;
while let Some(i) = key[..key.len().saturating_sub(1)].find('-') {
parts.push(&key[..i]);
key = &key[i + 1..];
}
// One character, and never an uppercase one — `parseChord` lowercases what it
// reads, so an uppercase key here is a chord that parses to something else.
// Symbols are allowed: `=` and `-` are the View menu's.
let key_ok = key.len() == 1 && !key.chars().any(|c| c.is_ascii_uppercase());
key_ok
&& parts
.iter()
Expand Down Expand Up @@ -390,6 +494,59 @@ mod tests {
// And the guard has teeth: the spelling this is here to keep out.
assert!(!is_registry_chord("CmdOrCtrl+C"));
assert!(!is_registry_chord("Mod-Meh-c"));
assert!(!is_registry_chord("Mod-C"));
// ...and it accepts the two symbol keys the View menu is spelled with.
assert!(is_registry_chord("Mod--"));
assert!(is_registry_chord("Mod-="));
}

#[test]
fn a_command_item_carries_a_chord_muda_can_actually_parse() {
// The one place B2 *chooses* an accelerator rather than restating one macOS
// assigned — and Tauri drops an unparseable accelerator silently
// (`.parse().ok()`), so a wrong spelling here is a menu line with no shortcut and
// no complaint. `muda_accelerator` is the single source; this pins what it emits.
assert_eq!(muda_accelerator("Mod-="), "CmdOrCtrl+=");
assert_eq!(muda_accelerator("Mod--"), "CmdOrCtrl+-");
assert_eq!(muda_accelerator("Mod-0"), "CmdOrCtrl+0");
assert_eq!(muda_accelerator("Mod-Shift-z"), "CmdOrCtrl+Shift+z");
assert_eq!(muda_accelerator("Mod-Ctrl-f"), "CmdOrCtrl+Ctrl+f");
// A bare key keeps its lone self rather than becoming an empty modifier.
assert_eq!(muda_accelerator("-"), "-");
assert_eq!(muda_accelerator("f"), "f");

// And every command row in the real table survives the trip: modifiers muda
// knows, one key left over, nothing empty.
for spec in all_items().filter(|s| s.item == Item::Command) {
let keys = spec.keys.unwrap_or_else(|| panic!("{}: no chord", spec.id));
let accel = muda_accelerator(keys);
let mut tokens = accel.split('+').collect::<Vec<_>>();
let key = tokens.pop().unwrap_or_default();
assert_eq!(key.chars().count(), 1, "{}: key is {key:?}", spec.id);
for t in tokens {
assert!(
matches!(t, "CmdOrCtrl" | "Ctrl" | "Shift" | "Alt"),
"{}: muda doesn't know the modifier {t:?}",
spec.id
);
}
}
}

#[test]
fn a_command_item_is_addressable_and_every_other_item_is_not() {
// The event payload is the row's id, so a command row without one is a menu line
// the frontend cannot act on. The converse matters just as much: a predefined row
// must stay predefined, because those are what route the platform's editing
// selectors into the webview (copy and paste work *because* of them).
for spec in all_items().filter(|s| s.item == Item::Command) {
assert!(!spec.id.is_empty(), "a command item with no id");
assert!(
spec.keys.is_some(),
"{}: a command item with no chord — it would be mouse-only (K1)",
spec.id
);
}
}

#[test]
Expand Down Expand Up @@ -417,6 +574,9 @@ mod tests {
"edit.copy Mod-c Copy",
"edit.paste Mod-v Paste",
"edit.select-all Mod-a Select All",
"view.zoom-in Mod-= Zoom In",
"view.zoom-out Mod-- Zoom Out",
"view.zoom-reset Mod-0 Actual Size",
"view.fullscreen Mod-Ctrl-f Toggle Full Screen",
"window.minimize Mod-m Minimize",
]
Expand Down
Loading