Skip to content
Merged
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
241 changes: 241 additions & 0 deletions packages/app-core/src/components/QuickCaptureApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ describe('Quick Capture Escape handling (#765)', () => {
configurable: true,
value: () => new DOMRect()
})
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn()
})
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
Expand Down Expand Up @@ -225,6 +229,10 @@ describe('Quick Capture close shortcut (#893)', () => {
configurable: true,
value: () => new DOMRect()
})
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn()
})
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
Expand Down Expand Up @@ -293,6 +301,239 @@ describe('Quick Capture close shortcut (#893)', () => {
expect(host.querySelector('input')).not.toBeNull()
})

// The chords below are claimed in a capture-phase listener on window, because
// codemirror-vim consumes Mod+N / Mod+P / Mod+W as normal-mode chords and stops
// propagation, so those chords only reach the bubble listener in insert mode.
// The fix moved them into a capture-phase listener on window. Dispatching on
// document is what makes these tests exercise that phase: the capture path
// still starts at window, so the listener runs, while the editor never sees
// the key and Vim cannot stand in for it.
const onWindow = (init: KeyboardEventInit): Promise<void> => press(document, init)
const modP: KeyboardEventInit = { key: 'p', code: 'KeyP', keyCode: 80, metaKey: true }
const modN: KeyboardEventInit = { key: 'n', code: 'KeyN', keyCode: 78, metaKey: true }
const modShiftP: KeyboardEventInit = {
key: 'p', code: 'KeyP', keyCode: 80, metaKey: true, shiftKey: true
}
// Navigation inside an overlay is Ctrl-only. isPalettePreviousKey requires
// ctrlKey and rejects metaKey outright, on every platform, which is why the
// footer reads Ctrl and not Mod. Mod+N/P only open the overlays.
const ctrlP: KeyboardEventInit = { key: 'p', code: 'KeyP', keyCode: 80, ctrlKey: true }
const ctrlN: KeyboardEventInit = { key: 'n', code: 'KeyN', keyCode: 78, ctrlKey: true }

const overlayInput = (): HTMLInputElement | null =>
host.querySelector<HTMLInputElement>('input')
const footerHints = (): string =>
(host.querySelector('footer')?.textContent ?? '').replace(/\s+/g, ' ').trim()
const rows = (): HTMLButtonElement[] =>
[...host.querySelectorAll<HTMLButtonElement>('[data-qc-idx]')]
// Exact class token, not a substring match: the inactive row carries
// `hover:bg-paper-200/60`, which contains "bg-paper-200" as a substring.
const activeRow = (): HTMLButtonElement =>
rows().find((r) => r.classList.contains('bg-paper-200')) ?? rows()[0]

/** `window.zen` is installed by defineProperty without `writable`, so seeding
* notes has to go through the same door rather than by assignment. */
function seedNotes(notes: { path: string; title: string; folder: string }[]): void {
const zen = (window as unknown as { zen: Record<string, unknown> }).zen
Object.defineProperty(window, 'zen', {
configurable: true,
value: {
...zen,
listNotes: vi.fn(async () =>
notes.map((n, i) => ({
...n,
siblingOrder: i,
createdAt: 0,
updatedAt: 0,
size: 0,
tags: [],
wikilinks: [],
hasAttachments: false,
assetEmbeds: [],
excerpt: ''
}))
)
}
})
}

/** CodeMirror starts in insert mode here, so one Escape puts Vim in normal
* mode, which is the state where the chords used to be swallowed. */
async function toNormalMode(view: EditorView): Promise<void> {
await press(view.contentDOM, { key: 'Escape', keyCode: 27 })
}

it('opens the note picker on Mod+P in normal mode, where Vim would take it', async () => {
const view = await mount()
await toNormalMode(view)

await onWindow(modP)

expect(overlayInput()?.placeholder).toContain('Search notes')
// Vim's cursor-up never ran, and nothing was saved or closed on the way.
expect(windowClose).not.toHaveBeenCalled()
expect(writeNote).not.toHaveBeenCalled()
})

it('starts a new note on Mod+N in normal mode', async () => {
const view = await mount()
await toNormalMode(view)

await onWindow(modN)

expect(createNote).toHaveBeenCalled()
expect(overlayInput()).toBeNull()
})

it('hides the window on Mod+W in normal mode, keeping the draft', async () => {
const view = await mount()
await toNormalMode(view)

await onWindow(cmdW)

expect(windowClose).toHaveBeenCalledOnce()
})

it('opens the command palette on Mod+Shift+P in normal mode', async () => {
const view = await mount()
await toNormalMode(view)

await onWindow(modShiftP)

expect(overlayInput()?.placeholder).toContain('Run a command')
})

