Skip to content

Commit 20708e7

Browse files
committed
feat: add new file support to diff system
Enable diff tool to handle file creation by displaying empty buffer comparison for non-existent files instead of erroring. Includes automatic parent directory creation and updated test expectations. Change-Id: I0032117b04309c63b605e21390083abb9ec741b2 Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent aa4f4ca commit 20708e7

File tree

3 files changed

+134
-49
lines changed

3 files changed

+134
-49
lines changed

lua/claudecode/diff.lua

Lines changed: 102 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,26 @@ function M._apply_accepted_changes(diff_data, final_content)
408408

409409
require("claudecode.logger").debug("diff", "Writing accepted changes to file:", old_file_path)
410410

411+
-- Ensure parent directories exist for new files
412+
if diff_data.is_new_file then
413+
local parent_dir = vim.fn.fnamemodify(old_file_path, ":h")
414+
if parent_dir and parent_dir ~= "" and parent_dir ~= "." then
415+
require("claudecode.logger").debug("diff", "Creating parent directories for new file:", parent_dir)
416+
local mkdir_success, mkdir_err = pcall(vim.fn.mkdir, parent_dir, "p")
417+
if not mkdir_success then
418+
require("claudecode.logger").error(
419+
"diff",
420+
"Failed to create parent directories:",
421+
parent_dir,
422+
"error:",
423+
mkdir_err
424+
)
425+
return
426+
end
427+
require("claudecode.logger").debug("diff", "Successfully created parent directories:", parent_dir)
428+
end
429+
end
430+
411431
-- Write the content to the actual file
412432
local lines = vim.split(final_content, "\n")
413433
local success, err = pcall(vim.fn.writefile, lines, old_file_path)
@@ -581,8 +601,9 @@ end
581601
-- @param old_file_path string Path to the original file
582602
-- @param new_buffer number New file buffer ID
583603
-- @param tab_name string The diff identifier
604+
-- @param is_new_file boolean Whether this is a new file (doesn't exist yet)
584605
-- @return table Info about the created diff layout
585-
function M._create_diff_view_from_window(target_window, old_file_path, new_buffer, tab_name)
606+
function M._create_diff_view_from_window(target_window, old_file_path, new_buffer, tab_name, is_new_file)
586607
require("claudecode.logger").debug("diff", "Creating diff view from window", target_window)
587608

588609
-- If no target window provided, create a new window in suitable location
@@ -608,16 +629,36 @@ function M._create_diff_view_from_window(target_window, old_file_path, new_buffe
608629
vim.api.nvim_set_current_win(target_window)
609630
end
610631

611-
-- Make sure the window shows the file we want to diff
612-
-- This handles the case where the buffer exists but isn't in the current window
613-
vim.cmd("edit " .. vim.fn.fnameescape(old_file_path))
614-
615-
-- Store the original buffer for later
616-
local original_buffer = vim.api.nvim_win_get_buf(target_window)
632+
-- Handle the left side of the diff (original file or empty for new files)
633+
local original_buffer
634+
if is_new_file then
635+
-- Create an empty buffer for new file comparison
636+
require("claudecode.logger").debug("diff", "Creating empty buffer for new file diff")
637+
local empty_buffer = vim.api.nvim_create_buf(false, true) -- unlisted, scratch
638+
vim.api.nvim_buf_set_name(empty_buffer, old_file_path .. " (NEW FILE)")
639+
vim.api.nvim_buf_set_lines(empty_buffer, 0, -1, false, {}) -- Empty content
640+
vim.api.nvim_buf_set_option(empty_buffer, "buftype", "nofile")
641+
vim.api.nvim_buf_set_option(empty_buffer, "modifiable", false)
642+
vim.api.nvim_buf_set_option(empty_buffer, "readonly", true)
643+
644+
-- Set the empty buffer in the target window
645+
vim.api.nvim_win_set_buf(target_window, empty_buffer)
646+
original_buffer = empty_buffer
647+
else
648+
-- Make sure the window shows the existing file we want to diff
649+
vim.cmd("edit " .. vim.fn.fnameescape(old_file_path))
650+
original_buffer = vim.api.nvim_win_get_buf(target_window)
651+
end
617652

618-
-- Enable diff mode on the original file
653+
-- Enable diff mode on the original/empty file
619654
vim.cmd("diffthis")
620-
require("claudecode.logger").debug("diff", "Enabled diff mode on original file in window", target_window)
655+
require("claudecode.logger").debug(
656+
"diff",
657+
"Enabled diff mode on",
658+
is_new_file and "empty buffer" or "original file",
659+
"in window",
660+
target_window
661+
)
621662

622663
-- Create vertical split for new buffer (proposed changes)
623664
vim.cmd("vsplit")
@@ -647,6 +688,14 @@ function M._create_diff_view_from_window(target_window, old_file_path, new_buffe
647688
-- Accept all changes
648689
local new_content = vim.api.nvim_buf_get_lines(new_buffer, 0, -1, false)
649690

691+
-- Ensure parent directories exist for new files
692+
if is_new_file then
693+
local parent_dir = vim.fn.fnamemodify(old_file_path, ":h")
694+
if parent_dir and parent_dir ~= "" and parent_dir ~= "." then
695+
vim.fn.mkdir(parent_dir, "p")
696+
end
697+
end
698+
650699
-- Write to file
651700
vim.fn.writefile(new_content, old_file_path)
652701

@@ -747,41 +796,49 @@ function M._setup_blocking_diff(params, resolution_callback)
747796
params.old_file_path
748797
)
749798

750-
-- Step 1: Check if the file exists
799+
-- Step 1: Check if the file exists (allow new files)
751800
local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1
752-
if not old_file_exists then
753-
error({
754-
code = -32000,
755-
message = "File access error",
756-
data = "Cannot open file: " .. params.old_file_path .. " (file does not exist)",
757-
})
758-
end
801+
local is_new_file = not old_file_exists
759802

760-
-- Step 2: Find if the file is already open in a buffer
803+
require("claudecode.logger").debug(
804+
"diff",
805+
"File existence check - old_file_exists:",
806+
old_file_exists,
807+
"is_new_file:",
808+
is_new_file,
809+
"path:",
810+
params.old_file_path
811+
)
812+
813+
-- Step 2: Find if the file is already open in a buffer (only for existing files)
761814
local existing_buffer = nil
762815
local target_window = nil
763816

764-
-- Look for existing buffer with this file
765-
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
766-
if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then
767-
local buf_name = vim.api.nvim_buf_get_name(buf)
768-
if buf_name == params.old_file_path then
769-
existing_buffer = buf
770-
require("claudecode.logger").debug("diff", "Found existing buffer", buf, "for file", params.old_file_path)
771-
break
817+
if old_file_exists then
818+
-- Look for existing buffer with this file
819+
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
820+
if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then
821+
local buf_name = vim.api.nvim_buf_get_name(buf)
822+
if buf_name == params.old_file_path then
823+
existing_buffer = buf
824+
require("claudecode.logger").debug("diff", "Found existing buffer", buf, "for file", params.old_file_path)
825+
break
826+
end
772827
end
773828
end
774-
end
775829

776-
-- Find window containing this buffer (if any)
777-
if existing_buffer then
778-
for _, win in ipairs(vim.api.nvim_list_wins()) do
779-
if vim.api.nvim_win_get_buf(win) == existing_buffer then
780-
target_window = win
781-
require("claudecode.logger").debug("diff", "Found window", win, "containing buffer", existing_buffer)
782-
break
830+
-- Find window containing this buffer (if any)
831+
if existing_buffer then
832+
for _, win in ipairs(vim.api.nvim_list_wins()) do
833+
if vim.api.nvim_win_get_buf(win) == existing_buffer then
834+
target_window = win
835+
require("claudecode.logger").debug("diff", "Found window", win, "containing buffer", existing_buffer)
836+
break
837+
end
783838
end
784839
end
840+
else
841+
require("claudecode.logger").debug("diff", "Skipping buffer search for new file:", params.old_file_path)
785842
end
786843

787844
-- If no existing buffer/window, find a suitable main editor window
@@ -811,7 +868,7 @@ function M._setup_blocking_diff(params, resolution_callback)
811868
})
812869
end
813870

