diff --git a/app/src/components/intelligence/VaultPanel.test.tsx b/app/src/components/intelligence/VaultPanel.test.tsx index e4c72f5fb..e44fc4c97 100644 --- a/app/src/components/intelligence/VaultPanel.test.tsx +++ b/app/src/components/intelligence/VaultPanel.test.tsx @@ -14,6 +14,9 @@ const mockSync = vi.fn(); const mockSyncStatus = vi.fn(); const mockRemove = vi.fn(); +const mockOpenUrl = vi.fn(); +const mockRevealPath = vi.fn(); + vi.mock('../../utils/tauriCommands/vault', () => ({ openhumanVaultList: (...args: unknown[]) => mockList(...args), openhumanVaultCreate: (...args: unknown[]) => mockCreate(...args), @@ -22,6 +25,11 @@ vi.mock('../../utils/tauriCommands/vault', () => ({ openhumanVaultRemove: (...args: unknown[]) => mockRemove(...args), })); +vi.mock('../../utils/openUrl', () => ({ + openUrl: (...args: unknown[]) => mockOpenUrl(...args), + revealPath: (...args: unknown[]) => mockRevealPath(...args), +})); + function vault(overrides: Record = {}) { return { id: 'v-1', @@ -67,6 +75,8 @@ describe('', () => { mockSync.mockReset(); mockSyncStatus.mockReset(); mockRemove.mockReset(); + mockOpenUrl.mockReset(); + mockRevealPath.mockReset(); }); afterEach(() => { @@ -403,4 +413,57 @@ describe('', () => { ); confirmSpy.mockRestore(); }); + + it('open vault fires obsidian deep link and shows success toast', async () => { + mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); + mockOpenUrl.mockResolvedValueOnce(undefined); + const onToast = vi.fn(); + render(); + await waitFor(() => screen.getByTestId('vault-list')); + + fireEvent.click(screen.getByTestId('vault-open')); + await waitFor(() => + expect(mockOpenUrl).toHaveBeenCalledWith( + 'obsidian://open?path=' + encodeURIComponent('/Users/me/notes') + ) + ); + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'info', title: 'Opened in Obsidian' }) + ); + }); + + it('open vault falls back to revealPath when obsidian deep link fails', async () => { + mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); + mockOpenUrl.mockRejectedValueOnce(new Error('scheme not handled')); + mockRevealPath.mockResolvedValueOnce(undefined); + const onToast = vi.fn(); + render(); + await waitFor(() => screen.getByTestId('vault-list')); + + fireEvent.click(screen.getByTestId('vault-open')); + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + await waitFor(() => expect(mockRevealPath).toHaveBeenCalledWith('/Users/me/notes')); + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + title: 'Obsidian not found — opened in file manager', + }) + ); + }); + + it('open vault shows error toast when both obsidian and reveal fail', async () => { + mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); + mockOpenUrl.mockRejectedValueOnce(new Error('scheme not handled')); + mockRevealPath.mockRejectedValueOnce(new Error('permission denied')); + const onToast = vi.fn(); + render(); + await waitFor(() => screen.getByTestId('vault-list')); + + fireEvent.click(screen.getByTestId('vault-open')); + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + await waitFor(() => expect(mockRevealPath).toHaveBeenCalled()); + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', title: "Couldn't open vault" }) + ); + }); }); diff --git a/app/src/components/intelligence/VaultPanel.tsx b/app/src/components/intelligence/VaultPanel.tsx index dfe9ece7d..9311a732e 100644 --- a/app/src/components/intelligence/VaultPanel.tsx +++ b/app/src/components/intelligence/VaultPanel.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import type { ToastNotification } from '../../types/intelligence'; +import { openUrl, revealPath } from '../../utils/openUrl'; import { type CoreVault, type CoreVaultSyncState, @@ -240,6 +241,26 @@ export function VaultPanel({ onToast }: VaultPanelProps) { [onToast, reload, t] ); + const handleOpenVault = useCallback( + async (rootPath: string) => { + try { + await openUrl(`obsidian://open?path=${encodeURIComponent(rootPath)}`); + onToast?.({ type: 'info', title: t('vault.openSuccess'), message: rootPath }); + return; + } catch (err) { + console.error('[ui-flow][vault-panel] obsidian deep link failed', err); + } + try { + await revealPath(rootPath); + onToast?.({ type: 'info', title: t('vault.openFallback'), message: rootPath }); + } catch (err) { + console.error('[ui-flow][vault-panel] reveal vault failed', err); + onToast?.({ type: 'error', title: t('vault.openError'), message: String(err) }); + } + }, + [onToast, t] + ); + return (
+