fix: add Open button to knowledge vault rows with Obsidian deep link fallback (#2909) (#3014)

This commit is contained in:
Harinayan
2026-05-30 08:40:29 -07:00
committed by GitHub
parent 5517b1701e
commit a388a10c57
16 changed files with 150 additions and 0 deletions
@@ -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<string, unknown> = {}) {
return {
id: 'v-1',
@@ -67,6 +75,8 @@ describe('<VaultPanel />', () => {
mockSync.mockReset();
mockSyncStatus.mockReset();
mockRemove.mockReset();
mockOpenUrl.mockReset();
mockRevealPath.mockReset();
});
afterEach(() => {
@@ -403,4 +413,57 @@ describe('<VaultPanel />', () => {
);
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(<VaultPanel onToast={onToast} />);
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(<VaultPanel onToast={onToast} />);
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(<VaultPanel onToast={onToast} />);
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" })
);
});
});
@@ -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 (
<div
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-sm"
@@ -360,6 +381,16 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleOpenVault(v.root_path)}
disabled={state === 'sync' || state === 'remove'}
className="rounded-md border border-stone-300 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs
font-semibold text-stone-700 dark:text-neutral-200 shadow-sm transition-colors
hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50"
data-testid="vault-open">
{t('vault.openButton')}
</button>
<button
type="button"
onClick={() => void handleSync(v)}
+4
View File
@@ -3988,6 +3988,10 @@ const messages: TranslationMap = {
'vault.relative.min': 'قبل {count}m',
'vault.relative.hr': 'قبل {count} قبل',
'vault.relative.day': 'قبل {count}d',
'vault.openButton': 'فتح',
'vault.openSuccess': 'تم الفتح في Obsidian',
'vault.openFallback': 'لم يتم العثور على Obsidian — تم الفتح في مدير الملفات',
'vault.openError': 'تعذر فتح المخزن',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 دقائق',
'subconscious.interval.tenMinutes': '10 دقائق',
+4
View File
@@ -4053,6 +4053,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count}মি আগে',
'vault.relative.hr': '{count}সেকেন্ড আগে',
'vault.relative.day': '{count}দিন আগে',
'vault.openButton': 'Open',
'vault.openSuccess': 'Opened in Obsidian',
'vault.openFallback': 'Obsidian not found — opened in file manager',
'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 মিনিট',
'subconscious.interval.tenMinutes': '10 মিনিট',
+4
View File
@@ -4164,6 +4164,10 @@ const messages: TranslationMap = {
'vault.relative.min': 'vor {count}m',
'vault.relative.hr': 'vor {count}h',
'vault.relative.day': 'vor {count}d',
'vault.openButton': 'Open',
'vault.openSuccess': 'Opened in Obsidian',
'vault.openFallback': 'Obsidian not found — opened in file manager',
'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 Min.',
'subconscious.interval.tenMinutes': '10 Min.',
+4
View File
@@ -4269,6 +4269,10 @@ const en: TranslationMap = {
'vault.relative.min': '{count}m ago',
'vault.relative.hr': '{count}h ago',
'vault.relative.day': '{count}d ago',
'vault.openButton': 'Open',
'vault.openSuccess': 'Opened in Obsidian',
'vault.openFallback': 'Obsidian not found — opened in file manager',
'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
+4
View File
@@ -4129,6 +4129,10 @@ const messages: TranslationMap = {
'vault.relative.min': 'Hace {count}m',
'vault.relative.hr': 'Hace {count}h',
'vault.relative.day': 'Hace {count}d',
'vault.openButton': 'Open',
'vault.openSuccess': 'Opened in Obsidian',
'vault.openFallback': 'Obsidian not found — opened in file manager',
'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minutos',
'subconscious.interval.tenMinutes': '10 minutos',
+4
View File
@@ -4146,6 +4146,10 @@ const messages: TranslationMap = {
'vault.relative.min': 'il y a {count}m',
'vault.relative.hr': 'il y a {count}h',
'vault.relative.day': '{count}d',
'vault.openButton': 'Ouvrir',
'vault.openSuccess': 'Ouvert dans Obsidian',
'vault.openFallback': 'Obsidian introuvable — ouvert dans le gestionnaire de fichiers',
'vault.openError': 'Impossible d\u0027ouvrir le coffre',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
+4
View File
@@ -4062,6 +4062,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count}m पहले',
'vault.relative.hr': '{count}h पहले',
'vault.relative.day': '{count}d पहले',
'vault.openButton': 'Open',
'vault.openSuccess': 'Opened in Obsidian',
'vault.openFallback': 'Obsidian not found — opened in file manager',
'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 मिनट',
'subconscious.interval.tenMinutes': '10 मिनट',
+4
View File
@@ -4072,6 +4072,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count}m yang lalu',
'vault.relative.hr': '{count}h yang lalu',
'vault.relative.day': '{count}d yang lalu',
'vault.openButton': 'Buka',
'vault.openSuccess': 'Dibuka di Obsidian',
'vault.openFallback': 'Obsidian tidak ditemukan — dibuka di pengelola file',
'vault.openError': 'Tidak dapat membuka vault',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 menit',
'subconscious.interval.tenMinutes': '10 menit',
+4
View File
@@ -4123,6 +4123,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count}m fa',
'vault.relative.hr': '{count}h fa',
'vault.relative.day': '{count}d fa',
'vault.openButton': 'Open',
'vault.openSuccess': 'Opened in Obsidian',
'vault.openFallback': 'Obsidian not found — opened in file manager',
'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minuti',
'subconscious.interval.tenMinutes': '10 minuti',
+4
View File
@@ -4023,6 +4023,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count}분 전',
'vault.relative.hr': '{count}시간 전',
'vault.relative.day': '{count}일 전',
'vault.openButton': '열기',
'vault.openSuccess': 'Obsidian에서 열림',
'vault.openFallback': 'Obsidian을 찾을 수 없음 — 파일 관리자에서 열림',
'vault.openError': '볼트를 열 수 없습니다',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5분',
'subconscious.interval.tenMinutes': '10분',
+4
View File
@@ -4125,6 +4125,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count} min temu',
'vault.relative.hr': '{count} godz. temu',
'vault.relative.day': '{count} dni temu',
'vault.openButton': 'Open',
'vault.openSuccess': 'Opened in Obsidian',
'vault.openFallback': 'Obsidian not found — opened in file manager',
'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
+4
View File
@@ -4124,6 +4124,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count}m atrás',
'vault.relative.hr': '{count}h atrás',
'vault.relative.day': '{count}d atrás',
'vault.openButton': 'Open',
'vault.openSuccess': 'Opened in Obsidian',
'vault.openFallback': 'Obsidian not found — opened in file manager',
'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minutos',
'subconscious.interval.tenMinutes': '10 minutos',
+4
View File
@@ -4092,6 +4092,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count}m назад',
'vault.relative.hr': '{count}h назад',
'vault.relative.day': '{count}d назад',
'vault.openButton': 'Открыть',
'vault.openSuccess': 'Открыто в Obsidian',
'vault.openFallback': 'Obsidian не найден — открыто в файловом менеджере',
'vault.openError': 'Не удалось открыть хранилище',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 минут',
'subconscious.interval.tenMinutes': '10 минут',
+4
View File
@@ -3865,6 +3865,10 @@ const messages: TranslationMap = {
'vault.relative.min': '{count}分钟前',
'vault.relative.hr': '{count} 小时前',
'vault.relative.day': '{count} 天前',
'vault.openButton': '打开',
'vault.openSuccess': '已在 Obsidian 中打开',
'vault.openFallback': '未找到 Obsidian — 已在文件管理器中打开',
'vault.openError': '无法打开保管库',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5分钟',
'subconscious.interval.tenMinutes': '10分钟',