feat(plugins): add git-ai-commit, diffview, and reorganize keymaps
- Add AI-powered commit message generation with git-ai-commit plugin. - Add diffview for side-by-side git diffs. - Reorganize telescope keymaps (<leader><leader> for live_grep, <leader>/ for find_files). - Update lazy.nvim imports to include plugins.extras. - Add mago.nvim for PHP support. - Update AI plugin with model configuration.
This commit is contained in:
7
init.lua
7
init.lua
@@ -25,8 +25,11 @@ if not (vim.uv or vim.loop).fs_stat(lazypath) then
|
|||||||
end
|
end
|
||||||
vim.opt.rtp:prepend(lazypath)
|
vim.opt.rtp:prepend(lazypath)
|
||||||
|
|
||||||
-- Load all plugin specs from lua/plugins/**/*.lua
|
-- Load all plugin specs from lua/plugins/*.lua and lua/plugins/extras/*.lua
|
||||||
require('lazy').setup('plugins', {
|
require('lazy').setup({
|
||||||
|
{ import = 'plugins' },
|
||||||
|
{ import = 'plugins.extras' },
|
||||||
|
}, {
|
||||||
ui = {
|
ui = {
|
||||||
icons = vim.g.have_nerd_font and {} or {
|
icons = vim.g.have_nerd_font and {} or {
|
||||||
cmd = '⌘',
|
cmd = '⌘',
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ map('n', '<C-k>', '<C-w><C-k>', { desc = 'Move focus to the upper window' })
|
|||||||
-- File explorer
|
-- File explorer
|
||||||
map('n', '<C-e>', ':Neotree toggle<CR>', { desc = '[E]xplorer (toggle neotree)' })
|
map('n', '<C-e>', ':Neotree toggle<CR>', { desc = '[E]xplorer (toggle neotree)' })
|
||||||
|
|
||||||
-- LazyGit
|
|
||||||
map('n', '<C-g>', '<cmd>LazyGit<CR>', { desc = 'LazyGit' })
|
|
||||||
|
|
||||||
-- Telescope live grep (fixed: was using broken <cmd>builtin... syntax)
|
-- Telescope live grep (fixed: was using broken <cmd>builtin... syntax)
|
||||||
map('n', '<C-f>', function()
|
map('n', '<C-f>', function()
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'ThePrimeagen/99',
|
'ThePrimeagen/99',
|
||||||
|
dependencies = {
|
||||||
|
{ 'saghen/blink.compat', version = '2.*' },
|
||||||
|
},
|
||||||
config = function()
|
config = function()
|
||||||
local _99 = require '99'
|
local _99 = require '99'
|
||||||
local basename = vim.fs.basename(vim.uv.cwd())
|
local basename = vim.fs.basename(vim.uv.cwd())
|
||||||
@@ -25,6 +28,9 @@ return {
|
|||||||
completion = {
|
completion = {
|
||||||
source = 'blink', -- blink.cmp is your completion engine
|
source = 'blink', -- blink.cmp is your completion engine
|
||||||
},
|
},
|
||||||
|
-- Default model (set to nil to use provider default)
|
||||||
|
-- Examples: 'opencode/claude-sonnet-4-5', 'claude-sonnet-4-5', 'gpt-4'
|
||||||
|
model = vim.env.NVIM_99_MODEL or 'openrouter/moonshotai/kimi-k2.5',
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Visual selection → AI replacement
|
-- Visual selection → AI replacement
|
||||||
|
|||||||
51
lua/plugins/extras/diffview.lua
Normal file
51
lua/plugins/extras/diffview.lua
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-- lua/plugins/extras/diffview.lua
|
||||||
|
-- Side-by-side diff view for staged/unstaged changes across all files
|
||||||
|
-- https://github.com/sindrets/diffview.nvim
|
||||||
|
--
|
||||||
|
-- Keymaps:
|
||||||
|
-- <leader>ds — staged changes (git diff --cached)
|
||||||
|
-- <leader>du — unstaged changes (git diff)
|
||||||
|
-- <leader>dh — diff against HEAD
|
||||||
|
-- <leader>dc — close diffview
|
||||||
|
--
|
||||||
|
-- Inside diffview:
|
||||||
|
-- <Tab> / <S-Tab> — next/prev changed file
|
||||||
|
-- [c / ]c — prev/next hunk in file
|
||||||
|
-- s — stage/unstage file (in file panel)
|
||||||
|
-- q / <leader>dc — close
|
||||||
|
|
||||||
|
return {
|
||||||
|
'sindrets/diffview.nvim',
|
||||||
|
lazy = true,
|
||||||
|
dependencies = { 'nvim-tree/nvim-web-devicons' },
|
||||||
|
cmd = { 'DiffviewOpen', 'DiffviewClose', 'DiffviewFileHistory' },
|
||||||
|
keys = {
|
||||||
|
{ '<leader>ds', '<cmd>DiffviewOpen --staged<cr>', desc = 'git [d]iff [s]taged' },
|
||||||
|
{ '<leader>du', '<cmd>DiffviewOpen<cr>', desc = 'git [d]iff [u]nstaged' },
|
||||||
|
{ '<leader>dh', '<cmd>DiffviewOpen HEAD<cr>', desc = 'git [d]iff [h]ead' },
|
||||||
|
{ '<leader>dc', '<cmd>DiffviewClose<cr>', desc = 'git [d]iff [c]lose' },
|
||||||
|
},
|
||||||
|
opts = {
|
||||||
|
enhanced_diff_hl = true,
|
||||||
|
view = {
|
||||||
|
default = {
|
||||||
|
layout = 'diff2_horizontal', -- side by side
|
||||||
|
winbar_info = true,
|
||||||
|
},
|
||||||
|
merge_tool = {
|
||||||
|
layout = 'diff3_horizontal',
|
||||||
|
disable_diagnostics = true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
file_panel = {
|
||||||
|
listing_style = 'tree',
|
||||||
|
win_config = { width = 35 },
|
||||||
|
},
|
||||||
|
hooks = {
|
||||||
|
-- close diffview with q in any diffview buffer
|
||||||
|
view_opened = function()
|
||||||
|
vim.keymap.set('n', 'q', '<cmd>DiffviewClose<cr>', { buffer = true, silent = true })
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
292
lua/plugins/extras/git-ai-commit.lua
Normal file
292
lua/plugins/extras/git-ai-commit.lua
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
-- lua/plugins/extras/git-ai-commit.lua
|
||||||
|
-- AI-powered commit message generator using ThePrimeagen/99 + lazygit
|
||||||
|
--
|
||||||
|
-- Flow:
|
||||||
|
-- <leader>9g → gather diff (staged → unstaged fallback)
|
||||||
|
-- → send diff to 99 with a Conventional Commits prompt
|
||||||
|
-- → write cleaned response to .git/COMMIT_EDITMSG
|
||||||
|
-- → show message preview with confirm/edit/cancel options
|
||||||
|
|
||||||
|
return {
|
||||||
|
-- Virtual plugin entry — no remote source, just wires deps + config
|
||||||
|
dir = vim.fn.stdpath('config'),
|
||||||
|
name = 'git-ai-commit',
|
||||||
|
dependencies = {
|
||||||
|
'ThePrimeagen/99',
|
||||||
|
'kdheepak/lazygit.nvim',
|
||||||
|
},
|
||||||
|
event = 'VeryLazy',
|
||||||
|
config = function()
|
||||||
|
-- ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
--- Return the git root for cwd, or nil if not in a repo.
|
||||||
|
local function git_root()
|
||||||
|
local result = vim.system({ 'git', 'rev-parse', '--show-toplevel' }, { text = true }):wait()
|
||||||
|
if result.code ~= 0 then return nil end
|
||||||
|
return vim.trim(result.stdout)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Collect the diff to describe.
|
||||||
|
--- Strategy: staged first, fall back to HEAD, abort if clean.
|
||||||
|
--- @return string|nil diff text, or nil when there is nothing to commit
|
||||||
|
local function get_diff()
|
||||||
|
-- 1. staged changes
|
||||||
|
local staged = vim.system({ 'git', 'diff', '--cached' }, { text = true }):wait()
|
||||||
|
if staged.code == 0 and vim.trim(staged.stdout) ~= '' then
|
||||||
|
return staged.stdout
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. unstaged changes against HEAD
|
||||||
|
local unstaged = vim.system({ 'git', 'diff', 'HEAD' }, { text = true }):wait()
|
||||||
|
if unstaged.code == 0 and vim.trim(unstaged.stdout) ~= '' then
|
||||||
|
return unstaged.stdout
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Strip markdown fences, surrounding quotes, and whitespace from the
|
||||||
|
--- AI response so we're left with a clean commit message.
|
||||||
|
--- Returns both the subject line and any body content.
|
||||||
|
--- @param raw string
|
||||||
|
--- @return string subject, string body
|
||||||
|
local function clean_message(raw)
|
||||||
|
local msg = vim.trim(raw)
|
||||||
|
-- remove ```...``` fences
|
||||||
|
msg = msg:gsub('^```[^\n]*\n', ''):gsub('\n```$', '')
|
||||||
|
-- remove surrounding single/double quotes
|
||||||
|
msg = msg:match('^["\'](.+)["\']$') or msg
|
||||||
|
|
||||||
|
-- split into subject (first line) and body (remaining lines)
|
||||||
|
local subject = msg:match('^([^\n]+)') or msg
|
||||||
|
local body = msg:match('^[^\n]+\n(.+)$') or ''
|
||||||
|
|
||||||
|
return vim.trim(subject), vim.trim(body)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── core function ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local function generate_and_commit()
|
||||||
|
-- 1. sanity: must be inside a git repo
|
||||||
|
local root = git_root()
|
||||||
|
if not root then
|
||||||
|
vim.notify('git-ai-commit: not inside a git repository', vim.log.levels.WARN)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. collect diff
|
||||||
|
local diff = get_diff()
|
||||||
|
if not diff then
|
||||||
|
vim.notify('git-ai-commit: nothing to commit (clean working tree)', vim.log.levels.WARN)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. tell the user we're working
|
||||||
|
vim.notify('AI: generating commit message…', vim.log.levels.INFO)
|
||||||
|
|
||||||
|
-- 4. build prompt
|
||||||
|
local prompt = string.format([[
|
||||||
|
<DIRECTIONS>
|
||||||
|
You are an expert at writing git commit messages.
|
||||||
|
|
||||||
|
Given the following git diff, write a commit message following
|
||||||
|
the Conventional Commits specification. Format:
|
||||||
|
type(scope): subject
|
||||||
|
|
||||||
|
body (optional, keep it brief)
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Subject: One line only, max 72 characters
|
||||||
|
- Subject: Use imperative mood ("add", "fix", "refactor" — not "added" / "fixes")
|
||||||
|
- Body: Optional but recommended for complex changes; explain WHAT and WHY, not HOW
|
||||||
|
- Body: Wrap at 72 characters, blank line between subject and body
|
||||||
|
- Output ONLY the raw commit message — no backticks, no quotes, no preamble
|
||||||
|
|
||||||
|
<DIFF>
|
||||||
|
%s
|
||||||
|
</DIFF>
|
||||||
|
</DIRECTIONS>
|
||||||
|
]], diff)
|
||||||
|
|
||||||
|
-- 5. fire the 99 request via its internal Prompt API
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
local _99 = require '99'
|
||||||
|
local Prompt = require '99.prompt'
|
||||||
|
local CleanUp = require '99.ops.clean-up'
|
||||||
|
local state = _99.__get_state()
|
||||||
|
|
||||||
|
local context = Prompt.search(state)
|
||||||
|
context:add_prompt_content(prompt)
|
||||||
|
context:finalize()
|
||||||
|
|
||||||
|
local clean_up = CleanUp.make_clean_up(function() context:stop() end)
|
||||||
|
context:add_clean_up(clean_up)
|
||||||
|
|
||||||
|
context:start_request(CleanUp.make_observer(clean_up, {
|
||||||
|
on_complete = function(status, response)
|
||||||
|
vim.schedule(function()
|
||||||
|
if status ~= 'success' then
|
||||||
|
vim.notify(
|
||||||
|
'git-ai-commit: AI request ' .. status,
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 6. clean and persist the message
|
||||||
|
local subject, body = clean_message(response)
|
||||||
|
if subject == '' then
|
||||||
|
vim.notify('git-ai-commit: AI returned an empty message', vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local editmsg_path = root .. '/.git/COMMIT_EDITMSG'
|
||||||
|
local f = io.open(editmsg_path, 'w')
|
||||||
|
if not f then
|
||||||
|
vim.notify(
|
||||||
|
'git-ai-commit: could not write to ' .. editmsg_path,
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Write subject and optional body
|
||||||
|
f:write(subject .. '\n')
|
||||||
|
if body ~= '' then
|
||||||
|
f:write('\n' .. body .. '\n')
|
||||||
|
end
|
||||||
|
f:close()
|
||||||
|
|
||||||
|
-- 7. show editable commit message with accept/cancel buttons
|
||||||
|
local full_msg = subject
|
||||||
|
if body ~= '' then
|
||||||
|
full_msg = full_msg .. '\n\n' .. body
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create floating window with editable buffer
|
||||||
|
local lines = vim.split(full_msg, '\n')
|
||||||
|
local max_width = 0
|
||||||
|
for _, line in ipairs(lines) do
|
||||||
|
max_width = math.max(max_width, vim.fn.strdisplaywidth(line))
|
||||||
|
end
|
||||||
|
max_width = math.min(math.max(max_width, 50), 80)
|
||||||
|
|
||||||
|
-- Add button row
|
||||||
|
local button_padding = math.floor((max_width - 28) / 2)
|
||||||
|
local accept_btn = string.rep(' ', button_padding) .. '[ Accept (Enter) ]'
|
||||||
|
local cancel_btn = '[ Cancel (Esc) ]'
|
||||||
|
local button_row = accept_btn .. ' ' .. cancel_btn
|
||||||
|
|
||||||
|
local buf = vim.api.nvim_create_buf(false, true)
|
||||||
|
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||||
|
|
||||||
|
-- Set buffer options for editing
|
||||||
|
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
|
||||||
|
vim.api.nvim_buf_set_option(buf, 'buftype', 'acwrite')
|
||||||
|
vim.api.nvim_buf_set_option(buf, 'swapfile', false)
|
||||||
|
|
||||||
|
local width = max_width
|
||||||
|
local height = math.min(#lines + 4, vim.o.lines - 8)
|
||||||
|
|
||||||
|
local opts = {
|
||||||
|
relative = 'editor',
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
col = (vim.o.columns - width) / 2,
|
||||||
|
row = (vim.o.lines - height - 4) / 2,
|
||||||
|
style = 'minimal',
|
||||||
|
border = 'rounded',
|
||||||
|
title = ' Edit Commit Message ',
|
||||||
|
title_pos = 'center',
|
||||||
|
}
|
||||||
|
|
||||||
|
local win = vim.api.nvim_open_win(buf, true, opts)
|
||||||
|
|
||||||
|
-- Create button window below
|
||||||
|
local btn_buf = vim.api.nvim_create_buf(false, true)
|
||||||
|
vim.api.nvim_buf_set_lines(btn_buf, 0, -1, false, { '', button_row, '' })
|
||||||
|
vim.api.nvim_buf_set_option(btn_buf, 'modifiable', false)
|
||||||
|
vim.api.nvim_buf_set_option(btn_buf, 'buftype', 'nofile')
|
||||||
|
|
||||||
|
-- Highlight the buttons
|
||||||
|
local ns = vim.api.nvim_create_namespace('git-ai-commit-buttons')
|
||||||
|
vim.api.nvim_buf_add_highlight(btn_buf, ns, 'Keyword', 1, button_padding, button_padding + 19)
|
||||||
|
vim.api.nvim_buf_add_highlight(btn_buf, ns, 'WarningMsg', 1, button_padding + 23, button_padding + 41)
|
||||||
|
|
||||||
|
local btn_opts = {
|
||||||
|
relative = 'editor',
|
||||||
|
width = width,
|
||||||
|
height = 3,
|
||||||
|
col = opts.col,
|
||||||
|
row = opts.row + opts.height + 1,
|
||||||
|
style = 'minimal',
|
||||||
|
border = 'none',
|
||||||
|
focusable = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
local btn_win = vim.api.nvim_open_win(btn_buf, false, btn_opts)
|
||||||
|
|
||||||
|
-- Set up keymaps for the floating window
|
||||||
|
local function close_windows()
|
||||||
|
if vim.api.nvim_win_is_valid(win) then
|
||||||
|
vim.api.nvim_win_close(win, true)
|
||||||
|
end
|
||||||
|
if vim.api.nvim_win_is_valid(btn_win) then
|
||||||
|
vim.api.nvim_win_close(btn_win, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function do_accept()
|
||||||
|
-- Get edited content
|
||||||
|
local edited_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||||
|
local edited_msg = table.concat(edited_lines, '\n')
|
||||||
|
|
||||||
|
close_windows()
|
||||||
|
|
||||||
|
-- Commit with the edited message
|
||||||
|
local commit_result = vim.system(
|
||||||
|
{ 'git', 'commit', '-m', edited_msg },
|
||||||
|
{ text = true }
|
||||||
|
):wait()
|
||||||
|
|
||||||
|
if commit_result.code == 0 then
|
||||||
|
-- Remove temp file on success
|
||||||
|
vim.fn.delete(editmsg_path)
|
||||||
|
vim.notify('Committed successfully!', vim.log.levels.INFO)
|
||||||
|
else
|
||||||
|
vim.notify(
|
||||||
|
'git-ai-commit: commit failed - ' .. vim.trim(commit_result.stderr or ''),
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function do_cancel()
|
||||||
|
close_windows()
|
||||||
|
vim.notify('Commit cancelled (message saved to .git/COMMIT_EDITMSG)', vim.log.levels.INFO)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keymaps
|
||||||
|
vim.keymap.set('n', '<CR>', do_accept, { buffer = buf, nowait = true, desc = 'Accept commit' })
|
||||||
|
vim.keymap.set('n', '<Esc>', do_cancel, { buffer = buf, nowait = true, desc = 'Cancel commit' })
|
||||||
|
vim.keymap.set('i', '<C-c>', do_cancel, { buffer = buf, desc = 'Cancel commit' })
|
||||||
|
|
||||||
|
-- Start in insert mode at the beginning
|
||||||
|
vim.cmd('normal! gg0')
|
||||||
|
end)
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
vim.notify('git-ai-commit: ' .. tostring(err), vim.log.levels.ERROR)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── keymap ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vim.keymap.set('n', '<leader>9g', generate_and_commit, {
|
||||||
|
desc = 'AI: generate commit message + confirm',
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ return {
|
|||||||
},
|
},
|
||||||
dependencies = { 'nvim-lua/plenary.nvim' },
|
dependencies = { 'nvim-lua/plenary.nvim' },
|
||||||
keys = {
|
keys = {
|
||||||
{ '<leader>lg', '<cmd>LazyGit<cr>', desc = 'LazyGit' },
|
{ '<C-g>', '<cmd>LazyGit<cr>', desc = 'LazyGit' },
|
||||||
-- <C-g> is also mapped in config/keymaps.lua
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
5
lua/plugins/mago.lua
Normal file
5
lua/plugins/mago.lua
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
return {
|
||||||
|
'calvinludwig/mago.nvim',
|
||||||
|
ft = 'php',
|
||||||
|
opts = {},
|
||||||
|
}
|
||||||
@@ -38,15 +38,8 @@ return {
|
|||||||
vim.keymap.set('n', '<leader>sd', builtin.diagnostics, { desc = '[S]earch [D]iagnostics' })
|
vim.keymap.set('n', '<leader>sd', builtin.diagnostics, { desc = '[S]earch [D]iagnostics' })
|
||||||
vim.keymap.set('n', '<leader>sr', builtin.resume, { desc = '[S]earch [R]esume' })
|
vim.keymap.set('n', '<leader>sr', builtin.resume, { desc = '[S]earch [R]esume' })
|
||||||
vim.keymap.set('n', '<leader>s.', builtin.oldfiles, { desc = '[S]earch Recent Files' })
|
vim.keymap.set('n', '<leader>s.', builtin.oldfiles, { desc = '[S]earch Recent Files' })
|
||||||
vim.keymap.set('n', '<leader><leader>', builtin.buffers, { desc = '[ ] Find existing buffers' })
|
vim.keymap.set('n', '<leader><leader>', builtin.live_grep, { desc = '[ ] Live grep (project)' })
|
||||||
|
vim.keymap.set('n', '<leader>/', builtin.find_files, { desc = '[/] Find files' })
|
||||||
-- Fuzzy search inside current buffer
|
|
||||||
vim.keymap.set('n', '<leader>/', function()
|
|
||||||
builtin.current_buffer_fuzzy_find(require('telescope.themes').get_dropdown {
|
|
||||||
winblend = 10,
|
|
||||||
previewer = false,
|
|
||||||
})
|
|
||||||
end, { desc = '[/] Fuzzily search in current buffer' })
|
|
||||||
|
|
||||||
-- Grep only open files
|
-- Grep only open files
|
||||||
vim.keymap.set('n', '<leader>s/', function()
|
vim.keymap.set('n', '<leader>s/', function()
|
||||||
|
|||||||
Reference in New Issue
Block a user