Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
fab6f18
Feat(share): Share > ZenNotes saves a page or a selection from Safari…
adibhanna Oct 28, 2026
15cee9f
Feat(covers): Unsplash photos in the cover picker, credited to their …
adibhanna Oct 28, 2026
a26d1cb
Merge feat/share-extension: Share > ZenNotes saves a page or a select…
adibhanna Oct 28, 2026
d10fdd7
Fix(editor): a note opened to read shows its first line rendered, not…
adibhanna Oct 28, 2026
c329a9a
Fix(search): a Search pick on a phone opens the note to read, keyboar…
adibhanna Oct 28, 2026
ea958af
Fix(palette): a phone backs out of a palette, and opens a note from a…
adibhanna Oct 28, 2026
cfd3b10
Feat(settings): a Status bar switch hides the bar along the bottom, e…
adibhanna Oct 28, 2026
00cf162
Fix(palette): a toggle from the command palette leaves a phone's keyb…
adibhanna Oct 28, 2026
5d0d78e
Fix(quick notes): a cleared prefix gives the bare timestamp Settings …
adibhanna Oct 28, 2026
63594b1
Fix(updates): a check while a release is still uploading says so in o…
adibhanna Oct 28, 2026
0648c77
Merge fix/issue-1003: a check while a release is still uploading says…
adibhanna Oct 28, 2026
2db21e7
Fix(tasks): the Tasks filter finds a task note by a tag in its body (…
adibhanna Oct 28, 2026
57f9e8b
Fix(editor): select all takes the note's text and leaves its title al…
adibhanna Oct 28, 2026
c6e0ad2
Merge fix/issue-1008: a cleared Quick Note prefix gives a bare timest…
adibhanna Oct 28, 2026
27c30cd
Fix(share): the Share sheet draws its content instead of an empty win…
adibhanna Oct 28, 2026
605b023
Feat(share): each vault chooses where Share > ZenNotes files a share
adibhanna Oct 28, 2026
99bce69
Merge fix/issue-1006: the first select-all leaves the note's title li…
adibhanna Oct 28, 2026
ad03ed6
Merge feat/share-extension: the Share sheet draws in Safari, and each…
adibhanna Oct 28, 2026
7edddae
Fix(release): a release becomes latest only once every update feed is…
adibhanna Oct 28, 2026
9e35242
Feat(share): a vault on a self-hosted server chooses where its shares…
adibhanna Oct 28, 2026
ecb3536
Feat(tasks): a Timeline panel lays the vault's dated tasks down one l…
adibhanna Oct 28, 2026
e6b934b
Merge feat/timeline-1007: a Timeline panel lays the vault's open task…
adibhanna Oct 28, 2026
28f7ed9
Feat(keymap): every palette command can take a key (#971)
adibhanna Oct 28, 2026
de773ed
Feat(config): command keys travel in config.toml under [keymaps.comma…
adibhanna Oct 28, 2026
8e1d97e
Docs(help): the manual says every command can take a key, and how (#971)
adibhanna Oct 28, 2026
9ee6500
Merge feat/command-keymaps-971: every palette command can take a key,…
adibhanna Oct 28, 2026
865c14d
Fix(keymap): a Mod chord stays Mod, and list views hear the chords th…
adibhanna Oct 28, 2026
4cb73ec
Fix(sidebar): x and Mod+Backspace move the sidebar's note to the Tras…
adibhanna Oct 28, 2026
49a8297
Docs(help): the manual says how to trash from the sidebar and that li…
adibhanna Oct 28, 2026
305eae4
Merge fix/sidebar-trash-972: x and Mod+Backspace trash from the sideb…
adibhanna Oct 28, 2026
9b070af
Fix(sidebar): with Vim off the sidebar walks like a tree, and Focus S…
adibhanna Oct 28, 2026
39ac61b
Fix(sidebar): the cursor shows on a selected row
adibhanna Oct 28, 2026
3feecd0
Merge fix/sidebar-keys-vim-off: with Vim off the sidebar walks like a…
adibhanna Oct 28, 2026
701988d
Fix(panels): with Vim off the note list, connections, outline and com…
adibhanna Oct 28, 2026
77a6e70
Merge fix/panel-keys-vim-off: with Vim off the note list, connections…
adibhanna Oct 28, 2026
4b7deaf
Fix(palette): with Vim mode off, no row names a Vim key
adibhanna Oct 28, 2026
aeaa41d
Fix(comments): deleting a comment from the keyboard leaves the cursor…
adibhanna Oct 28, 2026
1063a4f
Feat(covers): Unsplash search with a key of your own, straight from y…
adibhanna Oct 28, 2026
9682944
Merge feat/unsplash-own-key: Unsplash covers use the key each person …
adibhanna Oct 28, 2026
0df65fc
Feat(editor): a cover's frontmatter folds to one line in Edit mode
adibhanna Oct 28, 2026
3f4cdf8
Merge feat/fold-cover-frontmatter: a cover's frontmatter folds to one…
adibhanna Oct 28, 2026
3055e07
Docs(whats-new): the 2.79.0 highlights
adibhanna Oct 28, 2026
77c2afb
Release: align desktop and shared packages at 2.79.0
adibhanna Oct 28, 2026
a6c36c8
Fix(share): the share inbox checks a capture's size and reads it thro…
adibhanna Oct 28, 2026
b6225eb
Test(ci): the release PR's three test findings, fixed in the tests
adibhanna Oct 28, 2026
584487f
Chore(palette): drop an unused import CodeQL noted in commands.ts
adibhanna Oct 28, 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
41 changes: 41 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,19 @@ jobs:
- name: Create release if missing
env:
GH_TOKEN: ${{ github.token }}
# Public from the start, so per-tag download URLs work for the AUR,
# Nix and Homebrew steps, but not "latest": the in-app updater and the
# website's download buttons follow releases/latest, and until the
# last feed is up an update check answers 404 (#1003). promote-latest
# moves "latest" once every feed is on the release.
run: |
if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
echo "Release ${RELEASE_TAG} already exists."
else
gh release create "${RELEASE_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--title "ZenNotes ${RELEASE_TAG}" \
--latest=false \
--notes "Download the installer for your platform from the assets below."
fi

Expand Down Expand Up @@ -285,3 +291,38 @@ jobs:
for file in "${installers[@]}" "${manifests[@]}"; do
upload "${file}"
done

promote-latest:
name: Mark the release latest
needs: [create-release, build-and-upload]
# Also after a failed platform job: the release becomes latest once every
# feed is on it, whichever run (this one, or a dispatch that rebuilt the
# missing platform) uploaded the last one.
if: ${{ !cancelled() && needs.create-release.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Mark latest once every update feed is up
env:
GH_TOKEN: ${{ github.token }}
run: |
# One feed per platform job; keep in step with the matrix above.
feeds=(latest.yml latest-arm64.yml latest-mac.yml latest-linux.yml latest-linux-arm64.yml)
assets=$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name')
missing=()
for feed in "${feeds[@]}"; do
grep -qxF "${feed}" <<<"${assets}" || missing+=("${feed}")
done
if [ "${#missing[@]}" -gt 0 ]; then
echo "::warning::${RELEASE_TAG} stays off latest until these feeds are up: ${missing[*]}"
exit 0
fi
# A dispatch that rebuilds an older release must not take "latest"
# from a newer one.
current=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)
newest=$(printf '%s\n%s\n' "${current#v}" "${RELEASE_TAG#v}" | sort -V | tail -n 1)
if [ "${newest}" != "${RELEASE_TAG#v}" ]; then
echo "${current} is newer than ${RELEASE_TAG}; latest stays on ${current}."
exit 0
fi
gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --latest
echo "${RELEASE_TAG} is latest."
25 changes: 19 additions & 6 deletions apps/desktop/build/after-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ async function rewriteAsFreshFiles(dir) {

/**
* The identity the app will be signed with, decided the way electron-builder
* decides it for the app itself (MacPackager.sign), so the Quick Look
* extension carries the same one; null when the app is not signed, and the
* extension is then signed ad hoc.
* decides it for the app itself (MacPackager.sign), so the extensions in
* Contents/PlugIns carry the same one; null when the app is not signed, and
* the extensions are then signed ad hoc.
*/
async function quickLookSigning(context) {
async function appSigning(context) {
const { findIdentity, isSignAllowed } = require('app-builder-lib/out/codeSign/macCodeSign')
const config = context.packager.platformSpecificBuildOptions
if (!isSignAllowed(false) || config.identity === null) return null
Expand Down Expand Up @@ -75,15 +75,28 @@ exports.default = async function afterPack(context) {
// Contents/PlugIns, which electron-builder never signs, so it is signed
// here, before the app's own signature seals it in.
const arch = { 1: 'x64', 3: 'arm64' }[context.arch]
const appPath = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
const signing = await appSigning(context)
const { buildQuickLookExtension } = await import(pathToFileURL(path.resolve(__dirname, '../../quicklook/scripts/build-extension.mjs')).href)
await buildQuickLookExtension({
appPath: path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`),
appPath,
arch,
appId: context.packager.appInfo.id,
version: context.packager.appInfo.version,
signing: await quickLookSigning(context),
signing,
selfTest: process.env.ZENNOTES_QUICKLOOK_SELFTEST === '1' && !process.env.CI
})
// Share > ZenNotes in Safari and other apps (apps/share-extension).
const { buildShareExtension } = await import(pathToFileURL(path.resolve(__dirname, '../../share-extension/scripts/build-share-extension.mjs')).href)
await buildShareExtension({
appPath,
arch,
appId: context.packager.appInfo.id,
version: context.packager.appInfo.version,
iconPath: path.resolve(context.packager.projectDir, context.packager.platformSpecificBuildOptions.icon || 'build/icon.icns'),
signing,
selfTest: process.env.ZENNOTES_SHARE_SELFTEST === '1' && !process.env.CI
})
}
if (platform !== 'linux') return
await rewriteAsFreshFiles(path.join(context.appOutDir, 'resources', 'arch-extras'))
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.78.0",
"version": "2.79.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
108 changes: 107 additions & 1 deletion apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('TOML serialization', () => {
themeFamily: 'nord',
themeMode: 'dark',
showWindowTitleBar: false,
showStatusBar: false,
autoPairs: false,
autoPairQuotesInProse: true,
showHeadingLevelLabels: true,
Expand All @@ -133,6 +134,7 @@ describe('TOML serialization', () => {
kanbanGroupBy: 'folder',
kanbanCardSort: 'due',
tasksListOrder: 'priority',
timelineRange: 'month',
kanbanFolderRoot: 'Projects',
externalApplicationSchemes: ['zotero', 'obsidian'],
ignoredKeys: ['KanaMode', 'F24']
Expand All @@ -151,6 +153,9 @@ describe('TOML serialization', () => {
expect(round.themeFamily).toBe('nord')
expect(text).toContain('show_window_title_bar = false')
expect(round.showWindowTitleBar).toBe(false)
// Piotr's status bar switch travels as [appearance].show_status_bar.
expect(text).toMatch(/\[appearance\][^[]*show_status_bar = false/)
expect(round.showStatusBar).toBe(false)
expect(round.autoPairs).toBe(false)
expect(round.autoPairQuotesInProse).toBe(true)
expect(round.showHeadingLevelLabels).toBe(true)
Expand All @@ -168,6 +173,9 @@ describe('TOML serialization', () => {
expect(round.kanbanCardSort).toBe('due')
expect(text).toContain('tasks_list_order = "priority"')
expect(round.tasksListOrder).toBe('priority')
// The Timeline panel's reach travels as [view].timeline_range (#1007).
expect(text).toMatch(/\[view\][^[]*timeline_range = "month"/)
expect(round.timelineRange).toBe('month')
expect(round.kanbanFolderRoot).toBe('Projects')
expect(text).toContain('ignored_keys = ["KanaMode", "F24"]')
expect(round.externalApplicationSchemes).toEqual(['zotero', 'obsidian'])
Expand All @@ -181,11 +189,19 @@ describe('TOML serialization', () => {
})

it('persists null as empty string and reads it back as null', () => {
const text = serializeConfig({ ripgrepBinaryPath: null, fzfBinaryPath: null, monoFont: null })
const text = serializeConfig({
ripgrepBinaryPath: null,
fzfBinaryPath: null,
monoFont: null,
quickNoteTitlePrefix: null
})
expect(text).toContain('quick_note_title_prefix = ""')
const { portable } = deserializeConfig(text)
expect(portable.ripgrepBinaryPath).toBeNull()
expect(portable.fzfBinaryPath).toBeNull()
expect(portable.monoFont).toBeNull()
// A cleared Quick Note prefix stays cleared across machines (#1008).
expect(portable.quickNoteTitlePrefix).toBeNull()
})

it('always lists every option with allowed-value comments, even from empty input', () => {
Expand Down Expand Up @@ -440,3 +456,93 @@ describe('keymap reference across platforms (#901)', () => {
expect(deserializeConfig(text).portable.keymapOverrides ?? {}).toEqual({})
})
})

describe('command keys in [keymaps.commands] (#971)', () => {
it('round-trips command bindings, unbinds included, beside the action overrides', () => {
const portable: AppConfigPortable = {
keymapOverrides: { 'global.zoomIn': 'Mod+K' },
commandKeymaps: { 'note.trash': 'Mod+Backspace', 'note.archive': 'Leader x a', 'note.restore': '' }
}
const text = serializeConfig(portable)
expect(text).toContain('[keymaps.commands]\n"note.trash" = "Mod+Backspace"\n"note.archive" = "Leader x a"\n"note.restore" = "" # unbound\n')
const { portable: back } = deserializeConfig(text)
expect(back.keymapOverrides).toEqual({ 'global.zoomIn': 'Mod+K' })
expect(back.commandKeymaps).toEqual(portable.commandKeymaps)
// Canonical: writing what was read gives the same bytes.
expect(serializeConfig(back)).toBe(text)
})

it('lists every bindable command with its title, and only commented', () => {
const text = serializeConfig({})
expect(text).toContain('# "note.trash" = "" # Note: Move Note to Trash\n')
expect(text).toContain('# "note.favorite" = "" # Note: Toggle Note Favorite\n')
// The id that is also an action is listed in both, each in its own table.
expect(text).toContain('# "nav.back" = "" # Tabs: Go Back\n')
expect(text).toContain('# "nav.back" = "h" # Back out\n')
// Runtime-made commands are not in the list.
expect(text).not.toContain('"workflow.run.')
const { portable } = deserializeConfig(text)
expect(portable.commandKeymaps).toEqual({})
expect(portable.keymapOverrides).toEqual({})
})

it('reads a command id written straight under [keymaps], and the next write moves it', () => {
const handWritten = [
'config_version = 1',
'',
'[keymaps]',
'"global.zoomIn" = "Mod+K"',
'"note.trash" = "Mod+Backspace"',
'"nav.back" = "Ctrl+H"',
''
].join('\n')
const { portable } = deserializeConfig(handWritten)
// nav.back is an action id first: it stays the back-out action.
expect(portable.keymapOverrides).toEqual({ 'global.zoomIn': 'Mod+K', 'nav.back': 'Ctrl+H' })
expect(portable.commandKeymaps).toEqual({ 'note.trash': 'Mod+Backspace' })
const rewritten = serializeConfig(portable)
expect(rewritten).toContain('[keymaps.commands]\n"note.trash" = "Mod+Backspace"\n')
expect(rewritten.split('[keymaps.commands]')[0]).not.toMatch(/^"note\.trash"/m)
})

it('lets the canonical table win over a stray line for the same command', () => {
const text = [
'[keymaps]',
'"note.trash" = "F6"',
'',
'[keymaps.commands]',
'"note.trash" = "Mod+Backspace"',
''
].join('\n')
expect(deserializeConfig(text).portable.commandKeymaps).toEqual({ 'note.trash': 'Mod+Backspace' })
})

it('renders the same bytes on every OS', () => {
const realPlatform = process.platform
const portable: AppConfigPortable = { commandKeymaps: { 'note.trash': 'Mod+Backspace' } }
const renders = (['darwin', 'linux', 'win32'] as const).map((platform) => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
try {
return serializeConfig(portable)
} finally {
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true })
}
})
expect(new Set(renders).size).toBe(1)
})

it('moves a bare command id into its table when the app opens the file', async () => {
const dir = await tmp('zen-cfg-')
process.env.ZENNOTES_CONFIG_DIR = dir
await writeFile(
getConfigFilePath(),
'config_version = 1\n\n[keymaps]\n"note.trash" = "Mod+Backspace"\n',
'utf8'
)
await initAppConfig(() => {})
expect(getPortableConfigSnapshot().commandKeymaps).toEqual({ 'note.trash': 'Mod+Backspace' })
const onDisk = await readFile(getConfigFilePath(), 'utf8')
expect(onDisk).toContain('[keymaps.commands]\n"note.trash" = "Mod+Backspace"\n')
expect(onDisk.split('[keymaps.commands]')[0]).not.toMatch(/^"note\.trash"/m)
})
})
Loading
Loading