mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
## 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 -->
[](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>
179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
/**
|
|
* Unit tests for `openUrl`. The Tauri path is exercised in callers'
|
|
* integration tests; here we focus on the browser fallback and the
|
|
* CEF-IPC-not-ready recovery so the non-Tauri branch (used by dev
|
|
* preview builds) and the CEF gap window (#1472 / REACT-T/S/R) do
|
|
* not regress.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
|
|
|
const isTauriMock = vi.fn();
|
|
const tauriOpenUrlMock = vi.fn();
|
|
const revealItemInDirMock = vi.fn();
|
|
const addBreadcrumbMock = vi.fn();
|
|
|
|
vi.mock('./tauriCommands/common', () => ({ isTauri: () => isTauriMock() }));
|
|
|
|
vi.mock('@tauri-apps/plugin-opener', () => ({
|
|
openUrl: (url: string) => tauriOpenUrlMock(url),
|
|
revealItemInDir: (path: string) => revealItemInDirMock(path),
|
|
}));
|
|
|
|
vi.mock('@sentry/react', () => ({
|
|
addBreadcrumb: (...args: unknown[]) => addBreadcrumbMock(...args),
|
|
}));
|
|
|
|
describe('openUrl', () => {
|
|
let originalWindowOpen: typeof window.open;
|
|
let windowOpenMock: Mock;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
originalWindowOpen = window.open;
|
|
windowOpenMock = vi.fn();
|
|
window.open = windowOpenMock as unknown as typeof window.open;
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.open = originalWindowOpen;
|
|
});
|
|
|
|
it('routes through tauri-plugin-opener when running inside Tauri', async () => {
|
|
isTauriMock.mockReturnValue(true);
|
|
tauriOpenUrlMock.mockResolvedValue(undefined);
|
|
|
|
const { openUrl } = await import('./openUrl');
|
|
await openUrl('https://example.com/page');
|
|
|
|
expect(tauriOpenUrlMock).toHaveBeenCalledWith('https://example.com/page');
|
|
// Browser fallback must NOT fire when the Tauri call succeeded.
|
|
expect(windowOpenMock).not.toHaveBeenCalled();
|
|
expect(addBreadcrumbMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to window.open in a browser context (non-Tauri)', async () => {
|
|
isTauriMock.mockReturnValue(false);
|
|
|
|
const { openUrl } = await import('./openUrl');
|
|
await openUrl('https://docs.example.com/');
|
|
|
|
expect(windowOpenMock).toHaveBeenCalledWith(
|
|
'https://docs.example.com/',
|
|
'_blank',
|
|
'noopener,noreferrer'
|
|
);
|
|
expect(tauriOpenUrlMock).not.toHaveBeenCalled();
|
|
expect(addBreadcrumbMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('propagates Tauri opener errors for non-http schemes (no silent fallback)', async () => {
|
|
// Regression guard: `window.open` cannot launch custom-scheme
|
|
// URLs (`obsidian://`, `mailto:`, …) — it spawns a useless Tauri
|
|
// webview window. For those we MUST propagate the error to the
|
|
// caller, even when the failure is the CEF IPC race.
|
|
isTauriMock.mockReturnValue(true);
|
|
tauriOpenUrlMock.mockRejectedValue(new Error('scheme not allowed'));
|
|
|
|
const { openUrl } = await import('./openUrl');
|
|
await expect(openUrl('obsidian://open?path=/Users/me/Vault')).rejects.toThrow(
|
|
'scheme not allowed'
|
|
);
|
|
expect(windowOpenMock).not.toHaveBeenCalled();
|
|
// Non-http schemes log only the protocol — the rest of the URL (here the
|
|
// vault path) is the payload itself and must not leak to Sentry.
|
|
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
category: 'ipc',
|
|
level: 'warning',
|
|
message: 'tauriOpenUrl failed; evaluating fallback',
|
|
data: expect.objectContaining({ url: 'obsidian:' }),
|
|
})
|
|
);
|
|
const call = addBreadcrumbMock.mock.calls[0]?.[0] as { data?: { url?: string } } | undefined;
|
|
expect(call?.data?.url).not.toContain('Vault');
|
|
expect(call?.data?.url).not.toContain('/Users/me');
|
|
});
|
|
|
|
it('falls back to window.open when tauriOpenUrl rejects on an http URL (CEF IPC race recovery, #1472)', async () => {
|
|
// Concrete repro for OPENHUMAN-REACT-T/S/R: CEF embedder
|
|
// injects `window.ipc.postMessage` after `on_after_created`. A
|
|
// click landing in that gap causes `tauriOpenUrl` to reject with
|
|
// a TypeError. For http(s) URLs the safe recovery is to hand off
|
|
// to `window.open` so the Billing dashboard still opens.
|
|
isTauriMock.mockReturnValue(true);
|
|
const ipcError = new TypeError("Cannot read properties of undefined (reading 'postMessage')");
|
|
tauriOpenUrlMock.mockRejectedValue(ipcError);
|
|
|
|
const { openUrl } = await import('./openUrl');
|
|
await openUrl('https://tinyhumans.ai/dashboard?token=secret-redact-me');
|
|
|
|
expect(windowOpenMock).toHaveBeenCalledWith(
|
|
'https://tinyhumans.ai/dashboard?token=secret-redact-me',
|
|
'_blank',
|
|
'noopener,noreferrer'
|
|
);
|
|
// Breadcrumb keeps only origin for http(s) — pathname + query (which may
|
|
// carry tokens / emails / vault paths) must not be sent to Sentry.
|
|
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
category: 'ipc',
|
|
level: 'warning',
|
|
message: 'tauriOpenUrl failed; evaluating fallback',
|
|
data: expect.objectContaining({ url: 'https://tinyhumans.ai' }),
|
|
})
|
|
);
|
|
const call = addBreadcrumbMock.mock.calls[0]?.[0] as { data?: { url?: string } } | undefined;
|
|
expect(call?.data?.url).not.toContain('secret-redact-me');
|
|
expect(call?.data?.url).not.toContain('/dashboard');
|
|
});
|
|
|
|
it('revealPath dispatches to tauri-plugin-opener under Tauri (#2281 Reveal Folder fallback)', async () => {
|
|
isTauriMock.mockReturnValue(true);
|
|
revealItemInDirMock.mockResolvedValue(undefined);
|
|
|
|
const { revealPath } = await import('./openUrl');
|
|
await revealPath('/Users/me/Vault');
|
|
|
|
expect(revealItemInDirMock).toHaveBeenCalledWith('/Users/me/Vault');
|
|
});
|
|
|
|
it('revealPath is a no-op outside Tauri (no shell to drive)', async () => {
|
|
isTauriMock.mockReturnValue(false);
|
|
|
|
const { revealPath } = await import('./openUrl');
|
|
await revealPath('/Users/me/Vault');
|
|
|
|
expect(revealItemInDirMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('revealPath propagates underlying tauri-plugin-opener errors to the caller', async () => {
|
|
isTauriMock.mockReturnValue(true);
|
|
revealItemInDirMock.mockRejectedValue(new Error('reveal failed'));
|
|
|
|
const { revealPath } = await import('./openUrl');
|
|
await expect(revealPath('/Users/me/Vault')).rejects.toThrow('reveal failed');
|
|
});
|
|
|
|
it('trims surrounding whitespace before classifying an http URL for fallback', async () => {
|
|
isTauriMock.mockReturnValue(true);
|
|
tauriOpenUrlMock.mockRejectedValue(
|
|
new TypeError("Cannot read properties of undefined (reading 'postMessage')")
|
|
);
|
|
|
|
const { openUrl } = await import('./openUrl');
|
|
await openUrl(' https://tinyhumans.ai/dashboard?token=secret-redact-me ');
|
|
|
|
expect(tauriOpenUrlMock).toHaveBeenCalledWith(
|
|
'https://tinyhumans.ai/dashboard?token=secret-redact-me'
|
|
);
|
|
expect(windowOpenMock).toHaveBeenCalledWith(
|
|
'https://tinyhumans.ai/dashboard?token=secret-redact-me',
|
|
'_blank',
|
|
'noopener,noreferrer'
|
|
);
|
|
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: expect.objectContaining({ url: 'https://tinyhumans.ai' }) })
|
|
);
|
|
});
|
|
});
|