Compare commits
11 Commits
alpha-v202
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee42fb856 | ||
|
|
eacf05a9f8 | ||
|
|
a199fad803 | ||
|
|
e956c806e5 | ||
|
|
7a6e3863ad | ||
|
|
e0e2dc4063 | ||
|
|
0020ade6d7 | ||
|
|
503e482c7d | ||
|
|
0d0e6af4c9 | ||
|
|
dbaf361878 | ||
|
|
d3741c82f5 |
@@ -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
|
||||
|
||||
@@ -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)"]
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
53
src/App.tsx
53
src/App.tsx
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
src/utils/inverseRelationshipLabels.ts
Normal file
33
src/utils/inverseRelationshipLabels.ts
Normal 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]
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
91
src/utils/pulledVaultRefresh.test.ts
Normal file
91
src/utils/pulledVaultRefresh.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
68
src/utils/pulledVaultRefresh.ts
Normal file
68
src/utils/pulledVaultRefresh.ts
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
76
tests/smoke/pull-refresh-open-note.spec.ts
Normal file
76
tests/smoke/pull-refresh-open-note.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user