diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a90d2cc4f..e13c732f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,3 +92,29 @@ jobs: run: npm ci - name: Tauri debug build run: npm run tauri -- build --debug --no-bundle + + build-windows: + runs-on: windows-latest + needs: + - lint + - typecheck + - test-js + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + - uses: dtolnay/rust-toolchain@stable + - name: Install LLVM (bindgen) + run: choco install llvm -y --no-progress + - name: Configure LLVM (bindgen) + run: | + echo "LIBCLANG_PATH=C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_ENV + echo "C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_PATH + - name: Install dependencies + run: npm ci + - name: Doctor (Windows) + run: npm run doctor:win + - name: Tauri debug build (Windows) + run: npm run tauri -- build --debug --no-bundle --config src-tauri/tauri.windows.conf.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16995297c..d961f5cc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -235,12 +235,74 @@ jobs: src-tauri/target/release/bundle/appimage/*.AppImage* src-tauri/target/release/bundle/rpm/*.rpm + build_windows: + runs-on: windows-latest + environment: release + env: + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY_B64: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_B64 }} + steps: + - uses: actions/checkout@v4 + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install LLVM (bindgen) + run: choco install llvm -y --no-progress + + - name: Configure LLVM (bindgen) + run: | + echo "LIBCLANG_PATH=C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_ENV + echo "C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_PATH + + - name: install frontend dependencies + run: npm ci + + - name: Write Tauri signing key + shell: bash + run: | + set -euo pipefail + python - <<'PY' + import base64 + import os + from pathlib import Path + + raw = base64.b64decode(os.environ["TAURI_SIGNING_PRIVATE_KEY_B64"]) + home = Path.home() + target = home / ".tauri" + target.mkdir(parents=True, exist_ok=True) + (target / "codexmonitor.key").write_bytes(raw) + PY + + - name: build windows bundles + shell: bash + run: | + set -euo pipefail + export TAURI_SIGNING_PRIVATE_KEY + TAURI_SIGNING_PRIVATE_KEY="$(cat "$HOME/.tauri/codexmonitor.key")" + npm run tauri:build:win + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-artifacts + path: | + src-tauri/target/release/bundle/nsis/*.exe* + src-tauri/target/release/bundle/msi/*.msi* + release: runs-on: ubuntu-latest environment: release needs: - build_macos - build_linux + - build_windows steps: - name: Checkout uses: actions/checkout@v4 @@ -260,6 +322,11 @@ jobs: path: release-artifacts merge-multiple: true + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-artifacts + path: release-artifacts - name: Validate RPM artifacts run: | set -euo pipefail @@ -377,6 +444,37 @@ jobs: "signature": sig_path.read_text().strip(), } + exe_candidates = sorted(artifacts_dir.rglob("*.exe"), key=lambda p: p.name.lower()) + windows_installer = None + preferred_installers = [] + for candidate in exe_candidates: + lowered = candidate.name.lower() + if "codexmonitor" in lowered and ("setup" in lowered or "installer" in lowered): + preferred_installers.append(candidate) + if preferred_installers: + windows_installer = preferred_installers[0] + else: + for candidate in exe_candidates: + lowered = candidate.name.lower() + if "setup" in lowered or "installer" in lowered: + windows_installer = candidate + break + if windows_installer is None and exe_candidates: + windows_installer = exe_candidates[0] + if windows_installer is None: + raise SystemExit("No Windows installer (.exe) found for latest.json") + + print(f"Selected Windows installer for latest.json: {windows_installer.name}") + + win_sig_path = windows_installer.with_suffix(windows_installer.suffix + ".sig") + if not win_sig_path.exists(): + raise SystemExit(f"Missing signature for {windows_installer.name}") + + platforms["windows-x86_64"] = { + "url": f"https://github.com/Dimillian/CodexMonitor/releases/download/v${VERSION}/{windows_installer.name}", + "signature": win_sig_path.read_text().strip(), + } + payload = { "version": "${VERSION}", "notes": notes, @@ -404,11 +502,23 @@ jobs: shopt -s nullglob globstar appimages=(release-artifacts/**/*.AppImage*) mapfile -t rpms < <(find release-artifacts -type f -name '*.rpm' | sort) + mapfile -t windows_exes < <(find release-artifacts -type f -name '*.exe*' | sort) + mapfile -t windows_msis < <(find release-artifacts -type f -name '*.msi*' | sort) if [ ${#rpms[@]} -eq 0 ]; then echo "No RPM artifacts found for release upload" find release-artifacts -type f | sort exit 1 fi + if [ ${#windows_exes[@]} -eq 0 ]; then + echo "No Windows .exe artifacts found for release upload" + find release-artifacts -type f | sort + exit 1 + fi + if [ ${#windows_msis[@]} -eq 0 ]; then + echo "No Windows .msi artifacts found for release upload" + find release-artifacts -type f | sort + exit 1 + fi gh release create "v${VERSION}" \ --title "v${VERSION}" \ @@ -420,6 +530,8 @@ jobs: release-artifacts/CodexMonitor.app.tar.gz.sig \ "${appimages[@]}" \ "${rpms[@]}" \ + "${windows_exes[@]}" \ + "${windows_msis[@]}" \ release-artifacts/latest.json - name: Bump version and open PR diff --git a/README.md b/README.md index 9c46250ef..6e45a0f7e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![CodexMonitor](screenshot.png) -CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across local workspaces. It provides a sidebar to manage projects, a home screen for quick actions, and a conversation view backed by the Codex app-server protocol. +CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local workspaces. It provides a sidebar to manage projects, a home screen for quick actions, and a conversation view backed by the Codex app-server protocol. ## Features @@ -31,7 +31,7 @@ CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across ### Files & Prompts -- File tree with search, file-type icons, and Reveal in Finder. +- File tree with search, file-type icons, and Reveal in Finder/Explorer. - Prompt library for global/workspace prompts: create/edit/delete/move and run in current or new threads. ### UI & Experience @@ -40,13 +40,14 @@ CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across - Responsive layouts (desktop/tablet/phone) with tabbed navigation. - Sidebar usage and credits meter for account rate limits plus a home usage snapshot. - Terminal dock with multiple tabs for background commands (experimental). -- In-app updates with toast-driven download/install, debug panel copy/clear, sound notifications, and macOS overlay title bar with vibrancy + reduced transparency toggle. +- In-app updates with toast-driven download/install, debug panel copy/clear, sound notifications, plus platform-specific window effects (macOS overlay title bar + vibrancy) and a reduced transparency toggle. ## Requirements - Node.js + npm - Rust toolchain (stable) -- CMake (required for native dependencies; Whisper/dictation uses it on non-Windows) +- CMake (required for native dependencies; dictation/Whisper uses it) +- LLVM/Clang (required on Windows to build dictation dependencies via bindgen) - Codex installed on your system and available as `codex` in `PATH` - Git CLI (used for worktree operations) - GitHub CLI (`gh`) for the Issues panel (optional) @@ -74,13 +75,13 @@ npm run tauri dev ## Release Build -Build the production Tauri bundle (app + dmg): +Build the production Tauri bundle: ```bash npm run tauri build ``` -The macOS app bundle will be in `src-tauri/target/release/bundle/macos/`. +Artifacts will be in `src-tauri/target/release/bundle/` (platform-specific subfolders). ### Windows (opt-in) @@ -94,8 +95,8 @@ Artifacts will be in: - `src-tauri/target/release/bundle/nsis/` (installer exe) - `src-tauri/target/release/bundle/msi/` (msi) - -Note: dictation is currently disabled on Windows builds (to avoid requiring LLVM/libclang for `whisper-rs`/bindgen). + +Note: building from source on Windows requires LLVM/Clang (for `bindgen` / `libclang`) in addition to CMake. ## Type Checking diff --git a/docs/changelog.html b/docs/changelog.html index 52df64ca8..d90c6c331 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -81,7 +81,7 @@

Changelog

Codex Monitor app icon Codex Monitor -

macOS Codex agents orchestration, built by and for individuals who ship fast.

+

Desktop Codex agent orchestration, built by and for individuals who ship fast.

@@ -129,7 +129,7 @@

Skills + prompts

Updater + polish

-

Toast-driven updates, resizable panels, and a macOS overlay title bar.

+

Toast-driven updates, resizable panels, and platform-specific window chrome.

@@ -412,7 +412,7 @@

Ready to monitor every agent run?

Codex Monitor app icon Codex Monitor -

macOS Codex agents orchestration, built by and for individuals who ship fast.

+

Desktop Codex agent orchestration, built by and for individuals who ship fast.

diff --git a/src/features/app/components/OpenAppMenu.tsx b/src/features/app/components/OpenAppMenu.tsx index 616b14628..d6defb714 100644 --- a/src/features/app/components/OpenAppMenu.tsx +++ b/src/features/app/components/OpenAppMenu.tsx @@ -61,9 +61,14 @@ export function OpenAppMenu({ const fallbackTarget: OpenTarget = { id: DEFAULT_OPEN_APP_ID, - label: DEFAULT_OPEN_APP_TARGETS[0]?.label ?? "Open", + label: + DEFAULT_OPEN_APP_TARGETS.find((target) => target.id === DEFAULT_OPEN_APP_ID) + ?.label ?? + DEFAULT_OPEN_APP_TARGETS[0]?.label ?? + "Open", icon: getKnownOpenAppIcon(DEFAULT_OPEN_APP_ID) ?? GENERIC_APP_ICON, target: + DEFAULT_OPEN_APP_TARGETS.find((target) => target.id === DEFAULT_OPEN_APP_ID) ?? DEFAULT_OPEN_APP_TARGETS[0] ?? { id: DEFAULT_OPEN_APP_ID, label: "VS Code", diff --git a/src/features/app/constants.ts b/src/features/app/constants.ts index 1f06f4ad0..dacc6d2a2 100644 --- a/src/features/app/constants.ts +++ b/src/features/app/constants.ts @@ -1,50 +1,99 @@ import type { OpenAppTarget } from "../../types"; +import { + fileManagerName, + isMacPlatform, + isWindowsPlatform, +} from "../../utils/platformPaths"; export const OPEN_APP_STORAGE_KEY = "open-workspace-app"; -export const DEFAULT_OPEN_APP_ID = "vscode"; +export const DEFAULT_OPEN_APP_ID = isWindowsPlatform() ? "finder" : "vscode"; export type OpenAppId = string; -export const DEFAULT_OPEN_APP_TARGETS: OpenAppTarget[] = [ - { - id: "vscode", - label: "VS Code", - kind: "app", - appName: "Visual Studio Code", - args: [], - }, - { - id: "cursor", - label: "Cursor", - kind: "app", - appName: "Cursor", - args: [], - }, - { - id: "zed", - label: "Zed", - kind: "app", - appName: "Zed", - args: [], - }, - { - id: "ghostty", - label: "Ghostty", - kind: "app", - appName: "Ghostty", - args: [], - }, - { - id: "antigravity", - label: "Antigravity", - kind: "app", - appName: "Antigravity", - args: [], - }, - { - id: "finder", - label: "Finder", - kind: "finder", - args: [], - }, -]; +export const DEFAULT_OPEN_APP_TARGETS: OpenAppTarget[] = isMacPlatform() + ? [ + { + id: "vscode", + label: "VS Code", + kind: "app", + appName: "Visual Studio Code", + args: [], + }, + { + id: "cursor", + label: "Cursor", + kind: "app", + appName: "Cursor", + args: [], + }, + { + id: "zed", + label: "Zed", + kind: "app", + appName: "Zed", + args: [], + }, + { + id: "ghostty", + label: "Ghostty", + kind: "app", + appName: "Ghostty", + args: [], + }, + { + id: "antigravity", + label: "Antigravity", + kind: "app", + appName: "Antigravity", + args: [], + }, + { + id: "finder", + label: fileManagerName(), + kind: "finder", + args: [], + }, + ] + : [ + { + id: "vscode", + label: "VS Code", + kind: "command", + command: "code", + args: [], + }, + { + id: "cursor", + label: "Cursor", + kind: "command", + command: "cursor", + args: [], + }, + { + id: "zed", + label: "Zed", + kind: "command", + command: "zed", + args: [], + }, + { + id: "ghostty", + label: "Ghostty", + kind: "command", + command: "ghostty", + args: [], + }, + { + id: "antigravity", + label: "Antigravity", + kind: "command", + command: "antigravity", + args: [], + }, + { + id: "finder", + label: fileManagerName(), + kind: "finder", + args: [], + }, + ]; diff --git a/src/features/app/hooks/useNewAgentShortcut.ts b/src/features/app/hooks/useNewAgentShortcut.ts index 52db7e9ed..9675fa753 100644 --- a/src/features/app/hooks/useNewAgentShortcut.ts +++ b/src/features/app/hooks/useNewAgentShortcut.ts @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { isMacPlatform } from "../../../utils/shortcuts"; type UseNewAgentShortcutOptions = { isEnabled: boolean; @@ -13,8 +14,8 @@ export function useNewAgentShortcut({ if (!isEnabled) { return; } + const isMac = isMacPlatform(); function handleKeyDown(event: KeyboardEvent) { - const isMac = navigator.platform.toUpperCase().includes("MAC"); const modifierKey = isMac ? event.metaKey : event.ctrlKey; if (modifierKey && event.key === "n" && !event.shiftKey) { event.preventDefault(); diff --git a/src/features/app/hooks/useOpenAppIcons.ts b/src/features/app/hooks/useOpenAppIcons.ts index e10e3e104..0cbcca3de 100644 --- a/src/features/app/hooks/useOpenAppIcons.ts +++ b/src/features/app/hooks/useOpenAppIcons.ts @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { getOpenAppIcon } from "../../../services/tauri"; import type { OpenAppTarget } from "../../../types"; import { getKnownOpenAppIcon } from "../utils/openAppIcons"; +import { isMacPlatform } from "../../../utils/platformPaths"; type OpenAppIconMap = Record; @@ -10,18 +11,8 @@ type ResolvedAppTarget = { appName: string; }; -function detectMacOS(): boolean { - if (typeof navigator === "undefined") { - return false; - } - const platform = - (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData - ?.platform ?? navigator.platform ?? ""; - return platform.toLowerCase().includes("mac"); -} - export function useOpenAppIcons(openTargets: OpenAppTarget[]): OpenAppIconMap { - const isMacOS = detectMacOS(); + const isMacOS = isMacPlatform(); const iconCacheRef = useRef>(new Map()); const inFlightRef = useRef>>(new Map()); const [iconById, setIconById] = useState({}); diff --git a/src/features/app/hooks/useSidebarMenus.test.tsx b/src/features/app/hooks/useSidebarMenus.test.tsx index 01189fa11..9e26d47d1 100644 --- a/src/features/app/hooks/useSidebarMenus.test.tsx +++ b/src/features/app/hooks/useSidebarMenus.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { WorkspaceInfo } from "../../../types"; import { useSidebarMenus } from "./useSidebarMenus"; +import { fileManagerName } from "../../../utils/platformPaths"; const menuNew = vi.hoisted(() => vi.fn(async ({ items }) => ({ popup: vi.fn(), items })), @@ -42,7 +43,7 @@ vi.mock("../../../services/toasts", () => ({ })); describe("useSidebarMenus", () => { - it("adds a show in finder option for worktrees", async () => { + it("adds a show in file manager option for worktrees", async () => { const onDeleteThread = vi.fn(); const onSyncThread = vi.fn(); const onPinThread = vi.fn(); @@ -91,7 +92,7 @@ describe("useSidebarMenus", () => { const menuArgs = menuNew.mock.calls[0]?.[0]; const revealItem = menuArgs.items.find( - (item: { text: string }) => item.text === "Show in Finder", + (item: { text: string }) => item.text === `Show in ${fileManagerName()}`, ); expect(revealItem).toBeDefined(); diff --git a/src/features/app/hooks/useSidebarMenus.ts b/src/features/app/hooks/useSidebarMenus.ts index ff4f1eba5..19b69dfbc 100644 --- a/src/features/app/hooks/useSidebarMenus.ts +++ b/src/features/app/hooks/useSidebarMenus.ts @@ -5,6 +5,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import type { WorkspaceInfo } from "../../../types"; import { pushErrorToast } from "../../../services/toasts"; +import { fileManagerName } from "../../../utils/platformPaths"; type SidebarMenuHandlers = { onDeleteThread: (workspaceId: string, threadId: string) => void; @@ -116,12 +117,13 @@ export function useSidebarMenus({ async (event: MouseEvent, worktree: WorkspaceInfo) => { event.preventDefault(); event.stopPropagation(); + const fileManagerLabel = fileManagerName(); const reloadItem = await MenuItem.new({ text: "Reload threads", action: () => onReloadWorkspaceThreads(worktree.id), }); const revealItem = await MenuItem.new({ - text: "Show in Finder", + text: `Show in ${fileManagerLabel}`, action: async () => { if (!worktree.path) { return; @@ -134,7 +136,7 @@ export function useSidebarMenus({ } catch (error) { const message = error instanceof Error ? error.message : String(error); pushErrorToast({ - title: "Couldn't show worktree in Finder", + title: `Couldn't show worktree in ${fileManagerLabel}`, message, }); console.warn("Failed to reveal worktree", { diff --git a/src/features/app/utils/openAppIcons.ts b/src/features/app/utils/openAppIcons.ts index 82d8e9675..836520b90 100644 --- a/src/features/app/utils/openAppIcons.ts +++ b/src/features/app/utils/openAppIcons.ts @@ -4,6 +4,7 @@ import antigravityIcon from "../../../assets/app-icons/antigravity.png"; import ghosttyIcon from "../../../assets/app-icons/ghostty.png"; import vscodeIcon from "../../../assets/app-icons/vscode.png"; import zedIcon from "../../../assets/app-icons/zed.png"; +import { isMacPlatform } from "../../../utils/platformPaths"; const GENERIC_APP_SVG = ""; @@ -12,6 +13,13 @@ export const GENERIC_APP_ICON = `data:image/svg+xml;utf8,${encodeURIComponent( GENERIC_APP_SVG, )}`; +const GENERIC_FOLDER_SVG = + ""; + +export const GENERIC_FOLDER_ICON = `data:image/svg+xml;utf8,${encodeURIComponent( + GENERIC_FOLDER_SVG, +)}`; + export function getKnownOpenAppIcon(id: string): string | null { switch (id) { case "vscode": @@ -25,7 +33,7 @@ export function getKnownOpenAppIcon(id: string): string | null { case "antigravity": return antigravityIcon; case "finder": - return finderIcon; + return isMacPlatform() ? finderIcon : GENERIC_FOLDER_ICON; default: return null; } diff --git a/src/features/files/components/FileTreePanel.tsx b/src/features/files/components/FileTreePanel.tsx index a7fd87661..c479bdc3a 100644 --- a/src/features/files/components/FileTreePanel.tsx +++ b/src/features/files/components/FileTreePanel.tsx @@ -18,6 +18,7 @@ import { readWorkspaceFile } from "../../../services/tauri"; import type { OpenAppTarget } from "../../../types"; import { useDebouncedValue } from "../../../hooks/useDebouncedValue"; import { languageFromPath } from "../../../utils/syntax"; +import { joinWorkspacePath, revealInFileManagerLabel } from "../../../utils/platformPaths"; import { getFileTypeIconUrl } from "../../../utils/fileTypeIcons"; import { FilePreviewPopover } from "./FilePreviewPopover"; @@ -326,10 +327,7 @@ export function FileTreePanel({ const resolvePath = useCallback( (relativePath: string) => { - const base = workspacePath.endsWith("/") - ? workspacePath.slice(0, -1) - : workspacePath; - return `${base}/${relativePath}`; + return joinWorkspacePath(workspacePath, relativePath); }, [workspacePath], ); @@ -563,7 +561,7 @@ export function FileTreePanel({ }, }), await MenuItem.new({ - text: "Reveal in Finder", + text: revealInFileManagerLabel(), action: async () => { await revealItemInDir(resolvePath(relativePath)); }, diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index bd309af66..d713edbdb 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { GitLogEntry } from "../../../types"; import { GitDiffPanel } from "./GitDiffPanel"; +import { fileManagerName } from "../../../utils/platformPaths"; const menuNew = vi.hoisted(() => vi.fn(async ({ items }) => ({ popup: vi.fn(), items })), @@ -87,7 +88,7 @@ describe("GitDiffPanel", () => { expect(onCommit).toHaveBeenCalledTimes(1); }); - it("adds a show in finder option for file context menus", async () => { + it("adds a show in file manager option for file context menus", async () => { clipboardWriteText.mockClear(); const { container } = render( { await waitFor(() => expect(menuNew).toHaveBeenCalled()); const menuArgs = menuNew.mock.calls[0]?.[0]; const revealItem = menuArgs.items.find( - (item: { text: string }) => item.text === "Show in Finder", + (item: { text: string }) => item.text === `Show in ${fileManagerName()}`, ); expect(revealItem).toBeDefined(); @@ -172,7 +173,7 @@ describe("GitDiffPanel", () => { await waitFor(() => expect(menuNew).toHaveBeenCalled()); const menuArgs = menuNew.mock.calls[menuNew.mock.calls.length - 1]?.[0]; const revealItem = menuArgs.items.find( - (item: { text: string }) => item.text === "Show in Finder", + (item: { text: string }) => item.text === `Show in ${fileManagerName()}`, ); expect(revealItem).toBeDefined(); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 3c0100a14..cbd9db364 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -22,6 +22,10 @@ import { useMemo, useState, useCallback, useEffect, useRef } from "react"; import { formatRelativeTime } from "../../../utils/time"; import { PanelTabs, type PanelTabId } from "../../layout/components/PanelTabs"; import { pushErrorToast } from "../../../services/toasts"; +import { + fileManagerName, + isAbsolutePath as isAbsolutePathForPlatform, +} from "../../../utils/platformPaths"; type GitDiffPanelProps = { workspaceId?: string | null; @@ -168,17 +172,12 @@ function getRelativePathWithin(base: string, target: string) { } return targetSegments.slice(baseSegments.length).join("/"); } - -function isAbsolutePath(value: string) { - return value.startsWith("/") || /^[A-Za-z]:\//.test(value); -} - function resolveRootPath(root: string | null | undefined, workspacePath: string | null | undefined) { const normalized = normalizeRootPath(root); if (!normalized) { return ""; } - if (workspacePath && !isAbsolutePath(normalized)) { + if (workspacePath && !isAbsolutePathForPlatform(normalized)) { return joinRootAndPath(workspacePath, normalized); } return normalized; @@ -1071,6 +1070,7 @@ export function GitDiffPanel({ } if (targetPaths.length === 1) { + const fileManagerLabel = fileManagerName(); const rawPath = targetPaths[0]; const absolutePath = resolvedRoot ? joinRootAndPath(resolvedRoot, rawPath) @@ -1084,12 +1084,12 @@ export function GitDiffPanel({ const fileName = getFileName(rawPath); items.push( await MenuItem.new({ - text: "Show in Finder", + text: `Show in ${fileManagerLabel}`, action: async () => { try { - if (!resolvedRoot && !absolutePath.startsWith("/")) { + if (!resolvedRoot && !isAbsolutePathForPlatform(absolutePath)) { pushErrorToast({ - title: "Couldn't show file in Finder", + title: `Couldn't show file in ${fileManagerLabel}`, message: "Select a git root first.", }); return; @@ -1102,7 +1102,7 @@ export function GitDiffPanel({ const message = error instanceof Error ? error.message : String(error); pushErrorToast({ - title: "Couldn't show file in Finder", + title: `Couldn't show file in ${fileManagerLabel}`, message, }); console.warn("Failed to reveal file", { diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index d96640210..a3c56b75f 100644 --- a/src/features/home/components/Home.tsx +++ b/src/features/home/components/Home.tsx @@ -1,3 +1,4 @@ +import FolderOpen from "lucide-react/dist/esm/icons/folder-open"; import RefreshCw from "lucide-react/dist/esm/icons/refresh-cw"; import type { LocalUsageSnapshot } from "../../../types"; import { formatRelativeTime } from "../../../utils/time"; @@ -243,7 +244,7 @@ export function Home({ data-tauri-drag-region="false" > - ⌘ + Open Project diff --git a/src/features/layout/hooks/useUiScaleShortcuts.ts b/src/features/layout/hooks/useUiScaleShortcuts.ts index c94269e03..55d8038dd 100644 --- a/src/features/layout/hooks/useUiScaleShortcuts.ts +++ b/src/features/layout/hooks/useUiScaleShortcuts.ts @@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import type { AppSettings } from "../../../types"; import { clampUiScale, UI_SCALE_STEP } from "../../../utils/uiScale"; +import { isMacPlatform } from "../../../utils/shortcuts"; type UseUiScaleShortcutsOptions = { settings: AppSettings; @@ -34,10 +35,7 @@ export function useUiScaleShortcuts({ }, [uiScale]); const scaleShortcutLabel = useMemo(() => { - if (typeof navigator === "undefined") { - return "Ctrl"; - } - return /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Cmd" : "Ctrl"; + return isMacPlatform() ? "Cmd" : "Ctrl"; }, []); const scaleShortcutTitle = `${scaleShortcutLabel}+ and ${scaleShortcutLabel}-, ${scaleShortcutLabel}+0 to reset.`; diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index a4da2c256..00ca4a9b0 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -7,6 +7,11 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; +import { + isAbsolutePath, + joinWorkspacePath, + revealInFileManagerLabel, +} from "../../../utils/platformPaths"; type OpenTarget = { id: string; @@ -43,11 +48,10 @@ function resolveFilePath(path: string, workspacePath?: string | null) { if (!workspacePath) { return trimmed; } - if (trimmed.startsWith("/") || trimmed.startsWith("~/")) { + if (isAbsolutePath(trimmed)) { return trimmed; } - const base = workspacePath.replace(/\/+$/, ""); - return `${base}/${trimmed}`; + return joinWorkspacePath(workspacePath, trimmed); } function stripLineSuffix(path: string) { @@ -55,20 +59,6 @@ function stripLineSuffix(path: string) { return match ? match[1] : path; } -function revealLabel() { - const platform = - (navigator as Navigator & { userAgentData?: { platform?: string } }) - .userAgentData?.platform ?? navigator.platform ?? ""; - const normalized = platform.toLowerCase(); - if (normalized.includes("mac")) { - return "Reveal in Finder"; - } - if (normalized.includes("win")) { - return "Show in Explorer"; - } - return "Reveal in File Manager"; -} - export function useFileLinkOpener( workspacePath: string | null, openTargets: OpenAppTarget[], @@ -154,7 +144,7 @@ export function useFileLinkOpener( const canOpen = canOpenTarget(target); const openLabel = target.kind === "finder" - ? revealLabel() + ? revealInFileManagerLabel() : target.kind === "command" ? command ? `Open in ${target.label}` @@ -174,7 +164,7 @@ export function useFileLinkOpener( ? [] : [ await MenuItem.new({ - text: revealLabel(), + text: revealInFileManagerLabel(), action: async () => { try { await revealItemInDir(resolvedPath); diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 2a1ec1375..f9348f353 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -49,9 +49,9 @@ const baseSettings: AppSettings = { theme: "system", usageShowRemaining: false, uiFontFamily: - "\"SF Pro Text\", \"SF Pro Display\", -apple-system, \"Helvetica Neue\", sans-serif", + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', codeFontFamily: - "\"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, monospace", + 'ui-monospace, "Cascadia Mono", "Segoe UI Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', codeFontSize: 11, notificationSoundsEnabled: true, systemNotificationsEnabled: true, @@ -408,12 +408,12 @@ describe("SettingsView Display", () => { await waitFor(() => { expect(onUpdateAppSettings).toHaveBeenCalledWith( expect.objectContaining({ - uiFontFamily: expect.stringContaining("SF Pro Text"), + uiFontFamily: expect.stringContaining("system-ui"), }), ); expect(onUpdateAppSettings).toHaveBeenCalledWith( expect.objectContaining({ - codeFontFamily: expect.stringContaining("SF Mono"), + codeFontFamily: expect.stringContaining("ui-monospace"), }), ); }); diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index e0a49df48..1233171cc 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -26,6 +26,12 @@ import type { WorkspaceInfo, } from "../../../types"; import { formatDownloadSize } from "../../../utils/formatting"; +import { + fileManagerName, + isMacPlatform, + isWindowsPlatform, + openInFileManagerLabel, +} from "../../../utils/platformPaths"; import { buildShortcutValue, formatShortcut, @@ -451,6 +457,12 @@ export function SettingsView({ const globalConfigSaveLabel = globalConfigExists ? "Save" : "Create"; const globalConfigSaveDisabled = globalConfigLoading || globalConfigSaving || !globalConfigDirty; const globalConfigRefreshDisabled = globalConfigLoading || globalConfigSaving; + const optionKeyLabel = isMacPlatform() ? "Option" : "Alt"; + const metaKeyLabel = isMacPlatform() + ? "Command" + : isWindowsPlatform() + ? "Windows" + : "Meta"; const selectedDictationModel = useMemo(() => { return ( DICTATION_MODELS.find( @@ -1795,7 +1807,7 @@ export function SettingsView({
System notifications
- Show a macOS notification when a long-running agent finishes while the window is unfocused. + Show a system notification when a long-running agent finishes while the window is unfocused.
- Default: {formatShortcut("cmd+ctrl+a")} + Default:{" "} + {formatShortcut(isMacPlatform() ? "cmd+ctrl+a" : "ctrl+alt+a")}
@@ -2627,7 +2640,10 @@ export function SettingsView({
- Default: {formatShortcut("cmd+ctrl+down")} + Default:{" "} + {formatShortcut( + isMacPlatform() ? "cmd+ctrl+down" : "ctrl+alt+down", + )}
@@ -2651,7 +2667,10 @@ export function SettingsView({
- Default: {formatShortcut("cmd+ctrl+up")} + Default:{" "} + {formatShortcut( + isMacPlatform() ? "cmd+ctrl+up" : "ctrl+alt+up", + )}
@@ -2675,7 +2694,12 @@ export function SettingsView({
- Default: {formatShortcut("cmd+shift+down")} + Default:{" "} + {formatShortcut( + isMacPlatform() + ? "cmd+shift+down" + : "ctrl+alt+shift+down", + )}
@@ -2699,7 +2723,10 @@ export function SettingsView({
- Default: {formatShortcut("cmd+shift+up")} + Default:{" "} + {formatShortcut( + isMacPlatform() ? "cmd+shift+up" : "ctrl+alt+shift+up", + )}
@@ -2779,7 +2806,7 @@ export function SettingsView({ > - + {target.kind === "app" && ( @@ -2906,8 +2933,10 @@ export function SettingsView({ Add app
- Commands receive the selected path as the final argument. Apps use macOS open - with optional args. + Commands receive the selected path as the final argument.{" "} + {isMacPlatform() + ? "Apps open via `open -a` with optional args." + : "Apps run as an executable with optional args."}
@@ -3408,11 +3437,11 @@ export function SettingsView({
Config file
- Open the Codex config in Finder. + Open the Codex config in {fileManagerName()}.
{openConfigError && ( diff --git a/src/features/settings/hooks/useAppSettings.test.ts b/src/features/settings/hooks/useAppSettings.test.ts index 8f554f13c..bac9399c0 100644 --- a/src/features/settings/hooks/useAppSettings.test.ts +++ b/src/features/settings/hooks/useAppSettings.test.ts @@ -49,8 +49,8 @@ describe("useAppSettings", () => { expect(result.current.settings.uiScale).toBe(UI_SCALE_MAX); expect(result.current.settings.theme).toBe("system"); - expect(result.current.settings.uiFontFamily).toContain("SF Pro Text"); - expect(result.current.settings.codeFontFamily).toContain("SF Mono"); + expect(result.current.settings.uiFontFamily).toContain("system-ui"); + expect(result.current.settings.codeFontFamily).toContain("ui-monospace"); expect(result.current.settings.codeFontSize).toBe(16); expect(result.current.settings.personality).toBe("friendly"); expect(result.current.settings.backendMode).toBe("remote"); @@ -66,8 +66,8 @@ describe("useAppSettings", () => { expect(result.current.settings.uiScale).toBe(UI_SCALE_DEFAULT); expect(result.current.settings.theme).toBe("system"); - expect(result.current.settings.uiFontFamily).toContain("SF Pro Text"); - expect(result.current.settings.codeFontFamily).toContain("SF Mono"); + expect(result.current.settings.uiFontFamily).toContain("system-ui"); + expect(result.current.settings.codeFontFamily).toContain("ui-monospace"); expect(result.current.settings.backendMode).toBe("local"); expect(result.current.settings.dictationModelId).toBe("base"); expect(result.current.settings.interruptShortcut).toBeTruthy(); @@ -110,8 +110,8 @@ describe("useAppSettings", () => { expect.objectContaining({ theme: "system", uiScale: 0.1, - uiFontFamily: expect.stringContaining("SF Pro Text"), - codeFontFamily: expect.stringContaining("SF Mono"), + uiFontFamily: expect.stringContaining("system-ui"), + codeFontFamily: expect.stringContaining("ui-monospace"), codeFontSize: 9, notificationSoundsEnabled: false, }), diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 882757343..f5f4185bb 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { AppSettings } from "../../../types"; import { getAppSettings, runCodexDoctor, updateAppSettings } from "../../../services/tauri"; import { clampUiScale, UI_SCALE_DEFAULT } from "../../../utils/uiScale"; @@ -15,72 +15,75 @@ import { OPEN_APP_STORAGE_KEY, } from "../../app/constants"; import { normalizeOpenAppTargets } from "../../app/utils/openApp"; -import { getDefaultInterruptShortcut } from "../../../utils/shortcuts"; +import { getDefaultInterruptShortcut, isMacPlatform } from "../../../utils/shortcuts"; const allowedThemes = new Set(["system", "light", "dark", "dim"]); const allowedPersonality = new Set(["friendly", "pragmatic"]); -const defaultSettings: AppSettings = { - codexBin: null, - codexArgs: null, - backendMode: "local", - remoteBackendHost: "127.0.0.1:4732", - remoteBackendToken: null, - defaultAccessMode: "current", - reviewDeliveryMode: "inline", - composerModelShortcut: "cmd+shift+m", - composerAccessShortcut: "cmd+shift+a", - composerReasoningShortcut: "cmd+shift+r", - composerCollaborationShortcut: "shift+tab", - interruptShortcut: getDefaultInterruptShortcut(), - newAgentShortcut: "cmd+n", - newWorktreeAgentShortcut: "cmd+shift+n", - newCloneAgentShortcut: "cmd+alt+n", - archiveThreadShortcut: "cmd+ctrl+a", - toggleProjectsSidebarShortcut: "cmd+shift+p", - toggleGitSidebarShortcut: "cmd+shift+g", - branchSwitcherShortcut: "cmd+b", - toggleDebugPanelShortcut: "cmd+shift+d", - toggleTerminalShortcut: "cmd+shift+t", - cycleAgentNextShortcut: "cmd+ctrl+down", - cycleAgentPrevShortcut: "cmd+ctrl+up", - cycleWorkspaceNextShortcut: "cmd+shift+down", - cycleWorkspacePrevShortcut: "cmd+shift+up", - lastComposerModelId: null, - lastComposerReasoningEffort: null, - uiScale: UI_SCALE_DEFAULT, - theme: "system", - usageShowRemaining: false, - uiFontFamily: DEFAULT_UI_FONT_FAMILY, - codeFontFamily: DEFAULT_CODE_FONT_FAMILY, - codeFontSize: CODE_FONT_SIZE_DEFAULT, - notificationSoundsEnabled: true, - systemNotificationsEnabled: true, - preloadGitDiffs: true, - gitDiffIgnoreWhitespaceChanges: false, - experimentalCollabEnabled: false, - collaborationModesEnabled: true, - steerEnabled: true, - unifiedExecEnabled: true, - experimentalAppsEnabled: false, - personality: "friendly", - dictationEnabled: false, - dictationModelId: "base", - dictationPreferredLanguage: null, - dictationHoldKey: "alt", - composerEditorPreset: "default", - composerFenceExpandOnSpace: false, - composerFenceExpandOnEnter: false, - composerFenceLanguageTags: false, - composerFenceWrapSelection: false, - composerFenceAutoWrapPasteMultiline: false, - composerFenceAutoWrapPasteCodeLike: false, - composerListContinuation: false, - composerCodeBlockCopyUseModifier: false, - workspaceGroups: [], - openAppTargets: DEFAULT_OPEN_APP_TARGETS, - selectedOpenAppId: DEFAULT_OPEN_APP_ID, -}; +function buildDefaultSettings(): AppSettings { + const isMac = isMacPlatform(); + return { + codexBin: null, + codexArgs: null, + backendMode: "local", + remoteBackendHost: "127.0.0.1:4732", + remoteBackendToken: null, + defaultAccessMode: "current", + reviewDeliveryMode: "inline", + composerModelShortcut: isMac ? "cmd+shift+m" : "ctrl+shift+m", + composerAccessShortcut: isMac ? "cmd+shift+a" : "ctrl+shift+a", + composerReasoningShortcut: isMac ? "cmd+shift+r" : "ctrl+shift+r", + composerCollaborationShortcut: "shift+tab", + interruptShortcut: getDefaultInterruptShortcut(), + newAgentShortcut: isMac ? "cmd+n" : "ctrl+n", + newWorktreeAgentShortcut: isMac ? "cmd+shift+n" : "ctrl+shift+n", + newCloneAgentShortcut: isMac ? "cmd+alt+n" : "ctrl+alt+n", + archiveThreadShortcut: isMac ? "cmd+ctrl+a" : "ctrl+alt+a", + toggleProjectsSidebarShortcut: isMac ? "cmd+shift+p" : "ctrl+shift+p", + toggleGitSidebarShortcut: isMac ? "cmd+shift+g" : "ctrl+shift+g", + branchSwitcherShortcut: isMac ? "cmd+b" : "ctrl+b", + toggleDebugPanelShortcut: isMac ? "cmd+shift+d" : "ctrl+shift+d", + toggleTerminalShortcut: isMac ? "cmd+shift+t" : "ctrl+shift+t", + cycleAgentNextShortcut: isMac ? "cmd+ctrl+down" : "ctrl+alt+down", + cycleAgentPrevShortcut: isMac ? "cmd+ctrl+up" : "ctrl+alt+up", + cycleWorkspaceNextShortcut: isMac ? "cmd+shift+down" : "ctrl+alt+shift+down", + cycleWorkspacePrevShortcut: isMac ? "cmd+shift+up" : "ctrl+alt+shift+up", + lastComposerModelId: null, + lastComposerReasoningEffort: null, + uiScale: UI_SCALE_DEFAULT, + theme: "system", + usageShowRemaining: false, + uiFontFamily: DEFAULT_UI_FONT_FAMILY, + codeFontFamily: DEFAULT_CODE_FONT_FAMILY, + codeFontSize: CODE_FONT_SIZE_DEFAULT, + notificationSoundsEnabled: true, + systemNotificationsEnabled: true, + preloadGitDiffs: true, + gitDiffIgnoreWhitespaceChanges: false, + experimentalCollabEnabled: false, + collaborationModesEnabled: true, + steerEnabled: true, + unifiedExecEnabled: true, + experimentalAppsEnabled: false, + personality: "friendly", + dictationEnabled: false, + dictationModelId: "base", + dictationPreferredLanguage: null, + dictationHoldKey: "alt", + composerEditorPreset: "default", + composerFenceExpandOnSpace: false, + composerFenceExpandOnEnter: false, + composerFenceLanguageTags: false, + composerFenceWrapSelection: false, + composerFenceAutoWrapPasteMultiline: false, + composerFenceAutoWrapPasteCodeLike: false, + composerListContinuation: false, + composerCodeBlockCopyUseModifier: false, + workspaceGroups: [], + openAppTargets: DEFAULT_OPEN_APP_TARGETS, + selectedOpenAppId: DEFAULT_OPEN_APP_ID, + }; +} function normalizeAppSettings(settings: AppSettings): AppSettings { const normalizedTargets = @@ -129,6 +132,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { } export function useAppSettings() { + const defaultSettings = useMemo(() => buildDefaultSettings(), []); const [settings, setSettings] = useState(defaultSettings); const [isLoading, setIsLoading] = useState(true); @@ -156,7 +160,7 @@ export function useAppSettings() { return () => { active = false; }; - }, []); + }, [defaultSettings]); const saveSettings = useCallback(async (next: AppSettings) => { const normalized = normalizeAppSettings(next); @@ -168,7 +172,7 @@ export function useAppSettings() { }), ); return saved; - }, []); + }, [defaultSettings]); const doctor = useCallback( async (codexBin: string | null, codexArgs: string | null) => { diff --git a/src/styles/approval-toasts.css b/src/styles/approval-toasts.css index ff71a5a13..a488dfabf 100644 --- a/src/styles/approval-toasts.css +++ b/src/styles/approval-toasts.css @@ -77,7 +77,7 @@ } .approval-toast-detail-code { - font-family: "SF Mono", Menlo, monospace; + font-family: var(--code-font-family); font-size: 11px; color: var(--text-muted); white-space: pre-wrap; diff --git a/src/styles/plan.css b/src/styles/plan.css index 9f4770de8..162e96cd1 100644 --- a/src/styles/plan.css +++ b/src/styles/plan.css @@ -60,7 +60,7 @@ } .plan-step-status { - font-family: "SF Mono", Menlo, monospace; + font-family: var(--code-font-family); font-size: 11px; color: var(--text-faint); flex: 0 0 auto; diff --git a/src/styles/settings.css b/src/styles/settings.css index ee5b88d74..1ddb92090 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -259,7 +259,7 @@ } .settings-input--shortcut { - font-family: "SF Mono", "Fira Mono", "Menlo", monospace; + font-family: var(--code-font-family); letter-spacing: 0.02em; } @@ -286,6 +286,11 @@ font-size: 12px; } +.settings-select option { + background-color: var(--surface-popover); + color: var(--text-strong); +} + .settings-select--compact { padding: 6px 8px; font-size: 11px; diff --git a/src/styles/themes.dark.css b/src/styles/themes.dark.css index 73ac753cd..6fb78809b 100644 --- a/src/styles/themes.dark.css +++ b/src/styles/themes.dark.css @@ -1,14 +1,15 @@ :root { - font-family: "SF Pro Text", "SF Pro Display", -apple-system, "Helvetica Neue", - sans-serif; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; color: var(--text-primary); background-color: transparent; font-synthesis: none; text-rendering: optimizeLegibility; color-scheme: light dark; - --ui-font-family: "SF Pro Text", "SF Pro Display", -apple-system, "Helvetica Neue", - sans-serif; - --code-font-family: "SF Mono", "SFMono-Regular", Menlo, Monaco, monospace; + --ui-font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --code-font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", Menlo, + Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --code-font-size: 11px; --code-font-weight: 400; --code-line-height: 1.28; diff --git a/src/styles/update-toasts.css b/src/styles/update-toasts.css index 79768a83b..581fd3b4a 100644 --- a/src/styles/update-toasts.css +++ b/src/styles/update-toasts.css @@ -71,7 +71,7 @@ } .update-toast-error { - font-family: "SF Mono", Menlo, monospace; + font-family: var(--code-font-family); font-size: 11px; color: var(--text-muted); white-space: pre-wrap; diff --git a/src/utils/fonts.ts b/src/utils/fonts.ts index 2a1a45846..f086f62dc 100644 --- a/src/utils/fonts.ts +++ b/src/utils/fonts.ts @@ -1,8 +1,8 @@ export const DEFAULT_UI_FONT_FAMILY = - "\"SF Pro Text\", \"SF Pro Display\", -apple-system, \"Helvetica Neue\", sans-serif"; + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; export const DEFAULT_CODE_FONT_FAMILY = - "\"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, monospace"; + 'ui-monospace, "Cascadia Mono", "Segoe UI Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; export const CODE_FONT_SIZE_DEFAULT = 11; export const CODE_FONT_SIZE_MIN = 9; diff --git a/src/utils/platformPaths.ts b/src/utils/platformPaths.ts new file mode 100644 index 000000000..7d7799dee --- /dev/null +++ b/src/utils/platformPaths.ts @@ -0,0 +1,113 @@ +type PlatformKind = "mac" | "windows" | "linux" | "unknown"; + +function platformKind(): PlatformKind { + if (typeof navigator === "undefined") { + return "unknown"; + } + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? navigator.platform ?? ""; + const normalized = platform.toLowerCase(); + if (normalized.includes("mac")) { + return "mac"; + } + if (normalized.includes("win")) { + return "windows"; + } + if (normalized.includes("linux")) { + return "linux"; + } + return "unknown"; +} + +export function isMacPlatform(): boolean { + return platformKind() === "mac"; +} + +export function isWindowsPlatform(): boolean { + return platformKind() === "windows"; +} + +export function fileManagerName(): string { + const platform = platformKind(); + if (platform === "mac") { + return "Finder"; + } + if (platform === "windows") { + return "Explorer"; + } + return "File Manager"; +} + +export function revealInFileManagerLabel(): string { + const platform = platformKind(); + if (platform === "mac") { + return "Reveal in Finder"; + } + if (platform === "windows") { + return "Show in Explorer"; + } + return "Reveal in File Manager"; +} + +export function openInFileManagerLabel(): string { + return `Open in ${fileManagerName()}`; +} + +function looksLikeWindowsAbsolutePath(value: string): boolean { + if (/^[A-Za-z]:[\\/]/.test(value)) { + return true; + } + if (value.startsWith("\\\\") || value.startsWith("//")) { + return true; + } + if (value.startsWith("\\\\?\\")) { + return true; + } + return false; +} + +export function isAbsolutePath(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith("/") || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { + return true; + } + return looksLikeWindowsAbsolutePath(trimmed); +} + +function stripTrailingSeparators(value: string) { + return value.replace(/[\\/]+$/, ""); +} + +function stripLeadingSeparators(value: string) { + return value.replace(/^[\\/]+/, ""); +} + +function looksLikeWindowsPathPrefix(value: string): boolean { + const trimmed = value.trim(); + return looksLikeWindowsAbsolutePath(trimmed) || trimmed.includes("\\"); +} + +export function joinWorkspacePath(base: string, path: string): string { + const trimmedBase = base.trim(); + const trimmedPath = path.trim(); + if (!trimmedBase) { + return trimmedPath; + } + if (!trimmedPath || isAbsolutePath(trimmedPath)) { + return trimmedPath; + } + + const isWindows = looksLikeWindowsPathPrefix(trimmedBase); + const baseWithoutTrailing = stripTrailingSeparators(trimmedBase); + const pathWithoutLeading = stripLeadingSeparators(trimmedPath); + if (isWindows) { + const normalizedRelative = pathWithoutLeading.replace(/\//g, "\\"); + return `${baseWithoutTrailing}\\${normalizedRelative}`; + } + const normalizedRelative = pathWithoutLeading.replace(/\\/g, "/"); + return `${baseWithoutTrailing}/${normalizedRelative}`; +} diff --git a/src/utils/shortcuts.test.ts b/src/utils/shortcuts.test.ts new file mode 100644 index 000000000..1751b402a --- /dev/null +++ b/src/utils/shortcuts.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { formatShortcut, matchesShortcut, toMenuAccelerator } from "./shortcuts"; + +function withNavigatorPlatform(platform: string, fn: () => void) { + const originalUserAgentData = Object.getOwnPropertyDescriptor(navigator, "userAgentData"); + + Object.defineProperty(navigator, "userAgentData", { + value: { platform }, + configurable: true, + }); + + try { + fn(); + } finally { + if (originalUserAgentData) { + Object.defineProperty(navigator, "userAgentData", originalUserAgentData); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (navigator as any).userAgentData; + } + } +} + +describe("shortcuts", () => { + it("maps cmd+ctrl to Ctrl+Alt on non-mac platforms", () => { + withNavigatorPlatform("Win32", () => { + expect(formatShortcut("cmd+ctrl+a")).toBe("Ctrl+Alt+A"); + expect(toMenuAccelerator("cmd+ctrl+a")).toBe("Ctrl+Alt+A"); + + const ctrlOnly = new KeyboardEvent("keydown", { key: "a", ctrlKey: true }); + expect(matchesShortcut(ctrlOnly, "cmd+ctrl+a")).toBe(false); + + const ctrlAlt = new KeyboardEvent("keydown", { + key: "a", + ctrlKey: true, + altKey: true, + }); + expect(matchesShortcut(ctrlAlt, "cmd+ctrl+a")).toBe(true); + }); + }); + + it("keeps cmd as CmdOrCtrl on non-mac platforms", () => { + withNavigatorPlatform("Win32", () => { + const ctrlEvent = new KeyboardEvent("keydown", { key: "n", ctrlKey: true }); + expect(matchesShortcut(ctrlEvent, "cmd+n")).toBe(true); + }); + }); + + it("requires both cmd and ctrl on macOS", () => { + withNavigatorPlatform("MacIntel", () => { + expect(formatShortcut("cmd+ctrl+a")).toBe("⌘⌃A"); + expect(toMenuAccelerator("cmd+ctrl+a")).toBe("Cmd+Ctrl+A"); + + const cmdCtrl = new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + ctrlKey: true, + }); + expect(matchesShortcut(cmdCtrl, "cmd+ctrl+a")).toBe(true); + + const ctrlOnly = new KeyboardEvent("keydown", { key: "a", ctrlKey: true }); + expect(matchesShortcut(ctrlOnly, "cmd+ctrl+a")).toBe(false); + }); + }); +}); diff --git a/src/utils/shortcuts.ts b/src/utils/shortcuts.ts index 0d6eb1cf6..0972be4f2 100644 --- a/src/utils/shortcuts.ts +++ b/src/utils/shortcuts.ts @@ -1,3 +1,5 @@ +import { isMacPlatform as isMacPlatformFromPaths } from "./platformPaths"; + export type ShortcutDefinition = { key: string; meta: boolean; @@ -6,14 +8,34 @@ export type ShortcutDefinition = { shift: boolean; }; +function normalizeShortcutDefinitionForPlatform( + value: ShortcutDefinition, + isMac: boolean, +): ShortcutDefinition { + if (isMac) { + return value; + } + if (value.meta && value.ctrl) { + return { ...value, meta: false, alt: true }; + } + return value; +} + const MODIFIER_ORDER = ["cmd", "ctrl", "alt", "shift"] as const; -const MODIFIER_LABELS: Record = { +const MODIFIER_LABELS_MAC: Record = { cmd: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧", }; +const MODIFIER_LABELS_OTHER: Record = { + cmd: "Ctrl", + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", +}; + const KEY_LABELS: Record = { " ": "Space", space: "Space", @@ -85,25 +107,33 @@ export function formatShortcut(value: string | null | undefined): string { if (!parsed) { return value; } + const useSymbols = isMacPlatform(); + const normalized = normalizeShortcutDefinitionForPlatform(parsed, useSymbols); + const modifierLabels = useSymbols ? MODIFIER_LABELS_MAC : MODIFIER_LABELS_OTHER; const modifiers = MODIFIER_ORDER.flatMap((modifier) => { - if (modifier === "cmd" && parsed.meta) { - return MODIFIER_LABELS.cmd; + if (modifier === "cmd" && normalized.meta) { + return modifierLabels.cmd; } - if (modifier === "ctrl" && parsed.ctrl) { - return MODIFIER_LABELS.ctrl; + if (modifier === "ctrl" && normalized.ctrl) { + return modifierLabels.ctrl; } - if (modifier === "alt" && parsed.alt) { - return MODIFIER_LABELS.alt; + if (modifier === "alt" && normalized.alt) { + return modifierLabels.alt; } - if (modifier === "shift" && parsed.shift) { - return MODIFIER_LABELS.shift; + if (modifier === "shift" && normalized.shift) { + return modifierLabels.shift; } return []; }); + const uniqueModifiers = useSymbols + ? modifiers + : modifiers.filter((modifier, index) => modifiers.indexOf(modifier) === index); const keyLabel = KEY_LABELS[parsed.key] ?? (parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key); - return [...modifiers, keyLabel].join(""); + return useSymbols + ? [...uniqueModifiers, keyLabel].join("") + : [...uniqueModifiers, keyLabel].join("+"); } export function buildShortcutValue(event: KeyboardEvent): string | null { @@ -137,23 +167,35 @@ export function matchesShortcut(event: KeyboardEvent, value: string | null | und if (!parsed) { return false; } + const isMac = isMacPlatform(); + const normalized = normalizeShortcutDefinitionForPlatform(parsed, isMac); const key = normalizeKey(event.key); - if (!key || key !== parsed.key) { + if (!key || key !== normalized.key) { + return false; + } + const metaMatches = normalized.meta + ? isMac + ? event.metaKey + : event.ctrlKey || event.metaKey + : !event.metaKey; + if (!metaMatches) { return false; } + + const ctrlMatches = normalized.ctrl + ? event.ctrlKey + : normalized.meta && !isMac + ? true + : !event.ctrlKey; return ( - parsed.meta === event.metaKey && - parsed.ctrl === event.ctrlKey && - parsed.alt === event.altKey && - parsed.shift === event.shiftKey + ctrlMatches && + normalized.alt === event.altKey && + normalized.shift === event.shiftKey ); } export function isMacPlatform(): boolean { - if (typeof navigator === "undefined") { - return false; - } - return /Mac|iPhone|iPad|iPod/.test(navigator.platform); + return isMacPlatformFromPaths(); } export function getDefaultInterruptShortcut(): string { @@ -165,24 +207,26 @@ export function toMenuAccelerator(value: string | null | undefined): string | nu if (!parsed) { return null; } + const isMac = isMacPlatform(); + const normalized = normalizeShortcutDefinitionForPlatform(parsed, isMac); const parts: string[] = []; - if (parsed.meta && parsed.ctrl) { + if (normalized.meta && normalized.ctrl) { parts.push("Cmd"); parts.push("Ctrl"); - } else if (parsed.meta) { + } else if (normalized.meta) { parts.push("CmdOrCtrl"); - } else if (parsed.ctrl) { + } else if (normalized.ctrl) { parts.push("Ctrl"); } - if (parsed.alt) { + if (normalized.alt) { parts.push("Alt"); } - if (parsed.shift) { + if (normalized.shift) { parts.push("Shift"); } const key = - ACCELERATOR_KEYS[parsed.key] ?? - (parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key); + ACCELERATOR_KEYS[normalized.key] ?? + (normalized.key.length === 1 ? normalized.key.toUpperCase() : normalized.key); if (!key) { return null; }