inline diffs are pretty good now

This commit is contained in:
zolinthecow 2025-07-10 17:29:10 -07:00
parent 91c24b34a6
commit 720778cd2c
7 changed files with 455 additions and 124 deletions

View File

@ -1,19 +1,22 @@
MIT License - SEPARATED HUNK #1 DEBUG LOGGING: Check /tmp/claude-python-hook.log for debug output!
MIT License - ACCEPT TEST 1: Try accepting this first hunk
MULTI-EDIT TEST #1: Permission is hereby granted, free of charge, to any person obtaining a copy MULTI-EDIT TEST #1: Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
HUNK TEST #2: copies of the Software, and to permit persons to whom the Software is ACCEPT TEST 2: This should remain after accepting the first
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software. copies or substantial portions of the Software.
hi
MULTI-EDIT TEST #2: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR MULTI-EDIT TEST #2: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
SEPARATED HUNK #2: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ACCEPT TEST 3: Third hunk should still be visible
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. [EDIT 2: Middle change] IN NO EVENT SHALL THE
HUNK TEST #3: AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER TEST 3: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. [EDIT 2: Middle change] IN NO EVENT SHALL THE
TEST 2: Middle section edit - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
@ -94,10 +97,18 @@ MULTI-EDIT TEST #3: Testing multiple hunks and navigation!
TEST EDIT #2: Checking consistency of diff display! TEST EDIT #2: Checking consistency of diff display!
SEPARATED HUNK #4: Final test with better hunk separation! EDIT #2: Middle section replacement - this should be the second hunk!
MULTI-EDIT TEST #4: Testing hunk navigation with ]h and [h! MULTI-EDIT TEST #4: Testing hunk navigation with ]h and [h!
## PERSISTENCE TEST ## INLINE DIFF SYSTEM V2
This tests our new stash-based persistence system that doesn't create git commits! Our enhanced inline diff now uses git's histogram algorithm and proper stash-based baselines!
## TESTING HOOKS WITHOUT NOTIFICATIONS
The hooks now run silently without vim.notify interruptions!
EDIT #3: Bottom addition - this is the third hunk for testing!
EDIT 3: Final test line at the very bottom of the file!
ACCEPT TEST 4: Fourth and final hunk for testing!

View File

