refactor: extract app orchestration hooks
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
171
src/App.tsx
171
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<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
|
||||
|
||||
184
src/hooks/useNoteWindowLifecycle.ts
Normal file
184
src/hooks/useNoteWindowLifecycle.ts
Normal 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)
|
||||
}
|
||||
156
src/hooks/useStartupScreenState.ts
Normal file
156
src/hooks/useStartupScreenState.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
40
src/hooks/useVaultOpenedTelemetry.ts
Normal file
40
src/hooks/useVaultOpenedTelemetry.ts
Normal 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])
|
||||
}
|
||||
62
src/hooks/useVaultRenameDetection.ts
Normal file
62
src/hooks/useVaultRenameDetection.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user