814-
local new_unique_name = tab_name .. " (proposed)"
871+
local new_unique_name = is_new_file and (tab_name .. " (NEW FILE - proposed)") or (tab_name .. " (proposed)")
815872
vim.api.nvim_buf_set_name(new_buffer, new_unique_name)
816873
vim.api.nvim_buf_set_lines(new_buffer, 0, -1, false, vim.split(params.new_file_contents, "\n"))
817874

@@ -820,8 +877,15 @@ function M._setup_blocking_diff(params, resolution_callback)
820877
vim.api.nvim_buf_set_option(new_buffer, "modifiable", true)
821878

822879
-- Step 4: Set up diff view using the target window
823-
require("claudecode.logger").debug("diff", "Creating diff view from window", target_window)
824-
local diff_info = M._create_diff_view_from_window(target_window, params.old_file_path, new_buffer, tab_name)
880+
require("claudecode.logger").debug(
881+
"diff",
882+
"Creating diff view from window",
883+
target_window,
884+
"is_new_file:",
885+
is_new_file
886+
)
887+
local diff_info =
888+
M._create_diff_view_from_window(target_window, params.old_file_path, new_buffer, tab_name, is_new_file)
825889

826890
-- Step 5: Register autocmds for user interaction monitoring
827891
require("claudecode.logger").debug("diff", "Registering autocmds")
@@ -842,6 +906,7 @@ function M._setup_blocking_diff(params, resolution_callback)
842906
status = "pending",
843907
resolution_callback = resolution_callback,
844908
result_content = nil,
909+
is_new_file = is_new_file,
845910
})
846911
require("claudecode.logger").debug("diff", "Setup completed successfully for", tab_name)
847912
end

