mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
fix(memory-workspace): detect Obsidian vault registration before deep link (#2638)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0e4729e7f2
commit
e05cab9bb2
@@ -29,7 +29,6 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import {
|
||||
type GraphExportResponse,
|
||||
type GraphMode,
|
||||
@@ -38,10 +37,9 @@ import {
|
||||
memoryTreeResetTree,
|
||||
memoryTreeWipeAll,
|
||||
} from '../../utils/tauriCommands';
|
||||
import { revealWorkspacePath } from '../../utils/tauriCommands/workspacePaths';
|
||||
import { MemoryGraph } from './MemoryGraph';
|
||||
import { MemorySources } from './MemorySources';
|
||||
import { MEMORY_CONTENT_WORKSPACE_PATH } from './memoryWorkspacePaths';
|
||||
import { ObsidianVaultSection } from './ObsidianVaultSection';
|
||||
import { VaultPanel } from './VaultPanel';
|
||||
import { WhatsAppMemorySection } from './WhatsAppMemorySection';
|
||||
|
||||
@@ -62,34 +60,6 @@ interface MemoryWorkspaceProps {
|
||||
*/
|
||||
const SYNCABLE_TOOLKITS: ReadonlySet<string> = new Set(['gmail']);
|
||||
|
||||
/**
|
||||
* Trigger the `obsidian://open?path=<abs>` deep link via the OS shell.
|
||||
*
|
||||
* We deliberately route through `openUrl` (which delegates to
|
||||
* `tauri-plugin-opener`) rather than setting `window.location.href`.
|
||||
* The webview-host intent handler intercepts in-app navigations and
|
||||
* does NOT punt custom schemes to the OS, so a direct
|
||||
* `window.location.href = "obsidian://…"` either no-ops or navigates
|
||||
* the React app away from the Memory tab. The opener plugin hands the
|
||||
* URL straight to the system handler so Obsidian launches as a
|
||||
* separate process.
|
||||
*
|
||||
* Returns `null` on success, or the underlying error otherwise. The
|
||||
* caller decides how to surface the outcome (toast, fallback, …); an
|
||||
* unsurfaced no-op is the bug #2281 originally reported.
|
||||
*/
|
||||
async function openVaultInObsidian(contentRootAbs: string): Promise<unknown | null> {
|
||||
const url = `obsidian://open?path=${encodeURIComponent(contentRootAbs)}`;
|
||||
console.debug('[ui-flow][memory-workspace] open vault in Obsidian url=%s', url);
|
||||
try {
|
||||
await openUrl(url);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[ui-flow][memory-workspace] openUrl failed', err);
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
const { t } = useT();
|
||||
const [graph, setGraph] = useState<GraphExportResponse | null>(null);
|
||||
@@ -242,48 +212,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
}
|
||||
}, [onToast, mode]);
|
||||
|
||||
// #2281: clicking "View Vault" must never silently no-op. Some
|
||||
// users don't have Obsidian installed, in which case the OS shell
|
||||
// accepts the `obsidian://` URL and does nothing visible. We always
|
||||
// emit a toast that names the vault path AND offers a "Reveal
|
||||
// Folder" action so the user has an OS-native way to inspect the
|
||||
// vault even without Obsidian.
|
||||
const handleViewVault = useCallback(
|
||||
async (contentRootAbs: string) => {
|
||||
const revealHandler = () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await revealWorkspacePath(MEMORY_CONTENT_WORKSPACE_PATH);
|
||||
} catch (err) {
|
||||
console.error('[ui-flow][memory-workspace] revealWorkspacePath failed', err);
|
||||
onToast?.({
|
||||
type: 'error',
|
||||
title: t('workspace.revealVaultFailed'),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
})();
|
||||
};
|
||||
const err = await openVaultInObsidian(contentRootAbs);
|
||||
if (err === null) {
|
||||
onToast?.({
|
||||
type: 'info',
|
||||
title: t('workspace.openingVaultTitle'),
|
||||
message: `${t('workspace.openingVaultMessage')} ${contentRootAbs}`,
|
||||
action: { label: t('workspace.revealFolder'), handler: revealHandler },
|
||||
});
|
||||
} else {
|
||||
onToast?.({
|
||||
type: 'error',
|
||||
title: t('workspace.openVaultFailedTitle'),
|
||||
message: `${t('workspace.openVaultFailedMessage')} ${contentRootAbs}`,
|
||||
action: { label: t('workspace.revealFolder'), handler: revealHandler },
|
||||
});
|
||||
}
|
||||
},
|
||||
[onToast, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="memory-workspace">
|
||||
<MemorySources syncableToolkits={SYNCABLE_TOOLKITS} pollIntervalMs={5000} onToast={onToast} />
|
||||
@@ -358,18 +286,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
)}
|
||||
</button>
|
||||
{graph && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleViewVault(graph.content_root_abs)}
|
||||
data-testid="memory-open-in-obsidian"
|
||||
className="inline-flex items-center gap-2 rounded-lg
|
||||
bg-violet-500 px-4 py-2 text-sm font-semibold text-white
|
||||
shadow-sm transition-colors hover:bg-violet-600
|
||||
focus:outline-none focus:ring-2 focus:ring-violet-300"
|
||||
title={`obsidian://open?path=${graph.content_root_abs}`}>
|
||||
<ExternalLinkIcon />
|
||||
{t('workspace.viewVault')}
|
||||
</button>
|
||||
<ObsidianVaultSection contentRootAbs={graph.content_root_abs} onToast={onToast} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -491,25 +408,6 @@ function BrainIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M14 3h7v7" />
|
||||
<path d="M10 14L21 3" />
|
||||
<path d="M21 14v7H3V3h7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Inline "open your memory vault in Obsidian" control for the Memory tab.
|
||||
*
|
||||
* The vault is the on-disk `<workspace>/memory_tree/content/` folder. Opening
|
||||
* it uses an `obsidian://open?path=…` deep link — but that scheme only
|
||||
* resolves folders Obsidian has already registered as a vault (it cannot
|
||||
* register one, and a `.obsidian/` folder on disk is not enough). So a
|
||||
* first-time click would otherwise land on Obsidian's *"Unable to find a vault
|
||||
* for the URL"* error.
|
||||
*
|
||||
* Flow (progressive disclosure):
|
||||
* - Click → check registration via `memory_tree_obsidian_vault_status`.
|
||||
* - Registered → fire the deep link (success toast). Done.
|
||||
* - Not registered → expand inline guidance instead of firing a doomed link:
|
||||
* Reveal Folder · Open anyway · Install Obsidian · Advanced ▸ config-dir
|
||||
* override.
|
||||
*
|
||||
* Detection is best-effort — Obsidian can live somewhere we can't probe
|
||||
* (Flatpak/Snap/portable). "Open anyway" and the config-dir override are the
|
||||
* escape hatches for that case; a false "not registered" never blocks the user.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import { memoryTreeObsidianVaultStatus } from '../../utils/tauriCommands';
|
||||
import { revealWorkspacePath } from '../../utils/tauriCommands/workspacePaths';
|
||||
import { MEMORY_CONTENT_WORKSPACE_PATH } from './memoryWorkspacePaths';
|
||||
|
||||
/** localStorage key for the optional Obsidian config-dir override. */
|
||||
const CONFIG_DIR_KEY = 'openhuman.obsidian.configDir';
|
||||
const OBSIDIAN_DOWNLOAD_URL = 'https://obsidian.md/download';
|
||||
|
||||
interface ObsidianVaultSectionProps {
|
||||
/** Absolute path to `<workspace>/memory_tree/content/` (from graph export). */
|
||||
contentRootAbs: string;
|
||||
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
|
||||
}
|
||||
|
||||
function readConfigDirOverride(): string {
|
||||
try {
|
||||
return localStorage.getItem(CONFIG_DIR_KEY) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultSectionProps) {
|
||||
const { t } = useT();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// null = not probed yet / probe failed; otherwise last `config_found`.
|
||||
const [configFound, setConfigFound] = useState<boolean | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [configDir, setConfigDir] = useState<string>(readConfigDirOverride);
|
||||
|
||||
/** Build + fire the `obsidian://` deep link. Resolves to an error or null. */
|
||||
const fireDeepLink = useCallback(async (): Promise<unknown | null> => {
|
||||
const url = `obsidian://open?path=${encodeURIComponent(contentRootAbs)}`;
|
||||
console.debug('[ui-flow][obsidian-vault] firing deep link');
|
||||
try {
|
||||
await openUrl(url);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[ui-flow][obsidian-vault] openUrl failed', err);
|
||||
return err;
|
||||
}
|
||||
}, [contentRootAbs]);
|
||||
|
||||
const reveal = useCallback(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await revealWorkspacePath(MEMORY_CONTENT_WORKSPACE_PATH);
|
||||
} catch (err) {
|
||||
console.error('[ui-flow][obsidian-vault] revealWorkspacePath failed', err);
|
||||
onToast?.({
|
||||
type: 'error',
|
||||
title: t('workspace.revealVaultFailed'),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [onToast, t]);
|
||||
|
||||
const toastOpenOutcome = useCallback(
|
||||
(err: unknown | null) => {
|
||||
onToast?.(
|
||||
err === null
|
||||
? {
|
||||
type: 'info',
|
||||
title: t('workspace.openingVaultTitle'),
|
||||
message: `${t('workspace.openingVaultMessage')} ${contentRootAbs}`,
|
||||
action: { label: t('workspace.revealFolder'), handler: reveal },
|
||||
}
|
||||
: {
|
||||
type: 'error',
|
||||
title: t('workspace.openVaultFailedTitle'),
|
||||
message: `${t('workspace.openVaultFailedMessage')} ${contentRootAbs}`,
|
||||
action: { label: t('workspace.revealFolder'), handler: reveal },
|
||||
}
|
||||
);
|
||||
},
|
||||
[onToast, t, contentRootAbs, reveal]
|
||||
);
|
||||
|
||||
/** Fire the deep link unconditionally — the "Open anyway" escape hatch. */
|
||||
const openAnyway = useCallback(() => {
|
||||
void (async () => {
|
||||
toastOpenOutcome(await fireDeepLink());
|
||||
})();
|
||||
}, [fireDeepLink, toastOpenOutcome]);
|
||||
|
||||
const installObsidian = useCallback(() => {
|
||||
// https URL → openUrl falls back to window.open if the IPC bridge isn't
|
||||
// ready, so this normally reaches the download page. Swallow + log any
|
||||
// rejection so it can't surface as an unhandled promise rejection.
|
||||
void (async () => {
|
||||
try {
|
||||
await openUrl(OBSIDIAN_DOWNLOAD_URL);
|
||||
} catch (err) {
|
||||
console.error('[ui-flow][obsidian-vault] openUrl(download) failed', err);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleViewVault = useCallback(() => {
|
||||
void (async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const override = configDir.trim();
|
||||
const status = await memoryTreeObsidianVaultStatus(override || undefined);
|
||||
console.debug(
|
||||
'[ui-flow][obsidian-vault] status registered=%s config_found=%s',
|
||||
status.registered,
|
||||
status.config_found
|
||||
);
|
||||
setConfigFound(status.config_found);
|
||||
|
||||
if (status.registered) {
|
||||
// Known vault — open it directly.
|
||||
const err = await fireDeepLink();
|
||||
toastOpenOutcome(err);
|
||||
// Registered but the IPC/scheme still failed → surface guidance;
|
||||
// on success collapse any stale panel from a prior failed check.
|
||||
setExpanded(err !== null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not registered — guide the user instead of firing a doomed link.
|
||||
setExpanded(true);
|
||||
} catch (err) {
|
||||
// Detection itself failed — degrade gracefully to the guidance panel.
|
||||
console.error('[ui-flow][obsidian-vault] status check failed', err);
|
||||
setConfigFound(null);
|
||||
setExpanded(true);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
})();
|
||||
}, [configDir, fireDeepLink, toastOpenOutcome]);
|
||||
|
||||
const saveConfigDir = useCallback(() => {
|
||||
const trimmed = configDir.trim();
|
||||
try {
|
||||
if (trimmed) localStorage.setItem(CONFIG_DIR_KEY, trimmed);
|
||||
else localStorage.removeItem(CONFIG_DIR_KEY);
|
||||
} catch (err) {
|
||||
console.warn('[ui-flow][obsidian-vault] persist config dir failed', err);
|
||||
}
|
||||
// Re-run the check with the new override applied.
|
||||
handleViewVault();
|
||||
}, [configDir, handleViewVault]);
|
||||
|
||||
const helpText =
|
||||
configFound === false
|
||||
? t('workspace.obsidianNotFoundHelp')
|
||||
: t('workspace.vaultNotRegisteredHelp');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2" data-testid="obsidian-vault-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewVault}
|
||||
disabled={checking}
|
||||
data-testid="memory-open-in-obsidian"
|
||||
className="inline-flex items-center gap-2 rounded-lg
|
||||
bg-violet-500 px-4 py-2 text-sm font-semibold text-white
|
||||
shadow-sm transition-colors hover:bg-violet-600
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
focus:outline-none focus:ring-2 focus:ring-violet-300"
|
||||
title={`obsidian://open?path=${contentRootAbs}`}>
|
||||
<ExternalLinkIcon />
|
||||
{checking ? t('workspace.checkingVault') : t('workspace.viewVault')}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
data-testid="obsidian-vault-guidance"
|
||||
className="w-full max-w-xl rounded-lg border border-violet-200 bg-violet-50 p-4
|
||||
text-sm dark:border-violet-500/30 dark:bg-violet-500/10">
|
||||
<p className="text-neutral-700 dark:text-neutral-200">{helpText}</p>
|
||||
|
||||
<code
|
||||
className="mt-2 block break-all rounded bg-white/70 px-2 py-1 font-mono text-xs
|
||||
text-neutral-600 dark:bg-neutral-900/60 dark:text-neutral-300"
|
||||
data-testid="obsidian-vault-path">
|
||||
{contentRootAbs}
|
||||
</code>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reveal}
|
||||
data-testid="obsidian-reveal"
|
||||
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-semibold
|
||||
text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600
|
||||
dark:bg-neutral-800 dark:text-neutral-200">
|
||||
{t('workspace.revealFolder')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAnyway}
|
||||
data-testid="obsidian-open-anyway"
|
||||
className="rounded-md border border-violet-300 bg-white px-3 py-1.5 text-xs font-semibold
|
||||
text-violet-700 hover:bg-violet-50 dark:border-violet-500/40
|
||||
dark:bg-neutral-800 dark:text-violet-300">
|
||||
{t('workspace.openAnyway')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={installObsidian}
|
||||
data-testid="obsidian-install"
|
||||
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-semibold
|
||||
text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600
|
||||
dark:bg-neutral-800 dark:text-neutral-200">
|
||||
{t('workspace.installObsidian')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(v => !v)}
|
||||
data-testid="obsidian-advanced-toggle"
|
||||
className="mt-3 text-xs font-medium text-violet-600 hover:underline dark:text-violet-300">
|
||||
{t('workspace.obsidianAdvanced')}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<label
|
||||
htmlFor="obsidian-config-dir"
|
||||
className="block text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{t('workspace.obsidianConfigDirLabel')}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
id="obsidian-config-dir"
|
||||
type="text"
|
||||
value={configDir}
|
||||
onChange={e => setConfigDir(e.target.value)}
|
||||
placeholder={t('workspace.obsidianConfigDirPlaceholder')}
|
||||
spellCheck={false}
|
||||
data-testid="obsidian-config-dir-input"
|
||||
className="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-mono text-xs
|
||||
text-neutral-800 focus:outline-none focus:ring-1 focus:ring-violet-300
|
||||
dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveConfigDir}
|
||||
disabled={checking}
|
||||
data-testid="obsidian-config-dir-save"
|
||||
className="rounded-md bg-violet-500 px-3 py-1 text-xs font-semibold text-white
|
||||
hover:bg-violet-600 disabled:opacity-50">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workspace.obsidianConfigDirHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M14 3h7v7" />
|
||||
<path d="M10 14L21 3" />
|
||||
<path d="M21 14v7H3V3h7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ vi.mock('../../../utils/tauriCommands', () => ({
|
||||
memoryTreeFlushNow: vi.fn(),
|
||||
memoryTreeWipeAll: vi.fn(),
|
||||
memoryTreeResetTree: vi.fn(),
|
||||
memoryTreeObsidianVaultStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/memorySyncService', () => ({
|
||||
@@ -45,13 +46,19 @@ vi.mock('../../../utils/tauriCommands/workspacePaths', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { memoryTreeGraphExport, memoryTreeFlushNow, memoryTreeWipeAll, memoryTreeResetTree } =
|
||||
(await import('../../../utils/tauriCommands')) as unknown as {
|
||||
memoryTreeGraphExport: Mock;
|
||||
memoryTreeFlushNow: Mock;
|
||||
memoryTreeWipeAll: Mock;
|
||||
memoryTreeResetTree: Mock;
|
||||
};
|
||||
const {
|
||||
memoryTreeGraphExport,
|
||||
memoryTreeFlushNow,
|
||||
memoryTreeWipeAll,
|
||||
memoryTreeResetTree,
|
||||
memoryTreeObsidianVaultStatus,
|
||||
} = (await import('../../../utils/tauriCommands')) as unknown as {
|
||||
memoryTreeGraphExport: Mock;
|
||||
memoryTreeFlushNow: Mock;
|
||||
memoryTreeWipeAll: Mock;
|
||||
memoryTreeResetTree: Mock;
|
||||
memoryTreeObsidianVaultStatus: Mock;
|
||||
};
|
||||
|
||||
const { listConnections, syncConnection } =
|
||||
(await import('../../../lib/composio/composioApi')) as unknown as {
|
||||
@@ -115,6 +122,14 @@ describe('MemoryWorkspace (graph view)', () => {
|
||||
openUrl.mockResolvedValue(undefined);
|
||||
openWorkspacePath.mockResolvedValue(undefined);
|
||||
revealWorkspacePath.mockResolvedValue(undefined);
|
||||
// Default: the content root is already a registered Obsidian vault, so a
|
||||
// View-Vault click opens it directly (the not-registered guidance branch
|
||||
// is covered in ObsidianVaultSection.test.tsx).
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue({
|
||||
registered: true,
|
||||
config_found: true,
|
||||
content_root_abs: '/tmp/workspace/memory_tree/content',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the SVG graph once the export RPC resolves', async () => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { ObsidianVaultSection } from '../ObsidianVaultSection';
|
||||
|
||||
vi.mock('../../../utils/tauriCommands', () => ({ memoryTreeObsidianVaultStatus: vi.fn() }));
|
||||
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/workspacePaths', () => ({
|
||||
revealWorkspacePath: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { memoryTreeObsidianVaultStatus } =
|
||||
(await import('../../../utils/tauriCommands')) as unknown as {
|
||||
memoryTreeObsidianVaultStatus: Mock;
|
||||
};
|
||||
|
||||
const { openUrl } = (await import('../../../utils/openUrl')) as unknown as { openUrl: Mock };
|
||||
|
||||
const { revealWorkspacePath } =
|
||||
(await import('../../../utils/tauriCommands/workspacePaths')) as unknown as {
|
||||
revealWorkspacePath: Mock;
|
||||
};
|
||||
|
||||
const ROOT = '/tmp/workspace/memory_tree/content';
|
||||
const DEEP_LINK = 'obsidian://open?path=' + encodeURIComponent(ROOT);
|
||||
|
||||
function status(over: Partial<{ registered: boolean; config_found: boolean }> = {}) {
|
||||
return { registered: false, config_found: true, content_root_abs: ROOT, ...over };
|
||||
}
|
||||
|
||||
describe('ObsidianVaultSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
openUrl.mockResolvedValue(undefined);
|
||||
revealWorkspacePath.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('registered vault → opens the deep link directly, no guidance shown', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status({ registered: true }));
|
||||
const onToast = vi.fn();
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} onToast={onToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(DEEP_LINK));
|
||||
expect(screen.queryByTestId('obsidian-vault-guidance')).toBeNull();
|
||||
await waitFor(() => expect(onToast).toHaveBeenCalled());
|
||||
expect(onToast.mock.calls[0][0].type).toBe('info');
|
||||
});
|
||||
|
||||
it('unregistered vault → no deep link, shows guidance with the vault path', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('obsidian-vault-guidance')).toBeInTheDocument());
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('obsidian-vault-path')).toHaveTextContent(ROOT);
|
||||
});
|
||||
|
||||
it('"Open anyway" fires the deep link even when unregistered', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
const onToast = vi.fn();
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} onToast={onToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
const openAnyway = await screen.findByTestId('obsidian-open-anyway');
|
||||
fireEvent.click(openAnyway);
|
||||
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(DEEP_LINK));
|
||||
});
|
||||
|
||||
it('"Reveal Folder" in the guidance panel reveals the content root', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
fireEvent.click(await screen.findByTestId('obsidian-reveal'));
|
||||
|
||||
await waitFor(() => expect(revealWorkspacePath).toHaveBeenCalledWith('memory_tree/content'));
|
||||
});
|
||||
|
||||
it('config not found → Install Obsidian opens the download page', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status({ config_found: false }));
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
fireEvent.click(await screen.findByTestId('obsidian-install'));
|
||||
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith('https://obsidian.md/download'));
|
||||
});
|
||||
|
||||
it('Advanced config-dir override persists to localStorage and re-checks with it', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
// First click → not registered → guidance.
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
fireEvent.click(await screen.findByTestId('obsidian-advanced-toggle'));
|
||||
|
||||
const input = await screen.findByTestId('obsidian-config-dir-input');
|
||||
fireEvent.change(input, { target: { value: '/custom/obsidian' } });
|
||||
fireEvent.click(screen.getByTestId('obsidian-config-dir-save'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(memoryTreeObsidianVaultStatus).toHaveBeenLastCalledWith('/custom/obsidian')
|
||||
);
|
||||
expect(localStorage.getItem('openhuman.obsidian.configDir')).toBe('/custom/obsidian');
|
||||
});
|
||||
|
||||
it('detection failure degrades gracefully to the guidance panel', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockRejectedValue(new Error('rpc down'));
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('obsidian-vault-guidance')).toBeInTheDocument());
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user