From 85b5c722195c4a08c106e00008bc6920dbad788b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 09:57:07 +0000 Subject: [PATCH 1/8] test(diff): pin the text a diff accepts for a proposal with CRLF line breaks The split layouts joined the proposal buffer's lines with LF, and a CRLF proposal kept the CR inside each line with 'fileformat' unix. A line that the user types, or the join of two lines, came back with an LF or a stray CR in the middle of a line, where Neovim's own :w writes CRLF. The unified layout's buffer is read-only, so its answer is the proposal. The spec runs each case in a real Neovim and compares the answer with the bytes that :w writes for the same text read from a file after the same commands. It fails on main for every typed line, join and 'fileformat' case, and it passes for the controls that already matched. --- tests/integration/diff_accept_crlf_spec.lua | 217 ++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/integration/diff_accept_crlf_spec.lua diff --git a/tests/integration/diff_accept_crlf_spec.lua b/tests/integration/diff_accept_crlf_spec.lua new file mode 100644 index 000000000..4cb14417b --- /dev/null +++ b/tests/integration/diff_accept_crlf_spec.lua @@ -0,0 +1,217 @@ +if vim == nil or vim.fn.has("nvim") ~= 1 then + describe("the text a diff accepts when the proposal has CRLF line breaks", function() + pending("needs a real Neovim") + end) + return +end + +-- The accepted text goes back to Claude as the file's new contents, so it must be what Neovim's own `:w` writes for the +-- proposal. A proposal whose line breaks are all CRLF reads as 'fileformat' dos when Neovim reads it from a file, and +-- `:w` then ends every line with CRLF, a line that the user typed too. The split layouts kept each CR inside its line +-- and joined the lines with LF, so a line that the user typed, or the join of two lines, came back with an LF or with +-- a stray CR in the middle of a line. The unified layout's buffer is read-only, so its answer is the proposal itself. +describe("the text a diff accepts when the proposal has CRLF line breaks", function() + local diff = require("claudecode.diff") + local tools = require("claudecode.tools.init") + local tab = "✻ [Claude Code] crlf.txt ⧉" + local dir, answers + + --- Opens the diff of a proposal. `old_text` nil means the file does not exist yet. + local function open_diff(layout, old_text, new_text) + local file = dir .. "/crlf.txt" + if old_text then + local handle = assert(io.open(file, "wb")) + handle:write(old_text) + handle:close() + vim.cmd("edit " .. vim.fn.fnameescape(file)) + end + diff.setup({ terminal = { provider = "none" }, diff_opts = { layout = layout } }) + local call = tools.handle_invoke({ id = "crlf_spec" }, { + name = "openDiff", + arguments = { old_file_path = file, new_file_path = file, new_file_contents = new_text, tab_name = tab }, + }) + assert.is_true(call._deferred, "openDiff waits for the user") + _G.claude_deferred_responses[tostring(call.coroutine)] = function(response) + answers[#answers + 1] = response + end + return diff._get_active_diffs()[tab] + end + + --- What Neovim's own `:w` writes for the file `text` after the same commands. Neovim reads the text as a file, so it + --- detects 'fileformat' as it does for any file. + local function neovim_writes(text, commands) + local file = dir .. "/oracle.txt" + local handle = assert(io.open(file, "wb")) + handle:write(text) + handle:close() + vim.cmd("edit " .. vim.fn.fnameescape(file)) + for _, command in ipairs(commands) do + vim.cmd(command) + end + vim.cmd("write") + local written = assert(io.open(file, "rb")) + local bytes = written:read("*a") + written:close() + vim.cmd("bwipeout!") + return bytes + end + + --- Runs `commands` in the proposal window, then saves with `:write`, and returns the text that Claude gets. + local function edit_and_accept(state, commands) + vim.api.nvim_set_current_win(state.new_window) + for _, command in ipairs(commands) do + vim.cmd(command) + end + vim.cmd("write") + assert.equals(1, #answers, "one answer") + assert.equals("FILE_SAVED", answers[1].content[1].text) + return answers[1].content[2].text + end + + before_each(function() + vim.o.columns, vim.o.lines = 160, 40 + dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + tools.register_all() + _G.claude_deferred_responses = {} + answers = {} + end) + + after_each(function() + diff._cleanup_all_active_diffs("test cleanup") + vim.cmd("silent! tabonly!") + vim.cmd("silent! only!") + vim.cmd("silent! %bwipeout!") + vim.fn.delete(dir, "rf") + _G.claude_deferred_responses = nil + end) + + local split_layouts = { "vertical", "horizontal" } + local all_layouts = { "vertical", "horizontal" } + if vim.fn.has("nvim-0.9") == 1 then + all_layouts[#all_layouts + 1] = "unified" + else + describe("in the unified layout", function() + pending("the unified layout needs Neovim 0.9") + end) + end + + -- A proposal without a final line break stays without one (#542), where a default `:w` adds one. `nofixeol` makes + -- Neovim's `:w` keep it too, so the rest of the bytes can be compared. + local proposals = { + { name = "CRLF line breaks and a final one", text = "a\r\nb\r\n", commands = {} }, + { name = "a blank line inside", text = "a\r\n\r\nb\r\n", commands = {} }, + { name = "CRLF line breaks and no final one", text = "a\r\nb", commands = { "setlocal nofixeol" } }, + } + local old_files = { + { name = "a CRLF file", text = "old\r\n" }, + { name = "a file that does not exist yet", text = nil }, + } + + -- The edits change what the buffer holds, so the split layouts take the text from the buffer. The unified layout's + -- buffer is read-only. + local edits = { + { name = "a typed line after the last line", commands = { "normal! Gox" } }, + { name = "a typed line above the first line", commands = { "normal! ggOx" } }, + { name = "all text replaced by a typed line", commands = { "normal! ggdG", "normal! ix" } }, + { name = "the first two lines joined", commands = { "normal! ggJ" } }, + { name = "the first line deleted", commands = { "normal! ggdd" } }, + { name = "a line break typed at the end of the first line", commands = { 'execute "normal! ggA\\x"' } }, + } + + for _, layout in ipairs(split_layouts) do + describe("in the " .. layout .. " layout", function() + for _, proposal in ipairs(proposals) do + for _, edit in ipairs(edits) do + local commands = vim.list_extend(vim.list_slice(proposal.commands), edit.commands) + it(("accepts what :w writes after %s, from a proposal with %s"):format(edit.name, proposal.name), function() + local expected = neovim_writes(proposal.text, commands) + local state = open_diff(layout, "old\r\n", proposal.text) + + assert.equal(vim.inspect(expected), vim.inspect(edit_and_accept(state, commands))) + end) + end + end + + for _, old_file in ipairs(old_files) do + it("accepts what :w writes after a typed line, against " .. old_file.name, function() + local commands = { "normal! Gox" } + local expected = neovim_writes("a\r\nb\r\n", commands) + local state = open_diff(layout, old_file.text, "a\r\nb\r\n") + + assert.equal(vim.inspect(expected), vim.inspect(edit_and_accept(state, commands))) + end) + end + + it("ends a line that the user types with CRLF", function() + local state = open_diff(layout, "old\r\n", "a\r\nb\r\n") + + assert.equal(vim.inspect("a\r\nb\r\nx\r\n"), vim.inspect(edit_and_accept(state, { "normal! Gox" }))) + end) + + it("shows a CRLF proposal as 'fileformat' dos, without a CR in its lines", function() + local state = open_diff(layout, "old\r\n", "a\r\nb\r\n") + local buffer = vim.api.nvim_win_get_buf(state.new_window) + + assert.equal("dos", vim.api.nvim_buf_get_option(buffer, "fileformat")) + assert.same({ "a", "b" }, vim.api.nvim_buf_get_lines(buffer, 0, -1, false)) + end) + + -- Controls: these gave the text that :w writes before the fix too. A proposal that is not all CRLF reads as unix, so + -- it keeps the text it has, CRs included. + local controls = { + { name = "an LF proposal", text = "a\nb\n", commands = { "normal! Gox" }, format = "unix" }, + { + name = "a proposal with CRLF and LF line breaks", + text = "a\r\nb\n", + commands = { "normal! Gox" }, + format = "unix", + }, + { name = "a proposal with a lone CR", text = "a\rb\n", commands = { "normal! Gox" }, format = "unix" }, + { name = "a proposal with no line break", text = "a\r", commands = { "setlocal nofixeol" }, format = "unix" }, + { name = "a CRLF proposal that is not edited", text = "a\r\nb\r\n", commands = {} }, + { name = "an emptied CRLF proposal", text = "a\r\nb\r\n", commands = { "normal! ggdG" } }, + } + for _, control in ipairs(controls) do + it("still accepts what :w writes for " .. control.name, function() + local expected = neovim_writes(control.text, control.commands) + local state = open_diff(layout, "old\n", control.text) + local buffer = vim.api.nvim_win_get_buf(state.new_window) + + if control.format then + assert.equal(control.format, vim.api.nvim_buf_get_option(buffer, "fileformat")) + end + assert.equal(vim.inspect(expected), vim.inspect(edit_and_accept(state, control.commands))) + end) + end + + -- The user can change 'fileformat' of the proposal, and `:w` then writes that format. + local formats = { "dos", "mac", "unix" } + for _, format in ipairs(formats) do + it("accepts what :w writes after :setlocal fileformat=" .. format .. " on an LF proposal", function() + local commands = { "setlocal fileformat=" .. format } + local expected = neovim_writes("a\nb\n", commands) + local state = open_diff(layout, "old\n", "a\nb\n") + + assert.equal(vim.inspect(expected), vim.inspect(edit_and_accept(state, commands))) + end) + end + end) + end + + -- Every layout accepts an unedited proposal as it is. The unified layout has no other way: its buffer is read-only. + for _, layout in ipairs(all_layouts) do + for _, old_file in ipairs(old_files) do + for _, proposal in ipairs(proposals) do + it( + ("the %s layout accepts a proposal with %s unedited, against %s"):format(layout, proposal.name, old_file.name), + function() + local state = open_diff(layout, old_file.text, proposal.text) + + assert.equal(vim.inspect(proposal.text), vim.inspect(edit_and_accept(state, {}))) + end + ) + end + end + end +end) From f8ac9289f7f76ecd026a0b69db79db3f4bed88c1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 09:57:07 +0000 Subject: [PATCH 2/8] fix(diff): save a line that the user types in a CRLF proposal with CRLF A proposal whose every line break is CRLF now gets 'fileformat' dos and lines without their CRs, as the same text read from a file does. The save joins the proposal buffer's lines with the line break of its 'fileformat' (LF, CRLF for dos, CR for mac), and ends the text with it, so the answer is what :w writes for that buffer. Any other proposal stays unix with its CRs inside its lines, which :w writes back unchanged. The model has no text contents and no guard moved, so only covered.lock records the new hashes of the two blocks that changed. --- .../skills/verify-diff-lifecycle/covered.lock | 6 +++--- lua/claudecode/README.md | 2 +- lua/claudecode/diff.lua | 17 ++++++++++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 61ca360fa..6a715bd7e 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -3,11 +3,11 @@ file lua/claudecode/config.lua 3afb1bdeff27bfb6 == lua/claudecode/cwd.lua file lua/claudecode/cwd.lua 35b7af3572d13018 == lua/claudecode/diff.lua -file lua/claudecode/diff.lua 0ec43e91b199531e -block resolve_saved 4d34b0c7e34d561f +file lua/claudecode/diff.lua bed5aeac37af8475 +block resolve_saved 36fd89d5b97f0173 block resolve_rejected c7a620816abff9a1 block open_blocking 92067df7f9d4d4d9 -block setup_blocking b96c84e0961817fc +block setup_blocking f0355a1b98010b43 block close_by_name cc40c0d2ffdf7946 block close_pending 31bc827d1065da70 block close_for_client 330cc91666bc7884 diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index ab2f3ba6b..3f1cde551 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -179,7 +179,7 @@ Module map (direct files): - When the target buffer is dirty, diff setup raises `-32000` with message "Diff setup failed". Its `data` holds the inner error, which contains "Cannot create diff: file has unsaved changes" [diff.lua:M._setup_blocking_diff] - A diff resolves only from status `pending`. Save returns `FILE_SAVED` and the content. Reject returns `DIFF_REJECTED` and the tab name [diff.lua:M._resolve_diff_as_saved] - A write of the proposal buffer (`:w`, `:wq`, `:x`, `ZZ`, which run `BufWriteCmd`) never writes the file. It accepts a pending diff and does nothing else. `:w` leaves the windows open until `close_tab`, and `:wq`, `:x` and `ZZ` also close the proposal window. A write after the diff has its answer sends nothing and does not fail (the handler never returns `true`, which would delete the autocmd and make the next `:w` fail with E676). It warns when the buffer holds edits that Claude never gets [diff.lua:M._write_proposal] -- In the `vertical` and `horizontal` layouts the saved content is the lines of the proposal buffer, joined with line breaks, plus a final line break only when the buffer's `'eol'` is on and the buffer has a line. The diff sets `'eol'` from the proposal when it creates the buffer, so a proposal without a final line break is saved without one, and the user's edits keep that ending. A buffer with no lines at all (an empty proposal, or a buffer the user emptied with `ggdG` or `:%delete`) is saved as an empty string, because `:w` writes no bytes for it whatever `'eol'` says. A buffer with one empty line is saved as `"\n"` when `'eol'` is on and as `""` when it is off. The save tells the two apart with `wordcount().bytes`, because `nvim_buf_get_lines` returns one empty string for both [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved] +- In the `vertical` and `horizontal` layouts the saved content is the lines of the proposal buffer, joined with the line break of the buffer's `'fileformat'` (LF, CRLF for `dos`, CR for `mac`), plus a final line break only when the buffer's `'eol'` is on and the buffer has a line. A proposal whose every line break is CRLF gets `'fileformat'` `dos` and lines without their CRs, as the same text read from a file does, so a line that the user types or joins is saved with CRLF, as `:w` writes it. Any other proposal stays `unix` with its CRs inside its lines, which `:w` writes back unchanged. The `unified` layout's buffer is read-only, so its text is the proposal itself. The diff sets `'eol'` from the proposal when it creates the buffer, so a proposal without a final line break is saved without one, and the user's edits keep that ending. A buffer with no lines at all (an empty proposal, or a buffer the user emptied with `ggdG` or `:%delete`) is saved as an empty string, because `:w` writes no bytes for it whatever `'eol'` says. A buffer with one empty line is saved as `"\n"` when `'eol'` is on and as `""` when it is off. The save tells the two apart with `wordcount().bytes`, because `nvim_buf_get_lines` returns one empty string for both [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved] - A pending diff rejects at once when its proposal buffer is shown in no window, in the split and the unified layout: its last window closes (`:q`, `:close`, `c`, `:tabclose`), or that window switches to another buffer (`:edit`, `:buffer`, `:enew`, which only hide it). A copy of the proposal in another window or tab defers the reject until that copy leaves too. The switch path rejects on the next loop turn, and only when the diff is still the same one and still hidden. Both layouts keep the proposal buffer's `'bufhidden'` at `hide`: the buffer is modified, and `wipe` would make `:q` fail with E37 [diff.lua:M._register_reject_when_hidden] [diff_inline.lua:M.setup_inline_diff] - A diff that has its answer, by either one, clears `'modified'` on its proposal buffer. While the flag stayed set, `:wq` left the window open, and `:qa` stopped with E37 or E162 and moved a window onto the hidden buffer until `close_tab` deleted it [diff.lua:mark_proposal_unmodified] - The cleanup of a diff closes the proposal window, and the window that the split layout opened for the file's old text, only while each still shows its buffer. A window that the user switched to another buffer stays with that buffer. `:edit` turns diff mode off by itself, except when the new buffer is in diff mode in another window, as the file's own buffer is. So the split layout's cleanup ends diff mode only in a kept window that shows the file's own buffer, and leaves a diff mode in a window that shows another buffer, which is the user's. One limit comes from Neovim: the default `closeoff` in `'diffopt'` turns diff mode off in the last window that has it when another diff window closes. That ends a user's diff mode in a kept original-side window when the proposal window closes, and in a kept proposal window when the cleanup closes the window that the plugin opened for the file. A user's diff mode over two or more windows stays. The unified layout needs no cleanup there: it sets `'signcolumn'` to `yes` with local scope, for the proposal buffer only, so the window shows the sign column it had before the diff once the user switches it, and keeps a value the user sets afterwards. Two cases still close the window: a diff in its own tab (`open_in_new_tab`, or a tab that already has a diff) closes the whole tab, and the fallback window that the plugin opens in a tab that holds only the Claude terminal always closes [diff.lua:M._close_proposal_window] [diff.lua:M._cleanup_diff_state] [diff_inline.lua:M.cleanup_inline_diff] [diff_inline.lua:M.setup_inline_diff] diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 339e84b55..1a0c1215a 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1057,7 +1057,9 @@ function M._resolve_diff_as_saved(tab_name, buffer_id) -- Get content from buffer local content_lines = vim.api.nvim_buf_get_lines(buffer_id, 0, -1, false) - local final_content = table.concat(content_lines, "\n") + -- :w ends each line with the line break of the buffer's 'fileformat': LF, CRLF for dos, CR for mac. + local line_break = ({ dos = "\r\n", mac = "\r" })[vim.api.nvim_buf_get_option(buffer_id, "fileformat")] or "\n" + local final_content = table.concat(content_lines, line_break) -- Add trailing newline if the buffer has one, as :w does. :w writes nothing for an empty buffer, whatever 'eol' is. -- Such a buffer has no lines, but nvim_buf_get_lines still returns one empty string for it, so the count of bytes tells -- it from a buffer with one empty line. @@ -1065,7 +1067,7 @@ function M._resolve_diff_as_saved(tab_name, buffer_id) return vim.fn.wordcount().bytes end) if byte_count > 0 and vim.api.nvim_buf_get_option(buffer_id, "eol") then - final_content = final_content .. "\n" + final_content = final_content .. line_break end -- Do not modify windows/tabs here; wait for explicit close_tab tool call to clean up UI @@ -1743,12 +1745,21 @@ function M._setup_blocking_diff(params, resolution_callback) local new_unique_name = is_new_file and (tab_name .. " (NEW FILE - proposed)") or (tab_name .. " (proposed)") vim.api.nvim_buf_set_name(new_buffer, new_unique_name) - local lines = vim.split(params.new_file_contents, "\n") + -- A proposal whose every line break is CRLF takes 'fileformat' dos, as the same text read from a file does. The + -- buffer then holds the lines without their CRs, as the file's own buffer does, and the save gives a line that the + -- user types a CRLF, as :w does. Any other text stays unix with its CRs inside the lines, which :w writes back + -- unchanged. + local proposal = params.new_file_contents + local is_dos = proposal:find("\n", 1, true) ~= nil and not proposal:gsub("\r\n", ""):find("\n", 1, true) + local lines = vim.split(proposal, is_dos and "\r\n" or "\n", { plain = true }) -- Remove trailing empty line if content ended with \n if #lines > 0 and lines[#lines] == "" then table.remove(lines, #lines) end vim.api.nvim_buf_set_lines(new_buffer, 0, -1, false, lines) + if is_dos then + vim.api.nvim_buf_set_option(new_buffer, "fileformat", "dos") + end -- A new buffer has 'eol' on, and the saved text ends with a line break exactly when 'eol' is on. So the buffer takes -- the ending of the proposal, and a proposal without a final line break is saved without one. vim.api.nvim_buf_set_option(new_buffer, "eol", params.new_file_contents:sub(-1) == "\n") From 3b7479b47eecb58ed313ae49ea2c8771861a3430 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 10:20:45 +0000 Subject: [PATCH 3/8] test(diff): pin that a CRLF proposal shows no difference from a CRLF file with the same text The proposal buffer kept a CR inside each line, so every line of a CRLF proposal differed from the file's own buffer ('fileformat' dos, no CRs), and the diff marked each line as changed. --- tests/integration/diff_accept_crlf_spec.lua | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/integration/diff_accept_crlf_spec.lua b/tests/integration/diff_accept_crlf_spec.lua index 4cb14417b..5067c83eb 100644 --- a/tests/integration/diff_accept_crlf_spec.lua +++ b/tests/integration/diff_accept_crlf_spec.lua @@ -157,6 +157,22 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct assert.same({ "a", "b" }, vim.api.nvim_buf_get_lines(buffer, 0, -1, false)) end) + it("shows no difference between a CRLF proposal and the CRLF file that holds the same text", function() + local state = open_diff(layout, "a\r\nb\r\nc\r\n", "a\r\nb\r\nc\r\n") + + local changed_lines = vim.api.nvim_win_call(state.new_window, function() + local lines = {} + for line = 1, vim.api.nvim_buf_line_count(0) do + if vim.fn.diff_hlID(line, 1) ~= 0 then + lines[#lines + 1] = line + end + end + return lines + end) + + assert.same({}, changed_lines) + end) + -- Controls: these gave the text that :w writes before the fix too. A proposal that is not all CRLF reads as unix, so -- it keeps the text it has, CRs included. local controls = { From 367193b79b7ae59b177ef6ad9fce5a9bf106e45d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 15:33:52 +0000 Subject: [PATCH 4/8] test(diff): wrap two lines of the CRLF spec that were over 120 columns --- tests/integration/diff_accept_crlf_spec.lua | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/integration/diff_accept_crlf_spec.lua b/tests/integration/diff_accept_crlf_spec.lua index 5067c83eb..b9ead7974 100644 --- a/tests/integration/diff_accept_crlf_spec.lua +++ b/tests/integration/diff_accept_crlf_spec.lua @@ -173,8 +173,8 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct assert.same({}, changed_lines) end) - -- Controls: these gave the text that :w writes before the fix too. A proposal that is not all CRLF reads as unix, so - -- it keeps the text it has, CRs included. + -- Controls: these gave the text that :w writes before the fix too. A proposal that is not all CRLF reads as + -- unix, so it keeps the text it has, CRs included. local controls = { { name = "an LF proposal", text = "a\nb\n", commands = { "normal! Gox" }, format = "unix" }, { @@ -219,14 +219,16 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct for _, layout in ipairs(all_layouts) do for _, old_file in ipairs(old_files) do for _, proposal in ipairs(proposals) do - it( - ("the %s layout accepts a proposal with %s unedited, against %s"):format(layout, proposal.name, old_file.name), - function() - local state = open_diff(layout, old_file.text, proposal.text) - - assert.equal(vim.inspect(proposal.text), vim.inspect(edit_and_accept(state, {}))) - end + local title = ("the %s layout accepts a proposal with %s unedited, against %s"):format( + layout, + proposal.name, + old_file.name ) + it(title, function() + local state = open_diff(layout, old_file.text, proposal.text) + + assert.equal(vim.inspect(proposal.text), vim.inspect(edit_and_accept(state, {}))) + end) end end end From 6df469cb8bf5a93116f47742dba92071a8266d77 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 16:50:10 +0000 Subject: [PATCH 5/8] fix(diff): set the fileformat of every proposal buffer, and take dos only when 'fileformats' includes it A new scratch buffer takes the first entry of 'fileformats' as its 'fileformat'. The diff set 'fileformat' only for a CRLF proposal, so a user whose 'fileformats' starts with dos got an LF proposal, or one that mixes CRLF and LF, back with a CR before each LF when it accepted the text unedited. The diff now sets the option in both cases. A CRLF proposal also took dos when 'fileformats' was unix. The file's own buffer then keeps its CRs as part of each line, so the diff marked every line of an equal proposal as changed, and a typed line got a CRLF where :w writes an LF. The diff now takes dos only for a value that includes dos. The README line states both rules and that one empty line is saved as one line break of the buffer's 'fileformat'. The spec runs under three values of 'fileformats' and now runs its unified cases on Neovim 0.8.0, which #564 made work. --- .../skills/verify-diff-lifecycle/covered.lock | 4 +- lua/claudecode/README.md | 2 +- lua/claudecode/diff.lua | 17 ++-- tests/integration/diff_accept_crlf_spec.lua | 94 +++++++++++++++---- 4 files changed, 87 insertions(+), 30 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 170e29817..e714a9415 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -3,11 +3,11 @@ file lua/claudecode/config.lua 3afb1bdeff27bfb6 == lua/claudecode/cwd.lua file lua/claudecode/cwd.lua b034e82c76e8dee4 == lua/claudecode/diff.lua -file lua/claudecode/diff.lua b345f503594f7345 +file lua/claudecode/diff.lua cc806ab83aa9cf09 block resolve_saved 36fd89d5b97f0173 block resolve_rejected c7a620816abff9a1 block open_blocking 92067df7f9d4d4d9 -block setup_blocking f0355a1b98010b43 +block setup_blocking 8330a7c91ce19d2b block close_by_name cc40c0d2ffdf7946 block close_pending 31bc827d1065da70 block close_for_client 330cc91666bc7884 diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 26ec57860..f58a8942d 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -181,7 +181,7 @@ Module map (direct files): - When the target buffer is dirty, diff setup raises `-32000` with message "Diff setup failed". Its `data` holds the inner error, which contains "Cannot create diff: file has unsaved changes" [diff.lua:M._setup_blocking_diff] - A diff resolves only from status `pending`. Save returns `FILE_SAVED` and the content. In the `unified` layout the content is the proposal's lines with a final line break exactly when the proposal ends with one, so every blank line at its end stays. Reject returns `DIFF_REJECTED` and the tab name [diff.lua:M._resolve_diff_as_saved] [diff_inline.lua:M.resolve_inline_as_saved] - A write of the proposal buffer (`:w`, `:wq`, `:x`, `ZZ`, which run `BufWriteCmd`) never writes the file. It accepts a pending diff and does nothing else. `:w` leaves the windows open until `close_tab`, and `:wq`, `:x` and `ZZ` also close the proposal window. A write after the diff has its answer sends nothing and does not fail (the handler never returns `true`, which would delete the autocmd and make the next `:w` fail with E676). It warns when the buffer holds edits that Claude never gets [diff.lua:M._write_proposal] -- In the `vertical` and `horizontal` layouts the saved content is the lines of the proposal buffer, joined with the line break of the buffer's `'fileformat'` (LF, CRLF for `dos`, CR for `mac`), plus a final line break only when the buffer's `'eol'` is on and the buffer has a line. A proposal whose every line break is CRLF gets `'fileformat'` `dos` and lines without their CRs, as the same text read from a file does, so a line that the user types or joins is saved with CRLF, as `:w` writes it. Any other proposal stays `unix` with its CRs inside its lines, which `:w` writes back unchanged. The `unified` layout's buffer is read-only, so its text is the proposal itself. The diff sets `'eol'` from the proposal when it creates the buffer, so a proposal without a final line break is saved without one, and the user's edits keep that ending. A buffer with no lines at all (an empty proposal, or a buffer the user emptied with `ggdG` or `:%delete`) is saved as an empty string, because `:w` writes no bytes for it whatever `'eol'` says. A buffer with one empty line is saved as `"\n"` when `'eol'` is on and as `""` when it is off. The save tells the two apart with `wordcount().bytes`, because `nvim_buf_get_lines` returns one empty string for both [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved] +- In the `vertical` and `horizontal` layouts the saved content is the lines of the proposal buffer, joined with the line break of the buffer's `'fileformat'` (LF, CRLF for `dos`, CR for `mac`), plus a final line break only when the buffer's `'eol'` is on and the buffer has a line. When `'fileformats'` includes `dos`, a proposal whose every line break is CRLF gets `'fileformat'` `dos` and lines without their CRs, as the same text read from a file does, so a line that the user types or joins is saved with CRLF, as `:w` writes it. Any other proposal gets `'fileformat'` `unix` whatever the first entry of `'fileformats'` is, with its CRs inside its lines, which `:w` writes back unchanged. The `unified` layout's buffer is read-only, so its text is the proposal itself. The diff sets `'eol'` from the proposal when it creates the buffer, so a proposal without a final line break is saved without one, and the user's edits keep that ending. A buffer with no lines at all (an empty proposal, or a buffer the user emptied with `ggdG` or `:%delete`) is saved as an empty string, because `:w` writes no bytes for it whatever `'eol'` says. A buffer with one empty line is saved as one line break of the buffer's `'fileformat'` when `'eol'` is on and as `""` when it is off. The save tells the two apart with `wordcount().bytes`, because `nvim_buf_get_lines` returns one empty string for both [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved] - A pending diff rejects at once when its proposal buffer is shown in no window, in the split and the unified layout: its last window closes (`:q`, `:close`, `c`, `:tabclose`), or that window switches to another buffer (`:edit`, `:buffer`, `:enew`, which only hide it). A copy of the proposal in another window or tab defers the reject until that copy leaves too. The switch path rejects on the next loop turn, and only when the diff is still the same one and still hidden. Both layouts keep the proposal buffer's `'bufhidden'` at `hide`: the buffer is modified, and `wipe` would make `:q` fail with E37 [diff.lua:M._register_reject_when_hidden] [diff_inline.lua:M.setup_inline_diff] - A diff that has its answer, by either one, clears `'modified'` on its proposal buffer. While the flag stayed set, `:wq` left the window open, and `:qa` stopped with E37 or E162 and moved a window onto the hidden buffer until `close_tab` deleted it [diff.lua:mark_proposal_unmodified] - The cleanup of a diff closes the proposal window, and the window that the split layout opened for the file's old text, only while each still shows its buffer. A window that the user switched to another buffer stays with that buffer. Neovim saves a window's options, `'diff'` included, for each buffer that leaves it, and sets them again when that buffer enters the window. So `:edit #`, or `:edit `, back to a file that left the window in diff mode turns diff mode on again, after the diff has ended. A window that never showed the buffer can take diff mode on in two ways. It can copy the current options of a window that shows the buffer: `:split ` copies the window that it splits, and a window in a new tab page, from `:tabnew ` or from `:tabnew` and `:buffer `, can get diff mode while a window of another tab page shows the file in diff mode. Or it can take a set that another window saved when the buffer left it: while no window shows the buffer, a set with diff mode on reaches a window of the same tab page, and does not reach a window of another tab page or a window that closed. The split layout's cleanup does one thing about that. After it closes its windows and deletes its buffers, and before it fires `ClaudeCodeDiffClosed`, it runs `:diffoff` in a kept window that shows the file's own buffer, and in each other window of the original side's tab page that shows the file and is the only window of that tab page with `'diff'` on, so nothing that a handler of that event starts is looked at. A diff needs a partner window, so the plugin takes a window that is the only one with `'diff'` on in its tab page for the leftover of the ended diff, and a diff of the user's own over two or more windows stays. The `'diff'` check leaves an option that the user set after turning diff mode off. `:diffoff` also puts back the options that diff mode set (`'scrollbind'`, `'cursorbind'`, `'foldmethod'`, `'foldcolumn'`, `'wrap'`). When Neovim refuses `:diffoff` in a window, as it does in the command-line window, the window keeps its diff mode [diff.lua:end_diff_mode_in_windows_that_show_the_file]. The cleanup looks only at the tab page of the original side and only once, so a file that comes back later gets the options that Neovim saved or copies, diff mode included, as on main. That covers `:edit #` or `:edit ` in a window that left the file, a new window for it such as `:split `, a window of another tab page that took diff mode from the diff's windows (`:tabnew `, `:tab sbuffer`, or `:tabnew` then `:buffer `), and a `ClaudeCodeDiffClosed` handler that brings the file back into a window. One limit comes from Neovim: the default `closeoff` in `'diffopt'` turns diff mode off in the last window that has it when another diff window closes. That ends a user's diff mode in a kept original-side window when the proposal window closes, and in a kept proposal window when the cleanup closes the window that the plugin opened for the file. It also ends it in a window that the diff never used, when the user joined that window to the diff with `:diffthis` and it is the last diff window left after the cleanup closes the plugin's window for the file. A user's diff mode over two or more windows stays. The unified layout needs no cleanup there: it sets `'signcolumn'` to `yes` with local scope, for the proposal buffer only, so once the user switches the window, `'signcolumn'` is what Neovim gives the new buffer in that window: the value of a window that shows or showed that buffer, a `:setlocal` value of the editor window included, or, for a buffer that no window showed, the window default (what `:set` gives, which a new split copies from the window it splits). The window keeps a value that the user sets afterwards. Two cases still close the window: a diff in its own tab (`open_in_new_tab`, or a tab that already has a diff) closes the whole tab, and the fallback window that the plugin opens in a tab that holds only the Claude terminal always closes [diff.lua:M._close_proposal_window] [diff.lua:M._cleanup_diff_state] [diff_inline.lua:M.cleanup_inline_diff] [diff_inline.lua:M.setup_inline_diff] diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index ff991bf38..50ded8be6 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1784,21 +1784,22 @@ function M._setup_blocking_diff(params, resolution_callback) local new_unique_name = is_new_file and (tab_name .. " (NEW FILE - proposed)") or (tab_name .. " (proposed)") vim.api.nvim_buf_set_name(new_buffer, new_unique_name) - -- A proposal whose every line break is CRLF takes 'fileformat' dos, as the same text read from a file does. The - -- buffer then holds the lines without their CRs, as the file's own buffer does, and the save gives a line that the - -- user types a CRLF, as :w does. Any other text stays unix with its CRs inside the lines, which :w writes back - -- unchanged. + -- A proposal whose every line break is CRLF takes 'fileformat' dos, as the same text read from a file does, when + -- the user's 'fileformats' includes dos. The buffer then holds the lines without their CRs, as the file's own + -- buffer does, and the save gives a line that the user types a CRLF, as :w does. Any other text is unix, with its + -- CRs inside the lines, which :w writes back unchanged. 'fileformat' is set in both cases, because a new buffer + -- takes the first entry of 'fileformats', and that entry can be dos. local proposal = params.new_file_contents - local is_dos = proposal:find("\n", 1, true) ~= nil and not proposal:gsub("\r\n", ""):find("\n", 1, true) + local is_dos = vim.o.fileformats:find("dos", 1, true) ~= nil + and proposal:find("\n", 1, true) ~= nil + and not proposal:gsub("\r\n", ""):find("\n", 1, true) local lines = vim.split(proposal, is_dos and "\r\n" or "\n", { plain = true }) -- Remove trailing empty line if content ended with \n if #lines > 0 and lines[#lines] == "" then table.remove(lines, #lines) end vim.api.nvim_buf_set_lines(new_buffer, 0, -1, false, lines) - if is_dos then - vim.api.nvim_buf_set_option(new_buffer, "fileformat", "dos") - end + vim.api.nvim_buf_set_option(new_buffer, "fileformat", is_dos and "dos" or "unix") -- A new buffer has 'eol' on, and the saved text ends with a line break exactly when 'eol' is on. So the buffer takes -- the ending of the proposal, and a proposal without a final line break is saved without one. vim.api.nvim_buf_set_option(new_buffer, "eol", params.new_file_contents:sub(-1) == "\n") diff --git a/tests/integration/diff_accept_crlf_spec.lua b/tests/integration/diff_accept_crlf_spec.lua index b9ead7974..16892813b 100644 --- a/tests/integration/diff_accept_crlf_spec.lua +++ b/tests/integration/diff_accept_crlf_spec.lua @@ -10,6 +10,8 @@ end -- `:w` then ends every line with CRLF, a line that the user typed too. The split layouts kept each CR inside its line -- and joined the lines with LF, so a line that the user typed, or the join of two lines, came back with an LF or with -- a stray CR in the middle of a line. The unified layout's buffer is read-only, so its answer is the proposal itself. +-- The diff gives the buffer the 'fileformat' of the proposal, whatever the first entry of 'fileformats' is, and takes +-- dos only when 'fileformats' includes it, as Neovim does when it reads a file. describe("the text a diff accepts when the proposal has CRLF line breaks", function() local diff = require("claudecode.diff") local tools = require("claudecode.tools.init") @@ -68,6 +70,19 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct return answers[1].content[2].text end + --- The lines of the proposal window that diff mode marks as changed. + local function changed_lines(state) + return vim.api.nvim_win_call(state.new_window, function() + local lines = {} + for line = 1, vim.api.nvim_buf_line_count(0) do + if vim.fn.diff_hlID(line, 1) ~= 0 then + lines[#lines + 1] = line + end + end + return lines + end) + end + before_each(function() vim.o.columns, vim.o.lines = 160, 40 dir = vim.fn.tempname() @@ -87,14 +102,7 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct end) local split_layouts = { "vertical", "horizontal" } - local all_layouts = { "vertical", "horizontal" } - if vim.fn.has("nvim-0.9") == 1 then - all_layouts[#all_layouts + 1] = "unified" - else - describe("in the unified layout", function() - pending("the unified layout needs Neovim 0.9") - end) - end + local all_layouts = { "vertical", "horizontal", "unified" } -- A proposal without a final line break stays without one (#542), where a default `:w` adds one. `nofixeol` makes -- Neovim's `:w` keep it too, so the rest of the bytes can be compared. @@ -160,17 +168,7 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct it("shows no difference between a CRLF proposal and the CRLF file that holds the same text", function() local state = open_diff(layout, "a\r\nb\r\nc\r\n", "a\r\nb\r\nc\r\n") - local changed_lines = vim.api.nvim_win_call(state.new_window, function() - local lines = {} - for line = 1, vim.api.nvim_buf_line_count(0) do - if vim.fn.diff_hlID(line, 1) ~= 0 then - lines[#lines + 1] = line - end - end - return lines - end) - - assert.same({}, changed_lines) + assert.same({}, changed_lines(state)) end) -- Controls: these gave the text that :w writes before the fix too. A proposal that is not all CRLF reads as @@ -215,6 +213,64 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct end) end + -- 'fileformats' decides how Neovim reads a text, and its first entry is the 'fileformat' of a new buffer. So the diff + -- must not take the 'fileformat' of the proposal buffer from the option, and it takes dos only for a value that + -- includes dos. The oracle reads the text under the same value. + for _, fileformats in ipairs({ "unix,dos", "dos,unix", "unix" }) do + describe("with 'fileformats' set to " .. fileformats, function() + local saved_fileformats + + before_each(function() + saved_fileformats = vim.o.fileformats + vim.o.fileformats = fileformats + end) + + after_each(function() + vim.o.fileformats = saved_fileformats + end) + + local texts = { + { name = "an LF proposal", text = "a\nb\n" }, + { name = "a proposal with CRLF and LF line breaks", text = "a\r\nb\n" }, + { name = "a CRLF proposal", text = "a\r\nb\r\n" }, + } + + for _, layout in ipairs(all_layouts) do + for _, proposal in ipairs(texts) do + it(("the %s layout accepts %s unedited"):format(layout, proposal.name), function() + local state = open_diff(layout, "old\n", proposal.text) + + assert.equal(vim.inspect(proposal.text), vim.inspect(edit_and_accept(state, {}))) + end) + end + end + + for _, layout in ipairs(split_layouts) do + for _, proposal in ipairs(texts) do + it( + ("the %s layout accepts what :w writes after a typed line, from %s"):format(layout, proposal.name), + function() + local commands = { "normal! Gox" } + local expected = neovim_writes(proposal.text, commands) + local state = open_diff(layout, "old\n", proposal.text) + + assert.equal(vim.inspect(expected), vim.inspect(edit_and_accept(state, commands))) + end + ) + end + + it( + "the " .. layout .. " layout shows no difference between a CRLF proposal and the CRLF file with its text", + function() + local state = open_diff(layout, "a\r\nb\r\nc\r\n", "a\r\nb\r\nc\r\n") + + assert.same({}, changed_lines(state)) + end + ) + end + end) + end + -- Every layout accepts an unedited proposal as it is. The unified layout has no other way: its buffer is read-only. for _, layout in ipairs(all_layouts) do for _, old_file in ipairs(old_files) do From 349886993ae826ff7fca58e94977d98e2a733e20 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 17:27:38 +0000 Subject: [PATCH 6/8] test(mocks): give the mock vim.o the default 'fileformats' that Neovim has The diff now reads vim.o.fileformats when it builds a proposal buffer, and the mock vim.o had no such key. --- tests/mocks/vim.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mocks/vim.lua b/tests/mocks/vim.lua index 574f40ca6..d9c7192e5 100644 --- a/tests/mocks/vim.lua +++ b/tests/mocks/vim.lua @@ -1460,7 +1460,7 @@ vim._tab_windows[1] = { 1000 } vim._current_window = 1000 -- Global options table (minimal) -vim.o = setmetatable({ columns = 120, lines = 40, eventignore = "" }, { +vim.o = setmetatable({ columns = 120, lines = 40, eventignore = "", fileformats = "unix,dos" }, { __index = function(_, k) return vim._options[k] end, From 82805bd60b7fb780f43f1759d44e87ed83119c6c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 18:27:17 +0000 Subject: [PATCH 7/8] test(diff): pin that a 'fileformats' value takes dos exactly when it includes dos The spec ran 'fileformats' unix,dos, dos,unix and unix. A check that matched those two values exactly, or that took dos for any value except unix, kept it green. Add the single entry dos and unix,mac, with the cases that tell them apart: the unedited text, a typed line in a CRLF proposal against :w, and an equal CRLF text, which shows no difference. --- tests/integration/diff_accept_crlf_spec.lua | 33 +++++++++++++-------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/tests/integration/diff_accept_crlf_spec.lua b/tests/integration/diff_accept_crlf_spec.lua index 16892813b..c18111699 100644 --- a/tests/integration/diff_accept_crlf_spec.lua +++ b/tests/integration/diff_accept_crlf_spec.lua @@ -215,28 +215,37 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct -- 'fileformats' decides how Neovim reads a text, and its first entry is the 'fileformat' of a new buffer. So the diff -- must not take the 'fileformat' of the proposal buffer from the option, and it takes dos only for a value that - -- includes dos. The oracle reads the text under the same value. - for _, fileformats in ipairs({ "unix,dos", "dos,unix", "unix" }) do - describe("with 'fileformats' set to " .. fileformats, function() + -- includes dos, whatever else the value holds. The oracle reads the text under the same value. + local lf_text = { name = "an LF proposal", text = "a\nb\n" } + local mixed_text = { name = "a proposal with CRLF and LF line breaks", text = "a\r\nb\n" } + local crlf_text = { name = "a CRLF proposal", text = "a\r\nb\r\n" } + local all_texts = { lf_text, mixed_text, crlf_text } + local fileformats_values = { + { value = "unix,dos", typed = all_texts }, + { value = "dos,unix", typed = all_texts }, + { value = "unix", typed = all_texts }, + -- With the one entry dos, Neovim reads every file as dos, an LF file too, and `:w` writes CRLF for a line that + -- the user types into it. The diff keeps an LF proposal unix, so only the CRLF proposal is compared with `:w`. + { value = "dos", typed = { crlf_text } }, + -- A value without dos must not give dos. It holds mac, which Neovim also detects in some texts with CRs, so only + -- the CRLF proposal is compared with `:w`. + { value = "unix,mac", typed = { crlf_text } }, + } + for _, setting in ipairs(fileformats_values) do + describe("with 'fileformats' set to " .. setting.value, function() local saved_fileformats before_each(function() saved_fileformats = vim.o.fileformats - vim.o.fileformats = fileformats + vim.o.fileformats = setting.value end) after_each(function() vim.o.fileformats = saved_fileformats end) - local texts = { - { name = "an LF proposal", text = "a\nb\n" }, - { name = "a proposal with CRLF and LF line breaks", text = "a\r\nb\n" }, - { name = "a CRLF proposal", text = "a\r\nb\r\n" }, - } - for _, layout in ipairs(all_layouts) do - for _, proposal in ipairs(texts) do + for _, proposal in ipairs(all_texts) do it(("the %s layout accepts %s unedited"):format(layout, proposal.name), function() local state = open_diff(layout, "old\n", proposal.text) @@ -246,7 +255,7 @@ describe("the text a diff accepts when the proposal has CRLF line breaks", funct end for _, layout in ipairs(split_layouts) do - for _, proposal in ipairs(texts) do + for _, proposal in ipairs(setting.typed) do it( ("the %s layout accepts what :w writes after a typed line, from %s"):format(layout, proposal.name), function() From bfc4813a799587e41c1b386ef6281eb075953956 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 18:27:17 +0000 Subject: [PATCH 8/8] docs(diff): say that a CRLF proposal needs a line break to take dos A text with no line break at all has no CRLF line break, and the diff keeps it unix. The README line and the comment in _setup_blocking_diff said "every line break is CRLF", which also holds for such a text. --- lua/claudecode/README.md | 2 +- lua/claudecode/diff.lua | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index f58a8942d..3a0c516fc 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -181,7 +181,7 @@ Module map (direct files): - When the target buffer is dirty, diff setup raises `-32000` with message "Diff setup failed". Its `data` holds the inner error, which contains "Cannot create diff: file has unsaved changes" [diff.lua:M._setup_blocking_diff] - A diff resolves only from status `pending`. Save returns `FILE_SAVED` and the content. In the `unified` layout the content is the proposal's lines with a final line break exactly when the proposal ends with one, so every blank line at its end stays. Reject returns `DIFF_REJECTED` and the tab name [diff.lua:M._resolve_diff_as_saved] [diff_inline.lua:M.resolve_inline_as_saved] - A write of the proposal buffer (`:w`, `:wq`, `:x`, `ZZ`, which run `BufWriteCmd`) never writes the file. It accepts a pending diff and does nothing else. `:w` leaves the windows open until `close_tab`, and `:wq`, `:x` and `ZZ` also close the proposal window. A write after the diff has its answer sends nothing and does not fail (the handler never returns `true`, which would delete the autocmd and make the next `:w` fail with E676). It warns when the buffer holds edits that Claude never gets [diff.lua:M._write_proposal] -- In the `vertical` and `horizontal` layouts the saved content is the lines of the proposal buffer, joined with the line break of the buffer's `'fileformat'` (LF, CRLF for `dos`, CR for `mac`), plus a final line break only when the buffer's `'eol'` is on and the buffer has a line. When `'fileformats'` includes `dos`, a proposal whose every line break is CRLF gets `'fileformat'` `dos` and lines without their CRs, as the same text read from a file does, so a line that the user types or joins is saved with CRLF, as `:w` writes it. Any other proposal gets `'fileformat'` `unix` whatever the first entry of `'fileformats'` is, with its CRs inside its lines, which `:w` writes back unchanged. The `unified` layout's buffer is read-only, so its text is the proposal itself. The diff sets `'eol'` from the proposal when it creates the buffer, so a proposal without a final line break is saved without one, and the user's edits keep that ending. A buffer with no lines at all (an empty proposal, or a buffer the user emptied with `ggdG` or `:%delete`) is saved as an empty string, because `:w` writes no bytes for it whatever `'eol'` says. A buffer with one empty line is saved as one line break of the buffer's `'fileformat'` when `'eol'` is on and as `""` when it is off. The save tells the two apart with `wordcount().bytes`, because `nvim_buf_get_lines` returns one empty string for both [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved] +- In the `vertical` and `horizontal` layouts the saved content is the lines of the proposal buffer, joined with the line break of the buffer's `'fileformat'` (LF, CRLF for `dos`, CR for `mac`), plus a final line break only when the buffer's `'eol'` is on and the buffer has a line. When `'fileformats'` includes `dos`, a proposal that has a line break and only CRLF line breaks gets `'fileformat'` `dos` and lines without their CRs, as the same text read from a file does, so a line that the user types or joins is saved with CRLF, as `:w` writes it. Any other proposal gets `'fileformat'` `unix` whatever the first entry of `'fileformats'` is, with its CRs inside its lines, which `:w` writes back unchanged. The `unified` layout's buffer is read-only, so its text is the proposal itself. The diff sets `'eol'` from the proposal when it creates the buffer, so a proposal without a final line break is saved without one, and the user's edits keep that ending. A buffer with no lines at all (an empty proposal, or a buffer the user emptied with `ggdG` or `:%delete`) is saved as an empty string, because `:w` writes no bytes for it whatever `'eol'` says. A buffer with one empty line is saved as one line break of the buffer's `'fileformat'` when `'eol'` is on and as `""` when it is off. The save tells the two apart with `wordcount().bytes`, because `nvim_buf_get_lines` returns one empty string for both [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved] - A pending diff rejects at once when its proposal buffer is shown in no window, in the split and the unified layout: its last window closes (`:q`, `:close`, `c`, `:tabclose`), or that window switches to another buffer (`:edit`, `:buffer`, `:enew`, which only hide it). A copy of the proposal in another window or tab defers the reject until that copy leaves too. The switch path rejects on the next loop turn, and only when the diff is still the same one and still hidden. Both layouts keep the proposal buffer's `'bufhidden'` at `hide`: the buffer is modified, and `wipe` would make `:q` fail with E37 [diff.lua:M._register_reject_when_hidden] [diff_inline.lua:M.setup_inline_diff] - A diff that has its answer, by either one, clears `'modified'` on its proposal buffer. While the flag stayed set, `:wq` left the window open, and `:qa` stopped with E37 or E162 and moved a window onto the hidden buffer until `close_tab` deleted it [diff.lua:mark_proposal_unmodified] - The cleanup of a diff closes the proposal window, and the window that the split layout opened for the file's old text, only while each still shows its buffer. A window that the user switched to another buffer stays with that buffer. Neovim saves a window's options, `'diff'` included, for each buffer that leaves it, and sets them again when that buffer enters the window. So `:edit #`, or `:edit `, back to a file that left the window in diff mode turns diff mode on again, after the diff has ended. A window that never showed the buffer can take diff mode on in two ways. It can copy the current options of a window that shows the buffer: `:split ` copies the window that it splits, and a window in a new tab page, from `:tabnew ` or from `:tabnew` and `:buffer `, can get diff mode while a window of another tab page shows the file in diff mode. Or it can take a set that another window saved when the buffer left it: while no window shows the buffer, a set with diff mode on reaches a window of the same tab page, and does not reach a window of another tab page or a window that closed. The split layout's cleanup does one thing about that. After it closes its windows and deletes its buffers, and before it fires `ClaudeCodeDiffClosed`, it runs `:diffoff` in a kept window that shows the file's own buffer, and in each other window of the original side's tab page that shows the file and is the only window of that tab page with `'diff'` on, so nothing that a handler of that event starts is looked at. A diff needs a partner window, so the plugin takes a window that is the only one with `'diff'` on in its tab page for the leftover of the ended diff, and a diff of the user's own over two or more windows stays. The `'diff'` check leaves an option that the user set after turning diff mode off. `:diffoff` also puts back the options that diff mode set (`'scrollbind'`, `'cursorbind'`, `'foldmethod'`, `'foldcolumn'`, `'wrap'`). When Neovim refuses `:diffoff` in a window, as it does in the command-line window, the window keeps its diff mode [diff.lua:end_diff_mode_in_windows_that_show_the_file]. The cleanup looks only at the tab page of the original side and only once, so a file that comes back later gets the options that Neovim saved or copies, diff mode included, as on main. That covers `:edit #` or `:edit ` in a window that left the file, a new window for it such as `:split `, a window of another tab page that took diff mode from the diff's windows (`:tabnew `, `:tab sbuffer`, or `:tabnew` then `:buffer `), and a `ClaudeCodeDiffClosed` handler that brings the file back into a window. One limit comes from Neovim: the default `closeoff` in `'diffopt'` turns diff mode off in the last window that has it when another diff window closes. That ends a user's diff mode in a kept original-side window when the proposal window closes, and in a kept proposal window when the cleanup closes the window that the plugin opened for the file. It also ends it in a window that the diff never used, when the user joined that window to the diff with `:diffthis` and it is the last diff window left after the cleanup closes the plugin's window for the file. A user's diff mode over two or more windows stays. The unified layout needs no cleanup there: it sets `'signcolumn'` to `yes` with local scope, for the proposal buffer only, so once the user switches the window, `'signcolumn'` is what Neovim gives the new buffer in that window: the value of a window that shows or showed that buffer, a `:setlocal` value of the editor window included, or, for a buffer that no window showed, the window default (what `:set` gives, which a new split copies from the window it splits). The window keeps a value that the user sets afterwards. Two cases still close the window: a diff in its own tab (`open_in_new_tab`, or a tab that already has a diff) closes the whole tab, and the fallback window that the plugin opens in a tab that holds only the Claude terminal always closes [diff.lua:M._close_proposal_window] [diff.lua:M._cleanup_diff_state] [diff_inline.lua:M.cleanup_inline_diff] [diff_inline.lua:M.setup_inline_diff] diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 50ded8be6..83be47e85 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1784,11 +1784,11 @@ function M._setup_blocking_diff(params, resolution_callback) local new_unique_name = is_new_file and (tab_name .. " (NEW FILE - proposed)") or (tab_name .. " (proposed)") vim.api.nvim_buf_set_name(new_buffer, new_unique_name) - -- A proposal whose every line break is CRLF takes 'fileformat' dos, as the same text read from a file does, when - -- the user's 'fileformats' includes dos. The buffer then holds the lines without their CRs, as the file's own - -- buffer does, and the save gives a line that the user types a CRLF, as :w does. Any other text is unix, with its - -- CRs inside the lines, which :w writes back unchanged. 'fileformat' is set in both cases, because a new buffer - -- takes the first entry of 'fileformats', and that entry can be dos. + -- A proposal that has a line break, and only CRLF line breaks, takes 'fileformat' dos, as the same text read from + -- a file does, when the user's 'fileformats' includes dos. The buffer then holds the lines without their CRs, as + -- the file's own buffer does, and the save gives a line that the user types a CRLF, as :w does. Any other text is + -- unix, with its CRs inside the lines, which :w writes back unchanged. 'fileformat' is set in both cases, because + -- a new buffer takes the first entry of 'fileformats', and that entry can be dos. local proposal = params.new_file_contents local is_dos = vim.o.fileformats:find("dos", 1, true) ~= nil and proposal:find("\n", 1, true) ~= nil