Compare commits

...

17 Commits

Author SHA1 Message Date
lucaronin
3ee42fb856 test: align create-note smoke placeholder 2026-04-20 22:13:19 +02:00
lucaronin
eacf05a9f8 fix: reload notes after agent edits 2026-04-20 22:04:38 +02:00
lucaronin
a199fad803 fix: widen editor focus block typing 2026-04-20 18:38:51 +02:00
lucaronin
e956c806e5 fix: keep editor sync from clobbering live inserts 2026-04-20 18:35:50 +02:00
lucaronin
7a6e3863ad fix: reopen active note after pull updates 2026-04-20 16:04:11 +02:00
lucaronin
e0e2dc4063 test: add pull refresh regression 2026-04-20 15:53:56 +02:00
lucaronin
0020ade6d7 fix: tighten pulled note refresh flow 2026-04-20 14:43:52 +02:00
lucaronin
503e482c7d fix: normalize inverse relationship labels 2026-04-20 12:49:57 +02:00
lucaronin
0d0e6af4c9 fix: keep vault menu remove action in row 2026-04-20 12:19:10 +02:00
lucaronin
dbaf361878 test: fix starter-vault app shell coverage 2026-04-20 11:32:44 +02:00
lucaronin
d3741c82f5 test: fix starter-vault shell regression 2026-04-20 11:20:27 +02:00
lucaronin
b90d2f8612 fix: reorder welcome onboarding actions 2026-04-20 11:04:29 +02:00
lucaronin
9536efee0e fix: persist AI panel chats across reopen 2026-04-20 10:28:48 +02:00
lucaronin
c86a6ab9c5 docs: add/update ADR for starter vault local-first remote flow (guard — from commit 374b4f66) 2026-04-20 10:28:00 +02:00
Luca Rossi
1d7c027700 Updated Tolaria Icon 2026-04-20 10:25:46 +02:00
lucaronin
74e50c64bb fix: hide stale getting started vault entries 2026-04-20 01:02:08 +02:00
lucaronin
8b97ef711f feat: refresh welcome screen branding 2026-04-20 00:45:14 +02:00
56 changed files with 2324 additions and 651 deletions

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0070"
title: "Starter vaults are local-first with explicit remote connection"
status: active
date: 2026-04-19
---
## Context
ADR-0046 moved the Getting Started vault to a public GitHub repo cloned at runtime, and ADR-0059 established that Tolaria should support valid local-only vaults without treating a missing remote as an error.
That still left one mismatch: a freshly cloned starter vault inherited the template repo's `origin` remote. New users therefore landed in a vault that looked remote-backed by default, even though the intended workflow was to explore locally first and only connect a personal remote later. Keeping the starter remote also risked accidental pushes to the public template repo and gave Tolaria no safe place to reject incompatible remotes before tracking started.
## Decision
**After cloning the public starter vault, Tolaria removes every configured git remote so the vault opens local-only by default.** Users connect a remote later through an explicit Add Remote flow exposed from the `No remote` status-bar chip and the command palette.
**The new `git_add_remote` backend is the only path for attaching a remote to an existing local-only vault.** It adds `origin`, fetches the remote, rejects incompatible or ahead histories, and only starts tracking when the remote is safe for the current local repo.
## Options considered
- **Strip starter-vault remotes and add an explicit connect flow** (chosen): preserves a local-first onboarding experience, matches ADR-0059's local-only model, and prevents accidental coupling to the public template repo. Cons: users who want sync must do one extra explicit step.
- **Keep the starter repo's remote attached**: simplest implementation, but it makes the template repo look like the user's real sync target and increases the risk of accidental pushes or confusing remote state.
- **Force remote replacement during onboarding**: guarantees a personal remote up front, but adds too much setup friction to the Getting Started path and weakens Tolaria's offline/local-first story.
## Consequences
- Fresh Getting Started vaults now behave like any other local-only vault: commit locally first, then opt into sync later.
- The app gains a dedicated Add Remote UX (`AddRemoteModal`) plus a backend connection path (`git_add_remote`) instead of overloading clone or commit flows.
- Remote attachment is safer: Tolaria can reject unrelated or incompatible histories before the vault starts tracking a remote.
- The starter repo remains a distribution source only, not an ongoing sync destination.
- Re-evaluate if Tolaria later needs a faster "publish this local starter vault to my own repo" flow that should prefill or streamline the Add Remote step.

