From faaee41364f8f3140d192e64ead4f055176bc44f Mon Sep 17 00:00:00 2001 From: user <17591696+sand4rt@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:19:55 +0200 Subject: [PATCH 1/2] fix: layout shift --- lua/zen/init.lua | 177 ++++++++++++++---------- tests/test_integrations.lua | 263 +++++++++++++++++++++++++++++++++++- 2 files changed, 363 insertions(+), 77 deletions(-) diff --git a/lua/zen/init.lua b/lua/zen/init.lua index 5568dfa..85fcd64 100644 --- a/lua/zen/init.lua +++ b/lua/zen/init.lua @@ -27,8 +27,9 @@ local opts = { bottom = {}, left = { { filetype = "*", min_width = 46 } }, } +--- @type table local state = { - [vim.api.nvim_get_current_tabpage()] = { left = nil, right = nil }, + [vim.api.nvim_get_current_tabpage()] = { left = -1, right = -1 }, } local function get_main_width() @@ -77,18 +78,19 @@ local function is_filetype(target, filetype) return false end ----@param position "left" | "right" +---@param position "top" | "right" | "bottom" | "left" ---@param filetype string ---@return number local function get_min_width(position, filetype) local wildcard_width = 0 for _, integration in ipairs(opts[position]) do if type(integration) == "table" then - if integration.min_width and integration.filetype ~= "*" and is_filetype(filetype, integration.filetype) then - return integration.min_width + local min_w = integration.min_width or 0 + if min_w > 0 and integration.filetype ~= "*" and is_filetype(filetype, integration.filetype) then + return min_w end - if integration.filetype == "*" and integration.min_width then - wildcard_width = integration.min_width + if integration.filetype == "*" and min_w > 0 then + wildcard_width = min_w end end end @@ -130,6 +132,18 @@ local function filetypes_visible(filetypes) return false end +---@param list string[] +---@param filetype Filetype +local function append_filetype(list, filetype) + if type(filetype) == "table" then + for _, ft in ipairs(filetype) do + table.insert(list, ft) + end + else + table.insert(list, filetype) + end +end + ---@param filetypes string[] ---@param type_to_remove string local function remove_file_type(filetypes, type_to_remove) @@ -150,8 +164,7 @@ local function is_buff_integration(buf) end for _, position in ipairs({ "left", "right", "top", "bottom" }) do for _, integration in ipairs(opts[position] or {}) do - ---@diagnostic disable-next-line: undefined-field - if type(integration) == "table" and is_filetype(filetype, integration.filetype) then + if type(integration) == "table" and is_filetype(filetype, integration.filetype) then return true end end @@ -195,14 +208,6 @@ local function get_vsplits() return vsplits end ----@return boolean -local function is_hsplit(buf) - local win_id = vim.fn.bufwinid(buf) - local width = vim.api.nvim_win_get_width(win_id) - local height = vim.api.nvim_win_get_height(win_id) - return width > height -end - ---@param position "top" | "right" | "bottom" | "left" ---@return boolean local function is_integration_open(position) @@ -232,11 +237,55 @@ local function get_window_by_filetype(filetype) return nil end -local function adjust_top_bottom_window_hack(target_window, position) - if target_window then - vim.api.nvim_win_call(target_window, function() - vim.cmd("wincmd " .. position) - end) +local _saved_position_heights = {} +local _in_handler = false + +local function save_top_bottom_heights() + for _, position in ipairs({ "top", "bottom" }) do + for _, integration in pairs(opts[position]) do + if type(integration) == "table" then + local win = get_window_by_filetype(integration.filetype) + if win then + local w = vim.api.nvim_win_get_width(win) + -- only save when integration spans full width (properly positioned) + if w == vim.o.columns then + _saved_position_heights[position] = vim.api.nvim_win_get_height(win) + end + break + end + end + end + end +end + +local function reposition_top_bottom_integrations() + for _, integration in pairs(opts.top) do + local win = get_window_by_filetype(integration.filetype) + if win then + local needs_reposition = vim.api.nvim_win_get_width(win) ~= vim.o.columns + if needs_reposition then + local height_before = vim.api.nvim_win_get_height(win) + vim.api.nvim_win_set_config(win, { split = "above", win = -1 }) + vim.api.nvim_win_set_height(win, _saved_position_heights.top or height_before) + elseif _saved_position_heights.top then + vim.api.nvim_win_set_height(win, _saved_position_heights.top) + end + _saved_position_heights.top = vim.api.nvim_win_get_height(win) + end + end + for _, integration in pairs(opts.bottom) do + local win = get_window_by_filetype(integration.filetype) + if win then + local needs_reposition = vim.api.nvim_win_get_width(win) ~= vim.o.columns + if needs_reposition then + local height_before = vim.api.nvim_win_get_height(win) + vim.api.nvim_win_set_config(win, { split = "below", win = -1 }) + vim.api.nvim_win_set_height(win, _saved_position_heights.bottom or height_before) + elseif _saved_position_heights.bottom then + vim.api.nvim_win_set_height(win, _saved_position_heights.bottom) + end + _saved_position_heights.bottom = vim.api.nvim_win_get_height(win) + end end end @@ -271,28 +320,6 @@ local function setup(options) ---@type ConfigOptions opts = vim.tbl_extend("force", opts, options or {}) - vim.api.nvim_create_autocmd("CursorMoved", { - -- TODO: use pattern for better perf - callback = function(args) - if is_buff_integration(args.buf) then - local buf_info = vim.fn.getbufinfo(args.buf) - - local filetype = vim.api.nvim_get_option_value("filetype", { buf = args.buf }) - for _, position in ipairs({ "right", "left" }) do - for _, integration in pairs(opts[position]) do - ---@diagnostic disable-next-line: undefined-field - if type(integration) == "table" and is_filetype(filetype, integration.filetype) then - local new_width = math.max(get_min_width(position, filetype), math.floor((vim.o.columns - get_main_width()) / 2)) - vim.api.nvim_win_set_width(buf_info[1].windows[1], new_width) - return - end - end - end - end - end, - desc = "HACK: adjust the integration when opening", - }) - vim.api.nvim_create_autocmd({ "VimEnter", "TabNew" }, { callback = function() -- disable when window is too small @@ -317,9 +344,10 @@ local function setup(options) callback = function() if vim.bo.filetype == "zen-left" then vim.cmd("wincmd l") - end - if vim.bo.filetype == "zen-right" then + elseif vim.bo.filetype == "zen-right" then vim.cmd("wincmd h") + else + save_top_bottom_heights() end end, desc = "Prevent the cursor from moving to the side buffers.", @@ -419,25 +447,37 @@ local function setup(options) return end - local left_file_types = { "fugitiveblame", "fyler", "undotree", "dbui", "zen-left" } + -- save top/bottom integration heights before recreating zen buffers + if not _in_handler then + save_top_bottom_heights() + end + + local left_file_types = { "zen-left" } + for _, integration in ipairs(opts.left) do + if type(integration) == "table" and integration.filetype ~= "*" then + append_filetype(left_file_types, integration.filetype) + end + end remove_file_type(left_file_types, file_type) if not filetypes_visible(left_file_types) then state[vim.api.nvim_get_current_tabpage()].left = create_window("left") vim.cmd("wincmd l") end - local right_file_types = { "dapui_scopes", "neotest-summary", "zen-right" } + local right_file_types = { "zen-right" } + for _, integration in ipairs(opts.right) do + if type(integration) == "table" and integration.filetype ~= "*" then + append_filetype(right_file_types, integration.filetype) + end + end remove_file_type(right_file_types, file_type) if not filetypes_visible(right_file_types) then state[vim.api.nvim_get_current_tabpage()].right = create_window("right") vim.cmd("wincmd h") end - for _, integration in pairs(opts.top) do - adjust_top_bottom_window_hack(get_window_by_filetype(integration.filetype), "K") - end - for _, integration in pairs(opts.bottom) do - adjust_top_bottom_window_hack(get_window_by_filetype(integration.filetype), "J") + if not _in_handler then + reposition_top_bottom_integrations() end resize_side_buffers() end, @@ -464,11 +504,15 @@ local function setup(options) return end - for _, position in ipairs({ "top", "right", "bottom", "left" }) do + _in_handler = true + + ---@type ("top"|"right"|"bottom"|"left")[] + local positions = { "top", "right", "bottom", "left" } + for _, position in ipairs(positions) do for _, integration in pairs(opts[position]) do if type(integration) == "table" and is_filetype(filetype, integration.filetype) then close_side_buffer(position) - for _, position_inner in ipairs({ "top", "right", "bottom", "left" }) do + for _, position_inner in ipairs(positions) do for _, integration_inner in pairs(opts[position_inner]) do if position_inner == position @@ -481,32 +525,19 @@ local function setup(options) end if position == "left" or position == "right" then - local min_width = get_min_width(position, filetype) - if min_width > 0 then - local win = vim.fn.bufwinid(args.buf) - if win ~= -1 then - local current_width = vim.api.nvim_win_get_width(win) - if current_width < min_width then - vim.api.nvim_win_set_width(win, min_width) - end - end + local new_width = math.max(get_min_width(position, filetype), math.floor((vim.o.columns - get_main_width()) / 2)) + local win = vim.fn.bufwinid(args.buf) + if win ~= -1 then + vim.api.nvim_win_set_width(win, new_width) end end end end end - for _, integration in pairs(opts.top) do - if not is_hsplit(args.buf) then - adjust_top_bottom_window_hack(get_window_by_filetype(integration.filetype), "K") - end - end - for _, integration in pairs(opts.bottom) do - if not is_hsplit(args.buf) then - adjust_top_bottom_window_hack(get_window_by_filetype(integration.filetype), "J") - end - end + reposition_top_bottom_integrations() resize_side_buffers() + _in_handler = false end, desc = "Close side buffer plugins if another plugin is already occupying that side.", }) diff --git a/tests/test_integrations.lua b/tests/test_integrations.lua index 8877e94..a8815f8 100644 --- a/tests/test_integrations.lua +++ b/tests/test_integrations.lua @@ -36,6 +36,44 @@ T["left integration"]["opening closes zen side buffer, closing reopens it"] = fu }) end +T["left integration"]["opening a left integration preserves an existing top integration"] = function() + child.cmd("Git") + child.lua("vim.cmd('Fyler kind=split_left_most')") + + Helpers.expect.layout(child, { + type = "col", + children = { + { type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 25 }, + { + type = "row", + children = { + { type = "leaf", filetype = "fyler", buftype = "acwrite", width = 46, height = 24 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 24 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 24 }, + }, + }, + }, + }) + + child.cmd("close") + child.lua("vim.cmd('Fyler kind=split_left_most')") + child.cmd("close") + + Helpers.expect.layout(child, { + type = "col", + children = { + { type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 25 }, + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 24 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 24 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 24 }, + }, + }, + }, + }) +end T["left integration"]["opening an integration should close the existing integration on the same side"] = function() child.cmd("Fyler kind=split_left_most") @@ -61,6 +99,45 @@ T["left integration"]["opening an integration should close the existing integrat }) end +T["left integration"]["opening a left integration preserves an existing bottom integration"] = function() + child.cmd("Trouble diagnostics") + child.cmd("Fyler kind=split_left_most") + + Helpers.expect.layout(child, { + type = "col", + children = { + { + type = "row", + children = { + { type = "leaf", filetype = "fyler", buftype = "acwrite", width = 46, height = 39 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 39 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 39 }, + }, + }, + { type = "leaf", filetype = "trouble", buftype = "nofile", width = 240, height = 10 }, + }, + }) + + child.cmd("close") + child.cmd("Fyler kind=split_left_most") + child.cmd("close") + + Helpers.expect.layout(child, { + type = "col", + children = { + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 39 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 39 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 39 }, + }, + }, + { type = "leaf", filetype = "trouble", buftype = "nofile", width = 240, height = 10 }, + }, + }) +end + T["top integration"] = MiniTest.new_set({}) T["top integration"]["opening"] = function() @@ -118,6 +195,60 @@ T["top integration"]["opening an integration should close the existing integrati }) end +T["top integration"]["closing a stacked top split returns cursor to the integration below it"] = function() + child.cmd("Git") + + Helpers.expect.layout(child, { + type = "col", + children = { + { type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 25 }, + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 24 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 24 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 24 }, + }, + }, + }, + }) + + -- move cursor to fugitive window and start a commit + child.lua([[ + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + if vim.bo[buf].filetype == "fugitive" then + vim.api.nvim_set_current_win(win) + break + end + end + ]]) + child.cmd("Git commit --allow-empty") + + -- close the commit editor (abort the commit) + child.cmd("bdelete!") + + -- layout should be unchanged + Helpers.expect.layout(child, { + type = "col", + children = { + { type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 25 }, + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 24 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 24 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 24 }, + }, + }, + }, + }) + + -- cursor should be in the fugitive window + local ft = child.lua_get("vim.bo.filetype") + MiniTest.expect.equality(ft, "fugitive") +end + T["bottom integration"] = MiniTest.new_set({}) T["bottom integration"]["opening"] = function() @@ -177,6 +308,84 @@ end T["right integration"] = MiniTest.new_set({}) +T["right integration"]["opening a right integration preserves an existing top integration"] = function() + child.cmd("Git") + child.cmd("Neotest summary open") + + Helpers.expect.layout(child, { + type = "col", + children = { + { type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 25 }, + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 24 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 24 }, + { type = "leaf", filetype = "neotest-summary", buftype = "nofile", width = 46, height = 24 }, + }, + }, + }, + }) + + child.cmd("Neotest summary close") + child.cmd("Neotest summary open") + child.cmd("Neotest summary close") + + Helpers.expect.layout(child, { + type = "col", + children = { + { type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 25 }, + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 24 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 24 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 24 }, + }, + }, + }, + }) +end + +T["right integration"]["opening a right integration preserves an existing bottom integration"] = function() + child.cmd("Trouble diagnostics") + child.cmd("Neotest summary open") + + Helpers.expect.layout(child, { + type = "col", + children = { + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 39 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 39 }, + { type = "leaf", filetype = "neotest-summary", buftype = "nofile", width = 46, height = 39 }, + }, + }, + { type = "leaf", filetype = "trouble", buftype = "nofile", width = 240, height = 10 }, + }, + }) + + child.cmd("Neotest summary close") + child.cmd("Neotest summary open") + child.cmd("Neotest summary close") + + Helpers.expect.layout(child, { + type = "col", + children = { + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 39 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 39 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 39 }, + }, + }, + { type = "leaf", filetype = "trouble", buftype = "nofile", width = 240, height = 10 }, + }, + }) +end + T["right integration"]["opening closes zen side buffer, closing reopens it"] = function() child.cmd("Neotest summary open") @@ -184,8 +393,8 @@ T["right integration"]["opening closes zen side buffer, closing reopens it"] = f type = "row", children = { { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 50 }, - { type = "leaf", filetype = "", buftype = "", width = 142, height = 50 }, - { type = "leaf", filetype = "neotest-summary", buftype = "nofile", width = 50, height = 50 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 50 }, + { type = "leaf", filetype = "neotest-summary", buftype = "nofile", width = 46, height = 50 }, }, }) @@ -208,8 +417,8 @@ T["right integration"]["opening an integration should close the existing integra type = "row", children = { { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 50 }, - { type = "leaf", filetype = "", buftype = "", width = 142, height = 50 }, - { type = "leaf", filetype = "neotest-summary", buftype = "nofile", width = 50, height = 50 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 50 }, + { type = "leaf", filetype = "neotest-summary", buftype = "nofile", width = 46, height = 50 }, }, }) @@ -257,6 +466,52 @@ T["right integration"]["opening an integration with table filetype"] = function( }) end +T["combined"] = MiniTest.new_set({}) + +T["combined"]["opening a side integration preserves existing top and bottom integrations"] = function() + child.cmd("Git") + child.cmd("Trouble diagnostics") + child.cmd("Fyler kind=split_left_most") + + Helpers.expect.layout(child, { + type = "col", + children = { + { type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 18 }, + { + type = "row", + children = { + { type = "leaf", filetype = "fyler", buftype = "acwrite", width = 46, height = 20 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 20 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 20 }, + }, + }, + { type = "leaf", filetype = "trouble", buftype = "nofile", width = 240, height = 10 }, + }, + }) + + child.cmd("close") + child.cmd("Fyler kind=split_left_most") + child.cmd("close") + + Helpers.expect.layout(child, { + type = "col", + children = { + { type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 18 }, + { + type = "row", + children = { + { type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 20 }, + { type = "leaf", filetype = "", buftype = "", width = 146, height = 20 }, + { type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 20 }, + }, + }, + { type = "leaf", filetype = "trouble", buftype = "nofile", width = 240, height = 10 }, + }, + }) +end + + + local min_width_child = MiniTest.new_child_neovim() T["min_width"] = MiniTest.new_set({ From d288c90dde5fd5da8dcf24e99be495e8f8979f22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:31:47 +0000 Subject: [PATCH 2/2] fix: init real tmp git repo with user identity for commit test Agent-Logs-Url: https://github.com/sand4rt/zen.nvim/sessions/a652929b-24dc-4980-b03f-05568d133994 Co-authored-by: sand4rt <17591696+sand4rt@users.noreply.github.com> --- tests/test_integrations.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_integrations.lua b/tests/test_integrations.lua index a8815f8..8ce8eeb 100644 --- a/tests/test_integrations.lua +++ b/tests/test_integrations.lua @@ -196,6 +196,16 @@ T["top integration"]["opening an integration should close the existing integrati end T["top integration"]["closing a stacked top split returns cursor to the integration below it"] = function() + -- initialize a real temporary git repo with user identity so that + -- "Git commit --allow-empty" works deterministically in CI + child.lua([[ + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + vim.fn.system({ "git", "init", tmpdir }) + vim.fn.system({ "git", "-C", tmpdir, "config", "user.name", "Test" }) + vim.fn.system({ "git", "-C", tmpdir, "config", "user.email", "test@test.com" }) + vim.fn.chdir(tmpdir) + ]]) child.cmd("Git") Helpers.expect.layout(child, {