refactor: extract app orchestration hooks

This commit is contained in:
lucaronin
2026-05-12 03:38:54 +02:00
parent a3fdaee7ad
commit 7709255fb3
7 changed files with 496 additions and 129 deletions

View File

@@ -839,6 +839,9 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
| `useNoteWindowLifecycle` | note-window open/title side effects | Opens `tauri://` note windows without full vault scans and keeps the native title current |
| `useStartupScreenState` | startup visibility booleans | Keeps onboarding, telemetry-consent, missing-vault, and initial indexing decisions out of `App.tsx` |
| `useVaultRenameDetection` | detected rename banner state | Detects external Git renames on focus and owns the wikilink update callback |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |

View File

@@ -77,7 +77,7 @@ Linux AppImage builds still use the user's system `git`. Before Tolaria spawns t
tolaria/
├── src/ # React frontend
│ ├── main.tsx # Entry point (renders <App />)
│ ├── App.tsx # Root component — orchestrates layout + state
│ ├── App.tsx # Root component — wires layout + state hooks
│ ├── App.css # App shell layout styles
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
@@ -124,7 +124,7 @@ tolaria/
│ │ └── ui/ # shadcn/ui primitives
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
│ │
│ ├── hooks/ # Custom React hooks (~86 files)
│ ├── hooks/ # Custom React hooks (~90 files)
│ │ ├── useVaultLoader.ts # Loads vault entries + content
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ├── useVaultConfig.ts # Per-vault UI settings
@@ -258,7 +258,7 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how all features connect. |
| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how orchestration hooks connect. |
| `src/types.ts` | All shared TypeScript types. Read this first to understand the data model. |
| `src-tauri/src/commands/` | Tauri command handlers (split into modules). This is the frontend-backend API surface. |
| `src-tauri/src/lib.rs` | Tauri setup, command registration, startup tasks, WebSocket bridge lifecycle. |
@@ -271,6 +271,9 @@ tolaria/
| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. |
| `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. |
| `src/hooks/useNoteWindowLifecycle.ts` | Note-window URL opening, asset-scope sync, and window-title updates. |
| `src/hooks/useVaultRenameDetection.ts` | Focus-triggered Git rename detection and wikilink update action wiring. |
| `src/hooks/useStartupScreenState.ts` | Startup-screen and vault-content loading visibility decisions. |
| `src/components/AddRemoteModal.tsx` | Modal UI for connecting a local-only vault to a compatible remote. |
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |

View File

@@ -89,9 +89,9 @@ import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isWindows } from './utils/platform'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode'
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import { GitSetupDialog } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
import { RenameDetectedBanner } from './components/RenameDetectedBanner'
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
@@ -125,6 +125,10 @@ import { useVisibleWorkspaceEntries, useWorkspaceGraphState } from './hooks/useW
import { useGitSetupState } from './hooks/useGitSetupState'
import { useAppPreferences } from './hooks/useAppPreferences'
import { useInboxOrganizeAdvance } from './hooks/useInboxOrganizeAdvance'
import { syncVaultAssetScope, useNoteWindowLifecycle } from './hooks/useNoteWindowLifecycle'
import { useVaultRenameDetection } from './hooks/useVaultRenameDetection'
import { useVaultOpenedTelemetry } from './hooks/useVaultOpenedTelemetry'
import { useStartupScreenState } from './hooks/useStartupScreenState'
import './App.css'
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
@@ -156,37 +160,10 @@ function shouldPreferOnboardingVaultPath(
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
}
async function resolveNoteWindowEntry(noteWindowParams: NoteWindowParams): Promise<VaultEntry | undefined> {
for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
try {
const request = { path, vaultPath: noteWindowParams.vaultPath }
const entry = isTauri()
? await invoke<VaultEntry | null>('reload_vault_entry', request)
: await mockInvoke<VaultEntry | null>('reload_vault_entry', request)
if (entry) return entry
} catch {
// Try the next normalized candidate before reporting the note as unavailable.
}
}
}
async function loadNoteWindowContent(path: string, vaultPath: string): Promise<string> {
const request = { path, vaultPath }
if (!isTauri()) return mockInvoke<string>('get_note_content', request)
await invoke('sync_vault_asset_scope_for_window', { vaultPath })
return invoke<string>('get_note_content', request)
}
function appTauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
async function syncVaultAssetScope(vaultPath: string): Promise<void> {
if (!isTauri() || !vaultPath.trim()) return
await invoke('sync_vault_asset_scope_for_window', { vaultPath })
}
function canCustomizeColumnsForSelection(
selection: SidebarSelection,
explicitOrganizationEnabled: boolean,
@@ -465,13 +442,11 @@ function App() {
settingsLoaded,
})
const vaultOpenedRef = useRef('')
useEffect(() => {
if (vault.entries.length > 0 && gitRepoState !== 'checking' && resolvedPath !== vaultOpenedRef.current) {
vaultOpenedRef.current = resolvedPath
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
}
}, [vault.entries.length, gitRepoState, resolvedPath])
useVaultOpenedTelemetry({
entryCount: vault.entries.length,
gitRepoState,
resolvedPath,
})
const {
mcpStatus,
connectMcp,
@@ -557,32 +532,15 @@ function App() {
void loadMcpConfigSnippet().catch(() => undefined)
}, [loadMcpConfigSnippet])
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
if (!isTauri() || !resolvedPath) return
const handleFocus = () => {
invoke<DetectedRename[]>('detect_renames', { args: { vaultPath: resolvedPath } })
.then(renames => { if (renames.length > 0) setDetectedRenames(renames) })
.catch((err) => console.warn('[vault] Git rename detection failed:', err))
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [resolvedPath])
const handleUpdateWikilinks = useCallback(async () => {
if (!isTauri()) return
try {
const count = await invoke<number>('update_wikilinks_for_renames', { args: { vaultPath: resolvedPath, renames: detectedRenames } })
setDetectedRenames([])
vault.reloadVault()
setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`)
} catch (err) {
setToastMessage(`Failed to update wikilinks: ${err}`)
}
}, [resolvedPath, detectedRenames, vault, setToastMessage])
const handleDismissRenames = useCallback(() => setDetectedRenames([]), [])
const {
detectedRenames,
handleUpdateWikilinks,
handleDismissRenames,
} = useVaultRenameDetection({
reloadVault: vault.reloadVault,
setToastMessage,
vaultPath: resolvedPath,
})
const conflictResolver = useConflictResolver({
vaultPath: resolvedPath,
@@ -646,10 +604,14 @@ function App() {
closeAllTabs,
openTabWithContent,
} = notes
const noteWindowActionsRef = useRef({ handleSelectNote, openTabWithContent })
useEffect(() => {
noteWindowActionsRef.current = { handleSelectNote, openTabWithContent }
}, [handleSelectNote, openTabWithContent])
useNoteWindowLifecycle({
activeTabPath: notes.activeTabPath,
handleSelectNote,
noteWindowParams,
openTabWithContent,
setToastMessage,
tabs: notes.tabs,
})
const handleVaultUpdate = useCallback(async (
updatedFiles: string[],
options: { preserveFocusedEditor?: boolean } = {},
@@ -722,46 +684,6 @@ function App() {
const pendingDiffRequestIdRef = useRef(0)
const [pendingDiffRequest, setPendingDiffRequest] = useState<CommitDiffRequest | null>(null)
// Note window: auto-open the note from URL params without scanning the whole vault.
const noteWindowOpenedRef = useRef(false)
const noteWindowMissingPathRef = useRef<string | null>(null)
useEffect(() => {
if (!noteWindowParams || noteWindowOpenedRef.current) return
void resolveNoteWindowEntry(noteWindowParams).then(async (entry) => {
if (noteWindowOpenedRef.current) return
if (entry) {
try {
const content = await loadNoteWindowContent(entry.path, noteWindowParams.vaultPath)
if (noteWindowOpenedRef.current) return
noteWindowOpenedRef.current = true
noteWindowMissingPathRef.current = null
noteWindowActionsRef.current.openTabWithContent(entry, content)
} catch {
if (noteWindowOpenedRef.current) return
noteWindowOpenedRef.current = true
noteWindowMissingPathRef.current = null
void noteWindowActionsRef.current.handleSelectNote(entry)
}
return
}
if (noteWindowMissingPathRef.current === noteWindowParams.notePath) return
noteWindowMissingPathRef.current = noteWindowParams.notePath
setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`)
})
}, [noteWindowParams, setToastMessage])
// Note window: update window title when active note changes
useEffect(() => {
if (!noteWindowParams) return
const activeEntry = notes.tabs.find(t => t.entry.path === notes.activeTabPath)?.entry
const title = activeEntry?.title ?? noteWindowParams.noteTitle
if (!isTauri()) { document.title = title; return }
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
getCurrentWindow().setTitle(title)
}).catch((err) => console.warn('[window] Failed to update note window title:', err))
}, [noteWindowParams, notes.tabs, notes.activeTabPath])
// Keep note entry in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.
useEffect(() => {
@@ -1795,25 +1717,23 @@ function App() {
return { type: null, query: '' }
}, [effectiveSelection])
const shouldResumeFreshStartOnboarding = useMemo(() => {
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
const remembersOnlyImplicitDefaultVault = selectedVaultPath === null
return remembersOnlyImplicitDefaultVault
&& vaultSwitcher.allVaults.length === 1
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
const isStartupLoading = !noteWindowParams && onboarding.state.status === 'loading'
const shouldShowStartupScreen = !noteWindowParams && (
(!isStartupLoading && settingsLoaded && settings.telemetry_consent === null)
|| Boolean(runtimeMissingVaultPath)
|| onboarding.state.status === 'welcome'
|| onboarding.state.status === 'vault-missing'
|| shouldResumeFreshStartOnboarding
|| (onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt && !showMcpSetupDialog)
)
const {
isStartupLoading,
isVaultContentLoading,
shouldResumeFreshStartOnboarding,
shouldShowStartupScreen,
} = useStartupScreenState({
aiAgentsPromptVisible: aiAgentsOnboarding.showPrompt,
isNoteWindow: Boolean(noteWindowParams),
onboardingState: onboarding.state,
runtimeMissingVaultPath,
selectedVaultPath,
settingsLoaded,
showMcpSetupDialog,
telemetryConsent: settings.telemetry_consent,
vaultIsLoading: vault.isLoading,
vaultSwitcher,
})
if (shouldShowStartupScreen) {
return (
<StartupScreen
@@ -1836,7 +1756,6 @@ function App() {
)
}
const isVaultContentLoading = !noteWindowParams && (isStartupLoading || (onboarding.state.status === 'ready' && vault.isLoading))
const isChangesSelection = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes'
const noteListModifiedFiles = isChangesSelection ? selectedChangesModifiedFiles : allGitModifiedFiles
const noteListModifiedFilesError = isChangesSelection ? gitSurfaces.changesModifiedFilesError : null

View File

@@ -0,0 +1,184 @@
import { useEffect, useRef, type RefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import {
getNoteWindowPathCandidates,
type NoteWindowParams,
} from '../utils/windowMode'
interface NoteWindowActions {
handleSelectNote: (entry: VaultEntry) => unknown
openTabWithContent: (entry: VaultEntry, content: string) => unknown
}
interface UseNoteWindowLifecycleArgs extends NoteWindowActions {
activeTabPath: string | null
noteWindowParams: NoteWindowParams | null
setToastMessage: (message: string) => void
tabs: Array<{ entry: VaultEntry }>
}
interface OpenNoteWindowArgs {
actionsRef: RefObject<NoteWindowActions>
missingPathRef: RefObject<string | null>
noteWindowParams: NoteWindowParams
openedRef: RefObject<boolean>
setToastMessage: (message: string) => void
}
interface OpenResolvedNoteWindowEntryArgs extends Omit<OpenNoteWindowArgs, 'setToastMessage'> {
entry: VaultEntry
}
async function resolveNoteWindowEntry(noteWindowParams: NoteWindowParams): Promise<VaultEntry | undefined> {
for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
try {
const request = { path, vaultPath: noteWindowParams.vaultPath }
const entry = isTauri()
? await invoke<VaultEntry | null>('reload_vault_entry', request)
: await mockInvoke<VaultEntry | null>('reload_vault_entry', request)
if (entry) return entry
} catch {
// Try the next normalized candidate before reporting the note as unavailable.
}
}
}
async function loadNoteWindowContent(path: string, vaultPath: string): Promise<string> {
const request = { path, vaultPath }
if (!isTauri()) return mockInvoke<string>('get_note_content', request)
await syncVaultAssetScope(vaultPath)
return invoke<string>('get_note_content', request)
}
export async function syncVaultAssetScope(vaultPath: string): Promise<void> {
if (!isTauri() || !vaultPath.trim()) return
await invoke('sync_vault_asset_scope_for_window', { vaultPath })
}
async function openNoteWindowEntry({
actionsRef,
entry,
missingPathRef,
noteWindowParams,
openedRef,
}: OpenResolvedNoteWindowEntryArgs): Promise<void> {
try {
const content = await loadNoteWindowContent(entry.path, noteWindowParams.vaultPath)
if (openedRef.current) return
openedRef.current = true
missingPathRef.current = null
actionsRef.current.openTabWithContent(entry, content)
} catch {
if (openedRef.current) return
openedRef.current = true
missingPathRef.current = null
void actionsRef.current.handleSelectNote(entry)
}
}
function reportMissingNoteWindowPath({
missingPathRef,
noteWindowParams,
setToastMessage,
}: Pick<OpenNoteWindowArgs, 'missingPathRef' | 'noteWindowParams' | 'setToastMessage'>): void {
if (missingPathRef.current === noteWindowParams.notePath) return
missingPathRef.current = noteWindowParams.notePath
setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`)
}
async function resolveAndOpenNoteWindow({
actionsRef,
missingPathRef,
noteWindowParams,
openedRef,
setToastMessage,
}: OpenNoteWindowArgs): Promise<void> {
const entry = await resolveNoteWindowEntry(noteWindowParams)
if (openedRef.current) return
if (!entry) {
reportMissingNoteWindowPath({ missingPathRef, noteWindowParams, setToastMessage })
return
}
await openNoteWindowEntry({
actionsRef,
entry,
missingPathRef,
noteWindowParams,
openedRef,
})
}
function useNoteWindowActionsRef({
handleSelectNote,
openTabWithContent,
}: NoteWindowActions): RefObject<NoteWindowActions> {
const actionsRef = useRef<NoteWindowActions>({ handleSelectNote, openTabWithContent })
useEffect(() => {
actionsRef.current = { handleSelectNote, openTabWithContent }
}, [handleSelectNote, openTabWithContent])
return actionsRef
}
function useOpenNoteWindowOnMount(
noteWindowParams: NoteWindowParams | null,
actionsRef: RefObject<NoteWindowActions>,
setToastMessage: (message: string) => void,
): void {
const openedRef = useRef(false)
const missingPathRef = useRef<string | null>(null)
useEffect(() => {
if (!noteWindowParams || openedRef.current) return
void resolveAndOpenNoteWindow({
actionsRef,
missingPathRef,
noteWindowParams,
openedRef,
setToastMessage,
})
}, [actionsRef, noteWindowParams, setToastMessage])
}
function useNoteWindowTitle(
noteWindowParams: NoteWindowParams | null,
tabs: Array<{ entry: VaultEntry }>,
activeTabPath: string | null,
): void {
useEffect(() => {
if (!noteWindowParams) return
const activeEntry = tabs.find((tab) => tab.entry.path === activeTabPath)?.entry
const title = activeEntry?.title ?? noteWindowParams.noteTitle
if (!isTauri()) {
document.title = title
return
}
import('@tauri-apps/api/window')
.then(({ getCurrentWindow }) => {
getCurrentWindow().setTitle(title)
})
.catch((err) => console.warn('[window] Failed to update note window title:', err))
}, [activeTabPath, noteWindowParams, tabs])
}
export function useNoteWindowLifecycle({
activeTabPath,
handleSelectNote,
noteWindowParams,
openTabWithContent,
setToastMessage,
tabs,
}: UseNoteWindowLifecycleArgs): void {
const actionsRef = useNoteWindowActionsRef({ handleSelectNote, openTabWithContent })
useOpenNoteWindowOnMount(noteWindowParams, actionsRef, setToastMessage)
useNoteWindowTitle(noteWindowParams, tabs, activeTabPath)
}

View File

@@ -0,0 +1,156 @@
import { useMemo } from 'react'
interface StartupOnboardingState {
status: string
vaultPath?: string
}
interface StartupVaultSwitcherState {
allVaults: Array<{ path: string }>
loaded: boolean
vaultPath: string
}
interface UseStartupScreenStateArgs {
aiAgentsPromptVisible: boolean
isNoteWindow: boolean
onboardingState: StartupOnboardingState
runtimeMissingVaultPath: string | null
selectedVaultPath: string | null
settingsLoaded: boolean
showMcpSetupDialog: boolean
telemetryConsent: boolean | null
vaultIsLoading: boolean
vaultSwitcher: StartupVaultSwitcherState
}
interface StartupScreenState {
isStartupLoading: boolean
isVaultContentLoading: boolean
shouldResumeFreshStartOnboarding: boolean
shouldShowStartupScreen: boolean
}
interface ShouldShowStartupScreenArgs {
aiAgentsPromptVisible: boolean
isNoteWindow: boolean
isStartupLoading: boolean
onboardingState: StartupOnboardingState
runtimeMissingVaultPath: string | null
settingsLoaded: boolean
shouldResumeFreshStartOnboarding: boolean
showMcpSetupDialog: boolean
telemetryConsent: boolean | null
}
function shouldResumeFreshStart(
onboardingState: StartupOnboardingState,
selectedVaultPath: string | null,
vaultSwitcher: StartupVaultSwitcherState,
): boolean {
if (onboardingState.status !== 'ready' || !vaultSwitcher.loaded) return false
const remembersOnlyImplicitDefaultVault = selectedVaultPath === null
const hasOneRegisteredVault = vaultSwitcher.allVaults.length === 1
const registeredVaultPath = vaultSwitcher.allVaults[0]?.path
const switcherOwnsOnboardingVault = onboardingState.vaultPath === vaultSwitcher.vaultPath
return remembersOnlyImplicitDefaultVault
&& hasOneRegisteredVault
&& registeredVaultPath === vaultSwitcher.vaultPath
&& switcherOwnsOnboardingVault
}
function needsTelemetryConsent(
isStartupLoading: boolean,
settingsLoaded: boolean,
telemetryConsent: boolean | null,
): boolean {
return !isStartupLoading && settingsLoaded && telemetryConsent === null
}
function needsAiAgentsOnboarding(
onboardingState: StartupOnboardingState,
aiAgentsPromptVisible: boolean,
showMcpSetupDialog: boolean,
): boolean {
return onboardingState.status === 'ready' && aiAgentsPromptVisible && !showMcpSetupDialog
}
function shouldShowStartupScreenForState({
aiAgentsPromptVisible,
isNoteWindow,
isStartupLoading,
onboardingState,
runtimeMissingVaultPath,
settingsLoaded,
shouldResumeFreshStartOnboarding,
showMcpSetupDialog,
telemetryConsent,
}: ShouldShowStartupScreenArgs): boolean {
if (isNoteWindow) return false
const startupReasons = [
needsTelemetryConsent(isStartupLoading, settingsLoaded, telemetryConsent),
Boolean(runtimeMissingVaultPath),
onboardingState.status === 'welcome',
onboardingState.status === 'vault-missing',
shouldResumeFreshStartOnboarding,
needsAiAgentsOnboarding(onboardingState, aiAgentsPromptVisible, showMcpSetupDialog),
]
return startupReasons.some(Boolean)
}
function isVaultContentLoading(
isNoteWindow: boolean,
isStartupLoading: boolean,
onboardingState: StartupOnboardingState,
vaultIsLoading: boolean,
): boolean {
const readyVaultIsLoading = onboardingState.status === 'ready' && vaultIsLoading
return !isNoteWindow && (isStartupLoading || readyVaultIsLoading)
}
export function useStartupScreenState({
aiAgentsPromptVisible,
isNoteWindow,
onboardingState,
runtimeMissingVaultPath,
selectedVaultPath,
settingsLoaded,
showMcpSetupDialog,
telemetryConsent,
vaultIsLoading,
vaultSwitcher,
}: UseStartupScreenStateArgs): StartupScreenState {
const shouldResumeFreshStartOnboarding = useMemo(
() => shouldResumeFreshStart(onboardingState, selectedVaultPath, vaultSwitcher),
[onboardingState, selectedVaultPath, vaultSwitcher],
)
const isStartupLoading = !isNoteWindow && onboardingState.status === 'loading'
const shouldShowStartupScreen = shouldShowStartupScreenForState({
aiAgentsPromptVisible,
isNoteWindow,
isStartupLoading,
onboardingState,
runtimeMissingVaultPath,
settingsLoaded,
shouldResumeFreshStartOnboarding,
showMcpSetupDialog,
telemetryConsent,
})
const vaultContentLoading = isVaultContentLoading(
isNoteWindow,
isStartupLoading,
onboardingState,
vaultIsLoading,
)
return {
isStartupLoading,
isVaultContentLoading: vaultContentLoading,
shouldResumeFreshStartOnboarding,
shouldShowStartupScreen,
}
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react'
import { trackEvent } from '../lib/telemetry'
import type { GitRepoState } from './useGitSetupState'
interface UseVaultOpenedTelemetryArgs {
entryCount: number
gitRepoState: GitRepoState
resolvedPath: string
}
function shouldTrackVaultOpened(
entryCount: number,
gitRepoState: GitRepoState,
resolvedPath: string,
previousPath: string,
): boolean {
const hasEntries = entryCount > 0
const gitStateKnown = gitRepoState !== 'checking'
const vaultChanged = resolvedPath !== previousPath
return hasEntries && gitStateKnown && vaultChanged
}
export function useVaultOpenedTelemetry({
entryCount,
gitRepoState,
resolvedPath,
}: UseVaultOpenedTelemetryArgs): void {
const vaultOpenedRef = useRef('')
useEffect(() => {
if (!shouldTrackVaultOpened(entryCount, gitRepoState, resolvedPath, vaultOpenedRef.current)) return
vaultOpenedRef.current = resolvedPath
trackEvent('vault_opened', {
has_git: gitRepoState === 'ready' ? 1 : 0,
note_count: entryCount,
})
}, [entryCount, gitRepoState, resolvedPath])
}

View File

@@ -0,0 +1,62 @@
import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
import type { DetectedRename } from '../components/RenameDetectedBanner'
interface UseVaultRenameDetectionArgs {
vaultPath: string
reloadVault: () => unknown
setToastMessage: (message: string) => void
}
interface UseVaultRenameDetectionResult {
detectedRenames: DetectedRename[]
handleUpdateWikilinks: () => Promise<void>
handleDismissRenames: () => void
}
export function useVaultRenameDetection({
vaultPath,
reloadVault,
setToastMessage,
}: UseVaultRenameDetectionArgs): UseVaultRenameDetectionResult {
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
if (!isTauri() || !vaultPath) return
const handleFocus = () => {
invoke<DetectedRename[]>('detect_renames', { args: { vaultPath } })
.then((renames) => {
if (renames.length > 0) setDetectedRenames(renames)
})
.catch((err) => console.warn('[vault] Git rename detection failed:', err))
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [vaultPath])
const handleUpdateWikilinks = useCallback(async () => {
if (!isTauri()) return
try {
const count = await invoke<number>('update_wikilinks_for_renames', {
args: { vaultPath, renames: detectedRenames },
})
setDetectedRenames([])
reloadVault()
setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`)
} catch (err) {
setToastMessage(`Failed to update wikilinks: ${err}`)
}
}, [detectedRenames, reloadVault, setToastMessage, vaultPath])
const handleDismissRenames = useCallback(() => setDetectedRenames([]), [])
return {
detectedRenames,
handleUpdateWikilinks,
handleDismissRenames,
}
}