fix: toggle active neighborhood back to notes
This commit is contained in:
206
src/App.tsx
206
src/App.tsx
@@ -15,12 +15,10 @@ import { PulseView } from './components/PulseView'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { CloneVaultModal } from './components/CloneVaultModal'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
|
||||
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
||||
import { FeedbackDialog } from './components/FeedbackDialog'
|
||||
import { McpSetupDialog } from './components/McpSetupDialog'
|
||||
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
|
||||
import { StartupScreen } from './components/StartupScreen'
|
||||
import { useTelemetry } from './hooks/useTelemetry'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
||||
@@ -73,6 +71,12 @@ import { useAppSave } from './hooks/useAppSave'
|
||||
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
|
||||
import { useVaultBridge } from './hooks/useVaultBridge'
|
||||
import { useSavedViewOrdering } from './hooks/useSavedViewOrdering'
|
||||
import {
|
||||
useNeighborhoodEntry,
|
||||
useNeighborhoodEscape,
|
||||
useNeighborhoodHistoryBack,
|
||||
useSelectionSanitizer,
|
||||
} from './hooks/useNeighborhoodSelection'
|
||||
import { createViewFilename } from './utils/viewFilename'
|
||||
import { nextViewOrder } from './utils/viewOrdering'
|
||||
import type { CommitDiffRequest } from './hooks/useDiffMode'
|
||||
@@ -115,14 +119,6 @@ import { extractDeletedContentFromDiff } from './components/note-list/noteListUt
|
||||
import { isActiveVaultUnavailableError } from './utils/vaultErrors'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { filenameStemToTitle } from './utils/noteTitle'
|
||||
import {
|
||||
focusNoteListContainer,
|
||||
isEditableElement,
|
||||
isEditorEscapeTarget,
|
||||
popNeighborhoodHistory,
|
||||
pushNeighborhoodHistory,
|
||||
shouldProcessNeighborhoodEscape,
|
||||
} from './utils/neighborhoodHistory'
|
||||
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
@@ -244,18 +240,15 @@ function App() {
|
||||
if (!options?.preserveNeighborhoodHistory && sel.kind !== 'entity') {
|
||||
neighborhoodHistoryRef.current = []
|
||||
}
|
||||
selectionRef.current = sel
|
||||
setSelection(sel)
|
||||
setNoteListFilter('open')
|
||||
}, [])
|
||||
const handleEnterNeighborhood = useCallback((entry: VaultEntry) => {
|
||||
const nextSelection: SidebarSelection = { kind: 'entity', entry }
|
||||
neighborhoodHistoryRef.current = pushNeighborhoodHistory(
|
||||
neighborhoodHistoryRef.current,
|
||||
selectionRef.current,
|
||||
nextSelection,
|
||||
)
|
||||
handleSetSelection(nextSelection, { preserveNeighborhoodHistory: true })
|
||||
}, [handleSetSelection])
|
||||
const handleEnterNeighborhood = useNeighborhoodEntry({
|
||||
neighborhoodHistoryRef,
|
||||
selectionRef,
|
||||
setSelection: handleSetSelection,
|
||||
})
|
||||
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
|
||||
const { setInspectorCollapsed } = layout
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
@@ -419,31 +412,19 @@ function App() {
|
||||
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
|
||||
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
|
||||
|
||||
useEffect(() => {
|
||||
selectionRef.current = effectiveSelection
|
||||
}, [effectiveSelection])
|
||||
useSelectionSanitizer({
|
||||
effectiveSelection,
|
||||
neighborhoodHistoryRef,
|
||||
selection,
|
||||
selectionRef,
|
||||
setNoteListFilter,
|
||||
setSelection,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSelection !== selection) {
|
||||
if (effectiveSelection.kind !== 'entity') {
|
||||
neighborhoodHistoryRef.current = []
|
||||
}
|
||||
setSelection(effectiveSelection)
|
||||
setNoteListFilter('open')
|
||||
}
|
||||
}, [effectiveSelection, selection])
|
||||
|
||||
const handleNeighborhoodHistoryBack = useCallback(() => {
|
||||
const { previousSelection, nextHistory } = popNeighborhoodHistory(neighborhoodHistoryRef.current)
|
||||
if (!previousSelection) return false
|
||||
|
||||
neighborhoodHistoryRef.current = nextHistory
|
||||
handleSetSelection(previousSelection, { preserveNeighborhoodHistory: true })
|
||||
requestAnimationFrame(() => {
|
||||
focusNoteListContainer(document)
|
||||
})
|
||||
return true
|
||||
}, [handleSetSelection])
|
||||
const handleNeighborhoodHistoryBack = useNeighborhoodHistoryBack({
|
||||
neighborhoodHistoryRef,
|
||||
setSelection: handleSetSelection,
|
||||
})
|
||||
|
||||
const handleSaveExplicitOrganization = useCallback((enabled: boolean) => {
|
||||
updateConfig('inbox', {
|
||||
@@ -1409,30 +1390,11 @@ function App() {
|
||||
|| showFeedback
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
if (isEditorEscapeTarget(activeElement)) {
|
||||
event.preventDefault()
|
||||
activeElement.blur()
|
||||
requestAnimationFrame(() => {
|
||||
focusNoteListContainer(document)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableElement(activeElement)) return
|
||||
|
||||
if (handleNeighborhoodHistoryBack()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
|
||||
useNeighborhoodEscape({
|
||||
onBack: handleNeighborhoodHistoryBack,
|
||||
selectionRef,
|
||||
shouldBlockNeighborhoodEscape,
|
||||
})
|
||||
|
||||
const noteListColumnsLabel = useMemo(() => {
|
||||
if (effectiveSelection.kind === 'view') {
|
||||
@@ -1684,56 +1646,36 @@ function App() {
|
||||
|
||||
const isStartupLoading = !noteWindowParams && onboarding.state.status === 'loading'
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows).
|
||||
// After the user answers, the next render can continue into onboarding.
|
||||
if (!noteWindowParams && !isStartupLoading && settingsLoaded && settings.telemetry_consent === null) {
|
||||
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)
|
||||
)
|
||||
if (shouldShowStartupScreen) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
const id = crypto.randomUUID()
|
||||
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
|
||||
}}
|
||||
onDecline={() => {
|
||||
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
|
||||
}}
|
||||
<StartupScreen
|
||||
aiAgentsOnboarding={aiAgentsOnboarding}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
isOffline={networkStatus.isOffline}
|
||||
isStartupLoading={isStartupLoading}
|
||||
noteWindowParams={noteWindowParams}
|
||||
onboarding={onboarding}
|
||||
runtimeMissingVaultPath={runtimeMissingVaultPath}
|
||||
saveSettings={saveSettings}
|
||||
settings={settings}
|
||||
settingsLoaded={settingsLoaded}
|
||||
shouldResumeFreshStartOnboarding={shouldResumeFreshStartOnboarding}
|
||||
showMcpSetupDialog={showMcpSetupDialog}
|
||||
setToastMessage={setToastMessage}
|
||||
toastMessage={toastMessage}
|
||||
vaultSwitcher={vaultSwitcher}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
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} />
|
||||
}
|
||||
|
||||
if (
|
||||
!noteWindowParams
|
||||
&& onboarding.state.status === 'ready'
|
||||
&& aiAgentsOnboarding.showPrompt
|
||||
&& !showMcpSetupDialog
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<AiAgentsOnboardingView
|
||||
statuses={aiAgentsStatus}
|
||||
onContinue={aiAgentsOnboarding.dismissPrompt}
|
||||
/>
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const isVaultContentLoading = !noteWindowParams && (isStartupLoading || (onboarding.state.status === 'ready' && vault.isLoading))
|
||||
|
||||
return (
|
||||
@@ -1905,42 +1847,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
type OnboardingState = ReturnType<typeof useOnboarding>
|
||||
|
||||
/** Welcome screen view - extracted from main App component */
|
||||
function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) {
|
||||
const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string }
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<WelcomeScreen
|
||||
mode={state.status === 'welcome' ? 'welcome' : 'vault-missing'}
|
||||
missingPath={state.status === 'vault-missing' ? state.vaultPath : undefined}
|
||||
defaultVaultPath={state.defaultPath}
|
||||
onCreateVault={onboarding.handleCreateVault}
|
||||
onRetryCreateVault={onboarding.retryCreateVault}
|
||||
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
|
||||
onOpenFolder={onboarding.handleOpenFolder}
|
||||
isOffline={isOffline}
|
||||
creatingAction={onboarding.creatingAction}
|
||||
error={onboarding.error}
|
||||
canRetryTemplate={onboarding.canRetryTemplate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AiAgentsOnboardingView({
|
||||
statuses,
|
||||
onContinue,
|
||||
}: {
|
||||
statuses: ReturnType<typeof useAiAgentsStatus>
|
||||
onContinue: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<AiAgentsOnboardingPrompt statuses={statuses} onContinue={onContinue} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
156
src/components/StartupScreen.tsx
Normal file
156
src/components/StartupScreen.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt'
|
||||
import { TelemetryConsentDialog } from './TelemetryConsentDialog'
|
||||
import { Toast } from './Toast'
|
||||
import { WelcomeScreen } from './WelcomeScreen'
|
||||
import type { useAiAgentsOnboarding } from '../hooks/useAiAgentsOnboarding'
|
||||
import type { useAiAgentsStatus } from '../hooks/useAiAgentsStatus'
|
||||
import type { useOnboarding } from '../hooks/useOnboarding'
|
||||
import type { useVaultSwitcher } from '../hooks/useVaultSwitcher'
|
||||
import type { Settings } from '../types'
|
||||
import type { NoteWindowParams } from '../utils/windowMode'
|
||||
|
||||
type OnboardingState = ReturnType<typeof useOnboarding>
|
||||
type VaultSwitcherState = ReturnType<typeof useVaultSwitcher>
|
||||
type AiAgentsOnboardingState = ReturnType<typeof useAiAgentsOnboarding>
|
||||
|
||||
export interface StartupScreenParams {
|
||||
aiAgentsOnboarding: AiAgentsOnboardingState
|
||||
aiAgentsStatus: ReturnType<typeof useAiAgentsStatus>
|
||||
isOffline: boolean
|
||||
isStartupLoading: boolean
|
||||
noteWindowParams: NoteWindowParams | null
|
||||
onboarding: OnboardingState
|
||||
runtimeMissingVaultPath: string | null
|
||||
saveSettings: (settings: Settings) => Promise<void>
|
||||
settings: Settings
|
||||
settingsLoaded: boolean
|
||||
shouldResumeFreshStartOnboarding: boolean
|
||||
showMcpSetupDialog: boolean
|
||||
setToastMessage: (message: string | null) => void
|
||||
toastMessage: string | null
|
||||
vaultSwitcher: VaultSwitcherState
|
||||
}
|
||||
|
||||
function shouldShowTelemetryConsent(params: StartupScreenParams): boolean {
|
||||
return !params.noteWindowParams
|
||||
&& !params.isStartupLoading
|
||||
&& params.settingsLoaded
|
||||
&& params.settings.telemetry_consent === null
|
||||
}
|
||||
|
||||
function shouldShowWelcomeView(params: StartupScreenParams): boolean {
|
||||
return !params.noteWindowParams
|
||||
&& (
|
||||
Boolean(params.runtimeMissingVaultPath)
|
||||
|| params.onboarding.state.status === 'welcome'
|
||||
|| params.onboarding.state.status === 'vault-missing'
|
||||
|| params.shouldResumeFreshStartOnboarding
|
||||
)
|
||||
}
|
||||
|
||||
function welcomeOnboardingState(params: StartupScreenParams): OnboardingState {
|
||||
if (params.runtimeMissingVaultPath) {
|
||||
return {
|
||||
...params.onboarding,
|
||||
state: {
|
||||
status: 'vault-missing' as const,
|
||||
vaultPath: params.runtimeMissingVaultPath,
|
||||
defaultPath: params.vaultSwitcher.defaultPath || params.runtimeMissingVaultPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (params.shouldResumeFreshStartOnboarding) {
|
||||
return { ...params.onboarding, state: { status: 'welcome' as const, defaultPath: params.vaultSwitcher.vaultPath } }
|
||||
}
|
||||
return params.onboarding
|
||||
}
|
||||
|
||||
function shouldShowAiAgentsOnboarding(params: StartupScreenParams): boolean {
|
||||
return !params.noteWindowParams
|
||||
&& params.onboarding.state.status === 'ready'
|
||||
&& params.aiAgentsOnboarding.showPrompt
|
||||
&& !params.showMcpSetupDialog
|
||||
}
|
||||
|
||||
function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) {
|
||||
const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string }
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<WelcomeScreen
|
||||
mode={state.status === 'welcome' ? 'welcome' : 'vault-missing'}
|
||||
missingPath={state.status === 'vault-missing' ? state.vaultPath : undefined}
|
||||
defaultVaultPath={state.defaultPath}
|
||||
onCreateVault={onboarding.handleCreateVault}
|
||||
onRetryCreateVault={onboarding.retryCreateVault}
|
||||
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
|
||||
onOpenFolder={onboarding.handleOpenFolder}
|
||||
isOffline={isOffline}
|
||||
creatingAction={onboarding.creatingAction}
|
||||
error={onboarding.error}
|
||||
canRetryTemplate={onboarding.canRetryTemplate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AiAgentsOnboardingView({
|
||||
statuses,
|
||||
onContinue,
|
||||
}: {
|
||||
statuses: ReturnType<typeof useAiAgentsStatus>
|
||||
onContinue: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<AiAgentsOnboardingPrompt statuses={statuses} onContinue={onContinue} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StartupScreen(params: StartupScreenParams) {
|
||||
if (shouldShowTelemetryConsent(params)) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
const id = crypto.randomUUID()
|
||||
params.saveSettings({
|
||||
...params.settings,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: true,
|
||||
analytics_enabled: true,
|
||||
anonymous_id: id,
|
||||
})
|
||||
}}
|
||||
onDecline={() => {
|
||||
params.saveSettings({
|
||||
...params.settings,
|
||||
telemetry_consent: false,
|
||||
crash_reporting_enabled: false,
|
||||
analytics_enabled: false,
|
||||
anonymous_id: null,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldShowWelcomeView(params)) {
|
||||
return (
|
||||
<WelcomeView
|
||||
onboarding={welcomeOnboardingState(params)}
|
||||
isOffline={params.isOffline}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!shouldShowAiAgentsOnboarding(params)) return null
|
||||
return (
|
||||
<>
|
||||
<AiAgentsOnboardingView
|
||||
statuses={params.aiAgentsStatus}
|
||||
onContinue={params.aiAgentsOnboarding.dismissPrompt}
|
||||
/>
|
||||
<Toast message={params.toastMessage} onDismiss={() => params.setToastMessage(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
121
src/hooks/useNeighborhoodSelection.test.ts
Normal file
121
src/hooks/useNeighborhoodSelection.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { SidebarSelection, VaultEntry } from '../types'
|
||||
import {
|
||||
useNeighborhoodEntry,
|
||||
useNeighborhoodEscape,
|
||||
} from './useNeighborhoodSelection'
|
||||
|
||||
function buildEntry(path: string, title: string): VaultEntry {
|
||||
return {
|
||||
path,
|
||||
filename: `${title.toLowerCase()}.md`,
|
||||
title,
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1,
|
||||
createdAt: null,
|
||||
fileSize: 1,
|
||||
snippet: '',
|
||||
wordCount: 1,
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
function ref<T>(current: T): MutableRefObject<T> {
|
||||
return { current }
|
||||
}
|
||||
|
||||
const inboxSelection: SidebarSelection = { kind: 'filter', filter: 'inbox' }
|
||||
const alphaSelection: SidebarSelection = { kind: 'entity', entry: buildEntry('/vault/alpha.md', 'Alpha') }
|
||||
const betaSelection: SidebarSelection = { kind: 'entity', entry: buildEntry('/vault/beta.md', 'Beta') }
|
||||
|
||||
describe('useNeighborhoodEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 0
|
||||
})
|
||||
document.body.innerHTML = '<div data-testid="note-list-container" tabindex="-1"></div>'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('toggles the repeated active neighborhood action back to all notes', () => {
|
||||
const selectionRef = ref(alphaSelection)
|
||||
const historyRef = ref<SidebarSelection[]>([inboxSelection])
|
||||
const setSelection = vi.fn((selection: SidebarSelection) => {
|
||||
selectionRef.current = selection
|
||||
})
|
||||
const { result } = renderHook(() => useNeighborhoodEntry({
|
||||
neighborhoodHistoryRef: historyRef,
|
||||
selectionRef,
|
||||
setSelection,
|
||||
}))
|
||||
|
||||
act(() => result.current(alphaSelection.entry))
|
||||
|
||||
expect(setSelection).toHaveBeenCalledWith({ kind: 'filter', filter: 'all' })
|
||||
expect(historyRef.current).toEqual([inboxSelection])
|
||||
expect(document.activeElement).toBe(document.querySelector('[data-testid="note-list-container"]'))
|
||||
})
|
||||
|
||||
it('switches between neighborhoods without collapsing to all notes', () => {
|
||||
const selectionRef = ref(alphaSelection)
|
||||
const historyRef = ref<SidebarSelection[]>([inboxSelection])
|
||||
const setSelection = vi.fn()
|
||||
const { result } = renderHook(() => useNeighborhoodEntry({
|
||||
neighborhoodHistoryRef: historyRef,
|
||||
selectionRef,
|
||||
setSelection,
|
||||
}))
|
||||
|
||||
act(() => result.current(betaSelection.entry))
|
||||
|
||||
expect(setSelection).toHaveBeenCalledWith(betaSelection, { preserveNeighborhoodHistory: true })
|
||||
expect(historyRef.current).toEqual([inboxSelection, alphaSelection])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNeighborhoodEscape', () => {
|
||||
it('routes Escape to neighborhood history when focus is already outside editable controls', () => {
|
||||
const onBack = vi.fn(() => true)
|
||||
renderHook(() => useNeighborhoodEscape({
|
||||
onBack,
|
||||
selectionRef: ref(alphaSelection),
|
||||
shouldBlockNeighborhoodEscape: false,
|
||||
}))
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
|
||||
expect(onBack).toHaveBeenCalledOnce()
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
145
src/hooks/useNeighborhoodSelection.ts
Normal file
145
src/hooks/useNeighborhoodSelection.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { SidebarSelection, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import {
|
||||
focusNoteListContainer,
|
||||
isEditableElement,
|
||||
isEditorEscapeTarget,
|
||||
popNeighborhoodHistory,
|
||||
pushNeighborhoodHistory,
|
||||
resolveNeighborhoodSelection,
|
||||
shouldProcessNeighborhoodEscape,
|
||||
} from '../utils/neighborhoodHistory'
|
||||
|
||||
interface SetSelectionOptions {
|
||||
preserveNeighborhoodHistory?: boolean
|
||||
}
|
||||
|
||||
type SetSelection = (selection: SidebarSelection, options?: SetSelectionOptions) => void
|
||||
|
||||
interface NeighborhoodRefs {
|
||||
neighborhoodHistoryRef: MutableRefObject<SidebarSelection[]>
|
||||
selectionRef: MutableRefObject<SidebarSelection>
|
||||
}
|
||||
|
||||
interface UseNeighborhoodEntryOptions extends NeighborhoodRefs {
|
||||
setSelection: SetSelection
|
||||
}
|
||||
|
||||
interface UseSelectionSanitizerOptions extends NeighborhoodRefs {
|
||||
effectiveSelection: SidebarSelection
|
||||
selection: SidebarSelection
|
||||
setNoteListFilter: (filter: NoteListFilter) => void
|
||||
setSelection: (selection: SidebarSelection) => void
|
||||
}
|
||||
|
||||
interface UseNeighborhoodHistoryBackOptions {
|
||||
neighborhoodHistoryRef: MutableRefObject<SidebarSelection[]>
|
||||
setSelection: SetSelection
|
||||
}
|
||||
|
||||
interface UseNeighborhoodEscapeOptions {
|
||||
onBack: () => boolean
|
||||
selectionRef: MutableRefObject<SidebarSelection>
|
||||
shouldBlockNeighborhoodEscape: boolean
|
||||
}
|
||||
|
||||
function focusNoteListOnNextFrame(): void {
|
||||
requestAnimationFrame(() => {
|
||||
focusNoteListContainer(document)
|
||||
})
|
||||
}
|
||||
|
||||
export function useNeighborhoodEntry({
|
||||
neighborhoodHistoryRef,
|
||||
selectionRef,
|
||||
setSelection,
|
||||
}: UseNeighborhoodEntryOptions) {
|
||||
return useCallback((entry: VaultEntry) => {
|
||||
const currentSelection = selectionRef.current
|
||||
const nextSelection = resolveNeighborhoodSelection(currentSelection, entry)
|
||||
trackEvent('neighborhood_mode_toggled', { action: nextSelection.action })
|
||||
|
||||
if (nextSelection.action === 'exit') {
|
||||
setSelection(nextSelection.selection)
|
||||
focusNoteListOnNextFrame()
|
||||
return
|
||||
}
|
||||
|
||||
neighborhoodHistoryRef.current = pushNeighborhoodHistory(
|
||||
neighborhoodHistoryRef.current,
|
||||
currentSelection,
|
||||
nextSelection.selection,
|
||||
)
|
||||
setSelection(nextSelection.selection, { preserveNeighborhoodHistory: true })
|
||||
}, [neighborhoodHistoryRef, selectionRef, setSelection])
|
||||
}
|
||||
|
||||
export function useSelectionSanitizer({
|
||||
effectiveSelection,
|
||||
neighborhoodHistoryRef,
|
||||
selection,
|
||||
selectionRef,
|
||||
setNoteListFilter,
|
||||
setSelection,
|
||||
}: UseSelectionSanitizerOptions): void {
|
||||
useEffect(() => {
|
||||
selectionRef.current = effectiveSelection
|
||||
}, [effectiveSelection, selectionRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSelection === selection) return
|
||||
|
||||
if (effectiveSelection.kind !== 'entity') {
|
||||
neighborhoodHistoryRef.current = []
|
||||
}
|
||||
setSelection(effectiveSelection)
|
||||
setNoteListFilter('open')
|
||||
}, [effectiveSelection, neighborhoodHistoryRef, selection, setNoteListFilter, setSelection])
|
||||
}
|
||||
|
||||
export function useNeighborhoodHistoryBack({
|
||||
neighborhoodHistoryRef,
|
||||
setSelection,
|
||||
}: UseNeighborhoodHistoryBackOptions) {
|
||||
return useCallback(() => {
|
||||
const { previousSelection, nextHistory } = popNeighborhoodHistory(neighborhoodHistoryRef.current)
|
||||
if (!previousSelection) return false
|
||||
|
||||
neighborhoodHistoryRef.current = nextHistory
|
||||
setSelection(previousSelection, { preserveNeighborhoodHistory: true })
|
||||
focusNoteListOnNextFrame()
|
||||
return true
|
||||
}, [neighborhoodHistoryRef, setSelection])
|
||||
}
|
||||
|
||||
export function useNeighborhoodEscape({
|
||||
onBack,
|
||||
selectionRef,
|
||||
shouldBlockNeighborhoodEscape,
|
||||
}: UseNeighborhoodEscapeOptions): void {
|
||||
useEffect(() => {
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
if (isEditorEscapeTarget(activeElement)) {
|
||||
event.preventDefault()
|
||||
activeElement.blur()
|
||||
focusNoteListOnNextFrame()
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableElement(activeElement)) return
|
||||
|
||||
if (onBack()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}, [onBack, selectionRef, shouldBlockNeighborhoodEscape])
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isEditableElement,
|
||||
popNeighborhoodHistory,
|
||||
pushNeighborhoodHistory,
|
||||
resolveNeighborhoodSelection,
|
||||
selectionsEqual,
|
||||
shouldProcessNeighborhoodEscape,
|
||||
} from './neighborhoodHistory'
|
||||
@@ -62,6 +63,21 @@ describe('neighborhoodHistory', () => {
|
||||
expect(pushNeighborhoodHistory([inboxSelection], alphaSelection, alphaSelection)).toEqual([inboxSelection])
|
||||
})
|
||||
|
||||
it('resolves Neighborhood entry as enter, switch, or exit actions', () => {
|
||||
expect(resolveNeighborhoodSelection(inboxSelection, alphaSelection.entry)).toEqual({
|
||||
action: 'enter',
|
||||
selection: alphaSelection,
|
||||
})
|
||||
expect(resolveNeighborhoodSelection(alphaSelection, betaSelection.entry)).toEqual({
|
||||
action: 'switch',
|
||||
selection: betaSelection,
|
||||
})
|
||||
expect(resolveNeighborhoodSelection(alphaSelection, buildEntry('/vault/alpha.md', 'Alpha copy'))).toEqual({
|
||||
action: 'exit',
|
||||
selection: { kind: 'filter', filter: 'all' },
|
||||
})
|
||||
})
|
||||
|
||||
it('pops one level of note-list history at a time', () => {
|
||||
expect(popNeighborhoodHistory([inboxSelection, alphaSelection])).toEqual({
|
||||
previousSelection: alphaSelection,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { SidebarSelection } from '../types'
|
||||
import type { SidebarSelection, VaultEntry } from '../types'
|
||||
|
||||
const NOTE_LIST_CONTAINER_SELECTOR = '[data-testid="note-list-container"]'
|
||||
const EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .cm-editor'
|
||||
|
||||
export type NeighborhoodSelectionAction = 'enter' | 'switch' | 'exit'
|
||||
|
||||
export interface NeighborhoodSelectionUpdate {
|
||||
action: NeighborhoodSelectionAction
|
||||
selection: SidebarSelection
|
||||
}
|
||||
|
||||
function isSameFilterSelection(a: Extract<SidebarSelection, { kind: 'filter' }>, b: Extract<SidebarSelection, { kind: 'filter' }>) {
|
||||
return a.filter === b.filter
|
||||
}
|
||||
@@ -49,6 +56,21 @@ export function pushNeighborhoodHistory(
|
||||
return [...history, currentSelection]
|
||||
}
|
||||
|
||||
export function resolveNeighborhoodSelection(
|
||||
currentSelection: SidebarSelection,
|
||||
entry: VaultEntry,
|
||||
): NeighborhoodSelectionUpdate {
|
||||
const nextSelection: SidebarSelection = { kind: 'entity', entry }
|
||||
if (selectionsEqual(currentSelection, nextSelection)) {
|
||||
return { action: 'exit', selection: { kind: 'filter', filter: 'all' } }
|
||||
}
|
||||
|
||||
return {
|
||||
action: currentSelection.kind === 'entity' ? 'switch' : 'enter',
|
||||
selection: nextSelection,
|
||||
}
|
||||
}
|
||||
|
||||
export function popNeighborhoodHistory(history: SidebarSelection[]) {
|
||||
if (history.length === 0) return { previousSelection: null, nextHistory: history }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user