Skip to content
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.26.0",
"version": "2.27.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/server",
"private": true,
"version": "2.26.0",
"version": "2.27.0",
"scripts": {
"dev": "node ../../tooling/scripts/run-go-server-dev.mjs",
"prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.26.0",
"version": "2.27.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-monorepo",
"private": true,
"version": "2.26.0",
"version": "2.27.0",
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
"packageManager": "npm@10.9.2",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/app-core",
"private": true,
"version": "2.26.0",
"version": "2.27.0",
"type": "module",
"exports": {
"./main": "./src/main.tsx"
Expand Down
39 changes: 38 additions & 1 deletion packages/app-core/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ import { ServerDirectoryPickerHost } from './components/ServerDirectoryPickerHos
import { ToastHost } from './components/ui'
import { ExcalidrawEmbedMenuHost } from './components/ExcalidrawEmbedMenuHost'
import { resolveQuickNoteTitle } from './lib/quick-note-title'
import { isMacPlatform, matchesShortcut, matchesSequenceToken } from './lib/keymaps'
import {
eventMatchesUserOverride,
isMacPlatform,
matchesShortcut,
matchesSequenceToken,
TAB_SELECT_KEYMAP_IDS
} from './lib/keymaps'
import { selectActiveBuffer } from './lib/buffer-navigation'
import { focusPaneOrEdgePanel } from './lib/pane-nav'
import {
activatePanelRow,
Expand Down Expand Up @@ -696,6 +703,36 @@ function App(): JSX.Element {
state.setWordWrap(!state.wordWrap)
return
}
// Alt+1..9 (⌃1..9 on macOS): jump straight to tab N (#497). Position
// counts across panes in the same order gt cycles through. Bails while
// a modal, palette, menu, or Settings (with its keymap recorder) is
// open, per the house rule for global key handlers: switching the tab
// under an overlay strands the user on a different note than they left.
const tabSelectBlocked =
state.settingsOpen ||
state.searchOpen ||
state.vaultTextSearchOpen ||
state.commandPaletteOpen ||
state.bufferPaletteOpen ||
state.templatePaletteOpen ||
state.embedDrawingPaletteOpen ||
state.outlinePaletteOpen ||
document.querySelector('[data-ctx-menu]') ||
document.querySelector('[data-prompt-modal]') ||
document.querySelector('[data-confirm-modal]')
if (!tabSelectBlocked) {
for (let i = 0; i < TAB_SELECT_KEYMAP_IDS.length; i += 1) {
const id = TAB_SELECT_KEYMAP_IDS[i]
if (!matchesShortcut(e, overrides, id)) continue
// These defaults were inserted mid-handler: when the combination is
// one the user explicitly rebound to another action (checked later
// in this chain), the rebind wins over the shipped default.
if (!overrides[id] && eventMatchesUserOverride(e, overrides, id)) continue
e.preventDefault()
selectActiveBuffer(state, i + 1)
return
}
}
if (matchesShortcut(e, overrides, 'global.exportNotePdf')) {
e.preventDefault()
void state.exportActiveNotePdf()
Expand Down
41 changes: 31 additions & 10 deletions packages/app-core/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
type KeymapId,
type KeymapOverrides
} from '../lib/keymaps'
import { navigateActiveBuffer } from '../lib/buffer-navigation'
import { navigateActiveBuffer, selectActiveBuffer } from '../lib/buffer-navigation'
import { applyVimInsertEscape } from '../lib/vim-insert-escape'
import { listContinuationPrefix } from '../lib/list-continuation'
import { focusEditorNormalMode } from '../lib/editor-focus'
Expand Down Expand Up @@ -633,12 +633,26 @@ function registerVimCommands(): void {
Vim.defineAction('focusPaneRight', () => {
focusPaneOrEdgePanel('l')
})
Vim.defineAction('previousBuffer', () => {
navigateActiveBuffer(useStore.getState(), -1)
})
Vim.defineAction('nextBuffer', () => {
navigateActiveBuffer(useStore.getState(), 1)
})
// {count}gt goes straight to tab {count}, vim's absolute jump; without a
// count it keeps cycling. {count}gT is relative, vim-style: count tabs
// back. (#497)
Vim.defineAction(
'previousBuffer',
(_cm: unknown, actionArgs?: { repeat?: number; repeatIsExplicit?: boolean }) => {
const repeat = actionArgs?.repeatIsExplicit ? actionArgs.repeat ?? 1 : 1
navigateActiveBuffer(useStore.getState(), -repeat)
}
)
Vim.defineAction(
'nextBuffer',
(_cm: unknown, actionArgs?: { repeat?: number; repeatIsExplicit?: boolean }) => {
if (actionArgs?.repeatIsExplicit && actionArgs.repeat) {
selectActiveBuffer(useStore.getState(), actionArgs.repeat)
return
}
navigateActiveBuffer(useStore.getState(), 1)
}
)

registerVimNoteCommands()
registerCommandPaletteEx()
Expand Down Expand Up @@ -850,10 +864,15 @@ function registerVimNoteCommands(): void {
}
Vim.defineEx('qall', 'qa', closeEveryTab)
Vim.defineEx('quitall', 'quitall', closeEveryTab)
// :xa / :wa are just aliases for qall in this context (nothing to flush
// that autosave doesn't already handle).
// Quit-and-write variants close everything, like vim's :xa / :wqa. But
// :wa is a SAVE, not a quit: it used to alias qall here on the theory
// that autosave leaves nothing to flush, and every vim user's muscle
// memory (":wa after any change") nuked their tab layout instead (#569).
Vim.defineEx('xall', 'xa', closeEveryTab)
Vim.defineEx('wall', 'wa', closeEveryTab)
Vim.defineEx('wqall', 'wqa', closeEveryTab)
Vim.defineEx('wall', 'wa', () => {
void useStore.getState().flushDirtyNotes()
})

Vim.defineEx('help', 'h', () => {
void useStore.getState().openHelpView()
Expand Down Expand Up @@ -964,6 +983,8 @@ const MANUAL_EX_NAMES = new Set([
'quitall',
'xall',
'xa',
'wqall',
'wqa',
'wall',
'wa',
'help',
Expand Down
5 changes: 4 additions & 1 deletion packages/app-core/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { hopMarkerBackward, hopMarkerForward } from '../lib/cm-marker-hop'
import { toggleCheckbox } from '../lib/cm-toggle-checkbox'
import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav'
import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap'
import { isVimAwaitingArgument } from '../lib/vim-nav'
import { toCodeMirrorKey, vimHalfPageKeymap } from '../lib/vim-half-page-keymap'
import { scrollOff } from '../lib/cm-scrolloff'
import { followLinkTarget } from '../lib/follow-link'
Expand Down Expand Up @@ -1865,7 +1866,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const cm = getCM(view)
const insertMode = !!cm?.state.vim?.insertMode
const sel = view.state.selection.main
if (!insertMode && !sel.empty) {
// A pending sequence owns the next key: `v f m` jumps to the
// next `m`, it does not open the menu (#568).
if (!insertMode && !sel.empty && !isVimAwaitingArgument(view)) {
event.preventDefault()
event.stopPropagation()
openEditorContextMenu()
Expand Down
14 changes: 12 additions & 2 deletions packages/app-core/src/components/VimNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1059,11 +1059,18 @@ export function VimNav(): JSX.Element | null {
}
}

// A pending Vim sequence owns the next character: after `f`/`t`/`r` (or
// a count or register prefix), `m` is the operand, not the menu key.
// This runs on window capture, so without the guard Vim never even saw
// the key and the orphaned motion swallowed the next one (#568). The
// native context-menu key is not a character and stays available.
const wantsEditorTextContextMenu =
isEditorFocused(state.editorViewRef) &&
!editorInsertMode &&
!state.editorViewRef?.state.selection.main.empty &&
(matchesSequenceToken(e, overrides, 'nav.contextMenu') || wantsNativeContextMenuKey(e))
((matchesSequenceToken(e, overrides, 'nav.contextMenu') &&
!isVimAwaitingArgument(state.editorViewRef)) ||
wantsNativeContextMenuKey(e))
if (wantsEditorTextContextMenu) {
e.preventDefault()
e.stopImmediatePropagation()
Expand Down Expand Up @@ -1137,7 +1144,10 @@ export function VimNav(): JSX.Element | null {
const wantsTextContextMenu =
hasEditorSelection &&
!isEditorInsertMode(state.editorViewRef, state.vimMode) &&
(matchesSequenceToken(e, overrides, 'nav.contextMenu') || wantsNativeContextMenuKey(e))
// Same #568 guard as above: a pending f/t/r owns the character.
((matchesSequenceToken(e, overrides, 'nav.contextMenu') &&
!isVimAwaitingArgument(state.editorViewRef)) ||
wantsNativeContextMenuKey(e))
if (wantsTextContextMenu) {
e.preventDefault()
e.stopImmediatePropagation()
Expand Down
50 changes: 49 additions & 1 deletion packages/app-core/src/lib/buffer-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { NoteMeta } from '@shared/ipc'
import type { PaneLayout } from './pane-layout'
import { getBufferNavigationTarget } from './buffer-navigation'
import { getBufferNavigationTarget, getBufferSelectTarget } from './buffer-navigation'

function note(path: string, updatedAt: number, folder: NoteMeta['folder'] = 'inbox'): Pick<NoteMeta, 'path' | 'folder' | 'updatedAt'> {
return { path, folder, updatedAt }
Expand Down Expand Up @@ -64,4 +64,52 @@ describe('getBufferNavigationTarget', () => {
kind: 'create-quick'
})
})

it('walks back multiple tabs for {count}gT, wrapping past the start', () => {
const layout = leaf('pane-a', ['one.md', 'two.md', 'three.md'], 'two.md')

// 4 back from index 1 in a 3-tab ring lands on index 0.
expect(getBufferNavigationTarget(layout, 'pane-a', [], -4)).toEqual({
kind: 'focus',
paneId: 'pane-a',
path: 'one.md'
})
})
})

describe('getBufferSelectTarget (#497)', () => {
it('selects the Nth tab, counted across panes in cycle order', () => {
const layout: PaneLayout = {
kind: 'split',
id: 'root',
direction: 'row',
sizes: [0.5, 0.5],
children: [
leaf('pane-a', ['one.md', 'two.md'], 'one.md'),
leaf('pane-b', ['three.md'], 'three.md')
]
}

expect(getBufferSelectTarget(layout, 'pane-a', 3)).toEqual({
kind: 'focus',
paneId: 'pane-b',
path: 'three.md'
})
})

it('clamps a too-large index to the last tab, like a big {count}gt in vim', () => {
const layout = leaf('pane-a', ['one.md', 'two.md'], 'one.md')

expect(getBufferSelectTarget(layout, 'pane-a', 9)).toEqual({
kind: 'focus',
paneId: 'pane-a',
path: 'two.md'
})
})

it('targets open tabs only, never the recent-notes fallback', () => {
const layout = leaf('pane-a', [], null)

expect(getBufferSelectTarget(layout, 'pane-a', 1)).toEqual({ kind: 'none' })
})
})
Loading
Loading