⌨️ Keybinding, leader key và workflow làm việc hiệu quả

By 3T1C

Bạn đã có init.lua gọn gàng và một spec lazy.nvim với vài plugin đầu tiên. Bài trước dừng ở chỗ "cài được plugin", nhưng cài xong mà vẫn gõ :Telescope find_files bằng tay thì chưa phải workflow. Bài này biến phần config đó thành phản xạ của ngón tay: chọn leader key một lần, chia phím theo workflow, và viết keymap bằng Lua có mô tả để ba tháng sau bạn vẫn đọc được.

Sau bài này bạn sẽ:

  • Đặt mapleader đúng chỗ trong init.lua, trước khi plugin load.
  • Chia namespace phím theo hành động trong workflow, không theo từng plugin.
  • Viết keymap bằng vim.keymap.set với desc, <cmd> và map buffer-local.
  • Tự kiểm tra phím đã map bằng :map, :verbose và which-key.

Leader key: chọn một lần và đặt ở đầu file

-- init.lua, đặt ngay trên phần require("lazy").setup
vim.g.mapleader = " "
vim.g.maplocalleader = " "

Vì sao Space: đây là phím lớn nhất bàn phím, cả hai ngón cái đều với tới được mà không rời home row. Leader mặc định là \ nằm tít góc phải, mỗi lần dùng phải liếc mắt xuống. Space ở normal mode chỉ là motion sang phải, gần như không mất gì khi lấy nó làm prefix.

Điểm quan trọng hơn cả việc chọn phím nào: vị trí đặt. vim.g.mapleader phải được gán trước require("lazy").setup(...) và trước bất kỳ require nào của plugin, vì nhiều plugin đọc leader ngay lúc setup để bind phím của chúng. Nếu bạn set sau, keymap của plugin sẽ bind vào \ và bạn sẽ ngồi đoán xem config sai ở đâu.

Warning: Triệu chứng kinh điển của leader đặt sai thời điểm: bấm <leader>ff không có gì xảy ra, nhưng bấm \ff lại chạy. Đừng sửa keymap, hãy kiểm tra thứ tự dòng trong init.lua trước.

Bài tập nhanh 1: Xác nhận leader hiện tại và xem nó đã resolve thành phím nào.

Gợi ý

Dùng :lua =vim.g.mapleader để in giá trị, rồi :nmap <leader> để xem Neovim đã expand <leader> thành gì. Nếu :nmap <leader> liệt kê các phím bắt đầu bằng \, leader của bạn đang là mặc định.

Chia namespace theo workflow, không theo plugin

Đây là phần quyết định hệ phím của bạn sống được bao lâu. Nếu map theo plugin, bạn phải nhớ "phím này của plugin nào", và mỗi lần thay plugin bạn phải học lại phím. Nếu map theo hành động, bạn thay plugin thoải mái mà tay vẫn chạy đúng.

Nhóm workflow Prefix Ví dụ
File <leader>f <leader>ff mở file, <leader>fr file gần đây
Search <leader>s <leader>sg grep trong dự án
Buffer <leader>b <leader>bd đóng buffer hiện tại
Code / LSP <leader>c <leader>cr rename, <leader>ca code action
Git <leader>g <leader>gs status, <leader>gd diff
Terminal / toggle <leader>t <leader>tt mở terminal nổi

Quy tắc đặt tên: ký tự đầu là phạm vi, ký tự sau là động từ. <leader>s là "tìm cái gì đó", g là grep. Giữ 5 đến 7 nhóm là đủ; nhiều hơn thì bạn phải nhớ hai tầng, mất lợi ích.

mindmap
  root((Space))
    f["📁 File"]
    s["🔍 Search"]
    b["🧱 Buffer"]
    c["🛠️ Code"]
    g["🌿 Git"]
    t["💻 Terminal"]

Bài tập nhanh 2: Chọn 3 thao tác bạn làm nhiều nhất trong một ngày làm việc và gán phím cho chúng, biết rằng :w đã đủ nhanh nên bạn không bắt buộc phải map save.

Gợi ý

Ví dụ hợp lý: <leader>sg cho grep, <leader>bd cho đóng buffer, <leader>tt cho terminal. Lưu ý <leader>w thường được dành cho nhóm window; nếu bạn muốn giữ chỗ đó thì cứ để save là :w. Nguyên tắc: đừng chiếm một prefix có ý nghĩa khác chỉ vì hôm nay bạn thấy tiện.