it('moves down rather than toggling once the picker has focus', async () => {
seedNotes([
{ path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' },
{ path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' }
])
await mount()
await onWindow(modP)
expect(rows()).toHaveLength(2)
expect(activeRow().textContent).toContain('Alpha')

// The chord targets the focused input, which is where a real keystroke
// lands. The window capture listener stands aside while an overlay is
// open, so the overlay's own handler navigates rather than the chord
// re-entering the capture handler and closing the picker.
await press(overlayInput()!, ctrlN)

expect(overlayInput()?.placeholder).toContain('Search notes')
expect(rows()).toHaveLength(2)
expect(activeRow().textContent).toContain('Beta')
})

it('holds at the top row on Ctrl+P instead of closing the picker', async () => {
seedNotes([
{ path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' },
{ path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' }
])
await mount()
await onWindow(modP)
const input = overlayInput()!
await press(input, ctrlN) // move off the top row first

// Ctrl+P is previous. From the top row it clamps rather than toggling the
// overlay shut, which is the specific failure being fixed.
await press(input, ctrlP)
expect(overlayInput()?.placeholder).toContain('Search notes')
expect(activeRow().textContent).toContain('Alpha')

// And it does go up when there is somewhere to go.
await press(input, ctrlN)
await press(input, ctrlP)
expect(activeRow().textContent).toContain('Alpha')
})

it('stops moving at the last row instead of wrapping or closing', async () => {
seedNotes([
{ path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' },
{ path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' }
])
await mount()
await onWindow(modP)
const input = overlayInput()!
await press(input, ctrlN) // onto Beta, the last row
await press(input, ctrlN) // one past the end

expect(rows()).toHaveLength(2)
expect(activeRow().textContent).toContain('Beta')
})

it('swaps the footer hints while an overlay is open', async () => {
await mount()
const idle = footerHints()
expect(idle).toContain('notes')
expect(idle).toContain('close')

await onWindow(modP)
const open = footerHints()
expect(open).toContain('move')
expect(open).toContain('esc close')
// Ctrl, not Mod. Cmd+N/P do not navigate in any palette, so advertising
// them here would be the same lie Bug B was about, reintroduced.
expect(open).toContain('Ctrl+N/P')
expect(open).not.toContain('⌘N/P')
// The chords that no longer apply inside an overlay are not offered.
expect(open).not.toContain('new')
expect(open).not.toContain('cmd')
})

it('drops the two command hints that no longer run inside the command palette', async () => {
await mount()
await onWindow(modShiftP)

const listed = rows().map((r) => r.textContent?.replace(/\s+/g, ' ').trim() ?? '')
expect(listed.some((t) => t.startsWith('Save and start a new note'))).toBe(true)
expect(listed.some((t) => t.startsWith('Open another note'))).toBe(true)
// Mod+Enter is not a navigation chord, so its hint stays.
expect(rows()[0].textContent).toContain('↩')
})

it('scrolls the active row into view in both overlays', async () => {
seedNotes([
{ path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' },
{ path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' }
])
const scrolled = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>

await mount()

await onWindow(modP)
scrolled.mockClear()
await press(overlayInput()!, ctrlN)
expect(activeRow().textContent).toContain('Beta')
expect(scrolled).toHaveBeenCalledWith({ block: 'nearest' })

// Dismiss, then the same check for the command overlay. Its effect is
// keyed on the same `active`, but it is a separate component.
await press(overlayInput()!, { key: 'Escape', keyCode: 27 })
await onWindow(modShiftP)
scrolled.mockClear()
await press(overlayInput()!, ctrlN)
expect(scrolled).toHaveBeenCalledWith({ block: 'nearest' })
})

it('shows only the subpath beside the filename', async () => {
seedNotes([
{
path: 'inbox/ZenNotes/Enhancements/Calendar Panel Tasks Placement.md',
title: 'Calendar Panel Tasks Placement',
folder: 'inbox'
}
])
await mount()
await onWindow(modP)

const cells = [...rows()[0].querySelectorAll('span')].map((s) => s.textContent?.trim() ?? '')
expect(cells).toContain('Calendar Panel Tasks Placement')
expect(cells).toContain('ZenNotes/Enhancements')
// Neither the folder nor the filename is repeated in the path column.
expect(cells.filter((c) => c === 'inbox')).toHaveLength(1)
expect(cells.some((c) => c.endsWith('.md'))).toBe(false)
})

it('follows a rebind of Close active tab', async () => {
localStorage.setItem(
'zen:prefs:v2',
Expand Down
Loading
Loading