Compare commits

...

11 Commits

Author SHA1 Message Date
lucaronin
3ee42fb856 test: align create-note smoke placeholder 2026-04-20 22:13:19 +02:00
lucaronin
eacf05a9f8 fix: reload notes after agent edits 2026-04-20 22:04:38 +02:00
lucaronin
a199fad803 fix: widen editor focus block typing 2026-04-20 18:38:51 +02:00
lucaronin
e956c806e5 fix: keep editor sync from clobbering live inserts 2026-04-20 18:35:50 +02:00
lucaronin
7a6e3863ad fix: reopen active note after pull updates 2026-04-20 16:04:11 +02:00
lucaronin
e0e2dc4063 test: add pull refresh regression 2026-04-20 15:53:56 +02:00
lucaronin
0020ade6d7 fix: tighten pulled note refresh flow 2026-04-20 14:43:52 +02:00
lucaronin
503e482c7d fix: normalize inverse relationship labels 2026-04-20 12:49:57 +02:00
lucaronin
0d0e6af4c9 fix: keep vault menu remove action in row 2026-04-20 12:19:10 +02:00
lucaronin
dbaf361878 test: fix starter-vault app shell coverage 2026-04-20 11:32:44 +02:00
lucaronin
d3741c82f5 test: fix starter-vault shell regression 2026-04-20 11:20:27 +02:00
34 changed files with 1694 additions and 535 deletions

View File

@@ -273,7 +273,7 @@ type SidebarSelection =
- The selected `entry` is the neighborhood source note.
- The source note stays pinned at the top of the note list as a standard active row, not a special card.
- Outgoing relationship groups render first using the note's `relationships` map.
- Inverse groups (`Children`, `Events`, `Referenced By`) and `Backlinks` render after the outgoing groups.
- Inverse groups (`Children`, `Events`, `Referenced by`) and `Backlinks` render after the outgoing groups.
- Empty groups stay visible with count `0`.
- Notes may appear in multiple groups when multiple relationships are true; Neighborhood mode does not deduplicate them across sections.
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
@@ -395,6 +395,8 @@ interface PulseCommit {
`useAutoSync` hook handles automatic git sync:
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
- Pulls on interval, pushes after commits
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
- Reopens the clean active tab from disk after a successful pull update so the editor and note list stay aligned
- Detects merge conflicts → opens `ConflictResolverModal`
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
- Handles push rejection (divergence) → sets `pull_required` status

View File

@@ -531,7 +531,11 @@ flowchart TD
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
PULL --> PC{Result?}
PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"]
PC -->|Fast-forward| RV["reload vault"]
PC -->|Fast-forward| RV["reload vault + folders/views"]
RV --> TAB{"clean active tab?"}
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
TAB -->|No| DONE["idle"]
RT --> DONE
PC -->|Up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]

View File

@@ -570,9 +570,10 @@ describe('App', () => {
})
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
const workVaultPath = '/Users/mock/Documents/Work'
mockCommandResults.load_vault_list = {
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
active_vault: '/Users/mock/Documents/Getting Started',
vaults: [{ label: 'Work Vault', path: workVaultPath }],
active_vault: workVaultPath,
hidden_defaults: [],
}
const disabledWorkflowConfig = JSON.stringify({
@@ -584,14 +585,7 @@ describe('App', () => {
property_display_modes: null,
inbox: { noteListProperties: null, explicitOrganization: false },
})
const vaultPaths = [
'/Users/mock/Documents/Getting Started',
'/Users/mock/demo-vault-v2',
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
]
for (const path of vaultPaths) {
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
}
localStorage.setItem(`laputa:vault-config:${workVaultPath}`, disabledWorkflowConfig)
render(<App />)

View File

@@ -74,6 +74,7 @@ import type { NoteListItem } from './utils/ai-context'
import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode'
import { GitRequiredModal } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
@@ -404,17 +405,6 @@ function App() {
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: vault.reloadVault,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
},
onToast: (msg) => setToastMessage(msg),
})
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
// Detect external file renames on window focus
@@ -487,8 +477,41 @@ function App() {
const {
handleSelectNote,
handleReplaceActiveTab,
closeAllTabs,
openTabWithContent,
} = notes
const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => {
await refreshPulledVaultState({
activeTabPath: notes.activeTabPath,
closeAllTabs,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
reloadFolders: vault.reloadFolders,
reloadVault: vault.reloadVault,
reloadViews: vault.reloadViews,
replaceActiveTab: handleReplaceActiveTab,
updatedFiles,
vaultPath: resolvedPath,
})
}, [
closeAllTabs,
handleReplaceActiveTab,
notes.activeTabPath,
resolvedPath,
vault.reloadFolders,
vault.reloadVault,
vault.reloadViews,
vault.unsavedPaths,
])
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: handlePulledVaultUpdate,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
},
onToast: (msg) => setToastMessage(msg),
})
const pulseCommitDiffRequestIdRef = useRef(0)
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
@@ -592,8 +615,14 @@ function App() {
}, [handleEnterNeighborhood, handleReplaceActiveTab])
const vaultBridge = useVaultBridge({
entriesByPath, resolvedPath,
entriesByPath,
resolvedPath,
reloadVault: vault.reloadVault,
reloadFolders: vault.reloadFolders,
reloadViews: vault.reloadViews,
closeAllTabs,
replaceActiveTab: handleReplaceActiveTab,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
onSelectNote: notes.handleSelectNote,
activeTabPath: notes.activeTabPath,
})

View File

