Merge branch 'main' into pr-734

This commit is contained in:
github-actions[bot]
2026-05-23 21:49:25 +00:00
committed by GitHub
18 changed files with 215 additions and 117 deletions

View File

@@ -376,9 +376,9 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
## Command Surface
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, custom titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the custom desktop titlebar menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
## File System Integration

View File

@@ -235,7 +235,7 @@ The main Tauri window also persists its last normal size and screen position in
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers the macOS Window submenu with Tauri's reserved `WINDOW_SUBMENU_ID` so NSApp can add system window-management and window-list items, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
Linux and Windows use custom React-rendered window chrome instead of the native Tauri menu bar. `setup_custom_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu is macOS-only so Services/Hide/Quit and the reserved `WINDOW_SUBMENU_ID` keep behaving like normal NSApp menu items, while cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback from the renderer menu.
On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
## Multi-Window (Note Windows)
@@ -904,7 +904,7 @@ Shortcut routing is explicit:
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
- `menu.rs`, `useMenuEvents`, and the custom titlebar `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`

View File

@@ -125,7 +125,7 @@ tolaria/
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── LinuxTitlebar.tsx # Linux-only custom window chrome + controls
│ │ ├── LinuxTitlebar.tsx # Linux/Windows custom window chrome + controls
│ │ ├── LinuxMenuButton.tsx # Linux titlebar menu mirroring app commands
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later
@@ -387,7 +387,7 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux and Windows, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because those builds use Tolaria's custom chrome instead of the native desktop menu bar. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.

View File

@@ -4,20 +4,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
pub fn check_cli() -> AiAgentAvailability {
let binary = match find_codex_binary() {
Ok(binary) => binary,
Err(_) => {
return AiAgentAvailability {
installed: false,
version: None,
}
}
};
AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
}
codex_availability_from_binary_result(find_codex_binary())
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
@@ -29,15 +16,37 @@ where
}
fn find_codex_binary() -> Result<PathBuf, String> {
find_codex_binary_on_path()
.filter(|binary| is_usable_codex_binary(binary))
.or_else(|| {
find_codex_binary_in_user_shell().filter(|binary| is_usable_codex_binary(binary))
})
.or_else(|| find_usable_codex_binary(codex_binary_candidates()))
.ok_or_else(|| {
"Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()
})
if let Some(binary) = find_codex_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_codex_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate(
codex_binary_candidates(),
"Codex CLI",
)? {
return Ok(binary);
}
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
}
fn codex_availability_from_binary_result(
binary_result: Result<PathBuf, String>,
) -> AiAgentAvailability {
match binary_result {
Ok(binary) => AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
@@ -60,7 +69,7 @@ fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "codex"))
.find_map(|shell| codex_path_from_shell(&shell))
}
fn user_shell_candidates() -> Vec<PathBuf> {
@@ -75,10 +84,10 @@ fn user_shell_candidates() -> Vec<PathBuf> {
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
fn codex_path_from_shell(shell: &Path) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.arg("command -v codex")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
@@ -124,12 +133,16 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/codex"),
home.join(".local/bin/codex.exe"),
home.join(".local/bin/codex.cmd"),
home.join(".codex/bin/codex"),
home.join(".codex/bin/codex.exe"),
home.join(".codex/bin/codex.cmd"),
home.join(".local/share/mise/shims/codex"),
home.join(".local/share/mise/shims/codex.exe"),
home.join(".local/share/mise/shims/codex.cmd"),
home.join(".asdf/shims/codex"),
home.join(".asdf/shims/codex.exe"),
home.join(".asdf/shims/codex.cmd"),
home.join(".npm-global/bin/codex"),
home.join(".npm-global/bin/codex.cmd"),
home.join(".npm-global/bin/codex.exe"),
@@ -138,22 +151,24 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join(".npm/bin/codex.exe"),
home.join(".bun/bin/codex"),
home.join(".bun/bin/codex.exe"),
home.join(".bun/bin/codex.cmd"),
home.join(".linuxbrew/bin/codex"),
home.join("AppData/Roaming/npm/codex.cmd"),
home.join("AppData/Roaming/npm/codex.exe"),
home.join("AppData/Local/pnpm/codex.cmd"),
home.join("AppData/Local/pnpm/codex.exe"),
home.join("scoop/shims/codex.cmd"),
home.join("scoop/shims/codex.exe"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
];
candidates.extend(nvm_node_binary_candidates_for_home(home, "codex"));
candidates.extend(nvm_codex_binary_candidates_for_home(home));
candidates
}
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
fn nvm_codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
@@ -162,22 +177,12 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<Pa
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join(binary_name))
.map(|path| path.join("bin").join("codex"))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
fn find_usable_codex_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates
.into_iter()
.find(|binary| is_usable_codex_binary(binary))
}
fn is_usable_codex_binary(binary: &Path) -> bool {
crate::cli_agent_runtime::version_for_binary(binary).is_some()
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
@@ -202,7 +207,12 @@ where
emit,
codex_session_id,
dispatch_codex_event,
format_codex_error,
|stderr_output, status| {
format_codex_error(CodexProcessError {
stderr_output,
status,
})
},
)
}
@@ -276,7 +286,7 @@ fn build_codex_args(
"-c".into(),
codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]),
"-c".into(),
codex_mcp_env_config(&request.vault_path, &request.vault_paths),
codex_mcp_env_config(request),
];
if let Some(path) = last_message_path {
@@ -300,11 +310,14 @@ fn codex_config_string_list(key: &str, values: &[&str]) -> String {
format!("{key}=[{values}]")
}
fn codex_mcp_env_config(vault_path: &str, vault_paths: &[String]) -> String {
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths);
fn codex_mcp_env_config(request: &AgentStreamRequest) -> String {
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(
&request.vault_path,
&request.vault_paths,
);
format!(
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",VAULT_PATHS="{}",WS_UI_PORT="9711"}}"#,
toml_escape(vault_path),
toml_escape(&request.vault_path),
toml_escape(&vault_paths)
)
}
@@ -442,8 +455,13 @@ fn read_codex_last_message(path: &Path) -> Option<String> {
.filter(|text| !text.is_empty())
}
fn format_codex_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
struct CodexProcessError {
stderr_output: String,
status: String,
}
fn format_codex_error(error: CodexProcessError) -> String {
let lower = error.stderr_output.to_ascii_lowercase();
if is_codex_auth_error(&lower) {
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
}
@@ -452,10 +470,15 @@ fn format_codex_error(stderr_output: String, status: String) -> String {
return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or verify the selected vault folder is writable and retry. Writes outside the active vault remain blocked.".into();
}
if stderr_output.trim().is_empty() {
format!("codex exited with status {status}")
if error.stderr_output.trim().is_empty() {
format!("codex exited with status {}", error.status)
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
error
.stderr_output
.lines()
.take(3)
.collect::<Vec<_>>()
.join("\n")
}
}
@@ -956,16 +979,22 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
let candidates = codex_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/codex.exe"),
home.join(".local/bin/codex.cmd"),
home.join(".local/share/mise/shims/codex.exe"),
home.join(".local/share/mise/shims/codex.cmd"),
home.join(".asdf/shims/codex.exe"),
home.join(".asdf/shims/codex.cmd"),
home.join(".codex/bin/codex.cmd"),
home.join(".npm-global/bin/codex.cmd"),
home.join(".npm-global/bin/codex.exe"),
home.join(".npm/bin/codex.cmd"),
home.join(".npm/bin/codex.exe"),
home.join(".bun/bin/codex.cmd"),
home.join("AppData/Roaming/npm/codex.cmd"),
home.join("AppData/Roaming/npm/codex.exe"),
home.join("AppData/Local/pnpm/codex.cmd"),
home.join("AppData/Local/pnpm/codex.exe"),
home.join("scoop/shims/codex.cmd"),
home.join("scoop/shims/codex.exe"),
];
@@ -978,6 +1007,16 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
}
}
#[test]
fn codex_availability_reports_installed_even_when_version_probe_fails() {
let binary = PathBuf::from("C:/Users/alex/AppData/Roaming/npm/codex.cmd");
let availability = codex_availability_from_binary_result(Ok(binary));
assert!(availability.installed);
assert_eq!(availability.version, None);
}
#[test]
fn codex_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
@@ -990,18 +1029,6 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
assert!(candidates.contains(&codex), "missing {}", codex.display());
}
#[cfg(unix)]
#[test]
fn usable_codex_binary_skips_broken_shims() {
let dir = tempfile::tempdir().unwrap();
let broken = executable_script(dir.path(), "broken-codex", "exit 1\n");
let working = executable_script(dir.path(), "codex", "echo codex-cli 0.124.0-alpha.2\n");
let found = find_usable_codex_binary(vec![broken, working.clone()]);
assert_eq!(found, Some(working));
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
@@ -1051,7 +1078,7 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
assert_eq!(codex_path_from_shell(&shell), Some(codex));
}
#[test]
@@ -1156,10 +1183,10 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
#[test]
fn format_codex_error_explains_vault_write_permission_failures() {
let message = format_codex_error(
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
"exit status: 1".into(),
);
let message = format_codex_error(CodexProcessError {
stderr_output: "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
status: "exit status: 1".into(),
});
assert!(message.contains("active vault"));
assert!(message.contains("writable"));

View File

@@ -284,9 +284,10 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
app.handle().plugin(tauri_plugin_opener::init())?;
#[cfg(not(target_os = "linux"))]
menu::setup_menu(app)?;
setup_linux_window_chrome(app)?;
if should_use_native_desktop_menu(std::env::consts::OS) {
menu::setup_menu(app)?;
}
setup_custom_window_chrome(app)?;
window_state::restore_main_window_state(app);
show_debug_main_window(app);
Ok(())
@@ -307,8 +308,12 @@ fn show_debug_main_window(app: &mut tauri::App) {
#[cfg(not(debug_assertions))]
fn show_debug_main_window(_app: &mut tauri::App) {}
#[cfg(all(desktop, target_os = "linux"))]
fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
fn should_use_native_desktop_menu(target_os: &str) -> bool {
target_os == "macos"
}
#[cfg(all(desktop, any(target_os = "linux", target_os = "windows")))]
fn setup_custom_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
@@ -317,8 +322,8 @@ fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::er
Ok(())
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(not(all(desktop, any(target_os = "linux", target_os = "windows"))))]
fn setup_custom_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
@@ -572,6 +577,7 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::should_use_native_desktop_menu;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
@@ -726,4 +732,11 @@ mod tests {
missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty()
);
}
#[test]
fn native_desktop_menu_is_macos_only() {
assert!(should_use_native_desktop_menu("macos"));
assert!(!should_use_native_desktop_menu("windows"));
assert!(!should_use_native_desktop_menu("linux"));
}
}

View File

@@ -71,7 +71,7 @@ describe('BreadcrumbBar filename visibility', () => {
it('offsets the editor-only breadcrumb title past the macOS traffic lights', () => {
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
expect(editorCss).toContain('.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar')
expect(editorCss).toContain('body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar')
expect(editorCss).toContain('--breadcrumb-bar-left-padding: 90px;')
})

View File

@@ -30,7 +30,7 @@
transition: border-color 0.2s ease;
}
.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar {
body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar {
--breadcrumb-bar-left-padding: 90px;
}

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LinuxTitlebar } from './LinuxTitlebar'
import { shouldUseLinuxWindowChrome } from '../utils/platform'
import { shouldUseCustomWindowChrome } from '../utils/platform'
const {
close,
@@ -25,7 +25,7 @@ const {
vi.mock('../utils/platform', () => ({
isMac: () => false,
shouldUseLinuxWindowChrome: vi.fn(),
shouldUseCustomWindowChrome: vi.fn(),
}))
vi.mock('@tauri-apps/api/core', () => ({
@@ -47,11 +47,11 @@ vi.mock('@tauri-apps/api/window', () => ({
describe('LinuxTitlebar', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(true)
vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(true)
})
it('does not render when Linux chrome is disabled', () => {
vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(false)
it('does not render when custom desktop chrome is disabled', () => {
vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(false)
render(<LinuxTitlebar />)
@@ -68,7 +68,7 @@ describe('LinuxTitlebar', () => {
expect(startDragging).not.toHaveBeenCalled()
})
it('wires the Linux titlebar window controls to the current window', () => {
it('wires custom titlebar window controls to the current window', () => {
render(<LinuxTitlebar />)
fireEvent.click(screen.getByRole('button', { name: 'Minimize' }))

View File

@@ -2,7 +2,7 @@ import type { CSSProperties, MouseEvent, ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useDragRegion } from '../hooks/useDragRegion'
import { shouldUseLinuxWindowChrome } from '../utils/platform'
import { shouldUseCustomWindowChrome } from '../utils/platform'
import { LinuxMenuButton } from './LinuxMenuButton'
import { Button } from './ui/button'
@@ -36,11 +36,11 @@ const RESIZE_HANDLES: ReadonlyArray<{
]
export function LinuxTitlebar() {
const linuxChromeEnabled = shouldUseLinuxWindowChrome()
const customChromeEnabled = shouldUseCustomWindowChrome()
const { onMouseDown } = useDragRegion()
const maximized = useLinuxMaximizedState(linuxChromeEnabled)
const maximized = useLinuxMaximizedState(customChromeEnabled)
if (!linuxChromeEnabled) return null
if (!customChromeEnabled) return null
const appWindow = getCurrentWindow()
@@ -90,7 +90,7 @@ function useLinuxMaximizedState(enabled: boolean): boolean {
}
function ResizeHandles() {
if (!shouldUseLinuxWindowChrome()) return null
if (!shouldUseCustomWindowChrome()) return null
const startResize = (direction: ResizeDirection) => (event: MouseEvent<HTMLDivElement>) => {
if (event.button !== 0) return

View File

@@ -32,6 +32,18 @@ function makeBookTypeEntries(
}
const noop = () => undefined
const MAC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 Safari/605.1.15'
const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36'
function withUserAgent<T>(userAgent: string, callback: () => T): T {
const originalUserAgent = navigator.userAgent
Object.defineProperty(window.navigator, 'userAgent', { value: userAgent, configurable: true })
try {
return callback()
} finally {
Object.defineProperty(window.navigator, 'userAgent', { value: originalUserAgent, configurable: true })
}
}
function makeViewDefinition(overrides: Partial<ViewFile> = {}): ViewFile {
return {
@@ -831,16 +843,28 @@ describe('NoteList type sections', () => {
})
describe('NoteList traffic-light padding', () => {
it('adds left padding when the sidebar is collapsed', () => {
const { container } = renderNoteList({ sidebarCollapsed: true })
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('80px')
it('adds left padding for macOS traffic lights when the sidebar is collapsed', () => {
withUserAgent(MAC_USER_AGENT, () => {
const { container } = renderNoteList({ sidebarCollapsed: true })
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('80px')
})
})
it('does not add macOS traffic-light padding on Windows when the sidebar is collapsed', () => {
withUserAgent(WINDOWS_USER_AGENT, () => {
const { container } = renderNoteList({ sidebarCollapsed: true })
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
})
})
it('does not add extra left padding when the sidebar is expanded', () => {
const { container } = renderNoteList({ sidebarCollapsed: false })
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
withUserAgent(MAC_USER_AGENT, () => {
const { container } = renderNoteList({ sidebarCollapsed: false })
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
})
})
it('defaults to no extra padding when sidebarCollapsed is omitted', () => {

View File

@@ -11,9 +11,11 @@ import { SortDropdown } from '../SortDropdown'
import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPropertiesPopover'
import { GitRepositorySelect } from '../GitRepositorySelect'
import type { GitRepositoryOption } from '../../utils/gitRepositories'
import { isMac } from '../../utils/platform'
const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4'
const NOTE_LIST_EXPAND_BUTTON_CLASSNAME = '!h-6 !w-6 !min-w-0 !rounded !p-0 !text-muted-foreground hover:!bg-accent hover:!text-foreground focus-visible:!bg-accent [&_svg]:!size-4'
const COLLAPSED_SIDEBAR_MAC_CHROME_PADDING = 80
const PROPERTY_TRIGGER_TITLE_KEYS: Record<string, TranslationKey> = {
'Customize columns': 'noteList.properties.customizeColumns',
'Customize All Notes columns': 'noteList.properties.customizeAllColumns',
@@ -290,10 +292,13 @@ export function NoteListHeader({
onGitRepositoryChange,
}: NoteListHeaderProps) {
const { onMouseDown: onDragMouseDown } = useDragRegion()
const collapsedSidebarPadding = sidebarCollapsed && isMac()
? COLLAPSED_SIDEBAR_MAC_CHROME_PADDING
: undefined
return (
<>
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default', paddingLeft: sidebarCollapsed ? 80 : undefined }}>
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default', paddingLeft: collapsedSidebarPadding }}>
<HeaderLeading
title={title}
typeDocument={typeDocument}

View File

@@ -381,7 +381,7 @@
padding: 0;
overflow: hidden;
}
body.linux-chrome {
body.custom-window-chrome {
padding-top: 32px;
}
html, body {

View File

@@ -59,6 +59,16 @@ async function importEntrypoint() {
await import('./main')
}
async function withUserAgent<T>(userAgent: string, callback: () => Promise<T>): Promise<T> {
const originalUserAgent = navigator.userAgent
Object.defineProperty(window.navigator, 'userAgent', { value: userAgent, configurable: true })
try {
return await callback()
} finally {
Object.defineProperty(window.navigator, 'userAgent', { value: originalUserAgent, configurable: true })
}
}
function createDragEventWithDataTransfer(
type: 'dragover' | 'drop',
dataTransfer: Partial<DataTransfer>,
@@ -112,6 +122,7 @@ describe('main entrypoint', () => {
vi.resetModules()
vi.clearAllMocks()
document.body.innerHTML = '<div id="root"></div>'
document.body.className = ''
window.__tolariaFrontendReady = false
sessionStorage.clear()
})
@@ -146,6 +157,14 @@ describe('main entrypoint', () => {
expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '' })
})
it('marks macOS chrome for traffic-light layout offsets', async () => {
await withUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 Safari/605.1.15', async () => {
await importEntrypoint()
})
expect(document.body).toHaveClass('mac-chrome')
}, 60_000)
it('ignores ResizeObserver loop notifications instead of showing the fatal overlay', async () => {
await importEntrypoint()

View File

@@ -17,7 +17,7 @@ import {
type AppCommandShortcutEventOptions,
} from './hooks/appCommandCatalog'
import { isRecoveredBlockNoteRenderError } from './components/blockNoteRenderRecovery'
import { shouldUseLinuxWindowChrome } from './utils/platform'
import { isMac, shouldUseCustomWindowChrome } from './utils/platform'
import { reloadFrontendOnceIfStartupFailed } from './utils/frontendReady'
import { isNoteWindow } from './utils/windowMode'
@@ -60,8 +60,12 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
document.addEventListener('contextmenu', preventNativeContextMenu, true)
}
if (shouldUseLinuxWindowChrome()) {
document.body.classList.add('linux-chrome')
if (shouldUseCustomWindowChrome()) {
document.body.classList.add('custom-window-chrome')
}
if (isMac()) {
document.body.classList.add('mac-chrome')
}
applyStoredThemeMode(document, window.localStorage)

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildNoteWindowUrl, buildRuntimeNoteWindowUrl, openNoteInNewWindow } from './openNoteWindow'
import { isTauri } from '../mock-tauri'
import { shouldUseLinuxWindowChrome } from './platform'
import { shouldUseCustomWindowChrome } from './platform'
const webviewWindowCalls = vi.fn()
const localStorageMock = (() => {
@@ -21,7 +21,7 @@ vi.mock('../mock-tauri', () => ({
}))
vi.mock('./platform', () => ({
shouldUseLinuxWindowChrome: vi.fn(),
shouldUseCustomWindowChrome: vi.fn(),
}))
vi.mock('@tauri-apps/api/webviewWindow', () => ({
@@ -52,7 +52,7 @@ describe('openNoteWindow', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T16:00:00Z'))
vi.mocked(isTauri).mockReturnValue(false)
vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(false)
vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(false)
localStorage.clear()
})
@@ -104,9 +104,9 @@ describe('openNoteWindow', () => {
})
})
it('drops native decorations when Linux window chrome is active', async () => {
it('drops native decorations when custom desktop chrome is active', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(true)
vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(true)
await openNoteInNewWindow('/vault/linux.md', '/vault', 'Linux Note')

View File

@@ -1,5 +1,5 @@
import { isTauri } from '../mock-tauri'
import { shouldUseLinuxWindowChrome } from './platform'
import { shouldUseCustomWindowChrome } from './platform'
import { rememberNoteWindowParams } from './windowMode'
const MACOS_TRAFFIC_LIGHT_POSITION = { x: 18, y: 24 } as const
@@ -56,6 +56,6 @@ export async function openNoteInNewWindow(notePath: string, vaultPath: string, n
titleBarStyle: 'overlay',
trafficLightPosition: new LogicalPosition(MACOS_TRAFFIC_LIGHT_POSITION.x, MACOS_TRAFFIC_LIGHT_POSITION.y),
hiddenTitle: true,
decorations: !shouldUseLinuxWindowChrome(),
decorations: !shouldUseCustomWindowChrome(),
})
}

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isTauri } from '../mock-tauri'
import { isLinux, isMac, isWindows, shouldUseLinuxWindowChrome } from './platform'
import { isLinux, isMac, isWindows, shouldUseCustomWindowChrome } from './platform'
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(),
@@ -42,12 +42,18 @@ describe('platform helpers', () => {
expect(isWindows()).toBe(false)
})
it('only enables Linux window chrome inside Tauri', () => {
it('enables custom desktop chrome on Linux and Windows inside Tauri', () => {
setUserAgent('Mozilla/5.0 (X11; Linux x86_64)')
vi.mocked(isTauri).mockReturnValue(false)
expect(shouldUseLinuxWindowChrome()).toBe(false)
expect(shouldUseCustomWindowChrome()).toBe(false)
vi.mocked(isTauri).mockReturnValue(true)
expect(shouldUseLinuxWindowChrome()).toBe(true)
expect(shouldUseCustomWindowChrome()).toBe(true)
setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
expect(shouldUseCustomWindowChrome()).toBe(true)
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
expect(shouldUseCustomWindowChrome()).toBe(false)
})
})

View File

@@ -19,6 +19,6 @@ export function isWindows(): boolean {
return getUserAgent().includes('Windows')
}
export function shouldUseLinuxWindowChrome(): boolean {
return isTauri() && isLinux()
export function shouldUseCustomWindowChrome(): boolean {
return isTauri() && (isLinux() || isWindows())
}