Files
openhuman/app/src/utils/openUrl.ts
T
a472d6fd7d fix(memory-workspace): toast + Reveal Folder fallback for View Vault (#2281)
## Summary

- Wire a toast on every click of **View Vault** so users always see a result; surface the vault path in the toast.
- Add a **Reveal Folder** fallback action so users without Obsidian still have an OS-native escape hatch to inspect the vault.
- Add a `revealPath` helper (wraps `tauri-plugin-opener`'s `revealItemInDir`) + `opener:allow-reveal-item-in-dir` capability so the renderer can drive a Finder/Explorer reveal.
- 6 new Vitest cases cover the success-toast, reveal-fallback, error-toast, and reveal-error branches.

## Problem

`MemoryWorkspace.tsx` View Vault sent `obsidian://open?path=...` through `openUrl` and relied on the host OS to launch Obsidian. When Obsidian is not installed, the OS shell (LaunchServices on macOS, xdg-open on Linux, ShellExecute on Windows) accepts the URL handoff but launches nothing. No toast, no error, no fallback — the button looks broken to non-technical users.

## Solution

- New `revealPath(path)` helper in `app/src/utils/openUrl.ts` wraps `revealItemInDir` from `@tauri-apps/plugin-opener`. No-op outside Tauri.
- Capability `app/src-tauri/capabilities/default.json` adds `opener:allow-reveal-item-in-dir`.
- `MemoryWorkspace.tsx` replaces the silent module helper with a `handleViewVault` callback. On click:
  - Try `openUrl(obsidian://...)`.
  - On success: emit an `info` toast that names the vault path and exposes a **Reveal Folder** action.
  - On error: emit an `error` toast with the same **Reveal Folder** action.
  - **Reveal Folder** calls `revealPath(content_root_abs)`; if that fails too, surface a final error toast with the underlying message.
- New i18n keys (`workspace.openingVault*`, `workspace.openVaultFailed*`, `workspace.revealFolder`, `workspace.revealVaultFailed`) added to `en.ts` + `en-3.ts`, with English fallback into all 11 non-en `-3` chunks so `pnpm i18n:check` exits 0.

## Submission Checklist

- [x] Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy — 6 new Vitest cases across `MemoryWorkspace.test.tsx` and `openUrl.test.ts`.
- [x] **Diff coverage ≥ 80%** — every new branch in `handleViewVault` and `revealPath` has a dedicated test.
- [x] Coverage matrix updated — N/A: behaviour-only fix for an existing UI affordance; no new feature row needed.
- [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` — N/A: no matrix row affected.
- [x] No new external network dependencies introduced — no network calls added.
- [x] Manual smoke checklist updated if this touches release-cut surfaces — N/A: not on the release-cut surface list.
- [x] Linked issue closed via `Closes #NNN` in the `## Related` section.

## Impact

- **Runtime/platform**: desktop (mac/win/linux). No mobile/web/CLI surfaces touched.
- **Performance**: zero — a single extra toast emit + (optional, user-driven) `revealItemInDir` IPC call.
- **Security**: new capability permission is read-only filesystem-reveal scoped to the host file manager; no path escape.
- **Migration / compatibility**: backward-compatible. Existing `obsidian://` deep-link behaviour preserved for users with Obsidian installed.

## Related

- Closes: #2281
- Follow-up PR(s)/TODOs: maintainer-side translations of the new keys (English fallback shipped so `i18n:check` passes).

---

## AI Authored PR Metadata (required for Codex/Linear PRs)

### Linear Issue
- Key: N/A
- URL: N/A

### Commit & Branch
- Branch: `fix/2281-view-vault-no-feedback`
- Commit SHA: 964c122b3b

### Validation Run
- [x] `pnpm --filter openhuman-app format:check`
- [x] `pnpm typecheck`
- [x] Focused tests: `pnpm test src/utils/openUrl.test.ts src/components/intelligence/__tests__/MemoryWorkspace.test.tsx` (23/23 pass); full `pnpm test` (2880 pass, 3 skipped).
- [x] Rust fmt/check (if changed): N/A — no Rust changes.
- [x] Tauri fmt/check (if changed): N/A — Tauri capability JSON only.

### Validation Blocked
- `command:` N/A
- `error:` N/A
- `impact:` N/A

### Behavior Changes
- Intended behavior change: Clicking **View Vault** now always emits a toast with the vault path and a **Reveal Folder** fallback, instead of silently no-op-ing when Obsidian is not installed.
- User-visible effect: visible toast + working fallback on every click.

### Parity Contract
- Legacy behavior preserved: `obsidian://` deep link still dispatched first; users with Obsidian installed see Obsidian launch as before.
- Guard/fallback/dispatch parity checks: `openUrl` reject path still propagates non-http scheme errors; new error branch in `handleViewVault` surfaces those as user-facing toasts.

### Duplicate / Superseded PR Handling
- Duplicate PR(s): None.
- Canonical PR: This PR.
- Resolution (closed/superseded/updated): N/A.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Users can now open Obsidian vaults directly from the MemoryWorkspace with improved error handling.
  * Added "Reveal Folder" action to vault operations for quick file system access when issues occur.
  * Enhanced feedback with toast notifications for success and failure states.

* **Documentation**
  * Added translations for vault-opening workflows in 12+ languages.

<!-- review_stack_entry_start -->

[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2289?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: obchain <riteshnikhoriya94@gmail.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
2026-05-20 15:16:08 -07:00

86 lines
3.1 KiB
TypeScript

import * as Sentry from '@sentry/react';
import { revealItemInDir, openUrl as tauriOpenUrl } from '@tauri-apps/plugin-opener';
import { isTauri } from './tauriCommands/common';
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url.trim());
/**
* Returns a low-PII representation of `url` for telemetry breadcrumbs.
* For http(s) we keep only the origin so the host is identifiable but the
* pathname/query/fragment (which may carry tokens, emails, or local paths)
* never leave the device. For other schemes (`mailto:`, `obsidian://`, …)
* we keep only the protocol — the rest of the URL is the payload itself
* (the email address, the vault path) and must not be logged.
*/
const getTelemetryUrl = (url: string): string => {
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.origin;
}
return parsed.protocol;
} catch {
return 'invalid-url';
}
};
/**
* Opens a URL using the host OS's default handler.
*
* Inside Tauri the call is dispatched through `tauri-plugin-opener`
* (which delegates to the OS shell — Finder/`open`, xdg-open, etc.)
* so custom URL schemes like `obsidian://` actually launch their
* registered application instead of staying inside the embedded
* webview.
*
* CEF embedder note: the IPC bridge (`window.ipc.postMessage`) is
* injected on the renderer-side after `on_after_created` fires.
* A click landing in that gap causes the plugin's `invoke()` glue
* to reject with `TypeError: Cannot read properties of undefined
* (reading 'postMessage')`. For http(s) URLs we recover by falling
* back to `window.open` so the user-facing flow still works. For
* non-http schemes we re-throw — `window.open` would spawn a Tauri
* webview window that cannot handle custom schemes, which is worse
* UX than a propagated error the caller can surface.
*
* In a browser context (no Tauri) we keep the `window.open` path so
* `https://` / `mailto:` links still work for dev/preview builds.
*/
export const openUrl = async (url: string): Promise<void> => {
const normalizedUrl = url.trim();
if (isTauri()) {
try {
await tauriOpenUrl(normalizedUrl);
return;
} catch (err) {
Sentry.addBreadcrumb({
category: 'ipc',
level: 'warning',
message: 'tauriOpenUrl failed; evaluating fallback',
data: { url: getTelemetryUrl(normalizedUrl), error: String(err) },
});
if (!isHttpUrl(normalizedUrl)) {
throw err;
}
// http(s) URL — safe to fall back to window.open.
}
}
window.open(normalizedUrl, '_blank', 'noopener,noreferrer');
};
/**
* Reveals a filesystem path in the host OS file manager
* (Finder on macOS, Explorer on Windows, the default file manager on
* Linux). Used as a guaranteed-works fallback when a third-party
* deep link (e.g. `obsidian://`) may silently no-op because the
* target app isn't installed.
*
* Outside Tauri this is a no-op — there's no OS shell to drive.
*/
export const revealPath = async (path: string): Promise<void> => {
if (!isTauri()) return;
await revealItemInDir(path);
};