@@ -528,6 +528,37 @@ describe('click empty editor space', () => {
container.removeChild(editableDiv)
})
it('restores the cursor to the H1 when clicking the title block', async () => {
mockEditor.focus.mockClear()
mockEditor.setTextCursorPosition.mockClear()
mockEditor.document = [
{ id: 'title', type: 'heading', content: [{ type: 'text', text: 'Alpha Project', styles: {} }], props: { level: 1 }, children: [] },
{ id: 'body', type: 'paragraph', content: [], props: {}, children: [] },
]
render(
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
)
const container = document.querySelector('.editor__blocknote-container')!
const editableDiv = document.createElement('div')
editableDiv.setAttribute('contenteditable', 'true')
const heading = document.createElement('h1')
heading.textContent = 'Alpha Project'
heading.setAttribute('data-content-type', 'heading')
heading.setAttribute('data-level', '1')
editableDiv.appendChild(heading)
container.appendChild(editableDiv)
fireEvent.click(heading)
await act(() => Promise.resolve())
expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('title', 'end')
expect(mockEditor.focus).toHaveBeenCalled()
container.removeChild(editableDiv)
})
})
describe('archived note behavior', () => {
@@ -598,6 +629,12 @@ describe('wikilink autocomplete', () => {
expect(mockFilterSuggestionItems).toHaveBeenCalled()
})
it('normalizes BlockNote trigger-prefixed wikilink queries before filtering', async () => {
renderWithEntries()
const items = await capturedGetItems!('[[Al')
expect(items.length).toBeGreaterThan(0)
})
it('limits results to MAX_RESULTS (20)', async () => {
// Create many entries that will all match
const manyEntries = Array.from({ length: 50 }, (_, i) => ({
@@ -633,7 +670,7 @@ describe('wikilink autocomplete', () => {
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'vault/project/test' } },
' ',
])
], { updateSelection: true })
})
it('deduplicates entries with the same path', async () => {
@@ -790,7 +827,7 @@ describe('person @mention autocomplete', () => {
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
' ',
])
], { updateSelection: true })
})
it('shows Person type badge on results', async () => {

View File

@@ -451,8 +451,8 @@ Status: Active
/>
)
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
})
it('hides referenced-by section when no entries reference the current note', () => {
@@ -559,7 +559,7 @@ Status: Active
/>
)
// noteA shows in Referenced By (via Belongs to)
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()

View File

@@ -265,17 +265,29 @@ describe('DynamicRelationshipsPanel', () => {
describe('relation editing', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
const renderEditableRelationships = (
overrides: Partial<ComponentProps<typeof DynamicRelationshipsPanel>> = {},
) => renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
...overrides,
})
const openInlineAdd = (value?: string) => {
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
if (value !== undefined) {
fireEvent.change(input, { target: { value } })
}
return input
}
beforeEach(() => {
vi.clearAllMocks()
})
it('shows remove buttons on relation refs when editing is enabled', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
renderEditableRelationships()
expect(screen.getByTestId('remove-relation-ref')).toBeInTheDocument()
})
@@ -285,91 +297,59 @@ describe('DynamicRelationshipsPanel', () => {
})
it('calls onDeleteProperty when removing the last ref in a group', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
renderEditableRelationships()
fireEvent.click(screen.getByTestId('remove-relation-ref'))
expect(onDeleteProperty).toHaveBeenCalledWith('Belongs to')
})
it('calls onUpdateProperty with remaining refs when removing one of many', () => {
renderRelationshipsPanel({
it.each([
{
name: 'calls onUpdateProperty with remaining refs when removing one of many',
frontmatter: { 'Related to': ['[[project/my-project]]', '[[topic/ai]]'] },
onUpdateProperty,
onDeleteProperty,
})
const removeButtons = screen.getAllByTestId('remove-relation-ref')
fireEvent.click(removeButtons[0]) // Remove first ref
expect(onUpdateProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
})
it('calls onUpdateProperty with string when two refs become one', () => {
renderRelationshipsPanel({
expectedKey: 'Related to',
expectedValue: '[[topic/ai]]',
},
{
name: 'calls onUpdateProperty with string when two refs become one',
frontmatter: { Has: ['[[project/my-project]]', '[[topic/ai]]'] },
onUpdateProperty,
onDeleteProperty,
})
expectedKey: 'Has',
expectedValue: '[[topic/ai]]',
},
])('$name', ({ frontmatter, expectedKey, expectedValue }) => {
renderEditableRelationships({ frontmatter })
const removeButtons = screen.getAllByTestId('remove-relation-ref')
fireEvent.click(removeButtons[0])
// Should pass a single string, not an array of one
expect(onUpdateProperty).toHaveBeenCalledWith('Has', '[[topic/ai]]')
expect(onUpdateProperty).toHaveBeenCalledWith(expectedKey, expectedValue)
})
it('shows inline add button for each relationship group when editing is enabled', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
renderEditableRelationships()
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
})
it('opens inline add input when add button clicked', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
renderEditableRelationships()
openInlineAdd()
expect(screen.getByTestId('add-relation-ref-input')).toBeInTheDocument()
})
it('adds a note to an existing relationship via inline add', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
renderEditableRelationships()
const input = openInlineAdd('AI')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
})
it('does not add duplicate refs', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[topic/ai]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
renderEditableRelationships({ frontmatter: { 'Belongs to': ['[[topic/ai]]'] } })
const input = openInlineAdd('AI')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).not.toHaveBeenCalled()
})
it('closes inline add on Escape', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
renderEditableRelationships()
const input = openInlineAdd()
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
@@ -380,6 +360,21 @@ describe('DynamicRelationshipsPanel', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
const renderInlineCreatePanel = (
overrides: Partial<ComponentProps<typeof DynamicRelationshipsPanel>> = {},
) => renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
...overrides,
})
const openInlineCreate = (value: string) => {
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value } })
return input
}
beforeEach(() => {
vi.clearAllMocks()
@@ -387,43 +382,22 @@ describe('DynamicRelationshipsPanel', () => {
})
it('shows "Create & open" option when typed title does not match any note', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
renderInlineCreatePanel()
openInlineCreate('Brand New Note')
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
expect(screen.getByText(/Create & open/)).toBeInTheDocument()
expect(screen.getByText(/Brand New Note/)).toBeInTheDocument()
})
it('does not show "Create & open" when typed title matches an existing note', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
renderInlineCreatePanel()
openInlineCreate('AI')
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
})
it('calls onCreateAndOpenNote and adds wikilink when "Create & open" clicked', async () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
renderInlineCreatePanel()
openInlineCreate('Brand New Note')
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
await vi.waitFor(() => {
@@ -433,15 +407,8 @@ describe('DynamicRelationshipsPanel', () => {
it('does not add wikilink when note creation fails', async () => {
onCreateAndOpenNote.mockResolvedValue(false)
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Failing Note' } })
renderInlineCreatePanel()
openInlineCreate('Failing Note')
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Failing Note')
// Give async handler time to resolve
@@ -453,29 +420,16 @@ describe('DynamicRelationshipsPanel', () => {
})
it('shows both existing matches and "Create & open" for partial matches', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
// "My" partially matches "My Project" but is not an exact match
fireEvent.change(input, { target: { value: 'My' } })
renderInlineCreatePanel()
// "My" partially matches "My Project" but is not an exact match.
openInlineCreate('My')
// Should show search results AND create option
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
})
it('does not show "Create & open" when onCreateAndOpenNote is not provided', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
renderInlineCreatePanel({ onCreateAndOpenNote: undefined })
openInlineCreate('Brand New Note')
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
})
})
@@ -597,8 +551,8 @@ describe('ReferencedByPanel', () => {
expect(screen.getByText('Write Essays')).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {

View File

@@ -181,7 +181,7 @@ describe('NoteList rendering', () => {
it('shows referenced-by groups for topic entities', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[4] } })
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Referenced By')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
})
it('toggles the search input from the header action', () => {
@@ -318,7 +318,7 @@ describe('NoteList rendering', () => {
expect(screen.queryByRole('button', { name: /Children/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
})
@@ -348,7 +348,7 @@ describe('NoteList rendering', () => {
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
expect(screen.queryByText('Child Note')).not.toBeInTheDocument()
})
@@ -375,7 +375,7 @@ describe('NoteList rendering', () => {
})
expect(screen.getByText('Related to')).toBeInTheDocument()
expect(screen.getByText('Referenced By')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
expect(screen.getAllByText('Shared Note')).toHaveLength(2)
})

View File

@@ -128,6 +128,128 @@ function shouldIgnoreContainerClick(target: HTMLElement) {
)
}
function normalizeSuggestionQuery(query: string, triggerCharacter: string): string {
return query.startsWith(triggerCharacter)
? query.slice(triggerCharacter.length)
: query
}
function isSelectionInsideElement(element: HTMLElement): boolean {
const selection = window.getSelection()
const anchorNode = selection?.anchorNode ?? null
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
return Boolean(anchorElement && element.contains(anchorElement))
}
function queueTitleHeadingCursorRepair(
target: HTMLElement,
editor: ReturnType<typeof useCreateBlockNote>,
): boolean {
const titleHeading = target.closest<HTMLElement>(
'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])',
)
if (!titleHeading) return false
queueMicrotask(() => {
if (isSelectionInsideElement(titleHeading)) return
const firstBlock = editor.document[0]
if (firstBlock?.type !== 'heading') return
editor.setTextCursorPosition(firstBlock.id, 'end')
editor.focus()
})
return true
}
function useEditorContainerClickHandler(options: {
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
}) {
const { editable, editor } = options
return useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!editable) return
const target = e.target as HTMLElement
if (queueTitleHeadingCursorRepair(target, editor)) return
if (shouldIgnoreContainerClick(target)) return
const blocks = editor.document
if (blocks.length > 0) {
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
}
editor.focus()
}, [editor, editable])
}
function buildBaseSuggestionItems(entries: VaultEntry[]) {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
})))
}
function useInsertWikilink(editor: ReturnType<typeof useCreateBlockNote>) {
return useCallback((target: string) => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
], { updateSelection: true })
trackEvent('wikilink_inserted')
}, [editor])
}
function useSuggestionMenuItems(options: {
baseItems: ReturnType<typeof buildBaseSuggestionItems>
editor: ReturnType<typeof useCreateBlockNote>
insertWikilink: (target: string) => void
typeEntryMap: Record<string, VaultEntry>
vaultPath?: string
}) {
const {
baseItems,
editor,
insertWikilink,
typeEntryMap,
vaultPath,
} = options
const buildItems = useCallback((query: string, triggerCharacter: '[[' | '@') => {
const normalizedQuery = normalizeSuggestionQuery(query, triggerCharacter)
const minLength = triggerCharacter === '[[' ? MIN_QUERY_LENGTH : PERSON_MENTION_MIN_QUERY
if (normalizedQuery.length < minLength) return null
const candidates = triggerCharacter === '[['
? preFilterWikilinks(baseItems, normalizedQuery)
: filterPersonMentions(baseItems, normalizedQuery)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, normalizedQuery, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
buildItems(query, '[[') ?? []
), [buildItems])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
buildItems(query, '@') ?? []
), [buildItems])
const getSlashMenuItems = useCallback(async (query: string) => (
getTolariaSlashMenuItems(editor, query)
), [editor])
return {
getWikilinkItems,
getPersonMentionItems,
getSlashMenuItems,
}
}
/** Insert an image block after the current cursor position. */
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
const editorRef = useRef(editor)
@@ -150,22 +272,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
}) {
const { cssVars } = useEditorTheme()
const containerRef = useRef<HTMLDivElement>(null)
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
useBlockNoteSideMenuHoverGuard(containerRef)
useEditorLinkActivation(containerRef, onNavigateWikilink)
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!editable) return
const target = e.target as HTMLElement
if (shouldIgnoreContainerClick(target)) return
const blocks = editor.document
if (blocks.length > 0) {
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
}
editor.focus()
}, [editor, editable])
useEffect(() => {
_wikilinkEntriesRef.current = entries
}, [entries])
@@ -173,44 +285,19 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
useSeedBlockNoteTableBridge(editor)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(
() => deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
}))),
[entries]
)
const insertWikilink = useCallback((target: string) => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
])
trackEvent('wikilink_inserted')
}, [editor])
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < MIN_QUERY_LENGTH) return []
const candidates = preFilterWikilinks(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < PERSON_MENTION_MIN_QUERY) return []
const candidates = filterPersonMentions(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getSlashMenuItems = useCallback(async (query: string) => (
getTolariaSlashMenuItems(editor, query)
), [editor])
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
const insertWikilink = useInsertWikilink(editor)
const {
getWikilinkItems,
getPersonMentionItems,
getSlashMenuItems,
} = useSuggestionMenuItems({
baseItems,
editor,
insertWikilink,
typeEntryMap,
vaultPath,
})
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>

View File

@@ -224,7 +224,7 @@ describe('StatusBar', () => {
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
})
it('exposes a hover-revealed remove action for non-active vaults', () => {
it('exposes an in-row, hover-revealed remove action for non-active vaults', () => {
render(
<StatusBar
noteCount={100}
@@ -237,8 +237,16 @@ describe('StatusBar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByTestId('vault-menu-item-Work Vault').className).toContain('hover:bg-[var(--hover)]')
expect(screen.getByTestId('vault-menu-remove-Work Vault').className).toContain('group-hover:opacity-100')
const item = screen.getByTestId('vault-menu-item-Work Vault')
const removeAction = screen.getByTestId('vault-menu-remove-Work Vault')
expect(item.className).toContain('hover:bg-[var(--hover)]')
expect(item.className).toContain('pr-7')
expect(removeAction.className).toContain('absolute')
expect(removeAction.className).toContain('right-1')
expect(removeAction.className).toContain('group-hover:opacity-100')
expect(removeAction.className).toContain('group-focus-within:opacity-100')
expect(removeAction.className).toContain('pointer-events-none')
expect(screen.getByRole('button', { name: 'Remove Work Vault from list' })).toBeInTheDocument()
})

View File

@@ -24,16 +24,16 @@ interface WikilinkSuggestionMenuProps {
export function WikilinkSuggestionMenu({ items, selectedIndex, onItemClick }: WikilinkSuggestionMenuProps) {
return (
<div className="wikilink-menu">
<NoteSearchList
items={items}
selectedIndex={selectedIndex ?? 0}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => {
item.onItemClick()
onItemClick?.(item)
}}
emptyMessage="No results"
/>
<NoteSearchList
items={items}
selectedIndex={selectedIndex ?? 0}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => {
item.onItemClick()
onItemClick?.(item)
}}
emptyMessage="No results"
/>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react'
import type { VaultEntry } from '../../types'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { orderInverseRelationshipLabels, resolveInverseRelationshipLabel } from '../../utils/inverseRelationshipLabels'
import { getTypeColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { LinkButton } from './LinkButton'
@@ -16,24 +16,37 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
onNavigate: (target: string) => void
}) {
const grouped = useMemo(() => {
const map = new Map<string, VaultEntry[]>()
const map = new Map<string, Map<string, VaultEntry>>()
for (const item of items) {
const existing = map.get(item.viaKey)
if (existing) existing.push(item.entry)
else map.set(item.viaKey, [item.entry])
const label = resolveInverseRelationshipLabel(item.viaKey, item.entry)
const entriesByPath = map.get(label) ?? new Map<string, VaultEntry>()
entriesByPath.set(item.entry.path, item.entry)
map.set(label, entriesByPath)
}
return Array.from(map.entries())
return orderInverseRelationshipLabels(map.keys()).map((label) => [
label,
[...(map.get(label)?.values() ?? [])],
] as const)
}, [items])
if (items.length === 0) return null
return (
<div className="referenced-by-panel">
<div className="mb-2 flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
Derived relationships
</span>
<span className="text-[10px] text-muted-foreground/80">
Read-only groups sourced from other notes.
</span>
</div>
<div className="flex flex-col gap-2.5">
{grouped.map(([viaKey, groupEntries]) => (
<div key={viaKey}>
{grouped.map(([label, groupEntries]) => (
<div key={label}>
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
{humanizePropertyKey(viaKey)}
{label}
</span>
<div className="flex flex-col gap-0.5">
{groupEntries.map((e) => {

View File

@@ -112,7 +112,25 @@ describe('relationship display labels', () => {
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
})
it('dedupes canonical and legacy inverse keys into a single normalized inspector group', () => {
const entry = makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' })
const items: ReferencedByItem[] = [
{ entry, viaKey: 'belongs_to' },
{ entry, viaKey: 'Belongs to' },
{ entry: makeEntry({ path: '/vault/topic-alpha.md', filename: 'topic-alpha.md', title: 'Topic Alpha', isA: 'Note' }), viaKey: 'related_to' },
]
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
expect(screen.getAllByText('Project Alpha')).toHaveLength(1)
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
})
})

View File

@@ -104,9 +104,16 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
const unavailable = vault.available === false
const removeLabel = `Remove ${vault.label} from list`
const itemClassName = [
'w-full justify-start rounded-sm px-2 py-1 text-xs font-normal',
canRemove ? 'pr-7' : '',
isActive
? 'text-foreground hover:bg-[var(--hover)] hover:text-foreground'
: 'text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
].filter(Boolean).join(' ')
return (
<div className="group flex items-center gap-1 rounded-sm">
<div className="group relative flex w-full items-center rounded-sm">
<Button
type="button"
variant="ghost"
@@ -116,17 +123,14 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
aria-current={isActive ? 'true' : undefined}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
data-testid={`vault-menu-item-${vault.label}`}
className={isActive
? 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-foreground hover:bg-[var(--hover)] hover:text-foreground'
: 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
}
className={itemClassName}
style={{
height: 'auto',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
}}
>
<span className="flex items-center gap-1.5 truncate">
<span className="flex min-w-0 items-center gap-1.5">
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
<span className="truncate">{vault.label}</span>
</span>
@@ -143,7 +147,7 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
title={removeLabel}
aria-label={removeLabel}
data-testid={`vault-menu-remove-${vault.label}`}
className="rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
className="absolute top-1/2 right-1 -translate-y-1/2 rounded-sm text-muted-foreground opacity-0 pointer-events-none transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:pointer-events-auto group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
>
<X size={10} />
</Button>

View File

@@ -8,6 +8,13 @@ interface HeadingRange {
to: number
}
interface FocusableHeadingBlock {
id: string
type?: string
props?: { level?: number } & Record<string, unknown>
content?: unknown
}
interface TiptapChain {
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
run: () => void
@@ -21,6 +28,8 @@ export interface TiptapEditor {
export interface FocusableEditor {
focus: () => void
_tiptapEditor?: TiptapEditor
document?: FocusableHeadingBlock[]
setTextCursorPosition?: (targetBlock: string, placement?: 'start' | 'end') => void
}
function buildHeadingRange(pos: number, nodeSize: number): HeadingRange | null {
@@ -42,7 +51,38 @@ function findFirstHeadingRange(tiptap: TiptapEditor): HeadingRange | null {
return range
}
function isTopLevelHeadingBlock(block: FocusableHeadingBlock): boolean {
return block.type === 'heading' && (block.props?.level === undefined || block.props?.level === 1)
}
function getHeadingBlockText(block: FocusableHeadingBlock | undefined): string {
if (!Array.isArray(block?.content)) return ''
return block.content
.filter((item): item is { type?: string; text?: string } => (
typeof item === 'object' && item !== null
))
.filter((item) => item.type === 'text')
.map((item) => item.text ?? '')
.join('')
.trim()
}
function getFirstHeadingBlock(editor: FocusableEditor): FocusableHeadingBlock | undefined {
return editor.document?.find(isTopLevelHeadingBlock)
}
function trySelectEmptyFirstHeading(editor: FocusableEditor): boolean {
const firstHeadingBlock = getFirstHeadingBlock(editor)
if (!firstHeadingBlock || !editor.setTextCursorPosition) return false
if (getHeadingBlockText(firstHeadingBlock)) return false
editor.setTextCursorPosition(firstHeadingBlock.id, 'start')
return true
}
function trySelectFirstHeading(editor: FocusableEditor): boolean {
if (trySelectEmptyFirstHeading(editor)) return true
const tiptap = editor._tiptapEditor
if (!tiptap?.state?.doc) return false

View File

@@ -75,12 +75,47 @@ describe('useAutoSync', () => {
const { result } = renderSync()
await waitFor(() => {
expect(onVaultUpdated).toHaveBeenCalled()
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md', 'project/plan.md'])
expect(onToast).toHaveBeenCalledWith('Pulled 2 update(s) from remote')
expect(result.current.syncStatus).toBe('idle')
})
})
it('waits for vault refresh before showing the updated toast', async () => {
let releaseVaultRefresh: (() => void) | null = null
const asyncVaultRefresh = vi.fn(() => new Promise<void>((resolve) => {
releaseVaultRefresh = resolve
}))
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
return Promise.resolve(updated(['note.md']))
})
renderHook(() =>
useAutoSync({
vaultPath: '/Users/luca/Laputa',
intervalMinutes: 5,
onVaultUpdated: asyncVaultRefresh,
onConflict,
onToast,
}),
)
await waitFor(() => {
expect(asyncVaultRefresh).toHaveBeenCalledWith(['note.md'])
})
expect(onToast).not.toHaveBeenCalledWith('Pulled 1 update(s) from remote')
await act(async () => {
releaseVaultRefresh?.()
})
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith('Pulled 1 update(s) from remote')
})
})
it('calls onConflict and sets conflict status when pull has conflicts', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)

View File

@@ -1,10 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
import { trackEvent } from '../lib/telemetry'
const DEFAULT_INTERVAL_MS = 5 * 60_000
const FOCUS_COOLDOWN_MS = 30_000
type MaybePromise = void | Promise<void>
type SyncCallbacks = Pick<UseAutoSyncOptions, 'onVaultUpdated' | 'onSyncUpdated' | 'onConflict' | 'onToast'>
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
@@ -13,8 +19,8 @@ function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
interface UseAutoSyncOptions {
vaultPath: string
intervalMinutes: number | null
onVaultUpdated: () => void
onSyncUpdated?: () => void
onVaultUpdated: (updatedFiles: string[]) => MaybePromise
onSyncUpdated?: () => MaybePromise
onConflict: (files: string[]) => void
onToast: (msg: string) => void
}
@@ -36,6 +42,214 @@ export interface AutoSyncState {
handlePushRejected: () => void
}
type SyncSetState<T> = Dispatch<SetStateAction<T>>
interface PullErrorResolution {
checkExistingConflicts: () => Promise<boolean>
notifyError?: string
callbacksRef: MutableRefObject<SyncCallbacks>
setSyncStatus: SyncSetState<SyncStatus>
}
interface SyncTaskOptions {
blockWhenPaused: boolean
pauseRef: MutableRefObject<boolean>
syncingRef: MutableRefObject<boolean>
setLastSyncTime: SyncSetState<number | null>
setSyncStatus: SyncSetState<SyncStatus>
task: () => Promise<void>
}
function clearConflictState(
setSyncStatus: SyncSetState<SyncStatus>,
setConflictFiles: SyncSetState<string[]>,
): void {
setSyncStatus('idle')
setConflictFiles([])
}
function setConflictState(
files: string[],
setSyncStatus: SyncSetState<SyncStatus>,
setConflictFiles: SyncSetState<string[]>,
callbacksRef: MutableRefObject<SyncCallbacks>,
): void {
setSyncStatus('conflict')
setConflictFiles(files)
void callbacksRef.current.onConflict(files)
}
function markPullTimestamp(
setLastSyncTime: SyncSetState<number | null>,
refreshCommitInfo: () => void,
): void {
setLastSyncTime(Date.now())
refreshCommitInfo()
}
function useRemoteStatusRefresher(
vaultPath: string,
setRemoteStatus: SyncSetState<GitRemoteStatus | null>,
) {
return useCallback(async () => {
try {
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
setRemoteStatus(status)
return status
} catch {
return null
}
}, [vaultPath, setRemoteStatus])
}
function useConflictChecker(
vaultPath: string,
setSyncStatus: SyncSetState<SyncStatus>,
setConflictFiles: SyncSetState<string[]>,
callbacksRef: MutableRefObject<SyncCallbacks>,
) {
return useCallback(async (): Promise<boolean> => {
try {
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
if (!Array.isArray(files) || files.length === 0) return false
setConflictState(files, setSyncStatus, setConflictFiles, callbacksRef)
return true
} catch {
return false
}
}, [vaultPath, setSyncStatus, setConflictFiles, callbacksRef])
}
function useCommitInfoRefresher(
vaultPath: string,
setLastCommitInfo: SyncSetState<LastCommitInfo | null>,
) {
return useCallback(() => {
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
.then(info => setLastCommitInfo(info))
.catch(() => {})
}, [vaultPath, setLastCommitInfo])
}
async function handleUpdatedPull(options: {
result: GitPullResult
callbacksRef: MutableRefObject<SyncCallbacks>
setConflictFiles: SyncSetState<string[]>
setSyncStatus: SyncSetState<SyncStatus>
}): Promise<void> {
const {
result,
callbacksRef,
setConflictFiles,
setSyncStatus,
} = options
clearConflictState(setSyncStatus, setConflictFiles)
await callbacksRef.current.onVaultUpdated(result.updatedFiles)
await callbacksRef.current.onSyncUpdated?.()
await callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
}
async function resolvePullError(options: PullErrorResolution): Promise<void> {
const {
checkExistingConflicts,
notifyError,
callbacksRef,
setSyncStatus,
} = options
const hasConflicts = await checkExistingConflicts()
if (hasConflicts) return
setSyncStatus('error')
if (notifyError) await callbacksRef.current.onToast(notifyError)
}
function handlePushResult(options: {
pushResult: GitPushResult
callbacksRef: MutableRefObject<SyncCallbacks>
setConflictFiles: SyncSetState<string[]>
setSyncStatus: SyncSetState<SyncStatus>
}): void {
const {
pushResult,
callbacksRef,
setConflictFiles,
setSyncStatus,
} = options
if (pushResult.status === 'ok') {
clearConflictState(setSyncStatus, setConflictFiles)
void callbacksRef.current.onToast('Pulled and pushed successfully')
return
}
if (pushResult.status === 'rejected') {
setSyncStatus('pull_required')
void callbacksRef.current.onToast('Push still rejected after pull — try again')
return
}
setSyncStatus('error')
void callbacksRef.current.onToast(pushResult.message)
}
async function runSyncTask(options: SyncTaskOptions): Promise<void> {
const {
blockWhenPaused,
pauseRef,
syncingRef,
setLastSyncTime,
setSyncStatus,
task,
} = options
if (syncingRef.current || (blockWhenPaused && pauseRef.current)) return
syncingRef.current = true
setSyncStatus('syncing')
try {
await task()
} catch {
setSyncStatus('error')
setLastSyncTime(Date.now())
} finally {
syncingRef.current = false
}
}
function useAutoSyncLifecycle(options: {
checkExistingConflicts: () => Promise<boolean>
intervalMinutes: number | null
performPull: () => Promise<void>
refreshRemoteStatus: () => Promise<GitRemoteStatus | null>
}) {
const {
checkExistingConflicts,
intervalMinutes,
performPull,
refreshRemoteStatus,
} = options
useEffect(() => {
void checkExistingConflicts().then(hasConflicts => {
if (!hasConflicts) void performPull()
})
void refreshRemoteStatus()
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
const lastPullTimeRef = useRef(0)
useEffect(() => {
const handleFocus = () => {
const now = Date.now()
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
lastPullTimeRef.current = now
void performPull()
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [performPull])
useEffect(() => {
const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS
const id = setInterval(() => { void performPull() }, ms)
return () => clearInterval(id)
}, [performPull, intervalMinutes])
}
export function useAutoSync({
vaultPath,
intervalMinutes,
@@ -51,178 +265,111 @@ export function useAutoSync({
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
const syncingRef = useRef(false)
const pauseRef = useRef(false)
const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
const refreshRemoteStatus = useCallback(async () => {
try {
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
setRemoteStatus(status)
return status
} catch {
return null
}
}, [vaultPath])
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
try {
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
if (files.length > 0) {
setSyncStatus('conflict')
setConflictFiles(files)
callbacksRef.current.onConflict(files)
return true
}
} catch {
// If the command doesn't exist (e.g. browser mock), ignore
}
return false
}, [vaultPath])
const refreshCommitInfo = useCallback(() => {
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
.then(info => setLastCommitInfo(info))
.catch(() => {})
}, [vaultPath])
const callbacksRef = useRef<SyncCallbacks>({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
useEffect(() => {
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
}, [onVaultUpdated, onSyncUpdated, onConflict, onToast])
const refreshRemoteStatus = useRemoteStatusRefresher(vaultPath, setRemoteStatus)
const checkExistingConflicts = useConflictChecker(vaultPath, setSyncStatus, setConflictFiles, callbacksRef)
const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo)
const performPull = useCallback(async () => {
if (syncingRef.current || pauseRef.current) return
syncingRef.current = true
setSyncStatus('syncing')
await runSyncTask({
blockWhenPaused: true,
pauseRef,
syncingRef,
setLastSyncTime,
setSyncStatus,
task: async () => {
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
try {
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
setLastSyncTime(Date.now())
refreshCommitInfo()
if (result.status === 'updated') {
setSyncStatus('idle')
setConflictFiles([])
callbacksRef.current.onVaultUpdated()
callbacksRef.current.onSyncUpdated?.()
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
} else if (result.status === 'conflict') {
setSyncStatus('conflict')
setConflictFiles(result.conflictFiles)
callbacksRef.current.onConflict(result.conflictFiles)
} else if (result.status === 'error') {
// Pull failed — check if there are pre-existing conflicts that caused it
const hasConflicts = await checkExistingConflicts()
if (!hasConflicts) {
setSyncStatus('error')
if (result.status === 'updated') {
await handleUpdatedPull({
result,
callbacksRef,
setConflictFiles,
setSyncStatus,
})
} else if (result.status === 'conflict') {
setConflictState(result.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
} else if (result.status === 'error') {
await resolvePullError({
checkExistingConflicts,
callbacksRef,
setSyncStatus,
})
} else {
clearConflictState(setSyncStatus, setConflictFiles)
}
} else {
// up_to_date or no_remote
setSyncStatus('idle')
setConflictFiles([])
}
// Refresh remote status after pull
refreshRemoteStatus()
} catch {
setSyncStatus('error')
setLastSyncTime(Date.now())
} finally {
syncingRef.current = false
}
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
void refreshRemoteStatus()
},
})
}, [vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
/** Pull from remote, then auto-push if successful. Used for divergence recovery. */
const pullAndPush = useCallback(async () => {
if (syncingRef.current) return
syncingRef.current = true
setSyncStatus('syncing')
await runSyncTask({
blockWhenPaused: false,
pauseRef,
syncingRef,
setLastSyncTime,
setSyncStatus,
task: async () => {
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
try {
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
setLastSyncTime(Date.now())
refreshCommitInfo()
if (pullResult.status === 'conflict') {
setSyncStatus('conflict')
setConflictFiles(pullResult.conflictFiles)
callbacksRef.current.onConflict(pullResult.conflictFiles)
return
}
if (pullResult.status === 'error') {
const hasConflicts = await checkExistingConflicts()
if (!hasConflicts) {
setSyncStatus('error')
callbacksRef.current.onToast('Pull failed: ' + pullResult.message)
if (pullResult.status === 'conflict') {
setConflictState(pullResult.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
return
}
return
}
if (pullResult.status === 'updated') {
callbacksRef.current.onVaultUpdated()
callbacksRef.current.onSyncUpdated?.()
}
if (pullResult.status === 'error') {
await resolvePullError({
checkExistingConflicts,
notifyError: `Pull failed: ${pullResult.message}`,
callbacksRef,
setSyncStatus,
})
return
}
// Now push
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
if (pushResult.status === 'ok') {
setSyncStatus('idle')
setConflictFiles([])
callbacksRef.current.onToast('Pulled and pushed successfully')
} else if (pushResult.status === 'rejected') {
// Still diverged — shouldn't happen after pull but handle gracefully
setSyncStatus('pull_required')
callbacksRef.current.onToast('Push still rejected after pull — try again')
} else {
setSyncStatus('error')
callbacksRef.current.onToast(pushResult.message)
}
if (pullResult.status === 'updated') {
await callbacksRef.current.onVaultUpdated(pullResult.updatedFiles)
await callbacksRef.current.onSyncUpdated?.()
}
refreshRemoteStatus()
} catch {
setSyncStatus('error')
setLastSyncTime(Date.now())
} finally {
syncingRef.current = false
}
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
handlePushResult({
pushResult,
callbacksRef,
setConflictFiles,
setSyncStatus,
})
void refreshRemoteStatus()
},
})
}, [vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
const handlePushRejected = useCallback(() => {
setSyncStatus('pull_required')
}, [])
// Check for pre-existing conflicts on mount, then pull
useEffect(() => {
checkExistingConflicts().then(hasConflicts => {
if (!hasConflicts) performPull()
})
refreshRemoteStatus()
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
// Pull on window focus (app foreground) — with cooldown to avoid repeated pulls
const lastPullTimeRef = useRef(0)
useEffect(() => {
const FOCUS_COOLDOWN_MS = 30_000
const handleFocus = () => {
const now = Date.now()
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
lastPullTimeRef.current = now
performPull()
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [performPull])
// Periodic pull
useEffect(() => {
const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS
const id = setInterval(performPull, ms)
return () => clearInterval(id)
}, [performPull, intervalMinutes])
useAutoSyncLifecycle({
checkExistingConflicts,
intervalMinutes,
performPull,
refreshRemoteStatus,
})
const pausePull = useCallback(() => { pauseRef.current = true }, [])
const resumePull = useCallback(() => { pauseRef.current = false }, [])
const triggerSync = useCallback(() => {
trackEvent('sync_triggered')
performPull()
void performPull()
}, [performPull])
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useEditorFocus } from './useEditorFocus'
import type { FocusableEditor } from './editorFocusUtils'
function makeTiptapMock(hasHeading: boolean | Array<number | null> = true, headingNodeSize = 15) {
const headingSizes = Array.isArray(hasHeading)
@@ -36,13 +37,20 @@ describe('useEditorFocus', () => {
document.body.innerHTML = ''
})
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
function setup(
isMounted: boolean,
tiptap?: ReturnType<typeof makeTiptapMock>,
editorOverrides: Record<string, unknown> = {},
) {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
document.body.appendChild(editable)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn(() => editable.focus()), _tiptapEditor: tiptap } as any
const editor = {
focus: vi.fn(() => editable.focus()),
_tiptapEditor: tiptap,
...editorOverrides,
} as FocusableEditor & Record<string, unknown>
const mountedRef = { current: isMounted }
renderHook(() => useEditorFocus(editor, mountedRef))
return { editor, tiptap, editable }
@@ -186,6 +194,25 @@ describe('useEditorFocus', () => {
expect(tiptap.chain).not.toHaveBeenCalled()
})
it('places a text cursor inside an empty H1 instead of selecting through the next block', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const tiptap = makeTiptapMock(true)
const setTextCursorPosition = vi.fn()
const { editor } = setup(true, tiptap, {
document: [
{ id: 'title', type: 'heading', props: { level: 1 }, content: [] },
{ id: 'body', type: 'paragraph', props: {}, content: [] },
],
setTextCursorPosition,
})
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
expect(editor.focus).toHaveBeenCalled()
expect(setTextCursorPosition).toHaveBeenCalledWith('title', 'start')
expect(tiptap.chain).not.toHaveBeenCalled()
})
it('selects H1 text after timeout when editor not yet mounted', () => {
vi.useFakeTimers()
// Mock rAF synchronously so the deferred selectFirstHeading call inside doFocus

View File

@@ -10,6 +10,7 @@ function makeTab(path: string, title: string, body: string) {
}
function makeMockEditor(currentMarkdown: string) {
const markdownRef = { current: currentMarkdown }
const docRef = {
current: [
{
@@ -30,58 +31,95 @@ function makeMockEditor(currentMarkdown: string) {
onMount: (cb: () => void) => { cb(); return () => {} },
replaceBlocks: vi.fn(),
insertBlocks: vi.fn(),
blocksToMarkdownLossy: vi.fn(() => currentMarkdown),
blocksToMarkdownLossy: vi.fn(() => markdownRef.current),
blocksToHTMLLossy: vi.fn(() => ''),
tryParseMarkdownToBlocks: vi.fn(() => []),
_tiptapEditor: { commands: { setContent: vi.fn() } },
setMarkdown: (markdown: string) => {
markdownRef.current = markdown
},
}
return editor
}
function setupMountedEditorMocks() {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
}
function renderRenameHarness(options?: { onContentChange?: ReturnType<typeof vi.fn> }) {
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
const onContentChange = options?.onContentChange ?? vi.fn()
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
const hook = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: editor as never,
onContentChange,
}),
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
)
return {
editor,
onContentChange,
untitledTab,
renamedTab,
...hook,
}
}
async function settleRenameHarness(editor: ReturnType<typeof makeMockEditor>) {
await act(() => new Promise(r => setTimeout(r, 0)))
editor.replaceBlocks.mockClear()
editor.tryParseMarkdownToBlocks.mockClear()
}
async function expectRenameSessionContinues(options: { renamedTabArrivesLate: boolean }) {
const {
editor,
onContentChange,
renamedTab,
result,
rerender,
untitledTab,
} = renderRenameHarness()
await settleRenameHarness(editor)
if (options.renamedTabArrivesLate) {
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
}
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'fresh-title.md',
expect.stringContaining('Body typed live'),
)
}
describe('useEditorTabSwap untitled rename continuity', () => {
it('keeps the live editor session when an untitled note auto-renames', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
const onContentChange = vi.fn()
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
const { result, rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: editor as never,
onContentChange,
}),
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
editor.replaceBlocks.mockClear()
editor.tryParseMarkdownToBlocks.mockClear()
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'fresh-title.md',
expect.stringContaining('Body typed live'),
)
setupMountedEditorMocks()
await expectRenameSessionContinues({ renamedTabArrivesLate: false })
})
it('still swaps when the next note does not match the live untitled body', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
setupMountedEditorMocks()
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
@@ -107,13 +145,16 @@ describe('useEditorTabSwap untitled rename continuity', () => {
})
it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
setupMountedEditorMocks()
await expectRenameSessionContinues({ renamedTabArrivesLate: true })
})
it('does not re-swap the active note when app state catches up with live typing', async () => {
setupMountedEditorMocks()
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
const onContentChange = vi.fn()
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
const tab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
const { result, rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
@@ -122,29 +163,83 @@ describe('useEditorTabSwap untitled rename continuity', () => {
editor: editor as never,
onContentChange,
}),
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
{ initialProps: { tabs: [tab], activeTabPath: tab.entry.path } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
editor.replaceBlocks.mockClear()
editor.tryParseMarkdownToBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'fresh-title.md',
expect.stringContaining('Body typed live'),
const syncedContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof syncedContent).toBe('string')
editor.replaceBlocks.mockClear()
editor.tryParseMarkdownToBlocks.mockClear()
rerender({
tabs: [{ ...tab, content: syncedContent }],
activeTabPath: tab.entry.path,
})
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
})
it('does not re-swap while local wikilink insertion is ahead of the latest tab props', async () => {
setupMountedEditorMocks()
const editor = makeMockEditor('# Fresh Title\n\nBody')
const onContentChange = vi.fn()
const tab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
const { result, rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: editor as never,
onContentChange,
}),
{ initialProps: { tabs: [tab], activeTabPath: tab.entry.path } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
editor.setMarkdown('# Fresh Title\n\nBody\n\n[[Mana')
act(() => {
result.current.handleEditorChange()
})
const queryContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof queryContent).toBe('string')
editor.setMarkdown('# Fresh Title\n\nBody\n\n[[manage-sponsorships]] ')
act(() => {
result.current.handleEditorChange()
})
const insertedContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof insertedContent).toBe('string')
editor.replaceBlocks.mockClear()
editor.tryParseMarkdownToBlocks.mockClear()
rerender({
tabs: [{ ...tab, content: queryContent }],
activeTabPath: tab.entry.path,
})
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
rerender({
tabs: [{ ...tab, content: insertedContent }],
activeTabPath: tab.entry.path,
})
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
})
})

View File

@@ -361,6 +361,28 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('re-parses when the active tab content changes without a path change', async () => {
const tabA = makeTab('a.md', 'Note A')
const refreshedTabA = {
...tabA,
content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh after pull.',
}
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
expect.stringContaining('Fresh after pull.'),
)
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -13,6 +13,7 @@ interface Tab {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
type EditorBlocks = any[]
type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string }
type PendingLocalContent = { path: string; content: string }
const TAB_STATE_CACHE_LIMIT = 24
interface TabSwapState {
@@ -372,6 +373,8 @@ function useEditorChangeHandler(options: {
onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined>
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
editor,
@@ -379,6 +382,8 @@ function useEditorChangeHandler(options: {
onContentChangeRef,
prevActivePathRef,
suppressChangeRef,
tabCacheRef,
pendingLocalContentRef,
} = options
return useCallback(() => {
@@ -393,8 +398,15 @@ function useEditorChangeHandler(options: {
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const [frontmatter] = splitFrontmatter(tab.content)
onContentChangeRef.current?.(path, `${frontmatter}${bodyMarkdown}`)
}, [editor, onContentChangeRef, prevActivePathRef, suppressChangeRef, tabsRef])
const nextContent = `${frontmatter}${bodyMarkdown}`
pendingLocalContentRef.current = { path, content: nextContent }
cacheEditorState(tabCacheRef.current, path, {
blocks,
scrollTop: readEditorScrollTop(),
sourceContent: nextContent,
})
onContentChangeRef.current?.(path, nextContent)
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef])
}
function consumeRawModeTransition(
@@ -481,6 +493,113 @@ function syncActivePathTransition(options: {
return true
}
function markRawModeReswapPending(options: {
activeTabPath: string | null
cache: Map<string, CachedTabState>
rawSwapPendingRef: MutableRefObject<boolean>
}) {
const { activeTabPath, cache, rawSwapPendingRef } = options
if (!activeTabPath) return false
cache.delete(activeTabPath)
rawSwapPendingRef.current = true
return true
}
function currentEditorMatchesActiveTab(options: {
activeTabPath: string | null
activeTab: Tab | undefined
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
}) {
const {
activeTabPath,
activeTab,
editor,
editorMountedRef,
} = options
return Boolean(
activeTabPath
&& activeTab
&& editorMountedRef.current
&& typeof editor.blocksToMarkdownLossy === 'function'
&& serializeEditorBody(editor) === normalizeTabBody({ content: activeTab.content }),
)
}
function cacheStableActiveTabAndClearPending(options: {
cache: Map<string, CachedTabState>
activeTabPath: string | null
activeTab: Tab | undefined
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
pendingLocalContentRef,
} = options
cacheStableActivePath({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
})
pendingLocalContentRef.current = null
return true
}
function shouldKeepPendingLocalContent(options: {
activeTabPath: string | null
activeTab: Tab | undefined
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
activeTabPath,
activeTab,
pendingLocalContentRef,
} = options
const pendingLocalContent = pendingLocalContentRef.current
if (!activeTabPath || !activeTab || pendingLocalContent?.path !== activeTabPath) return false
return true
}
function consumePendingLocalContent(options: {
cache: Map<string, CachedTabState>
activeTabPath: string | null
activeTab: Tab | undefined
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
pendingLocalContentRef,
} = options
const pendingLocalContent = pendingLocalContentRef.current
if (!pendingLocalContent || pendingLocalContent.content !== activeTab?.content) return true
return cacheStableActiveTabAndClearPending({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
pendingLocalContentRef,
})
}
function handleStableActivePath(options: {
pathChanged: boolean
rawModeJustEnded: boolean
@@ -490,6 +609,7 @@ function handleStableActivePath(options: {
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
pathChanged,
@@ -500,14 +620,34 @@ function handleStableActivePath(options: {
editor,
editorMountedRef,
rawSwapPendingRef,
pendingLocalContentRef,
} = options
if (pathChanged) return false
if (rawModeJustEnded && activeTabPath) {
cache.delete(activeTabPath)
rawSwapPendingRef.current = true
return false
if (rawModeJustEnded) {
return !markRawModeReswapPending({ activeTabPath, cache, rawSwapPendingRef })
}
if (currentEditorMatchesActiveTab({ activeTabPath, activeTab, editor, editorMountedRef })) {
return cacheStableActiveTabAndClearPending({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
pendingLocalContentRef,
})
}
if (shouldKeepPendingLocalContent({ activeTabPath, activeTab, pendingLocalContentRef })) {
return consumePendingLocalContent({
cache,
activeTabPath,
activeTab,
editor,
editorMountedRef,
pendingLocalContentRef,
})
}
if (shouldRefreshStableActivePath({ activeTabPath, activeTab, cache })) return false
if (rawSwapPendingRef.current) return true
cacheStableActivePath({
@@ -520,6 +660,22 @@ function handleStableActivePath(options: {
return true
}
function shouldRefreshStableActivePath(options: {
activeTabPath: string | null
activeTab: Tab | undefined
cache: Map<string, CachedTabState>
}): boolean {
const {
activeTabPath,
activeTab,
cache,
} = options
if (!activeTabPath || !activeTab) return false
const cachedState = cache.get(activeTabPath)
return !cachedState || cachedState.sourceContent !== activeTab.content
}
function cacheStableActivePath(options: {
cache: Map<string, CachedTabState>
activeTabPath: string | null
@@ -779,6 +935,7 @@ function shouldSkipScheduledTabSwap(options: {
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
state,
@@ -787,8 +944,13 @@ function shouldSkipScheduledTabSwap(options: {
editorMountedRef,
prevActivePathRef,
rawSwapPendingRef,
pendingLocalContentRef,
} = options
if (state.pathChanged) {
pendingLocalContentRef.current = null
}
if (syncActivePathTransition({
prevPath: state.prevPath,
pathChanged: state.pathChanged,
@@ -812,6 +974,7 @@ function shouldSkipScheduledTabSwap(options: {
editor,
editorMountedRef,
rawSwapPendingRef,
pendingLocalContentRef,
})
}
@@ -827,6 +990,7 @@ function runTabSwapEffect(options: {
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
tabs,
@@ -840,6 +1004,7 @@ function runTabSwapEffect(options: {
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
} = options
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
@@ -859,6 +1024,7 @@ function runTabSwapEffect(options: {
editorMountedRef,
prevActivePathRef,
rawSwapPendingRef,
pendingLocalContentRef,
})) {
return
}
@@ -889,6 +1055,7 @@ function useTabSwapEffect(options: {
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
}) {
const {
tabs,
@@ -902,6 +1069,7 @@ function useTabSwapEffect(options: {
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
} = options
useEffect(() => {
@@ -917,6 +1085,7 @@ function useTabSwapEffect(options: {
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
})
}, [
activeTabPath,
@@ -930,6 +1099,7 @@ function useTabSwapEffect(options: {
suppressChangeRef,
tabCacheRef,
tabs,
pendingLocalContentRef,
])
}
@@ -946,6 +1116,7 @@ function useTabSwapEffect(options: {
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) {
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
const pendingLocalContentRef = useRef<PendingLocalContent | null>(null)
const prevActivePathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
@@ -960,6 +1131,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
onContentChangeRef,
prevActivePathRef,
suppressChangeRef,
tabCacheRef,
pendingLocalContentRef,
})
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
@@ -975,6 +1148,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
})
return { handleEditorChange, editorMountedRef }

View File

@@ -152,15 +152,45 @@ describe('useTabManagement (single-note model)', () => {
expectSingleActiveTab(result, '/vault/b.md')
})
it('is a no-op when replacing with the same entry', async () => {
it('treats /tmp and /private/tmp aliases as the same active note', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockResolvedValueOnce('# Stale before pull')
.mockResolvedValueOnce('# Fresh after pull')
const beforeNavigate = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/private/tmp/vault/active.md', title: 'Active' })
await act(async () => {
await result.current.handleReplaceActiveTab(
makeEntry({ path: '/tmp/vault/active.md', title: 'Active' }),
)
})
expect(beforeNavigate).not.toHaveBeenCalled()
expect(result.current.activeTabPath).toBe('/tmp/vault/active.md')
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].content).toBe('# Fresh after pull')
})
it('reloads content when replacing with the same entry', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockResolvedValueOnce('# Stale before pull')
.mockResolvedValueOnce('# Fresh after pull')
const { result } = renderHook(() => useTabManagement())
const entry = { path: '/vault/a.md' }
const entry = { path: '/vault/a.md', title: 'A' }
await selectNote(result, entry)
await act(async () => {
await result.current.handleReplaceActiveTab(makeEntry(entry))
})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].content).toBe('# Fresh after pull')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
})
it('opens a note when no note is active', async () => {

View File

@@ -93,7 +93,8 @@ function getCachedNoteContent(path: string): string | null {
return prefetchCache.get(path)?.value ?? null
}
async function loadNoteContent(path: string): Promise<string> {
async function loadNoteContent(path: string, forceFresh = false): Promise<string> {
if (forceFresh) return requestNoteContent({ path }).promise
return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise
}
@@ -112,6 +113,18 @@ function syncActiveTabPath(
setActiveTabPath(path)
}
function normalizeComparablePath(path: string): string {
return path
.replaceAll('\\', '/')
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
.replace(/\/+$/u, '')
}
function pathsMatch(leftPath: string | null, rightPath: string | null): boolean {
if (!leftPath || !rightPath) return false
return normalizeComparablePath(leftPath) === normalizeComparablePath(rightPath)
}
function setSingleTab(
tabsRef: React.MutableRefObject<Tab[]>,
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
@@ -126,7 +139,8 @@ function isAlreadyViewingPath(
activeTabPathRef: React.MutableRefObject<string | null>,
path: string,
) {
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
return pathsMatch(activeTabPathRef.current, path)
|| tabsRef.current.some((tab) => pathsMatch(tab.entry.path, path))
}
function startEntryNavigation(options: {
@@ -162,6 +176,7 @@ function shouldApplyLoadedEntry(options: {
navSeqRef: React.MutableRefObject<number>
cachedContent: string | null
content: string
forceReload: boolean
activeTabPathRef: React.MutableRefObject<string | null>
path: string
}) {
@@ -170,12 +185,14 @@ function shouldApplyLoadedEntry(options: {
navSeqRef,
cachedContent,
content,
forceReload,
activeTabPathRef,
path,
} = options
if (navSeqRef.current !== seq) return false
return cachedContent !== content || activeTabPathRef.current !== path
if (forceReload) return true
return cachedContent !== content || !pathsMatch(activeTabPathRef.current, path)
}
function handleEntryLoadFailure(options: {
@@ -203,6 +220,7 @@ function handleEntryLoadFailure(options: {
async function navigateToEntry(options: {
entry: VaultEntry
forceReload?: boolean
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
@@ -211,6 +229,7 @@ async function navigateToEntry(options: {
}) {
const {
entry,
forceReload = false,
navSeqRef,
tabsRef,
activeTabPathRef,
@@ -222,7 +241,7 @@ async function navigateToEntry(options: {
failNoteOpenTrace(entry.path, 'binary-entry')
return
}
if (isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
if (!forceReload && isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
finishNoteOpenTrace(entry.path)
return
@@ -239,13 +258,14 @@ async function navigateToEntry(options: {
try {
markNoteOpenTrace(entry.path, 'contentLoadStart')
const content = await loadNoteContent(entry.path)
const content = await loadNoteContent(entry.path, forceReload)
markNoteOpenTrace(entry.path, 'contentLoadEnd')
if (!shouldApplyLoadedEntry({
seq,
navSeqRef,
cachedContent,
content,
forceReload,
activeTabPathRef,
path: entry.path,
})) return
@@ -282,7 +302,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
) => {
const seq = ++beforeNavigateSeqRef.current
const currentPath = activeTabPathRef.current
if (beforeNavigate && currentPath && currentPath !== targetPath) {
if (beforeNavigate && currentPath && !pathsMatch(currentPath, targetPath)) {
try {
markNoteOpenTrace(targetPath, 'beforeNavigateStart')
await beforeNavigate(currentPath, targetPath)
@@ -299,7 +319,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
/** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
if (entry.path !== activeTabPathRef.current) {
if (!pathsMatch(entry.path, activeTabPathRef.current)) {
beginNoteOpenTrace(entry.path, 'select-note')
}
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
@@ -325,11 +345,12 @@ export function useTabManagement(options: TabManagementOptions = {}) {
}, [executeNavigationWithBoundary])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
if (entry.path !== activeTabPathRef.current) {
if (!pathsMatch(entry.path, activeTabPathRef.current)) {
beginNoteOpenTrace(entry.path, 'replace-active-tab')
}
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
entry,
forceReload: true,
navSeqRef,
tabsRef,
activeTabPathRef,

View File

@@ -7,22 +7,54 @@ function makeEntry(path: string, title = 'Test'): VaultEntry {
return { path, title, filename: path.split('/').pop()!, content: '', outgoingLinks: [], snippet: '', wordCount: 0, isA: 'Note', status: null, createdAt: null, modifiedAt: null, icon: null, tags: [] } as unknown as VaultEntry
}
function expectVaultDerivedStateReloaded(options: {
reloadVault: ReturnType<typeof vi.fn>
reloadFolders: ReturnType<typeof vi.fn>
reloadViews: ReturnType<typeof vi.fn>
}) {
const { reloadVault, reloadFolders, reloadViews } = options
expect(reloadVault).toHaveBeenCalledOnce()
expect(reloadFolders).toHaveBeenCalledOnce()
expect(reloadViews).toHaveBeenCalledOnce()
}
describe('useVaultBridge', () => {
const onSelectNote = vi.fn()
let reloadVault: ReturnType<typeof vi.fn>
let reloadFolders: ReturnType<typeof vi.fn>
let reloadViews: ReturnType<typeof vi.fn>
let closeAllTabs: ReturnType<typeof vi.fn>
let replaceActiveTab: ReturnType<typeof vi.fn>
let hasUnsavedChanges: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
reloadVault = vi.fn().mockResolvedValue([])
reloadFolders = vi.fn()
reloadViews = vi.fn()
closeAllTabs = vi.fn()
replaceActiveTab = vi.fn().mockResolvedValue(undefined)
hasUnsavedChanges = vi.fn(() => false)
})
function renderBridge(entries: VaultEntry[] = [], activeTabPath: string | null = null) {
function renderBridge(
entries: VaultEntry[] = [],
activeTabPath: string | null = null,
overrides: Partial<{
hasUnsavedChanges: typeof hasUnsavedChanges
}> = {},
) {
const entriesByPath = new Map(entries.map(e => [e.path, e]))
return renderHook(() =>
useVaultBridge({
entriesByPath,
resolvedPath: '/vault',
reloadVault,
reloadFolders,
reloadViews,
closeAllTabs,
replaceActiveTab,
hasUnsavedChanges: overrides.hasUnsavedChanges ?? hasUnsavedChanges,
onSelectNote,
activeTabPath,
}),
@@ -87,27 +119,52 @@ describe('useVaultBridge', () => {
expect(onSelectNote).toHaveBeenCalledWith(fresh)
})
it('handleAgentFileModified reloads when active tab matches', () => {
it('handleAgentFileModified refreshes the active tab with fresh disk content', async () => {
const fresh = makeEntry('/vault/active.md', 'Fresh active')
reloadVault.mockResolvedValue([fresh])
const { result } = renderBridge([], '/vault/active.md')
act(() => { result.current.handleAgentFileModified('active.md') })
await act(async () => { result.current.handleAgentFileModified('active.md') })
expect(reloadVault).toHaveBeenCalled()
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
expect(closeAllTabs).toHaveBeenCalledOnce()
expect(replaceActiveTab).toHaveBeenCalledWith(fresh)
})
it('handleAgentFileModified does not reload for different tab', () => {
it('handleAgentFileModified still refreshes vault-derived UI for other notes', async () => {
const active = makeEntry('/vault/other.md', 'Other')
reloadVault.mockResolvedValue([active])
const { result } = renderBridge([], '/vault/other.md')
act(() => { result.current.handleAgentFileModified('active.md') })
await act(async () => { result.current.handleAgentFileModified('active.md') })
expect(reloadVault).not.toHaveBeenCalled()
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
expect(closeAllTabs).not.toHaveBeenCalled()
expect(replaceActiveTab).toHaveBeenCalledWith(active)
})
it('handleAgentVaultChanged always reloads', () => {
const { result } = renderBridge([])
it('keeps unsaved active note content intact while reloading agent changes', async () => {
const fresh = makeEntry('/vault/active.md', 'Fresh active')
reloadVault.mockResolvedValue([fresh])
const hasUnsaved = vi.fn((path: string) => path === '/vault/active.md')
const { result } = renderBridge([], '/vault/active.md', { hasUnsavedChanges: hasUnsaved })
act(() => { result.current.handleAgentVaultChanged() })
await act(async () => { result.current.handleAgentFileModified('active.md') })
expect(reloadVault).toHaveBeenCalled()
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
expect(closeAllTabs).not.toHaveBeenCalled()
expect(replaceActiveTab).not.toHaveBeenCalled()
})
it('handleAgentVaultChanged reloads vault-derived state and refreshes the active note when safe', async () => {
const fresh = makeEntry('/vault/active.md', 'Fresh active')
reloadVault.mockResolvedValue([fresh])
const { result } = renderBridge([], '/vault/active.md')
await act(async () => { result.current.handleAgentVaultChanged() })
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
expect(closeAllTabs).not.toHaveBeenCalled()
expect(replaceActiveTab).toHaveBeenCalledWith(fresh)
})
})

View File

@@ -1,10 +1,16 @@
import { useCallback } from 'react'
import type { VaultEntry } from '../types'
import { refreshPulledVaultState } from '../utils/pulledVaultRefresh'
interface VaultBridgeDeps {
entriesByPath: Map<string, VaultEntry>
resolvedPath: string
reloadVault: () => Promise<unknown>
reloadVault: () => Promise<VaultEntry[]>
reloadFolders: () => Promise<unknown> | unknown
reloadViews: () => Promise<unknown> | unknown
closeAllTabs: () => void
replaceActiveTab: (entry: VaultEntry) => Promise<void>
hasUnsavedChanges: (path: string) => boolean
onSelectNote: (entry: VaultEntry) => void
activeTabPath: string | null
}
@@ -13,12 +19,21 @@ function findEntry(entriesByPath: Map<string, VaultEntry>, resolvedPath: string,
return entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
}
function findInFresh(entries: unknown, resolvedPath: string, path: string): VaultEntry | undefined {
return (entries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
function findInFresh(entries: VaultEntry[], resolvedPath: string, path: string): VaultEntry | undefined {
return entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
}
export function useVaultBridge({
entriesByPath, resolvedPath, reloadVault, onSelectNote, activeTabPath,
entriesByPath,
resolvedPath,
reloadVault,
reloadFolders,
reloadViews,
closeAllTabs,
replaceActiveTab,
hasUnsavedChanges,
onSelectNote,
activeTabPath,
}: VaultBridgeDeps) {
const reloadAndOpen = useCallback((path: string) => {
reloadVault().then(fresh => {
@@ -27,6 +42,29 @@ export function useVaultBridge({
})
}, [reloadVault, onSelectNote, resolvedPath])
const refreshAgentChanges = useCallback((updatedFiles: string[]) => (
refreshPulledVaultState({
activeTabPath,
closeAllTabs,
hasUnsavedChanges,
reloadFolders,
reloadVault,
reloadViews,
replaceActiveTab,
updatedFiles,
vaultPath: resolvedPath,
})
), [
activeTabPath,
closeAllTabs,
hasUnsavedChanges,
reloadFolders,
reloadVault,
reloadViews,
replaceActiveTab,
resolvedPath,
])
const openNoteByPath = useCallback((path: string) => {
const entry = findEntry(entriesByPath, resolvedPath, path)
if (entry) onSelectNote(entry)
@@ -40,11 +78,12 @@ export function useVaultBridge({
}, [entriesByPath, resolvedPath, onSelectNote])
const handleAgentFileModified = useCallback((relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
if (activeTabPath === relativePath || activeTabPath === fullPath) reloadVault()
}, [reloadVault, activeTabPath, resolvedPath])
void refreshAgentChanges([relativePath])
}, [refreshAgentChanges])
const handleAgentVaultChanged = useCallback(() => { reloadVault() }, [reloadVault])
const handleAgentVaultChanged = useCallback(() => {
void refreshAgentChanges([])
}, [refreshAgentChanges])
return {
openNoteByPath,

View File

@@ -0,0 +1,33 @@
import type { VaultEntry } from '../types'
import { humanizePropertyKey } from './propertyLabels'
const PREFERRED_INVERSE_RELATIONSHIP_LABELS = ['Children', 'Events', 'Referenced by'] as const
export function resolveInverseRelationshipLabel(
key: string,
entry: Pick<VaultEntry, 'isA'>,
): string {
const normalizedKey = humanizePropertyKey(key).trim().toLowerCase()
if (normalizedKey === 'belongs to') {
return entry.isA === 'Event'
? 'Events'
: 'Children'
}
if (normalizedKey === 'related to') {
return entry.isA === 'Event'
? 'Events'
: 'Referenced by'
}
return `${humanizePropertyKey(key)}`
}
export function orderInverseRelationshipLabels(labels: Iterable<string>): string[] {
const customLabels = [...labels]
.filter((label) => !PREFERRED_INVERSE_RELATIONSHIP_LABELS.includes(label as typeof PREFERRED_INVERSE_RELATIONSHIP_LABELS[number]))
.sort((left, right) => left.localeCompare(right))
return [...PREFERRED_INVERSE_RELATIONSHIP_LABELS, ...customLabels]
}

View File

@@ -161,7 +161,43 @@ describe('buildRelationshipGroups', () => {
const groups = buildRelationshipGroups(parent, [parent, shared])
expect(groups.find((group) => group.label === 'Related to')?.entries).toEqual([shared])
expect(groups.find((group) => group.label === 'Referenced By')?.entries).toEqual([shared])
expect(groups.find((group) => group.label === 'Referenced by')?.entries).toEqual([shared])
})
it('normalizes canonical inverse relationship keys without duplicating raw inverse groups', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
})
const child = makeEntry({
path: '/vault/child.md',
filename: 'child.md',
title: 'Child',
isA: 'Note',
belongsTo: ['[[parent]]'],
relationships: {
belongs_to: ['[[parent]]'],
},
})
const related = makeEntry({
path: '/vault/related.md',
filename: 'related.md',
title: 'Related',
isA: 'Note',
relatedTo: ['[[parent]]'],
relationships: {
related_to: ['[[parent]]'],
},
})
const groups = buildRelationshipGroups(parent, [parent, child, related])
expect(groups.find((group) => group.label === 'Children')?.entries).toEqual([child])
expect(groups.find((group) => group.label === 'Referenced by')?.entries).toEqual([related])
expect(groups.find((group) => group.label === '← belongs_to')).toBeUndefined()
expect(groups.find((group) => group.label === '← related_to')).toBeUndefined()
})
it('includes all inverse relationship groups for non-core relationship keys', () => {

View File

@@ -1,5 +1,9 @@
import type { VaultEntry, SidebarSelection, InboxPeriod, ViewFile } from '../types'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
import {
orderInverseRelationshipLabels as sortInverseRelationshipLabels,
resolveInverseRelationshipLabel,
} from './inverseRelationshipLabels'
import { evaluateView } from './viewFilters'
import { wikilinkTarget, resolveEntry } from './wikilink'
@@ -308,12 +312,6 @@ function appendInverseRelationshipEntries(
inverseGroups.set(label, [entry])
}
function resolveInverseRelationshipLabel(key: string, entry: VaultEntry): string {
if (key === 'Belongs to') return entry.isA === 'Event' ? 'Events' : 'Children'
if (key === 'Related to') return entry.isA === 'Event' ? 'Events' : 'Referenced By'
return `${key}`
}
function appendLegacyInverseRelationshipEntries(
inverseGroups: Map<string, VaultEntry[]>,
entity: VaultEntry,
@@ -340,12 +338,7 @@ function appendDynamicInverseRelationshipEntries(
}
function orderInverseRelationshipLabels(inverseGroups: Map<string, VaultEntry[]>): string[] {
const preferredOrder = ['Children', 'Events', 'Referenced By']
const customLabels = [...inverseGroups.keys()]
.filter((label) => !preferredOrder.includes(label))
.sort((a, b) => a.localeCompare(b))
return [...preferredOrder, ...customLabels]
return sortInverseRelationshipLabels(inverseGroups.keys())
}
function collectInverseRelationshipGroups(
@@ -381,7 +374,7 @@ export function buildRelationshipGroups(
}
// Direct relationships first — all keys from entity.relationships take
// priority so that reverse/computed groups (Children, Events, Referenced By)
// priority so that reverse/computed groups (Children, Events, Referenced by)
// only show *additional* entries not already covered by a direct property.
Object.keys(rels)
.filter((k) => k.toLowerCase() !== 'type')

View File

@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest'
import type { VaultEntry } from '../types'
import { refreshPulledVaultState } from './pulledVaultRefresh'
function makeEntry(path: string, title = 'Test note'): VaultEntry {
return {
path,
title,
filename: path.split('/').pop() ?? 'note.md',
snippet: '',
wordCount: 0,
outgoingLinks: [],
} as VaultEntry
}
function makeOptions(overrides: Partial<Parameters<typeof refreshPulledVaultState>[0]> = {}) {
const activeEntry = makeEntry('/vault/active.md', 'Active')
return {
activeTabPath: activeEntry.path,
closeAllTabs: vi.fn(),
hasUnsavedChanges: vi.fn(() => false),
reloadFolders: vi.fn(),
reloadVault: vi.fn().mockResolvedValue([activeEntry]),
reloadViews: vi.fn(),
replaceActiveTab: vi.fn().mockResolvedValue(undefined),
updatedFiles: ['active.md'],
vaultPath: '/vault',
...overrides,
}
}
describe('refreshPulledVaultState', () => {
it('reloads vault-derived data and refreshes the active note when pull updated it', async () => {
const options = makeOptions()
const entries = await refreshPulledVaultState(options)
expect(entries).toHaveLength(1)
expect(options.reloadVault).toHaveBeenCalledOnce()
expect(options.reloadFolders).toHaveBeenCalledOnce()
expect(options.reloadViews).toHaveBeenCalledOnce()
expect(options.closeAllTabs).toHaveBeenCalledOnce()
expect(options.replaceActiveTab).toHaveBeenCalledWith(entries[0])
})
it('reloads the active tab after any successful pull with updates', async () => {
const options = makeOptions({ updatedFiles: ['project/plan.md'] })
await refreshPulledVaultState(options)
expect(options.reloadVault).toHaveBeenCalledOnce()
expect(options.closeAllTabs).not.toHaveBeenCalled()
expect(options.replaceActiveTab).toHaveBeenCalledWith(expect.objectContaining({ path: '/vault/active.md' }))
})
it('matches macOS /tmp and /private/tmp aliases when reloading the active tab entry', async () => {
const activeEntry = makeEntry('/private/tmp/tolaria/active.md', 'Active')
const options = makeOptions({
activeTabPath: activeEntry.path,
reloadVault: vi.fn().mockResolvedValue([activeEntry]),
vaultPath: '/tmp/tolaria',
})
await refreshPulledVaultState(options)
expect(options.closeAllTabs).toHaveBeenCalledOnce()
expect(options.replaceActiveTab).toHaveBeenCalledWith(activeEntry)
})
it('skips tab replacement when the active note has unsaved edits', async () => {
const options = makeOptions({
hasUnsavedChanges: vi.fn((path: string) => path === '/vault/active.md'),
})
await refreshPulledVaultState(options)
expect(options.replaceActiveTab).not.toHaveBeenCalled()
expect(options.closeAllTabs).not.toHaveBeenCalled()
})
it('closes the tab when the pulled note disappeared from the reloaded vault', async () => {
const options = makeOptions({
reloadVault: vi.fn().mockResolvedValue([makeEntry('/vault/other.md', 'Other')]),
})
await refreshPulledVaultState(options)
expect(options.replaceActiveTab).not.toHaveBeenCalled()
expect(options.closeAllTabs).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,68 @@
import type { VaultEntry } from '../types'
interface PulledVaultRefreshOptions {
activeTabPath: string | null
closeAllTabs: () => void
hasUnsavedChanges: (path: string) => boolean
reloadFolders: () => Promise<unknown> | unknown
reloadVault: () => Promise<VaultEntry[]>
reloadViews: () => Promise<unknown> | unknown
replaceActiveTab: (entry: VaultEntry) => Promise<void>
updatedFiles: string[]
vaultPath: string
}
function normalizePath(path: string): string {
return path
.replaceAll('\\', '/')
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
.replace(/\/+$/u, '')
}
function resolveUpdatedFilePath(path: string, vaultPath: string): string {
if (path.startsWith('/')) return normalizePath(path)
return normalizePath(`${vaultPath}/${path}`)
}
function didPullUpdateActiveNote(updatedFiles: string[], vaultPath: string, activeTabPath: string): boolean {
const normalizedActivePath = normalizePath(activeTabPath)
return updatedFiles.some((path) => resolveUpdatedFilePath(path, vaultPath) === normalizedActivePath)
}
export async function refreshPulledVaultState(options: PulledVaultRefreshOptions): Promise<VaultEntry[]> {
const {
activeTabPath,
closeAllTabs,
hasUnsavedChanges,
reloadFolders,
reloadVault,
reloadViews,
replaceActiveTab,
updatedFiles,
vaultPath,
} = options
const [entries] = await Promise.all([
reloadVault(),
Promise.resolve(reloadFolders()),
Promise.resolve(reloadViews()),
])
if (!activeTabPath || hasUnsavedChanges(activeTabPath)) return entries
const refreshedEntry = entries.find(entry => normalizePath(entry.path) === normalizePath(activeTabPath))
if (!refreshedEntry) {
closeAllTabs()
return entries
}
// Native BlockNote can keep rendering the previous document after a pull that
// changes the active file in place. Dropping the tab first forces a full
// reopen for that specific case without affecting unrelated pull updates.
if (didPullUpdateActiveNote(updatedFiles, vaultPath, activeTabPath)) {
closeAllTabs()
}
await replaceActiveTab(refreshedEntry)
return entries
}

View File

@@ -36,25 +36,59 @@ function untitledRow(page: Page, typeLabel: string) {
return page.getByText(new RegExp(`^Untitled ${typeLabel}(?: \\d+)?$`, 'i')).first()
}
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
await expect.poll(async () => page.evaluate(() => {
type EmptyHeadingState = {
contentType: string | null
editorFocused: boolean
placeholder: string | null
}
function capturePageErrors(page: Page): string[] {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
return errors
}
async function readEmptyHeadingState(page: Page): Promise<EmptyHeadingState> {
return page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
const inlineHeading = firstBlock?.querySelector('.bn-inline-content') as HTMLElement | null
return {
editorFocused: Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]')),
contentType: firstBlock?.getAttribute('data-content-type') ?? null,
editorFocused: Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]')),
placeholder: inlineHeading ? getComputedStyle(inlineHeading, '::before').content : null,
}
}), {
timeout: 5_000,
}).toEqual({
editorFocused: true,
contentType: 'heading',
placeholder: '"Title"',
})
}
function hasExpectedTitlePlaceholder(placeholder: string | null): boolean {
return placeholder === '"Heading"' || placeholder === '"Title"'
}
function isReadyEmptyTitleHeading(state: EmptyHeadingState): boolean {
return state.editorFocused && state.contentType === 'heading' && hasExpectedTitlePlaceholder(state.placeholder)
}
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
await expect.poll(async () => isReadyEmptyTitleHeading(await readEmptyHeadingState(page)), {
timeout: 5_000,
}).toBe(true)
}
async function expectUntitledNoteWithoutCrash(
page: Page,
typeLabel: string,
createNote: () => Promise<void>,
): Promise<void> {
const errors = capturePageErrors(page)
await createNote()
await expect(untitledRow(page, typeLabel)).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
}
test.describe('Create note crash fix', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
@@ -65,57 +99,36 @@ test.describe('Create note crash fix', () => {
})
test('clicking + next to a type section creates a note without crashing @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
await openTestVault(page)
await selectSection(page, 'Projects')
await createNoteFromListHeader(page)
await expect(untitledRow(page, 'project')).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
await expectUntitledNoteWithoutCrash(page, 'project', async () => {
await createNoteFromListHeader(page)
})
})
test('Cmd+N creates a note without crashing @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
await openTestVault(page)
await page.waitForTimeout(300)
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(untitledRow(page, 'note')).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
await expectUntitledNoteWithoutCrash(page, 'note', async () => {
await page.waitForTimeout(300)
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
})
})
test('creating note for custom type does not crash', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
await openTestVault(page)
await selectSection(page, 'Events')
await createNoteFromListHeader(page)
await expect(untitledRow(page, 'event')).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
await expectUntitledNoteWithoutCrash(page, 'event', async () => {
await createNoteFromListHeader(page)
})
})
test('command palette creates typed notes without crashing when a type template is present @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
seedTypeEntry(tempVaultDir, 'Procedure', '## Checklist\n\n- first step\n- [[Alpha Project]]\n- unmatched [link')
await openTestVault(page)
await openCommandPalette(page)
await executeCommand(page, 'new procedure')
await expect(untitledRow(page, 'procedure')).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
await expectUntitledNoteWithoutCrash(page, 'procedure', async () => {
await openCommandPalette(page)
await executeCommand(page, 'new procedure')
})
})
})

View File

@@ -0,0 +1,76 @@
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.getByTestId('note-list-container')
await noteList.getByText(title, { exact: true }).click()
}
async function stubUpdatedPull(page: Page, updatedFile: string) {
await page.evaluate((filePath) => {
window.__mockHandlers!.git_pull = () => ({
status: 'updated',
message: 'Pulled 1 update from remote',
updatedFiles: [filePath],
conflictFiles: [],
})
}, updatedFile)
}
async function pullFromRemote(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Pull from Remote')
}
test.describe('Pull refreshes the open note immediately', () => {
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.setViewportSize({ width: 1600, height: 900 })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('successful pull refreshes the open editor and note list title immediately', async ({ page }) => {
const originalTitle = 'Note B'
const pulledTitle = `Pulled Note B ${Date.now()}`
const pulledBody = `Pulled change ${Date.now()}`
const notePath = path.join(tempVaultDir, 'note', 'note-b.md')
await openNote(page, originalTitle)
await expect(page.locator('.bn-editor h1').first()).toHaveText(originalTitle, { timeout: 5_000 })
fs.writeFileSync(notePath, `---
Is A: Note
Status: Active
---
# ${pulledTitle}
${pulledBody}
`, 'utf8')
await stubUpdatedPull(page, notePath)
await pullFromRemote(page)
await expect(page.getByText('Pulled 1 update(s) from remote')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor h1').first()).toHaveText(pulledTitle, { timeout: 5_000 })
await expect(page.locator('.bn-editor')).toContainText(pulledBody, { timeout: 5_000 })
const noteList = page.getByTestId('note-list-container')
await expect(noteList.getByText(pulledTitle, { exact: true })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText(originalTitle, { exact: true })).toHaveCount(0)
})
})

View File

@@ -201,6 +201,17 @@ test('missing Getting Started vault stays hidden while remove actions still work
await expect(removeButton).toHaveCSS('opacity', '0')
await personalVaultItem.hover()
await expect(removeButton).toHaveCSS('opacity', '1')
const itemBounds = await personalVaultItem.boundingBox()
const removeBounds = await removeButton.boundingBox()
expect(itemBounds).not.toBeNull()
expect(removeBounds).not.toBeNull()
expect(removeBounds!.x).toBeGreaterThanOrEqual(itemBounds!.x - 1)
expect(removeBounds!.y).toBeGreaterThanOrEqual(itemBounds!.y - 1)
expect(removeBounds!.x + removeBounds!.width).toBeLessThanOrEqual(itemBounds!.x + itemBounds!.width + 1)
expect(removeBounds!.y + removeBounds!.height).toBeLessThanOrEqual(itemBounds!.y + itemBounds!.height + 1)
await removeButton.click()
await trigger.click()

View File

@@ -32,6 +32,7 @@ async function insertWikilink(page: Page) {
test.describe('Wikilink insertion and navigation', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await page.waitForTimeout(500)