tests/unit/diff_mcp_spec.lua

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,30 @@ describe("MCP-compliant diff operations", function()
9090
assert.equal("text", result.content[2].type)
9191
end)
9292

93-
it("should error on non-existent old file", function()
93+
it("should handle non-existent old file as new file", function()
9494
local non_existent_file = "/tmp/non_existent_file.txt"
95+
96+
-- Set up mock resolution
97+
_G.claude_deferred_responses = {
98+
[tostring(coroutine.running())] = function()
99+
-- Mock resolution
100+
end,
101+
}
102+
95103
local co = coroutine.create(function()
96104
diff.open_diff_blocking(non_existent_file, test_new_file, test_content_new, test_tab_name)
97105
end)
98106

99-
local success, err = coroutine.resume(co)
100-
assert.is_false(success, "Should fail with non-existent file")
101-
assert.is_table(err)
102-
assert.equal(-32000, err.code)
103-
assert_contains(err.message, "File access error")
107+
local success = coroutine.resume(co)
108+
assert.is_true(success, "Should handle new file scenario successfully")
109+
110+
-- The coroutine should yield (waiting for user action)
111+
assert.equal("suspended", coroutine.status(co))
112+
113+
-- Verify diff state was created for new file
114+
local active_diffs = diff._get_active_diffs()
115+
assert.is_table(active_diffs[test_tab_name])
116+
assert.is_true(active_diffs[test_tab_name].is_new_file)
104117
end)
105118

106119
it("should replace existing diff with same tab_name", function()

tests/unit/tools/open_diff_mcp_spec.lua

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,23 +202,30 @@ describe("openDiff tool MCP compliance", function()
202202
end)
203203

204204
describe("error handling", function()
205-
it("should handle file access errors", function()
205+
it("should handle new files successfully", function()
206206
local params = {
207207
old_file_path = "/tmp/non_existent_file.txt",
208208
new_file_path = test_new_file,
209209
new_file_contents = test_content_new,
210210
tab_name = test_tab_name,
211211
}
212212

213+
-- Set up mock resolution to avoid hanging
214+
_G.claude_deferred_responses = {
215+
[tostring(coroutine.running())] = function(result)
216+
-- Mock resolution
217+
end,
218+
}
219+
213220
local co = coroutine.create(function()
214221
open_diff_tool.handler(params)
215222
end)
216223

217-
local success, err = coroutine.resume(co)
218-
assert.is_false(success)
219-
assert.is_table(err)
220-
assert.equal(-32000, err.code)
221-
assert_contains(err.data, "Cannot open file")
224+
local success = coroutine.resume(co)
225+
assert.is_true(success, "Should handle new file scenario successfully")
226+
227+
-- The coroutine should yield (waiting for user action)
228+
assert.equal("suspended", coroutine.status(co))
222229
end)
223230

224231
it("should handle diff module loading errors", function()

0 commit comments

Comments
 (0)