diff --git a/VERSION b/VERSION index d685d64..345a83e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.41.1 +2.42.0 diff --git a/lua/codediff/ui/core.lua b/lua/codediff/ui/core.lua index 55f2fcf..7e34b25 100644 --- a/lua/codediff/ui/core.lua +++ b/lua/codediff/ui/core.lua @@ -364,8 +364,14 @@ function M.render_diff(left_bufnr, right_bufnr, original_lines, modified_lines, end -- Render moved code indicators (separate module) - local move = require("codediff.ui.move") - move.render_moves(left_bufnr, right_bufnr, lines_diff) + if lines_diff.moves and #lines_diff.moves > 0 then + local ok, move = pcall(require, "codediff.ui.move") + if ok and move then + move.render_moves(left_bufnr, right_bufnr, lines_diff) + else + vim.notify_once("[codediff] failed to load codediff.ui.move: " .. tostring(move), vim.log.levels.WARN) + end + end return { left_fillers = total_left_fillers, diff --git a/lua/codediff/ui/explorer/refresh.lua b/lua/codediff/ui/explorer/refresh.lua index bea1ee9..c1f4fe7 100644 --- a/lua/codediff/ui/explorer/refresh.lua +++ b/lua/codediff/ui/explorer/refresh.lua @@ -3,6 +3,7 @@ local M = {} local config = require("codediff.config") local tree_module = require("codediff.ui.explorer.tree") +local welcome = require("codediff.ui.welcome") -- Setup auto-refresh triggers for explorer -- Returns a cleanup function that should be called when the explorer is destroyed @@ -252,16 +253,95 @@ function M.refresh(explorer) -- Update status result for file selection logic explorer.status_result = status_result - -- Try to restore selection - if current_path then - local nodes = explorer.tree:get_nodes() - for _, node in ipairs(nodes) do - if node.data and node.data.path == current_path then - explorer.tree:set_node(node:get_id()) - break + local function clear_current_file() + explorer.current_file_path = nil + explorer.current_file_group = nil + end + + -- Helper: show the welcome page in the diff panes + local function show_welcome_page() + local lifecycle = require("codediff.ui.lifecycle") + local session = lifecycle.get_session(explorer.tabpage) + if session and not welcome.is_welcome_buffer(session.modified_bufnr) then + local mod_win = session.modified_win + if mod_win and vim.api.nvim_win_is_valid(mod_win) then + if session.layout == "inline" then + local w = vim.api.nvim_win_get_width(mod_win) + local h = vim.api.nvim_win_get_height(mod_win) + local welcome_buf = welcome.create_buffer(w, h) + require("codediff.ui.view.inline_view").show_welcome(explorer.tabpage, welcome_buf) + else + local orig_win = session.original_win + if orig_win and vim.api.nvim_win_is_valid(orig_win) then + local w = vim.api.nvim_win_get_width(orig_win) + vim.api.nvim_win_get_width(mod_win) + 1 + local h = vim.api.nvim_win_get_height(orig_win) + local welcome_buf = welcome.create_buffer(w, h) + require("codediff.ui.view.side_by_side").show_welcome(explorer.tabpage, welcome_buf) + end + end end end end + + -- Show welcome page when all files are clean + local total_files = #(status_result.unstaged or {}) + #(status_result.staged or {}) + #(status_result.conflicts or {}) + if total_files == 0 then + clear_current_file() + show_welcome_page() + end + + -- Re-select the currently viewed file after refresh. + -- Search all file children across all groups for the current file. + -- If found (possibly in a new group), call on_file_select to update diff panes. + -- If not found (committed/removed), show welcome page. + if explorer.current_file_path and total_files > 0 then + local found_file = nil + local found_group = nil + -- Search helper: look in a specific status list + local function search_group(files, group_name) + for _, f in ipairs(files or {}) do + if f.path == explorer.current_file_path then + return f, group_name + end + end + return nil, nil + end + -- Search same group first (preferred — e.g. hunk staging keeps file in same group) + local current_group = explorer.current_file_group + if current_group then + local group_lists = { + unstaged = status_result.unstaged, + staged = status_result.staged, + conflicts = status_result.conflicts, + } + found_file, found_group = search_group(group_lists[current_group], current_group) + end + -- If not in same group, search all groups + if not found_file then + found_file, found_group = search_group(status_result.conflicts, "conflicts") + end + if not found_file then + found_file, found_group = search_group(status_result.unstaged, "unstaged") + end + if not found_file then + found_file, found_group = search_group(status_result.staged, "staged") + end + + if found_file then + -- File still exists (possibly in a new group) — re-select it + explorer.on_file_select({ + path = found_file.path, + old_path = found_file.old_path, + status = found_file.status, + git_root = explorer.git_root, + group = found_group, + }) + else + -- File was committed/removed — show welcome + clear_current_file() + show_welcome_page() + end + end end) end diff --git a/lua/codediff/ui/lifecycle/cleanup.lua b/lua/codediff/ui/lifecycle/cleanup.lua index 2660811..d6efe28 100644 --- a/lua/codediff/ui/lifecycle/cleanup.lua +++ b/lua/codediff/ui/lifecycle/cleanup.lua @@ -4,6 +4,7 @@ local M = {} local accessors = require("codediff.ui.lifecycle.accessors") local session = require("codediff.ui.lifecycle.session") local state = require("codediff.ui.lifecycle.state") +local welcome_window = require("codediff.ui.view.welcome_window") -- Autocmd group for cleanup local augroup = vim.api.nvim_create_augroup("codediff_lifecycle", { clear = true }) @@ -92,9 +93,11 @@ local function cleanup_diff(tabpage) -- Clear window variables if windows still exist if vim.api.nvim_win_is_valid(diff.original_win) then + welcome_window.apply_normal(diff.original_win) vim.w[diff.original_win].codediff_restore = nil end if vim.api.nvim_win_is_valid(diff.modified_win) then + welcome_window.apply_normal(diff.modified_win) vim.w[diff.modified_win].codediff_restore = nil end diff --git a/lua/codediff/ui/lifecycle/session.lua b/lua/codediff/ui/lifecycle/session.lua index b00af47..10b4985 100644 --- a/lua/codediff/ui/lifecycle/session.lua +++ b/lua/codediff/ui/lifecycle/session.lua @@ -5,6 +5,7 @@ local M = {} local config = require("codediff.config") local virtual_file = require("codediff.core.virtual_file") local accessors = require("codediff.ui.lifecycle.accessors") +local welcome_window = require("codediff.ui.view.welcome_window") -- Track active diff sessions -- Structure: { @@ -110,6 +111,8 @@ function M.create_session( reapply_keymaps = reapply_keymaps, } + welcome_window.capture_session_profiles(active_diffs[tabpage]) + -- Mark windows with restore flag vim.w[original_win].codediff_restore = 1 vim.w[modified_win].codediff_restore = 1 @@ -141,29 +144,33 @@ function M.create_session( end -- Force disable winbar to prevent alignment issues (except in conflict mode) - local function ensure_no_winbar() - local sess = active_diffs[tabpage] + local function sync_window_ui(sess, win) -- In conflict mode, preserve existing winbar titles (set by conflict_window.lua) if sess and sess.result_win and vim.api.nvim_win_is_valid(sess.result_win) then return end -- Normal diff mode: disable winbar - if vim.api.nvim_win_is_valid(original_win) then - vim.wo[original_win].winbar = "" + if sess and vim.api.nvim_win_is_valid(sess.original_win) then + vim.wo[sess.original_win].winbar = "" end - if vim.api.nvim_win_is_valid(modified_win) then - vim.wo[modified_win].winbar = "" + if sess and vim.api.nvim_win_is_valid(sess.modified_win) then + vim.wo[sess.modified_win].winbar = "" end end - vim.api.nvim_create_autocmd({ "BufWinEnter", "FileType" }, { + vim.api.nvim_create_autocmd({ "BufWinEnter", "BufEnter", "WinEnter", "FileType" }, { group = tab_augroup, - callback = function(args) + callback = function() + local sess = active_diffs[tabpage] + if not sess then + return + end local win = vim.api.nvim_get_current_win() - if win == original_win or win == modified_win then - ensure_no_winbar() + if win == sess.original_win or win == sess.modified_win then + sync_window_ui(sess, win) -- Re-apply critical window options that might get reset by ftplugins/autocmds vim.wo[win].wrap = false + welcome_window.sync(win) end end, }) diff --git a/lua/codediff/ui/view/inline_view.lua b/lua/codediff/ui/view/inline_view.lua index 79baaad..44a0e35 100644 --- a/lua/codediff/ui/view/inline_view.lua +++ b/lua/codediff/ui/view/inline_view.lua @@ -9,6 +9,7 @@ local diff_module = require("codediff.core.diff") local inline = require("codediff.ui.inline") local semantic = require("codediff.ui.semantic_tokens") local layout = require("codediff.ui.layout") +local welcome_window = require("codediff.ui.view.welcome_window") local helpers = require("codediff.ui.view.helpers") local panel = require("codediff.ui.view.panel") @@ -105,6 +106,7 @@ function M.create(session_config, filetype, on_ready) vim.bo[mod_scratch].buftype = "nofile" pcall(vim.api.nvim_buf_set_name, mod_scratch, "CodeDiff " .. tabpage .. ".inline") vim.api.nvim_win_set_buf(modified_win, mod_scratch) + welcome_window.sync(modified_win) local orig_scratch = vim.api.nvim_create_buf(false, true) vim.bo[orig_scratch].buftype = "nofile" @@ -171,6 +173,7 @@ function M.create(session_config, filetype, on_ready) else vim.api.nvim_win_set_buf(modified_win, modified_info.bufnr) end + welcome_window.sync(modified_win) -- Load original buffer (hidden — never displayed in a window) if original_is_virtual and original_info.needs_edit then @@ -378,6 +381,7 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) end vim.api.nvim_win_set_buf(modified_win, mod_buf) end + welcome_window.sync(modified_win) local should_auto_scroll = auto_scroll_to_first_hunk == true @@ -551,6 +555,7 @@ function M.show_single_file(tabpage, file_path, opts) file_bufnr = vim.api.nvim_create_buf(false, true) vim.bo[file_bufnr].buftype = "nofile" vim.api.nvim_win_set_buf(mod_win, file_bufnr) + welcome_window.sync(mod_win) local ft = vim.filetype.match({ filename = opts.rel_path or file_path }) if ft then vim.bo[file_bufnr].filetype = ft @@ -575,6 +580,7 @@ function M.show_single_file(tabpage, file_path, opts) file_bufnr = vim.fn.bufadd(file_path) vim.fn.bufload(file_bufnr) vim.api.nvim_win_set_buf(mod_win, file_bufnr) + welcome_window.sync(mod_win) end -- Update session state @@ -588,6 +594,42 @@ function M.show_single_file(tabpage, file_path, opts) local view_keymaps = require("codediff.ui.view.keymaps") view_keymaps.setup_all_keymaps(tabpage, empty_buf, file_bufnr, true) + welcome_window.sync_later(mod_win) +end + +--- Show the welcome page in the inline diff window +---@param tabpage number +---@param load_bufnr number Welcome buffer created by welcome.create_buffer +function M.show_welcome(tabpage, load_bufnr) + local session = lifecycle.get_session(tabpage) + if not session then + return + end + + local mod_win = session.modified_win + if not mod_win or not vim.api.nvim_win_is_valid(mod_win) then + return + end + + if session.modified_bufnr and vim.api.nvim_buf_is_valid(session.modified_bufnr) then + inline.clear(session.modified_bufnr) + auto_refresh.disable(session.modified_bufnr) + end + + vim.api.nvim_win_set_buf(mod_win, load_bufnr) + welcome_window.sync(mod_win) + + local empty_buf = vim.api.nvim_create_buf(false, true) + vim.bo[empty_buf].buftype = "nofile" + + lifecycle.update_buffers(tabpage, empty_buf, load_bufnr) + lifecycle.update_paths(tabpage, "", "") + lifecycle.update_revisions(tabpage, nil, nil) + lifecycle.update_diff_result(tabpage, {}) + + local view_keymaps = require("codediff.ui.view.keymaps") + view_keymaps.setup_all_keymaps(tabpage, empty_buf, load_bufnr, true) + welcome_window.sync_later(mod_win) end return M diff --git a/lua/codediff/ui/view/keymaps.lua b/lua/codediff/ui/view/keymaps.lua index d72e46a..c700661 100644 --- a/lua/codediff/ui/view/keymaps.lua +++ b/lua/codediff/ui/view/keymaps.lua @@ -683,9 +683,13 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore -- Help keymap (g?) - show floating window with available keymaps if keymaps.show_help then - local help = require("codediff.ui.keymap_help") lifecycle.set_tab_keymap(tabpage, "n", keymaps.show_help, function() - help.toggle(tabpage) + local ok, help = pcall(require, "codediff.ui.keymap_help") + if ok and help then + help.toggle(tabpage) + else + vim.notify_once("[codediff] failed to load codediff.ui.keymap_help: " .. tostring(help), vim.log.levels.WARN) + end end, { desc = "Show keymap help" }) end diff --git a/lua/codediff/ui/view/side_by_side.lua b/lua/codediff/ui/view/side_by_side.lua index 10a551f..6373569 100644 --- a/lua/codediff/ui/view/side_by_side.lua +++ b/lua/codediff/ui/view/side_by_side.lua @@ -18,6 +18,7 @@ local render = require("codediff.ui.view.render") local view_keymaps = require("codediff.ui.view.keymaps") local conflict_window = require("codediff.ui.view.conflict_window") local panel = require("codediff.ui.view.panel") +local welcome_window = require("codediff.ui.view.welcome_window") local is_virtual_revision = helpers.is_virtual_revision local prepare_buffer = helpers.prepare_buffer @@ -72,6 +73,8 @@ function M.create(session_config, filetype, on_ready) pcall(vim.api.nvim_buf_set_name, mod_scratch, "CodeDiff " .. tabpage .. ".2") vim.api.nvim_win_set_buf(original_win, orig_scratch) vim.api.nvim_win_set_buf(modified_win, mod_scratch) + welcome_window.sync(original_win) + welcome_window.sync(modified_win) -- Create placeholder buffer info (will be updated by explorer) original_info = { bufnr = orig_scratch } @@ -107,6 +110,8 @@ function M.create(session_config, filetype, on_ready) else vim.api.nvim_win_set_buf(modified_win, modified_info.bufnr) end + welcome_window.sync(original_win) + welcome_window.sync(modified_win) end -- Clean up initial buffer @@ -662,6 +667,9 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) end end + welcome_window.sync(original_win) + welcome_window.sync(modified_win) + -- Update lifecycle session metadata lifecycle.update_paths(tabpage, session_config.original_path, session_config.modified_path) @@ -729,6 +737,7 @@ local function show_single_file(tabpage, opts) -- Load the file into the kept window if keep_win and vim.api.nvim_win_is_valid(keep_win) then vim.api.nvim_win_set_buf(keep_win, opts.load_bufnr) + welcome_window.sync(keep_win) -- Create a scratch buffer as placeholder for the empty side local empty_buf = vim.api.nvim_create_buf(false, true) @@ -747,6 +756,9 @@ local function show_single_file(tabpage, opts) end layout.arrange(tabpage) + if keep_win and vim.api.nvim_win_is_valid(keep_win) then + welcome_window.sync_later(keep_win) + end end -- Load a real file from disk, return bufnr @@ -805,4 +817,12 @@ function M.show_deleted_virtual_file(tabpage, git_root, file_path, revision) }) end +--- Show the welcome page in a single pane (modified side) +function M.show_welcome(tabpage, load_bufnr) + show_single_file(tabpage, { + keep = "modified", + load_bufnr = load_bufnr, + }) +end + return M diff --git a/lua/codediff/ui/view/welcome_window.lua b/lua/codediff/ui/view/welcome_window.lua new file mode 100644 index 0000000..66d2e8c --- /dev/null +++ b/lua/codediff/ui/view/welcome_window.lua @@ -0,0 +1,112 @@ +local M = {} + +local welcome = require("codediff.ui.welcome") + +local option_names = { + "number", + "relativenumber", + "signcolumn", + "foldcolumn", + "statuscolumn", +} + +local welcome_opts = { + number = false, + relativenumber = false, + signcolumn = "no", + foldcolumn = "0", + statuscolumn = " ", +} + +local function is_valid_window(winid) + return winid and vim.api.nvim_win_is_valid(winid) +end + +local function read_window_opts(winid) + local opts = {} + for _, name in ipairs(option_names) do + opts[name] = vim.wo[winid][name] + end + return opts +end + +local function apply_opts(winid, opts) + for name, value in pairs(opts) do + vim.wo[winid][name] = value + end +end + +local function get_session_for_window(winid) + local active_diffs = require("codediff.ui.lifecycle.session").get_active_diffs() + for _, sess in pairs(active_diffs) do + if sess.original_win == winid then + return sess, "original" + end + if sess.modified_win == winid then + return sess, "modified" + end + end + return nil, nil +end + +function M.capture_session_profiles(sess) + if not sess then + return + end + + sess.window_profiles = sess.window_profiles or {} + if is_valid_window(sess.original_win) and not sess.window_profiles.original then + sess.window_profiles.original = read_window_opts(sess.original_win) + end + if is_valid_window(sess.modified_win) and not sess.window_profiles.modified then + sess.window_profiles.modified = read_window_opts(sess.modified_win) + end +end + +function M.apply(winid) + if not is_valid_window(winid) then + return + end + + apply_opts(winid, welcome_opts) +end + +function M.apply_normal(winid) + if not is_valid_window(winid) then + return + end + + local sess, side = get_session_for_window(winid) + if not sess or not side then + return + end + + M.capture_session_profiles(sess) + local normal_opts = sess.window_profiles and sess.window_profiles[side] + if not normal_opts then + return + end + + apply_opts(winid, normal_opts) +end + +function M.sync(winid) + if not is_valid_window(winid) then + return + end + + local bufnr = vim.api.nvim_win_get_buf(winid) + if welcome.is_welcome_buffer(bufnr) then + M.apply(winid) + else + M.apply_normal(winid) + end +end + +function M.sync_later(winid) + vim.schedule(function() + M.sync(winid) + end) +end + +return M diff --git a/lua/codediff/ui/welcome.lua b/lua/codediff/ui/welcome.lua new file mode 100644 index 0000000..9247b10 --- /dev/null +++ b/lua/codediff/ui/welcome.lua @@ -0,0 +1,119 @@ +-- Welcome page for empty diff panes +-- Pure buffer factory — no window, session, or lifecycle knowledge +local M = {} + +local logo = { + " ██████╗ ██████╗ ██████╗ ███████╗██████╗ ██╗███████╗███████╗", + "██╔════╝ ██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║██╔════╝██╔════╝", + "██║ ██║ ██║██║ ██║█████╗ ██║ ██║██║█████╗ █████╗ ", + "██║ ██║ ██║██║ ██║██╔══╝ ██║ ██║██║██╔══╝ ██╔══╝ ", + "╚██████╗ ╚██████╔╝██████╔╝███████╗██████╔╝██║██║ ██║ ", + " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═════╝ ╚═╝╚═╝ ╚═╝ ", +} + +local hint_line = "Working tree is clean — no changes to display." +local keys_line = "[R] Refresh [q] Close" + +local ns = vim.api.nvim_create_namespace("codediff-welcome") + +-- Setup highlight groups (called at module load) +local function setup_highlights() + vim.api.nvim_set_hl(0, "CodeDiffWelcomeLogo", { link = "Function", default = true }) + vim.api.nvim_set_hl(0, "CodeDiffWelcomeKey", { link = "Special", default = true }) +end + +setup_highlights() + +-- Compute display width of a string (handles multi-byte characters) +local function display_width(str) + return vim.fn.strdisplaywidth(str) +end + +-- Center a string within given width, return padded string +local function center(str, width) + local str_width = display_width(str) + if str_width >= width then + return str + end + local pad = math.floor((width - str_width) / 2) + return string.rep(" ", pad) .. str +end + +--- Create a welcome buffer with centered logo and hints +--- @param width number Available width in columns +--- @param height number Available height in rows +--- @return number bufnr +function M.create_buffer(width, height) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].buftype = "nofile" + vim.bo[bufnr].bufhidden = "wipe" + vim.bo[bufnr].buflisted = false + if not pcall(vim.api.nvim_buf_set_name, bufnr, "codediff") then + pcall(vim.api.nvim_buf_set_name, bufnr, "codediff (" .. bufnr .. ")") + end + + -- Build content lines: logo + blank + hint + keys + local content_lines = {} + for _, line in ipairs(logo) do + table.insert(content_lines, center(line, width)) + end + table.insert(content_lines, "") + table.insert(content_lines, center(hint_line, width)) + table.insert(content_lines, center(keys_line, width)) + + -- Vertical centering: add blank lines above + local total_content = #content_lines + local top_pad = math.max(0, math.floor((height - total_content) / 2)) + + local lines = {} + for _ = 1, top_pad do + table.insert(lines, "") + end + for _, line in ipairs(content_lines) do + table.insert(lines, line) + end + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modifiable = false + + -- Apply highlights using extmarks in the welcome namespace + for i, line in ipairs(lines) do + local row = i - 1 + + -- Logo lines: highlight the non-whitespace portion + if line:find("██") or line:find("╔") or line:find("╚") then + local start_col = line:find("%S") + if start_col then + vim.api.nvim_buf_set_extmark(bufnr, ns, row, start_col - 1, { + end_col = #line, + hl_group = "CodeDiffWelcomeLogo", + }) + end + end + + -- Keys line: highlight bracket pairs [R] and [q] + if line:find("%[R%]") or line:find("%[q%]") then + for bracket_start, bracket_end in line:gmatch("()%[.-%]()") do + vim.api.nvim_buf_set_extmark(bufnr, ns, row, bracket_start - 1, { + end_col = bracket_end - 1, + hl_group = "CodeDiffWelcomeKey", + }) + end + end + end + + return bufnr +end + +--- Check if a buffer is a welcome buffer (has codediff-welcome extmarks) +--- @param bufnr number|nil +--- @return boolean +function M.is_welcome_buffer(bufnr) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + local marks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { limit = 1 }) + return #marks > 0 +end + +return M diff --git a/tests/core/virtual_file_lsp_spec.lua b/tests/core/virtual_file_lsp_spec.lua index 28da4fc..603d68e 100644 --- a/tests/core/virtual_file_lsp_spec.lua +++ b/tests/core/virtual_file_lsp_spec.lua @@ -8,23 +8,21 @@ -- and PASS when TreeSitter is started directly without setting filetype. local virtual_file = require("codediff.core.virtual_file") +local h = dofile('tests/helpers.lua') describe("Virtual buffer LSP prevention", function() it("prevents LSP attachment while keeping TreeSitter active", function() -- Ensure virtual file autocmds are registered (plugin file may not be sourced in test subprocess) virtual_file.setup() -- Create a temp git repo - local temp_dir = vim.fn.tempname() - vim.fn.mkdir(temp_dir, "p") - vim.fn.system("git -C " .. temp_dir .. " init") - vim.fn.system("git -C " .. temp_dir .. " config user.email 'test@test.com'") - vim.fn.system("git -C " .. temp_dir .. " config user.name 'Test'") + local repo = h.create_temp_git_repo() + local temp_dir = repo.dir local f = io.open(temp_dir .. "/app.js", "w") f:write("const x = 1;\nconsole.log(x);\n") f:close() - vim.fn.system("git -C " .. temp_dir .. " add .") - vim.fn.system("git -C " .. temp_dir .. " commit -m 'initial'") + repo.git("add .") + repo.git('commit -m "initial"') -- Setup a mock LSP that mimics eslint/terraform-ls behavior: -- Listen for FileType and call vim.lsp.start() to attach. @@ -51,7 +49,7 @@ describe("Virtual buffer LSP prevention", function() }) -- Create a virtual buffer (this is what CodeDiff does internally) - local commit = vim.fn.system("git -C " .. temp_dir .. " rev-parse HEAD"):gsub("%s+", "") + local commit = vim.trim(repo.git("rev-parse HEAD")) local url = "codediff:///" .. temp_dir .. "///" .. commit .. "/app.js" vim.cmd("edit " .. vim.fn.fnameescape(url)) local buf = vim.api.nvim_get_current_buf() @@ -99,6 +97,6 @@ describe("Virtual buffer LSP prevention", function() end vim.api.nvim_del_autocmd(mock_autocmd_id) pcall(vim.api.nvim_buf_delete, buf, { force = true }) - vim.fn.delete(temp_dir, "rf") + repo.cleanup() end) end) diff --git a/tests/full_integration_spec.lua b/tests/full_integration_spec.lua index 50f8636..b3b1a53 100644 --- a/tests/full_integration_spec.lua +++ b/tests/full_integration_spec.lua @@ -21,6 +21,12 @@ describe("Full Integration Suite", function() local commit_hash_1 local commit_hash_2 + -- Helper to run git in temp_dir (cross-platform) + local function git(args) + local h = dofile('tests/helpers.lua') + return h.git_cmd(temp_dir, args) + end + before_each(function() -- Setup command setup_command() @@ -29,22 +35,6 @@ describe("Full Integration Suite", function() temp_dir = vim.fn.tempname() vim.fn.mkdir(temp_dir, "p") - -- Helper to run git in temp_dir - local function git(args) - -- Use -C to run git in the temp directory - -- On Windows, we need to ensure paths are handled correctly - -- Use string.format for safer command construction - -- IMPORTANT: On Windows, shellescape wraps in single quotes which cmd.exe doesn't like for paths - -- So we use double quotes for the path manually - local cmd - if vim.fn.has("win32") == 1 then - cmd = string.format('git -C "%s" %s', temp_dir, args) - else - cmd = string.format('git -C %s %s', vim.fn.shellescape(temp_dir), args) - end - return vim.fn.system(cmd) - end - -- Initialize git repo git("init") -- Rename branch to main to be sure @@ -129,18 +119,10 @@ describe("Full Integration Suite", function() -- 3. Explorer Mode: Branch it("Runs :CodeDiff main", function() -- Create a dev branch and switch to it so main is different - -- Use shellescape for paths to handle spaces/special chars on Windows - local safe_temp_dir - if vim.fn.has("win32") == 1 then - safe_temp_dir = '"' .. temp_dir .. '"' - else - safe_temp_dir = vim.fn.shellescape(temp_dir) - end - - vim.fn.system(string.format('git -C %s reset --hard HEAD~1', safe_temp_dir)) - vim.fn.system(string.format('git -C %s checkout -b feature', safe_temp_dir)) + git("reset --hard HEAD~1") + git("checkout -b feature") vim.fn.writefile({"feature change"}, temp_dir .. "/file.txt") - vim.fn.system(string.format('git -C %s commit -am "feature commit"', safe_temp_dir)) + git('commit -am "feature commit"') -- Now compare against main vim.cmd("CodeDiff main") @@ -156,14 +138,7 @@ describe("Full Integration Suite", function() -- 11. Arbitrary Revision Diff (Explorer) it("Runs :CodeDiff main HEAD", function() -- Ensure there is a diff between main and HEAD - local safe_temp_dir - if vim.fn.has("win32") == 1 then - safe_temp_dir = '"' .. temp_dir .. '"' - else - safe_temp_dir = vim.fn.shellescape(temp_dir) - end - - vim.fn.system(string.format('git -C %s checkout HEAD~1', safe_temp_dir)) + git("checkout HEAD~1") -- Now HEAD is commit 1. main is commit 2. vim.cmd("CodeDiff main HEAD") @@ -172,20 +147,12 @@ describe("Full Integration Suite", function() -- 12. Merge-base Mode (PR-like diff) it("Runs :CodeDiff main... (merge-base)", function() - -- Create a feature branch from commit 1 - local safe_temp_dir - if vim.fn.has("win32") == 1 then - safe_temp_dir = '"' .. temp_dir .. '"' - else - safe_temp_dir = vim.fn.shellescape(temp_dir) - end - -- Go back to first commit and create feature branch - vim.fn.system(string.format('git -C %s checkout %s', safe_temp_dir, commit_hash_1)) - vim.fn.system(string.format('git -C %s checkout -b feature-branch', safe_temp_dir)) + git("checkout " .. commit_hash_1) + git("checkout -b feature-branch") vim.fn.writefile({"feature line 1", "feature line 2"}, temp_dir .. "/feature.txt") - vim.fn.system(string.format('git -C %s add feature.txt', safe_temp_dir)) - vim.fn.system(string.format('git -C %s commit -m "feature commit"', safe_temp_dir)) + git("add feature.txt") + git('commit -m "feature commit"') -- Now we have: -- main: commit_hash_1 -> commit_hash_2 diff --git a/tests/ui/explorer/explorer_spec.lua b/tests/ui/explorer/explorer_spec.lua index 72cf5aa..a06b90f 100644 --- a/tests/ui/explorer/explorer_spec.lua +++ b/tests/ui/explorer/explorer_spec.lua @@ -2,6 +2,7 @@ -- Validates git status explorer functionality, window management, and file selection local git = require('codediff.core.git') +local h = dofile('tests/helpers.lua') -- Setup CodeDiff command for tests local function setup_command() @@ -35,15 +36,15 @@ describe("Explorer Mode", function() vim.fn.chdir(temp_dir) -- Initialize git repo - local init_result = vim.fn.system("git init") - assert(vim.v.shell_error == 0, "git init failed: " .. init_result) - vim.fn.system('git config user.email "test@example.com"') - vim.fn.system('git config user.name "Test User"') + h.git_cmd(temp_dir, "init") + h.git_cmd(temp_dir, "branch -m main") + h.git_cmd(temp_dir, 'config user.email "test@example.com"') + h.git_cmd(temp_dir, 'config user.name "Test User"') -- Create and commit initial file vim.fn.writefile({"line 1", "line 2"}, temp_dir .. "/file1.txt") - vim.fn.system("git add file1.txt") - local commit_result = vim.fn.system('git commit -m "Initial commit"') + h.git_cmd(temp_dir, "add file1.txt") + local commit_result = h.git_cmd(temp_dir, 'commit -m "Initial commit"') assert(vim.v.shell_error == 0, "git commit failed: " .. commit_result) -- Modify file (unstaged) @@ -51,7 +52,7 @@ describe("Explorer Mode", function() -- Create new file (staged) vim.fn.writefile({"new file"}, temp_dir .. "/file2.txt") - vim.fn.system("git add file2.txt") + h.git_cmd(temp_dir, "add file2.txt") -- Create untracked file vim.fn.writefile({"untracked"}, temp_dir .. "/file3.txt") @@ -145,18 +146,18 @@ describe("Explorer Mode", function() -- Test 2.5: Git status detects merge conflicts it("Detects merge conflicts", function() -- Create a branch and make conflicting changes - vim.fn.system("git checkout -b feature") + h.git_cmd(temp_dir, "checkout -b feature") vim.fn.writefile({"feature line 1", "line 2"}, temp_dir .. "/file1.txt") - vim.fn.system("git add file1.txt") - vim.fn.system('git commit -m "Feature change"') + h.git_cmd(temp_dir, "add file1.txt") + h.git_cmd(temp_dir, 'commit -m "Feature change"') - vim.fn.system("git checkout master") + h.git_cmd(temp_dir, "checkout main") vim.fn.writefile({"master line 1", "line 2"}, temp_dir .. "/file1.txt") - vim.fn.system("git add file1.txt") - vim.fn.system('git commit -m "Master change"') + h.git_cmd(temp_dir, "add file1.txt") + h.git_cmd(temp_dir, 'commit -m "Master change"') -- Attempt merge (will fail with conflict) - vim.fn.system("git merge feature") + h.git_cmd(temp_dir, "merge feature") local callback_called = false local status_result = nil @@ -179,7 +180,7 @@ describe("Explorer Mode", function() assert.is_true(has_conflict, "Should detect merge conflict") -- Abort merge to clean up - vim.fn.system("git merge --abort") + h.git_cmd(temp_dir, "merge --abort") end) -- Test 3: Explorer creates proper window layout @@ -385,8 +386,8 @@ describe("Explorer Mode", function() -- Test 8: No changes shows appropriate message it("Shows message when no changes exist", function() -- Commit all changes to have clean working tree - vim.fn.system("git add -A") - vim.fn.system("git commit -m 'Clean'") + h.git_cmd(temp_dir, "add -A") + h.git_cmd(temp_dir, 'commit -m "Clean"') local notified = false local original_notify = vim.notify diff --git a/tests/ui/explorer/stale_buffer_spec.lua b/tests/ui/explorer/stale_buffer_spec.lua new file mode 100644 index 0000000..55921c5 --- /dev/null +++ b/tests/ui/explorer/stale_buffer_spec.lua @@ -0,0 +1,354 @@ +-- Test: Stale buffer after git operations in explorer mode +-- Validates that diff panes update after stage, unstage, commit, and stash. +-- +-- The bug: process_result() in refresh.lua rebuilds the explorer tree but +-- never calls on_file_select to update the diff panes. After any git +-- operation (stage, unstage, commit, stash) the diff panes show stale +-- content from the *previous* state. +-- +-- These tests are written TDD-style — they should FAIL until the fix +-- is implemented in refresh.lua. + +local h = dofile("tests/helpers.lua") + +-- Ensure plugin is loaded (needed for PlenaryBustedFile subprocess) +h.ensure_plugin_loaded() + +-- Setup CodeDiff command for tests +local function setup_command() + local commands = require("codediff.commands") + vim.api.nvim_create_user_command("CodeDiff", function(opts) + commands.vscode_diff(opts) + end, { + nargs = "*", + bang = true, + complete = function() + return { "file", "install" } + end, + }) +end + +--- Trigger an explorer refresh and wait for the async git-status + +--- vim.schedule callback to propagate. +--- @param explorer table The explorer object from lifecycle +--- @param timeout_ms? number How long to spin (default 3000) +local function refresh_and_wait(explorer, timeout_ms) + timeout_ms = timeout_ms or 3000 + local refresh = require("codediff.ui.explorer.refresh") + refresh.refresh(explorer) + -- The refresh is async (git status → vim.schedule). We must let the + -- event loop run so the callback fires and the tree / session update. + vim.wait(timeout_ms, function() + return false -- just spin, processing the event loop + end, 50) +end + +--- Open :CodeDiff in the given repo directory and wait until the explorer +--- is fully ready (session exists, explorer is set, diff buffers loaded). +--- Returns tabpage, session, explorer. +--- @param repo table Repo helper from h.create_temp_git_repo() +--- @return number tabpage +--- @return table session +--- @return table explorer +local function open_codediff_and_wait(repo) + vim.fn.chdir(repo.dir) + -- Open a file so CodeDiff has context + vim.cmd("edit " .. repo.path("file1.txt")) + vim.cmd("CodeDiff") + + local lifecycle = require("codediff.ui.lifecycle") + local tabpage + + local ready = vim.wait(10000, function() + for _, tp in ipairs(vim.api.nvim_list_tabpages()) do + local s = lifecycle.get_session(tp) + if s and s.explorer then + tabpage = tp + local orig_buf, mod_buf = lifecycle.get_buffers(tp) + if orig_buf and mod_buf then + return vim.api.nvim_buf_is_valid(orig_buf) and vim.api.nvim_buf_is_valid(mod_buf) + end + end + end + return false + end, 100) + + assert.is_true(ready, "CodeDiff explorer and diff panes should be ready") + + local session = lifecycle.get_session(tabpage) + local explorer = session.explorer + assert.is_not_nil(explorer, "Explorer should exist on session") + + return tabpage, session, explorer +end + +--- Count the total number of file entries across all groups in the tree. +--- @param explorer table +--- @return number +local function count_tree_files(explorer) + local refresh = require("codediff.ui.explorer.refresh") + local files = refresh.get_all_files(explorer.tree) + return #files +end + +--- Check whether a file path exists in any group of the explorer tree. +--- @param explorer table +--- @param path string Relative file path +--- @return boolean +local function file_exists_in_tree(explorer, path) + local refresh = require("codediff.ui.explorer.refresh") + local files = refresh.get_all_files(explorer.tree) + for _, f in ipairs(files) do + if f.data.path == path then + return true + end + end + return false +end + +-- ============================================================================ +describe("Stale Buffer After Git Operations", function() + local repo + local original_cwd + + before_each(function() + require("codediff").setup({ diff = { layout = "side-by-side" } }) + setup_command() + original_cwd = vim.fn.getcwd() + + -- Create temp git repo with two modified files + repo = h.create_temp_git_repo() + + -- file1.txt: committed, then modified (unstaged) + repo.write_file("file1.txt", { "line1", "line2", "line3" }) + repo.git("add file1.txt") + repo.git('commit -m "add file1"') + repo.write_file("file1.txt", { "line1", "modified line2", "line3" }) + + -- file2.txt: committed, then modified (unstaged) + repo.write_file("file2.txt", { "alpha", "beta", "gamma" }) + repo.git("add file2.txt") + repo.git('commit -m "add file2"') + repo.write_file("file2.txt", { "alpha", "modified beta", "gamma" }) + end) + + after_each(function() + -- Safe cleanup: create fresh tab, close all others + pcall(function() + vim.cmd("tabnew") + vim.cmd("tabonly") + end) + vim.fn.chdir(original_cwd) + vim.wait(200) + if repo then + repo.cleanup() + end + end) + + -- -------------------------------------------------------------------------- + -- Test 1: Staging the currently viewed file should update diff panes + -- -------------------------------------------------------------------------- + it("updates diff panes when current file is staged", function() + local tabpage, session, explorer = open_codediff_and_wait(repo) + local lifecycle = require("codediff.ui.lifecycle") + + -- Precondition: file1.txt should be in unstaged with working-tree diff + assert.equals("unstaged", explorer.current_file_group, "Initial file should be in unstaged group") + assert.equals("file1.txt", explorer.current_file_path, "Initial file should be file1.txt") + + -- The modified side should show the working copy (modified_revision == nil or "WORKING") + session = lifecycle.get_session(tabpage) + local pre_mod_rev = session.modified_revision + assert.is_true(pre_mod_rev == nil or pre_mod_rev == "WORKING", "Before staging: modified_revision should be nil/WORKING, got: " .. tostring(pre_mod_rev)) + + -- Stage file1.txt + repo.git("add file1.txt") + + -- Refresh and wait for async completion + refresh_and_wait(explorer) + + -- After staging, the file should have moved to the staged group + -- and the diff panes should reflect the staged comparison (HEAD vs :0) + session = lifecycle.get_session(tabpage) + assert.equals("staged", explorer.current_file_group, "After staging: file should move to staged group") + assert.equals(":0", session.modified_revision, "After staging: modified_revision should be :0 (staged index)") + end) + + -- -------------------------------------------------------------------------- + -- Test 2: Unstaging the currently viewed file should update diff panes + -- -------------------------------------------------------------------------- + it("updates diff panes when current file is unstaged", function() + -- First stage file1.txt so it starts in the staged group + repo.git("add file1.txt") + + local tabpage, session, explorer = open_codediff_and_wait(repo) + local lifecycle = require("codediff.ui.lifecycle") + + -- file2.txt is still unstaged, so auto-select picks it first. + -- Explicitly select file1 in staged. The wrapper sets + -- explorer.current_file_group synchronously. + explorer.on_file_select({ + path = "file1.txt", + status = "M", + git_root = repo.dir, + group = "staged", + }) + -- Give the async view.update chain time to settle + vim.wait(3000, function() + return false + end, 50) + + -- Precondition: the synchronous tracker should reflect staged + assert.equals("staged", explorer.current_file_group, "Precondition: should be viewing staged file1") + assert.equals("file1.txt", explorer.current_file_path, "Precondition: should be viewing file1.txt") + + -- Unstage file1.txt + repo.git("reset HEAD file1.txt") + + refresh_and_wait(explorer) + + -- After unstaging, the file should be back in the unstaged group + -- and the diff panes should be re-selected with the new group. + assert.equals("unstaged", explorer.current_file_group, "After unstaging: file should move to unstaged group") + + -- Also check that the session revision was updated + session = lifecycle.get_session(tabpage) + local post_mod_rev = session.modified_revision + assert.is_true(post_mod_rev == nil or post_mod_rev == "WORKING", "After unstaging: modified_revision should be nil/WORKING, got: " .. tostring(post_mod_rev)) + end) + + -- -------------------------------------------------------------------------- + -- Test 3: File in both groups (stage, then modify again) — re-staging + -- -------------------------------------------------------------------------- + it("updates diff panes when file exists in both groups and is re-staged", function() + -- Stage file1.txt (original change) + repo.git("add file1.txt") + -- Modify file1.txt again → now appears in BOTH unstaged and staged + repo.write_file("file1.txt", { "line1", "re-modified line2", "line3" }) + + local tabpage, session, explorer = open_codediff_and_wait(repo) + local lifecycle = require("codediff.ui.lifecycle") + + -- Select file1 in unstaged group + explorer.on_file_select({ + path = "file1.txt", + status = "M", + git_root = repo.dir, + group = "unstaged", + }) + vim.wait(3000, function() + return false + end, 50) + + assert.equals("unstaged", explorer.current_file_group, "Precondition: should be viewing unstaged file1") + + -- Stage the new changes too (git add merges into staged) + repo.git("add file1.txt") + + refresh_and_wait(explorer) + + -- File should now be in staged only, diff panes updated + session = lifecycle.get_session(tabpage) + assert.equals("staged", explorer.current_file_group, "After re-staging: file should move to staged group") + assert.equals(":0", session.modified_revision, "After re-staging: modified_revision should be :0") + end) + + -- -------------------------------------------------------------------------- + -- Test 4: Stage all hunks — file moves from unstaged to staged only + -- -------------------------------------------------------------------------- + it("moves file to staged group after staging all hunks", function() + local tabpage, session, explorer = open_codediff_and_wait(repo) + local lifecycle = require("codediff.ui.lifecycle") + + -- Precondition: viewing file1.txt in unstaged + assert.equals("unstaged", explorer.current_file_group, "Precondition: file should be unstaged") + + -- Stage file1.txt + repo.git("add file1.txt") + + refresh_and_wait(explorer) + + session = lifecycle.get_session(tabpage) + assert.equals("staged", explorer.current_file_group, "After staging: current_file_group should be staged") + assert.equals(":0", session.modified_revision, "After staging: diff panes should show staged comparison (modified_revision = :0)") + end) + + -- -------------------------------------------------------------------------- + -- Test 5: Commit current file — others remain, welcome if empty + -- -------------------------------------------------------------------------- + it("shows welcome or next file when current file is committed", function() + local tabpage, session, explorer = open_codediff_and_wait(repo) + local lifecycle = require("codediff.ui.lifecycle") + local welcome = require("codediff.ui.welcome") + + -- Precondition: file1.txt selected in unstaged + assert.equals("file1.txt", explorer.current_file_path, "Precondition: should be viewing file1.txt") + + -- Commit only file1.txt + repo.git("add file1.txt") + repo.git('commit -m "commit file1"') + + refresh_and_wait(explorer) + + -- file1.txt should be gone from the tree + assert.is_false(file_exists_in_tree(explorer, "file1.txt"), "file1.txt should be removed from explorer after commit") + + -- file2.txt should still be present + assert.is_true(file_exists_in_tree(explorer, "file2.txt"), "file2.txt should still be in explorer tree") + + -- The diff panes should NOT show stale file1.txt content. + -- They should either show welcome (if nothing auto-selected) + -- or show a different file. + session = lifecycle.get_session(tabpage) + local shows_welcome = welcome.is_welcome_buffer(session.modified_bufnr) + local shows_different_file = explorer.current_file_path ~= "file1.txt" + assert.is_true(shows_welcome or shows_different_file, "After committing file1: should show welcome or a different file, not stale file1 content") + end) + + -- -------------------------------------------------------------------------- + -- Test 6: Commit everything — welcome page shows + -- -------------------------------------------------------------------------- + it("shows welcome page when all files are committed", function() + local tabpage, session, explorer = open_codediff_and_wait(repo) + local lifecycle = require("codediff.ui.lifecycle") + local welcome = require("codediff.ui.welcome") + + -- Commit everything + repo.git("add -A") + repo.git('commit -m "commit all"') + + refresh_and_wait(explorer) + + -- Explorer tree should have zero files + assert.equals(0, count_tree_files(explorer), "Explorer tree should have 0 files after committing everything") + + -- Diff panes should show the welcome page + session = lifecycle.get_session(tabpage) + assert.is_true(welcome.is_welcome_buffer(session.modified_bufnr), "Welcome buffer should be shown after committing all files") + assert.is_nil(explorer.current_file_path, "Current file path should be cleared when the tree becomes empty") + assert.is_nil(explorer.current_file_group, "Current file group should be cleared when the tree becomes empty") + end) + + -- -------------------------------------------------------------------------- + -- Test 7: Stash all changes — welcome page shows + -- -------------------------------------------------------------------------- + it("shows welcome page when all changes are stashed", function() + local tabpage, session, explorer = open_codediff_and_wait(repo) + local lifecycle = require("codediff.ui.lifecycle") + local welcome = require("codediff.ui.welcome") + + -- Stash everything + repo.git("stash") + + refresh_and_wait(explorer) + + -- Explorer tree should have zero files + assert.equals(0, count_tree_files(explorer), "Explorer tree should have 0 files after stashing all changes") + + -- Diff panes should show the welcome page + session = lifecycle.get_session(tabpage) + assert.is_true(welcome.is_welcome_buffer(session.modified_bufnr), "Welcome buffer should be shown after stashing all changes") + assert.is_nil(explorer.current_file_path, "Current file path should be cleared when the tree becomes empty") + assert.is_nil(explorer.current_file_group, "Current file group should be cleared when the tree becomes empty") + end) +end) diff --git a/tests/ui/welcome_spec.lua b/tests/ui/welcome_spec.lua new file mode 100644 index 0000000..a9110f8 --- /dev/null +++ b/tests/ui/welcome_spec.lua @@ -0,0 +1,335 @@ +-- Test: Welcome page for empty diff panes +-- Validates welcome buffer creation, detection, and integration with refresh + +local helpers = require("tests.helpers") + +-- Setup CodeDiff command for tests +local function setup_command() + local commands = require("codediff.commands") + vim.api.nvim_create_user_command("CodeDiff", function(opts) + commands.vscode_diff(opts) + end, { + nargs = "*", + bang = true, + complete = function() + return { "file", "install" } + end, + }) +end + +describe("Welcome Page", function() + -- ============================================================================ + -- Unit tests for welcome.lua buffer factory + -- ============================================================================ + + describe("Unit:", function() + local welcome + + before_each(function() + welcome = require("codediff.ui.welcome") + end) + + it("create_buffer returns a valid buffer with logo content", function() + local bufnr = welcome.create_buffer(80, 24) + + assert.is_true(vim.api.nvim_buf_is_valid(bufnr)) + assert.equals("nofile", vim.bo[bufnr].buftype) + assert.equals("wipe", vim.bo[bufnr].bufhidden) + assert.is_false(vim.bo[bufnr].buflisted) + assert.equals("codediff", vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ":t")) + + -- Buffer should contain the logo + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, "\n") + assert.is_true(content:find("CODEDIFF") ~= nil or content:find("██") ~= nil, "Buffer should contain logo text") + + -- Buffer should contain hint text + assert.is_true(content:find("Working tree is clean") ~= nil, "Buffer should contain hint message") + end) + + it("is_welcome_buffer returns true for welcome buffers", function() + local bufnr = welcome.create_buffer(80, 24) + assert.is_true(welcome.is_welcome_buffer(bufnr)) + end) + + it("is_welcome_buffer returns false for non-welcome buffers", function() + local bufnr = vim.api.nvim_create_buf(false, true) + assert.is_false(welcome.is_welcome_buffer(bufnr)) + + -- Cleanup + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end) + + it("is_welcome_buffer returns false for invalid buffer", function() + assert.is_false(welcome.is_welcome_buffer(nil)) + assert.is_false(welcome.is_welcome_buffer(-1)) + assert.is_false(welcome.is_welcome_buffer(999999)) + end) + + it("create_buffer does not modify any window options", function() + -- Capture window options before + local win = vim.api.nvim_get_current_win() + local number_before = vim.wo[win].number + local signcolumn_before = vim.wo[win].signcolumn + local wrap_before = vim.wo[win].wrap + + welcome.create_buffer(80, 24) + + -- Window options should be unchanged + assert.equals(number_before, vim.wo[win].number) + assert.equals(signcolumn_before, vim.wo[win].signcolumn) + assert.equals(wrap_before, vim.wo[win].wrap) + end) + + it("create_buffer centers logo based on dimensions", function() + local bufnr = welcome.create_buffer(120, 40) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- First line with logo content should have leading spaces (centered) + local found_logo = false + for _, line in ipairs(lines) do + if line:find("██") then + found_logo = true + -- Should have leading spaces for centering + assert.is_true(line:match("^%s") ~= nil, "Logo line should have leading spaces for centering") + break + end + end + assert.is_true(found_logo, "Should find logo in buffer") + end) + + it("welcome window override is saved and restored per window", function() + local lifecycle = require("codediff.ui.lifecycle") + local welcome_window = require("codediff.ui.view.welcome_window") + local tabpage = vim.api.nvim_get_current_tabpage() + local main_win = vim.api.nvim_get_current_win() + local regular_buf = vim.api.nvim_create_buf(false, true) + local other_buf = vim.api.nvim_create_buf(false, true) + local welcome_buf = welcome.create_buffer(80, 24) + + vim.wo[main_win].number = true + vim.wo[main_win].relativenumber = true + vim.wo[main_win].signcolumn = "yes:1" + vim.wo[main_win].foldcolumn = "2" + vim.wo[main_win].statuscolumn = "%l" + + vim.cmd("vsplit") + local other_win = vim.api.nvim_get_current_win() + vim.wo[other_win].number = true + vim.wo[other_win].relativenumber = false + vim.wo[other_win].signcolumn = "yes" + vim.wo[other_win].statuscolumn = "%=%l" + + lifecycle.create_session(tabpage, "standalone", nil, "", "", nil, nil, regular_buf, other_buf, main_win, other_win, {}, nil) + vim.api.nvim_set_current_win(main_win) + + vim.api.nvim_win_set_buf(main_win, welcome_buf) + welcome_window.sync(main_win) + + assert.is_false(vim.wo[main_win].number) + assert.is_false(vim.wo[main_win].relativenumber) + assert.equals("no", vim.wo[main_win].signcolumn) + assert.equals("0", vim.wo[main_win].foldcolumn) + assert.equals(" ", vim.wo[main_win].statuscolumn) + + assert.is_true(vim.wo[other_win].number) + assert.is_false(vim.wo[other_win].relativenumber) + assert.equals("yes", vim.wo[other_win].signcolumn) + assert.equals("%=%l", vim.wo[other_win].statuscolumn) + + vim.api.nvim_win_set_buf(main_win, regular_buf) + welcome_window.sync(main_win) + + assert.is_true(vim.wo[main_win].number) + assert.is_true(vim.wo[main_win].relativenumber) + assert.equals("yes:1", vim.wo[main_win].signcolumn) + assert.equals("2", vim.wo[main_win].foldcolumn) + assert.equals("%l", vim.wo[main_win].statuscolumn) + + lifecycle.cleanup(tabpage) + vim.api.nvim_set_current_win(main_win) + if vim.api.nvim_win_is_valid(other_win) then + vim.api.nvim_win_close(other_win, true) + end + if vim.api.nvim_buf_is_valid(regular_buf) then + vim.api.nvim_buf_delete(regular_buf, { force = true }) + end + if vim.api.nvim_buf_is_valid(other_buf) then + vim.api.nvim_buf_delete(other_buf, { force = true }) + end + end) + end) + + -- ============================================================================ + -- E2E integration test: refresh triggers welcome, then file select restores + -- ============================================================================ + + describe("E2E:", function() + local repo + local original_cwd + + before_each(function() + require("codediff").setup({ diff = { layout = "side-by-side" } }) + setup_command() + original_cwd = vim.fn.getcwd() + repo = helpers.create_temp_git_repo() + end) + + after_each(function() + -- Create a safe tab to avoid closing last tab + vim.cmd("tabnew") + vim.cmd("tabonly") + vim.fn.chdir(original_cwd) + vim.wait(200) + if repo then + repo.cleanup() + end + end) + + it("shows welcome when all changes are discarded, restores on file select", function() + -- Setup: create a file, commit, then modify + repo.write_file("test.txt", { "line 1", "line 2" }) + repo.git("add .") + repo.git("commit -m 'initial'") + repo.write_file("test.txt", { "line 1", "line 2 modified", "line 3" }) + + vim.fn.chdir(repo.dir) + vim.cmd("edit " .. repo.path("test.txt")) + + -- Open CodeDiff explorer + vim.cmd("CodeDiff") + + -- Wait for explorer and diff to be ready + -- CodeDiff opens a new tab, so we need to find the right tabpage + local lifecycle = require("codediff.ui.lifecycle") + + local tabpage + local explorer_ready = vim.wait(10000, function() + -- Find the tabpage with a session (CodeDiff creates a new tab) + for _, tp in ipairs(vim.api.nvim_list_tabpages()) do + local s = lifecycle.get_session(tp) + if s then + tabpage = tp + break + end + end + if not tabpage then + return false + end + local session = lifecycle.get_session(tabpage) + if not session then + return false + end + if not session.explorer then + return false + end + -- Wait for diff to render (buffers loaded) + local orig_buf, mod_buf = lifecycle.get_buffers(tabpage) + if not orig_buf or not mod_buf then + return false + end + return vim.api.nvim_buf_is_valid(orig_buf) and vim.api.nvim_buf_is_valid(mod_buf) + end, 100) + + assert.is_true(explorer_ready, "Explorer and diff should be ready") + + local session = lifecycle.get_session(tabpage) + assert.is_not_nil(session, "Session should exist") + assert.is_not_nil(session.explorer, "Explorer should exist") + + -- Verify two windows are valid (before discard) + local orig_win = session.original_win + local mod_win = session.modified_win + assert.is_true(vim.api.nvim_win_is_valid(orig_win), "Original window should be valid") + assert.is_true(vim.api.nvim_win_is_valid(mod_win), "Modified window should be valid") + + vim.wo[mod_win].number = true + vim.wo[mod_win].relativenumber = true + vim.wo[mod_win].signcolumn = "yes:1" + vim.wo[mod_win].foldcolumn = "2" + vim.wo[mod_win].statuscolumn = "%l" + + -- Discard changes: run git checkout + vim.fn.system("git -C " .. vim.fn.shellescape(repo.dir) .. " checkout -- test.txt") + + -- Trigger refresh + local refresh = require("codediff.ui.explorer.refresh") + local explorer = session.explorer + refresh.refresh(explorer) + + -- Wait for welcome buffer to appear + local welcome = require("codediff.ui.welcome") + local welcome_appeared = vim.wait(10000, function() + local s = lifecycle.get_session(tabpage) + if not s then + return false + end + return welcome.is_welcome_buffer(s.modified_bufnr) + end, 100) + + assert.is_true(welcome_appeared, "Welcome buffer should appear after discarding all changes") + + -- Verify welcome state + session = lifecycle.get_session(tabpage) + assert.is_true(session.single_pane == true, "Session should be in single_pane mode") + assert.is_false(vim.wo[session.modified_win].number) + assert.is_false(vim.wo[session.modified_win].relativenumber) + assert.equals("no", vim.wo[session.modified_win].signcolumn) + assert.equals("0", vim.wo[session.modified_win].foldcolumn) + assert.equals(" ", vim.wo[session.modified_win].statuscolumn) + + -- Restore changes: modify the file again + repo.write_file("test.txt", { "line 1", "line 2 changed again" }) + + -- Trigger refresh again + refresh.refresh(explorer) + + -- Wait for tree to update (refresh is async) + local tree_updated = vim.wait(10000, function() + -- The tree should now have files + local files = refresh.get_all_files(explorer.tree) + return #files > 0 + end, 100) + + assert.is_true(tree_updated, "Tree should update with new files") + + -- Verify NO auto-select: diff panes still show welcome content + session = lifecycle.get_session(tabpage) + assert.is_true(welcome.is_welcome_buffer(session.modified_bufnr), "Welcome buffer should still be shown (no auto-select on refresh)") + + -- Simulate user click: select the file + explorer.on_file_select({ + path = repo.path("test.txt"), + status = "M", + git_root = repo.dir, + group = "unstaged", + }) + + -- Wait for diff to restore + local diff_restored = vim.wait(10000, function() + local s = lifecycle.get_session(tabpage) + if not s then + return false + end + -- single_pane should be cleared and both windows valid + if s.single_pane then + return false + end + return vim.api.nvim_win_is_valid(s.original_win) and vim.api.nvim_win_is_valid(s.modified_win) + end, 100) + + assert.is_true(diff_restored, "Diff should be restored after file select") + + -- Verify diff content is visible + session = lifecycle.get_session(tabpage) + assert.is_false(welcome.is_welcome_buffer(session.modified_bufnr), "Welcome buffer should be replaced with diff content") + assert.is_true(vim.wo[session.modified_win].number) + assert.is_true(vim.wo[session.modified_win].relativenumber) + assert.equals("yes:1", vim.wo[session.modified_win].signcolumn) + assert.equals("2", vim.wo[session.modified_win].foldcolumn) + assert.equals("%l", vim.wo[session.modified_win].statuscolumn) + end) + end) +end)