diff --git a/README.md b/README.md index 3a2b5dcc..23933c33 100644 --- a/README.md +++ b/README.md @@ -166,16 +166,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 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", diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index 0b52d3d7..5536b902 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -111,11 +111,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..4256c023 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,13 @@ 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, + 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..5622d646 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,13 @@ 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, + 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.lua b/lua/codediff/ui/lib/tree.lua index 90dfc4da..e1485118 100644 --- a/lua/codediff/ui/lib/tree.lua +++ b/lua/codediff/ui/lib/tree.lua @@ -41,6 +41,15 @@ end -- Node methods +local function set_expanded_recursively(node, expanded) + if node:is_foldable() then + node._expanded = expanded + end + for _, child in ipairs(node._children) do + set_expanded_recursively(child, expanded) + end +end + function Node:get_id() return self._id end @@ -49,14 +58,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 diff --git a/lua/codediff/ui/lib/tree_utils.lua b/lua/codediff/ui/lib/tree_utils.lua new file mode 100644 index 00000000..236d4dc2 --- /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 and 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() + if not node then + return + end + + 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, keymaps, bufnr } +function M.setup_fold_keymaps(opts) + local tree = opts.tree + local keymaps = opts.keymaps + local bufnr = opts.bufnr + + local function update_tree_view(node) + tree:render() + 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 + + 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 + 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 })) + end + end +end + +return M 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)