diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a90d2cc4..e13c732f 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 6025a9f9..d082bff4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -219,12 +219,74 @@ jobs: name: appimage-${{ matrix.arch }} path: src-tauri/target/release/bundle/appimage/*.AppImage* + 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 @@ -244,6 +306,12 @@ jobs: path: release-artifacts merge-multiple: true + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-artifacts + path: release-artifacts + - name: Build latest.json run: | set -euo pipefail @@ -345,6 +413,27 @@ jobs: "signature": sig_path.read_text().strip(), } + exe_candidates = sorted(artifacts_dir.rglob("*.exe"), key=lambda p: p.name.lower()) + windows_installer = None + 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") + + 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, @@ -378,6 +467,8 @@ jobs: release-artifacts/CodexMonitor.app.tar.gz \ release-artifacts/CodexMonitor.app.tar.gz.sig \ release-artifacts/*.AppImage* \ + release-artifacts/*.exe* \ + release-artifacts/*.msi* \ release-artifacts/latest.json - name: Bump version and open PR diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..de866e56 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,57 @@ +# Windows Support Execution Plan + +Source of truth for requirements: `SPEC.md`. +This plan is **live** and tracks what has landed and what remains for the Windows milestone (auto-updater + dictation). + +## Workstream + +### 1) Docs + +- [x] Add `SPEC.md` (Windows scope, updater + dictation required). +- [x] Keep `PLAN.md` current as work lands. + +### 2) Git safety + PR + +- [x] Push `feature/windows-support` to a fork remote and set upstream to avoid accidental `main` pushes. +- [x] Open a draft PR early so CI runs on every push. + +### 3) Windows UX + path correctness + +- [x] Make “Reveal in Finder” platform-aware (Explorer on Windows). +- [x] Fix path joining in the frontend so Windows absolute/relative paths behave. +- [x] Make backend `open_workspace_in` work cross-platform (macOS/Windows/Linux). +- [x] Make default “Open in” targets sensible on Windows (Explorer + command-based editors). + +### 4) Dictation on Windows (required) + +- [x] Enable Whisper dictation on Windows (`whisper-rs` + `cpal`) by removing the Windows stub. +- [x] Update Windows build checks (`doctor:win`) to require LLVM/Clang + CMake. +- [x] Fix `doctor:win` dependency detection on Unix (no shell builtins). + +### 5) CI (required) + +- [x] Add a Windows CI job that runs a Tauri debug build with `src-tauri/tauri.windows.conf.json`. + +### 6) Release + updater (required) + +- [x] Enable Windows updater artifacts in `src-tauri/tauri.windows.conf.json`. +- [x] Add a Windows release build job to `.github/workflows/release.yml`. +- [x] Extend `latest.json` generation to include Windows URL + signature. + +## Validation (run after each step) + +- `npm run lint` +- `npm run test` +- `npm run typecheck` +- Rust checks are executed in CI for macOS + Windows jobs added by this plan. + +## Manual checklist (Windows) + +- [ ] `npm run tauri:build:win` succeeds on Windows 10/11. +- [ ] App launches and can open workspaces. +- [ ] Adding a workspace succeeds when `codex --version` works in Windows Terminal. +- [ ] “Reveal in Explorer” opens the right folder. +- [ ] Shortcut hints use Ctrl/Alt labels and work on Windows. +- [ ] Theme dropdown options are readable in Dark/Dim with Reduce Transparency off. +- [ ] Auto-updater finds and applies the latest release. +- [ ] Dictation works end-to-end (download → hold-to-talk → transcript). diff --git a/README.md b/README.md index 7773e668..584b21c1 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 @@ -46,7 +46,8 @@ CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across - 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) @@ -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/SPEC.md b/SPEC.md new file mode 100644 index 00000000..66fdfed1 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,92 @@ +# Windows Support Spec + +## Goal + +Ship a **fully functioning Windows version** of CodexMonitor with: + +- **Auto-updater** enabled and wired into the release workflow (`latest.json` includes Windows). +- **Dictation** working on Windows (same UX + model management as other platforms). + +This spec is the source of truth for Windows support requirements and acceptance criteria. + +## Target Platforms + +- Windows 10/11, **x86_64** (GitHub Actions `windows-latest`). + +## Non-Goals (for this milestone) + +- Authenticode / EV code signing (nice-to-have; not required to be functional). +- Windows ARM64 builds. + +## Functional Requirements + +### Updater (Required) + +- Windows release artifacts must be produced by CI and attached to GitHub Releases. +- `latest.json` must include a **Windows platform entry** with: + - correct asset URL + - signature generated by Tauri updater signing +- In-app updater must be enabled for Windows builds (same endpoints + pubkey as other platforms). +- The updater entry for Windows uses the **NSIS installer** (`.exe` + `.exe.sig`) as the updater bundle. + +### Dictation (Required) + +Dictation must work on Windows with the same surface area as other platforms: + +- Model status: missing/downloading/ready/error +- Model download/cancel/remove +- Session lifecycle: start/listening/stop->processing/transcript/cancel +- Emits the same Tauri events: + - `dictation-download` + - `dictation-event` + +Implementation choice for this milestone: + +- Use the existing Whisper-based implementation on Windows (via `whisper-rs` + `cpal`). + +### Windows UX Correctness (Required) + +- “Reveal in Finder” strings must be platform-aware (“Explorer” on Windows). +- Opening paths in an editor or file manager must behave correctly on Windows. +- Shortcut hints and formatting must be platform-aware on Windows (Ctrl/Alt labels; no macOS-only glyphs). +- Settings selects (for example Theme) must remain readable in Dark/Dim with Reduce Transparency off. + +## Build & Tooling Requirements + +### Windows Build Prereqs (dev + CI) + +Required on Windows to build dictation (Whisper + bindgen): + +- CMake +- LLVM/Clang (for `bindgen` / `libclang`) + +`npm run doctor:win` must fail fast with actionable instructions if missing. +`npm run doctor:win` must correctly detect installed dependencies on Windows/macOS/Linux. + +## CI / Release Requirements + +### CI (Required) + +- Add a Windows job to `.github/workflows/ci.yml` that: + - installs deps + - runs a Windows Tauri debug build (`--no-bundle`) using the Windows config + +### Release (Required) + +Update `.github/workflows/release.yml` to include Windows: + +- Build Windows bundles on `windows-latest` +- Upload `.msi` / `.exe` plus updater `.sig` artifacts +- Include Windows in generated `latest.json` + +## Acceptance Criteria + +- `npm run tauri:build:win` succeeds on Windows. +- Windows release workflow publishes installers and `latest.json` that enables in-app updates. +- Dictation works on Windows end-to-end (model download → hold-to-talk → transcript). +- Adding a workspace works when `codex --version` runs in Windows Terminal (Codex PATH handling is correct). +- Repo checks pass: + - `npm run lint` + - `npm run test` + - `npm run typecheck` + - Rust checks executed in CI for Windows/macOS as configured diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index b247cb4a..2480275e 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -1,16 +1,47 @@ -import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; const strict = process.argv.includes("--strict"); +function canExecute(filePath) { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + if (process.platform === "win32") return true; + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + function hasCommand(command) { - const checker = process.platform === "win32" ? "where" : "command"; - const checkerArgs = process.platform === "win32" ? [command] : ["-v", command]; - const result = spawnSync(checker, checkerArgs, { stdio: "ignore" }); - return result.status === 0; + const pathValue = process.env.PATH; + if (!pathValue) return false; + + const dirs = pathValue.split(path.delimiter).filter(Boolean); + + if (process.platform !== "win32") { + return dirs.some((dir) => canExecute(path.join(dir, command))); + } + + const pathExtValue = process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM"; + const exts = pathExtValue.split(";").filter(Boolean); + const hasExtension = path.extname(command) !== ""; + + for (const dir of dirs) { + if (hasExtension && canExecute(path.join(dir, command))) return true; + for (const ext of exts) { + if (canExecute(path.join(dir, `${command}${ext}`))) return true; + } + } + + return false; } const missing = []; if (!hasCommand("cmake")) missing.push("cmake"); +if (process.platform === "win32" && !hasCommand("clang")) missing.push("llvm"); if (missing.length === 0) { console.log("Doctor: OK"); @@ -29,8 +60,9 @@ switch (process.platform) { console.log("Arch: sudo pacman -S cmake"); break; case "win32": - console.log("Install: choco install cmake"); + console.log("Install: choco install cmake llvm"); console.log("Or download from: https://cmake.org/download/"); + console.log("If bindgen fails, set LIBCLANG_PATH to your LLVM bin directory."); break; default: console.log("Install CMake from: https://cmake.org/download/"); @@ -38,4 +70,3 @@ switch (process.platform) { } process.exit(strict ? 1 : 0); - diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 809ce302..9762b153 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,8 +46,6 @@ toml = "0.8" [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" tauri-plugin-window-state = "2" - -[target."cfg(not(target_os = \"windows\"))".dependencies] cpal = "0.15" whisper-rs = "0.12" sha2 = "0.10" diff --git a/src-tauri/src/backend/app_server.rs b/src-tauri/src/backend/app_server.rs index 96801a28..687b9f05 100644 --- a/src-tauri/src/backend/app_server.rs +++ b/src-tauri/src/backend/app_server.rs @@ -84,54 +84,68 @@ impl WorkspaceSession { } pub(crate) fn build_codex_path_env(codex_bin: Option<&str>) -> Option { - let mut paths: Vec = env::var("PATH") - .unwrap_or_default() - .split(':') - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) - .collect(); - let mut extras = vec![ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] - .into_iter() - .map(|value| value.to_string()) - .collect::>(); - if let Ok(home) = env::var("HOME") { - extras.push(format!("{home}/.local/bin")); - extras.push(format!("{home}/.local/share/mise/shims")); - extras.push(format!("{home}/.cargo/bin")); - extras.push(format!("{home}/.bun/bin")); - let nvm_root = Path::new(&home).join(".nvm/versions/node"); - if let Ok(entries) = std::fs::read_dir(nvm_root) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.is_dir() { - extras.push(bin_path.to_string_lossy().to_string()); + let mut paths: Vec = env::var_os("PATH") + .map(|value| env::split_paths(&value).collect()) + .unwrap_or_default(); + + let mut extras: Vec = Vec::new(); + + #[cfg(not(target_os = "windows"))] + { + extras.extend([ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ].into_iter().map(PathBuf::from)); + + if let Ok(home) = env::var("HOME") { + let home_path = Path::new(&home); + extras.push(home_path.join(".local/bin")); + extras.push(home_path.join(".local/share/mise/shims")); + extras.push(home_path.join(".cargo/bin")); + extras.push(home_path.join(".bun/bin")); + let nvm_root = home_path.join(".nvm/versions/node"); + if let Ok(entries) = std::fs::read_dir(nvm_root) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.is_dir() { + extras.push(bin_path); + } } } } } + + #[cfg(target_os = "windows")] + { + if let Ok(home) = env::var("USERPROFILE").or_else(|_| env::var("HOME")) { + let home_path = Path::new(&home); + extras.push(home_path.join(".cargo").join("bin")); + } + } + if let Some(bin_path) = codex_bin.filter(|value| !value.trim().is_empty()) { - let parent = Path::new(bin_path).parent(); - if let Some(parent) = parent { - extras.push(parent.to_string_lossy().to_string()); + if let Some(parent) = Path::new(bin_path).parent() { + extras.push(parent.to_path_buf()); } } + for extra in extras { - if !paths.contains(&extra) { + if !paths.iter().any(|path| path == &extra) { paths.push(extra); } } + if paths.is_empty() { - None - } else { - Some(paths.join(":")) + return None; } + + env::join_paths(paths) + .ok() + .map(|joined| joined.to_string_lossy().to_string()) } pub(crate) fn build_codex_command_with_bin(codex_bin: Option) -> Command { diff --git a/src-tauri/src/dictation/mod.rs b/src-tauri/src/dictation/mod.rs index 8fe265c3..fe9b8f97 100644 --- a/src-tauri/src/dictation/mod.rs +++ b/src-tauri/src/dictation/mod.rs @@ -1,9 +1,4 @@ -#[cfg(not(target_os = "windows"))] #[path = "real.rs"] mod imp; -#[cfg(target_os = "windows")] -#[path = "stub.rs"] -mod imp; - pub(crate) use imp::*; diff --git a/src-tauri/src/dictation/stub.rs b/src-tauri/src/dictation/stub.rs deleted file mode 100644 index 5cb8a14e..00000000 --- a/src-tauri/src/dictation/stub.rs +++ /dev/null @@ -1,195 +0,0 @@ -use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; - -use crate::state::AppState; - -const DEFAULT_MODEL_ID: &str = "base"; -const UNSUPPORTED_MESSAGE: &str = "Dictation is not supported on Windows builds."; - -#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum DictationModelState { - Missing, - Downloading, - Ready, - Error, -} - -#[derive(Debug, Serialize, Clone)] -pub(crate) struct DictationDownloadProgress { - #[serde(rename = "downloadedBytes")] - pub(crate) downloaded_bytes: u64, - #[serde(rename = "totalBytes")] - pub(crate) total_bytes: Option, -} - -#[derive(Debug, Serialize, Clone)] -pub(crate) struct DictationModelStatus { - pub(crate) state: DictationModelState, - #[serde(rename = "modelId")] - pub(crate) model_id: String, - pub(crate) progress: Option, - pub(crate) error: Option, - pub(crate) path: Option, -} - -#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum DictationSessionState { - Idle, - Listening, - Processing, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(crate) enum DictationEvent { - State { state: DictationSessionState }, - Level { value: f32 }, - Transcript { text: String }, - Error { message: String }, - Canceled { message: String }, -} - -pub(crate) struct DictationState { - pub(crate) model_status: DictationModelStatus, - pub(crate) session_state: DictationSessionState, -} - -impl Default for DictationState { - fn default() -> Self { - Self { - model_status: DictationModelStatus { - state: DictationModelState::Error, - model_id: DEFAULT_MODEL_ID.to_string(), - progress: None, - error: Some(UNSUPPORTED_MESSAGE.to_string()), - path: None, - }, - session_state: DictationSessionState::Idle, - } - } -} - -fn emit_status(app: &AppHandle, status: &DictationModelStatus) { - let _ = app.emit("dictation-download", status); -} - -fn emit_event(app: &AppHandle, event: DictationEvent) { - let _ = app.emit("dictation-event", event); -} - -fn windows_unsupported_status(model_id: Option) -> DictationModelStatus { - DictationModelStatus { - state: DictationModelState::Error, - model_id: model_id.unwrap_or_else(|| DEFAULT_MODEL_ID.to_string()), - progress: None, - error: Some(UNSUPPORTED_MESSAGE.to_string()), - path: None, - } -} - -#[tauri::command] -pub(crate) async fn dictation_model_status( - app: AppHandle, - state: State<'_, AppState>, - model_id: Option, -) -> Result { - let status = windows_unsupported_status(model_id); - { - let mut dictation = state.dictation.lock().await; - dictation.model_status = status.clone(); - dictation.session_state = DictationSessionState::Idle; - } - emit_status(&app, &status); - Ok(status) -} - -#[tauri::command] -pub(crate) async fn dictation_download_model( - app: AppHandle, - state: State<'_, AppState>, - model_id: Option, -) -> Result { - let status = dictation_model_status(app.clone(), state, model_id).await?; - emit_event( - &app, - DictationEvent::Error { - message: status - .error - .clone() - .unwrap_or_else(|| "Dictation is unavailable on Windows.".to_string()), - }, - ); - Ok(status) -} - -#[tauri::command] -pub(crate) async fn dictation_cancel_download( - app: AppHandle, - state: State<'_, AppState>, - model_id: Option, -) -> Result { - dictation_model_status(app, state, model_id).await -} - -#[tauri::command] -pub(crate) async fn dictation_remove_model( - app: AppHandle, - state: State<'_, AppState>, - model_id: Option, -) -> Result { - dictation_model_status(app, state, model_id).await -} - -#[tauri::command] -pub(crate) async fn dictation_start( - _preferred_language: Option, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - { - let mut dictation = state.dictation.lock().await; - dictation.session_state = DictationSessionState::Idle; - } - let message = UNSUPPORTED_MESSAGE.to_string(); - emit_event(&app, DictationEvent::Error { message: message.clone() }); - Err(message) -} - -#[tauri::command] -pub(crate) async fn dictation_request_permission(_app: AppHandle) -> Result { - Err(UNSUPPORTED_MESSAGE.to_string()) -} - -#[tauri::command] -pub(crate) async fn dictation_stop( - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - { - let mut dictation = state.dictation.lock().await; - dictation.session_state = DictationSessionState::Idle; - } - let message = UNSUPPORTED_MESSAGE.to_string(); - emit_event(&app, DictationEvent::Error { message: message.clone() }); - Err(message) -} - -#[tauri::command] -pub(crate) async fn dictation_cancel( - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - { - let mut dictation = state.dictation.lock().await; - dictation.session_state = DictationSessionState::Idle; - } - emit_event( - &app, - DictationEvent::Canceled { - message: "Canceled".to_string(), - }, - ); - Ok(DictationSessionState::Idle) -} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 3fee17d0..fb42c00c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -711,50 +711,109 @@ fn default_workspace_groups() -> Vec { } fn default_open_app_targets() -> Vec { + if cfg!(target_os = "macos") { + return vec![ + OpenAppTarget { + id: "vscode".to_string(), + label: "VS Code".to_string(), + kind: "app".to_string(), + app_name: Some("Visual Studio Code".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "cursor".to_string(), + label: "Cursor".to_string(), + kind: "app".to_string(), + app_name: Some("Cursor".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "zed".to_string(), + label: "Zed".to_string(), + kind: "app".to_string(), + app_name: Some("Zed".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "ghostty".to_string(), + label: "Ghostty".to_string(), + kind: "app".to_string(), + app_name: Some("Ghostty".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "antigravity".to_string(), + label: "Antigravity".to_string(), + kind: "app".to_string(), + app_name: Some("Antigravity".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "finder".to_string(), + label: "Finder".to_string(), + kind: "finder".to_string(), + app_name: None, + command: None, + args: Vec::new(), + }, + ]; + } + + let file_manager_label = if cfg!(windows) { + "Explorer" + } else { + "File Manager" + }; + vec![ OpenAppTarget { id: "vscode".to_string(), label: "VS Code".to_string(), - kind: "app".to_string(), - app_name: Some("Visual Studio Code".to_string()), - command: None, + kind: "command".to_string(), + app_name: None, + command: Some("code".to_string()), args: Vec::new(), }, OpenAppTarget { id: "cursor".to_string(), label: "Cursor".to_string(), - kind: "app".to_string(), - app_name: Some("Cursor".to_string()), - command: None, + kind: "command".to_string(), + app_name: None, + command: Some("cursor".to_string()), args: Vec::new(), }, OpenAppTarget { id: "zed".to_string(), label: "Zed".to_string(), - kind: "app".to_string(), - app_name: Some("Zed".to_string()), - command: None, + kind: "command".to_string(), + app_name: None, + command: Some("zed".to_string()), args: Vec::new(), }, OpenAppTarget { id: "ghostty".to_string(), label: "Ghostty".to_string(), - kind: "app".to_string(), - app_name: Some("Ghostty".to_string()), - command: None, + kind: "command".to_string(), + app_name: None, + command: Some("ghostty".to_string()), args: Vec::new(), }, OpenAppTarget { id: "antigravity".to_string(), label: "Antigravity".to_string(), - kind: "app".to_string(), - app_name: Some("Antigravity".to_string()), - command: None, + kind: "command".to_string(), + app_name: None, + command: Some("antigravity".to_string()), args: Vec::new(), }, OpenAppTarget { id: "finder".to_string(), - label: "Finder".to_string(), + label: file_manager_label.to_string(), kind: "finder".to_string(), app_name: None, command: None, @@ -764,7 +823,11 @@ fn default_open_app_targets() -> Vec { } fn default_selected_open_app_id() -> String { - "vscode".to_string() + if cfg!(windows) { + "finder".to_string() + } else { + "vscode".to_string() + } } impl Default for AppSettings { diff --git a/src-tauri/src/workspaces/commands.rs b/src-tauri/src/workspaces/commands.rs index f7e7f217..2e99d6f5 100644 --- a/src-tauri/src/workspaces/commands.rs +++ b/src-tauri/src/workspaces/commands.rs @@ -821,11 +821,28 @@ pub(crate) async fn open_workspace_in( cmd.status() .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? } else if let Some(app) = app { - let mut cmd = std::process::Command::new("open"); - cmd.arg("-a").arg(app).arg(path); - if !args.is_empty() { - cmd.arg("--args").args(args); + let trimmed = app.trim(); + if trimmed.is_empty() { + return Err("Missing app or command".to_string()); } + + #[cfg(target_os = "macos")] + let mut cmd = { + let mut cmd = std::process::Command::new("open"); + cmd.arg("-a").arg(trimmed).arg(&path); + if !args.is_empty() { + cmd.arg("--args").args(&args); + } + cmd + }; + + #[cfg(not(target_os = "macos"))] + let mut cmd = { + let mut cmd = std::process::Command::new(trimmed); + cmd.args(&args).arg(&path); + cmd + }; + cmd.status() .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? } else { diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 12d4499d..6884d2fa 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -17,6 +17,6 @@ ] }, "bundle": { - "createUpdaterArtifacts": false + "createUpdaterArtifacts": true } } diff --git a/src/features/app/components/MainHeader.tsx b/src/features/app/components/MainHeader.tsx index 867f4086..19bb0eb2 100644 --- a/src/features/app/components/MainHeader.tsx +++ b/src/features/app/components/MainHeader.tsx @@ -5,6 +5,7 @@ import Terminal from "lucide-react/dist/esm/icons/terminal"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import type { BranchInfo, OpenAppTarget, WorkspaceInfo } from "../../../types"; import type { ReactNode } from "react"; +import { revealInFileManagerLabel } from "../../../utils/platformPaths"; import { OpenAppMenu } from "./OpenAppMenu"; import { LaunchScriptButton } from "./LaunchScriptButton"; import { LaunchScriptEntryButton } from "./LaunchScriptEntryButton"; @@ -372,7 +373,7 @@ export function MainHeader({ }} data-tauri-drag-region="false" > - Reveal in Finder + {revealInFileManagerLabel()} diff --git a/src/features/app/components/OpenAppMenu.tsx b/src/features/app/components/OpenAppMenu.tsx index 06d54f02..51fed084 100644 --- a/src/features/app/components/OpenAppMenu.tsx +++ b/src/features/app/components/OpenAppMenu.tsx @@ -62,9 +62,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 1f06f4ad..dacc6d2a 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/files/components/FileTreePanel.tsx b/src/features/files/components/FileTreePanel.tsx index 98c8fab9..c8a259f8 100644 --- a/src/features/files/components/FileTreePanel.tsx +++ b/src/features/files/components/FileTreePanel.tsx @@ -30,6 +30,7 @@ import { PanelTabs, type PanelTabId } from "../../layout/components/PanelTabs"; import { readWorkspaceFile } from "../../../services/tauri"; import type { OpenAppTarget } from "../../../types"; import { languageFromPath } from "../../../utils/syntax"; +import { joinWorkspacePath, revealInFileManagerLabel } from "../../../utils/platformPaths"; import { FilePreviewPopover } from "./FilePreviewPopover"; type FileTreeNode = { @@ -370,10 +371,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], ); @@ -585,7 +583,7 @@ export function FileTreePanel({ }, }), await MenuItem.new({ - text: "Reveal in Finder", + text: revealInFileManagerLabel(), action: async () => { await revealItemInDir(resolvePath(relativePath)); }, diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index d9664021..a3c56b75 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/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 94d3e31e..879d5259 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -8,6 +8,11 @@ import * as Sentry from "@sentry/react"; 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; @@ -44,11 +49,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) { @@ -56,20 +60,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[], @@ -164,7 +154,7 @@ export function useFileLinkOpener( const canOpen = canOpenTarget(target); const openLabel = target.kind === "finder" - ? revealLabel() + ? revealInFileManagerLabel() : target.kind === "command" ? command ? `Open in ${target.label}` @@ -184,7 +174,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.tsx b/src/features/settings/components/SettingsView.tsx index 88386bbf..56194262 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -25,6 +25,12 @@ import type { WorkspaceInfo, } from "../../../types"; import { formatDownloadSize } from "../../../utils/formatting"; +import { + fileManagerName, + isMacPlatform, + isWindowsPlatform, + openInFileManagerLabel, +} from "../../../utils/platformPaths"; import { buildShortcutValue, formatShortcut, @@ -425,6 +431,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( @@ -1730,7 +1742,7 @@ export function SettingsView({
Copy blocks without fences
- When enabled, Copy is plain text. Hold Option to include ``` fences. + When enabled, Copy is plain text. Hold {optionKeyLabel} to include ``` fences.
{openConfigError && ( diff --git a/src/styles/settings.css b/src/styles/settings.css index 14e6fe55..fa64ea42 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -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/utils/platformPaths.ts b/src/utils/platformPaths.ts new file mode 100644 index 00000000..0ecf9d9e --- /dev/null +++ b/src/utils/platformPaths.ts @@ -0,0 +1,110 @@ +type PlatformKind = "mac" | "windows" | "linux" | "unknown"; + +function platformKind(): PlatformKind { + 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.ts b/src/utils/shortcuts.ts index 0d6eb1cf..8bbaac93 100644 --- a/src/utils/shortcuts.ts +++ b/src/utils/shortcuts.ts @@ -7,13 +7,20 @@ export type ShortcutDefinition = { }; 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 +92,32 @@ export function formatShortcut(value: string | null | undefined): string { if (!parsed) { return value; } + const useSymbols = isMacPlatform(); + const modifierLabels = useSymbols ? MODIFIER_LABELS_MAC : MODIFIER_LABELS_OTHER; const modifiers = MODIFIER_ORDER.flatMap((modifier) => { if (modifier === "cmd" && parsed.meta) { - return MODIFIER_LABELS.cmd; + return modifierLabels.cmd; } if (modifier === "ctrl" && parsed.ctrl) { - return MODIFIER_LABELS.ctrl; + return modifierLabels.ctrl; } if (modifier === "alt" && parsed.alt) { - return MODIFIER_LABELS.alt; + return modifierLabels.alt; } if (modifier === "shift" && parsed.shift) { - return MODIFIER_LABELS.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 { @@ -141,12 +155,22 @@ export function matchesShortcut(event: KeyboardEvent, value: string | null | und if (!key || key !== parsed.key) { return false; } - return ( - parsed.meta === event.metaKey && - parsed.ctrl === event.ctrlKey && - parsed.alt === event.altKey && - parsed.shift === event.shiftKey - ); + const isMac = isMacPlatform(); + const metaMatches = parsed.meta + ? isMac + ? event.metaKey + : event.ctrlKey || event.metaKey + : !event.metaKey; + if (!metaMatches) { + return false; + } + + const ctrlMatches = parsed.ctrl + ? event.ctrlKey + : parsed.meta && !isMac + ? true + : !event.ctrlKey; + return ctrlMatches && parsed.alt === event.altKey && parsed.shift === event.shiftKey; } export function isMacPlatform(): boolean {