@ -3,6 +3,7 @@ local M = {}
-- Track hook state -- Track hook state
M.pre_edit_commit = nil M.pre_edit_commit = nil
M.stable_baseline_ref = nil -- The stable baseline to compare all changes against
function M.setup() function M.setup()
-- Setup persistence layer on startup -- Setup persistence layer on startup
@ -11,11 +12,22 @@ function M.setup()
end, 500) end, 500)
end end
-- Pre-tool-use hook: Now just validates existing baseline -- Pre-tool-use hook: Create baseline stash if we don't have one
function M.pre_tool_use_hook() function M.pre_tool_use_hook()
-- Pre-hook no longer creates baselines local persistence = require 'nvim-claude.inline-diff-persistence'
-- Baselines are created on startup or through accept/reject
return -- Only create a baseline if we don't have one yet
if not M.stable_baseline_ref then
-- Create baseline stash synchronously
local stash_ref = persistence.create_stash('nvim-claude: baseline ' .. os.date('%Y-%m-%d %H:%M:%S'))
if stash_ref then
M.stable_baseline_ref = stash_ref
persistence.current_stash_ref = stash_ref
end
end
-- Return success to allow the tool to proceed
return true
end end
-- Post-tool-use hook: Create stash of Claude's changes and trigger diff review -- Post-tool-use hook: Create stash of Claude's changes and trigger diff review
@ -49,17 +61,28 @@ function M.post_tool_use_hook()
end end
end end
-- Get the stash reference from pre-hook -- Use the stable baseline reference for comparison
local stash_ref = persistence.current_stash_ref local stash_ref = M.stable_baseline_ref or persistence.current_stash_ref
if not stash_ref then
-- If no pre-hook stash, create one now -- Check for in-memory baselines first
stash_ref = persistence.create_stash('nvim-claude: changes detected ' .. os.date('%Y-%m-%d %H:%M:%S')) local inline_diff = require 'nvim-claude.inline-diff'
local has_baselines = false
for _, content in pairs(inline_diff.original_content) do
if content then
has_baselines = true
break
end
end
-- If no baseline exists at all, create one now (shouldn't happen normally)
if not stash_ref and not has_baselines then
stash_ref = persistence.create_stash('nvim-claude: baseline ' .. os.date('%Y-%m-%d %H:%M:%S'))
M.stable_baseline_ref = stash_ref
persistence.current_stash_ref = stash_ref persistence.current_stash_ref = stash_ref
end end
if stash_ref then if stash_ref or has_baselines then
-- Check if any modified files are currently open in buffers -- Check if any modified files are currently open in buffers
local inline_diff = require 'nvim-claude.inline-diff'
local opened_inline = false local opened_inline = false
for _, file in ipairs(modified_files) do for _, file in ipairs(modified_files) do
@ -70,15 +93,27 @@ function M.post_tool_use_hook()
if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then
local buf_name = vim.api.nvim_buf_get_name(buf) local buf_name = vim.api.nvim_buf_get_name(buf)
if buf_name == full_path or buf_name:match('/' .. file:gsub('([^%w])', '%%%1') .. '$') then if buf_name == full_path or buf_name:match('/' .. file:gsub('([^%w])', '%%%1') .. '$') then
-- Get the original content from stash -- Get the original content - prefer in-memory baseline if available
local stash_cmd = string.format('cd "%s" && git show %s:%s 2>/dev/null', git_root, stash_ref, file) local original_content = nil
local original_content, orig_err = utils.exec(stash_cmd)
if not orig_err and original_content then -- Check for in-memory baseline first
if inline_diff.original_content[buf] then
original_content = inline_diff.original_content[buf]
elseif stash_ref then
-- Fall back to stash baseline
local stash_cmd = string.format('cd "%s" && git show %s:%s 2>/dev/null', git_root, stash_ref, file)
original_content = utils.exec(stash_cmd)
end
if original_content then
-- Get current content -- Get current content
local current_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local current_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local current_content = table.concat(current_lines, '\n') local current_content = table.concat(current_lines, '\n')
-- Debug: Log content lengths
-- vim.notify(string.format('DEBUG: Baseline has %d chars, current has %d chars',
-- #(original_content or ''), #current_content), vim.log.levels.WARN)
-- Show inline diff -- Show inline diff
inline_diff.show_inline_diff(buf, original_content, current_content) inline_diff.show_inline_diff(buf, original_content, current_content)
opened_inline = true opened_inline = true
@ -187,7 +222,16 @@ function M.create_startup_baseline()
persistence.setup_autocmds() persistence.setup_autocmds()
-- Try to restore any saved diffs -- Try to restore any saved diffs
persistence.restore_diffs() local restored = persistence.restore_diffs()
-- If no diffs were restored and we don't have a baseline, create one now
if not restored and not M.stable_baseline_ref then
local stash_ref = persistence.create_stash('nvim-claude: startup baseline ' .. os.date('%Y-%m-%d %H:%M:%S'))
if stash_ref then
M.stable_baseline_ref = stash_ref
persistence.current_stash_ref = stash_ref
end
end
end end
-- Manual hook testing -- Manual hook testing
@ -338,6 +382,12 @@ function M.setup_commands()
desc = 'Test Claude keymap functionality', desc = 'Test Claude keymap functionality',
}) })
vim.api.nvim_create_user_command('ClaudeDebugInlineDiff', function()
require('nvim-claude.inline-diff-debug').debug_inline_diff()
end, {
desc = 'Debug Claude inline diff state',
})
vim.api.nvim_create_user_command('ClaudeUpdateBaseline', function() vim.api.nvim_create_user_command('ClaudeUpdateBaseline', function()
local bufnr = vim.api.nvim_get_current_buf() local bufnr = vim.api.nvim_get_current_buf()
local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
@ -412,6 +462,26 @@ function M.setup_commands()
end, { end, {
desc = 'Uninstall Claude Code hooks for this project', desc = 'Uninstall Claude Code hooks for this project',
}) })
vim.api.nvim_create_user_command('ClaudeResetBaseline', function()
-- Clear all baselines and force new baseline on next edit
local inline_diff = require 'nvim-claude.inline-diff'
local persistence = require 'nvim-claude.inline-diff-persistence'
-- Clear in-memory baselines
inline_diff.original_content = {}
-- Clear stable baseline reference
M.stable_baseline_ref = nil
persistence.current_stash_ref = nil
-- Clear persistence state
persistence.clear_state()
vim.notify('Baseline reset. Next edit will create a new baseline.', vim.log.levels.INFO)
end, {
desc = 'Reset Claude baseline for cumulative diffs',
})
end end
-- Cleanup old temp files (no longer cleans up commits) -- Cleanup old temp files (no longer cleans up commits)

