A lightweight Neovim plugin for managing notes in a dedicated tab — modelled on the macOS Notes app — with optional GitHub synchronization via Git (SSH).
notes.mp4
──────────────┬──────────────────────────────────
Folders │ Notes ← statuslines
Notes/ │ 26.06.2026 - Shopping list
├─ Work[12]/ │ 25.06.2026 - Project idea
└─ Personal[3]/ │ 24.06.2026 - Report
──────────────┴──────────────────────────────────
Notes/Shopping list ← statusline (folder/title)
# Shopping list
- [ ] Milk
- [ ] Call the bank
Folders nest to any depth, shown drill-down one level at a time (o to enter / go up):
────────────────┬────────────────────────────────
Folders │ Notes
Notes/Work/ .. │ 26.06.2026 - Sprint notes
├─ Projects[8]/│
└─ Archive[4]/ │
────────────────┴────────────────────────────────
- 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. - Nested folders, drill-down navigation — the folders column shows one level at a time: row 1 is the current level ("Notes" at the root, or
Notes/<path>/ ..once you've drilled in), followed by its immediate subfolders, sorted so the one whose subtree has the most recently edited note comes first. Each subfolder shows a[N]count next to its name — the total number of notes in it and all its nested subfolders. Pressoon a subfolder to enter it and load its notes into the notes column, oroon row 1 to go back up. Moving the cursor alone does not reload the notes column — only entering a folder withodoes; the folders column uses a nativecursorlineto show which row is under the cursor. New folders (a) are created inside the current level. Empty folders are supported via a hidden.gitkeepso they commit and sync. - Move by cursor — press
xon a note (or a folder) to mark it (highlighted with the selection color); pressxagain on the same item to cancel. For a note, navigate to a folder in the folders column and presspto drop it there. For a folder, navigate within the folders column itself (drill in/out witho, move the cursor) to the destination and pressp. The destination folder becomes the selected one and rises to the top of the folders column; for a moved folder, the column also drills into the destination so the moved folder shows up as one of its children. Only a subfolder (a child row) can be marked — the folder you're currently viewing (row 1, including the root "Notes") can't be moved from inside itself. A folder also can't be moved into itself or one of its own subfolders. - 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,xmarks for move,ppastes; 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. - Windows hold their buffer — the three notes windows have
winfixbufset, so a global mapping that switches buffers (:bn,:bp,:b <name>) can't replace a column with an unrelated buffer and destroy the UI; such a command simply reportsE1513and nothing changes. - Toggle panels — press
<C-t>(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 = 30,
-- Keymaps (override individually; unset keys keep their defaults).
keys = {
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; folders: mark folder for moving
paste = 'p', -- folders: drop the marked note/folder 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 = '<C-t>', -- hide/show Folders + Notes columns
change_folder = 'o', -- folders: enter the folder under cursor / go up from row 1
select = '<CR>', -- folders: enter child folder / focus notes; notes: focus editor
},
-- 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 (navigate; press o to enter/load the folder) |
folders |
a |
Create a folder inside the current level | folders |
r |
Rename the selected folder | folders |
d |
Delete the selected folder (confirmation) | folders |
o |
Enter the folder under cursor / go up from row 1 | folders |
<CR> |
Enter the folder under cursor (row 1 keeps its level) and focus the Notes column | folders |
x |
Mark the folder for moving (then navigate within Folders and press p) |
folders |
p |
Drop the marked note/folder into this folder | folders |
j / k |
Move cursor + open note instantly | 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 |
<CR> |
Open the note under cursor and focus the editor | 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 |
<C-t> |
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 |
NotesCut |
Visual |
the note or folder marked for moving (x) |
NotesConflict |
undercurl, sp from DiagnosticError |
wavy error underline on a note in a merge conflict, and on its folder |
NotesActive and NotesDir both use a low-priority (0) highlight over the row text, so NotesCut (priority 200) and NotesConflict (priority 300) are never hidden by them even when both land on the same row — a marked-for-move or conflicted note/folder stays visibly highlighted even while it's also under the cursor. The notes window does not use cursorline; the terminal cursor shows the current position. The folders column is the exception: it uses a native cursorline as its navigation indicator, since moving the cursor there no longer changes what's shown in the notes column.
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 (any depth is supported)
20260625101500.md ← a note inside the folder
Projects/ ← a subfolder, entered with `o`
20260701090000.md
.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 can nest to any depth; the folders column shows one level at a time — press o to drill in or go back up. Create with a (inside the current level), rename with r, delete with d; move notes between folders with x (mark) then p (paste into the selected folder). Whole folders can be moved the same way (x then p), navigating to the destination within the folders column itself instead of switching columns; only a subfolder can be marked (not the folder you're currently viewing), and a folder can't be moved into itself or one of its own subfolders.
| 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 |
Quitting Neovim (:qa, etc.) |
A local git commit runs synchronously as a safety net, so a rapid delete followed by an immediate quit is never lost; the push (network) happens on the next open instead |
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