From 38acebba7c62e769d6ab90f9819b95f5240451e7 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 24 Apr 2026 16:25:36 +0200 Subject: [PATCH] feat: add linux desktop support --- .github/workflows/ci.yml | 60 +++++ .github/workflows/release.yml | 80 +++++++ README.md | 27 ++- docs/ARCHITECTURE.md | 11 +- docs/GETTING-STARTED.md | 26 ++- ...0079-linux-window-chrome-and-menu-reuse.md | 37 +++ src-tauri/src/lib.rs | 17 ++ src-tauri/src/menu.rs | 2 +- src/components/LinuxMenuButton.test.tsx | 57 +++++ src/components/LinuxMenuButton.tsx | 211 ++++++++++++++++++ src/components/LinuxTitlebar.test.tsx | 81 +++++++ src/components/LinuxTitlebar.tsx | 208 +++++++++++++++++ src/hooks/appCommandCatalog.ts | 2 +- src/hooks/appCommandDispatcher.test.ts | 4 +- src/hooks/useAppKeyboard.test.ts | 4 +- src/index.css | 3 + src/main.test.ts | 10 +- src/main.tsx | 7 + src/utils/openNoteWindow.test.ts | 21 ++ src/utils/openNoteWindow.ts | 2 + src/utils/platform.test.ts | 45 ++++ src/utils/platform.ts | 20 ++ tests/smoke/keyboard-command-routing.spec.ts | 4 +- 23 files changed, 922 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0079-linux-window-chrome-and-menu-reuse.md create mode 100644 src/components/LinuxMenuButton.test.tsx create mode 100644 src/components/LinuxMenuButton.tsx create mode 100644 src/components/LinuxTitlebar.test.tsx create mode 100644 src/components/LinuxTitlebar.tsx create mode 100644 src/utils/platform.test.ts create mode 100644 src/utils/platform.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a926c6fa..950eca0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,3 +167,63 @@ jobs: - name: Format check (Rust) run: cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check + + linux-build: + name: Linux build verification + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install Tauri Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libsoup-3.0-dev \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + build-essential \ + file + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Frontend build + run: pnpm build + + - name: Cargo check + run: cargo check --manifest-path=src-tauri/Cargo.toml + + - name: Clippy + run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c907a642..91bd0096 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -303,6 +303,86 @@ jobs: src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig retention-days: 1 + build-linux: + name: Build (linux-x86_64) + needs: version + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install Tauri Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libsoup-3.0-dev \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + build-essential \ + file + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}- + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Set version + run: | + VERSION="${{ needs.version.outputs.version }}" + jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml + + - name: Build Tauri app (Linux bundles) + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} + VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} + run: | + pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage + + - name: Upload Linux bundles + uses: actions/upload-artifact@v4 + with: + name: linux-x86_64-bundles + path: | + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig + retention-days: 7 + # ───────────────────────────────────────────────────────────── # Phase 3: Publish GitHub Release # No lipo/re-signing — use the per-arch artifacts directly diff --git a/README.md b/README.md index c8d6b3ca..a20bf16e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # 💧 Tolaria -Tolaria is a desktop app for Mac for managing **markdown knowledge bases**. People use it for a variety of use cases: +Tolaria is a desktop app for Mac and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases: * Operate second brains and personal knowledge * Organize company docs as context for AI @@ -46,7 +46,30 @@ Tolaria is open source and built with Tauri, React, and TypeScript. If you want - Node.js 20+ - pnpm 8+ - Rust stable -- macOS for development +- macOS or Linux for development + +#### Linux system dependencies + +Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3: + +- Arch / Manjaro: + ```bash + sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \ + appmenu-gtk-module libappindicator-gtk3 librsvg + ``` +- Debian / Ubuntu (22.04+): + ```bash + sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \ + libsoup-3.0-dev patchelf + ``` +- Fedora 38+: + ```bash + sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \ + libappindicator-gtk3-devel librsvg2-devel + ``` + +The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow. ### Quick start diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 52da2fa4..0cf50a0e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -185,6 +185,8 @@ Panels are separated by `ResizeHandle` components that support drag-to-resize. The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing. +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 macOS uses for native menu clicks. + ## Multi-Window (Note Windows) Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list). @@ -200,7 +202,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win - `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window) - `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance - Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload -- Secondary windows are sized 800×700 with overlay title bar +- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows - Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels ## AI System @@ -611,7 +613,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `settings.rs` | App settings persistence | | `vault_config.rs` | Per-vault UI config | | `vault_list.rs` | Vault list persistence | -| `menu.rs` | Native macOS menu bar | +| `menu.rs` | Native desktop menu definitions and command IDs (not mounted on Linux) | ## Tauri IPC Commands @@ -782,7 +784,7 @@ Shortcut routing is explicit: - `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata - `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs - macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path -- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators +- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same 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()` @@ -804,6 +806,9 @@ push to main → build job: → pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app → upload signed .app.tar.gz + .sig updater artifacts + → build-linux job: + → pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage + → upload .deb, .AppImage, and signed updater tarball artifacts for manual download → release job: → generate alpha-latest.json → publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index fe325338..5c0f7d21 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -8,6 +8,27 @@ How to navigate the codebase, run the app, and find what you need. - **Rust** 1.77.2+ (for the Tauri backend) - **git** CLI (required by the git integration features) +### Linux system dependencies + +If you run the desktop app on Linux, install Tauri's WebKit2GTK 4.1 dependencies first: + +- Arch / Manjaro: + ```bash + sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \ + appmenu-gtk-module libappindicator-gtk3 librsvg + ``` +- Debian / Ubuntu (22.04+): + ```bash + sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \ + libsoup-3.0-dev patchelf + ``` +- Fedora 38+: + ```bash + sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \ + libappindicator-gtk3-devel librsvg2-devel + ``` + ## Quick Start ```bash @@ -68,6 +89,8 @@ 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 +│ │ ├── 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 │ │ ├── ConflictResolverModal.tsx # Git conflict resolution @@ -118,6 +141,7 @@ tolaria/ │ ├── utils/ # Pure utility functions (~48 files) │ │ ├── wikilinks.ts # Wikilink preprocessing pipeline │ │ ├── frontmatter.ts # TypeScript YAML parser +│ │ ├── platform.ts # Runtime platform + Linux chrome gating helpers │ │ ├── ai-agent.ts # Agent stream utilities │ │ ├── ai-chat.ts # Token estimation utilities │ │ ├── ai-context.ts # Context snapshot builder @@ -311,7 +335,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. 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. 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`. 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. 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. 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, `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. 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. diff --git a/docs/adr/0079-linux-window-chrome-and-menu-reuse.md b/docs/adr/0079-linux-window-chrome-and-menu-reuse.md new file mode 100644 index 00000000..7a07577d --- /dev/null +++ b/docs/adr/0079-linux-window-chrome-and-menu-reuse.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0079" +title: "Linux window chrome and menu reuse" +status: active +date: 2026-04-24 +--- + +## Context + +Tolaria's desktop shell was designed around macOS window chrome. `titleBarStyle: "Overlay"` and `hiddenTitle: true` give the app a clean single-surface titlebar on macOS, but Linux ignores those flags and draws native GTK decorations and a native menu bar on top of the React UI. That creates a double-titlebar effect, mismatched theming, and inconsistent behavior between the main window and detached note windows. + +We still need Linux to reuse Tolaria's existing command palette, shortcut manifest, and deterministic menu-command routing instead of inventing a Linux-only command path. + +## Decision + +**Tolaria uses custom React-rendered window chrome on Linux and routes its menu through the existing shared command IDs.** + +- The main Tauri window disables server-side decorations on Linux during app setup. +- Detached note windows set `decorations: false` when Linux chrome is active. +- `LinuxTitlebar` renders the drag region, resize handles, and window controls for Linux windows. +- `LinuxMenuButton` mirrors the app's File/Edit/View/Go/Note/Vault/Window menus, but dispatches the existing command IDs through `trigger_menu_command`. +- The native Tauri menu bar is not mounted on Linux; macOS and other existing desktop targets keep the native menu. +- Shared shortcuts remain defined in `appCommandCatalog.ts`, including `Cmd+Shift+L` on macOS and `Ctrl+Shift+L` on Linux through the same command manifest. + +## Options considered + +- **React-rendered Linux chrome with shared command IDs** (chosen): keeps Linux visually aligned with Tolaria's existing shell and preserves one command-routing model across keyboard shortcuts, menu clicks, and QA helpers. Cons: Tolaria now owns Linux window chrome behavior directly. +- **Keep native GTK decorations and menu bar on Linux**: cheaper to ship, but it breaks visual consistency and produces overlapping titlebar/menu surfaces that do not match the rest of the app. +- **Introduce Linux-only command wiring for the custom menu**: would allow a Linux-specific implementation, but it would fork the shortcut/menu architecture and weaken deterministic QA. + +## Consequences + +- Linux main windows and detached note windows now present one consistent titlebar surface controlled by Tolaria. +- Menu commands, command palette actions, and deterministic QA still share the same command IDs, which limits platform-specific drift. +- Linux packaging and CI must install WebKit2GTK 4.1 dependencies and produce Linux bundles explicitly. +- Tolaria now owns Linux resize handles, maximize/minimize/close behavior, and titlebar drag-region behavior in the renderer, so regressions in those surfaces require direct tests. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 705e0755..f42af9a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -91,7 +91,24 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box Result<(), Box> { + use tauri::Manager; + + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_decorations(false); + } + Ok(()) +} + +#[cfg(not(all(desktop, target_os = "linux")))] +fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box> { Ok(()) } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 08726d5c..92d4772e 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -320,7 +320,7 @@ fn build_note_menu(app: &App) -> MenuResult { .build(app)?; let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel") .id(VIEW_TOGGLE_AI_CHAT) - .accelerator("Cmd+Shift+L") + .accelerator("CmdOrCtrl+Shift+L") .build(app)?; let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks") .id(VIEW_TOGGLE_BACKLINKS) diff --git a/src/components/LinuxMenuButton.test.tsx b/src/components/LinuxMenuButton.test.tsx new file mode 100644 index 00000000..ae061220 --- /dev/null +++ b/src/components/LinuxMenuButton.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { LinuxMenuButton } from './LinuxMenuButton' + +const { close, invoke, minimize, toggleMaximize } = vi.hoisted(() => ({ + invoke: vi.fn().mockResolvedValue(undefined), + minimize: vi.fn().mockResolvedValue(undefined), + toggleMaximize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke, +})) + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ minimize, toggleMaximize, close }), +})) + +async function openSubmenu(label: string) { + fireEvent.pointerDown(screen.getByRole('button', { name: 'Application menu' }), { button: 0 }) + const trigger = await screen.findByText(label) + fireEvent.pointerMove(trigger) + fireEvent.click(trigger) +} + +describe('LinuxMenuButton', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('dispatches shared menu commands from the Linux menu', async () => { + render() + + await openSubmenu('Note') + expect(screen.getByText('Ctrl+Shift+L')).toBeInTheDocument() + fireEvent.click(await screen.findByText('Toggle AI Panel')) + expect(invoke).toHaveBeenCalledWith('trigger_menu_command', { id: 'view-toggle-ai-chat' }) + }) + + it('invokes direct window actions from the Window submenu', async () => { + render() + + await openSubmenu('Window') + fireEvent.click(await screen.findByText('Minimize')) + + await openSubmenu('Window') + fireEvent.click(await screen.findByText('Maximize')) + + await openSubmenu('Window') + fireEvent.click(await screen.findByText('Close')) + + expect(minimize).toHaveBeenCalledOnce() + expect(toggleMaximize).toHaveBeenCalledOnce() + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/LinuxMenuButton.tsx b/src/components/LinuxMenuButton.tsx new file mode 100644 index 00000000..fd63d5b0 --- /dev/null +++ b/src/components/LinuxMenuButton.tsx @@ -0,0 +1,211 @@ +import { invoke } from '@tauri-apps/api/core' +import { getCurrentWindow } from '@tauri-apps/api/window' +import type { AppCommandId } from '../hooks/appCommandCatalog' +import { APP_COMMAND_DEFINITIONS, APP_COMMAND_IDS } from '../hooks/appCommandCatalog' +import { Button } from './ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from './ui/dropdown-menu' + +type MenuItem = + | { kind: 'separator' } + | { kind: 'command'; commandId: MenuCommandId; label: string } + | { kind: 'action'; action: () => void; label: string; shortcut?: string } + +type MenuSection = { + items: ReadonlyArray + label: string +} + +type MenuCommandId = AppCommandId | 'edit-toggle-note-list-search' + +const MENU_SECTIONS: ReadonlyArray = [ + { + label: 'File', + items: [ + { kind: 'command', label: 'New Note', commandId: APP_COMMAND_IDS.fileNewNote }, + { kind: 'command', label: 'New Type', commandId: APP_COMMAND_IDS.fileNewType }, + { kind: 'command', label: 'Quick Open', commandId: APP_COMMAND_IDS.fileQuickOpen }, + { kind: 'separator' }, + { kind: 'command', label: 'Save', commandId: APP_COMMAND_IDS.fileSave }, + ], + }, + { + label: 'Edit', + items: [ + { kind: 'command', label: 'Find in Vault', commandId: APP_COMMAND_IDS.editFindInVault }, + { kind: 'command', label: 'Toggle Note List Search', commandId: 'edit-toggle-note-list-search' }, + { kind: 'command', label: 'Toggle Diff Mode', commandId: APP_COMMAND_IDS.editToggleDiff }, + ], + }, + { + label: 'View', + items: [ + { kind: 'command', label: 'Editor Only', commandId: APP_COMMAND_IDS.viewEditorOnly }, + { kind: 'command', label: 'Editor + Notes', commandId: APP_COMMAND_IDS.viewEditorList }, + { kind: 'command', label: 'All Panels', commandId: APP_COMMAND_IDS.viewAll }, + { kind: 'separator' }, + { kind: 'command', label: 'Toggle Properties Panel', commandId: APP_COMMAND_IDS.viewToggleProperties }, + { kind: 'separator' }, + { kind: 'command', label: 'Zoom In', commandId: APP_COMMAND_IDS.viewZoomIn }, + { kind: 'command', label: 'Zoom Out', commandId: APP_COMMAND_IDS.viewZoomOut }, + { kind: 'command', label: 'Actual Size', commandId: APP_COMMAND_IDS.viewZoomReset }, + { kind: 'separator' }, + { kind: 'command', label: 'Command Palette', commandId: APP_COMMAND_IDS.viewCommandPalette }, + ], + }, + { + label: 'Go', + items: [ + { kind: 'command', label: 'All Notes', commandId: APP_COMMAND_IDS.goAllNotes }, + { kind: 'command', label: 'Archived', commandId: APP_COMMAND_IDS.goArchived }, + { kind: 'command', label: 'Changes', commandId: APP_COMMAND_IDS.goChanges }, + { kind: 'command', label: 'Inbox', commandId: APP_COMMAND_IDS.goInbox }, + { kind: 'separator' }, + { kind: 'command', label: 'Go Back', commandId: APP_COMMAND_IDS.viewGoBack }, + { kind: 'command', label: 'Go Forward', commandId: APP_COMMAND_IDS.viewGoForward }, + ], + }, + { + label: 'Note', + items: [ + { kind: 'command', label: 'Toggle Organized', commandId: APP_COMMAND_IDS.noteToggleOrganized }, + { kind: 'command', label: 'Archive Note', commandId: APP_COMMAND_IDS.noteArchive }, + { kind: 'command', label: 'Delete Note', commandId: APP_COMMAND_IDS.noteDelete }, + { kind: 'command', label: 'Restore Deleted Note', commandId: APP_COMMAND_IDS.noteRestoreDeleted }, + { kind: 'separator' }, + { kind: 'command', label: 'Open in New Window', commandId: APP_COMMAND_IDS.noteOpenInNewWindow }, + { kind: 'separator' }, + { kind: 'command', label: 'Toggle Raw Editor', commandId: APP_COMMAND_IDS.editToggleRawEditor }, + { kind: 'command', label: 'Toggle AI Panel', commandId: APP_COMMAND_IDS.viewToggleAiChat }, + { kind: 'command', label: 'Toggle Backlinks', commandId: APP_COMMAND_IDS.viewToggleBacklinks }, + ], + }, + { + label: 'Vault', + items: [ + { kind: 'command', label: 'Open Vault…', commandId: APP_COMMAND_IDS.vaultOpen }, + { kind: 'command', label: 'Remove Vault from List', commandId: APP_COMMAND_IDS.vaultRemove }, + { kind: 'command', label: 'Restore Getting Started', commandId: APP_COMMAND_IDS.vaultRestoreGettingStarted }, + { kind: 'separator' }, + { kind: 'command', label: 'Add Remote…', commandId: APP_COMMAND_IDS.vaultAddRemote }, + { kind: 'command', label: 'Commit & Push', commandId: APP_COMMAND_IDS.vaultCommitPush }, + { kind: 'command', label: 'Pull from Remote', commandId: APP_COMMAND_IDS.vaultPull }, + { kind: 'command', label: 'Resolve Conflicts', commandId: APP_COMMAND_IDS.vaultResolveConflicts }, + { kind: 'command', label: 'View Pending Changes', commandId: APP_COMMAND_IDS.vaultViewChanges }, + { kind: 'separator' }, + { kind: 'command', label: 'Reload Vault', commandId: APP_COMMAND_IDS.vaultReload }, + { kind: 'command', label: 'Repair Vault', commandId: APP_COMMAND_IDS.vaultRepair }, + { kind: 'command', label: 'Set Up External AI Tools…', commandId: APP_COMMAND_IDS.vaultInstallMcp }, + ], + }, + { + label: 'Window', + items: [ + { kind: 'action', label: 'Minimize', action: () => void getCurrentWindow().minimize().catch(() => {}) }, + { kind: 'action', label: 'Maximize', action: () => void getCurrentWindow().toggleMaximize().catch(() => {}) }, + { kind: 'separator' }, + { kind: 'action', label: 'Close', action: () => void getCurrentWindow().close().catch(() => {}) }, + ], + }, +] + +function formatShortcutKey(key: string): string { + switch (key) { + case 'ArrowLeft': + return '←' + case 'ArrowRight': + return '→' + default: + return key.length === 1 ? key.toUpperCase() : key + } +} + +function getLinuxShortcut(commandId: MenuCommandId): string | null { + if (commandId === 'edit-toggle-note-list-search') { + return 'Ctrl+F' + } + + const shortcut = APP_COMMAND_DEFINITIONS[commandId].shortcut + if (!shortcut) return null + + const modifier = shortcut.combo === 'command-or-ctrl' ? 'Ctrl' : 'Ctrl+Shift' + return `${modifier}+${formatShortcutKey(shortcut.key)}` +} + +function triggerMenuCommand(commandId: MenuCommandId): void { + void invoke('trigger_menu_command', { id: commandId }).catch(() => {}) +} + +function HamburgerIcon() { + return ( + + + + + + ) +} + +export function LinuxMenuButton() { + return ( + + + + + + {MENU_SECTIONS.map((section) => ( + + {section.label} + + {section.items.map((item, index) => { + if (item.kind === 'separator') { + return + } + + if (item.kind === 'command') { + const shortcut = getLinuxShortcut(item.commandId) + return ( + triggerMenuCommand(item.commandId)} + > + {item.label} + {shortcut && ( + {shortcut} + )} + + ) + } + + return ( + + {item.label} + {item.shortcut && {item.shortcut}} + + ) + })} + + + ))} + + + ) +} diff --git a/src/components/LinuxTitlebar.test.tsx b/src/components/LinuxTitlebar.test.tsx new file mode 100644 index 00000000..ab4dd93b --- /dev/null +++ b/src/components/LinuxTitlebar.test.tsx @@ -0,0 +1,81 @@ +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' + +const { + close, + invoke, + isMaximized, + minimize, + onResized, + startDragging, + startResizeDragging, + toggleMaximize, +} = vi.hoisted(() => ({ + invoke: vi.fn().mockResolvedValue(undefined), + startDragging: vi.fn().mockResolvedValue(undefined), + startResizeDragging: vi.fn().mockResolvedValue(undefined), + minimize: vi.fn().mockResolvedValue(undefined), + toggleMaximize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + isMaximized: vi.fn().mockResolvedValue(false), + onResized: vi.fn().mockResolvedValue(() => {}), +})) + +vi.mock('../utils/platform', () => ({ + shouldUseLinuxWindowChrome: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke, +})) + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + startDragging, + startResizeDragging, + minimize, + toggleMaximize, + close, + isMaximized, + onResized, + }), +})) + +describe('LinuxTitlebar', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(true) + }) + + it('does not render when Linux chrome is disabled', () => { + vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(false) + + render() + + expect(screen.queryByTestId('linux-titlebar')).toBeNull() + }) + + it('routes titlebar double-click through the shared drag-region command only once', () => { + render() + + fireEvent.mouseDown(screen.getByTestId('linux-titlebar'), { button: 0, detail: 2 }) + + expect(invoke).toHaveBeenCalledWith('perform_current_window_titlebar_double_click') + expect(toggleMaximize).not.toHaveBeenCalled() + expect(startDragging).not.toHaveBeenCalled() + }) + + it('wires the Linux titlebar window controls to the current window', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Minimize' })) + fireEvent.click(screen.getByRole('button', { name: 'Maximize' })) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + + expect(minimize).toHaveBeenCalledOnce() + expect(toggleMaximize).toHaveBeenCalledOnce() + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/LinuxTitlebar.tsx b/src/components/LinuxTitlebar.tsx new file mode 100644 index 00000000..fedf1111 --- /dev/null +++ b/src/components/LinuxTitlebar.tsx @@ -0,0 +1,208 @@ +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 { LinuxMenuButton } from './LinuxMenuButton' +import { Button } from './ui/button' + +export const LINUX_TITLEBAR_HEIGHT = 32 + +const RESIZE_EDGE = 6 + +type ResizeDirection = + | 'East' + | 'North' + | 'NorthEast' + | 'NorthWest' + | 'South' + | 'SouthEast' + | 'SouthWest' + | 'West' + +const RESIZE_HANDLES: ReadonlyArray<{ + cursor: CSSProperties['cursor'] + direction: ResizeDirection + style: CSSProperties +}> = [ + { direction: 'North', cursor: 'ns-resize', style: { top: 0, left: RESIZE_EDGE, right: RESIZE_EDGE, height: RESIZE_EDGE } }, + { direction: 'South', cursor: 'ns-resize', style: { bottom: 0, left: RESIZE_EDGE, right: RESIZE_EDGE, height: RESIZE_EDGE } }, + { direction: 'West', cursor: 'ew-resize', style: { top: RESIZE_EDGE, bottom: RESIZE_EDGE, left: 0, width: RESIZE_EDGE } }, + { direction: 'East', cursor: 'ew-resize', style: { top: RESIZE_EDGE, bottom: RESIZE_EDGE, right: 0, width: RESIZE_EDGE } }, + { direction: 'NorthWest', cursor: 'nwse-resize', style: { top: 0, left: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } }, + { direction: 'NorthEast', cursor: 'nesw-resize', style: { top: 0, right: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } }, + { direction: 'SouthWest', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } }, + { direction: 'SouthEast', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } }, +] + +export function LinuxTitlebar() { + const linuxChromeEnabled = shouldUseLinuxWindowChrome() + const { onMouseDown } = useDragRegion() + const maximized = useLinuxMaximizedState(linuxChromeEnabled) + + if (!linuxChromeEnabled) return null + + const appWindow = getCurrentWindow() + + return ( + <> + +
+
+ +
+ +
+ + ) +} + +function useLinuxMaximizedState(enabled: boolean): boolean { + const [maximized, setMaximized] = useState(false) + + useEffect(() => { + if (!enabled) return + + const appWindow = getCurrentWindow() + let active = true + + const syncMaximizeState = () => { + void appWindow.isMaximized().then((value) => { + if (active) setMaximized(value) + }).catch(() => {}) + } + + syncMaximizeState() + const unlistenPromise = appWindow.onResized(syncMaximizeState) + + return () => { + active = false + void unlistenPromise.then((unlisten) => unlisten()).catch(() => {}) + } + }, [enabled]) + + return maximized +} + +function ResizeHandles() { + if (!shouldUseLinuxWindowChrome()) return null + + const startResize = (direction: ResizeDirection) => (event: MouseEvent) => { + if (event.button !== 0) return + + event.preventDefault() + void getCurrentWindow().startResizeDragging(direction).catch(() => {}) + } + + return ( + <> + {RESIZE_HANDLES.map(({ cursor, direction, style }) => ( +
+ ))} + + ) +} + +function TitlebarWindowControls({ + appWindow, + maximized, +}: { + appWindow: ReturnType + maximized: boolean +}) { + return ( +
+ void appWindow.minimize().catch(() => {})}> + + + void appWindow.toggleMaximize().catch(() => {})} + > + {maximized ? : } + + void appWindow.close().catch(() => {})} + > + + +
+ ) +} + +function TitlebarButton({ + ariaLabel, + children, + close = false, + onClick, +}: { + ariaLabel: string + children: ReactNode + close?: boolean + onClick: () => void +}) { + return ( + + ) +} + +function MinimizeIcon() { + return ( + + + + ) +} + +function MaximizeIcon() { + return ( + + + + ) +} + +function RestoreIcon() { + return ( + + + + + ) +} + +function CloseIcon() { + return ( + + + + + ) +} diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index e973be55..4cde2949 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -204,7 +204,7 @@ export const APP_COMMAND_DEFINITIONS: Record [APP_COMMAND_IDS.viewToggleAiChat]: { route: { kind: 'handler', handler: 'onToggleAIChat' }, menuOwned: true, - shortcut: { combo: 'command-shift', key: 'l', code: 'KeyL', display: '⌘⇧L' }, + shortcut: { combo: 'command-or-ctrl-shift', key: 'l', code: 'KeyL', display: '⌘⇧L' }, }, [APP_COMMAND_IDS.viewToggleBacklinks]: { route: { kind: 'handler', handler: 'onToggleInspector' }, diff --git a/src/hooks/appCommandDispatcher.test.ts b/src/hooks/appCommandDispatcher.test.ts index 2aa640ce..faf884eb 100644 --- a/src/hooks/appCommandDispatcher.test.ts +++ b/src/hooks/appCommandDispatcher.test.ts @@ -80,7 +80,7 @@ describe('appCommandDispatcher', () => { it('finds raw editor and AI shortcuts from the shared catalog', () => { expect(findShortcutCommandId('command-or-ctrl', 'o', 'KeyO')).toBe(APP_COMMAND_IDS.fileQuickOpen) expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor) - expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat) + expect(findShortcutCommandId('command-or-ctrl-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat) }) it('gives every shortcut command an explicit deterministic QA strategy', () => { @@ -189,7 +189,7 @@ describe('appCommandDispatcher', () => { metaKey: false, shiftKey: true, }), - ).toBeNull() + ).toBe(APP_COMMAND_IDS.viewToggleAiChat) }) it('dispatches create note through the shared command path', () => { diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index bcbbdc9c..976fd738 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -391,12 +391,12 @@ describe('useAppKeyboard', () => { expect(onToggleAIChat).toHaveBeenCalled() }) - it('Ctrl+Shift+L does not trigger toggle AI chat', () => { + it('Ctrl+Shift+L triggers toggle AI chat', () => { const actions = makeActions() const onToggleAIChat = vi.fn() renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat })) fireKey('l', { ctrlKey: true, shiftKey: true }) - expect(onToggleAIChat).not.toHaveBeenCalled() + expect(onToggleAIChat).toHaveBeenCalled() }) it('Cmd+I does not trigger AI chat (reserved for italic)', () => { diff --git a/src/index.css b/src/index.css index 7622599b..40d2bbdd 100644 --- a/src/index.css +++ b/src/index.css @@ -143,6 +143,9 @@ padding: 0; overflow: hidden; } + body.linux-chrome { + padding-top: 32px; + } html, body { height: 100%; width: 100%; diff --git a/src/main.test.ts b/src/main.test.ts index 7b8a3966..669b698c 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -37,9 +37,13 @@ vi.mock('./hooks/appCommandDispatcher', () => ({ isAppCommandId: (id: string) => id === 'known-command', isNativeMenuCommandId: (id: string) => id === 'native-command', })) -vi.mock('./hooks/appCommandCatalog', () => ({ - getShortcutEventInit: mocks.getShortcutEventInit, -})) +vi.mock('./hooks/appCommandCatalog', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getShortcutEventInit: mocks.getShortcutEventInit, + } +}) async function importEntrypoint() { await import('./main') diff --git a/src/main.tsx b/src/main.tsx index 15d162b4..587e41e8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client' import { TooltipProvider } from '@/components/ui/tooltip' import './index.css' import App from './App.tsx' +import { LinuxTitlebar } from './components/LinuxTitlebar' import { APP_COMMAND_EVENT_NAME, isAppCommandId, @@ -14,6 +15,7 @@ import { type AppCommandShortcutEventInit, type AppCommandShortcutEventOptions, } from './hooks/appCommandCatalog' +import { shouldUseLinuxWindowChrome } from './utils/platform' // Disable native WebKit context menu in Tauri (WKWebView intercepts right-click // at native level before React's synthetic events can call preventDefault). @@ -23,6 +25,10 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) { document.addEventListener('contextmenu', (e) => e.preventDefault(), true) } +if (shouldUseLinuxWindowChrome()) { + document.body.classList.add('linux-chrome') +} + function dispatchDeterministicShortcutEvent(init: AppCommandShortcutEventInit) { const target = document.activeElement instanceof HTMLElement @@ -89,6 +95,7 @@ createRoot(document.getElementById('root')!, { }).render( + , diff --git a/src/utils/openNoteWindow.test.ts b/src/utils/openNoteWindow.test.ts index 1fd0773d..959d6656 100644 --- a/src/utils/openNoteWindow.test.ts +++ b/src/utils/openNoteWindow.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildNoteWindowUrl, openNoteInNewWindow } from './openNoteWindow' import { isTauri } from '../mock-tauri' +import { shouldUseLinuxWindowChrome } from './platform' const webviewWindowCalls = vi.fn() @@ -8,6 +9,10 @@ vi.mock('../mock-tauri', () => ({ isTauri: vi.fn(), })) +vi.mock('./platform', () => ({ + shouldUseLinuxWindowChrome: vi.fn(), +})) + vi.mock('@tauri-apps/api/webviewWindow', () => ({ WebviewWindow: class MockWebviewWindow { constructor(label: string, options: unknown) { @@ -22,6 +27,7 @@ describe('openNoteWindow', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-04-14T16:00:00Z')) vi.mocked(isTauri).mockReturnValue(false) + vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(false) }) it('builds a root-app route that preserves the note window params', () => { @@ -56,6 +62,21 @@ describe('openNoteWindow', () => { resizable: true, titleBarStyle: 'overlay', hiddenTitle: true, + decorations: true, + }), + ) + }) + + it('drops native decorations when Linux window chrome is active', async () => { + vi.mocked(isTauri).mockReturnValue(true) + vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(true) + + await openNoteInNewWindow('/vault/linux.md', '/vault', 'Linux Note') + + expect(webviewWindowCalls).toHaveBeenCalledWith( + 'note-1776182400000', + expect.objectContaining({ + decorations: false, }), ) }) diff --git a/src/utils/openNoteWindow.ts b/src/utils/openNoteWindow.ts index cf7ae1d2..4892d5ca 100644 --- a/src/utils/openNoteWindow.ts +++ b/src/utils/openNoteWindow.ts @@ -1,4 +1,5 @@ import { isTauri } from '../mock-tauri' +import { shouldUseLinuxWindowChrome } from './platform' export function buildNoteWindowUrl(notePath: string, vaultPath: string, noteTitle: string): string { const params = new URLSearchParams({ @@ -29,5 +30,6 @@ export async function openNoteInNewWindow(notePath: string, vaultPath: string, n resizable: true, titleBarStyle: 'overlay', hiddenTitle: true, + decorations: !shouldUseLinuxWindowChrome(), }) } diff --git a/src/utils/platform.test.ts b/src/utils/platform.test.ts new file mode 100644 index 00000000..c3b100f4 --- /dev/null +++ b/src/utils/platform.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isTauri } from '../mock-tauri' +import { isLinux, isMac, shouldUseLinuxWindowChrome } from './platform' + +vi.mock('../mock-tauri', () => ({ + isTauri: vi.fn(), +})) + +const originalUserAgent = navigator.userAgent + +function setUserAgent(userAgent: string) { + Object.defineProperty(window.navigator, 'userAgent', { + configurable: true, + value: userAgent, + }) +} + +describe('platform helpers', () => { + afterEach(() => { + setUserAgent(originalUserAgent) + vi.mocked(isTauri).mockReturnValue(false) + }) + + it('detects Linux user agents but ignores Android', () => { + setUserAgent('Mozilla/5.0 (X11; Linux x86_64)') + expect(isLinux()).toBe(true) + + setUserAgent('Mozilla/5.0 (Linux; Android 14)') + expect(isLinux()).toBe(false) + }) + + it('detects macOS user agents', () => { + setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') + expect(isMac()).toBe(true) + }) + + it('only enables Linux window chrome inside Tauri', () => { + setUserAgent('Mozilla/5.0 (X11; Linux x86_64)') + vi.mocked(isTauri).mockReturnValue(false) + expect(shouldUseLinuxWindowChrome()).toBe(false) + + vi.mocked(isTauri).mockReturnValue(true) + expect(shouldUseLinuxWindowChrome()).toBe(true) + }) +}) diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 00000000..6e9c1edb --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,20 @@ +import { isTauri } from '../mock-tauri' + +function getUserAgent(): string { + if (typeof navigator === 'undefined') return '' + return navigator.userAgent +} + +export function isLinux(): boolean { + const userAgent = getUserAgent() + return userAgent.includes('Linux') && !userAgent.includes('Android') +} + +export function isMac(): boolean { + const userAgent = getUserAgent() + return userAgent.includes('Mac OS X') || userAgent.includes('Macintosh') +} + +export function shouldUseLinuxWindowChrome(): boolean { + return isTauri() && isLinux() +} diff --git a/tests/smoke/keyboard-command-routing.spec.ts b/tests/smoke/keyboard-command-routing.spec.ts index 2dd0c07a..49a8677f 100644 --- a/tests/smoke/keyboard-command-routing.spec.ts +++ b/tests/smoke/keyboard-command-routing.spec.ts @@ -126,10 +126,10 @@ test.describe('keyboard command routing', () => { await dispatchShortcutEvent(page, { key: 'l', code: 'KeyL', - ctrlKey: true, + ctrlKey: false, metaKey: false, shiftKey: true, - altKey: false, + altKey: true, bubbles: true, cancelable: true, })