View File

@ -121,6 +121,7 @@ function M.setup(user_config)
M.registry = require('nvim-claude.registry') M.registry = require('nvim-claude.registry')
M.hooks = require('nvim-claude.hooks') M.hooks = require('nvim-claude.hooks')
M.diff_review = require('nvim-claude.diff-review') M.diff_review = require('nvim-claude.diff-review')
M.settings_updater = require('nvim-claude.settings-updater')
-- Initialize submodules with config -- Initialize submodules with config
M.tmux.setup(M.config.tmux) M.tmux.setup(M.config.tmux)
@ -128,6 +129,7 @@ function M.setup(user_config)
M.registry.setup(M.config.agents) M.registry.setup(M.config.agents)
M.hooks.setup() M.hooks.setup()
M.diff_review.setup() M.diff_review.setup()
M.settings_updater.setup()
-- Set up commands -- Set up commands
M.commands.setup(M) M.commands.setup(M)
@ -137,7 +139,6 @@ function M.setup(user_config)
vim.defer_fn(function() vim.defer_fn(function()
if M.utils.get_project_root() then if M.utils.get_project_root() then
M.hooks.install_hooks() M.hooks.install_hooks()
vim.notify('Claude Code hooks auto-installed', vim.log.levels.INFO)
end end
end, 100) end, 100)

View File

