From 49906c175d9f467405e563cb9427a98f560b10dd Mon Sep 17 00:00:00 2001 From: zias Date: Thu, 26 Feb 2026 15:27:39 +0100 Subject: [PATCH] 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 ( for live_grep, / for find_files). - Update lazy.nvim imports to include plugins.extras. - Add mago.nvim for PHP support. - Update AI plugin with model configuration. --- init.lua | 7 +- lua/config/keymaps.lua | 2 - lua/plugins/extras/ai.lua | 6 + lua/plugins/extras/diffview.lua | 51 +++++ lua/plugins/extras/git-ai-commit.lua | 292 +++++++++++++++++++++++++++ lua/plugins/extras/lazygit.lua | 3 +- lua/plugins/mago.lua | 5 + lua/plugins/telescope.lua | 11 +- 8 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 lua/plugins/extras/diffview.lua create mode 100644 lua/plugins/extras/git-ai-commit.lua create mode 100644 lua/plugins/mago.lua diff --git a/init.lua b/init.lua index 1038850..3a90985 100644 --- a/init.lua +++ b/init.lua @@ -25,8 +25,11 @@ if not (vim.uv or vim.loop).fs_stat(lazypath) then end vim.opt.rtp:prepend(lazypath) --- Load all plugin specs from lua/plugins/**/*.lua -require('lazy').setup('plugins', { +-- Load all plugin specs from lua/plugins/*.lua and lua/plugins/extras/*.lua +require('lazy').setup({ + { import = 'plugins' }, + { import = 'plugins.extras' }, +}, { ui = { icons = vim.g.have_nerd_font and {} or { cmd = '⌘', diff --git a/lua/config/keymaps.lua b/lua/config/keymaps.lua index 99782af..ba4a7f3 100644 --- a/lua/config/keymaps.lua +++ b/lua/config/keymaps.lua @@ -22,8 +22,6 @@ map('n', '', '', { desc = 'Move focus to the upper window' }) -- File explorer map('n', '', ':Neotree toggle', { desc = '[E]xplorer (toggle neotree)' }) --- LazyGit -map('n', '', 'LazyGit', { desc = 'LazyGit' }) -- Telescope live grep (fixed: was using broken builtin... syntax) map('n', '', function() diff --git a/lua/plugins/extras/ai.lua b/lua/plugins/extras/ai.lua index 6664d35..90a88f2 100644 --- a/lua/plugins/extras/ai.lua +++ b/lua/plugins/extras/ai.lua @@ -7,6 +7,9 @@ return { 'ThePrimeagen/99', + dependencies = { + { 'saghen/blink.compat', version = '2.*' }, + }, config = function() local _99 = require '99' local basename = vim.fs.basename(vim.uv.cwd()) @@ -25,6 +28,9 @@ return { completion = { 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 diff --git a/lua/plugins/extras/diffview.lua b/lua/plugins/extras/diffview.lua new file mode 100644 index 0000000..03eef14 --- /dev/null +++ b/lua/plugins/extras/diffview.lua @@ -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: +-- ds — staged changes (git diff --cached) +-- du — unstaged changes (git diff) +-- dh — diff against HEAD +-- dc — close diffview +-- +-- Inside diffview: +-- / — next/prev changed file +-- [c / ]c — prev/next hunk in file +-- s — stage/unstage file (in file panel) +-- q / dc — close + +return { + 'sindrets/diffview.nvim', + lazy = true, + dependencies = { 'nvim-tree/nvim-web-devicons' }, + cmd = { 'DiffviewOpen', 'DiffviewClose', 'DiffviewFileHistory' }, + keys = { + { 'ds', 'DiffviewOpen --staged', desc = 'git [d]iff [s]taged' }, + { 'du', 'DiffviewOpen', desc = 'git [d]iff [u]nstaged' }, + { 'dh', 'DiffviewOpen HEAD', desc = 'git [d]iff [h]ead' }, + { 'dc', 'DiffviewClose', 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', 'DiffviewClose', { buffer = true, silent = true }) + end, + }, + }, +} diff --git a/lua/plugins/extras/git-ai-commit.lua b/lua/plugins/extras/git-ai-commit.lua new file mode 100644 index 0000000..c8c7364 --- /dev/null +++ b/lua/plugins/extras/git-ai-commit.lua @@ -0,0 +1,292 @@ +-- lua/plugins/extras/git-ai-commit.lua +-- AI-powered commit message generator using ThePrimeagen/99 + lazygit +-- +-- Flow: +-- 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([[ + +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 + + +%s + + +]], 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', '', do_accept, { buffer = buf, nowait = true, desc = 'Accept commit' }) + vim.keymap.set('n', '', do_cancel, { buffer = buf, nowait = true, desc = 'Cancel commit' }) + vim.keymap.set('i', '', 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', '9g', generate_and_commit, { + desc = 'AI: generate commit message + confirm', + }) + end, +} diff --git a/lua/plugins/extras/lazygit.lua b/lua/plugins/extras/lazygit.lua index 51fe0a1..7634df1 100644 --- a/lua/plugins/extras/lazygit.lua +++ b/lua/plugins/extras/lazygit.lua @@ -14,7 +14,6 @@ return { }, dependencies = { 'nvim-lua/plenary.nvim' }, keys = { - { 'lg', 'LazyGit', desc = 'LazyGit' }, - -- is also mapped in config/keymaps.lua + { '', 'LazyGit', desc = 'LazyGit' }, }, } diff --git a/lua/plugins/mago.lua b/lua/plugins/mago.lua new file mode 100644 index 0000000..083c6e5 --- /dev/null +++ b/lua/plugins/mago.lua @@ -0,0 +1,5 @@ +return { + 'calvinludwig/mago.nvim', + ft = 'php', + opts = {}, +} diff --git a/lua/plugins/telescope.lua b/lua/plugins/telescope.lua index dbe6ca0..96bab04 100644 --- a/lua/plugins/telescope.lua +++ b/lua/plugins/telescope.lua @@ -38,15 +38,8 @@ return { vim.keymap.set('n', 'sd', builtin.diagnostics, { desc = '[S]earch [D]iagnostics' }) vim.keymap.set('n', 'sr', builtin.resume, { desc = '[S]earch [R]esume' }) vim.keymap.set('n', 's.', builtin.oldfiles, { desc = '[S]earch Recent Files' }) - vim.keymap.set('n', '', builtin.buffers, { desc = '[ ] Find existing buffers' }) - - -- Fuzzy search inside current buffer - vim.keymap.set('n', '/', function() - builtin.current_buffer_fuzzy_find(require('telescope.themes').get_dropdown { - winblend = 10, - previewer = false, - }) - end, { desc = '[/] Fuzzily search in current buffer' }) + vim.keymap.set('n', '', builtin.live_grep, { desc = '[ ] Live grep (project)' }) + vim.keymap.set('n', '/', builtin.find_files, { desc = '[/] Find files' }) -- Grep only open files vim.keymap.set('n', 's/', function()