Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
width = 40, -- Width when position is "left" (columns)
height = 15, -- Height when position is "bottom" (lines)
auto_refresh = true, -- Auto-refresh file list on focus / git index changes (set false to avoid lag in huge repos; R still refreshes manually)
open_on_empty = false, -- When true, open the explorer even when the working tree is clean
indent_markers = true, -- Show indent markers in tree view (│, ├, └)
initial_focus = "explorer", -- Initial focus: "explorer", "original", or "modified"
icons = {
Expand Down
1 change: 1 addition & 0 deletions doc/codediff.txt
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ Setup entry point:
width = 40,
height = 15,
auto_refresh = true,
open_on_empty = false, -- When true, open the explorer even when the working tree is clean
auto_open_on_cursor = false,
indent_markers = true,
icons = {
Expand Down
12 changes: 9 additions & 3 deletions lua/codediff/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -372,12 +372,18 @@ local function handle_explorer(revision, revision2, global_opts, pathspec)

-- Check if there are any changes (including conflicts)
local has_conflicts = status_result.conflicts and #status_result.conflicts > 0
if #status_result.unstaged == 0 and #status_result.staged == 0 and not has_conflicts then
local is_empty = #status_result.unstaged == 0
and #status_result.staged == 0
and not has_conflicts
if is_empty and not config.options.explorer.open_on_empty then
vim.notify("No changes to show", vim.log.levels.INFO)
return
end

-- Create explorer view with empty diff panes initially
-- Create explorer view with empty diff panes initially.
-- When open_on_empty is true and is_empty, status_result still has its
-- (empty) unstaged/staged/conflicts arrays, so the explorer renders
-- empty groups rather than nil-ing out.

---@type SessionConfig
local session_config = {
Expand Down Expand Up @@ -486,7 +492,7 @@ local function handle_explorer_staged(revision, global_opts, pathspec)
return
end

if #status_result.staged == 0 then
if #status_result.staged == 0 and not config.options.explorer.open_on_empty then
vim.notify("No staged changes to show", vim.log.levels.INFO)
return
end
Expand Down
1 change: 1 addition & 0 deletions lua/codediff/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ M.defaults = {
width = 40, -- Width when position is "left" (columns)
height = 15, -- Height when position is "bottom" (lines)
auto_refresh = true, -- Enable automatic explorer refresh (BufEnter + git watcher)
open_on_empty = false, -- When true, open the explorer even when the working tree is clean
view_mode = "list", -- "list" (flat file list) or "tree" (directory tree)
indent_markers = true, -- Show indent markers in tree view (│, ├, └)
initial_focus = "explorer", -- Initial focus: "explorer", "original", or "modified"
Expand Down
9 changes: 9 additions & 0 deletions lua/codediff/ui/explorer/render.lua
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,15 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
-- Setup auto-refresh
refresh_module.setup_auto_refresh(explorer, tabpage)

-- Empty working tree (explorer.open_on_empty): the explorer opened with zero
-- files. Render the welcome banner in the diff panes (instead of blank
-- scratch buffers) so the state is legible. This mirrors what a refresh
-- shows when the tree later becomes empty. should_show_welcome() also rejects
-- dir mode, so directory comparisons are unaffected.
if should_show_welcome(explorer) then
show_welcome_page(explorer)
end

-- Re-render on window resize for dynamic width
vim.api.nvim_create_autocmd("WinResized", {
callback = function()
Expand Down
139 changes: 139 additions & 0 deletions tests/command_e2e_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ describe("Command E2E (real dispatch + render)", function()
end, { nargs = "*", bang = true, range = true })
end

-- config.setup() merges into the *current* options, so it accumulates across
-- calls. Reset to defaults first so every scenario starts from a known state.
local function reset_config(opts)
local config = require("codediff.config")
config.options = vim.deepcopy(config.defaults)
require("codediff").setup(opts or {})
end

local function find_explorer_buf()
for _, tp in ipairs(vim.api.nvim_list_tabpages()) do
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tp)) do
Expand Down Expand Up @@ -229,6 +237,137 @@ describe("Command E2E (real dispatch + render)", function()
h.assert_contains(notified, "No staged changes")
end)

-- ── Empty working tree (explorer.open_on_empty) ───────────────────────────
-- A fresh, fully-committed repo is clean: nothing untracked, unstaged,
-- staged, or in conflict. `--repo` pins the target so the current buffer is
-- irrelevant.

it(":CodeDiff on a clean repo: notifies and opens no tab (default)", function()
reset_config()
local repo2 = h.create_temp_git_repo()
repo2.write_file("sit.txt", { "x" })
repo2.git("add sit.txt")
repo2.git("commit -m clean")
local notified
local orig_notify = vim.notify
vim.notify = function(msg, level)
if level == vim.log.levels.INFO then
notified = tostring(msg)
end
end
vim.cmd("CodeDiff --repo " .. repo2.dir)
vim.wait(2000, function()
return notified ~= nil
end)
vim.notify = orig_notify
local tabs = #session_tabs()
repo2.cleanup()
assert.is_not_nil(notified, "a notification is shown on a clean repo")
h.assert_contains(notified, "No changes to show")
assert.equals(0, tabs, "no explorer tab opens on a clean repo by default")
end)

it(":CodeDiff on a clean repo: open_on_empty opens an empty explorer", function()
reset_config({ explorer = { open_on_empty = true } })
local repo2 = h.create_temp_git_repo()
repo2.write_file("sit.txt", { "x" })
repo2.git("add sit.txt")
repo2.git("commit -m clean")
local notified
local orig_notify = vim.notify
vim.notify = function(msg, level)
if level == vim.log.levels.INFO then
notified = tostring(msg)
end
end
vim.cmd("CodeDiff --repo " .. repo2.dir)
local tabs_exist = vim.wait(5000, function()
return #session_tabs() > 0
end, 50)
vim.notify = orig_notify
assert.is_true(tabs_exist, "a session tab is opened with open_on_empty")
assert.is_nil(notified, "no 'No changes to show' notification with open_on_empty")

local text = explorer_text()
h.assert_contains(text, "Changes (0)", "explorer shows the empty Changes group")
h.assert_contains(text, "Staged Changes (0)", "explorer shows the empty Staged Changes group")

local welcome = require("codediff.ui.welcome")
local tab = find_explorer_tab()
local session = lifecycle.get_session(tab)
assert.is_not_nil(session, "session exists for the empty explorer")
local show_welcome = vim.wait(5000, function()
return welcome.is_welcome_buffer(session.modified_bufnr)
end, 50)
assert.is_true(show_welcome, "empty explorer shows the welcome banner in the diff pane")
repo2.cleanup()
end)

it(":CodeDiff --staged on a clean repo: open_on_empty opens an empty staged explorer", function()
reset_config({ explorer = { open_on_empty = true } })
local repo2 = h.create_temp_git_repo()
repo2.write_file("sit.txt", { "x" })
repo2.git("add sit.txt")
repo2.git("commit -m clean")
local notified
local orig_notify = vim.notify
vim.notify = function(msg, level)
if level == vim.log.levels.INFO then
notified = tostring(msg)
end
end
vim.cmd("CodeDiff --repo " .. repo2.dir .. " --staged")
local tabs_exist = vim.wait(5000, function()
return #session_tabs() > 0
end, 50)
vim.notify = orig_notify
assert.is_true(tabs_exist, "a session tab is opened with open_on_empty")
assert.is_nil(notified, "no 'No staged changes to show' notification with open_on_empty")
repo2.cleanup()
end)

it(":CodeDiff on a clean repo: open_on_empty explorer repopulates on refresh", function()
reset_config({ explorer = { open_on_empty = true } })
local repo2 = h.create_temp_git_repo()
repo2.write_file("sit.txt", { "x" })
repo2.git("add sit.txt")
repo2.git("commit -m clean")
vim.cmd("CodeDiff --repo " .. repo2.dir)
local tabs_exist = vim.wait(5000, function()
return #session_tabs() > 0
end, 50)
assert.is_true(tabs_exist, "an empty explorer tab is opened")

-- Externally create a change, then refresh through the real code path.
repo2.write_file("sit.txt", { "x changed" })
local explorer = lifecycle.get_explorer(find_explorer_tab())
assert.is_not_nil(explorer, "explorer object exists")
require("codediff.ui.explorer.refresh").refresh(explorer)

local refreshed = vim.wait(10000, function()
local files = require("codediff.ui.explorer.refresh").get_all_files(explorer.tree)
return #files > 0
end, 50)
assert.is_true(refreshed, "refresh populates the explorer with the new change")
assert.is_true(vim.api.nvim_tabpage_is_valid(find_explorer_tab()), "the tab stays open after refresh")

-- Refresh does not auto-select (existing no-auto-select-on-refresh design);
-- a manual select replaces the welcome banner with the file diff.
local session = lifecycle.get_session(find_explorer_tab())
explorer.on_file_select({
path = repo2.path("sit.txt"),
status = "M",
git_root = repo2.dir,
group = "unstaged",
})
local welcome = require("codediff.ui.welcome")
local restored = vim.wait(10000, function()
return not welcome.is_welcome_buffer(session.modified_bufnr)
end, 50)
assert.is_true(restored, "selecting the file replaces the welcome banner with the diff")
repo2.cleanup()
end)

-- ── Single-file diff (two real files) ─────────────────────────────────────

it(":CodeDiff file <a> <b> — renders both files in a diff", function()
Expand Down