diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d615a4550..0031d3ed5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -173,13 +173,20 @@ return true, result ### Resource Cleanup -Automatic cleanup on shutdown: +The `VimLeavePre` autocmd that `setup` creates runs `stop` only when the server runs. Without a server, it only clears the @-mention queue [lua/claudecode/init.lua:M.setup] [lua/claudecode/init.lua:clear_mention_queue]: ```lua +-- init.lua, M.setup (shortened) vim.api.nvim_create_autocmd("VimLeavePre", { + group = vim.api.nvim_create_augroup("ClaudeCodeShutdown", { clear = true }), callback = function() - M.stop() -- Stop server, try to remove lock file (only logs a warning when it cannot) - end + if M.state.server then + M.stop() -- Stop server, try to remove lock file (only logs a warning when it cannot) + else + -- Clear queue even if server isn't running + clear_mention_queue() + end + end, }) ``` @@ -266,4 +273,4 @@ vv netrw # Test with built-in netrw - **Localhost Only**: Server binds to 127.0.0.1 - **Resource Cleanup**: A `VimLeavePre` autocmd runs `stop` when the server runs. `stop` stops the server and tries to remove the lock file [lua/claudecode/init.lua:M.setup] [lua/claudecode/init.lua:M.stop]. A SIGKILL skips it and leaves the lock file - **In-memory State**: The plugin keeps, among other things, the latest selection [lua/claudecode/selection.lua:M.state], the proposed text of each open diff [lua/claudecode/diff.lua:M._register_diff_state], an input buffer for each connected client [lua/claudecode/server/client.lua:M.create_client], and the @-mentions that wait to go out [lua/claudecode/init.lua:mention_queue]. `lockfile.lua` reads `CLAUDE_CONFIG_DIR` once, when it loads [lua/claudecode/lockfile.lua:get_lock_dir] -- **Async and Blocking I/O**: Sockets and timers use libuv callbacks. The TCP server accepts connections and reads through callbacks, writes with libuv's asynchronous `write`, and runs its ping timer from a libuv timer [lua/claudecode/server/tcp.lua:M._bind_and_listen] [lua/claudecode/server/tcp.lua:M._handle_new_connection] [lua/claudecode/server/tcp.lua:M.start_ping_timer]. The selection debounce and the @-mention queue use timers [lua/claudecode/selection.lua:M.debounce_update] [lua/claudecode/init.lua:start_timer]. File I/O is synchronous and blocks Neovim until it returns: `lockfile.lua` calls `fs_open`, `fs_write` and `fs_close` without callbacks and uses `os.rename` and `os.remove` [lua/claudecode/lockfile.lua:M.create] [lua/claudecode/lockfile.lua:M.remove], and `diff.lua` reads and writes files with `io.open` [lua/claudecode/diff.lua:read_file] [lua/claudecode/diff.lua:create_temp_file] +- **Async and Blocking I/O**: Sockets and timers use libuv callbacks. The TCP server accepts connections and reads through callbacks, writes with libuv's asynchronous `write`, and runs its ping timer from a libuv timer [lua/claudecode/server/tcp.lua:M._bind_and_listen] [lua/claudecode/server/tcp.lua:M._handle_new_connection] [lua/claudecode/server/client.lua:write] [lua/claudecode/server/tcp.lua:M.start_ping_timer]. The selection debounce and the @-mention queue use timers [lua/claudecode/selection.lua:M.debounce_update] [lua/claudecode/init.lua:start_timer]. File I/O is synchronous and blocks Neovim until it returns: `lockfile.lua` calls `fs_open`, `fs_write` and `fs_close` without callbacks and uses `os.rename` and `os.remove` [lua/claudecode/lockfile.lua:M.create] [lua/claudecode/lockfile.lua:M.remove], and `diff.lua` reads and writes files with `io.open` [lua/claudecode/diff.lua:read_file] [lua/claudecode/diff.lua:create_temp_file] diff --git a/README.md b/README.md index d2986c0a4..acfb4ca5b 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ When Claude proposes changes, the plugin opens a native Neovim diff view: - **Accept**: `:w` (save) or `:ClaudeCodeDiffAccept` - **Reject**: `:q` or `:ClaudeCodeDiffDeny` -Run these in the proposed buffer. The plugin sets no keymaps for the diff. The `keys` in the Installation spec map `aa` to `:ClaudeCodeDiffAccept` and `ad` to `:ClaudeCodeDiffDeny`. +Run these in the proposal buffer. The plugin sets no keymaps for the diff. The `keys` in the Installation spec map `aa` to `:ClaudeCodeDiffAccept` and `ad` to `:ClaudeCodeDiffDeny`. You can edit Claude's suggestions before accepting them. diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index f4ac629b3..f9fdae306 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -178,13 +178,13 @@ Module map (direct files): - When closing a diff closes the window the cursor is in, the cursor goes back to the window it was in when the diff opened, in another tab too. A cursor the user moved to another window during the diff stays there. A diff in its own tab goes back to the tab the user was in instead. With `diff_opts.keep_terminal_focus = true` the cursor is in the Claude terminal during the diff, so it stays there. When no Claude terminal is shown, the cursor stays in the diff, so it goes back to the window it was in, as without the option [diff.lua:restore_cursor] - 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 proposed 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 proposed buffer, joined with line breaks, plus a final line break only when the buffer's `'eol'` is on. 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 [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved] -- A pending diff rejects at once when its proposed 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 proposed 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 proposed 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] +- 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. 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 [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] - Rejecting a new-file diff that is not in its own tab cleans it up at once when `diff_opts.on_new_file_reject` is `"keep_empty"` (the default), before `close_tab`. A `unified` diff gets no such cleanup [diff.lua:M._resolve_diff_as_rejected] [diff_inline.lua:M.resolve_inline_as_rejected] -- `BufDelete`, `BufUnload`, and `BufWipeout` on the proposed buffer reject a pending diff. `WinClosed` rejects it only when no other window in any tab shows the proposed buffer. The original file's buffer is not watched. The unified layout registers the same autocmds [diff.lua:register_diff_autocmds] [diff_inline.lua:M.setup_inline_diff] +- `BufDelete`, `BufUnload`, and `BufWipeout` on the proposal buffer reject a pending diff. `WinClosed` rejects it only when no other window in any tab shows the proposal buffer. The original file's buffer is not watched. The unified layout registers the same autocmds [diff.lua:register_diff_autocmds] [diff_inline.lua:M.setup_inline_diff] - `close_pending_diffs` and `close_diffs_for_client` close only `pending` diffs. `close_rejected_diffs` closes only `rejected` diffs. `close_all_diffs` closes every diff, except a saved diff that another client owns when it gets a `client_id`. Of the bulk closes, only `close_all_diffs` passes a `client_id` to `close_diff_by_tab_name`. The other bulk closes do not, so the owner check never keeps one of their diffs [diff.lua:M.close_pending_diffs] [diff.lua:M.close_all_diffs] [diff.lua:close_active_diffs] - A bulk close lists its diffs first, and each close fires `ClaudeCodeDiffClosed`. Before each close it reads the entry again: a diff a handler accepted since the list was made stays open when the close selects by status, and one a handler rejected closes as listed. `close_all_diffs` selects no status, so it also closes a diff accepted in the meantime. It closes the newest diff first, not in `pairs()` order, so when each close puts the cursor back, the cursor ends where it was before the oldest of them opened. `:ClaudeCodeCloseAllDiffs` runs three bulk closes, for pending, then rejected, then accepted diffs, so it closes newest first only within each status. With diffs of more than one status, the cursor can end in another window [diff.lua:close_active_diffs] - `:ClaudeCodeCloseAllDiffs` runs `close_pending_diffs`, `close_rejected_diffs`, then `close_saved_diffs` [init.lua:M._create_commands] @@ -205,7 +205,7 @@ Module map (direct files): - Each diff that opens in a new tab adds a buffer-local `BufEnter` autocmd (`startinsert`) to the Claude terminal buffer in `ClaudeCodeMCPDiff`, unless `terminal.auto_insert` is `false`, `diff_opts.hide_terminal_in_new_tab` is `true`, or no valid Claude terminal buffer exists. A `WinClosed` autocmd deletes it when the terminal window it was made for closes [diff.lua:display_terminal_in_new_tab] - Diffs create scratch buffers, splits, and tabs. They resize the terminal window and fire `User` events. Cleanup closes these and reloads unchanged file buffers 100 ms after a save [diff.lua:M.close_diff_by_tab_name] - When a blocking diff resolves, `open_diff_blocking` sends the reply through the global `_G.claude_deferred_responses[tostring(co)]`, then sets that entry to nil. Without an entry, it logs an error [diff.lua:M.open_diff_blocking] -- The native diff writes each temp file into its own session folder under `stdpath("cache")/claudecode_diffs`, or `claudecode_diffs_fallback` when it cannot create a directory in the first. The folder name includes the Neovim PID, so two running Neovim processes never share one. If both fail, `open_diff` returns `success = false` with an error that names both directories [diff.lua:create_temp_file]. A `BufDelete`/`BufWipeout` autocmd on the proposed buffer cleans up once, even when a wipe fires both events. It deletes the file and then its empty folder with `vim.fn.delete`, and warns once for each delete that fails. When the file delete fails, the folder is not empty, so its delete fails too and both warnings show [diff.lua:cleanup_temp_file]. `_cleanup_diff_state` does not [diff.lua:M._open_native_diff] +- The native diff writes each temp file into its own session folder under `stdpath("cache")/claudecode_diffs`, or `claudecode_diffs_fallback` when it cannot create a directory in the first. The folder name includes the Neovim PID, so two running Neovim processes never share one. If both fail, `open_diff` returns `success = false` with an error that names both directories [diff.lua:create_temp_file]. A `BufDelete`/`BufWipeout` autocmd on the proposal buffer cleans up once, even when a wipe fires both events. It deletes the file and then its empty folder with `vim.fn.delete`, and warns once for each delete that fails. When the file delete fails, the folder is not empty, so its delete fails too and both warnings show [diff.lua:cleanup_temp_file]. `_cleanup_diff_state` does not [diff.lua:M._open_native_diff] - `diff_inline.lua` creates the `claudecode_inline_diff` namespace when the module loads. It defines the four `ClaudeCodeInlineDiff*` highlight groups (with `default = true`) [diff_inline.lua:setup_highlights] and sets extmarks [diff_inline.lua:M.render_diff_buffer] - The plugin runs `git rev-parse --show-toplevel` at most once for each git root lookup [cwd.lua:M.git_root], and `health.check` runs ` --version` [health.lua:check_cli] - `logger` defers each log line through `vim.schedule`. ERROR and WARN go to `vim.notify`. Other levels go to `nvim_echo` [logger.lua:log]. The level is INFO until `setup` runs. `logger.setup` warns at once, not deferred, when `log_level` is missing or invalid, and uses INFO [logger.lua:M.setup]. The plugin's `setup` never reaches that warning: `config.apply` fills in `info` and raises on an invalid `log_level` first [config.lua:M.validate]