Viết keymap bằng Lua: desc là bắt buộc, <cmd> là mặc định tốt

-- lua/config/keymaps.lua
local map = vim.keymap.set

local function nmap(lhs, rhs, desc)
  map("n", lhs, rhs, { silent = true, desc = desc })
end

nmap("<leader>w", "<cmd>write<cr>", "Ghi file hiện tại")
nmap("<leader>bd", "<cmd>bdelete<cr>", "Đóng buffer hiện tại")
nmap("<leader>sg", "<cmd>Telescope live_grep<cr>", "Grep trong dự án")

Các option đáng nhớ:

Option Dùng khi Lý do
desc luôn luôn nguồn dữ liệu cho :map, which-key, Telescope keymaps
remap = true thật sự muốn map chồng map vim.keymap.set đã mặc định noremap
silent = true vế phải là command không đẩy thông báo lên cmdline
expr = true vế phải là hàm trả về chuỗi phím dùng cho toggle

Hai chi tiết nhỏ nhưng khác biệt lớn. Thứ nhất, <cmd>write<cr> không đi qua cmdline nên không đổi mode, không bị ảnh hưởng bởi mapping của :, và không nháy màn hình. Thứ hai, khi vế phải là một Lua function (như vim.lsp.buf.rename), bạn truyền thẳng function, không cần bọc <cmd>.

Map theo buffer thì để trong autocmd, vì phím LSP chỉ nên sống trong buffer có LSP:

vim.api.nvim_create_autocmd("LspAttach", {
  callback = function(ev)
    local buf = ev.buf
    map("n", "gd", vim.lsp.buf.definition, { buffer = buf, desc = "Nhảy tới định nghĩa" })
    map("n", "<leader>cr", vim.lsp.buf.rename, { buffer = buf, desc = "Rename symbol" })
  end,
})

Trên Windows 11, hãy tránh lấy <C-s>, <C-q> hay <C-v> làm phím sống còn: một số terminal giữ lại các tổ hợp này trước khi Neovim kịp nhận. Phần xử lý triệt để nằm ở Module 3, còn ở đây chỉ cần biết để không thiết kế phím dựa vào chúng.

Bài tập nhanh 3: Viết map <leader>tt mở terminal nổi, có desc, và giải thích trong một câu vì sao bạn dùng <cmd> thay :.

Gợi ý

nmap("<leader>tt", "<cmd>split | terminal<cr>", "Mở terminal trong split")

<cmd> chạy command trực tiếp, không qua cmdline, nên không bị ảnh hưởng bởi các mapping của : và không đổi mode khi bạn đang ở normal mode.

Để config tự dạy bạn phím

Một hệ phím tốt phải tự giải thích được. :map <leader> liệt kê mọi phím bắt đầu bằng leader, :verbose nmap <leader>ff cho biết file nào đã định nghĩa phím đó. Nếu spec lazy.nvim của bạn đã có which-key, bấm leader rồi dừng lại sẽ thấy popup các nhóm phím, và nó đọc chính các desc bạn viết. Đó là lý do desc không phải trang trí.

Bài tập nhanh 4: Một phím bấm không có phản ứng gì. Nêu ba bước kiểm tra theo thứ tự.

Gợi ý

  1. :verbose nmap <phím> xem phím có được map không và map ở file nào (nếu không có kết quả, phím chưa được map).
  2. :lua =vim.g.mapleader xem leader có đúng không, và kiểm tra nó được set trước lazy.setup chưa.
  3. Kiểm tra xung đột prefix: một map ngắn hơn có thể nuốt phím của bạn, hoặc plugin khác map đè sau.

