A lightweight Neovim plugin for managing notes in a dedicated tab — modelled on the macOS Notes app — with optional GitHub synchronization via Git (SSH).
demo.mp4
──────────────┬──────────────────────────────────
Folders │ Notes ← statuslines
Notes/ │ 26.06.2026 - Shopping list
├─ Work/ │ 25.06.2026 - Project idea
└─ Personal/ │ 24.06.2026 - Report
──────────────┴──────────────────────────────────
Notes/Shopping list ← statusline (folder/title)
# Shopping list
- [ ] Milk
- [ ] Call the bank
- Two-pane, macOS-Notes-style UI — opens in a new full-screen tab: folders (left column) and notes (right column) on top, the editor on the bottom. Closing notes closes the tab.
- Title from content — a note has no manual filename. Its title is the first non-blank line of its text; an empty note is titled "New Note" and is always pinned to the top of the list. On disk each note is an opaque ID file (
.mdextension, e.g.20260627143000.md), so editing a title never churns git history or collides. The notes column showsdd.mm.yyyy - Title, sorted by modification time (newest first). The title in the list updates live as you type, without saving. - Folders, one level deep — the folders column lists "Notes" (the root: notes that have no folder) plus your folders, sorted so the folder with the most recently edited note comes first. Selecting a folder filters the notes column to it. Empty folders are supported via a hidden
.gitkeepso they commit and sync. - Move by cursor — press
xon a note to mark it (highlighted with the selection color); pressxagain on the same note to cancel. Then navigate to a folder in the folders column and presspto drop it there. The destination folder becomes the selected one and rises to the top of the folders column, and its notes fill the notes column. - Native editing — the editor window behaves like a normal
markdownfile window (number,cursorline,signcolumn), so globalInsertEnter/InsertLeavestyling and statusline plugins work inside it. - Instant UI updates — the note list updates immediately on
:w(sort order, title); git sync runs in the background. - Full management — same keys in each column:
acreates (a note in the notes column, a folder in the folders column),ddeletes; notes also support move (x+p), folders also support rename (r); refresh (R). Every create/delete/move/rename immediately commits and pushes to GitHub. - Configurable keymaps — every action, the close key, and panel-focus keys are remappable via
config.keys. - Git sync — on first open:
git clone(if the directory doesn't exist) thengit pull(merge). On:w: commit + merge + push. On any create/delete/move/rename: immediate commit + merge + push. On close (q): commit + merge + push of any remaining changes. - Conflicts stay in the file — a merge conflict is left as standard git markers in the note (no dialog). The conflicted note title and its folder name get a wavy error underline; edit the markers out, save, and the merge completes and pushes. Move/rename/delete of a conflicted note is blocked until you resolve it.
- Unsaved changes prompt — pressing
qwhen the editor has unsaved changes shows a Save / Discard / Cancel dialog instead of silently writing or discarding. Choosing Discard reloads the saved version from disk. - Crash-safe — on every open, tracked files deleted outside the plugin (e.g. an accidental
rm) are restored from the last commit before anything is pushed, so an empty working tree never propagates to the remote. - Sync status icon — the Neovim tab label shows a sync status indicator next to
notes.nvim: an animated braille spinner (⠋⠙⠹…) while syncing,✓when idle,!when there is a merge conflict. Nerd Font glyphs are used automatically for idle/conflict (nf-fa-check/nf-cod-warning) ifnvim-web-deviconsis installed (required on demand, so load order doesn't matter); otherwise plain ASCII. No icon whenrepo = ''. Icons are fully configurable viaconfig.sync_icons. - Toggle panels — press
f(configurable) from any window to hide the Folders and Notes columns, giving the editor the full screen; press again to restore them. Useful when writing longer notes. - No external dependencies — pure Lua, no third-party plugins required.
- Works from any directory — open your notes regardless of the current working directory.
- Neovim ≥ 0.10
- Git (for sync; optional if
repois not set) - SSH key configured for GitHub (if using a private repo)
vim.pack.add({ src = 'https://github.com/lgick/notes.nvim' })
require('notes').setup({
dir = vim.fn.expand('~/.notes'),
repo = 'git@github.com:youruser/notes.git',
}){
'lgick/notes.nvim',
opts = {
dir = vim.fn.expand('~/.notes'),
repo = 'git@github.com:youruser/notes.git',
},
}use {
'lgick/notes.nvim',
config = function()
require('notes').setup({
dir = vim.fn.expand('~/.notes'),
repo = 'git@github.com:youruser/notes.git',
})
end,
}require('notes').setup({
-- Local directory where notes are stored (also the git worktree root).
dir = vim.fn.expand('~/.notes'),
-- SSH remote for GitHub sync.
-- Leave empty ('') to use notes locally without any git sync.
repo = 'git@github.com:youruser/notes.git',
-- Height of the folders/notes row in rows (content rows, excluding statusline).
list_height = 10,
-- Width of the folders column.
folders_width = 25,
-- Keymaps (override individually; unset keys keep their defaults).
keys = {
open_file = '<CR>', -- folders: focus the notes column; notes: focus the editor
create = 'a', -- folders: create a folder; notes: create a note
delete = 'd', -- folders: delete the folder; notes: delete the note (confirmation)
rename = 'r', -- folders: rename the selected folder
move = 'x', -- notes: mark note for moving
paste = 'p', -- folders: drop the marked note into the selected folder
refresh = 'R', -- refresh the list
open_github = 'O', -- open the notes repository in the browser
scroll_down = '<C-n>', -- notes: scroll the open note down
scroll_up = '<C-p>', -- notes: scroll the open note up
close = 'q', -- close notes (works from any notes window)
window_nav = '<C-w>', -- prefix; then h/j/k/l → move between windows
toggle_panels = 'f', -- hide/show Folders + Notes columns
},
-- Sync status icons shown in the tab label next to 'notes.nvim'.
-- nil = auto: Nerd Font glyphs if nvim-web-devicons is loaded, otherwise Unicode.
-- Set to a table to override individual icons.
sync_icons = nil,
-- sync_icons = { idle = '✓', syncing = '⠋', conflict = '!' },
})vim.keymap.set('n', '<leader>m', '<cmd>Notes<CR>', { desc = 'Notes' })| Command | Description |
|---|---|
:Notes |
Open the notes tab |
All keys are configurable via config.keys (see above).
| Key | Action | Where |
|---|---|---|
j / k |
Move cursor → filter notes to that folder | folders |
a |
Create a folder | folders |
r |
Rename the selected folder | folders |
d |
Delete the selected folder (confirmation) | folders |
<CR> |
Focus the notes column | folders |
p |
Drop the marked note into this folder | folders |
j / k |
Move cursor + open note instantly | notes |
<CR> |
Focus the editor window | notes |
a |
Create a new note in the current folder (or root) | notes |
d |
Delete the selected note (confirmation) | notes |
x |
Mark the note for moving (then navigate to a folder and press p) |
notes |
<C-n> / <C-p> |
Scroll the open note down / up | notes |
R |
Refresh the list | folders / notes |
O |
Open the notes repository in the browser | folders / notes |
f |
Toggle Folders + Notes columns (hide / show) | any |
<C-w> then h/j/k/l |
Move between windows | any |
q |
Close notes (prompts if editor has unsaved changes) | any |
Window navigation reads the direction key right after <C-w> (via getcharstr), so it is not affected by timeoutlen. It moves spatially between the three windows (wincmd h/j/k/l). Pressing <C-w>k from the editor always goes to the notes column (not folders).
Override these to customize colors (they link to sensible defaults):
| Group | Default link | Applies to |
|---|---|---|
NotesDir |
Directory |
folder rows |
NotesFile |
Normal |
note rows (defined for overriding; not applied per-row by default) |
NotesTitle |
bold |
the title text of each note row (after the date prefix) |
NotesActive |
CursorLine |
the currently open note in the notes column |
NotesDirActive |
computed | the selected folder in the folders column |
NotesCut |
Visual |
the note marked for moving (x) |
NotesConflict |
undercurl, sp from DiagnosticError |
wavy error underline on a note in a merge conflict, and on its folder |
NotesDirActive is computed at open time from the resolved colors of Directory (fg) and CursorLine (bg), combining the folder color with the selection background. Override it with an explicit nvim_set_hl call in your config after setup.
NotesActive and NotesDirActive both use line_hl_group to fill the full line width so they stay visible when focus is elsewhere. NotesCut has higher priority (200) than NotesActive (0), so it is never hidden by it even when both land on the same row. The folders and notes windows do not use cursorline; the terminal cursor shows the current position.
All three windows use fixed per-window statuslines. The folders column shows Folders, the notes column shows Notes, and the editor shows folder/title [+] when a note is open (e.g. Notes/Shopping list or Work/Project idea), falling back to Editor when no note is selected. If you use a statusline plugin (lualine, etc.) that overrides per-window statuslines, add the filetypes NotesFolders and NotesList to its exclusion list, and exclude the editor window by filetype (markdown) or by checking the buffer path.
The notes tab is labelled notes.nvim plus a sync status indicator (e.g. notes.nvim ✓, or a spinning notes.nvim ⠋ while syncing). The label is pinned in the tab-local variable t:title, so tabline plugins that read it show the right name regardless of which inner window is focused. Only if you have no tabline set does the plugin install its own (restored on close). See config.sync_icons to customize or disable the icon.
~/.notes/ ← config.dir
20260626223010.md ← a note: opaque ID file (.md); title = first line
Work/ ← a folder (one level deep)
20260625101500.md ← a note inside the folder
.gitkeep ← hidden marker so an empty folder still commits
Personal/
.gitkeep
Each note is an ID-named .md file; its title in the list is read from the first non-blank line of its content. The virtual "Notes" entry in the folders column is the repo root (notes with no folder). Folders are one level deep — create with a, rename with r, delete with d; move notes between folders with x (mark) then p (paste into the selected folder).
| Event | Action |
|---|---|
| Every open | Restore tracked files deleted outside the plugin (git checkout -- <deleted>) |
First :Notes per session |
git clone if missing, then git pull (merge) |
Subsequent :Notes |
Restore only; no network call (already synced) |
Saving a file (:w) |
UI refreshes instantly; then git commit → git pull (merge) → git push |
| Create / delete / move / rename | Immediate git commit → git pull (merge) → git push |
Closing notes (q) |
Optionally saves the open buffer, then git commit → git pull (merge) → git push |
There are no dialogs. When a git pull can't merge cleanly, the conflict is left in the note as standard git markers and the repository enters a normal "merging" state:
<<<<<<< HEAD
your local line
=======
the version from GitHub
>>>>>>> origin/main
The conflicted note title — and the name of the folder that contains it — get a wavy underline in the error color (NotesConflict) in the two columns, so you can see exactly which notes need attention. To resolve: open the note, edit the markers out, and save (:w). That completes the merge and pushes. A half-resolved note (markers still present) is never committed.
While a note is in conflict, move / rename / delete of it (or its folder) is blocked with a Resolve the conflict first message — moving a file mid-merge would corrupt git's index. A modify/delete conflict (one side edited, the other deleted) auto-resolves by keeping the surviving file, so sync never deadlocks.
Multiple rapid CRUD actions are serialised: at most one git chain runs at a time, with one queued follow-up that captures everything that accumulated while the first chain was in flight.
Set repo = '' to disable all git operations.
If you find this plugin useful, you can support development with a Bitcoin donation:
bc1q0fnakv2jean57p3rjqzhq826jklygpj6gc7evu
MIT