diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e824992..d78bea47 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 448de898..9432d84f 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -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.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. | diff --git a/src/App.tsx b/src/App.tsx index 5bfd2b65..0cfb12f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 { - for (const path of getNoteWindowPathCandidates(noteWindowParams)) { - try { - const request = { path, vaultPath: noteWindowParams.vaultPath } - const entry = isTauri() - ? await invoke('reload_vault_entry', request) - : await mockInvoke('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 { - const request = { path, vaultPath } - if (!isTauri()) return mockInvoke('get_note_content', request) - - await invoke('sync_vault_asset_scope_for_window', { vaultPath }) - return invoke('get_note_content', request) -} - function appTauriCall(command: string, args: Record): Promise { return isTauri() ? invoke(command, args) : mockInvoke(command, args) } -async function syncVaultAssetScope(vaultPath: string): Promise { - 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([]) - useEffect(() => { - if (!isTauri() || !resolvedPath) return - const handleFocus = () => { - invoke('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('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(null) - // Note window: auto-open the note from URL params without scanning the whole vault. - const noteWindowOpenedRef = useRef(false) - const noteWindowMissingPathRef = useRef(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 ( 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 + missingPathRef: RefObject + noteWindowParams: NoteWindowParams + openedRef: RefObject + setToastMessage: (message: string) => void +} + +interface OpenResolvedNoteWindowEntryArgs extends Omit { + entry: VaultEntry +} + +async function resolveNoteWindowEntry(noteWindowParams: NoteWindowParams): Promise { + for (const path of getNoteWindowPathCandidates(noteWindowParams)) { + try { + const request = { path, vaultPath: noteWindowParams.vaultPath } + const entry = isTauri() + ? await invoke('reload_vault_entry', request) + : await mockInvoke('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 { + const request = { path, vaultPath } + if (!isTauri()) return mockInvoke('get_note_content', request) + + await syncVaultAssetScope(vaultPath) + return invoke('get_note_content', request) +} + +export async function syncVaultAssetScope(vaultPath: string): Promise { + if (!isTauri() || !vaultPath.trim()) return + await invoke('sync_vault_asset_scope_for_window', { vaultPath }) +} + +async function openNoteWindowEntry({ + actionsRef, + entry, + missingPathRef, + noteWindowParams, + openedRef, +}: OpenResolvedNoteWindowEntryArgs): Promise { + 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): 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 { + 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 { + const actionsRef = useRef({ handleSelectNote, openTabWithContent }) + + useEffect(() => { + actionsRef.current = { handleSelectNote, openTabWithContent } + }, [handleSelectNote, openTabWithContent]) + + return actionsRef +} + +function useOpenNoteWindowOnMount( + noteWindowParams: NoteWindowParams | null, + actionsRef: RefObject, + setToastMessage: (message: string) => void, +): void { + const openedRef = useRef(false) + const missingPathRef = useRef(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) +} diff --git a/src/hooks/useStartupScreenState.ts b/src/hooks/useStartupScreenState.ts new file mode 100644 index 00000000..2fe7b58c --- /dev/null +++ b/src/hooks/useStartupScreenState.ts @@ -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, + } +} diff --git a/src/hooks/useVaultOpenedTelemetry.ts b/src/hooks/useVaultOpenedTelemetry.ts new file mode 100644 index 00000000..d63d6129 --- /dev/null +++ b/src/hooks/useVaultOpenedTelemetry.ts @@ -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]) +} diff --git a/src/hooks/useVaultRenameDetection.ts b/src/hooks/useVaultRenameDetection.ts new file mode 100644 index 00000000..f27e8220 --- /dev/null +++ b/src/hooks/useVaultRenameDetection.ts @@ -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 + handleDismissRenames: () => void +} + +export function useVaultRenameDetection({ + vaultPath, + reloadVault, + setToastMessage, +}: UseVaultRenameDetectionArgs): UseVaultRenameDetectionResult { + const [detectedRenames, setDetectedRenames] = useState([]) + + useEffect(() => { + if (!isTauri() || !vaultPath) return + + const handleFocus = () => { + invoke('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('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, + } +}