Những quy tắc giữ cho keybinding không phản bội bạn

  • Chỉ map thao tác bạn đã làm thật ít nhất vài lần trong tuần. Map trước khi dùng là cách nhanh nhất để có một bộ phím bạn không nhớ.
  • Không map hai tổ hợp cho cùng một việc, trừ khi đang chuyển tiếp có chủ đích.
  • Giữ keymaps.lua phẳng cho phím global. Phím phụ thuộc filetype hoặc LSP thì để trong autocmd với buffer.
  • Viết desc ngay lúc viết map. Ba tháng sau bạn sẽ không nhớ <leader>cx là gì, nhưng :map thì nhớ.
  • Đừng map lại hjkl, x, p, u, . hay các motion bạn đã nhuyễn. Bạn đến đây để giữ tốc độ, không phải để học lại Vim.
  • Khi thêm plugin mới, nhét phím của nó vào nhóm có sẵn. Chỉ tạo nhóm mới khi workflow thật sự mới.

Chốt lại và bước kế tiếp

Bốn điều cần mang theo: leader được set trước lazy.setup, namespace chia theo workflow chứ không theo plugin, mọi keymap đều có desc, và <cmd> là vế phải mặc định cho command. Đây là bộ phím phản ánh công việc của bạn, không phải bản sao config của người khác.

Bài tiếp theo là checkpoint của Module 1: bạn sẽ tự dựng lại bộ khung init.lua, cài plugin qua lazy.nvim và chứng minh hệ phím của mình chạy đúng trước khi bước sang LSP, Telescope và Treesitter.

Front: vim.g.mapleader phải được đặt ở đâu trong init.lua? Back: Trước require("lazy").setup(...) và trước khi bất kỳ plugin nào được load.

Front: Vì sao Space là leader key được khuyên dùng? Back: Phím lớn nhất, hai ngón cái với tới mà không rời home row, và gần như không mất chức năng gốc.

Front: Triệu chứng của việc set leader quá muộn là gì? Back: ff không chạy nhưng \ff chạy, vì plugin đã bind phím với leader mặc định.

Front: Quy tắc đặt tên một nhóm phím theo workflow? Back: Ký tự đầu là phạm vi hành động, ký tự sau là động từ, ví dụ sg là grep trong nhóm search.

Front: Vì sao chia phím theo workflow thay vì theo plugin? Back: Plugin có thể bị thay hoặc tháo, còn phím gắn với hành động thì tay vẫn chạy đúng.

Front: vim.keymap.set mặc định có bật noremap không? Back: Có, mặc định là noremap; chỉ đặt remap = true khi thật sự muốn map chồng map.

Front: Option desc trong keymap dùng để làm gì? Back: Mô tả phím, hiển thị trong :map, which-key và Telescope keymaps.

Front: write khác :write ở điểm nào? Back: không đi qua cmdline, không đổi mode và không bị ảnh hưởng bởi mapping của dấu hai chấm.

Front: Muốn một keymap chỉ có hiệu lực trong buffer hiện tại thì dùng option nào? Back: buffer = bufnr, thường đặt trong autocmd LspAttach với ev.buf.

Front: Lệnh nào cho biết keymap được định nghĩa ở file nào? Back: :verbose nmap

Front: silent = true trong keymap có tác dụng gì? Back: Không hiển thị command hoặc thông báo trên cmdline khi phím được bấm, tránh cmdline nhấp nháy và giữ màn hình sạch.

Front: Vì sao nên map w để ghi file thay vì dựa vào :w? Back: Một phím thay cho ba thao tác gõ, và tay không phải rời vị trí làm việc để vào cmdline.

Front: Điều gì xảy ra nếu hai keymap trùng trong cùng mode? Back: Cái định nghĩa sau ghi đè cái trước, và Neovim không báo lỗi, nên rất dễ mất phím mà không biết.

Chốt lại và bước tiếp theo

Ba ý cần mang theo vào buổi làm việc thật:

  • Leader key phải được set trước khi require("lazy").setup(), nếu không plugin sẽ bind phím với leader mặc định là \.
  • Mỗi keymap đi qua vim.keymap.set với desc, và <cmd>...<cr> là dạng rhs mặc định cho lệnh Ex.
  • Phím được tổ chức theo workflow (nhóm hành động), không theo tên plugin, để sau này thay Telescope bằng thứ khác thì tay vẫn chạy đúng.

Cấu hình lua/config/keymaps.lua bạn vừa viết chính là thứ sẽ được kiểm tra ở bài checkpoint tiếp theo, nơi bạn ráp toàn bộ init.lua, spec lazy.nvim và keymaps thành một bộ khung chạy được, kèm vài lỗi cố ý để bạn tự sửa.