From 2ebc139d77a3c2ba75db956ec71f0fd3c149de4e Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Thu, 11 Jun 2026 23:13:39 +0200 Subject: [PATCH 1/2] fix: stop file dialogs failing silently on portal-less Linux setups (#95) The Linux dialog backend is portal-only (tauri-plugin-dialog with the xdg-portal feature; rfd falls back to spawning zenity when the portal call fails). On minimal window-manager setups (i3, dwm) with neither installed, every picker resolved None exactly like a user cancel, so Open Project and the new-project Browse button did nothing, with zero diagnostics: no Rust logger was installed, so rfd's log::error! output vanished too. - dialog_preflight.rs: probe the portal FileChooser version property over zbus plus PATH for zenity before every dialog command; return a marker error (NO_FILE_PICKER_BACKEND) with install instructions when neither backend exists - src/lib/file-dialog.ts: route all UI picker call sites through never-throw wrappers that turn the marker into an actionable toast and resolve like a cancel (settings export/import sites already surface errors inline) - tauri-plugin-log: warn+ to stderr and a rotating app-log-dir file so dependency failures are visible from a terminal - new CLI: codemux logs [--tail n] and codemux doctor, both fully local so they work when the app itself is misbehaving - dev mock: sessionStorage flag simulates the failure for visual toast verification under npm run dev - tests: env-isolated preflight integration test, file-dialog vitest suite, app_logs tail unit test; verified end-to-end in a clean Arch container with only the package runtime deps (doctor reports the failure with the install hint, passes after installing zenity) and on a portal-equipped host - package-lock.json: sync version field missed by the 0.9.0 bump The AUR PKGBUILD gains xdg-desktop-portal + xdg-desktop-portal-gtk as hard depends and zenity as optdepends at next release. --- docs/INDEX.md | 3 +- docs/core/STATUS.md | 2 + docs/features/observability.md | 19 ++ docs/features/workspace-creation.md | 8 + docs/reference/CONTROL.md | 18 + package-lock.json | 4 +- src-tauri/Cargo.lock | 314 +++++++++++++++++- src-tauri/Cargo.toml | 15 + src-tauri/src/app_logs.rs | 64 ++++ src-tauri/src/cli.rs | 37 +++ src-tauri/src/commands/mod.rs | 14 + src-tauri/src/dialog_preflight.rs | 118 +++++++ src-tauri/src/doctor.rs | 85 +++++ src-tauri/src/lib.rs | 21 ++ src-tauri/tests/dialog_preflight.rs | 54 +++ src/components/openflow/new-run-dialog.tsx | 4 +- src/components/overlays/clone-dialog.tsx | 4 +- .../overlays/new-project-screen.tsx | 4 +- .../overlays/new-workspace-dialog.tsx | 4 +- src/dev/tauri-mock.ts | 25 ++ src/hooks/use-project-actions.ts | 4 +- src/lib/file-dialog.test.ts | 86 +++++ src/lib/file-dialog.ts | 64 ++++ 23 files changed, 957 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/app_logs.rs create mode 100644 src-tauri/src/dialog_preflight.rs create mode 100644 src-tauri/src/doctor.rs create mode 100644 src-tauri/tests/dialog_preflight.rs create mode 100644 src/lib/file-dialog.test.ts create mode 100644 src/lib/file-dialog.ts diff --git a/docs/INDEX.md b/docs/INDEX.md index f20c1074..f385fb1b 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -63,7 +63,8 @@ If the docs themselves feel stale or scattered, also read `docs/reference/DOCS_R - Automations (scheduled host-side agent runs): `docs/features/automations.md`; roadmap at `docs/plans/automations.md`; Phase 2 (sync + remote-host) detailed plan at `docs/plans/automations-sync.md`; Superset research at `docs/research/superset-automations.md` - Agent hooks: `docs/features/hooks.md` - Execution backends / sandboxing: `docs/features/execution.md` -- Observability (flags, metrics, safety config): `docs/features/observability.md` +- Observability (flags, metrics, safety config + native log file / `codemux logs` / `codemux doctor`): `docs/features/observability.md` +- Linux file-dialog backend preflight (issue #95 — portal/zenity detection, install-hint toast): `docs/features/workspace-creation.md` (§ constraints), `docs/reference/CONTROL.md` (§ local diagnostics) - Port detection (incl. Docker-published container ports for open worktrees): `docs/features/ports.md` - Search: `docs/features/search.md` - Code indexing: `docs/features/code-indexing.md` diff --git a/docs/core/STATUS.md b/docs/core/STATUS.md index 78b321d9..ea2fcafe 100644 --- a/docs/core/STATUS.md +++ b/docs/core/STATUS.md @@ -16,6 +16,8 @@ Landed on `main` after the `v0.8.0` tag (unreleased): the **Tauri/React performa Also landed on `main` after the `v0.8.0` tag (unreleased): **headless-daemon worktree provisioning parity** (issue #78). The daemon's `worktree_create` MCP tool now provisions a new worktree the same way the desktop does: gitignored include files (`.env` & co, `.codemuxinclude` → defaults) are copied from the parent repo synchronously before the tool returns, and the project's `.codemux/config.json` setup commands run on a background thread through a new UI-free `crate::scripts::run_setup_commands` core (shared with the desktop's `run_setup_scripts_with_config`, now a thin Tauri-event wrapper over it) with the full `CODEMUX_ROOT_PATH`/`CODEMUX_WORKSPACE_PATH`/`CODEMUX_BRANCH`/`CODEMUX_PORT` (+ NAME/ID) env and the same deterministic `allocate_workspace_port` port. The tool response gains a `setup` summary (`{port, includes_copied, setup_commands, setup_running}`); setup progress/failures log to the daemon's stderr, and a failing setup script never fails the tool call. Combined with the earlier daemon-side fetch-before-branch in `remote/git.rs` (issue #76 parity), remote-created branches start at the freshly-fetched `origin/` tip with graceful offline/local-only fallback. Verified by 3 new unit tests in `remote/tools/mod.rs`, a full-daemon HTTP integration test (`http_worktree_create_provisions_like_desktop` in `src-tauri/tests/codemux_remote_serve_mcp.rs`, stale-clone scenario), and a checked-in containerized clean-host e2e (`scripts/e2e/daemon-worktree-setup-e2e.sh`, PR #93 — drives the real authed HTTP `tools/call` surface in a fresh Arch container and asserts worktree creation + setup-script run + gitignored-include copy on the container's filesystem). See `docs/features/setup-teardown.md` § "Headless Daemon Parity", `docs/features/remote-hosts.md`. +Also landed after the `v0.8.0` tag (unreleased): the **silent file-dialog fix for minimal Linux setups** (issue #95). The Linux dialog backend is portal-only (`tauri-plugin-dialog` with `xdg-portal`; rfd falls back to spawning `zenity` when the portal call fails), so on a minimal WM (i3/dwm) with neither installed every folder/file picker resolved `None` exactly like a user cancel — "Open Project" did nothing, with zero diagnostics (no Rust logger was installed, so rfd's `log::error!` vanished). The fix is four-layered: (1) `src-tauri/src/dialog_preflight.rs` probes the portal's `FileChooser.version` property over zbus + `which zenity` before every dialog command and returns a marker error (`NO_FILE_PICKER_BACKEND`) when neither backend exists; (2) all UI call sites route through `src/lib/file-dialog.ts`, which turns that marker into an actionable install-hint toast and resolves like a cancel (the settings export/import sites already surfaced errors inline); (3) tauri-plugin-log now writes warn+ to stderr and a rotating `~/.local/share/com.codemux.app/logs/codemux.log`, readable via the new `codemux logs [--tail n]`, and the new `codemux doctor` prints a local environment diagnosis (portal/zenity/session info) with no running app required; (4) the AUR PKGBUILD template gains `xdg-desktop-portal` + `xdg-desktop-portal-gtk` as hard deps and `zenity` as optdepends. Verified by an env-isolated integration test (`src-tauri/tests/dialog_preflight.rs`), `src/lib/file-dialog.test.ts`, a dev-mock toast simulation (`sessionStorage` flag in `src/dev/tauri-mock.ts`), and a clean-Arch-container `codemux doctor` run replicating the reporter's machine. See `docs/features/workspace-creation.md` § constraints, `docs/features/observability.md` § native log file, `docs/reference/CONTROL.md` § local diagnostics. + Shipped in `v0.7.9` is **"Operate a remote workspace in place" (Open on host)** — the no-pull remote-operation capability (issue #64). The Workspaces overview's host-backed sibling row gains an **"Open on host"** action (enabled when the host is configured locally) that creates a local *attach-in-place* workspace: `WorkspaceSnapshot` gains `remote_cwd` (the workspace's real on-host directory) + `attach_only` (operated in place, no local files), `create_remote_attach_workspace` builds a ready single-terminal workspace with `host_id` set and **nothing copied under `~/.codemux/` locally**, and the daemon-backed terminal path (`remote_spawn_cwd`) spawns into `remote_cwd` over the existing SSH-tunneled pty-daemon so commands run on the host with live streaming. Persistence is real: `ssh::tunnel::build_remote_command` now **reuses a still-running daemon** (via a `.pid` liveness probe) or **spawns it detached** (`setsid`/`nohup`, stdio redirected) instead of `exec`-ing it in the SSH foreground, so closing the app leaves the host process running and reopening re-tunnels + `client.list()`-reattaches the live sessions (a strict improvement for the push flow too). The command (`workspace_open_on_host`) resolves the host-backed sync row → local host → `origin_path`, is idempotent, and is excluded from `reconcile_from_snapshot` so it never creates a duplicate cloud row; the overview dedupes the sibling card against the open in-place view and renders an "on host" badge with detach-only close. See `docs/features/remote-in-place.md`. Shipped in `v0.7.9` is a **multi-device robustness + remote-persistence pass** layered on top of repo-unit sync. (1) **SSH tunnel health is now surfaced in the UI**: the `TunnelStatus` the supervisor already computed (`connected`/`pending`/`reconnecting`/`circuit_open`) is bridged to the frontend via a new `tunnel-status-changed` event + `spawn_tunnel_status_forwarder` (self-terminating per supervisor), a zustand `tunnel-status-store` fed by an app-root `useTunnelStatusEvents` hook, and a sidebar pill — amber **"Reconnecting…"** on a sleep/wake or WiFi flap, red **"Connection lost — re-push"** once the circuit breaker trips — so a dropped tunnel no longer looks like a frozen workspace. (2) **Host persistence**: auto-upgrade no longer kills host-side agents — `hosts_upgrade` probes the daemon's `live_terminals` (via `codemux-remote serve status`) and **defers** the systemd-unit restart when sessions are live (`UpgradeOutcome::Skipped`); separately, the local pty-daemon now **idle-reaps** itself after 1h with zero sessions (hard re-check under lock so it can never reap a live session). (3) **Workspaces-sync robustness**: project-first remote pull with a real protected root (new local-only `default_branch` column + `resolve_default_branch`/`ensure_origin_head` + `workspaces_adopt_project` "Pull project" action), serialized adopts via a per-row creation lock, client-side `dedupe_sibling_rows` collapse of cross-device duplicate cards, daemon-side `collapse_main_for_uid`/`normalize_main_workspaces` (one repo root per project), uid-keyed collision-safe host paths (`-`), and a non-destructive `workspaces_reconcile_copy` action for legacy divergent copies. (4) **OpenFlow comm-log fix**: the daemon-backed agent spawn path (default since persistent agents) now tees cleaned PTY output to the communication log via the shared `comm_log_entry_for_chunk` helper, so daemon-spawned OpenFlow agents stop producing an empty log that blinded stuck-detection. See `docs/features/remote-hosts.md`, `docs/features/persistent-agents.md`, `docs/features/workspaces-sync.md`, `docs/features/workspaces-overview.md`, `docs/features/openflow.md`, `docs/plans/repo-unit-sync.md`. diff --git a/docs/features/observability.md b/docs/features/observability.md index 1498de36..d31fa05b 100644 --- a/docs/features/observability.md +++ b/docs/features/observability.md @@ -45,6 +45,25 @@ All six pieces are bundled in `ObservabilitySnapshot` and persisted as one JSON - **No external exporter** — the data never leaves the local machine. No OTLP, no Prometheus, no structured log shipping. That's deliberate for v1 (local-first principle), but means remote debugging requires shipping the JSON file manually. - **Feature flags are boolean only** — no percentages, no ramps, no user targeting. A flag is either on or off for the current user. +## Native Log File (tauri-plugin-log) + +Separate from `ObservabilityStore`, the desktop app installs a real +`log`-crate logger via tauri-plugin-log (registered in `lib.rs`), +writing warn-and-above to stderr **and** to a rotating file in the +platform app-log dir (`~/.local/share/com.codemux.app/logs/codemux.log` +on Linux, 2 MB cap, one rotation kept). This exists because +dependencies report real failures through the `log` crate — rfd's +"Failed to pick folder" when no dialog backend exists (issue #95) was +invisible before a logger was installed. + +Support surface: + +- `codemux logs [--tail ]` — print recent log lines, no running app + required (`src-tauri/src/app_logs.rs`). +- `codemux doctor` — environment diagnostics including the file-dialog + backend preflight (`src-tauri/src/doctor.rs`, + `src-tauri/src/dialog_preflight.rs`). + ## Important Touch Points - `src-tauri/src/observability.rs`: diff --git a/docs/features/workspace-creation.md b/docs/features/workspace-creation.md index 5d288bfc..b2d4d898 100644 --- a/docs/features/workspace-creation.md +++ b/docs/features/workspace-creation.md @@ -68,6 +68,14 @@ After onboarding: scripts saved to project config, worktree workspace created, w - No workspace templates or saved configurations - No multi-issue linking (one issue per workspace) - Package detection is best-effort (one pass on project open) +- Native folder/file pickers on Linux need either a working XDG + desktop portal (with a FileChooser backend such as + xdg-desktop-portal-gtk) or zenity installed. When neither exists + (minimal i3/dwm setups — issue #95), the Rust side preflights and + rejects with a `NO_FILE_PICKER_BACKEND` error, and every UI call + site goes through `src/lib/file-dialog.ts`, which shows an + install-hint toast instead of silently doing nothing. `codemux + doctor` diagnoses this from a terminal. ## Important Touch Points diff --git a/docs/reference/CONTROL.md b/docs/reference/CONTROL.md index 036de91b..e96cb547 100644 --- a/docs/reference/CONTROL.md +++ b/docs/reference/CONTROL.md @@ -65,8 +65,26 @@ codemux browser snapshot codemux memory show codemux handoff codemux index build +codemux logs --tail 200 +codemux doctor ``` +## Local Diagnostics + +`codemux logs` and `codemux doctor` run entirely locally — no running +Codemux instance or control socket needed, so they work precisely when +the app itself is misbehaving. + +- `codemux logs [--tail ]` prints the last `n` lines (default 200) + of the desktop app's persistent log file (written via + tauri-plugin-log to the platform app-log dir, e.g. + `~/.local/share/com.codemux.app/logs/codemux.log` on Linux). +- `codemux doctor` checks the local environment and prints an + actionable report: desktop/session info, whether the XDG desktop + portal file chooser or the zenity fallback is available (file + dialogs silently failed on portal-less minimal WM setups before the + issue #95 fix), and where the log file lives. + ## Browser Note From agent terminals, always use explicit `codemux browser ...` subcommands. Do not use `xdg-open`, `open`, or any other system-browser launcher when the goal is to work inside Codemux. diff --git a/package-lock.json b/package-lock.json index d128fc4e..03781d82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codemux", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codemux", - "version": "0.8.0", + "version": "0.9.0", "license": "Elastic-2.0", "dependencies": { "@codemirror/commands": "^6.10.3", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 319fd36b..ea7a953f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -43,6 +43,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -79,6 +90,23 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -186,6 +214,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "ashpd" version = "0.11.1" @@ -486,6 +520,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" @@ -526,6 +572,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "brotli" version = "8.0.2" @@ -563,6 +633,40 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars 1.2.1", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -700,6 +804,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -829,6 +939,7 @@ dependencies = [ "hostname", "ignore", "libc", + "log", "mockito", "notify", "notify-rust", @@ -850,6 +961,7 @@ dependencies = [ "tauri-build", "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", + "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-single-instance", @@ -864,6 +976,7 @@ dependencies = [ "uuid", "which", "windows-sys 0.59.0", + "zbus", ] [[package]] @@ -1395,6 +1508,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1482,6 +1605,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1614,6 +1746,12 @@ dependencies = [ "libc", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -2100,6 +2238,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2107,7 +2248,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", ] [[package]] @@ -2895,6 +3036,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "mac" @@ -3255,6 +3399,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -4045,6 +4198,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -4105,6 +4278,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.7.3" @@ -4319,6 +4498,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -4443,6 +4631,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rrule" version = "0.14.0" @@ -4470,6 +4687,23 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4692,6 +4926,12 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "security-framework" version = "3.7.0" @@ -5099,6 +5339,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -5404,6 +5650,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.45" @@ -5610,6 +5862,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-log" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +dependencies = [ + "android_logger", + "byte-unit", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -5907,7 +6181,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -5940,6 +6216,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.51.0" @@ -6409,6 +6700,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6434,6 +6731,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -7544,6 +7847,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 52165603..25e97b26 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,12 @@ libc = "0.2" tauri-plugin-single-instance = "2.4.0" tauri-plugin-updater = "2" tauri-plugin-process = "2" +# Persistent native-side logging (file in the app log dir + stderr). +# Without an installed logger every `log::error!` from dependencies — +# notably rfd's "Failed to pick folder" when no dialog backend exists +# (issue #95) — vanished silently. `codemux logs` tails the file. +tauri-plugin-log = "2" +log = "0.4" # WebKit2GTK strips clipboard image payloads from the standard # `paste` event for security reasons, so reading clipboard images # from JS via `e.clipboardData` always comes back empty on Linux. @@ -94,6 +100,15 @@ tower = "0.5" [target.'cfg(unix)'.dependencies] tauri-plugin-dialog = { version = "2", default-features = false, features = ["xdg-portal"] } +[target.'cfg(target_os = "linux")'.dependencies] +# Used by dialog_preflight.rs to ask the XDG desktop portal whether a +# FileChooser backend actually exists before opening a file dialog. +# The dialog plugin above is portal-only, and on minimal window-manager +# setups (i3, dwm, ...) no portal backend may be running — the dialog +# then silently resolves as if cancelled (issue #95). zbus is already +# in the tree via rfd -> ashpd, so this adds no new build cost. +zbus = "5" + [target.'cfg(windows)'.dependencies] tauri-plugin-dialog = { version = "2" } # Used only by os_input.rs Tier-3 input injection — EnumWindows, diff --git a/src-tauri/src/app_logs.rs b/src-tauri/src/app_logs.rs new file mode 100644 index 00000000..3ace67b5 --- /dev/null +++ b/src-tauri/src/app_logs.rs @@ -0,0 +1,64 @@ +//! Locating and tailing the desktop app's persistent log file from a +//! CLI context (`codemux logs`, `codemux doctor`). +//! +//! The Tauri side writes the file via tauri-plugin-log into the app +//! log dir (see the logger registration in lib.rs). CLI invocations +//! have no `AppHandle`, so this mirrors Tauri's `app_log_dir` +//! platform resolution for our fixed bundle identifier instead. + +use std::path::{Path, PathBuf}; + +/// Base name passed to tauri-plugin-log's `LogDir` target; the plugin +/// appends `.log`. +pub const LOG_FILE_STEM: &str = "codemux"; + +/// Must match `identifier` in `tauri.conf.json`. +const BUNDLE_IDENTIFIER: &str = "com.codemux.app"; + +/// Platform path of the app's log file. Mirrors Tauri's +/// `app_log_dir`: +/// - Linux: `$XDG_DATA_HOME/{identifier}/logs` +/// - macOS: `$HOME/Library/Logs/{identifier}` +/// - Windows: `{FOLDERID_LocalAppData}/{identifier}/logs` +pub fn app_log_file() -> Option { + #[cfg(target_os = "linux")] + let dir = dirs::data_dir()?.join(BUNDLE_IDENTIFIER).join("logs"); + #[cfg(target_os = "macos")] + let dir = dirs::home_dir()?.join("Library/Logs").join(BUNDLE_IDENTIFIER); + #[cfg(target_os = "windows")] + let dir = dirs::data_local_dir()?.join(BUNDLE_IDENTIFIER).join("logs"); + + Some(dir.join(format!("{LOG_FILE_STEM}.log"))) +} + +/// Last `count` lines of `path`. The log rotates at a small fixed +/// size (see lib.rs), so reading the whole file is fine. +pub fn tail_lines(path: &Path, count: usize) -> std::io::Result> { + let content = std::fs::read_to_string(path)?; + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(count); + Ok(lines[start..].iter().map(|line| line.to_string()).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn tail_returns_last_n_lines() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("codemux.log"); + let mut file = std::fs::File::create(&path).expect("create"); + for i in 1..=5 { + writeln!(file, "line {i}").expect("write"); + } + + let tail = tail_lines(&path, 2).expect("tail"); + assert_eq!(tail, vec!["line 4".to_string(), "line 5".to_string()]); + + // Asking for more lines than exist returns everything. + let all = tail_lines(&path, 50).expect("tail"); + assert_eq!(all.len(), 5); + } +} diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index d32a521a..290cab51 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -38,6 +38,15 @@ pub enum CommandSet { #[command(subcommand)] command: WorkspaceCommand, }, + /// Print recent lines from the desktop app's log file + Logs { + /// Number of lines from the end of the log to print + #[arg(long, default_value_t = 200)] + tail: usize, + }, + /// Check the local environment for common problems (file dialogs, + /// logs). Works without a running Codemux instance. + Doctor, /// List all available codemux commands and capabilities Capabilities, /// Start MCP server (JSON-RPC over stdio) @@ -568,6 +577,32 @@ pub async fn maybe_run_cli() -> Result { println!("{}", serde_json::to_string_pretty(&response).map_err(|error| error.to_string())?); Ok(true) } + Some(CommandSet::Logs { tail }) => { + // Purely local — reads the file tauri-plugin-log writes, so + // it works even when (especially when) the app won't start. + match crate::app_logs::app_log_file() { + Some(path) if path.exists() => { + eprintln!("# {}", path.display()); + for line in + crate::app_logs::tail_lines(&path, tail).map_err(|e| e.to_string())? + { + println!("{line}"); + } + } + Some(path) => { + println!( + "No log file at {} yet. It is created the first time the desktop app runs.", + path.display() + ); + } + None => println!("Could not resolve the platform log directory."), + } + Ok(true) + } + Some(CommandSet::Doctor) => { + crate::doctor::run().await; + Ok(true) + } Some(CommandSet::Mcp) => { crate::mcp_server::run_mcp_server().await?; Ok(true) @@ -645,6 +680,8 @@ pub async fn maybe_run_cli() -> Result { "status": { "description": "Show Codemux app status" }, "notify": { "args": "", "description": "Send a notification to the user" }, "handoff": { "description": "Generate project handoff summary" }, + "logs": { "args": "[--tail ]", "description": "Print recent lines from the desktop app's log file" }, + "doctor": { "description": "Diagnose the local environment (file dialogs, logs)" }, "capabilities": { "description": "List all available commands (this output)" } }, "environment": { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1b3009b5..259e6e1e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -312,6 +312,11 @@ pub async fn pick_folder_dialog( use tauri_plugin_dialog::DialogExt; use tokio::sync::oneshot; + // Fail loudly when no dialog backend exists (issue #95) — without + // this the portal-only backend resolves `None` exactly like a user + // cancel and the UI silently does nothing. + crate::dialog_preflight::ensure_file_picker_backend().await?; + let (tx, rx) = oneshot::channel(); let mut builder = app @@ -340,6 +345,9 @@ pub async fn pick_files_dialog( use tauri_plugin_dialog::DialogExt; use tokio::sync::oneshot; + // See pick_folder_dialog — same silent-failure guard (issue #95). + crate::dialog_preflight::ensure_file_picker_backend().await?; + let (tx, rx) = oneshot::channel(); let mut builder = app @@ -380,6 +388,9 @@ pub async fn pick_save_file_dialog( use tauri_plugin_dialog::DialogExt; use tokio::sync::oneshot; + // See pick_folder_dialog — same silent-failure guard (issue #95). + crate::dialog_preflight::ensure_file_picker_backend().await?; + let (tx, rx) = oneshot::channel(); let mut builder = app @@ -422,6 +433,9 @@ pub async fn pick_open_file_dialog( use tauri_plugin_dialog::DialogExt; use tokio::sync::oneshot; + // See pick_folder_dialog — same silent-failure guard (issue #95). + crate::dialog_preflight::ensure_file_picker_backend().await?; + let (tx, rx) = oneshot::channel(); let mut builder = app diff --git a/src-tauri/src/dialog_preflight.rs b/src-tauri/src/dialog_preflight.rs new file mode 100644 index 00000000..11050cbf --- /dev/null +++ b/src-tauri/src/dialog_preflight.rs @@ -0,0 +1,118 @@ +//! Preflight check for native file dialogs on Linux (issue #95). +//! +//! The dialog plugin is compiled portal-only on unix (see Cargo.toml: +//! `tauri-plugin-dialog` with `features = ["xdg-portal"]`), and the +//! underlying rfd crate falls back to spawning `zenity` when the +//! portal call fails. On minimal window-manager setups (i3, dwm, ...) +//! neither the XDG desktop portal nor zenity is guaranteed to exist. +//! When both are missing, rfd resolves the dialog to `None` — the +//! exact same value as a user cancel — and the UI silently does +//! nothing. +//! +//! This module detects that situation *before* the dialog is opened +//! so the command can return a real error that the frontend turns +//! into an actionable toast, and `codemux doctor` can print a +//! diagnosis. Non-Linux platforms always pass: their native dialogs +//! need no external services. + +/// Stable, greppable marker the frontend matches on to distinguish +/// the "no backend installed" failure from any other dialog error. +/// Kept in sync with `NO_FILE_PICKER_BACKEND` in +/// `src/lib/file-dialog.ts`. +pub const NO_BACKEND_MARKER: &str = "NO_FILE_PICKER_BACKEND"; + +/// Human install hint appended to the error and surfaced in the +/// frontend toast and `codemux doctor`. +pub const INSTALL_HINT: &str = "Install xdg-desktop-portal plus a backend such as \ + xdg-desktop-portal-gtk and restart your session, or install zenity."; + +/// Result of probing every dialog path the compiled backend can take. +#[cfg(target_os = "linux")] +pub struct FilePickerDiagnosis { + /// `Ok(version)` when the portal's FileChooser interface answered + /// a `version` property read; `Err(reason)` otherwise. A running + /// portal without any FileChooser backend fails this probe too, + /// which is exactly what we want — the interface is only exported + /// when a backend implements it. + pub portal: Result, + /// Path of the `zenity` binary when present on `PATH` (rfd's + /// automatic fallback when the portal call fails). + pub zenity: Option, +} + +#[cfg(target_os = "linux")] +impl FilePickerDiagnosis { + /// At least one path can produce a visible dialog. + pub fn usable(&self) -> bool { + self.portal.is_ok() || self.zenity.is_some() + } +} + +#[cfg(target_os = "linux")] +pub async fn diagnose() -> FilePickerDiagnosis { + FilePickerDiagnosis { + portal: portal_file_chooser_version().await, + zenity: which::which("zenity").ok(), + } +} + +/// Read the `version` property of `org.freedesktop.portal.FileChooser` +/// on the session bus. This is the canonical availability probe: the +/// portal frontend only exports an interface when some backend +/// implements it, so this fails fast for "portal not installed", +/// "no session bus", and "portal running but no FileChooser backend" +/// alike. +#[cfg(target_os = "linux")] +async fn portal_file_chooser_version() -> Result { + use std::time::Duration; + + let probe = async { + let connection = zbus::Connection::session() + .await + .map_err(|error| format!("session bus unavailable: {error}"))?; + let proxy = zbus::Proxy::new( + &connection, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.FileChooser", + ) + .await + .map_err(|error| format!("portal proxy setup failed: {error}"))?; + proxy + .get_property::("version") + .await + .map_err(|error| format!("FileChooser interface not provided: {error}")) + }; + + // A healthy portal answers in single-digit milliseconds; the + // timeout only guards against a wedged bus so the UI button never + // feels dead for longer than this. + tokio::time::timeout(Duration::from_secs(4), probe) + .await + .map_err(|_| "timed out talking to the desktop portal".to_string())? +} + +/// `Err(actionable message)` when no file-dialog backend can possibly +/// work, `Ok(())` otherwise. Called by every dialog command before +/// the dialog is built. +pub async fn ensure_file_picker_backend() -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let diagnosis = diagnose().await; + if !diagnosis.usable() { + let portal_error = match &diagnosis.portal { + Err(reason) => reason.clone(), + Ok(_) => unreachable!("usable() is false, portal must be Err"), + }; + log::error!( + "no file picker backend: portal probe failed ({portal_error}); zenity not on PATH" + ); + return Err(format!( + "{NO_BACKEND_MARKER}: cannot open a file dialog. \ + The XDG desktop portal is unavailable ({portal_error}) \ + and zenity is not installed. {INSTALL_HINT}" + )); + } + } + Ok(()) +} diff --git a/src-tauri/src/doctor.rs b/src-tauri/src/doctor.rs new file mode 100644 index 00000000..9edb60e6 --- /dev/null +++ b/src-tauri/src/doctor.rs @@ -0,0 +1,85 @@ +//! `codemux doctor` — local environment diagnostics. +//! +//! Runs entirely offline against the local machine (no control +//! socket, no running app needed) so it works precisely when the app +//! itself is misbehaving. Designed for support: "run `codemux doctor` +//! and paste the output" should be enough to triage environment +//! problems like issue #95 (file dialogs silently failing on minimal +//! window-manager setups). + +fn env_or(name: &str, fallback: &str) -> String { + std::env::var(name).unwrap_or_else(|_| fallback.to_string()) +} + +pub async fn run() { + println!("codemux doctor (v{})", env!("CARGO_PKG_VERSION")); + println!(); + + // ── Environment ───────────────────────────────────────────── + println!("environment"); + println!(" os: {}", std::env::consts::OS); + #[cfg(target_os = "linux")] + { + println!( + " desktop: {}", + env_or("XDG_CURRENT_DESKTOP", "(unset)") + ); + println!( + " session type: {}", + env_or("XDG_SESSION_TYPE", "(unset)") + ); + println!( + " wayland display: {}", + env_or("WAYLAND_DISPLAY", "(unset)") + ); + println!(" x11 display: {}", env_or("DISPLAY", "(unset)")); + println!( + " session bus: {}", + env_or("DBUS_SESSION_BUS_ADDRESS", "(unset)") + ); + } + println!(); + + // ── File dialogs (Linux-only concern) ─────────────────────── + // The compiled dialog backend is portal-first with a zenity + // fallback; macOS/Windows use native dialogs that need nothing. + #[cfg(target_os = "linux")] + { + println!("file dialogs"); + let diagnosis = crate::dialog_preflight::diagnose().await; + match &diagnosis.portal { + Ok(version) => println!( + " [ok] desktop portal file chooser available (interface version {version})" + ), + Err(reason) => { + println!(" [FAIL] desktop portal file chooser unavailable: {reason}") + } + } + match &diagnosis.zenity { + Some(path) => println!(" [ok] zenity fallback found at {}", path.display()), + None => println!(" [warn] zenity fallback not found on PATH"), + } + if diagnosis.usable() { + println!(" [ok] file dialogs should work"); + } else { + println!(" [FAIL] file dialogs cannot open on this system."); + println!(" {}", crate::dialog_preflight::INSTALL_HINT); + } + println!(); + } + + // ── Logs ──────────────────────────────────────────────────── + println!("logs"); + match crate::app_logs::app_log_file() { + Some(path) if path.exists() => { + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + println!(" [ok] log file: {} ({size} bytes)", path.display()); + println!(" view recent entries with: codemux logs"); + } + Some(path) => { + println!(" [warn] no log file yet at {}", path.display()); + println!(" it is created the first time the desktop app runs"); + } + None => println!(" [warn] could not resolve the platform log directory"), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 864070b6..8777355f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,6 +29,9 @@ pub mod github; pub mod github_cache; pub mod control; pub mod diagnostics; +pub mod dialog_preflight; +pub mod app_logs; +pub mod doctor; pub mod encryption; pub mod execution; pub mod indexing; @@ -220,6 +223,24 @@ pub fn run() { database::DatabaseStore::new_in_memory() })) .plugin(tauri_plugin_opener::init()) + // Persistent native logging: app log dir + stderr. Warn-level + // globally so dependency chatter stays out, which still + // captures the failures that used to vanish — e.g. rfd's + // "Failed to pick folder" when no dialog backend exists + // (issue #95). Users can read the file via `codemux logs`. + .plugin( + tauri_plugin_log::Builder::new() + .level(log::LevelFilter::Warn) + .targets([ + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stderr), + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { + file_name: Some(crate::app_logs::LOG_FILE_STEM.into()), + }), + ]) + .max_file_size(2 * 1024 * 1024) + .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne) + .build(), + ) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) diff --git a/src-tauri/tests/dialog_preflight.rs b/src-tauri/tests/dialog_preflight.rs new file mode 100644 index 00000000..e8828f4e --- /dev/null +++ b/src-tauri/tests/dialog_preflight.rs @@ -0,0 +1,54 @@ +//! Integration tests for the Linux file-picker backend preflight +//! (issue #95: dialogs silently failing on minimal WM setups). +//! +//! Lives in its own integration-test binary on purpose: the checks +//! mutate process-global env (DBUS_SESSION_BUS_ADDRESS, PATH) and +//! cargo runs each tests/*.rs file as a separate process, so nothing +//! else can race those variables. Inside this file everything runs +//! sequentially in a single #[tokio::test] for the same reason. + +#![cfg(target_os = "linux")] + +use codemux_lib::dialog_preflight; + +#[tokio::test] +async fn preflight_distinguishes_missing_backends_from_cancel() { + // ── 1. No portal, no zenity → actionable error ─────────────── + // Point the session bus somewhere dead and strip PATH so neither + // backend can be found. This replicates a minimal i3/Arch setup + // (no xdg-desktop-portal running, zenity not installed). + std::env::set_var( + "DBUS_SESSION_BUS_ADDRESS", + "unix:path=/nonexistent/codemux-preflight-test", + ); + std::env::set_var("PATH", "/nonexistent-codemux-bin"); + + let error = dialog_preflight::ensure_file_picker_backend() + .await + .expect_err("preflight must fail when no dialog backend exists"); + assert!( + error.contains(dialog_preflight::NO_BACKEND_MARKER), + "error must carry the frontend-matchable marker, got: {error}" + ); + assert!( + error.contains("zenity") && error.contains("xdg-desktop-portal"), + "error must name the installable fixes, got: {error}" + ); + + // ── 2. zenity alone satisfies the preflight ────────────────── + // rfd falls back to spawning zenity when the portal call fails, + // so a present zenity binary means dialogs can open. + let dir = tempfile::tempdir().expect("tempdir"); + let fake_zenity = dir.path().join("zenity"); + std::fs::write(&fake_zenity, "#!/bin/sh\nexit 0\n").expect("write fake zenity"); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake_zenity, std::fs::Permissions::from_mode(0o755)) + .expect("chmod fake zenity"); + } + std::env::set_var("PATH", dir.path()); + + dialog_preflight::ensure_file_picker_backend() + .await + .expect("zenity on PATH must satisfy the preflight even without a portal"); +} diff --git a/src/components/openflow/new-run-dialog.tsx b/src/components/openflow/new-run-dialog.tsx index 237b94bd..57298a66 100644 --- a/src/components/openflow/new-run-dialog.tsx +++ b/src/components/openflow/new-run-dialog.tsx @@ -15,8 +15,8 @@ import { createOpenflowRun, spawnOpenflowAgents, activateWorkspace, - pickFolderDialog, } from "@/tauri/commands"; +import { pickFolder } from "@/lib/file-dialog"; import { Dialog, DialogContent, @@ -165,7 +165,7 @@ export function NewRunDialog({ defaultCwd }: NewRunDialogProps) { ); const handlePickFolder = async () => { - const folder = await pickFolderDialog("Choose folder"); + const folder = await pickFolder("Choose folder"); if (folder) setCwd(folder); }; diff --git a/src/components/overlays/clone-dialog.tsx b/src/components/overlays/clone-dialog.tsx index 629c0c75..73854e0e 100644 --- a/src/components/overlays/clone-dialog.tsx +++ b/src/components/overlays/clone-dialog.tsx @@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button"; import { FolderOpen, Loader2 } from "lucide-react"; import { useUIStore } from "@/stores/ui-store"; import { useProjectActions } from "@/hooks/use-project-actions"; -import { pickFolderDialog } from "@/tauri/commands"; +import { pickFolder } from "@/lib/file-dialog"; export function CloneDialog() { const open = useUIStore((s) => s.showCloneDialog); @@ -57,7 +57,7 @@ export function CloneDialog() { }; const handlePickDir = async () => { - const folder = await pickFolderDialog("Clone destination"); + const folder = await pickFolder("Clone destination"); if (folder) setTargetDir(folder); }; diff --git a/src/components/overlays/new-project-screen.tsx b/src/components/overlays/new-project-screen.tsx index a6297f18..b94d09dd 100644 --- a/src/components/overlays/new-project-screen.tsx +++ b/src/components/overlays/new-project-screen.tsx @@ -16,13 +16,13 @@ import { useAppStore } from "@/stores/app-store"; import { basename } from "@/lib/path"; import { useFeatureFlags } from "@/stores/feature-flags"; import { - pickFolderDialog, createEmptyRepo, gitCloneRepo, dbAddRecentProject, createEmptyWorkspace, activateWorkspace, } from "@/tauri/commands"; +import { pickFolder } from "@/lib/file-dialog"; type Mode = "empty" | "clone"; @@ -69,7 +69,7 @@ export function NewProjectScreen() { const effectiveName = mode === "clone" ? repoName || derivedName : repoName; const handleBrowse = async () => { - const folder = await pickFolderDialog("Select project location"); + const folder = await pickFolder("Select project location"); if (folder) setParentDir(folder); }; diff --git a/src/components/overlays/new-workspace-dialog.tsx b/src/components/overlays/new-workspace-dialog.tsx index 67539abd..0b16d86b 100644 --- a/src/components/overlays/new-workspace-dialog.tsx +++ b/src/components/overlays/new-workspace-dialog.tsx @@ -59,13 +59,13 @@ import { checkGhAvailable, checkGithubRepo, listPullRequests, - pickFilesDialog, pasteClipboardImageToFile, suggestIssueBranchName, linkWorkspaceIssue, getGithubIssueByPath, applyPreset, } from "@/tauri/commands"; +import { pickFiles } from "@/lib/file-dialog"; import type { TerminalPreset, WorktreeInfo, BranchDetail, PullRequestInfo, GitHubIssue, LinkedIssue } from "@/tauri/types"; const ISSUE_BODY_MAX_CHARS = 10_000; @@ -935,7 +935,7 @@ export function NewWorkspaceDialog({ open, onOpenChange }: Props) { aria-label="Attach files" className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground outline-none" onClick={async () => { - const files = await pickFilesDialog("Attach files"); + const files = await pickFiles("Attach files"); if (files.length > 0) { setAttachments((prev) => { const existing = new Set(prev); diff --git a/src/dev/tauri-mock.ts b/src/dev/tauri-mock.ts index 17c5f19d..00de68d2 100644 --- a/src/dev/tauri-mock.ts +++ b/src/dev/tauri-mock.ts @@ -570,6 +570,31 @@ const handlers: Record = { db_get_recent_projects: () => [], db_add_recent_project: () => undefined, + // ── File dialogs ── + // Default: resolve as a cancel (null / empty) like the previous + // fall-through did. Set the sessionStorage flag below to simulate + // the Linux "no file picker backend" preflight rejection (issue + // #95) and visually verify the error toast in the browser: + // sessionStorage.setItem("codemux-mock-no-file-picker", "1") + pick_folder_dialog: () => { + if (sessionStorage.getItem("codemux-mock-no-file-picker")) { + return Promise.reject( + "NO_FILE_PICKER_BACKEND: cannot open a file dialog (dev mock simulation). " + + "The XDG desktop portal is unavailable and zenity is not installed.", + ); + } + return null; + }, + pick_files_dialog: () => { + if (sessionStorage.getItem("codemux-mock-no-file-picker")) { + return Promise.reject( + "NO_FILE_PICKER_BACKEND: cannot open a file dialog (dev mock simulation). " + + "The XDG desktop portal is unavailable and zenity is not installed.", + ); + } + return []; + }, + // ── Theme / appearance ── get_current_theme: () => THEME, get_shell_appearance: () => SHELL_APPEARANCE, diff --git a/src/hooks/use-project-actions.ts b/src/hooks/use-project-actions.ts index cbb58ede..6de9c50c 100644 --- a/src/hooks/use-project-actions.ts +++ b/src/hooks/use-project-actions.ts @@ -1,6 +1,5 @@ import { useCallback } from "react"; import { - pickFolderDialog, checkIsGitRepo, initGitRepo, dbAddRecentProject, @@ -8,6 +7,7 @@ import { createEmptyWorkspace, activateWorkspace, } from "@/tauri/commands"; +import { pickFolder } from "@/lib/file-dialog"; import { useAppStore } from "@/stores/app-store"; import { useFeatureFlags } from "@/stores/feature-flags"; import { useUIStore } from "@/stores/ui-store"; @@ -44,7 +44,7 @@ export function useProjectActions() { const setShowCloneDialog = useUIStore((s) => s.setShowCloneDialog); const openProject = useCallback(async (): Promise => { - const folder = await pickFolderDialog("Open project"); + const folder = await pickFolder("Open project"); if (!folder) return { success: false }; const name = basename(folder); diff --git a/src/lib/file-dialog.test.ts b/src/lib/file-dialog.test.ts new file mode 100644 index 00000000..84c15d7c --- /dev/null +++ b/src/lib/file-dialog.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── Mock Tauri commands + toast ── +vi.mock("@/tauri/commands", () => ({ + pickFolderDialog: vi.fn(), + pickFilesDialog: vi.fn(), +})); +vi.mock("@/lib/toast", () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +import { pickFolderDialog, pickFilesDialog } from "@/tauri/commands"; +import { toast } from "@/lib/toast"; +import { pickFolder, pickFiles, NO_FILE_PICKER_BACKEND } from "./file-dialog"; + +const mockPickFolderDialog = vi.mocked(pickFolderDialog); +const mockPickFilesDialog = vi.mocked(pickFilesDialog); +const mockToastError = vi.mocked(toast.error); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("pickFolder", () => { + it("passes through a selected path", async () => { + mockPickFolderDialog.mockResolvedValue("/home/user/project"); + await expect(pickFolder("Open project")).resolves.toBe( + "/home/user/project", + ); + expect(mockToastError).not.toHaveBeenCalled(); + }); + + it("passes through a cancel (null) without toasting", async () => { + mockPickFolderDialog.mockResolvedValue(null); + await expect(pickFolder("Open project")).resolves.toBeNull(); + expect(mockToastError).not.toHaveBeenCalled(); + }); + + it("turns the missing-backend rejection into an actionable toast and resolves null", async () => { + // The Rust preflight rejects with this marker when neither the + // XDG portal nor zenity exists (issue #95). + mockPickFolderDialog.mockRejectedValue( + `${NO_FILE_PICKER_BACKEND}: cannot open a file dialog. The XDG desktop portal is unavailable and zenity is not installed.`, + ); + + await expect(pickFolder("Open project")).resolves.toBeNull(); + + expect(mockToastError).toHaveBeenCalledTimes(1); + const [headline, opts] = mockToastError.mock.calls[0]; + expect(headline).toMatch(/file picker/i); + expect(opts?.description).toMatch(/xdg-desktop-portal/); + expect(opts?.description).toMatch(/zenity/); + }); + + it("surfaces other dialog errors as a generic toast and resolves null", async () => { + mockPickFolderDialog.mockRejectedValue("channel closed"); + await expect(pickFolder("Open project")).resolves.toBeNull(); + expect(mockToastError).toHaveBeenCalledTimes(1); + const [, opts] = mockToastError.mock.calls[0]; + expect(opts?.description).toContain("channel closed"); + }); +}); + +describe("pickFiles", () => { + it("passes through selected files", async () => { + mockPickFilesDialog.mockResolvedValue(["/a.png", "/b.png"]); + await expect(pickFiles("Attach files")).resolves.toEqual([ + "/a.png", + "/b.png", + ]); + expect(mockToastError).not.toHaveBeenCalled(); + }); + + it("resolves empty list and toasts on missing backend", async () => { + mockPickFilesDialog.mockRejectedValue( + `${NO_FILE_PICKER_BACKEND}: cannot open a file dialog.`, + ); + await expect(pickFiles("Attach files")).resolves.toEqual([]); + expect(mockToastError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/file-dialog.ts b/src/lib/file-dialog.ts new file mode 100644 index 00000000..e87ee2f2 --- /dev/null +++ b/src/lib/file-dialog.ts @@ -0,0 +1,64 @@ +/** + * Never-silent wrappers around the native file dialog commands. + * + * Issue #95: on Linux the dialog backend is portal-first with a + * zenity fallback, and on minimal window-manager setups (i3, dwm, + * ...) neither may exist. The Rust side preflights that and rejects + * with a marker error — these wrappers turn it into an actionable + * toast and resolve like a cancel, so call sites keep their simple + * "null/empty means no selection" contract while the user actually + * learns what to install. + * + * UI call sites should import from here, not call the raw + * `pickFolderDialog`/`pickFilesDialog` commands directly. + */ + +import { pickFolderDialog, pickFilesDialog } from "@/tauri/commands"; +import { toast } from "@/lib/toast"; + +/** Marker prefix the Rust preflight puts on the error when no file + * picker backend exists. Kept in sync with `NO_BACKEND_MARKER` in + * `src-tauri/src/dialog_preflight.rs`. */ +export const NO_FILE_PICKER_BACKEND = "NO_FILE_PICKER_BACKEND"; + +function describeError(err: unknown): string { + if (typeof err === "string") return err; + if (err instanceof Error) return err.message; + return String(err); +} + +function surfaceDialogError(err: unknown): void { + const message = describeError(err); + if (message.includes(NO_FILE_PICKER_BACKEND)) { + toast.error("No file picker available on this system", { + description: + "Install xdg-desktop-portal and xdg-desktop-portal-gtk, then restart your session. Installing zenity also works.", + }); + } else { + toast.error("Could not open the file dialog", { description: message }); + } + console.error("[file-dialog]", err); +} + +/** Folder picker that never throws: resolves the chosen path, or + * `null` on cancel AND on failure — failures additionally surface + * as an error toast instead of a silent no-op. */ +export async function pickFolder(title: string): Promise { + try { + return await pickFolderDialog(title); + } catch (err) { + surfaceDialogError(err); + return null; + } +} + +/** Multi-file picker with the same never-throw contract as + * {@link pickFolder}; failure resolves to an empty list. */ +export async function pickFiles(title?: string): Promise { + try { + return await pickFilesDialog(title); + } catch (err) { + surfaceDialogError(err); + return []; + } +} From fcfa0a2ec3e2b7ac622f0683d17152fcdf4551bd Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Thu, 11 Jun 2026 23:13:47 +0200 Subject: [PATCH 2/2] test(agent-browser): tolerate a system-wide install in resolve_binary test resolve_binary() step 1 prefers an agent-browser on PATH, so on any machine running a packaged Codemux (/usr/bin/agent-browser) the resolve_binary_finds_native_binary_from_project_root test failed by asserting the node_modules binary name against the system path. Accept the legitimate system-install resolution and keep the node_modules assertion for hosts without one. --- src-tauri/src/agent_browser.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src-tauri/src/agent_browser.rs b/src-tauri/src/agent_browser.rs index 41831222..53b4a2dc 100644 --- a/src-tauri/src/agent_browser.rs +++ b/src-tauri/src/agent_browser.rs @@ -1961,6 +1961,17 @@ mod tests { fn resolve_binary_finds_native_binary_from_project_root() { // Run from the project root where node_modules exists let result = resolve_binary(); + // Step 1 of resolve_binary() prefers a system-wide install on + // PATH (e.g. /usr/bin/agent-browser from a packaged install). + // On dev machines that also run a packaged Codemux, that branch + // legitimately wins over the node_modules lookup this test was + // written for — accept it instead of failing on those hosts. + if let Ok(system) = which::which("agent-browser") { + if result == system.to_string_lossy() { + assert!(system.is_file(), "system agent-browser vanished: {result}"); + return; + } + } // On this machine, the native binary should be found in node_modules if !result.starts_with("npx ") { assert!(