fix: recover when active vault disappears
This commit is contained in:
@@ -367,7 +367,7 @@ Command-facing vault content is filtered through `vault::filter_gitignored_entri
|
||||
|
||||
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
|
||||
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
|
||||
@@ -490,6 +490,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
|
||||
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
|
||||
|
||||
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
|
||||
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
@@ -556,6 +558,11 @@ sequenceDiagram
|
||||
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
alt Runtime vault path disappears
|
||||
VL->>T: invoke('check_vault_exists')
|
||||
VL-->>A: unavailable vault path + cleared stale state
|
||||
A-->>U: WelcomeScreen (vault missing)
|
||||
end
|
||||
A->>T: useMcpStatus — check explicit MCP setup state
|
||||
A->>T: sync_mcp_bridge_vault(selected path)
|
||||
VL-->>A: entries ready
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -108,6 +108,7 @@ import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { isActiveVaultUnavailableError } from './utils/vaultErrors'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { filenameStemToTitle } from './utils/noteTitle'
|
||||
import {
|
||||
@@ -375,6 +376,7 @@ function App() {
|
||||
}, [resolvedPath, setToastMessage])
|
||||
|
||||
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
|
||||
const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
|
||||
const {
|
||||
markInternalWrite: markRecentVaultWrite,
|
||||
filterExternalPaths: filterExternalVaultPaths,
|
||||
@@ -578,6 +580,9 @@ function App() {
|
||||
markRecentVaultWrite(path)
|
||||
vault.loadModifiedFiles()
|
||||
}, [markRecentVaultWrite, vault])
|
||||
const handleMissingActiveVault = useCallback(() => {
|
||||
if (!noteWindowParams && resolvedPath) vault.markVaultUnavailable(resolvedPath)
|
||||
}, [noteWindowParams, resolvedPath, vault])
|
||||
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
@@ -596,6 +601,7 @@ function App() {
|
||||
unsavedPaths: vault.unsavedPaths,
|
||||
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
|
||||
onNewNotePersisted: handleCreatedVaultEntryPersisted,
|
||||
onMissingActiveVault: handleMissingActiveVault,
|
||||
onTypeStateChanged: async () => { await vault.reloadVault() },
|
||||
replaceEntry: vault.replaceEntry,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
@@ -1169,7 +1175,15 @@ function App() {
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
try {
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
} catch (err) {
|
||||
if (isActiveVaultUnavailableError(err)) {
|
||||
vault.markVaultUnavailable(resolvedPath)
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
await vault.reloadViews()
|
||||
await vault.reloadVault()
|
||||
vault.reloadFolders()
|
||||
@@ -1636,8 +1650,17 @@ function App() {
|
||||
}
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = shouldResumeFreshStartOnboarding
|
||||
if (!noteWindowParams && (runtimeMissingVaultPath || onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = runtimeMissingVaultPath
|
||||
? {
|
||||
...onboarding,
|
||||
state: {
|
||||
status: 'vault-missing' as const,
|
||||
vaultPath: runtimeMissingVaultPath,
|
||||
defaultPath: vaultSwitcher.defaultPath || runtimeMissingVaultPath,
|
||||
},
|
||||
}
|
||||
: shouldResumeFreshStartOnboarding
|
||||
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
|
||||
: onboarding
|
||||
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { markNoteOpenTrace } from '../utils/noteOpenPerformance'
|
||||
import { errorMessage, isActiveVaultUnavailableError } from '../utils/vaultErrors'
|
||||
import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode'
|
||||
|
||||
type NotePath = VaultEntry['path']
|
||||
@@ -273,14 +274,8 @@ export async function loadContentForOpen(options: {
|
||||
return loadNoteContent(entry, forceReload || hasResolvedCachedContent(cachedEntry))
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function isNoActiveVaultSelectedError(error: unknown): boolean {
|
||||
return /no active vault selected/i.test(errorMessage(error))
|
||||
return isActiveVaultUnavailableError(error)
|
||||
}
|
||||
|
||||
export function isUnreadableNoteContentError(error: unknown): boolean {
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface NoteActionsConfig {
|
||||
onNewNotePersisted?: (path: string) => void
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** Called when note loading proves the active vault path is no longer usable. */
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
|
||||
onFrontmatterContentChanged?: (path: string, content: string) => void
|
||||
/** Called after a frontmatter mutation is fully persisted, including follow-up renames. */
|
||||
@@ -194,15 +196,19 @@ async function updateFrontmatterAndMaybeRename({
|
||||
}
|
||||
|
||||
function buildTabManagementOptions(
|
||||
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'reloadVault' | 'setToastMessage' | 'unsavedPaths'>,
|
||||
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'onMissingActiveVault' | 'reloadVault' | 'setToastMessage' | 'unsavedPaths'>,
|
||||
) {
|
||||
const options: {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
hasUnsavedChanges: (path: string) => boolean
|
||||
onMissingActiveVault: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath: (entry: VaultEntry) => void
|
||||
onUnreadableNoteContent: (entry: VaultEntry) => void
|
||||
} = {
|
||||
hasUnsavedChanges: (path) => config.unsavedPaths?.has(path) ?? false,
|
||||
onMissingActiveVault: (entry, error) => {
|
||||
void config.onMissingActiveVault?.(entry, error)
|
||||
},
|
||||
onMissingNotePath: (entry) => {
|
||||
const label = entry.title.trim() || entry.filename
|
||||
config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)
|
||||
|
||||
@@ -258,6 +258,23 @@ describe('useTabManagement (single-note model)', () => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reports an unavailable active vault instead of opening a blank stale tab', async () => {
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('Active vault is not available'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const onMissingActiveVault = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ onMissingActiveVault }))
|
||||
await selectNote(result, { path: '/vault/note/orphaned.md', title: 'Orphaned Note' })
|
||||
|
||||
expect(result.current.tabs).toEqual([])
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
expect(onMissingActiveVault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/vault/note/orphaned.md', title: 'Orphaned Note' }),
|
||||
expect.any(Error),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses the note-window vault path when Tauri reloads the selected note', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockResolvedValue('# Window content')
|
||||
|
||||
@@ -51,6 +51,7 @@ export type { Tab }
|
||||
interface TabManagementOptions {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
hasUnsavedChanges?: (path: string) => boolean
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}
|
||||
@@ -64,6 +65,7 @@ interface NavigateToEntryOptions {
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
hasUnsavedChanges?: (path: string) => boolean
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}
|
||||
@@ -252,6 +254,7 @@ function handleRecoverableEntryLoadFailure(options: {
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
error: unknown
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}) {
|
||||
@@ -263,6 +266,7 @@ function handleRecoverableEntryLoadFailure(options: {
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
error,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
} = options
|
||||
@@ -279,6 +283,16 @@ function handleRecoverableEntryLoadFailure(options: {
|
||||
})
|
||||
failNoteOpenTrace(entry.path, kind)
|
||||
|
||||
if (kind === 'missing-active-vault') {
|
||||
runEntryFailureCallback({
|
||||
callback: onMissingActiveVault,
|
||||
entry,
|
||||
error,
|
||||
warning: 'Failed to handle missing active vault:',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'missing-path') {
|
||||
runEntryFailureCallback({
|
||||
callback: onMissingNotePath,
|
||||
@@ -308,6 +322,7 @@ function handleEntryLoadFailure(options: {
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
error: unknown
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}) {
|
||||
@@ -320,6 +335,7 @@ function handleEntryLoadFailure(options: {
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
error,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
} = options
|
||||
@@ -337,6 +353,7 @@ function handleEntryLoadFailure(options: {
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
error,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
})
|
||||
@@ -370,6 +387,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
} = options
|
||||
@@ -409,6 +427,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
error: err,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
})
|
||||
@@ -443,6 +462,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
const beforeNavigateSeqRef = useRef(0)
|
||||
const beforeNavigate = options.beforeNavigate
|
||||
const hasUnsavedChanges = options.hasUnsavedChanges
|
||||
const onMissingActiveVault = options.onMissingActiveVault
|
||||
const onMissingNotePath = options.onMissingNotePath
|
||||
const onUnreadableNoteContent = options.onUnreadableNoteContent
|
||||
|
||||
@@ -484,13 +504,14 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
}))
|
||||
if (!navigated) {
|
||||
resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path)
|
||||
}
|
||||
}, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingNotePath, onUnreadableNoteContent])
|
||||
}, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent])
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => {
|
||||
requestedActiveTabPathRef.current = path
|
||||
@@ -523,13 +544,14 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
}))
|
||||
if (!navigated) {
|
||||
resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path)
|
||||
}
|
||||
}, [executeNavigationWithBoundary, onMissingNotePath, onUnreadableNoteContent])
|
||||
}, [executeNavigationWithBoundary, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent])
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
tabsRef.current = []
|
||||
|
||||
60
src/hooks/useUnavailableVaultState.ts
Normal file
60
src/hooks/useUnavailableVaultState.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { clearPrefetchCache } from './useTabManagement'
|
||||
import { resetVaultState, type VaultStateResetOptions } from './vaultStateReset'
|
||||
|
||||
interface UnavailableVaultStateOptions extends VaultStateResetOptions {
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
export function useUnavailableVaultState(options: UnavailableVaultStateOptions) {
|
||||
const [unavailableVaultPath, setUnavailableVaultPath] = useState<string | null>(null)
|
||||
const {
|
||||
clearNewPaths,
|
||||
clearUnsaved,
|
||||
isCurrentVaultPath,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
vaultPath,
|
||||
} = options
|
||||
|
||||
const markVaultUnavailable = useCallback((path: string) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
clearPrefetchCache()
|
||||
resetVaultState({
|
||||
clearNewPaths,
|
||||
clearUnsaved,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
})
|
||||
setUnavailableVaultPath(path)
|
||||
}, [
|
||||
clearNewPaths,
|
||||
clearUnsaved,
|
||||
isCurrentVaultPath,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
])
|
||||
|
||||
const markVaultAvailable = useCallback((path: string) => {
|
||||
if (isCurrentVaultPath(path)) setUnavailableVaultPath(null)
|
||||
}, [isCurrentVaultPath])
|
||||
|
||||
return {
|
||||
markVaultAvailable,
|
||||
markVaultUnavailable,
|
||||
unavailableVaultPath: unavailableVaultPath === vaultPath ? unavailableVaultPath : null,
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,30 @@ describe('useVaultLoader', () => {
|
||||
expect(issuedCommands).not.toContain('list_vault')
|
||||
})
|
||||
|
||||
it('marks the vault unavailable when the initial load finds a missing active vault', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.reject(new Error('No such file or directory'))
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
|
||||
if (cmd === 'list_vault_folders') return Promise.reject(new Error('Active vault is not available'))
|
||||
if (cmd === 'list_views') return Promise.reject(new Error('Active vault is not available'))
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.unavailableVaultPath).toBe('/vault')
|
||||
})
|
||||
expect(result.current.entries).toEqual([])
|
||||
expect(result.current.folders).toEqual([])
|
||||
expect(result.current.views).toEqual([])
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores stale reload_vault results after the vault path changes', async () => {
|
||||
await enableTauriMode()
|
||||
const firstLoad = createDeferred<VaultEntry[]>()
|
||||
@@ -919,6 +943,46 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.entries).toEqual(mockEntries)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears stale entries and marks the vault unavailable when the active vault disappears', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const initialViews = [{
|
||||
filename: 'work.yml',
|
||||
definition: {
|
||||
name: 'Work',
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
},
|
||||
}]
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'reload_vault') return Promise.reject(new Error('No such file or directory'))
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([{ name: 'note', path: '/vault/note', children: [] }])
|
||||
if (cmd === 'list_views') return Promise.resolve(initialViews)
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = await renderVaultLoader()
|
||||
await waitFor(() => expect(result.current.views).toHaveLength(1))
|
||||
|
||||
let entries: VaultEntry[] = []
|
||||
await act(async () => {
|
||||
entries = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(entries).toEqual([])
|
||||
expect(result.current.entries).toEqual([])
|
||||
expect(result.current.folders).toEqual([])
|
||||
expect(result.current.views).toEqual([])
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
expect(result.current.unavailableVaultPath).toBe('/vault')
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reloadViews', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../lib/gitignoredVisibilityEvents'
|
||||
import { clearPrefetchCache } from './useTabManagement'
|
||||
import {
|
||||
checkVaultPathAvailability,
|
||||
commitWithPush,
|
||||
hasVaultPath,
|
||||
loadVaultChrome,
|
||||
@@ -17,55 +18,91 @@ import {
|
||||
tauriCall,
|
||||
} from './vaultLoaderCommands'
|
||||
import { normalizeVaultEntry } from '../utils/vaultMetadataNormalization'
|
||||
import { useUnavailableVaultState } from './useUnavailableVaultState'
|
||||
import { resetVaultState } from './vaultStateReset'
|
||||
|
||||
function resetVaultState(options: {
|
||||
clearNewPaths: () => void
|
||||
clearUnsaved: () => void
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setModifiedFiles: (files: ModifiedFile[]) => void
|
||||
setModifiedFilesError: (message: string | null) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
options.setEntries([])
|
||||
options.setFolders([])
|
||||
options.setViews([])
|
||||
options.setModifiedFiles([])
|
||||
options.setModifiedFilesError(null)
|
||||
options.setIsLoading(false)
|
||||
options.clearNewPaths()
|
||||
options.clearUnsaved()
|
||||
}
|
||||
|
||||
async function loadInitialVaultState(options: {
|
||||
interface InitialVaultLoadStateOptions {
|
||||
handleVaultAvailable: (path: string) => void
|
||||
path: string
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
const { path, isCurrentVaultPath, setEntries, setFolders, setIsLoading, setViews } = options
|
||||
const chromeLoad = loadVaultChrome({ vaultPath: path }).then(({ folders, views }) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setFolders(folders)
|
||||
setViews(views)
|
||||
}
|
||||
|
||||
interface InitialVaultChromeOptions extends Pick<
|
||||
InitialVaultLoadStateOptions,
|
||||
'handleVaultUnavailable' | 'isCurrentVaultPath' | 'path' | 'setFolders' | 'setViews'
|
||||
> {
|
||||
shouldApplyChrome: () => boolean
|
||||
}
|
||||
|
||||
async function loadInitialVaultChromeState(options: InitialVaultChromeOptions): Promise<boolean> {
|
||||
const { handleVaultUnavailable, isCurrentVaultPath, path, setFolders, setViews, shouldApplyChrome } = options
|
||||
try {
|
||||
const { folders, views } = await loadVaultChrome({ vaultPath: path })
|
||||
if (shouldApplyChrome()) {
|
||||
setFolders(folders)
|
||||
setViews(views)
|
||||
}
|
||||
} catch (err) {
|
||||
const unavailable = await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
|
||||
if (unavailable) return true
|
||||
console.warn('Vault chrome load failed:', err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadInitialVaultEntriesState(options: Pick<
|
||||
InitialVaultLoadStateOptions,
|
||||
'handleVaultAvailable' | 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'path' | 'setEntries'
|
||||
>): Promise<boolean> {
|
||||
const { handleVaultAvailable, handleVaultUnavailable, isCurrentVaultPath, path, setEntries } = options
|
||||
|
||||
try {
|
||||
const { entries } = await loadVaultData({ vaultPath: path })
|
||||
if (isCurrentVaultPath(path)) {
|
||||
handleVaultAvailable(path)
|
||||
setEntries(entries)
|
||||
}
|
||||
} catch (err) {
|
||||
const unavailable = await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
|
||||
if (unavailable) return true
|
||||
console.warn('Vault scan failed:', err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadInitialVaultState(options: InitialVaultLoadStateOptions) {
|
||||
const { path, isCurrentVaultPath, setIsLoading } = options
|
||||
let vaultUnavailable = false
|
||||
const chromeLoad = loadInitialVaultChromeState({
|
||||
...options,
|
||||
shouldApplyChrome: () => !vaultUnavailable && isCurrentVaultPath(path),
|
||||
})
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { entries } = await loadVaultData({ vaultPath: path })
|
||||
if (isCurrentVaultPath(path)) setEntries(entries)
|
||||
} catch (err) {
|
||||
console.warn('Vault scan failed:', err)
|
||||
} finally {
|
||||
if (isCurrentVaultPath(path)) setIsLoading(false)
|
||||
}
|
||||
|
||||
vaultUnavailable = await loadInitialVaultEntriesState(options)
|
||||
if (isCurrentVaultPath(path)) setIsLoading(false)
|
||||
await chromeLoad
|
||||
}
|
||||
|
||||
async function handleUnavailableVaultPath(options: {
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
path: string
|
||||
}): Promise<boolean> {
|
||||
const { handleVaultUnavailable, isCurrentVaultPath, path } = options
|
||||
if (!isCurrentVaultPath(path)) return true
|
||||
|
||||
const available = await checkVaultPathAvailability({ vaultPath: path })
|
||||
if (available !== false) return false
|
||||
if (isCurrentVaultPath(path)) handleVaultUnavailable(path)
|
||||
return true
|
||||
}
|
||||
|
||||
function useCurrentVaultPathGuard(vaultPath: string) {
|
||||
const currentPathRef = useRef(vaultPath)
|
||||
|
||||
@@ -164,19 +201,9 @@ export function resolveNoteStatus({
|
||||
return resolveGitBackedNoteStatus(modifiedFiles.find((file) => file.path === path))
|
||||
}
|
||||
|
||||
function useInitialVaultLoad({
|
||||
vaultPath,
|
||||
tracker,
|
||||
unsaved,
|
||||
isCurrentVaultPath,
|
||||
resetReloading,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
}: {
|
||||
interface InitialVaultLoadOptions {
|
||||
handleVaultAvailable: (path: string) => void
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
vaultPath: string
|
||||
tracker: ReturnType<typeof useNewNoteTracker>
|
||||
unsaved: ReturnType<typeof useUnsavedTracker>
|
||||
@@ -188,7 +215,25 @@ function useInitialVaultLoad({
|
||||
setModifiedFiles: (files: ModifiedFile[]) => void
|
||||
setModifiedFilesError: (message: string | null) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
}
|
||||
|
||||
function useInitialVaultLoad(options: InitialVaultLoadOptions) {
|
||||
const {
|
||||
handleVaultAvailable,
|
||||
handleVaultUnavailable,
|
||||
vaultPath,
|
||||
tracker,
|
||||
unsaved,
|
||||
isCurrentVaultPath,
|
||||
resetReloading,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
const path = vaultPath
|
||||
clearPrefetchCache()
|
||||
@@ -208,7 +253,9 @@ function useInitialVaultLoad({
|
||||
|
||||
let cancelled = false
|
||||
void loadInitialVaultState({
|
||||
handleVaultAvailable,
|
||||
path,
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath: (candidate) => !cancelled && isCurrentVaultPath(candidate),
|
||||
setEntries,
|
||||
setFolders,
|
||||
@@ -217,6 +264,8 @@ function useInitialVaultLoad({
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [
|
||||
handleVaultAvailable,
|
||||
handleVaultUnavailable,
|
||||
vaultPath,
|
||||
tracker.clear,
|
||||
unsaved.clearAll,
|
||||
@@ -349,42 +398,71 @@ function useGitLoaders(vaultPath: string) {
|
||||
return { loadGitHistory, loadDiffAtCommit, loadDiff, commitAndPush }
|
||||
}
|
||||
|
||||
function useVaultReloads({
|
||||
vaultPath,
|
||||
isCurrentVaultPath,
|
||||
loadModifiedFiles,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setViews,
|
||||
}: {
|
||||
interface VaultReloadOptions {
|
||||
handleVaultAvailable: (path: string) => void
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
vaultPath: string
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
loadModifiedFiles: () => Promise<void>
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
const [activeReloads, setActiveReloads] = useState(0)
|
||||
const isReloading = activeReloads > 0
|
||||
const beginReload = useCallback(() => setActiveReloads((count) => count + 1), [])
|
||||
const finishReload = useCallback(() => setActiveReloads((count) => Math.max(0, count - 1)), [])
|
||||
const resetReloading = useCallback(() => setActiveReloads(0), [])
|
||||
}
|
||||
|
||||
const reloadFolders = useCallback(async () => {
|
||||
const path = vaultPath
|
||||
if (!hasVaultPath({ vaultPath: path })) return [] as FolderNode[]
|
||||
try {
|
||||
const folders = await loadVaultFolders({ vaultPath: path })
|
||||
if (!isCurrentVaultPath(path)) return [] as FolderNode[]
|
||||
const nextFolders = folders ?? []
|
||||
setFolders(nextFolders)
|
||||
return nextFolders
|
||||
} catch {
|
||||
return [] as FolderNode[]
|
||||
}
|
||||
}, [vaultPath, isCurrentVaultPath, setFolders])
|
||||
interface EntryReloadOptions extends VaultReloadOptions {
|
||||
beginReload: () => void
|
||||
finishReload: () => void
|
||||
}
|
||||
|
||||
const reloadVault = useCallback(async () => {
|
||||
interface CollectionReloadOptions<T> {
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
loadCollection: (options: { vaultPath: string }) => Promise<T[]>
|
||||
path: string
|
||||
setCollection: (items: T[]) => void
|
||||
}
|
||||
|
||||
async function reloadVaultCollection<T>(options: CollectionReloadOptions<T>): Promise<T[]> {
|
||||
const { handleVaultUnavailable, isCurrentVaultPath, loadCollection, path, setCollection } = options
|
||||
if (!hasVaultPath({ vaultPath: path })) return []
|
||||
try {
|
||||
const items = await loadCollection({ vaultPath: path })
|
||||
if (!isCurrentVaultPath(path)) return []
|
||||
const nextItems = items ?? []
|
||||
setCollection(nextItems)
|
||||
return nextItems
|
||||
} catch {
|
||||
await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function useFolderReload({
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
setFolders,
|
||||
vaultPath,
|
||||
}: Pick<VaultReloadOptions, 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'setFolders' | 'vaultPath'>) {
|
||||
return useCallback(() => reloadVaultCollection({
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
loadCollection: loadVaultFolders,
|
||||
path: vaultPath,
|
||||
setCollection: setFolders,
|
||||
}), [handleVaultUnavailable, vaultPath, isCurrentVaultPath, setFolders])
|
||||
}
|
||||
|
||||
function useEntryReload({
|
||||
beginReload,
|
||||
finishReload,
|
||||
handleVaultAvailable,
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
loadModifiedFiles,
|
||||
setEntries,
|
||||
vaultPath,
|
||||
}: EntryReloadOptions) {
|
||||
return useCallback(async () => {
|
||||
const path = vaultPath
|
||||
if (!hasVaultPath({ vaultPath: path })) return [] as VaultEntry[]
|
||||
clearPrefetchCache()
|
||||
@@ -392,29 +470,44 @@ function useVaultReloads({
|
||||
try {
|
||||
const entries = await reloadVaultEntries({ vaultPath: path })
|
||||
if (!isCurrentVaultPath(path)) return [] as VaultEntry[]
|
||||
handleVaultAvailable(path)
|
||||
setEntries(entries)
|
||||
void loadModifiedFiles()
|
||||
return entries
|
||||
} catch (err) {
|
||||
if (await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })) return [] as VaultEntry[]
|
||||
console.warn('Vault reload failed:', err)
|
||||
return [] as VaultEntry[]
|
||||
} finally {
|
||||
finishReload()
|
||||
}
|
||||
}, [vaultPath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries])
|
||||
}, [handleVaultAvailable, handleVaultUnavailable, vaultPath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries])
|
||||
}
|
||||
|
||||
const reloadViews = useCallback(async () => {
|
||||
const path = vaultPath
|
||||
if (!hasVaultPath({ vaultPath: path })) return []
|
||||
try {
|
||||
const nextViews = await loadVaultViews({ vaultPath: path })
|
||||
if (!isCurrentVaultPath(path)) return []
|
||||
const resolvedViews = nextViews ?? []
|
||||
setViews(resolvedViews)
|
||||
return resolvedViews
|
||||
} catch { /* views are optional */ }
|
||||
return []
|
||||
}, [vaultPath, isCurrentVaultPath, setViews])
|
||||
function useViewReload({
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
setViews,
|
||||
vaultPath,
|
||||
}: Pick<VaultReloadOptions, 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'setViews' | 'vaultPath'>) {
|
||||
return useCallback(() => reloadVaultCollection({
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
loadCollection: loadVaultViews,
|
||||
path: vaultPath,
|
||||
setCollection: setViews,
|
||||
}), [handleVaultUnavailable, vaultPath, isCurrentVaultPath, setViews])
|
||||
}
|
||||
|
||||
function useVaultReloads(options: VaultReloadOptions) {
|
||||
const [activeReloads, setActiveReloads] = useState(0)
|
||||
const isReloading = activeReloads > 0
|
||||
const beginReload = useCallback(() => setActiveReloads((count) => count + 1), [])
|
||||
const finishReload = useCallback(() => setActiveReloads((count) => Math.max(0, count - 1)), [])
|
||||
const resetReloading = useCallback(() => setActiveReloads(0), [])
|
||||
const reloadFolders = useFolderReload(options)
|
||||
const reloadVault = useEntryReload({ ...options, beginReload, finishReload })
|
||||
const reloadViews = useViewReload(options)
|
||||
|
||||
return { isReloading, reloadFolders, reloadVault, reloadViews, resetReloading }
|
||||
}
|
||||
@@ -445,7 +538,7 @@ function useGitignoredVisibilityReloads(
|
||||
}, [reloadFolders, reloadVault, reloadViews])
|
||||
}
|
||||
|
||||
export function useVaultLoader(vaultPath: string) {
|
||||
function useVaultState(vaultPath: string) {
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [folders, setFolders] = useState<FolderNode[]>([])
|
||||
const [isLoading, setIsLoading] = useState(() => hasVaultPath({ vaultPath }))
|
||||
@@ -454,16 +547,67 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const pendingSave = usePendingSaveTracker()
|
||||
const unsaved = useUnsavedTracker()
|
||||
const isCurrentVaultPath = useCurrentVaultPathGuard(vaultPath)
|
||||
const modified = useModifiedFilesLoader(vaultPath, isCurrentVaultPath)
|
||||
|
||||
return {
|
||||
entries,
|
||||
folders,
|
||||
isCurrentVaultPath,
|
||||
isLoading,
|
||||
modified,
|
||||
pendingSave,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setViews,
|
||||
tracker,
|
||||
unsaved,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
function useVaultUnavailable(vaultPath: string, state: ReturnType<typeof useVaultState>) {
|
||||
const {
|
||||
isCurrentVaultPath,
|
||||
modified,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setViews,
|
||||
tracker,
|
||||
unsaved,
|
||||
} = state
|
||||
|
||||
return useUnavailableVaultState({
|
||||
clearNewPaths: tracker.clear,
|
||||
clearUnsaved: unsaved.clearAll,
|
||||
isCurrentVaultPath,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles: modified.setModifiedFiles,
|
||||
setModifiedFilesError: modified.setModifiedFilesError,
|
||||
setViews,
|
||||
vaultPath,
|
||||
})
|
||||
}
|
||||
|
||||
export function useVaultLoader(vaultPath: string) {
|
||||
const state = useVaultState(vaultPath)
|
||||
const { entries, folders, isCurrentVaultPath, isLoading, modified, pendingSave, setEntries, setFolders, setIsLoading, setViews, tracker, unsaved, views } = state
|
||||
const {
|
||||
modifiedFiles,
|
||||
modifiedFilesError,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
loadModifiedFiles,
|
||||
} = useModifiedFilesLoader(vaultPath, isCurrentVaultPath)
|
||||
} = modified
|
||||
const entryMutations = useEntryMutations(setEntries, tracker.trackNew)
|
||||
const gitLoaders = useGitLoaders(vaultPath)
|
||||
const unavailableVault = useVaultUnavailable(vaultPath, state)
|
||||
const vaultReloads = useVaultReloads({
|
||||
handleVaultAvailable: unavailableVault.markVaultAvailable,
|
||||
handleVaultUnavailable: unavailableVault.markVaultUnavailable,
|
||||
vaultPath,
|
||||
isCurrentVaultPath,
|
||||
loadModifiedFiles,
|
||||
@@ -474,6 +618,8 @@ export function useVaultLoader(vaultPath: string) {
|
||||
useGitignoredVisibilityReloads(vaultReloads)
|
||||
|
||||
useInitialVaultLoad({
|
||||
handleVaultAvailable: unavailableVault.markVaultAvailable,
|
||||
handleVaultUnavailable: unavailableVault.markVaultUnavailable,
|
||||
vaultPath,
|
||||
tracker,
|
||||
unsaved,
|
||||
@@ -498,6 +644,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
|
||||
return {
|
||||
entries, folders, isLoading, isReloading: vaultReloads.isReloading, views, modifiedFiles, modifiedFilesError,
|
||||
unavailableVaultPath: unavailableVault.unavailableVaultPath,
|
||||
...entryMutations,
|
||||
loadModifiedFiles,
|
||||
...gitLoaders,
|
||||
@@ -505,6 +652,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
reloadVault: vaultReloads.reloadVault,
|
||||
reloadFolders: vaultReloads.reloadFolders,
|
||||
reloadViews: vaultReloads.reloadViews,
|
||||
markVaultUnavailable: unavailableVault.markVaultUnavailable,
|
||||
addPendingSave: pendingSave.addPendingSave,
|
||||
removePendingSave: pendingSave.removePendingSave,
|
||||
unsavedPaths: unsaved.unsavedPaths,
|
||||
|
||||
@@ -34,6 +34,19 @@ export function tauriCall<T>({ command, tauriArgs, mockArgs }: TauriCallOptions)
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
||||
}
|
||||
|
||||
export async function checkVaultPathAvailability({ vaultPath }: VaultPathOptions): Promise<boolean | null> {
|
||||
if (!hasVaultPath({ vaultPath })) return false
|
||||
|
||||
try {
|
||||
return await tauriCall<boolean>({
|
||||
command: 'check_vault_exists',
|
||||
tauriArgs: { path: vaultPath },
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function loadVaultEntriesWithCommand({ vaultPath, command }: VaultPathOptions & { command: string }): Promise<VaultEntry[]> {
|
||||
return tauriCall<unknown>({ command, tauriArgs: { path: vaultPath } })
|
||||
.then((entries) => normalizeVaultEntries(entries, vaultPath))
|
||||
|
||||
23
src/hooks/vaultStateReset.ts
Normal file
23
src/hooks/vaultStateReset.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { FolderNode, ModifiedFile, VaultEntry, ViewFile } from '../types'
|
||||
|
||||
export interface VaultStateResetOptions {
|
||||
clearNewPaths: () => void
|
||||
clearUnsaved: () => void
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setModifiedFiles: (files: ModifiedFile[]) => void
|
||||
setModifiedFilesError: (message: string | null) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}
|
||||
|
||||
export function resetVaultState(options: VaultStateResetOptions) {
|
||||
options.setEntries([])
|
||||
options.setFolders([])
|
||||
options.setViews([])
|
||||
options.setModifiedFiles([])
|
||||
options.setModifiedFilesError(null)
|
||||
options.setIsLoading(false)
|
||||
options.clearNewPaths()
|
||||
options.clearUnsaved()
|
||||
}
|
||||
9
src/utils/vaultErrors.ts
Normal file
9
src/utils/vaultErrors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function isActiveVaultUnavailableError(error: unknown): boolean {
|
||||
return /no active vault selected|active vault is not available/i.test(errorMessage(error))
|
||||
}
|
||||
113
tests/smoke/missing-active-vault-recovery.spec.ts
Normal file
113
tests/smoke/missing-active-vault-recovery.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
type MockHandlers = Record<string, (args?: unknown) => unknown>
|
||||
type MockWindow = Window & {
|
||||
__markVaultMissing?: () => void
|
||||
}
|
||||
|
||||
const entry = {
|
||||
path: '/vault/note/runtime.md',
|
||||
filename: 'runtime.md',
|
||||
title: 'Runtime Vault Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 64,
|
||||
snippet: 'Loaded before the vault path disappears.',
|
||||
wordCount: 7,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
}
|
||||
|
||||
async function installMissingVaultMock(page: Page): Promise<void> {
|
||||
await page.addInitScript((noteEntry: typeof entry) => {
|
||||
localStorage.setItem('tolaria_welcome_dismissed', '1')
|
||||
localStorage.setItem('tolaria:ai-agents-onboarding-dismissed', '1')
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
const missingError = () => new Error('Active vault is not available')
|
||||
const mockWindow = window as MockWindow
|
||||
let vaultAvailable = true
|
||||
let handlers: MockHandlers | null = null
|
||||
|
||||
mockWindow.__markVaultMissing = () => {
|
||||
vaultAvailable = false
|
||||
}
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value: unknown) {
|
||||
handlers = value as MockHandlers
|
||||
handlers.load_vault_list = () => ({
|
||||
vaults: [{ label: 'Runtime Vault', path: '/vault' }],
|
||||
active_vault: '/vault',
|
||||
hidden_defaults: [],
|
||||
})
|
||||
handlers.check_vault_exists = () => vaultAvailable
|
||||
handlers.get_default_vault_path = () => '/vault'
|
||||
handlers.get_settings = () => ({
|
||||
auto_pull_interval_minutes: null,
|
||||
auto_advance_inbox_after_organize: null,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
})
|
||||
handlers.get_vault_settings = () => ({ theme: null })
|
||||
handlers.list_vault = () => vaultAvailable ? [noteEntry] : Promise.reject(new Error('No such file or directory'))
|
||||
handlers.reload_vault = () => vaultAvailable ? [noteEntry] : Promise.reject(new Error('No such file or directory'))
|
||||
handlers.list_vault_folders = () => vaultAvailable ? [] : Promise.reject(missingError())
|
||||
handlers.list_views = () => vaultAvailable ? [] : Promise.reject(missingError())
|
||||
handlers.get_modified_files = () => vaultAvailable ? [] : Promise.reject(missingError())
|
||||
handlers.get_all_content = () => ({ [noteEntry.path]: '# Runtime Vault Note\n\nBody.' })
|
||||
handlers.get_note_content = () => vaultAvailable
|
||||
? '# Runtime Vault Note\n\nBody.'
|
||||
: Promise.reject(missingError())
|
||||
handlers.is_git_repo = () => true
|
||||
handlers.sync_mcp_bridge_vault = () => null
|
||||
},
|
||||
get() {
|
||||
return handlers
|
||||
},
|
||||
})
|
||||
}, entry)
|
||||
}
|
||||
|
||||
test('missing active vault reload shows recovery state and clears stale notes @smoke', async ({ page }) => {
|
||||
await installMissingVaultMock(page)
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByText('Runtime Vault Note')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as MockWindow).__markVaultMissing?.()
|
||||
})
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Reload Vault')
|
||||
|
||||
await expect(page.getByText('Vault not found')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('welcome-open-folder')).toContainText('Choose a different folder')
|
||||
await expect(page.getByText('Runtime Vault Note')).not.toBeVisible()
|
||||
})
|
||||
Reference in New Issue
Block a user