diff --git a/LICENSE.md b/LICENSE.md index 12dc00d1..5237e294 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -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 of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights 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: The above copyright notice and this permission notice shall be included in all 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 -SEPARATED HUNK #2: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -HUNK TEST #3: AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +ACCEPT TEST 3: Third hunk should still be visible +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. [EDIT 2: Middle change] IN NO EVENT SHALL THE +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, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. @@ -94,10 +97,18 @@ MULTI-EDIT TEST #3: Testing multiple hunks and navigation! 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! -## PERSISTENCE TEST -This tests our new stash-based persistence system that doesn't create git commits! +## INLINE DIFF SYSTEM V2 +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! diff --git a/lua/nvim-claude/hooks.lua b/lua/nvim-claude/hooks.lua index 6ad18b8e..d12d15ee 100644 --- a/lua/nvim-claude/hooks.lua +++ b/lua/nvim-claude/hooks.lua @@ -3,6 +3,7 @@ local M = {} -- Track hook state M.pre_edit_commit = nil +M.stable_baseline_ref = nil -- The stable baseline to compare all changes against function M.setup() -- Setup persistence layer on startup @@ -11,11 +12,22 @@ function M.setup() end, 500) 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() - -- Pre-hook no longer creates baselines - -- Baselines are created on startup or through accept/reject - return + local persistence = require 'nvim-claude.inline-diff-persistence' + + -- 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 -- 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 - -- Get the stash reference from pre-hook - local stash_ref = persistence.current_stash_ref - if not stash_ref then - -- If no pre-hook stash, create one now - stash_ref = persistence.create_stash('nvim-claude: changes detected ' .. os.date('%Y-%m-%d %H:%M:%S')) + -- Use the stable baseline reference for comparison + local stash_ref = M.stable_baseline_ref or persistence.current_stash_ref + + -- Check for in-memory baselines first + 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 end - if stash_ref then + if stash_ref or has_baselines then -- Check if any modified files are currently open in buffers - local inline_diff = require 'nvim-claude.inline-diff' local opened_inline = false 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 local buf_name = vim.api.nvim_buf_get_name(buf) if buf_name == full_path or buf_name:match('/' .. file:gsub('([^%w])', '%%%1') .. '$') then - -- Get the original content from stash - local stash_cmd = string.format('cd "%s" && git show %s:%s 2>/dev/null', git_root, stash_ref, file) - local original_content, orig_err = utils.exec(stash_cmd) + -- Get the original content - prefer in-memory baseline if available + local original_content = nil + + -- 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 not orig_err and original_content then + if original_content then -- Get current content local current_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) 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 inline_diff.show_inline_diff(buf, original_content, current_content) opened_inline = true @@ -187,7 +222,16 @@ function M.create_startup_baseline() persistence.setup_autocmds() -- 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 -- Manual hook testing @@ -337,6 +381,12 @@ function M.setup_commands() end, { 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() local bufnr = vim.api.nvim_get_current_buf() @@ -412,6 +462,26 @@ function M.setup_commands() end, { 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 -- Cleanup old temp files (no longer cleans up commits) diff --git a/lua/nvim-claude/init.lua b/lua/nvim-claude/init.lua index 55558319..54ebe408 100644 --- a/lua/nvim-claude/init.lua +++ b/lua/nvim-claude/init.lua @@ -121,6 +121,7 @@ function M.setup(user_config) M.registry = require('nvim-claude.registry') M.hooks = require('nvim-claude.hooks') M.diff_review = require('nvim-claude.diff-review') + M.settings_updater = require('nvim-claude.settings-updater') -- Initialize submodules with config M.tmux.setup(M.config.tmux) @@ -128,6 +129,7 @@ function M.setup(user_config) M.registry.setup(M.config.agents) M.hooks.setup() M.diff_review.setup() + M.settings_updater.setup() -- Set up commands M.commands.setup(M) @@ -137,7 +139,6 @@ function M.setup(user_config) vim.defer_fn(function() if M.utils.get_project_root() then M.hooks.install_hooks() - vim.notify('Claude Code hooks auto-installed', vim.log.levels.INFO) end end, 100) diff --git a/lua/nvim-claude/inline-diff-debug.lua b/lua/nvim-claude/inline-diff-debug.lua new file mode 100644 index 00000000..e5252904 --- /dev/null +++ b/lua/nvim-claude/inline-diff-debug.lua @@ -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 == '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('✗ 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('^') then + vim.notify(string.format(' %s -> %s', map.lhs, map.desc or 'no desc'), vim.log.levels.INFO) + end + end + end +end + +return M \ No newline at end of file diff --git a/lua/nvim-claude/inline-diff-persistence.lua b/lua/nvim-claude/inline-diff-persistence.lua index fae64ef2..33db2b68 100644 --- a/lua/nvim-claude/inline-diff-persistence.lua +++ b/lua/nvim-claude/inline-diff-persistence.lua @@ -142,7 +142,7 @@ function M.restore_diffs() end 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 -- Store the stash reference for future operations @@ -180,39 +180,31 @@ function M.check_pending_restore(bufnr) -- Remove from pending 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 -- Create a stash of current changes (instead of baseline commit) function M.create_stash(message) + local utils = require('nvim-claude.utils') message = message or 'nvim-claude: pre-edit state' - -- Check if there are changes to stash - local status = utils.exec('git status --porcelain') - if not status or status == '' then - -- 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 + -- Create a stash object without removing changes from working directory + local stash_cmd = 'git stash create' + local stash_hash, err = utils.exec(stash_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 + if not err and stash_hash and stash_hash ~= '' then + -- Store the stash with a message + stash_hash = stash_hash:gsub('%s+', '') -- trim whitespace + local store_cmd = string.format('git stash store -m "%s" %s', message, stash_hash) + 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 return nil diff --git a/lua/nvim-claude/inline-diff.lua b/lua/nvim-claude/inline-diff.lua index d24efae0..c9b2274a 100644 --- a/lua/nvim-claude/inline-diff.lua +++ b/lua/nvim-claude/inline-diff.lua @@ -40,6 +40,9 @@ function M.show_inline_diff(bufnr, old_content, new_content) 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 M.apply_diff_visualization(bufnr) @@ -49,7 +52,7 @@ function M.show_inline_diff(bufnr, old_content, new_content) -- Jump to first hunk M.jump_to_hunk(bufnr, 1) - vim.notify('Inline diff active. Use [h/]h to navigate, ia/ir to accept/reject hunks', vim.log.levels.INFO) + -- Silent activation - no notification end -- 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(new_file, new_text) - -- Generate unified diff with minimal context to avoid grouping nearby changes - local cmd = string.format('diff -U1 "%s" "%s" || true', old_file, new_file) + -- Use git diff with histogram algorithm for better code diffs + 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) -- 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 -- Diff 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 @@ -349,46 +358,51 @@ function M.accept_current_hunk(bufnr) local hunk = diff_data.hunks[diff_data.current_hunk] if not hunk then return end - -- Mark as applied (the changes are already in the buffer) - diff_data.applied_hunks[diff_data.current_hunk] = true + -- Mark this hunk as processed + 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 - local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local current_content = table.concat(current_lines, '\n') - M.original_content[bufnr] = current_content + -- For single hunk case + if #diff_data.hunks == 1 then + vim.notify('All changes accepted. Closing inline diff.', vim.log.levels.INFO) + 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) -- Adjust current hunk index 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 - 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 - -- 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 for future diffs + -- No more hunks + vim.notify('All changes accepted. Closing inline diff.', vim.log.levels.INFO) + M.close_inline_diff(bufnr, true) else - -- Refresh visualization and move to current hunk + -- Refresh visualization to show remaining hunks M.apply_diff_visualization(bufnr) 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 -- Reject current hunk function M.reject_current_hunk(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] - 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 M.revert_hunk_changes(bufnr, hunk) @@ -400,33 +414,29 @@ function M.reject_current_hunk(bufnr) 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_content = table.concat(current_lines, '\n') - M.original_content[bufnr] = current_content - - -- Remove this hunk from the diff data since it's rejected - table.remove(diff_data.hunks, diff_data.current_hunk) - - -- Adjust current hunk index - if diff_data.current_hunk > #diff_data.hunks then - diff_data.current_hunk = math.max(1, #diff_data.hunks) - end - vim.notify(string.format('Rejected hunk - %d hunks remaining', #diff_data.hunks), vim.log.levels.INFO) + -- Recalculate diff between current state (with rejected hunk) and original baseline + local new_diff_data = M.compute_diff(M.original_content[bufnr], current_content) - -- 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 + if not new_diff_data or #new_diff_data.hunks == 0 then + -- No more changes from baseline - close the diff + vim.notify('All changes processed. Closing inline diff.', vim.log.levels.INFO) + M.close_inline_diff(bufnr, false) 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.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 @@ -434,45 +444,119 @@ end function M.revert_hunk_changes(bufnr, hunk) -- Get current buffer lines local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local original_content = M.original_content[bufnr] - if not original_content then - vim.notify('No original content available for rejection', vim.log.levels.ERROR) - return - end + -- Extract the expected content from the hunk + local expected_lines = {} + local original_lines = {} - -- Split original content into lines - local original_lines = vim.split(original_content, '\n') + 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 - -- Build new lines by reverting this hunk - local new_lines = {} - local buffer_line = 1 - local applied = false + -- Find where this hunk actually is in the current buffer + -- 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 + + -- Include some context before and after for better matching + local context_before = {} + local context_after = {} + + -- Extract context from the diff + local in_changes = false + for i, diff_line in ipairs(hunk.lines) do + if diff_line:match('^[%+%-]') then + 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 - while buffer_line <= #lines do - if buffer_line >= hunk.new_start and buffer_line < hunk.new_start + hunk.new_count and not applied then - -- Revert this section by using original lines - local orig_start = hunk.old_start - local orig_end = hunk.old_start + hunk.old_count - 1 + -- Search for the hunk by matching content with context + for i = 1, #lines - #expected_lines + 1 do + 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 - for orig_line = orig_start, orig_end do - if orig_line <= #original_lines then - table.insert(new_lines, original_lines[orig_line]) + -- Bonus points for matching context after + local after_start = i + #expected_lines + if after_start + #context_after - 1 <= #lines then + for j = 1, #context_after do + if lines[after_start + j - 1] == context_after[j] then + score = score + 2 -- Context is worth more + end end end - -- Skip the modified lines in current buffer - buffer_line = hunk.new_start + hunk.new_count - applied = true - else - -- Copy unchanged line - if buffer_line <= #lines then - table.insert(new_lines, lines[buffer_line]) + -- Keep the best match + if score > best_score then + best_score = score + best_start = i end - buffer_line = buffer_line + 1 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 vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) end @@ -581,11 +665,15 @@ function M.close_inline_diff(bufnr, keep_baseline) 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 local persistence = require('nvim-claude.inline-diff-persistence') persistence.clear_state() persistence.current_stash_ref = nil + + -- Reset the stable baseline in hooks + local hooks = require('nvim-claude.hooks') + hooks.stable_baseline_ref = nil end vim.notify('Inline diff closed', vim.log.levels.INFO) diff --git a/lua/nvim-claude/settings-updater.lua b/lua/nvim-claude/settings-updater.lua new file mode 100644 index 00000000..6ba88229 --- /dev/null +++ b/lua/nvim-claude/settings-updater.lua @@ -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 ":lua require(\'nvim-claude.hooks\').post_tool_use_hook()"', + 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 \ No newline at end of file