From ae7636158b3e9e4c8d1fea8e8db3d9c4f4895b62 Mon Sep 17 00:00:00 2001 From: Ozay Date: Sun, 1 Mar 2026 17:28:16 +0100 Subject: [PATCH 1/9] refactor: extract fold logic into tree-utils.setup_fold_keymaps() Deduplicate fold actions (open/close/toggle + recursive + all variants), update_tree_view, fold_bindings table, and keymap registration loop that were nearly identical in explorer/actions.lua and history/keymaps.lua. Also adds fold keymaps to config defaults, keymap help, and Node methods (is_foldable, expand/collapse_recursively) to the tree library. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 89 +++++++++++++++ lua/codediff/config.lua | 18 +++ lua/codediff/ui/explorer/keymaps.lua | 9 ++ lua/codediff/ui/history/keymaps.lua | 9 ++ lua/codediff/ui/keymap_help.lua | 16 +++ lua/codediff/ui/lib/tree-utils.lua | 159 +++++++++++++++++++++++++++ lua/codediff/ui/lib/tree.lua | 22 ++++ 7 files changed, 322 insertions(+) create mode 100644 CLAUDE.md create mode 100644 lua/codediff/ui/lib/tree-utils.lua diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7e220504 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Critical Rules (from AGENTS.md) + +- **NEVER commit code unless the user explicitly requests it.** After completing changes, STOP and wait for user to say "commit". Each commit request authorizes only ONE commit operation. +- Never add trailing spaces to any line in any file. + +## Build, Test, and Lint + +```bash +make # Build the C diff library +make test # Run all tests (C + Lua) +make test-c # Run C unit tests only +make test-lua # Run Lua integration tests only +make lint # Check Lua code style (stylua --check lua) +make format # Format Lua code (stylua lua) +make clean # Clean build artifacts +make bump-patch # Bump patch version +``` + +The `Makefile` is auto-generated from `CMakeLists.txt`. Always regenerate it via `cmake -B build` if it becomes stale. + +Lua code style: 2-space indent, 180-column width, double quotes, Unix line endings (`.stylua.toml`). + +## Architecture Overview + +### Two-layer design + +1. **C diff engine** (`libvscode-diff/`) — computes diffs using Myers algorithm with VSCode-parity output. Compiled to a shared library (`libvscode_diff_.so/dylib/dll`) and called from Lua via FFI. + +2. **Lua plugin** (`lua/codediff/`) — handles UI, git operations, configuration, and session lifecycle. Lazy-loaded on first `:CodeDiff` invocation. + +### Plugin entry flow + +``` +plugin/codediff.lua ← Neovim auto-loads this (registers :CodeDiff, highlights, virtual scheme) + → lua/codediff/init.lua ← Public Lua API (setup, navigation) + → lua/codediff/commands.lua ← Dispatches 5 subcommands: merge, file, dir, history, install +``` + +### Core modules (`lua/codediff/core/`) + +| File | Role | +|------|------| +| `diff.lua` | FFI bridge to C library; `compute_diff()` | +| `git.lua` | Async git operations (uses `vim.system`) | +| `config.lua` | All defaults; `setup(opts)` merges via `vim.tbl_deep_extend` | +| `installer.lua` | Auto-downloads versioned C binary from GitHub releases | +| `virtual_file.lua` | Registers a custom buffer scheme for git history files | +| `args.lua` | Parses `:CodeDiff` command arguments | + +### UI modules (`lua/codediff/ui/`) + +| Path | Role | +|------|------| +| `core.lua` | Diff rendering engine — applies line/char extmarks | +| `view/` | View router: chooses side-by-side vs inline layout | +| `lifecycle/` | Per-tabpage session state (config + mutable state) | +| `explorer/` | Git status explorer panel (list/tree view) | +| `history/` | Commit history panel | +| `conflict/` | Merge conflict resolution UI | +| `inline.lua` | Unified/inline diff layout | +| `highlights.lua` | Defines all highlight groups | +| `layout.lua` | Window layout management | +| `auto_refresh.lua` | Watches buffer changes to live-update the diff | +| `keymap_help.lua` | Floating help window (`g?`) | + +### Session state + +State is stored **per tabpage** in `lua/codediff/ui/lifecycle/`. There is an immutable session config and a mutable state accessed via typed accessor functions. Do not store session state as module-level variables. + +### Configuration + +All user-facing options live in `lua/codediff/config.lua` (`M.defaults`). Key namespaces: `highlights`, `diff`, `explorer`, `history`, `keymaps`. Keymaps are split into `view`, `explorer`, `history`, and `conflict` sub-tables. + +### C library versioning + +`VERSION` file is the single source of truth. The C library filename includes the version (e.g., `libvscode_diff_2.39.1.so`). `lua/codediff/core/installer.lua` and `lua/codediff/core/diff.lua` both read this version at runtime. When bumping version, run `make bump-patch/minor/major` rather than editing `VERSION` directly. + +## Tests + +Tests use [plenary.nvim](https://github.com/nvim-lua/plenary.nvim). Each spec is in `tests/*_spec.lua`. Shared helpers (git repo setup, temp dirs) are in `tests/helpers.lua`. The test runner is `tests/run_plenary_tests.sh`. + +To run a single Lua spec file: +```bash +nvim --headless -u tests/init.lua -c "PlenaryBustedFile tests/.lua" +``` diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index 8a5efe94..37a57342 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -109,11 +109,29 @@ M.defaults = { restore = "X", -- Discard changes to file (restore to index/HEAD) toggle_changes = "gu", -- Toggle Changes (unstaged) group visibility toggle_staged = "gs", -- Toggle Staged Changes group visibility + -- Fold keymaps (Vim-style) + fold_open = "zo", -- Open fold (expand current node) + fold_open_recursive = "zO", -- Open fold recursively (expand current node and all descendants) + fold_close = "zc", -- Close fold (collapse current node) + fold_close_recursive = "zC", -- Close fold recursively (collapse current node and all descendants) + fold_toggle = "za", -- Toggle fold (expand/collapse current node) + fold_toggle_recursive = "zA", -- Toggle fold recursively + fold_open_all = "zR", -- Open all folds in tree + fold_close_all = "zM", -- Close all folds in tree }, history = { select = "", -- Select commit/file or toggle expand toggle_view_mode = "i", -- Toggle between 'list' and 'tree' views refresh = "R", -- Refresh history (re-fetch commits) + -- Fold keymaps (Vim-style, apply to directory nodes only) + fold_open = "zo", + fold_open_recursive = "zO", + fold_close = "zc", + fold_close_recursive = "zC", + fold_toggle = "za", + fold_toggle_recursive = "zA", + fold_open_all = "zR", + fold_close_all = "zM", }, -- Conflict mode keymaps (only active in merge conflict views) conflict = { diff --git a/lua/codediff/ui/explorer/keymaps.lua b/lua/codediff/ui/explorer/keymaps.lua index 2ed79a20..8af9cfdb 100644 --- a/lua/codediff/ui/explorer/keymaps.lua +++ b/lua/codediff/ui/explorer/keymaps.lua @@ -2,6 +2,7 @@ local config = require("codediff.config") local actions_module = require("codediff.ui.explorer.actions") local refresh_module = require("codediff.ui.explorer.refresh") +local tree_utils = require("codediff.ui.lib.tree-utils") local M = {} @@ -171,6 +172,14 @@ function M.setup(explorer) end, vim.tbl_extend("force", map_options, { buffer = split.bufnr, desc = "Toggle Staged Changes visibility" })) end + -- Fold keymaps (Vim-style: zo/zO/zc/zC/za/zA/zR/zM) + tree_utils.setup_fold_keymaps({ + tree = tree, + winid = split.winid, + keymaps = explorer_keymaps, + bufnr = split.bufnr, + }) + -- Note: next_file/prev_file keymaps are set via view/keymaps.lua:setup_all_keymaps() -- which uses set_tab_keymap to set them on all buffers including explorer end diff --git a/lua/codediff/ui/history/keymaps.lua b/lua/codediff/ui/history/keymaps.lua index 394e79c1..d38d7ac9 100644 --- a/lua/codediff/ui/history/keymaps.lua +++ b/lua/codediff/ui/history/keymaps.lua @@ -1,5 +1,6 @@ -- Keymaps for history panel local config = require("codediff.config") +local tree_utils = require("codediff.ui.lib.tree-utils") local M = {} @@ -120,6 +121,14 @@ function M.setup(history, opts) refresh_module.refresh(history) end, vim.tbl_extend("force", map_options, { buffer = split.bufnr, desc = "Refresh history" })) end + + -- Fold keymaps (Vim-style: zo/zO/zc/zC/za/zA/zR/zM — directory nodes only) + tree_utils.setup_fold_keymaps({ + tree = tree, + winid = split.winid, + keymaps = history_keymaps, + bufnr = split.bufnr, + }) end return M diff --git a/lua/codediff/ui/keymap_help.lua b/lua/codediff/ui/keymap_help.lua index 758c6863..c065b217 100644 --- a/lua/codediff/ui/keymap_help.lua +++ b/lua/codediff/ui/keymap_help.lua @@ -80,6 +80,14 @@ local function build_sections(keymaps, is_explorer, is_history, is_conflict) { ekm.restore, "Discard changes to file" }, { ekm.toggle_changes, "Toggle Changes visibility" }, { ekm.toggle_staged, "Toggle Staged visibility" }, + { ekm.fold_open, "Open fold" }, + { ekm.fold_open_recursive, "Open fold recursively" }, + { ekm.fold_close, "Close fold" }, + { ekm.fold_close_recursive, "Close fold recursively" }, + { ekm.fold_toggle, "Toggle fold" }, + { ekm.fold_toggle_recursive, "Toggle fold recursively" }, + { ekm.fold_open_all, "Open all folds" }, + { ekm.fold_close_all, "Close all folds" }, }) ) end @@ -93,6 +101,14 @@ local function build_sections(keymaps, is_explorer, is_history, is_conflict) { hkm.select, "Select commit/file or toggle" }, { hkm.toggle_view_mode, "Toggle list/tree view" }, { hkm.refresh, "Refresh history" }, + { hkm.fold_open, "Open fold" }, + { hkm.fold_open_recursive, "Open fold recursively" }, + { hkm.fold_close, "Close fold" }, + { hkm.fold_close_recursive, "Close fold recursively" }, + { hkm.fold_toggle, "Toggle fold" }, + { hkm.fold_toggle_recursive, "Toggle fold recursively" }, + { hkm.fold_open_all, "Open all folds" }, + { hkm.fold_close_all, "Close all folds" }, }) ) end diff --git a/lua/codediff/ui/lib/tree-utils.lua b/lua/codediff/ui/lib/tree-utils.lua new file mode 100644 index 00000000..c1642e8e --- /dev/null +++ b/lua/codediff/ui/lib/tree-utils.lua @@ -0,0 +1,159 @@ +local M = {} + +function M.find_foldable_node(node, tree) + if node:is_foldable() then + return node + end + + if node._parent_id then + local parent = tree:get_node(node._parent_id) + if parent:is_foldable() then + return parent + end + end + return nil +end + +function M.find_foldable_at_cursor(tree) + local node = tree:get_node() + if not node then + return nil + end + return M.find_foldable_node(node, tree) +end + +function M.get_root_node(tree) + local node = tree:get_node() + local function find_root(n) + if not n._parent_id then + return n + end + local parent = tree:get_node(n._parent_id) + if parent then + return find_root(parent) + else + return n + end + end + return find_root(node) +end + +-- Setup all fold-related keymaps on a tree buffer. +-- @param opts table { tree, winid, keymaps, bufnr } +function M.setup_fold_keymaps(opts) + local tree = opts.tree + local winid = opts.winid + local keymaps = opts.keymaps + local bufnr = opts.bufnr + + local function update_tree_view(node) + tree:render() + if node._line then + vim.api.nvim_win_set_cursor(winid, { node._line, 0 }) + end + end + + local function fold_open() + local node = M.find_foldable_at_cursor(tree) + if not node then + return + end + node:expand() + update_tree_view(node) + end + + local function fold_open_recursive() + local node = M.find_foldable_at_cursor(tree) + if not node then + return + end + node:expand_recursively() + update_tree_view(node) + end + + local function fold_close() + local node = M.find_foldable_at_cursor(tree) + if not node then + return + end + node:collapse() + update_tree_view(node) + end + + local function fold_close_recursive() + local node = M.find_foldable_at_cursor(tree) + if not node then + return + end + node:collapse_recursively() + update_tree_view(node) + end + + local function fold_toggle() + local node = M.find_foldable_at_cursor(tree) + if not node then + return + end + if node:is_expanded() then + node:collapse() + else + node:expand() + end + update_tree_view(node) + end + + local function fold_toggle_recursive() + local node = M.find_foldable_at_cursor(tree) + if not node then + return + end + if node:is_expanded() then + node:collapse_recursively() + else + node:expand_recursively() + end + update_tree_view(node) + end + + local function fold_open_all() + local root = M.get_root_node(tree) + if not root then + return + end + root:expand_recursively() + update_tree_view(root) + end + + local function fold_close_all() + local root = M.get_root_node(tree) + if not root then + return + end + root:collapse_recursively() + update_tree_view(root) + end + + local fold_bindings = { + { key = "fold_open", fn = fold_open, desc = "Open fold" }, + { key = "fold_open_recursive", fn = fold_open_recursive, desc = "Open fold recursively" }, + { key = "fold_close", fn = fold_close, desc = "Close fold" }, + { key = "fold_close_recursive", fn = fold_close_recursive, desc = "Close fold recursively" }, + { key = "fold_toggle", fn = fold_toggle, desc = "Toggle fold" }, + { key = "fold_toggle_recursive", fn = fold_toggle_recursive, desc = "Toggle fold recursively" }, + { key = "fold_open_all", fn = fold_open_all, desc = "Open all folds" }, + { key = "fold_close_all", fn = fold_close_all, desc = "Close all folds" }, + } + local map_options = { noremap = true, silent = true, nowait = true } + for _, binding in ipairs(fold_bindings) do + if keymaps[binding.key] then + vim.keymap.set( + "n", + keymaps[binding.key], + binding.fn, + vim.tbl_extend("force", map_options, { buffer = bufnr, desc = binding.desc }) + ) + end + end +end + +return M diff --git a/lua/codediff/ui/lib/tree.lua b/lua/codediff/ui/lib/tree.lua index 90dfc4da..7ff8089a 100644 --- a/lua/codediff/ui/lib/tree.lua +++ b/lua/codediff/ui/lib/tree.lua @@ -41,6 +41,16 @@ end -- Node methods +local function set_expanded_recursively(node, expanded) + if not node:is_foldable() then + return + end + node._expanded = expanded + for _, child in ipairs(node._children) do + set_expanded_recursively(child, expanded) + end +end + function Node:get_id() return self._id end @@ -49,14 +59,26 @@ function Node:is_expanded() return self._expanded end +function Node:is_foldable() + return self.data and (self.data.type == "group" or self.data.type == "directory") or false +end + function Node:expand() self._expanded = true end +function Node:expand_recursively() + set_expanded_recursively(self, true) +end + function Node:collapse() self._expanded = false end +function Node:collapse_recursively() + set_expanded_recursively(self, false) +end + function Node:has_children() return #self._children > 0 end From b47c202625de2859d8905718bd90bb91df60486f Mon Sep 17 00:00:00 2001 From: Ozay Date: Tue, 3 Mar 2026 17:56:17 +0100 Subject: [PATCH 2/9] refactor(tree): rename tree-utils to tree_utils and fix nil checks - Rename tree-utils.lua to tree_utils.lua (snake_case convention) - Update all require() calls in explorer and history keymaps - Add nil guards in find_foldable_node() and get_root_node() - Retrieve winid dynamically via vim.fn.bufwinid() instead of passing it - Fix set_expanded_recursively() logic (was skipping foldable nodes) Co-Authored-By: Claude Opus 4.6 --- lua/codediff/ui/explorer/keymaps.lua | 3 +-- lua/codediff/ui/history/keymaps.lua | 3 +-- lua/codediff/ui/lib/tree.lua | 5 ++--- .../ui/lib/{tree-utils.lua => tree_utils.lua} | 19 +++++++++---------- 4 files changed, 13 insertions(+), 17 deletions(-) rename lua/codediff/ui/lib/{tree-utils.lua => tree_utils.lua} (90%) diff --git a/lua/codediff/ui/explorer/keymaps.lua b/lua/codediff/ui/explorer/keymaps.lua index 8af9cfdb..4256c023 100644 --- a/lua/codediff/ui/explorer/keymaps.lua +++ b/lua/codediff/ui/explorer/keymaps.lua @@ -2,7 +2,7 @@ local config = require("codediff.config") local actions_module = require("codediff.ui.explorer.actions") local refresh_module = require("codediff.ui.explorer.refresh") -local tree_utils = require("codediff.ui.lib.tree-utils") +local tree_utils = require("codediff.ui.lib.tree_utils") local M = {} @@ -175,7 +175,6 @@ function M.setup(explorer) -- Fold keymaps (Vim-style: zo/zO/zc/zC/za/zA/zR/zM) tree_utils.setup_fold_keymaps({ tree = tree, - winid = split.winid, keymaps = explorer_keymaps, bufnr = split.bufnr, }) diff --git a/lua/codediff/ui/history/keymaps.lua b/lua/codediff/ui/history/keymaps.lua index d38d7ac9..5622d646 100644 --- a/lua/codediff/ui/history/keymaps.lua +++ b/lua/codediff/ui/history/keymaps.lua @@ -1,6 +1,6 @@ -- Keymaps for history panel local config = require("codediff.config") -local tree_utils = require("codediff.ui.lib.tree-utils") +local tree_utils = require("codediff.ui.lib.tree_utils") local M = {} @@ -125,7 +125,6 @@ function M.setup(history, opts) -- Fold keymaps (Vim-style: zo/zO/zc/zC/za/zA/zR/zM — directory nodes only) tree_utils.setup_fold_keymaps({ tree = tree, - winid = split.winid, keymaps = history_keymaps, bufnr = split.bufnr, }) diff --git a/lua/codediff/ui/lib/tree.lua b/lua/codediff/ui/lib/tree.lua index 7ff8089a..e1485118 100644 --- a/lua/codediff/ui/lib/tree.lua +++ b/lua/codediff/ui/lib/tree.lua @@ -42,10 +42,9 @@ end -- Node methods local function set_expanded_recursively(node, expanded) - if not node:is_foldable() then - return + if node:is_foldable() then + node._expanded = expanded end - node._expanded = expanded for _, child in ipairs(node._children) do set_expanded_recursively(child, expanded) end diff --git a/lua/codediff/ui/lib/tree-utils.lua b/lua/codediff/ui/lib/tree_utils.lua similarity index 90% rename from lua/codediff/ui/lib/tree-utils.lua rename to lua/codediff/ui/lib/tree_utils.lua index c1642e8e..f96a15d1 100644 --- a/lua/codediff/ui/lib/tree-utils.lua +++ b/lua/codediff/ui/lib/tree_utils.lua @@ -7,7 +7,7 @@ function M.find_foldable_node(node, tree) if node._parent_id then local parent = tree:get_node(node._parent_id) - if parent:is_foldable() then + if parent and parent:is_foldable() then return parent end end @@ -24,6 +24,10 @@ end function M.get_root_node(tree) local node = tree:get_node() + if not node then + return + end + local function find_root(n) if not n._parent_id then return n @@ -39,16 +43,16 @@ function M.get_root_node(tree) end -- Setup all fold-related keymaps on a tree buffer. --- @param opts table { tree, winid, keymaps, bufnr } +-- @param opts table { tree, keymaps, bufnr } function M.setup_fold_keymaps(opts) local tree = opts.tree - local winid = opts.winid local keymaps = opts.keymaps local bufnr = opts.bufnr local function update_tree_view(node) tree:render() - if node._line then + local winid = vim.fn.bufwinid(bufnr) + if node._line and winid ~= -1 then vim.api.nvim_win_set_cursor(winid, { node._line, 0 }) end end @@ -146,12 +150,7 @@ function M.setup_fold_keymaps(opts) local map_options = { noremap = true, silent = true, nowait = true } for _, binding in ipairs(fold_bindings) do if keymaps[binding.key] then - vim.keymap.set( - "n", - keymaps[binding.key], - binding.fn, - vim.tbl_extend("force", map_options, { buffer = bufnr, desc = binding.desc }) - ) + vim.keymap.set("n", keymaps[binding.key], binding.fn, vim.tbl_extend("force", map_options, { buffer = bufnr, desc = binding.desc })) end end end From cc193e3114fa9ddd7c85fda59d24f434c0d0ac54 Mon Sep 17 00:00:00 2001 From: Ozay Date: Tue, 3 Mar 2026 17:56:56 +0100 Subject: [PATCH 3/9] chore: untrack CLAUDE.md CLAUDE.md is a local-only file for Claude Code guidance. Remove it from git tracking. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 89 ------------------------------------------------------- 1 file changed, 89 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7e220504..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,89 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Critical Rules (from AGENTS.md) - -- **NEVER commit code unless the user explicitly requests it.** After completing changes, STOP and wait for user to say "commit". Each commit request authorizes only ONE commit operation. -- Never add trailing spaces to any line in any file. - -## Build, Test, and Lint - -```bash -make # Build the C diff library -make test # Run all tests (C + Lua) -make test-c # Run C unit tests only -make test-lua # Run Lua integration tests only -make lint # Check Lua code style (stylua --check lua) -make format # Format Lua code (stylua lua) -make clean # Clean build artifacts -make bump-patch # Bump patch version -``` - -The `Makefile` is auto-generated from `CMakeLists.txt`. Always regenerate it via `cmake -B build` if it becomes stale. - -Lua code style: 2-space indent, 180-column width, double quotes, Unix line endings (`.stylua.toml`). - -## Architecture Overview - -### Two-layer design - -1. **C diff engine** (`libvscode-diff/`) — computes diffs using Myers algorithm with VSCode-parity output. Compiled to a shared library (`libvscode_diff_.so/dylib/dll`) and called from Lua via FFI. - -2. **Lua plugin** (`lua/codediff/`) — handles UI, git operations, configuration, and session lifecycle. Lazy-loaded on first `:CodeDiff` invocation. - -### Plugin entry flow - -``` -plugin/codediff.lua ← Neovim auto-loads this (registers :CodeDiff, highlights, virtual scheme) - → lua/codediff/init.lua ← Public Lua API (setup, navigation) - → lua/codediff/commands.lua ← Dispatches 5 subcommands: merge, file, dir, history, install -``` - -### Core modules (`lua/codediff/core/`) - -| File | Role | -|------|------| -| `diff.lua` | FFI bridge to C library; `compute_diff()` | -| `git.lua` | Async git operations (uses `vim.system`) | -| `config.lua` | All defaults; `setup(opts)` merges via `vim.tbl_deep_extend` | -| `installer.lua` | Auto-downloads versioned C binary from GitHub releases | -| `virtual_file.lua` | Registers a custom buffer scheme for git history files | -| `args.lua` | Parses `:CodeDiff` command arguments | - -### UI modules (`lua/codediff/ui/`) - -| Path | Role | -|------|------| -| `core.lua` | Diff rendering engine — applies line/char extmarks | -| `view/` | View router: chooses side-by-side vs inline layout | -| `lifecycle/` | Per-tabpage session state (config + mutable state) | -| `explorer/` | Git status explorer panel (list/tree view) | -| `history/` | Commit history panel | -| `conflict/` | Merge conflict resolution UI | -| `inline.lua` | Unified/inline diff layout | -| `highlights.lua` | Defines all highlight groups | -| `layout.lua` | Window layout management | -| `auto_refresh.lua` | Watches buffer changes to live-update the diff | -| `keymap_help.lua` | Floating help window (`g?`) | - -### Session state - -State is stored **per tabpage** in `lua/codediff/ui/lifecycle/`. There is an immutable session config and a mutable state accessed via typed accessor functions. Do not store session state as module-level variables. - -### Configuration - -All user-facing options live in `lua/codediff/config.lua` (`M.defaults`). Key namespaces: `highlights`, `diff`, `explorer`, `history`, `keymaps`. Keymaps are split into `view`, `explorer`, `history`, and `conflict` sub-tables. - -### C library versioning - -`VERSION` file is the single source of truth. The C library filename includes the version (e.g., `libvscode_diff_2.39.1.so`). `lua/codediff/core/installer.lua` and `lua/codediff/core/diff.lua` both read this version at runtime. When bumping version, run `make bump-patch/minor/major` rather than editing `VERSION` directly. - -## Tests - -Tests use [plenary.nvim](https://github.com/nvim-lua/plenary.nvim). Each spec is in `tests/*_spec.lua`. Shared helpers (git repo setup, temp dirs) are in `tests/helpers.lua`. The test runner is `tests/run_plenary_tests.sh`. - -To run a single Lua spec file: -```bash -nvim --headless -u tests/init.lua -c "PlenaryBustedFile tests/.lua" -``` From b278ba11c3c1f3983b9e2f4c5404bf9a37468b45 Mon Sep 17 00:00:00 2001 From: Ozay Date: Tue, 3 Mar 2026 18:23:12 +0100 Subject: [PATCH 4/9] test(tree): add unit tests for is_foldable and recursive expand/collapse Cover is_foldable() for all node types (group, directory, commit, title, file, and untyped). Cover expand_recursively() and collapse_recursively() propagation rules, including the history scenario where commit nodes are non-foldable. Cover interaction with render() to verify buffer output reflects fold state. Co-Authored-By: Claude Opus 4.6 --- tests/ui/lib/lib_spec.lua | 125 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/ui/lib/lib_spec.lua b/tests/ui/lib/lib_spec.lua index 6d4d0e52..d396ea65 100644 --- a/tests/ui/lib/lib_spec.lua +++ b/tests/ui/lib/lib_spec.lua @@ -385,4 +385,129 @@ describe("Tree", function() local details = mark[4] assert.equals("Comment", details.hl_group) end) + + -- is_foldable ----------------------------------------------------- + + it("is_foldable() returns true for group nodes", function() + local node = Tree.Node({ text = "grp", data = { type = "group" } }) + assert.is_true(node:is_foldable()) + end) + + it("is_foldable() returns true for directory nodes", function() + local node = Tree.Node({ text = "dir", data = { type = "directory" } }) + assert.is_true(node:is_foldable()) + end) + + it("is_foldable() returns false for commit nodes even with children", function() + local child = Tree.Node({ text = "f", data = { type = "file" } }) + local node = Tree.Node({ text = "commit", data = { type = "commit" } }, { child }) + assert.is_true(node:has_children()) + assert.is_false(node:is_foldable()) + end) + + it("is_foldable() returns false for title nodes", function() + local node = Tree.Node({ text = "title", data = { type = "title" } }) + assert.is_false(node:is_foldable()) + end) + + it("is_foldable() returns false for file nodes", function() + local node = Tree.Node({ text = "file", data = { type = "file" } }) + assert.is_false(node:is_foldable()) + end) + + it("is_foldable() returns false for nodes without data.type", function() + local node = Tree.Node({ text = "plain" }) + assert.is_false(node:is_foldable()) + end) + + -- expand_recursively / collapse_recursively ----------------------- + + it("expand_recursively() expands foldable descendants only", function() + local file = Tree.Node({ text = "f", id = "ef", data = { type = "file" } }) + local dir = Tree.Node({ text = "d", id = "ed", data = { type = "directory" } }, { file }) + local group = Tree.Node({ text = "g", id = "eg", data = { type = "group" } }, { dir }) + Tree({ bufnr = bufnr, nodes = { group } }) + + group:expand_recursively() + + assert.is_true(group:is_expanded()) + assert.is_true(dir:is_expanded()) + assert.is_false(file:is_expanded()) + end) + + it("expand_recursively() on non-foldable node still propagates to foldable children", function() + local file = Tree.Node({ text = "f", id = "hf", data = { type = "file" } }) + local dir = Tree.Node({ text = "d", id = "hd", data = { type = "directory" } }, { file }) + local commit = Tree.Node({ text = "c", id = "hc", data = { type = "commit" } }, { dir }) + Tree({ bufnr = bufnr, nodes = { commit } }) + + commit:expand_recursively() + + assert.is_false(commit:is_expanded()) + assert.is_true(dir:is_expanded()) + assert.is_false(file:is_expanded()) + end) + + it("collapse_recursively() collapses all foldable descendants", function() + local dir = Tree.Node({ text = "d", id = "cd", data = { type = "directory" } }) + local group = Tree.Node({ text = "g", id = "cg", data = { type = "group" } }, { dir }) + Tree({ bufnr = bufnr, nodes = { group } }) + + group:expand() + dir:expand() + assert.is_true(group:is_expanded()) + assert.is_true(dir:is_expanded()) + + group:collapse_recursively() + + assert.is_false(group:is_expanded()) + assert.is_false(dir:is_expanded()) + end) + + it("collapse_recursively() on non-foldable node propagates to foldable children", function() + local dir = Tree.Node({ text = "d", id = "pd", data = { type = "directory" } }) + local commit = Tree.Node({ text = "c", id = "pc", data = { type = "commit" } }, { dir }) + Tree({ bufnr = bufnr, nodes = { commit } }) + + commit:expand() + dir:expand() + + commit:collapse_recursively() + + -- commit is not foldable so its _expanded stays as-is (set_expanded_recursively skips it) + assert.is_true(commit:is_expanded()) + assert.is_false(dir:is_expanded()) + end) + + -- Interaction with render ----------------------------------------- + + it("expand_recursively() + render() shows foldable children in buffer", function() + local file = Tree.Node({ text = "file.lua", id = "rf", data = { type = "file" } }) + local dir = Tree.Node({ text = "src/", id = "rd", data = { type = "directory" } }, { file }) + local group = Tree.Node({ text = "Changes", id = "rg", data = { type = "group" } }, { dir }) + local tree = Tree({ bufnr = bufnr, nodes = { group } }) + + group:expand_recursively() + tree:render() + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.same({ "Changes", "src/", "file.lua" }, lines) + end) + + it("collapse_recursively() + render() shows only root node", function() + local file = Tree.Node({ text = "file.lua", id = "cf2", data = { type = "file" } }) + local dir = Tree.Node({ text = "src/", id = "cd2", data = { type = "directory" } }, { file }) + local group = Tree.Node({ text = "Changes", id = "cg2", data = { type = "group" } }, { dir }) + local tree = Tree({ bufnr = bufnr, nodes = { group } }) + + group:expand_recursively() + tree:render() + assert.equals(3, #vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) + + group:collapse_recursively() + tree:render() + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.same({ "Changes" }, lines) + end) end) From fbebb16e4bea3d2cbc726d95ccfb96f1ce6be782 Mon Sep 17 00:00:00 2001 From: Ozay Date: Tue, 3 Mar 2026 18:31:58 +0100 Subject: [PATCH 5/9] docs: add missing keymaps to README configuration Document fold keymaps (zo/zO/zc/zC/za/zA/zR/zM) for explorer and history panels, history refresh keymap (R), and conflict accept-all shortcuts (cT/cO/cB/cX). Co-Authored-By: Claude Opus 4.6 --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index d65d4d73..3f0c42cf 100644 --- a/README.md +++ b/README.md @@ -163,16 +163,40 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e restore = "X", -- Discard changes (restore file) toggle_changes = "gu", -- Toggle Changes (unstaged) group visibility toggle_staged = "gs", -- Toggle Staged Changes group visibility + -- Fold keymaps (Vim-style) + fold_open = "zo", -- Open fold (expand current node) + fold_open_recursive = "zO", -- Open fold recursively (expand all descendants) + fold_close = "zc", -- Close fold (collapse current node) + fold_close_recursive = "zC", -- Close fold recursively (collapse all descendants) + fold_toggle = "za", -- Toggle fold (expand/collapse current node) + fold_toggle_recursive = "zA", -- Toggle fold recursively + fold_open_all = "zR", -- Open all folds in tree + fold_close_all = "zM", -- Close all folds in tree }, history = { select = "", -- Select commit/file or toggle expand toggle_view_mode = "i", -- Toggle between 'list' and 'tree' views + refresh = "R", -- Refresh history (re-fetch commits) + -- Fold keymaps (Vim-style, apply to directory nodes only) + fold_open = "zo", -- Open fold (expand current node) + fold_open_recursive = "zO", -- Open fold recursively (expand all descendants) + fold_close = "zc", -- Close fold (collapse current node) + fold_close_recursive = "zC", -- Close fold recursively (collapse all descendants) + fold_toggle = "za", -- Toggle fold (expand/collapse current node) + fold_toggle_recursive = "zA", -- Toggle fold recursively + fold_open_all = "zR", -- Open all folds in tree + fold_close_all = "zM", -- Close all folds in tree }, conflict = { accept_incoming = "ct", -- Accept incoming (theirs/left) change accept_current = "co", -- Accept current (ours/right) change accept_both = "cb", -- Accept both changes (incoming first) discard = "cx", -- Discard both, keep base + -- Accept all (whole file) - uppercase versions + accept_all_incoming = "cT", -- Accept ALL incoming changes + accept_all_current = "cO", -- Accept ALL current changes + accept_all_both = "cB", -- Accept ALL both changes + discard_all = "cX", -- Discard ALL, reset to base next_conflict = "]x", -- Jump to next conflict prev_conflict = "[x", -- Jump to previous conflict diffget_incoming = "2do", -- Get hunk from incoming (left/theirs) buffer From 52db63ee83b36d2c844ade8462d35310dc9ec9a3 Mon Sep 17 00:00:00 2001 From: Ozay Date: Tue, 3 Mar 2026 18:42:21 +0100 Subject: [PATCH 6/9] fix(tree): warn when fold keymap key is undefined Add a warning notification with source location when a fold binding key is missing from the keymaps config. Co-Authored-By: Claude Opus 4.6 --- lua/codediff/ui/lib/tree_utils.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/codediff/ui/lib/tree_utils.lua b/lua/codediff/ui/lib/tree_utils.lua index f96a15d1..71946022 100644 --- a/lua/codediff/ui/lib/tree_utils.lua +++ b/lua/codediff/ui/lib/tree_utils.lua @@ -151,6 +151,10 @@ function M.setup_fold_keymaps(opts) for _, binding in ipairs(fold_bindings) do if keymaps[binding.key] then vim.keymap.set("n", keymaps[binding.key], binding.fn, vim.tbl_extend("force", map_options, { buffer = bufnr, desc = binding.desc })) + else + local info = debug.getinfo(1, "Sl") + local location = string.format("%s:%d", info.short_src, info.currentline) + vim.notify(string.format("No keymap defined for %s, skipping fold keymap [%s]", binding.key, location), vim.log.levels.WARN) end end end From f2eb1360e789395245f580f04dfb660a7379b8d6 Mon Sep 17 00:00:00 2001 From: Ozay Date: Tue, 3 Mar 2026 20:47:49 +0100 Subject: [PATCH 7/9] fix(tree): distinguish nil vs false for fold keymaps Extract keymaps[binding.key] into a local variable and check for nil specifically, so that false can be used to intentionally disable a fold keymap without triggering a warning. Co-Authored-By: Claude Opus 4.6 --- lua/codediff/ui/lib/tree_utils.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lua/codediff/ui/lib/tree_utils.lua b/lua/codediff/ui/lib/tree_utils.lua index 71946022..9a6760f9 100644 --- a/lua/codediff/ui/lib/tree_utils.lua +++ b/lua/codediff/ui/lib/tree_utils.lua @@ -149,9 +149,10 @@ function M.setup_fold_keymaps(opts) } local map_options = { noremap = true, silent = true, nowait = true } for _, binding in ipairs(fold_bindings) do - if keymaps[binding.key] then - vim.keymap.set("n", keymaps[binding.key], binding.fn, vim.tbl_extend("force", map_options, { buffer = bufnr, desc = binding.desc })) - else + local key = keymaps[binding.key] + if key then + vim.keymap.set("n", key, binding.fn, vim.tbl_extend("force", map_options, { buffer = bufnr, desc = binding.desc })) + elseif key == nil then local info = debug.getinfo(1, "Sl") local location = string.format("%s:%d", info.short_src, info.currentline) vim.notify(string.format("No keymap defined for %s, skipping fold keymap [%s]", binding.key, location), vim.log.levels.WARN) From d1094860aa15cf24034f394adf37458eeb898858 Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Wed, 4 Mar 2026 21:37:48 -0500 Subject: [PATCH 8/9] fix(tree_utils): silently skip disabled fold keymaps Remove vim.notify warning when a fold keymap is nil/disabled. This matches the existing codebase convention where missing keymaps are silently skipped (e.g. explorer/history keymap setup). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lua/codediff/ui/lib/tree_utils.lua | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lua/codediff/ui/lib/tree_utils.lua b/lua/codediff/ui/lib/tree_utils.lua index 9a6760f9..236d4dc2 100644 --- a/lua/codediff/ui/lib/tree_utils.lua +++ b/lua/codediff/ui/lib/tree_utils.lua @@ -152,10 +152,6 @@ function M.setup_fold_keymaps(opts) local key = keymaps[binding.key] if key then vim.keymap.set("n", key, binding.fn, vim.tbl_extend("force", map_options, { buffer = bufnr, desc = binding.desc })) - elseif key == nil then - local info = debug.getinfo(1, "Sl") - local location = string.format("%s:%d", info.short_src, info.currentline) - vim.notify(string.format("No keymap defined for %s, skipping fold keymap [%s]", binding.key, location), vim.log.levels.WARN) end end end From 4eb1c60ad91291ac2c49fbf5b46d53584e83a18f Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Wed, 4 Mar 2026 21:41:04 -0500 Subject: [PATCH 9/9] docs: add fold keymaps to vimdoc Add fold keymap entries (zo/zO/zc/zC/za/zA/zR/zM) to explorer and history sections in codediff.txt vimdoc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/codediff.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/codediff.txt b/doc/codediff.txt index 9f6d0b89..6e0d3619 100644 --- a/doc/codediff.txt +++ b/doc/codediff.txt @@ -227,10 +227,26 @@ Setup entry point: restore = "X", toggle_changes = "gu", toggle_staged = "gs", + fold_open = "zo", + fold_open_recursive = "zO", + fold_close = "zc", + fold_close_recursive = "zC", + fold_toggle = "za", + fold_toggle_recursive = "zA", + fold_open_all = "zR", + fold_close_all = "zM", }, history = { select = "", toggle_view_mode = "i", + fold_open = "zo", + fold_open_recursive = "zO", + fold_close = "zc", + fold_close_recursive = "zC", + fold_toggle = "za", + fold_toggle_recursive = "zA", + fold_open_all = "zR", + fold_close_all = "zM", }, conflict = { accept_incoming = "ct",