View File

@@ -125,3 +125,4 @@ proposed → active → superseded
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_111_151)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
</g>
<defs>
<clipPath id="clip0_111_151">
<rect width="266" height="363" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 889 B

View File

@@ -77,6 +77,12 @@ describe('AiPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('starts a new AI chat when the header action is clicked', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByTitle('New AI chat'))
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
@@ -153,6 +159,20 @@ describe('AiPanel', () => {
vi.useRealTimers()
})
it('focuses the panel shell when reopening with existing messages', async () => {
vi.useFakeTimers()
mockMessages = [{
userMessage: 'Remember this',
actions: [],
response: 'Still here.',
id: 'msg-3',
}]
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
await act(() => { vi.advanceTimersByTime(1) })
expect(document.activeElement).toBe(screen.getByTestId('ai-panel'))
vi.useRealTimers()
})
it('calls onClose when Escape is pressed while panel has focus', async () => {
vi.useFakeTimers()
const onClose = vi.fn()

View File

@@ -8,7 +8,7 @@ import {
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
import { type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { useAiPanelController } from './useAiPanelController'
import { useAiPanelController, type AiPanelController } from './useAiPanelController'
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
import { useAiPanelFocus } from './useAiPanelFocus'
@@ -32,29 +32,31 @@ interface AiPanelProps {
noteListFilter?: { type: string | null; query: string }
}
export function AiPanel({
interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onOpenNote?: (path: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReady?: boolean
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
}
export function AiPanelView({
controller,
onClose,
onOpenNote,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
onFileModified,
onVaultChanged,
vaultPath,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
}: AiPanelProps) {
}: AiPanelViewProps) {
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
const {
agent,
input,
@@ -64,24 +66,17 @@ export function AiPanel({
isActive,
handleSend,
handleNavigateWikilink,
} = useAiPanelController({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
handleNewChat,
} = controller
useAiPanelPromptQueue({ agent, input, isActive, setInput })
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
useAiPanelFocus({
inputRef,
panelRef,
hasMessages: agent.messages.length > 0,
isActive,
onClose,
})
return (
<aside
@@ -99,7 +94,13 @@ export function AiPanel({
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
<AiPanelHeader agentLabel={agentLabel} agentReady={defaultAiAgentReady} legacyCopy={useLegacyAiExperience} onClose={onClose} onClear={agent.clearConversation} />
<AiPanelHeader
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
onClose={onClose}
onNewChat={handleNewChat}
/>
{activeEntry && (
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
)}
@@ -128,3 +129,48 @@ export function AiPanel({
</aside>
)
}
export function AiPanel({
onClose,
onOpenNote,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
onFileModified,
onVaultChanged,
vaultPath,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
}: AiPanelProps) {
const controller = useAiPanelController({
vaultPath,
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
return (
<AiPanelView
controller={controller}
onClose={onClose}
onOpenNote={onOpenNote}
defaultAiAgent={providedDefaultAiAgent}
defaultAiAgentReady={providedDefaultAiAgentReady}
activeEntry={activeEntry}
entries={entries}
/>
)
}

View File

@@ -12,7 +12,7 @@ interface AiPanelHeaderProps {
agentReady: boolean
legacyCopy: boolean
onClose: () => void
onClear: () => void
onNewChat: () => void
}
interface AiPanelContextBarProps {
@@ -111,7 +111,7 @@ export function AiPanelHeader({
agentReady,
legacyCopy,
onClose,
onClear,
onNewChat,
}: AiPanelHeaderProps) {
return (
<div
@@ -132,8 +132,9 @@ export function AiPanelHeader({
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClear}
title="New conversation"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</button>

View File

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

View File

@@ -1,8 +1,11 @@
import { useEffect } from 'react'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
import { AiPanel } from './AiPanel'
import { AiPanelView } from './AiPanel'
import { useAiPanelController } from './useAiPanelController'
import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
interface EditorRightPanelProps {
showAIChat?: boolean
@@ -43,26 +46,45 @@ export function EditorRightPanel({
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
const aiPanelController = useAiPanelController({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
activeEntry: inspectorEntry,
activeNoteContent: inspectorContent,
entries,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
const { handleNewChat } = aiPanelController
useEffect(() => {
const handleRequestedNewChat = () => {
handleNewChat()
}
window.addEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
}, [handleNewChat])
if (showAIChat) {
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<AiPanel
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
activeNoteContent={inspectorContent}
entries={entries}
noteList={noteList}
noteListFilter={noteListFilter}
/>
</div>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { WelcomeScreen } from './WelcomeScreen'
import tolariaIcon from '@/assets/tolaria-icon.svg'
const dragRegionMouseDown = vi.fn()
@@ -30,19 +31,28 @@ describe('WelcomeScreen', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
expect(screen.getByText('Markdown knowledge management for the age of AI')).toBeInTheDocument()
})
it('shows all three option buttons', () => {
it('renders the local Tolaria branding icon', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
const brandIcon = screen.getByAltText('Tolaria icon')
expect(brandIcon).toHaveAttribute('src', tolariaIcon)
})
it('shows the onboarding actions in the guided-first order', () => {
render(<WelcomeScreen {...defaultProps} />)
const optionButtons = screen.getAllByRole('button')
expect(optionButtons[0]).toBe(screen.getByTestId('welcome-create-vault'))
expect(optionButtons[1]).toBe(screen.getByTestId('welcome-create-new'))
expect(optionButtons[2]).toBe(screen.getByTestId('welcome-open-folder'))
})
it('focuses the first action for keyboard users', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
expect(screen.getByTestId('welcome-create-vault')).toHaveFocus()
})
it('shows the simplified template option description', () => {
@@ -101,13 +111,13 @@ describe('WelcomeScreen', () => {
})
it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
const onOpenFolder = vi.fn()
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
fireEvent.keyDown(window, { key: 'Tab' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(onOpenFolder).toHaveBeenCalledOnce()
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('disables all buttons while creating', () => {

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from '@/components/ui/button'
import tolariaIcon from '@/assets/tolaria-icon.svg'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -105,6 +106,12 @@ const ICON_WRAP_STYLE: React.CSSProperties = {
justifyContent: 'center',
}
const BRAND_ICON_STYLE: React.CSSProperties = {
width: 64,
height: 64,
display: 'block',
}
const TITLE_STYLE: React.CSSProperties = {
fontSize: 28,
fontWeight: 700,
@@ -267,10 +274,10 @@ function getWelcomeScreenPresentation(
): WelcomeScreenPresentation {
if (mode === 'welcome') {
return {
heroBackground: 'var(--accent-blue-light, #EBF4FF)',
heroIcon: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>,
heroBackground: 'transparent',
heroIcon: <img src={tolariaIcon} alt="Tolaria icon" style={BRAND_ICON_STYLE} />,
openFolderLabel: 'Open existing vault',
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
subtitle: 'Markdown knowledge management for the age of AI',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: 'Download the getting started vault',
@@ -303,18 +310,18 @@ function useWelcomeActionButtons({
> & {
busy: boolean
}) {
const primaryActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const templateActionRef = useRef<HTMLButtonElement>(null)
const createEmptyActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const actionButtonRefs = useMemo(
() => [primaryActionRef, openFolderActionRef, templateActionRef],
() => [templateActionRef, createEmptyActionRef, openFolderActionRef],
[],
)
const actions = useMemo<WelcomeAction[]>(
() => ([
{ disabled: isOffline, run: onCreateVault },
{ disabled: false, run: onCreateEmptyVault },
{ disabled: false, run: onOpenFolder },
{ disabled: isOffline, run: onCreateVault },
]),
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
)
@@ -323,7 +330,7 @@ function useWelcomeActionButtons({
if (busy) return
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
// onboarding always starts on "Create empty vault".
// onboarding always starts on the guided template flow.
focusWelcomeAction(actionButtonRefs, 0)
}, [actionButtonRefs, busy, mode])
@@ -355,9 +362,9 @@ function useWelcomeActionButtons({
}, [actionButtonRefs, actions, busy])
return {
primaryActionRef,
openFolderActionRef,
templateActionRef,
createEmptyActionRef,
openFolderActionRef,
}
}
@@ -375,7 +382,7 @@ export function WelcomeScreen({
}: WelcomeScreenProps) {
const busy = creatingAction !== null
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({
mode,
busy,
isOffline,
@@ -410,6 +417,21 @@ export function WelcomeScreen({
<div style={DIVIDER_STYLE} />
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
label="Get started with a template"
description={presentation.templateDescription}
loadingLabel="Downloading template…"
loadingDescription="Cloning the Getting Started vault template"
onClick={onCreateVault}
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
autoFocus
buttonRef={templateActionRef}
/>
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
@@ -421,8 +443,7 @@ export function WelcomeScreen({
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"
autoFocus
buttonRef={primaryActionRef}
buttonRef={createEmptyActionRef}
/>
<OptionButton
@@ -435,20 +456,6 @@ export function WelcomeScreen({
testId="welcome-open-folder"
buttonRef={openFolderActionRef}
/>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
label="Get started with a template"
description={presentation.templateDescription}
loadingLabel="Downloading template…"
loadingDescription="Cloning the Getting Started vault template"
onClick={onCreateVault}
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
buttonRef={templateActionRef}
/>
</div>
{creatingAction === 'template' && (

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,18 @@ interface UseAiPanelControllerArgs {
onVaultChanged?: () => void
}
export interface AiPanelController {
agent: ReturnType<typeof useCliAiAgent>
input: string
setInput: React.Dispatch<React.SetStateAction<string>>
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['linkedEntries']
hasContext: boolean
isActive: boolean
handleSend: (text: string, references: NoteReference[]) => void
handleNavigateWikilink: (target: string) => void
handleNewChat: () => void
}
export function useAiPanelController({
vaultPath,
defaultAiAgent,
@@ -38,7 +50,7 @@ export function useAiPanelController({
onFileCreated,
onFileModified,
onVaultChanged,
}: UseAiPanelControllerArgs) {
}: UseAiPanelControllerArgs): AiPanelController {
const [input, setInput] = useState('')
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
activeEntry,
@@ -73,6 +85,11 @@ export function useAiPanelController({
onOpenNote?.(target)
}, [onOpenNote])
const handleNewChat = useCallback(() => {
agent.clearConversation()
setInput('')
}, [agent])
return {
agent,
input,
@@ -82,5 +99,6 @@ export function useAiPanelController({
isActive,
handleSend,
handleNavigateWikilink,
handleNewChat,
}
}

View File

@@ -3,33 +3,53 @@ import { useCallback, useEffect } from 'react'
interface UseAiPanelFocusArgs {
inputRef: React.RefObject<HTMLDivElement | null>
panelRef: React.RefObject<HTMLElement | null>
hasMessages: boolean
isActive: boolean
onClose: () => void
}
function focusPreferredElement(
panelRef: React.RefObject<HTMLElement | null>,
inputRef: React.RefObject<HTMLDivElement | null>,
shouldFocusPanel: boolean,
) {
if (shouldFocusPanel) {
panelRef.current?.focus()
return
}
inputRef.current?.focus()
}
function shouldHandleEscape(
event: KeyboardEvent,
panelRef: React.RefObject<HTMLElement | null>,
): boolean {
return event.key === 'Escape' && !!panelRef.current?.contains(document.activeElement)
}
export function useAiPanelFocus({
inputRef,
panelRef,
hasMessages,
isActive,
onClose,
}: UseAiPanelFocusArgs) {
const shouldFocusPanel = hasMessages || isActive
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 0)
const timer = setTimeout(() => {
focusPreferredElement(panelRef, inputRef, shouldFocusPanel)
}, 0)
return () => clearTimeout(timer)
}, [inputRef])
}, [inputRef, panelRef, shouldFocusPanel])
useEffect(() => {
if (isActive) {
panelRef.current?.focus()
return
}
inputRef.current?.focus()
}, [inputRef, isActive, panelRef])
focusPreferredElement(panelRef, inputRef, shouldFocusPanel)
}, [inputRef, panelRef, shouldFocusPanel])
const handleEscape = useCallback((event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (!panelRef.current?.contains(document.activeElement)) return
if (!shouldHandleEscape(event, panelRef)) return
event.preventDefault()
onClose()

View File

@@ -1,5 +1,6 @@
import type { CommandAction } from './types'
import type { ViewMode } from '../useViewMode'
import { requestNewAiChat } from '../../utils/aiPromptBridge'
interface ViewCommandsConfig {
hasActiveNote: boolean
@@ -34,6 +35,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
function makeConfig(overrides: Record<string, unknown> = {}) {
return {
@@ -220,6 +221,24 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
})
it('includes a New AI chat command that opens and resets the panel session', () => {
const config = makeConfig()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'new-ai-chat')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('New AI chat')
expect(cmd!.enabled).toBe(true)
cmd!.execute()
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: NEW_AI_CHAT_EVENT }))
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: OPEN_AI_CHAT_EVENT }))
dispatchSpy.mockRestore()
})
it('omits Inbox navigation when the explicit workflow is disabled', () => {
const config = makeConfig({ showInbox: false })
const { result } = renderHook(() => useCommandRegistry(config))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -195,6 +195,55 @@ describe('useVaultSwitcher', () => {
expect(result.current.isGettingStartedHidden).toBe(false)
})
it('drops stale canonical Getting Started entries when the starter path is missing', async () => {
mockVaultListStore = {
vaults: [
{ label: 'Getting Started', path: expectedDefaultVaultPath },
{ label: 'Work', path: '/work/vault' },
],
active_vault: '/work/vault',
hidden_defaults: [],
}
setMockInvokeBehavior({
checkVaultExists: ({ path }) => path === '/work/vault',
})
const { result } = await renderLoadedVaultSwitcher()
expect(result.current.allVaults).toEqual([{ label: 'Work', path: '/work/vault', available: true }])
expect(result.current.vaultPath).toBe('/work/vault')
await waitFor(() => {
expect(mockVaultListStore).toEqual({
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [],
})
})
})
it('clears a stale canonical Getting Started selection when the starter path is missing', async () => {
mockVaultListStore = {
vaults: [{ label: 'Getting Started', path: expectedDefaultVaultPath }],
active_vault: expectedDefaultVaultPath,
hidden_defaults: [],
}
setMockInvokeBehavior({ checkVaultExists: false })
const { result } = await renderLoadedVaultSwitcher()
expect(result.current.allVaults).toEqual([])
expect(result.current.selectedVaultPath).toBeNull()
await waitFor(() => {
expect(mockVaultListStore).toEqual({
vaults: [],
active_vault: null,
hidden_defaults: [],
})
})
})
it('handles load error gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockImplementation((cmd: string) => {

View File

@@ -137,6 +137,18 @@ function syncDefaultVaultExport(path: string) {
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path }
}
function isCanonicalGettingStartedPath(path: string, resolvedDefaultPath: string): boolean {
return path === resolvedDefaultPath
}
function isUnavailableGettingStartedVault(vault: VaultOption): boolean {
return vault.label === GETTING_STARTED_LABEL && vault.available === false
}
function shouldDropPersistedGettingStartedVault(vault: VaultOption, resolvedDefaultPath: string): boolean {
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) || isUnavailableGettingStartedVault(vault)
}
async function checkVaultAvailability(path: string): Promise<boolean> {
if (!path) {
return false
@@ -166,7 +178,79 @@ async function loadInitialVaultState() {
console.warn('Failed to load vault list:', vaultListResult.reason)
}
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
return sanitizeCanonicalGettingStartedState({
activeVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults,
})
}
function sanitizeCanonicalGettingStartedState({
activeVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults,
}: {
activeVault: string | null
defaultAvailable: boolean
hiddenDefaults: string[]
resolvedDefaultPath: string
vaults: VaultOption[]
}) {
if (!resolvedDefaultPath) {
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
}
const filteredVaults = vaults.filter(
(vault) => !shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath),
)
const removedStarterPaths = new Set(
vaults
.filter((vault) => shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath))
.map((vault) => vault.path),
)
const sanitizedActiveVault = resolveSanitizedGettingStartedSelection({
activeVault,
defaultAvailable,
filteredVaults,
removedStarterPaths,
resolvedDefaultPath,
})
return {
activeVault: sanitizedActiveVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults: filteredVaults,
}
}
function resolveSanitizedGettingStartedSelection({
activeVault,
defaultAvailable,
filteredVaults,
removedStarterPaths,
resolvedDefaultPath,
}: {
activeVault: string | null
defaultAvailable: boolean
filteredVaults: VaultOption[]
removedStarterPaths: Set<string>
resolvedDefaultPath: string
}): string | null {
if (!activeVault || !removedStarterPaths.has(activeVault)) {
return activeVault
}
if (isCanonicalGettingStartedPath(activeVault, resolvedDefaultPath) && defaultAvailable) {
return activeVault
}
return filteredVaults[0]?.path ?? null
}
function buildDefaultVaults({

View File

@@ -2,6 +2,7 @@ import type { NoteReference } from './ai-context'
export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat'
export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued'
export const NEW_AI_CHAT_EVENT = 'tolaria:new-ai-chat'
export interface QueuedAiPrompt {
id: number
@@ -32,3 +33,8 @@ export function takeQueuedAiPrompt(): QueuedAiPrompt | null {
export function requestOpenAiChat() {
window.dispatchEvent(new Event(OPEN_AI_CHAT_EVENT))
}
export function requestNewAiChat() {
window.dispatchEvent(new Event(NEW_AI_CHAT_EVENT))
requestOpenAiChat()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,14 +14,14 @@ test.describe('AI chat conversation history', () => {
await noteItem.click()
await page.waitForTimeout(500)
// Open AI Chat with Ctrl+I
await sendShortcut(page, 'i', ['Control'])
// Open AI Chat with the current keyboard shortcut.
await sendShortcut(page, 'L', ['Meta', 'Shift'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
})
test('first message has no conversation history marker', async ({ page }) => {
test('first message renders a mocked AI response', async ({ page }) => {
// Find the input and send a message
const input = page.locator('input[placeholder*="Ask"]')
const input = page.getByTestId('agent-input')
await input.fill('Hello')
await page.getByTestId('agent-send').click()
@@ -29,36 +29,35 @@ test.describe('AI chat conversation history', () => {
const response = page.getByTestId('ai-message').last()
await expect(response).toBeVisible({ timeout: 5000 })
// First message should have [mock-no-history] since there's no prior conversation
await expect(response).toContainText('[mock-no-history]')
await expect(response).toContainText('[mock-claude code]')
await expect(response).toContainText('You said: "Hello"')
})
test('second message includes conversation history from first exchange', async ({ page }) => {
test('second message appends to the current visible conversation', async ({ page }) => {
// Send first message
const input = page.locator('input[placeholder*="Ask"]')
const input = page.getByTestId('agent-input')
await input.fill('What is 2+2?')
await page.getByTestId('agent-send').click()
// Wait for first response to appear
const firstResponse = page.getByTestId('ai-message').last()
await expect(firstResponse).toBeVisible({ timeout: 5000 })
await expect(firstResponse).toContainText('[mock-no-history]')
await expect(firstResponse).toContainText('[mock-claude code]')
// Send second message
await input.fill('What was my previous question?')
await page.getByTestId('agent-send').click()
// Wait for second response — it should contain history marker
await page.waitForTimeout(1000)
const messages = page.getByTestId('ai-message')
await expect(messages).toHaveCount(2)
await expect(messages.first()).toContainText('What is 2+2?')
const secondResponse = page.getByTestId('ai-message').last()
await expect(secondResponse).toContainText('[mock-with-history', { timeout: 5000 })
// turns=2 means 2 [user] lines: original + new question
await expect(secondResponse).toContainText('turns=2')
await expect(secondResponse).toContainText('What was my previous question?', { timeout: 5000 })
})
test('history resets after clearing conversation', async ({ page }) => {
// Send first message
const input = page.locator('input[placeholder*="Ask"]')
const input = page.getByTestId('agent-input')
await input.fill('Hello')
await page.getByTestId('agent-send').click()
@@ -67,7 +66,7 @@ test.describe('AI chat conversation history', () => {
await expect(firstResponse).toBeVisible({ timeout: 5000 })
// Clear conversation (click the + button)
await page.locator('button[title="New conversation"]').click()
await page.locator('button[title="New AI chat"]').click()
await page.waitForTimeout(300)
// Messages should be cleared
@@ -79,6 +78,31 @@ test.describe('AI chat conversation history', () => {
const freshResponse = page.getByTestId('ai-message').last()
await expect(freshResponse).toBeVisible({ timeout: 5000 })
await expect(freshResponse).toContainText('[mock-no-history]')
await expect(freshResponse).toContainText('[mock-claude code]')
await expect(freshResponse).toContainText('You said: "Fresh start"')
})
test('closing and reopening restores the last chat until a new AI chat is started', async ({ page }) => {
const input = page.getByTestId('agent-input')
await input.fill('Keep this thread alive')
await page.getByTestId('agent-send').click()
const firstResponse = page.getByTestId('ai-message').last()
await expect(firstResponse).toContainText('[mock-claude code]', { timeout: 5000 })
await page.getByTitle('Close AI panel').click()
await expect(page.getByTestId('ai-panel')).toHaveCount(0)
await sendShortcut(page, 'L', ['Meta', 'Shift'])
const panel = page.getByTestId('ai-panel')
await expect(panel).toBeVisible({ timeout: 3_000 })
const restoredMessage = page.getByTestId('ai-message').last()
await expect(restoredMessage).toContainText('Keep this thread alive')
await expect(restoredMessage).toContainText('[mock-claude code]')
await page.keyboard.press('Tab')
await expect(page.getByTitle('New AI chat')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('ai-message')).toHaveCount(0)
})
})

View File

@@ -181,8 +181,10 @@ test('keyboard onboarding can create an empty vault and the first note', async (
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toContainText('Create empty vault')
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })

View File

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

View File

@@ -34,7 +34,7 @@ test.describe('Fresh-install regression: AI panel renders and works', () => {
// Layer 1: Header with title and buttons
await expect(panel.locator('text=AI Chat')).toBeVisible()
await expect(panel.locator('button[title="New conversation"]')).toBeVisible()
await expect(panel.locator('button[title="New AI chat"]')).toBeVisible()
await expect(panel.locator('button[title="Close AI panel"]')).toBeVisible()
// Layer 2: Message area (empty state with robot icon suggestion)

View File

@@ -59,7 +59,7 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
})
test('telemetry consent still leaves the welcome wizard fully keyboard navigable @smoke', async ({ page }) => {
@@ -76,6 +76,9 @@ test('telemetry consent still leaves the welcome wizard fully keyboard navigable
await page.keyboard.press('Enter')
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Tab')
@@ -92,10 +95,10 @@ test('telemetry consent still leaves the welcome wizard fully keyboard navigable
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
})
for (const action of ['accept', 'decline'] as const) {

View File

@@ -3,6 +3,7 @@ import { test, expect, type Page } from '@playwright/test'
async function installOnboardingMocks(page: Page, offline: boolean) {
await page.addInitScript((isOffline: boolean) => {
localStorage.clear()
let createdVaultPath: string | null = null
let ref: Record<string, unknown> | null = null
@@ -16,11 +17,12 @@ async function installOnboardingMocks(page: Page, offline: boolean) {
hidden_defaults: [],
})
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
ref.check_vault_exists = () => false
ref.check_vault_exists = (args: { path?: string }) => args.path === createdVaultPath
ref.create_getting_started_vault = (args: { targetPath?: string | null }) => {
if (args.targetPath !== '/Users/mock/Documents/Getting Started') {
throw new Error(`Unexpected Getting Started target: ${args.targetPath}`)
}
createdVaultPath = args.targetPath
return args.targetPath
}
},
@@ -65,6 +67,6 @@ test('status bar keeps a Getting Started clone entry available after onboarding'
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
await page.getByTestId('claude-onboarding-continue').click()
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
await page.getByTitle('Switch vault').click()
await page.getByTestId('status-vault-trigger').click()
await expect(page.getByTestId('vault-menu-clone-getting-started')).toBeVisible()
})

View File

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

View File

@@ -0,0 +1,135 @@
import { test, expect, type Page } from '@playwright/test'
interface StaleStarterPaths {
gettingStartedPath: string
personalVaultPath: string
workVaultPath: string
}
function buildPaths(): StaleStarterPaths {
return {
gettingStartedPath: '/Users/mock/Documents/Getting Started',
personalVaultPath: '/Users/mock/Personal',
workVaultPath: '/Users/mock/Work',
}
}
async function installStaleStarterMocks(page: Page) {
const paths = buildPaths()
await page.addInitScript((data: StaleStarterPaths) => {
localStorage.clear()
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => ({
vaults: [
{ label: 'Getting Started', path: data.gettingStartedPath },
{ label: 'Work Vault', path: data.workVaultPath },
{ label: 'Personal Vault', path: data.personalVaultPath },
],
active_vault: data.workVaultPath,
hidden_defaults: [],
})
ref.get_default_vault_path = () => data.gettingStartedPath
ref.check_vault_exists = (args: { path?: string }) =>
args?.path === data.workVaultPath || args?.path === data.personalVaultPath
ref.list_vault = (args: { path?: string }) => {
if (args?.path === data.workVaultPath) {
return [{
path: `${data.workVaultPath}/work-home.md`,
filename: 'work-home.md',
title: 'Work Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Work Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}]
}
if (args?.path === data.personalVaultPath) {
return [{
path: `${data.personalVaultPath}/personal-home.md`,
filename: 'personal-home.md',
title: 'Personal Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Personal Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}]
}
return []
}
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => ({
[`${data.workVaultPath}/work-home.md`]: '# Work Home\n\nWork Home snippet',
[`${data.personalVaultPath}/personal-home.md`]: '# Personal Home\n\nPersonal Home snippet',
})
ref.get_note_content = (args: { path?: string }) => {
if (args?.path === `${data.workVaultPath}/work-home.md`) {
return '# Work Home\n\nWork Home snippet'
}
if (args?.path === `${data.personalVaultPath}/personal-home.md`) {
return '# Personal Home\n\nPersonal Home snippet'
}
return ''
}
ref.get_modified_files = () => []
ref.get_file_history = () => []
},
get() {
return ref
},
})
}, paths)
}
test('stale persisted Getting Started entries stay hidden when the starter path is missing @smoke', async ({ page }) => {
await installStaleStarterMocks(page)
await page.goto('/', { waitUntil: 'domcontentloaded' })
const trigger = page.getByTestId('status-vault-trigger')
await expect(trigger).toContainText('Work Vault')
await trigger.focus()
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('vault-menu-item-Getting Started')).toHaveCount(0)
await expect(page.getByTestId('vault-menu-item-Work Vault')).toBeVisible()
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeVisible()
})

View File

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

View File

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