Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions apps/desktop/src/editor/use-editor-autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHook } from 'vitest-browser-react'
import { useEditorAutocomplete } from './use-editor-autocomplete'

const materializeDailyNote = vi.hoisted(() => vi.fn())
const resolveOrCreateNoteWithTitle = vi.hoisted(() => vi.fn())
const suggestWikiLinkTargets = vi.hoisted(() => vi.fn())
const operationFail = vi.hoisted(() => vi.fn())
Expand All @@ -13,6 +14,7 @@ vi.mock('@reflect/core', async (importOriginal) => ({
suggestWikiTargets: async () => [],
suggestWikiLinkTargets,
suggestTags: async () => [],
materializeDailyNote,
resolveOrCreateNoteWithTitle,
}))
vi.mock('@/providers/graph-provider', () => ({
Expand All @@ -33,6 +35,7 @@ vi.mock('@/hooks/use-contacts-authorization', () => ({
vi.mock('@/lib/operations', () => ({ startOperation }))

beforeEach(() => {
materializeDailyNote.mockReset().mockResolvedValue('daily/2026-07-27.md')
resolveOrCreateNoteWithTitle.mockReset()
suggestWikiLinkTargets.mockReset()
suggestWikiLinkTargets.mockResolvedValue({
Expand All @@ -45,6 +48,88 @@ beforeEach(() => {
})

describe('useEditorAutocomplete', () => {
it.each([
{ query: '2026-07-27', generated: undefined },
{ query: 'six days from now', generated: { phrase: 'Six days from now' } },
])('materializes a new daily when selecting $query', async ({ query, generated }) => {
suggestWikiLinkTargets.mockResolvedValue({
suggestions: [
{
target: '2026-07-27',
insertText: '2026-07-27',
title: '2026-07-27',
alias: null,
date: '2026-07-27',
path: null,
...(generated === undefined ? {} : { generated }),
},
],
claimedTargetKeys: [],
queryReadsAsDate: true,
})
const { result } = renderHook(() => useEditorAutocomplete())
const items = await result.current.onWikilinkSearch(query)

act(() => {
items[0]!.onSelect?.()
})

await waitFor(() =>
expect(materializeDailyNote).toHaveBeenCalledWith('2026-07-27', 7),
)
Comment on lines +70 to +79
})

it('does not try to recreate an existing daily suggestion', async () => {
suggestWikiLinkTargets.mockResolvedValue({
suggestions: [
{
target: '2026-07-27',
insertText: '2026-07-27',
title: '2026-07-27',
alias: null,
date: '2026-07-27',
path: 'daily/2026-07-27.md',
},
],
claimedTargetKeys: ['2026-07-27'],
queryReadsAsDate: true,
})
const { result } = renderHook(() => useEditorAutocomplete())
const items = await result.current.onWikilinkSearch('2026-07-27')

expect(items[0]?.onSelect).toBeUndefined()
expect(materializeDailyNote).not.toHaveBeenCalled()
})

it('surfaces a failed daily creation instead of leaving a silent dangling link', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
materializeDailyNote.mockRejectedValue(new Error('graph changed'))
suggestWikiLinkTargets.mockResolvedValue({
suggestions: [
{
target: '2026-07-27',
insertText: '2026-07-27',
title: '2026-07-27',
alias: null,
date: '2026-07-27',
path: null,
},
],
claimedTargetKeys: [],
queryReadsAsDate: true,
})
const { result } = renderHook(() => useEditorAutocomplete())
const items = await result.current.onWikilinkSearch('2026-07-27')

act(() => {
items[0]!.onSelect?.()
})

await waitFor(() => expect(operationFail).toHaveBeenCalledWith('graph changed'))
expect(startOperation).toHaveBeenCalledWith('Creating daily note')
consoleError.mockRestore()
})

it('does not offer create when the exact query has an unaddressable claim', async () => {
suggestWikiLinkTargets.mockResolvedValue({
suggestions: [],
Expand Down
36 changes: 34 additions & 2 deletions apps/desktop/src/editor/use-editor-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
errorMessage,
hasBridge,
isContactsReadable,
materializeDailyNote,
resolveOrCreateNoteWithTitle,
suggestTags,
suggestWikiLinkTargets,
Expand Down Expand Up @@ -73,6 +74,19 @@ export function useEditorAutocomplete(): EditorAutocomplete {
[generation],
)

const materializeDailyFromAutocomplete = useCallback(
(date: string): void => {
if (generation === null) {
return
}
void materializeDailyNote(date, generation).catch((error: unknown) => {
console.error('create-daily-from-autocomplete failed:', error)
startOperation('Creating daily note').fail(errorMessage(error))
})
},
[generation],
)

const onWikilinkSearch = useCallback(
async (query: string): Promise<WikilinkItem[]> => {
if (!hasBridge() || graph === null) {
Expand Down Expand Up @@ -135,10 +149,22 @@ export function useEditorAutocomplete(): EditorAutocomplete {
}
}
const { title, alias, date, path, generated, insertText: target } = entry.suggestion
// Selecting a pathless date is the create action: materialize its empty
// daily file in the background so the inserted link resolves and gains
// a backlink without requiring the user to visit or edit that day.
const dateSelection =
date !== null && path === null
? { onSelect: () => materializeDailyFromAutocomplete(date) }
: {}
// A generated date leads with its phrase ("Next Friday"), resolved day
// as the detail; everything else keeps the title/alias/daily form.
if (generated !== undefined && date !== null) {
return { target, label: generated.phrase, detail: formatDayLabel(date, settings.dateFormat) }
return {
target,
label: generated.phrase,
detail: formatDayLabel(date, settings.dateFormat),
...dateSelection,
}
}
// A rich title reads as its rendered form; the raw form stays the identity.
const displayedTitle = displayNoteTitle(title)
Expand All @@ -151,14 +177,20 @@ export function useEditorAutocomplete(): EditorAutocomplete {
? `${date} · new`
: date
: undefined
return { target, label, ...(detail !== undefined ? { detail } : {}) }
return {
target,
label,
...(detail !== undefined ? { detail } : {}),
...dateSelection,
}
})
},
[
graph,
settings.dateFormat,
settings.weekStartDay,
resolveOrCreateFromAutocomplete,
materializeDailyFromAutocomplete,
contactsInMenu,
generation,
],
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/exports/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export {
untitledNotePath,
isUntitledNotePath,
createNoteWithTitle,
materializeDailyNote,
resolveOrCreateNoteWithTitle,
type ResolveOrCreateNoteResult,
} from '../graph/create-note'
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/graph/create-note.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { setBridge } from '../ipc/bridge'
import {
createNoteWithTitle,
isUntitledNotePath,
materializeDailyNote,
resolveOrCreateNoteWithTitle,
untitledNotePath,
untitledNoteSeed,
Expand Down Expand Up @@ -146,6 +147,43 @@ describe('createNoteWithTitle', () => {
})
})

describe('materializeDailyNote', () => {
it('atomically creates an empty daily file at the date path', async () => {
const invoke = bindBridge()

await expect(materializeDailyNote('2026-07-27', 7)).resolves.toBe(
'daily/2026-07-27.md',
)
expect(invoke).toHaveBeenCalledWith('note_create', {
path: 'daily/2026-07-27.md',
contents: '',
generation: 7,
})
})

it('does not replace an existing daily file', async () => {
const invoke = bindBridge({ files: { 'daily/2026-07-27.md': 'existing\n' } })

await expect(materializeDailyNote('2026-07-27', 7)).resolves.toBe(
'daily/2026-07-27.md',
)
expect(invoke).toHaveBeenCalledWith('note_create', {
path: 'daily/2026-07-27.md',
contents: '',
generation: 7,
})
})

it('rejects impossible dates before attempting a write', async () => {
const invoke = bindBridge()

await expect(materializeDailyNote('2026-02-31', 7)).rejects.toThrow(
'dailyPath expects a valid calendar date',
)
expect(invoke.mock.calls.some(([command]) => command === 'note_create')).toBe(false)
})
})

describe('resolveOrCreateNoteWithTitle', () => {
it('uses exact index resolution before reading the slug family', async () => {
const invoke = bindBridge({
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/graph/create-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ulid } from 'ulidx'
import { upsertFrontmatter } from '../markdown/frontmatter'
import { slugForTitle } from '../markdown/slug'
import { createNoteIfAbsent } from './commands'
import { notePath } from './paths'
import { dailyPath, notePath } from './paths'
import {
resolveExistingWikiTarget,
type ExistingWikiTargetResolution,
Expand Down Expand Up @@ -81,6 +81,19 @@ export async function createNoteWithTitle(
return claimed.path
}

/**
* Materialize a daily note selected from wiki-link autocomplete without
* replacing one created concurrently. Daily notes have no title seed: their
* date path is their identity, and an empty file is the canonical new state.
*
* @returns The graph-relative daily-note path.
*/
export async function materializeDailyNote(date: string, generation: number): Promise<string> {
const path = dailyPath(date)
await createNoteIfAbsent(path, '', generation)
return path
}

/** Far beyond any real graph's same-slug population; fail loud instead of spinning. */
const MAX_CREATE_ATTEMPTS = 1000

Expand Down