@ -0,0 +1,57 @@
local M = {}
-- Debug function to check inline diff state
function M.debug_inline_diff()
local inline_diff = require('nvim-claude.inline-diff')
local bufnr = vim.api.nvim_get_current_buf()
vim.notify('=== Inline Diff Debug Info ===', vim.log.levels.INFO)
-- Check if inline diff is active for current buffer
local diff_data = inline_diff.active_diffs[bufnr]
if diff_data then
vim.notify(string.format('✓ Inline diff ACTIVE for buffer %d', bufnr), vim.log.levels.INFO)
vim.notify(string.format(' - Hunks: %d', #diff_data.hunks), vim.log.levels.INFO)
vim.notify(string.format(' - Current hunk: %d', diff_data.current_hunk or 0), vim.log.levels.INFO)
vim.notify(string.format(' - Original content length: %d', #(inline_diff.original_content[bufnr] or '')), vim.log.levels.INFO)
vim.notify(string.format(' - New content length: %d', #(diff_data.new_content or '')), vim.log.levels.INFO)
else
vim.notify(string.format('✗ No inline diff for buffer %d', bufnr), vim.log.levels.WARN)
end
-- Check all active diffs
local count = 0
for buf, _ in pairs(inline_diff.active_diffs) do
count = count + 1
end
vim.notify(string.format('Total active inline diffs: %d', count), vim.log.levels.INFO)
-- Check keymaps
local keymaps = vim.api.nvim_buf_get_keymap(bufnr, 'n')
local found_ir = false
local leader = vim.g.mapleader or '\\'
local ir_pattern = leader .. 'ir'
vim.notify(string.format('Looking for keymap: %s', ir_pattern), vim.log.levels.INFO)
for _, map in ipairs(keymaps) do
if map.lhs == ir_pattern or map.lhs == '<leader>ir' then
found_ir = true
vim.notify(string.format('✓ Found keymap: %s -> %s', map.lhs, map.desc or 'no desc'), vim.log.levels.INFO)
break
end
end
if not found_ir then
vim.notify('✗ <leader>ir keymap not found', vim.log.levels.WARN)
-- List all keymaps that start with leader
vim.notify('Buffer keymaps starting with leader:', vim.log.levels.INFO)
for _, map in ipairs(keymaps) do
if map.lhs:match('^' .. vim.pesc(leader)) or map.lhs:match('^<leader>') then
vim.notify(string.format(' %s -> %s', map.lhs, map.desc or 'no desc'), vim.log.levels.INFO)
end
end
end
end
return M

View File

@ -142,7 +142,7 @@ function M.restore_diffs()
end end
if restored_count > 0 then if restored_count > 0 then
vim.notify(string.format('Restored inline diffs for %d file(s)', restored_count), vim.log.levels.INFO) -- Silent restore - no notification
end end
-- Store the stash reference for future operations -- Store the stash reference for future operations
@ -180,39 +180,31 @@ function M.check_pending_restore(bufnr)
-- Remove from pending -- Remove from pending
M.pending_restores[file_path] = nil M.pending_restores[file_path] = nil
vim.notify('Restored inline diff for ' .. vim.fn.fnamemodify(file_path, ':~:.'), vim.log.levels.INFO) -- Silent restore - no notification
end end
end end
-- Create a stash of current changes (instead of baseline commit) -- Create a stash of current changes (instead of baseline commit)
function M.create_stash(message) function M.create_stash(message)
local utils = require('nvim-claude.utils')
message = message or 'nvim-claude: pre-edit state' message = message or 'nvim-claude: pre-edit state'
-- Check if there are changes to stash -- Create a stash object without removing changes from working directory
local status = utils.exec('git status --porcelain') local stash_cmd = 'git stash create'
if not status or status == '' then local stash_hash, err = utils.exec(stash_cmd)
-- No changes, but we still need to track current state
-- Create an empty stash by making a tiny change
local temp_file = '.nvim-claude-temp'
utils.write_file(temp_file, 'temp')
utils.exec('git add ' .. temp_file)
utils.exec(string.format('git stash push -m "%s" -- %s', message, temp_file))
os.remove(temp_file)
else
-- Stash all current changes
local cmd = string.format('git stash push -m "%s" --include-untracked', message)
local result, err = utils.exec(cmd)
if err and not err:match('Saved working directory') then
vim.notify('Failed to create stash: ' .. err, vim.log.levels.ERROR)
return nil
end
end
-- Get the stash reference if not err and stash_hash and stash_hash ~= '' then
local stash_list = utils.exec('git stash list -n 1') -- Store the stash with a message
if stash_list then stash_hash = stash_hash:gsub('%s+', '') -- trim whitespace
local stash_ref = stash_list:match('^(stash@{%d+})') local store_cmd = string.format('git stash store -m "%s" %s', message, stash_hash)
return stash_ref utils.exec(store_cmd)
-- Get the stash reference
local stash_list = utils.exec('git stash list -n 1')
if stash_list then
local stash_ref = stash_list:match('^(stash@{%d+})')
return stash_ref
end
end end
return nil return nil

View File

@ -40,6 +40,9 @@ function M.show_inline_diff(bufnr, old_content, new_content)
applied_hunks = {} applied_hunks = {}
} }
-- Debug: Log target content length
-- vim.notify(string.format('DEBUG: Stored target content with %d chars', #new_content), vim.log.levels.WARN)
-- Apply visual indicators -- Apply visual indicators
M.apply_diff_visualization(bufnr) M.apply_diff_visualization(bufnr)
@ -49,7 +52,7 @@ function M.show_inline_diff(bufnr, old_content, new_content)
-- Jump to first hunk -- Jump to first hunk
M.jump_to_hunk(bufnr, 1) M.jump_to_hunk(bufnr, 1)
vim.notify('Inline diff active. Use [h/]h to navigate, <leader>ia/<leader>ir to accept/reject hunks', vim.log.levels.INFO) -- Silent activation - no notification
end end
-- Compute diff between two texts -- Compute diff between two texts
@ -63,8 +66,11 @@ function M.compute_diff(old_text, new_text)
utils.write_file(old_file, old_text) utils.write_file(old_file, old_text)
utils.write_file(new_file, new_text) utils.write_file(new_file, new_text)
-- Generate unified diff with minimal context to avoid grouping nearby changes -- Use git diff with histogram algorithm for better code diffs
local cmd = string.format('diff -U1 "%s" "%s" || true', old_file, new_file) local cmd = string.format(
'git diff --no-index --no-prefix --unified=1 --diff-algorithm=histogram "%s" "%s" 2>/dev/null || true',
old_file, new_file
)
local diff_output = utils.exec(cmd) local diff_output = utils.exec(cmd)
-- Parse diff into hunks -- Parse diff into hunks
@ -101,6 +107,9 @@ function M.parse_diff(diff_text)
elseif in_hunk and (line:match('^[%+%-]') or line:match('^%s')) then elseif in_hunk and (line:match('^[%+%-]') or line:match('^%s')) then
-- Diff line -- Diff line
table.insert(current_hunk.lines, line) table.insert(current_hunk.lines, line)
elseif line:match('^diff %-%-git') or line:match('^index ') or line:match('^%+%+%+ ') or line:match('^%-%-%-') then
-- Skip git diff headers
in_hunk = false
end end
end end
@ -349,46 +358,51 @@ function M.accept_current_hunk(bufnr)
local hunk = diff_data.hunks[diff_data.current_hunk] local hunk = diff_data.hunks[diff_data.current_hunk]
if not hunk then return end if not hunk then return end
-- Mark as applied (the changes are already in the buffer) -- Mark this hunk as processed
diff_data.applied_hunks[diff_data.current_hunk] = true vim.notify(string.format('Accepted hunk %d/%d', diff_data.current_hunk, #diff_data.hunks), vim.log.levels.INFO)
-- Update in-memory baseline to current state -- For single hunk case
local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) if #diff_data.hunks == 1 then
local current_content = table.concat(current_lines, '\n') vim.notify('All changes accepted. Closing inline diff.', vim.log.levels.INFO)
M.original_content[bufnr] = current_content M.close_inline_diff(bufnr, true) -- Keep new baseline
return
end
-- Remove this hunk from the diff data since it's accepted -- Multiple hunks: remove the accepted hunk and continue
table.remove(diff_data.hunks, diff_data.current_hunk) table.remove(diff_data.hunks, diff_data.current_hunk)
-- Adjust current hunk index -- Adjust current hunk index
if diff_data.current_hunk > #diff_data.hunks then if diff_data.current_hunk > #diff_data.hunks then
diff_data.current_hunk = math.max(1, #diff_data.hunks) diff_data.current_hunk = #diff_data.hunks
end end
vim.notify(string.format('Accepted hunk - %d hunks remaining', #diff_data.hunks), vim.log.levels.INFO)
-- Save state for persistence
local persistence = require('nvim-claude.inline-diff-persistence')
persistence.save_state({ stash_ref = persistence.current_stash_ref })
if #diff_data.hunks == 0 then if #diff_data.hunks == 0 then
-- No more hunks to review -- No more hunks
vim.notify('All hunks processed! Closing inline diff.', vim.log.levels.INFO) vim.notify('All changes accepted. Closing inline diff.', vim.log.levels.INFO)
M.close_inline_diff(bufnr, true) -- Keep baseline for future diffs M.close_inline_diff(bufnr, true)
else else
-- Refresh visualization and move to current hunk -- Refresh visualization to show remaining hunks
M.apply_diff_visualization(bufnr) M.apply_diff_visualization(bufnr)
M.jump_to_hunk(bufnr, diff_data.current_hunk) M.jump_to_hunk(bufnr, diff_data.current_hunk)
vim.notify(string.format('%d hunks remaining', #diff_data.hunks), vim.log.levels.INFO)
end end
end end
-- Reject current hunk -- Reject current hunk
function M.reject_current_hunk(bufnr) function M.reject_current_hunk(bufnr)
local diff_data = M.active_diffs[bufnr] local diff_data = M.active_diffs[bufnr]
if not diff_data then return end if not diff_data then
vim.notify('No diff data for buffer', vim.log.levels.ERROR)
return
end
local hunk = diff_data.hunks[diff_data.current_hunk] local hunk = diff_data.hunks[diff_data.current_hunk]
if not hunk then return end if not hunk then
vim.notify('No hunk at index ' .. tostring(diff_data.current_hunk), vim.log.levels.ERROR)
return
end
-- vim.notify(string.format('Rejecting hunk %d/%d', diff_data.current_hunk, #diff_data.hunks), vim.log.levels.INFO)
-- Revert the hunk by applying original content -- Revert the hunk by applying original content
M.revert_hunk_changes(bufnr, hunk) M.revert_hunk_changes(bufnr, hunk)
@ -400,33 +414,29 @@ function M.reject_current_hunk(bufnr)
end end
end) end)
-- Update in-memory baseline to current state (with rejected changes) -- Get current content after rejection
local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local current_content = table.concat(current_lines, '\n') local current_content = table.concat(current_lines, '\n')
M.original_content[bufnr] = current_content
-- Remove this hunk from the diff data since it's rejected -- Recalculate diff between current state (with rejected hunk) and original baseline
table.remove(diff_data.hunks, diff_data.current_hunk) local new_diff_data = M.compute_diff(M.original_content[bufnr], current_content)
-- Adjust current hunk index if not new_diff_data or #new_diff_data.hunks == 0 then
if diff_data.current_hunk > #diff_data.hunks then -- No more changes from baseline - close the diff
diff_data.current_hunk = math.max(1, #diff_data.hunks) vim.notify('All changes processed. Closing inline diff.', vim.log.levels.INFO)
end M.close_inline_diff(bufnr, false)
vim.notify(string.format('Rejected hunk - %d hunks remaining', #diff_data.hunks), vim.log.levels.INFO)
-- Save state for persistence
local persistence = require('nvim-claude.inline-diff-persistence')
persistence.save_state({ stash_ref = persistence.current_stash_ref })
if #diff_data.hunks == 0 then
-- No more hunks to review
vim.notify('All hunks processed! Closing inline diff.', vim.log.levels.INFO)
M.close_inline_diff(bufnr, true) -- Keep baseline after rejection too
else else
-- Refresh visualization and move to current hunk -- Update diff data with remaining hunks
diff_data.hunks = new_diff_data.hunks
diff_data.current_hunk = 1
-- The new_content should remain as Claude's original suggestion
-- so we can continue to accept remaining hunks if desired
-- Refresh visualization and jump to first remaining hunk
M.apply_diff_visualization(bufnr) M.apply_diff_visualization(bufnr)
M.jump_to_hunk(bufnr, diff_data.current_hunk) M.jump_to_hunk(bufnr, 1)
vim.notify(string.format('%d hunks remaining', #diff_data.hunks), vim.log.levels.INFO)
end end
end end
@ -434,45 +444,119 @@ end
function M.revert_hunk_changes(bufnr, hunk) function M.revert_hunk_changes(bufnr, hunk)
-- Get current buffer lines -- Get current buffer lines
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local original_content = M.original_content[bufnr]
if not original_content then -- Extract the expected content from the hunk
vim.notify('No original content available for rejection', vim.log.levels.ERROR) local expected_lines = {}
return local original_lines = {}
for _, diff_line in ipairs(hunk.lines) do
if diff_line:match('^%+') then
-- Lines that were added (these should be in current buffer)
table.insert(expected_lines, diff_line:sub(2))
elseif diff_line:match('^%-') then
-- Lines that were removed (these should be restored)
table.insert(original_lines, diff_line:sub(2))
elseif diff_line:match('^%s') then
-- Context lines (should be in both)
table.insert(expected_lines, diff_line:sub(2))
table.insert(original_lines, diff_line:sub(2))
end
end end
-- Split original content into lines -- Find where this hunk actually is in the current buffer
local original_lines = vim.split(original_content, '\n') -- We'll look for the best match by checking context lines too
local hunk_start = nil
local hunk_end = nil
local best_score = -1
local best_start = nil
-- Build new lines by reverting this hunk -- Include some context before and after for better matching
local new_lines = {} local context_before = {}
local buffer_line = 1 local context_after = {}
local applied = false
while buffer_line <= #lines do -- Extract context from the diff
if buffer_line >= hunk.new_start and buffer_line < hunk.new_start + hunk.new_count and not applied then local in_changes = false
-- Revert this section by using original lines for i, diff_line in ipairs(hunk.lines) do
local orig_start = hunk.old_start if diff_line:match('^[%+%-]') then
local orig_end = hunk.old_start + hunk.old_count - 1 in_changes = true
elseif diff_line:match('^%s') and not in_changes then
-- Context before changes
table.insert(context_before, diff_line:sub(2))
elseif diff_line:match('^%s') and in_changes then
-- Context after changes
table.insert(context_after, diff_line:sub(2))
end
end
for orig_line = orig_start, orig_end do -- Search for the hunk by matching content with context
if orig_line <= #original_lines then for i = 1, #lines - #expected_lines + 1 do
table.insert(new_lines, original_lines[orig_line]) local score = 0
local matches = true
-- Check the main content
for j = 1, #expected_lines do
if lines[i + j - 1] == expected_lines[j] then
score = score + 1
else
matches = false
end
end
if matches then
-- Bonus points for matching context before
local before_start = i - #context_before
if before_start > 0 then
for j = 1, #context_before do
if lines[before_start + j - 1] == context_before[j] then
score = score + 2 -- Context is worth more
end
end end
end end
-- Skip the modified lines in current buffer -- Bonus points for matching context after
buffer_line = hunk.new_start + hunk.new_count local after_start = i + #expected_lines
applied = true if after_start + #context_after - 1 <= #lines then
else for j = 1, #context_after do
-- Copy unchanged line if lines[after_start + j - 1] == context_after[j] then
if buffer_line <= #lines then score = score + 2 -- Context is worth more
table.insert(new_lines, lines[buffer_line]) end
end
end
-- Keep the best match
if score > best_score then
best_score = score
best_start = i
end end
buffer_line = buffer_line + 1
end end
end end
if best_start then
hunk_start = best_start
hunk_end = best_start + #expected_lines - 1
else
vim.notify('Could not find hunk in current buffer - content may have changed', vim.log.levels.ERROR)
return
end
-- Build new buffer content
local new_lines = {}
-- Copy lines before the hunk
for i = 1, hunk_start - 1 do
table.insert(new_lines, lines[i])
end
-- Insert the original lines
for _, line in ipairs(original_lines) do
table.insert(new_lines, line)
end
-- Copy lines after the hunk
for i = hunk_end + 1, #lines do
table.insert(new_lines, lines[i])
end
-- Update buffer -- Update buffer
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines)
end end
@ -581,11 +665,15 @@ function M.close_inline_diff(bufnr, keep_baseline)
end end
end end
-- If no more active diffs, clear persistence state -- If no more active diffs, clear persistence state and reset baseline
if not has_active_diffs then if not has_active_diffs then
local persistence = require('nvim-claude.inline-diff-persistence') local persistence = require('nvim-claude.inline-diff-persistence')
persistence.clear_state() persistence.clear_state()
persistence.current_stash_ref = nil persistence.current_stash_ref = nil
-- Reset the stable baseline in hooks
local hooks = require('nvim-claude.hooks')
hooks.stable_baseline_ref = nil
end end
vim.notify('Inline diff closed', vim.log.levels.INFO) vim.notify('Inline diff closed', vim.log.levels.INFO)

View File

@ -0,0 +1,112 @@
local M = {}
local utils = require('nvim-claude.utils')
-- Update Claude settings with current Neovim server address
function M.update_claude_settings()
local project_root = utils.get_project_root()
if not project_root then
return
end
local settings_path = project_root .. '/.claude/settings.json'
local settings_dir = project_root .. '/.claude'
-- Get current Neovim server address
local server_addr = vim.v.servername
if not server_addr or server_addr == '' then
-- If no servername, we can't communicate
return
end
-- Ensure .claude directory exists
if vim.fn.isdirectory(settings_dir) == 0 then
vim.fn.mkdir(settings_dir, 'p')
end
-- Read existing settings or create new
local settings = {}
if vim.fn.filereadable(settings_path) == 1 then
local ok, content = pcall(vim.fn.readfile, settings_path)
if ok and #content > 0 then
local decode_ok, decoded = pcall(vim.json.decode, table.concat(content, '\n'))
if decode_ok then
settings = decoded
end
end
end
-- Ensure hooks structure exists
if not settings.hooks then
settings.hooks = {}
end
if not settings.hooks.PreToolUse then
settings.hooks.PreToolUse = {}
end
if not settings.hooks.PostToolUse then
settings.hooks.PostToolUse = {}
end
-- Update hook commands with current server address
local pre_hook_cmd = string.format(
'nvr --servername "%s" --remote-expr \'luaeval("require(\\"nvim-claude.hooks\\").pre_tool_use_hook()")\'',
server_addr
)
local post_hook_cmd = string.format(
'nvr --servername "%s" --remote-send "<C-\\\\><C-N>:lua require(\'nvim-claude.hooks\').post_tool_use_hook()<CR>"',
server_addr
)
-- Update PreToolUse hooks
local pre_hook_found = false
for _, hook_group in ipairs(settings.hooks.PreToolUse) do
if hook_group.matcher == "Edit|Write|MultiEdit" then
hook_group.hooks = {{type = "command", command = pre_hook_cmd}}
pre_hook_found = true
break
end
end
if not pre_hook_found then
table.insert(settings.hooks.PreToolUse, {
matcher = "Edit|Write|MultiEdit",
hooks = {{type = "command", command = pre_hook_cmd}}
})
end
-- Update PostToolUse hooks
local post_hook_found = false
for _, hook_group in ipairs(settings.hooks.PostToolUse) do
if hook_group.matcher == "Edit|Write|MultiEdit" then
hook_group.hooks = {{type = "command", command = post_hook_cmd}}
post_hook_found = true
break
end
end
if not post_hook_found then
table.insert(settings.hooks.PostToolUse, {
matcher = "Edit|Write|MultiEdit",
hooks = {{type = "command", command = post_hook_cmd}}
})
end
-- Write updated settings
local encoded = vim.json.encode(settings)
vim.fn.writefile({encoded}, settings_path)
end
-- Setup autocmds to update settings
function M.setup()
vim.api.nvim_create_autocmd({"VimEnter", "DirChanged"}, {
group = vim.api.nvim_create_augroup("NvimClaudeSettingsUpdater", { clear = true }),
callback = function()
-- Defer to ensure servername is available
vim.defer_fn(function()
M.update_claude_settings()
end, 100)
end,
})
end
return M