fix: toggle active neighborhood back to notes

This commit is contained in:
lucaronin
2026-05-10 10:56:57 +02:00
parent f8515fb191
commit b4b49eaad2
6 changed files with 516 additions and 152 deletions

View File

@@ -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

View 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)} />
</>
)
}

View 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)
})
})

View 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])
}

View File

@@ -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,

View File

@@ -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 }