fix: save raw note changes before switching notes
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -354,12 +354,20 @@ function App() {
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
||||
})
|
||||
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
|
||||
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
removeEntry: vault.removeEntry,
|
||||
entries: vault.entries,
|
||||
flushBeforePathRename: (path) => appSave.flushBeforeAction(path),
|
||||
flushBeforeNoteSwitch: async (path) => {
|
||||
flushPendingRawContentRef.current?.(path)
|
||||
await appSave.flushBeforeAction(path)
|
||||
},
|
||||
flushBeforePathRename: async (path) => {
|
||||
flushPendingRawContentRef.current?.(path)
|
||||
await appSave.flushBeforeAction(path)
|
||||
},
|
||||
reloadVault: vault.reloadVault,
|
||||
setToastMessage,
|
||||
updateEntry: vault.updateEntry,
|
||||
@@ -1160,6 +1168,7 @@ function App() {
|
||||
isConflicted={conflictFlow.isConflicted}
|
||||
onKeepMine={conflictFlow.handleKeepMine}
|
||||
onKeepTheirs={conflictFlow.handleKeepTheirs}
|
||||
flushPendingRawContentRef={flushPendingRawContentRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,8 @@ interface EditorProps {
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
@@ -224,39 +226,147 @@ function useEditorSetup({
|
||||
}
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
} = props
|
||||
function useRegisterRawContentFlush({
|
||||
activeTab,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
}: {
|
||||
activeTab: Tab | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawMode: boolean
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
}) {
|
||||
const flushPendingRawContent = useCallback((path: string) => {
|
||||
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
const latestContent = rawLatestContentRef.current
|
||||
if (latestContent === null || latestContent === activeTab.content) return
|
||||
|
||||
onContentChange?.(path, latestContent)
|
||||
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushPendingRawContentRef) return
|
||||
|
||||
flushPendingRawContentRef.current = flushPendingRawContent
|
||||
return () => {
|
||||
if (flushPendingRawContentRef.current === flushPendingRawContent) {
|
||||
flushPendingRawContentRef.current = null
|
||||
}
|
||||
}
|
||||
}, [flushPendingRawContent, flushPendingRawContentRef])
|
||||
}
|
||||
|
||||
function EditorLayout({
|
||||
tabs,
|
||||
activeTab,
|
||||
isLoadingNewTab,
|
||||
entries,
|
||||
editor,
|
||||
diffMode,
|
||||
diffContent,
|
||||
diffLoading,
|
||||
handleToggleDiffExclusive,
|
||||
rawMode,
|
||||
handleToggleRawExclusive,
|
||||
onContentChange,
|
||||
onSave,
|
||||
activeStatus,
|
||||
showDiffToggle,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onNavigateWikilink,
|
||||
handleEditorChange,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onDeleteNote,
|
||||
onArchiveNote,
|
||||
onUnarchiveNote,
|
||||
vaultPath,
|
||||
rawModeContent,
|
||||
rawLatestContentRef,
|
||||
onRenameFilename,
|
||||
isConflicted,
|
||||
onKeepMine,
|
||||
onKeepTheirs,
|
||||
onInspectorResize,
|
||||
inspectorWidth,
|
||||
defaultAiAgent,
|
||||
defaultAiAgentReady,
|
||||
inspectorEntry,
|
||||
inspectorContent,
|
||||
gitHistory,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
handleViewCommitDiff,
|
||||
onUpdateFrontmatter,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
onCreateMissingType,
|
||||
onCreateAndOpenNote,
|
||||
onInitializeProperties,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: Tab | null
|
||||
isLoadingNewTab: boolean
|
||||
entries: VaultEntry[]
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
diffMode: boolean
|
||||
diffContent: string | null
|
||||
diffLoading: boolean
|
||||
handleToggleDiffExclusive: () => void | Promise<void>
|
||||
rawMode: boolean
|
||||
handleToggleRawExclusive: () => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
activeStatus: NoteStatus
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed: boolean
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
handleEditorChange: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
rawModeContent: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent: AiAgentId
|
||||
defaultAiAgentReady: boolean
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
gitHistory: GitCommit[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
handleViewCommitDiff: (commitHash: string) => void
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
@@ -330,4 +440,103 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingRawContentRef,
|
||||
} = props
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
useRegisterRawContentFlush({
|
||||
activeTab,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
})
|
||||
|
||||
return (
|
||||
<EditorLayout
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
entries={entries}
|
||||
editor={editor}
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
handleToggleDiffExclusive={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
handleToggleRawExclusive={handleToggleRawExclusive}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
handleEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
onInspectorResize={onInspectorResize}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
gitHistory={gitHistory}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
handleViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface NoteActionsConfig {
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
removeEntry: (path: string) => void
|
||||
entries: VaultEntry[]
|
||||
flushBeforeNoteSwitch?: (path: string) => Promise<void>
|
||||
flushBeforePathRename?: (path: string) => Promise<void>
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
@@ -176,9 +177,89 @@ async function updateFrontmatterAndMaybeRename({
|
||||
config.onFrontmatterPersisted?.()
|
||||
}
|
||||
|
||||
function buildTabManagementOptions(
|
||||
flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'],
|
||||
) {
|
||||
return flushBeforeNoteSwitch
|
||||
? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) }
|
||||
: undefined
|
||||
}
|
||||
|
||||
function useFrontmatterActionHandlers({
|
||||
config,
|
||||
renameTabsRef,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
runFrontmatterOp,
|
||||
}: {
|
||||
config: NoteActionsConfig
|
||||
renameTabsRef: TitleRenameDeps['tabsRef']
|
||||
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleSwitchTab: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
updateTabContent: (path: string, newContent: string) => void
|
||||
runFrontmatterOp: (
|
||||
op: 'update' | 'delete',
|
||||
path: string,
|
||||
key: string,
|
||||
value?: FrontmatterValue,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => Promise<string | undefined>
|
||||
}) {
|
||||
const handleUpdateFrontmatter = useCallback(async (
|
||||
path: string,
|
||||
key: string,
|
||||
value: FrontmatterValue,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => {
|
||||
await updateFrontmatterAndMaybeRename({
|
||||
config,
|
||||
deps: {
|
||||
vaultPath: config.vaultPath,
|
||||
tabsRef: renameTabsRef,
|
||||
reloadVault: config.reloadVault,
|
||||
replaceEntry: config.replaceEntry,
|
||||
onPathRenamed: config.onPathRenamed,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
},
|
||||
path,
|
||||
key,
|
||||
value,
|
||||
options,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
|
||||
|
||||
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [config, runFrontmatterOp])
|
||||
|
||||
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [config, runFrontmatterOp])
|
||||
|
||||
return {
|
||||
handleUpdateFrontmatter,
|
||||
handleDeleteProperty,
|
||||
handleAddProperty,
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteActions(config: NoteActionsConfig) {
|
||||
const { entries, setToastMessage, updateEntry } = config
|
||||
const tabMgmt = useTabManagement()
|
||||
const tabMgmt = useTabManagement(buildTabManagementOptions(config.flushBeforeNoteSwitch))
|
||||
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
|
||||
|
||||
const updateTabContent = useCallback((path: string, newContent: string) => {
|
||||
@@ -201,6 +282,16 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
|
||||
[updateTabContent, updateEntry, setToastMessage, entries],
|
||||
)
|
||||
const frontmatterActions = useFrontmatterActionHandlers({
|
||||
config,
|
||||
renameTabsRef: rename.tabsRef,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
|
||||
return {
|
||||
...tabMgmt,
|
||||
@@ -210,38 +301,9 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
|
||||
handleCreateType: creation.handleCreateType,
|
||||
createTypeEntrySilent: creation.createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
|
||||
await updateFrontmatterAndMaybeRename({
|
||||
config,
|
||||
deps: {
|
||||
vaultPath: config.vaultPath,
|
||||
tabsRef: rename.tabsRef,
|
||||
reloadVault: config.reloadVault,
|
||||
replaceEntry: config.replaceEntry,
|
||||
onPathRenamed: config.onPathRenamed,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
},
|
||||
path,
|
||||
key,
|
||||
value,
|
||||
options,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: frontmatterActions.handleDeleteProperty,
|
||||
handleAddProperty: frontmatterActions.handleAddProperty,
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
handleRenameFilename: rename.handleRenameFilename,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
|
||||
import { cacheNoteContent } from './useTabManagement'
|
||||
|
||||
export async function persistContent(path: string, content: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
@@ -19,6 +20,7 @@ export async function persistContent(path: string, content: string): Promise<voi
|
||||
export function useSaveNote(updateContent: (path: string, content: string) => void) {
|
||||
const saveNote = useCallback(async (path: string, content: string) => {
|
||||
await persistContent(path, content)
|
||||
cacheNoteContent(path, content)
|
||||
if (!isTauri()) {
|
||||
updateMockContent(path, content)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useTabManagement, prefetchNoteContent, clearPrefetchCache } from './useTabManagement'
|
||||
import { useTabManagement, prefetchNoteContent, cacheNoteContent, clearPrefetchCache } from './useTabManagement'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
@@ -47,15 +47,32 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
|
||||
})
|
||||
}
|
||||
|
||||
async function prefetchResolvedContent(path: string, content: string) {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue(content)
|
||||
prefetchNoteContent(path)
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
return mockInvoke
|
||||
}
|
||||
|
||||
function expectSingleActiveTab(result: HookState, path: string) {
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe(path)
|
||||
expect(result.current.activeTabPath).toBe(path)
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useTabManagement (single-note model)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearPrefetchCache()
|
||||
})
|
||||
|
||||
it('starts with no note and null active path', () => {
|
||||
@@ -204,11 +221,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
describe('content prefetch cache', () => {
|
||||
it('prefetch serves content to loadNoteContent (no extra IPC)', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')
|
||||
|
||||
prefetchNoteContent('/vault/note/pre.md')
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })
|
||||
@@ -218,11 +231,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('clearPrefetchCache prevents stale content from being served', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Stale')
|
||||
|
||||
prefetchNoteContent('/vault/note/stale.md')
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/stale.md', '# Stale')
|
||||
|
||||
clearPrefetchCache()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
|
||||
@@ -244,6 +253,18 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('serves refreshed cached content after a save replaces stale prefetched data', async () => {
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content')
|
||||
|
||||
cacheNoteContent('/vault/note/saved.md', '# Persisted content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/note/saved.md', title: 'Saved' })
|
||||
|
||||
expect(result.current.tabs[0].content).toBe('# Persisted content')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rapid switching safety', () => {
|
||||
@@ -277,5 +298,91 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
|
||||
it('waits for beforeNavigate before switching away from the current note', async () => {
|
||||
const beforeNavigate = vi.fn(() => createDeferred<void>().promise)
|
||||
const deferred = createDeferred<void>()
|
||||
beforeNavigate.mockReturnValueOnce(deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
let replaceDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
.then(() => { replaceDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(beforeNavigate).toHaveBeenCalledWith('/vault/a.md', '/vault/b.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
expect(replaceDone).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(replaceDone).toBe(true))
|
||||
expectSingleActiveTab(result, '/vault/b.md')
|
||||
})
|
||||
|
||||
it('keeps only the latest target when note switches overlap during beforeNavigate', async () => {
|
||||
const first = createDeferred<void>()
|
||||
const second = createDeferred<void>()
|
||||
const beforeNavigate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
let switchToBDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
.then(() => { switchToBDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
let switchToCDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/c.md', title: 'C' }))
|
||||
.then(() => { switchToCDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(switchToBDone && switchToCDone).toBe(true))
|
||||
expect(result.current.activeTabPath).toBe('/vault/c.md')
|
||||
})
|
||||
|
||||
it('keeps the current note active when beforeNavigate fails', async () => {
|
||||
const beforeNavigate = vi.fn().mockRejectedValueOnce(new Error('save failed'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to persist note before navigation:',
|
||||
expect.any(Error),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,10 @@ export function prefetchNoteContent(path: string): void {
|
||||
prefetchCache.set(path, promise)
|
||||
}
|
||||
|
||||
export function cacheNoteContent(path: string, content: string): void {
|
||||
prefetchCache.set(path, Promise.resolve(content))
|
||||
}
|
||||
|
||||
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
|
||||
export function clearPrefetchCache(): void {
|
||||
prefetchCache.clear()
|
||||
@@ -49,6 +53,10 @@ async function loadNoteContent(path: string): Promise<string> {
|
||||
|
||||
export type { Tab }
|
||||
|
||||
interface TabManagementOptions {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
}
|
||||
|
||||
function syncActiveTabPath(
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
@@ -112,7 +120,7 @@ async function navigateToEntry(options: {
|
||||
}
|
||||
}
|
||||
|
||||
export function useTabManagement() {
|
||||
export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
// Single-note model: tabs has 0 or 1 elements.
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
|
||||
@@ -123,18 +131,38 @@ export function useTabManagement() {
|
||||
|
||||
// Sequence counter for rapid-switch safety: only the latest navigation wins.
|
||||
const navSeqRef = useRef(0)
|
||||
const beforeNavigateSeqRef = useRef(0)
|
||||
const beforeNavigate = options.beforeNavigate
|
||||
|
||||
const executeNavigationWithBoundary = useCallback(async (
|
||||
targetPath: string,
|
||||
navigate: () => void | Promise<void>,
|
||||
) => {
|
||||
const seq = ++beforeNavigateSeqRef.current
|
||||
const currentPath = activeTabPathRef.current
|
||||
if (beforeNavigate && currentPath && currentPath !== targetPath) {
|
||||
try {
|
||||
await beforeNavigate(currentPath, targetPath)
|
||||
} catch (err) {
|
||||
console.warn('Failed to persist note before navigation:', err)
|
||||
return
|
||||
}
|
||||
if (beforeNavigateSeqRef.current !== seq) return
|
||||
}
|
||||
await navigate()
|
||||
}, [beforeNavigate])
|
||||
|
||||
/** Open a note — replaces the current note (single-note model). */
|
||||
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
||||
await navigateToEntry({
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
}, [])
|
||||
}))
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
|
||||
@@ -142,20 +170,22 @@ export function useTabManagement() {
|
||||
|
||||
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
|
||||
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
}, [])
|
||||
void executeNavigationWithBoundary(entry.path, () => {
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
})
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
|
||||
await navigateToEntry({
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
}, [])
|
||||
}))
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
tabsRef.current = []
|
||||
|
||||
83
tests/smoke/save-before-note-switch.spec.ts
Normal file
83
tests/smoke/save-before-note-switch.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.getByText(title, { exact: true }).click()
|
||||
}
|
||||
|
||||
async function openRawMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function getRawEditorContent(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) return ''
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const view = (el as any).cmTile?.view
|
||||
if (view) return view.state.doc.toString() as string
|
||||
return el.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
async function setRawEditorContent(page: Page, content: string) {
|
||||
await page.evaluate((nextContent) => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) {
|
||||
throw new Error('CodeMirror content element is missing')
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const view = (el as any).cmTile?.view
|
||||
if (!view) {
|
||||
throw new Error('CodeMirror view is missing')
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: nextContent },
|
||||
})
|
||||
}, content)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('@smoke switching notes persists unsaved raw edits without waiting for the debounce window', async ({ page }) => {
|
||||
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
const appendedText = `Flushed before note switch ${Date.now()}`
|
||||
|
||||
await openNote(page, 'Note B')
|
||||
await openRawMode(page)
|
||||
|
||||
const rawContent = await getRawEditorContent(page)
|
||||
await setRawEditorContent(page, `${rawContent}\n\n${appendedText}`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await openNote(page, 'Alpha Project')
|
||||
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project', { timeout: 5_000 })
|
||||
await expect.poll(async () => getRawEditorContent(page)).toContain('# Alpha Project')
|
||||
await expect.poll(
|
||||
() => fs.readFileSync(noteBPath, 'utf8'),
|
||||
{ timeout: 450, intervals: [50, 100, 100, 100, 100] },
|
||||
).toContain(appendedText)
|
||||
})
|
||||
Reference in New Issue
Block a user