Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
64abae7
Fix(palette): picking a theme on a phone leaves the keyboard down
adibhanna Oct 29, 2026
2eded35
Feat(database): a saved view can keep its own columns and its own pic…
adibhanna Oct 29, 2026
2b1d9a7
Feat(database): a Columns picker chooses the columns each table view …
adibhanna Oct 29, 2026
b396549
Feat(database): the + button makes table views and copies of a view
adibhanna Oct 29, 2026
3f563e9
Feat(database): select rows and show only those in a view
adibhanna Oct 29, 2026
3499e9e
Feat(database): palette commands for columns, views and picked rows
adibhanna Oct 29, 2026
cb65e7e
Docs(database): the manual covers Columns, more views and picked rows
adibhanna Oct 29, 2026
46cd723
Fix(database): Show Only Selected Rows names x only with Vim mode on
adibhanna Oct 29, 2026
34dcfe6
Fix(editor): typing in a new note goes under its title, not in front …
adibhanna Oct 29, 2026
338a1c1
Fix(database): with Vim mode off, the grid's letters stop acting
adibhanna Oct 29, 2026
efbf7e3
Merge feat/db-view-columns: a table view keeps its own columns and pi…
adibhanna Oct 29, 2026
76e2221
Feat(gallery): which notes a folder Gallery holds, in what order, and…
adibhanna Oct 29, 2026
deb9355
Feat(gallery): a folder's notes as a grid of their covers, from the f…
adibhanna Oct 29, 2026
426f06d
Feat(gallery): Open Folder Gallery in the palette, Space G and :gallery
adibhanna Oct 29, 2026
77adcc9
Docs(gallery): the in-app manual covers the folder Gallery
adibhanna Oct 29, 2026
ee5d1dc
Merge feat/folder-gallery: a folder opens as a Gallery of its notes b…
adibhanna Oct 29, 2026
a7b5540
Docs(whats-new): 2.79.0 also brings the folder Gallery and database v…
adibhanna Oct 29, 2026
638a7bc
Feat(editor): a block becomes a callout, changes type, or stops being…
adibhanna Oct 29, 2026
84f21c8
Feat(editor): right-click a paragraph or a callout to set its callout…
adibhanna Oct 29, 2026
1e1d7d1
Feat(editor): Set Callout Type… and Remove Callout from the palette, …
adibhanna Oct 29, 2026
63f079c
Fix(menus): with Vim off, j and k are letters in a menu, not moves
adibhanna Oct 29, 2026
f0a1b63
Docs(callouts): the in-app manual covers the callout menu (#1016)
adibhanna Oct 29, 2026
14b2159
Merge feat/callout-menu: right-click turns a block into a callout, ch…
adibhanna Oct 29, 2026
b996b58
Test(search): the tagged-block palette test waits for its rows, not f…
adibhanna Oct 29, 2026
2b2b243
Feat(menus): a long menu gets a find field, with the best match first
adibhanna Oct 29, 2026
65aa445
Feat(editor): a code block's label picks its language, and guesses on…
adibhanna Oct 29, 2026
fc28cd6
Feat(settings): Detect code block languages, and the reading view gue…
adibhanna Oct 29, 2026
6b029b1
Feat(editor): Set Code Block Language… in the palette, and Space l l …
adibhanna Oct 29, 2026
2830023
Docs(code blocks): the in-app manual covers the code block language p…
adibhanna Oct 29, 2026
8f80a3b
Merge feat/code-language-picker: a code block's label picks its langu…
adibhanna Oct 29, 2026
47d2682
Docs(whats-new): 2.79.0 also makes callouts and picks code languages …
adibhanna Oct 29, 2026
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
7 changes: 7 additions & 0 deletions apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ describe('TOML serialization', () => {
kanbanCardSort: 'due',
tasksListOrder: 'priority',
timelineRange: 'month',
gallerySort: 'title',
galleryIncludeSubfolders: true,
kanbanFolderRoot: 'Projects',
externalApplicationSchemes: ['zotero', 'obsidian'],
ignoredKeys: ['KanaMode', 'F24']
Expand Down Expand Up @@ -176,6 +178,11 @@ describe('TOML serialization', () => {
// The Timeline panel's reach travels as [view].timeline_range (#1007).
expect(text).toMatch(/\[view\][^[]*timeline_range = "month"/)
expect(round.timelineRange).toBe('month')
// A folder Gallery's order and reach travel under [view] too.
expect(text).toMatch(/\[view\][^[]*gallery_sort = "title"/)
expect(text).toMatch(/\[view\][^[]*gallery_include_subfolders = true/)
expect(round.gallerySort).toBe('title')
expect(round.galleryIncludeSubfolders).toBe(true)
expect(round.kanbanFolderRoot).toBe('Projects')
expect(text).toContain('ignored_keys = ["KanaMode", "F24"]')
expect(round.externalApplicationSchemes).toEqual(['zotero', 'obsidian'])
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/main/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
tomlKey: 'render_tables',
comment: 'render tables as widgets in live preview; off keeps them as plain text'
},
detectCodeLanguages: {
section: 'editor',
tomlKey: 'detect_code_languages',
comment: 'guess the language of a code block that names none, and highlight it; never written to the note'
},
syncTitleHeadingOnRename: {
section: 'editor',
tomlKey: 'sync_title_heading_on_rename',
Expand Down Expand Up @@ -446,6 +451,16 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
tomlKey: 'timeline_range',
comment: 'two-weeks | month | all: how far ahead the Timeline panel reaches; overdue tasks always show'
},
gallerySort: {
section: 'view',
tomlKey: 'gallery_sort',
comment: 'updated | created | title: the order of a folder Gallery\'s cards (newest first for the dates)'
},
galleryIncludeSubfolders: {
section: 'view',
tomlKey: 'gallery_include_subfolders',
comment: 'a folder Gallery also shows the notes in its subfolders'
},
kanbanFolderRoot: {
section: 'view',
tomlKey: 'kanban_folder_root',
Expand Down
6 changes: 6 additions & 0 deletions packages/app-core/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
import { isAppOverlayOpen, isOverlayOrDialogOpen } from './lib/overlay-open'
import { installMarkdownFileDropHandler } from './lib/markdown-file-drop'
import {
setMarkdownDetectCodeLanguages,
setMarkdownLooseMathDelimiters,
setMarkdownMathRenderer
} from './lib/markdown-settings'
Expand Down Expand Up @@ -383,6 +384,7 @@ function App(): JSX.Element {
const completedTaskStyle = useStore((s) => s.completedTaskStyle)
const mathRenderer = useStore((s) => s.mathRenderer)
const looseMathDelimiters = useStore((s) => s.looseMathDelimiters)
const detectCodeLanguages = useStore((s) => s.detectCodeLanguages)
const lineNumberPosition = useStore((s) => s.lineNumberPosition)
const interfaceFont = useStore((s) => s.interfaceFont)
const textFont = useStore((s) => s.textFont)
Expand Down Expand Up @@ -700,6 +702,10 @@ function App(): JSX.Element {
setMarkdownLooseMathDelimiters(looseMathDelimiters)
}, [looseMathDelimiters])

useEffect(() => {
setMarkdownDetectCodeLanguages(detectCodeLanguages)
}, [detectCodeLanguages])

// The app now always runs fully opaque.
useEffect(() => {
document.documentElement.setAttribute('data-opaque', '')
Expand Down
11 changes: 8 additions & 3 deletions packages/app-core/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,9 @@ export function CommandPalette(): JSX.Element {
// command opened Settings or another palette (search, vault text search,
// outline, …) so we don't pull focus behind it, and likewise for the Cloud
// conflict queue, the Cloud settings prompt and the Publish Note dialog,
// which claim focus themselves and are tracked outside the store.
// which claim focus themselves and are tracked outside the store, and
// for a menu the command opened (Set Callout Type…): the menu holds the
// keyboard, and the letters typed to filter it would land in the note.
// On a phone the command decides where the keyboard goes
// (lib/command-landing.ts): a note that was already there is left to
// read, a command whose point is typing takes the editor, and anything
Expand All @@ -372,7 +374,8 @@ export function CommandPalette(): JSX.Element {
{ ...latest, focusedPanel: landedPanel },
useCloudSyncStatusStore.getState().conflictReviewOpen ||
useCloudSyncStatusStore.getState().settingsConflictPromptOpen ||
getPublishNoteRequest() !== null
getPublishNoteRequest() !== null ||
document.querySelector('[data-ctx-menu]') !== null
)
) {
if (landing === 'read') focusEditorAfterReadOpen()
Expand Down Expand Up @@ -409,7 +412,9 @@ export function CommandPalette(): JSX.Element {
setTheme({ id: theme.id, family: theme.family, mode: theme.mode })
committedRef.current = true
setOpen(false)
focusEditorNormalMode()
// A theme is a `stay` landing (lib/command-landing.ts): on a phone the
// keyboard stays down over the note being read.
focusEditorAfterPaletteClose()
}

const runPickedWorkflow = (entry: WorkflowIndexEntry): void => {
Expand Down
276 changes: 275 additions & 1 deletion packages/app-core/src/components/ContextMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useStore } from '../store'
import { ContextMenu } from './ContextMenu'

describe('ContextMenu focus', () => {
Expand Down Expand Up @@ -45,3 +46,276 @@ describe('ContextMenu focus', () => {
expect(document.activeElement).toBe(opener)
})
})

describe('ContextMenu picking one of a set (#1016)', () => {
let host: HTMLDivElement
let root: Root
let picked: string[]

const items = () =>
['Note', 'Tip', 'Danger (Error)', 'Warning'].map((label) => ({
label,
checked: label === 'Tip',
keywords: label === 'Warning' ? ['caution'] : [],
onSelect: () => {
picked.push(label)
}
}))

const menuEl = (): HTMLElement => document.querySelector('[data-ctx-menu]') as HTMLElement
const press = (key: string): void => {
menuEl().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
}
const active = (): string | undefined =>
[...menuEl().querySelectorAll('button')].find((b) => b.className.includes('bg-paper-200 '))
?.textContent ?? undefined

beforeEach(async () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
picked = []
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
await act(async () =>
root.render(
createElement(ContextMenu, { x: 10, y: 10, title: 'Callout type', items: items(), onClose: () => {} })
)
)
})

afterEach(() => {
act(() => root.unmount())
host.remove()
})

it('shows its title and names the menu by it', () => {
expect(menuEl().getAttribute('aria-label')).toBe('Callout type')
expect(menuEl().textContent).toContain('Callout type')
})

it('starts on the checked item and marks it for assistive tech', () => {
expect(active()).toBe('Tip')
const tip = [...menuEl().querySelectorAll('button')].find((b) => b.textContent === 'Tip')
expect(tip?.getAttribute('role')).toBe('menuitemradio')
expect(tip?.getAttribute('aria-checked')).toBe('true')
})

it('finds an item by its label or by a keyword', async () => {
await act(async () => {
for (const ch of 'error') press(ch)
})
expect([...menuEl().querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Danger (Error)'])
await act(async () => {
for (let i = 0; i < 5; i++) press('Backspace')
for (const ch of 'caution') press(ch)
})
expect([...menuEl().querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Warning'])
await act(async () => press('Enter'))
expect(picked).toEqual(['Warning'])
})
})

describe('ContextMenu letters with Vim mode on and off', () => {
let host: HTMLDivElement
let root: Root

const render = async (): Promise<void> => {
await act(async () =>
root.render(
createElement(ContextMenu, {
x: 10,
y: 10,
items: [{ label: 'Note' }, { label: 'Tip' }, { label: 'Quote' }].map((i) => ({
...i,
onSelect: () => {}
})),
onClose: () => {}
})
)
)
}
const menuEl = (): HTMLElement => document.querySelector('[data-ctx-menu]') as HTMLElement
const press = (key: string): void => {
menuEl().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
}
const labels = (): Array<string | null> =>
[...menuEl().querySelectorAll('button')].map((b) => b.textContent)
const active = (): string | null | undefined =>
[...menuEl().querySelectorAll('button')].find((b) => b.className.includes('bg-paper-200 '))
?.textContent

beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
})

afterEach(() => {
act(() => root.unmount())
host.remove()
useStore.setState({ vimMode: false })
})

it('moves with j and k in Vim mode', async () => {
useStore.setState({ vimMode: true })
await render()
await act(async () => press('j'))
expect(active()).toBe('Tip')
await act(async () => press('k'))
expect(active()).toBe('Note')
})

it('reads j and k as filter letters with Vim mode off; the arrows still move', async () => {
useStore.setState({ vimMode: false })
await render()
await act(async () => press('ArrowDown'))
expect(active()).toBe('Tip')
await act(async () => press('k'))
expect(labels()).toEqual([])
expect(menuEl().textContent).toContain('No matches')
})
})

describe('ContextMenu with a find field (#1014)', () => {
let host: HTMLDivElement
let root: Root
let picked: string[]

const names = ['Text (plain)', 'Brainfuck', 'C', 'C++', 'Java', 'JavaScript', 'JSON', 'Kotlin', 'Python']
const render = async (): Promise<void> => {
await act(async () =>
root.render(
createElement(ContextMenu, {
x: 10,
y: 10,
title: 'Code block language',
findPlaceholder: 'Find a language',
items: names.map((label) => ({
label,
checked: label === 'Text (plain)',
keywords: label === 'JavaScript' ? ['js'] : label === 'Python' ? ['py'] : [],
onSelect: () => {
picked.push(label)
}
})),
onClose: () => {}
})
)
)
}
const menuEl = (): HTMLElement => document.querySelector('[data-ctx-menu]') as HTMLElement
const field = (): HTMLInputElement => menuEl().querySelector('input') as HTMLInputElement
const keydown = (target: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent => {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
target.dispatchEvent(event)
return event
}
const typeInField = (text: string): void => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
for (const ch of text) {
if (keydown(field(), ch).defaultPrevented) continue
setValue.call(field(), field().value + ch)
field().dispatchEvent(new Event('input', { bubbles: true }))
}
}
const rows = (): Array<string | null> => [...menuEl().querySelectorAll('button')].map((b) => b.textContent)
const active = (): string | null | undefined =>
[...menuEl().querySelectorAll('button')].find((b) => b.hasAttribute('data-ctx-active'))?.textContent

beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
picked = []
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
})

afterEach(() => {
act(() => root.unmount())
host.remove()
useStore.setState({ vimMode: false })
vi.unstubAllGlobals()
})

it('gives the field the keyboard with Vim off, and every letter is text there', async () => {
await render()
expect(document.activeElement).toBe(field())
expect(active()).toBe('Text (plain)')
await act(async () => typeInField('j'))
expect(field().value).toBe('j')
expect(rows()).toEqual(['Java', 'JavaScript', 'JSON'])
})

it('puts the exact match first, so a letter and Enter pick it', async () => {
await render()
await act(async () => typeInField('c'))
expect(rows()[0]).toBe('C')
expect(active()).toBe('C')
await act(async () => keydown(field(), 'Enter'))
expect(picked).toEqual(['C'])
})

it('finds by keyword and moves with the arrows and Ctrl+N / Ctrl+P from the field', async () => {
await render()
await act(async () => typeInField('py'))
expect(rows()).toEqual(['Python'])
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
setValue.call(field(), 'ja')
field().dispatchEvent(new Event('input', { bubbles: true }))
})
expect(active()).toBe('Java')
await act(async () => keydown(field(), 'ArrowDown'))
expect(active()).toBe('JavaScript')
await act(async () => keydown(field(), 'p', { ctrlKey: true }))
expect(active()).toBe('Java')
await act(async () => keydown(field(), 'n', { ctrlKey: true }))
expect(active()).toBe('JavaScript')
})

it('clears the field on the first Escape and closes on the second', async () => {
let closed = 0
await act(async () =>
root.render(
createElement(ContextMenu, {
x: 10,
y: 10,
findPlaceholder: 'Find a language',
items: names.map((label) => ({ label, onSelect: () => {} })),
onClose: () => {
closed++
}
})
)
)
await act(async () => typeInField('ko'))
await act(async () => keydown(field(), 'Escape'))
expect(field().value).toBe('')
expect(closed).toBe(0)
await act(async () => keydown(field(), 'Escape'))
expect(closed).toBe(1)
})

it('keeps the list in charge with Vim on: j and k move, / goes to the field', async () => {
useStore.setState({ vimMode: true })
await render()
expect(document.activeElement).toBe(menuEl())
await act(async () => keydown(menuEl(), 'j'))
expect(active()).toBe('Brainfuck')
await act(async () => keydown(menuEl(), 'k'))
expect(active()).toBe('Text (plain)')
await act(async () => keydown(menuEl(), '/'))
expect(document.activeElement).toBe(field())
await act(async () => typeInField('j'))
expect(field().value).toBe('j')
expect(rows()).toEqual(['Java', 'JavaScript', 'JSON'])
})

it('opens on the list on a touch screen, so no keyboard covers it', async () => {
vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(pointer: coarse)' }))
await render()
expect(document.activeElement).toBe(menuEl())
expect(field()).not.toBeNull()
})
})
Loading
Loading