Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
- Deep/dark character-level highlights showing exact changes within lines
- **Side-by-side diff view** in a new tab with synchronized scrolling
- **Inline (unified) diff view** — single-window layout with deleted lines as virtual overlays, with treesitter syntax highlighting
- **Toggle layout** — switch between side-by-side and inline layout at runtime with `t`
- **Git integration**: Compare between any git revision (HEAD, commits, branches, tags)
- **Same implementation as VSCode's diff engine**, providing identical visual highlighting for most scenarios
- **Fast C-based diff computation** using FFI with **multi-core parallelization** (OpenMP)
Expand Down Expand Up @@ -155,6 +156,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
hunk_textobject = "ih", -- Textobject for hunk (vih to select, yih to yank, etc.)
show_help = "g?", -- Show floating window with available keymaps
align_move = "gm", -- Temporarily align moved code blocks across panes
toggle_layout = "t", -- Toggle between side-by-side and inline layout
},
explorer = {
select = "<CR>", -- Open diff for selected file
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.42.0
2.43.0
18 changes: 18 additions & 0 deletions doc/codediff.txt
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ The `gm` keymap disables scrollbind and positions both panes so the moved
block appears at the same visual row. Moving the cursor outside the block
or switching windows restores normal scrollbind.

TOGGLING LAYOUT *codediff-toggle-layout*

Press `t` in a diff view to toggle between side-by-side and inline layout.
The current file is re-rendered in the new layout automatically. This works
in all modes (explorer, history, standalone).

The toggle keymap can be customized:
>lua
require("codediff").setup({
keymaps = {
view = {
toggle_layout = "t", -- or false to disable
},
},
})
<

Setup entry point:
>lua
require("codediff").setup({
Expand Down Expand Up @@ -216,6 +233,7 @@ Setup entry point:
hunk_textobject = "ih",
show_help = "g?",
align_move = "gm",
toggle_layout = "t",
},
explorer = {
select = "<CR>",
Expand Down
1 change: 1 addition & 0 deletions doc/tags
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ codediff-lua-api codediff.txt /*codediff-lua-api*
codediff-moved-code codediff.txt /*codediff-moved-code*
codediff-quickstart codediff.txt /*codediff-quickstart*
codediff-see-also codediff.txt /*codediff-see-also*
codediff-toggle-layout codediff.txt /*codediff-toggle-layout*
codediff-usage codediff.txt /*codediff-usage*
codediff.nvim codediff.txt /*codediff.nvim*
codediff.txt codediff.txt /*codediff.txt*
1 change: 1 addition & 0 deletions lua/codediff/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ M.defaults = {
discard_hunk = "<leader>hr", -- Discard the hunk under cursor (working tree only)
hunk_textobject = "ih", -- Textobject for hunk (vih to select, yih to yank, etc.)
align_move = "gm", -- Temporarily align other pane to show paired moved code
toggle_layout = "t", -- Toggle diff layout for the current codediff session
show_help = "g?", -- Show floating window with available keymaps
},
explorer = {
Expand Down
2 changes: 2 additions & 0 deletions lua/codediff/ui/explorer/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ local actions = require("codediff.ui.explorer.actions")

-- Delegate to render module
M.create = render.create
M.rerender_current = render.rerender_current
M.show_welcome_page = render.show_welcome_page

-- Delegate to refresh module
M.setup_auto_refresh = refresh.setup_auto_refresh
Expand Down
41 changes: 13 additions & 28 deletions lua/codediff/ui/explorer/refresh.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ 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
function M.setup_auto_refresh(explorer, tabpage)
Expand Down Expand Up @@ -256,38 +255,24 @@ function M.refresh(explorer)
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
explorer.current_selection = nil
if explorer.clear_selection then
explorer.clear_selection()
end
end

-- Show welcome page when all files are clean
local show_welcome_page = require("codediff.ui.explorer.render").show_welcome_page

-- Show welcome page when all files are clean (skip if already showing)
local total_files = #(status_result.unstaged or {}) + #(status_result.staged or {}) + #(status_result.conflicts or {})
if total_files == 0 then
local lifecycle = require("codediff.ui.lifecycle")
local session = lifecycle.get_session(explorer.tabpage)
local already_welcome = session and welcome.is_welcome_buffer(session.modified_bufnr)
clear_current_file()
show_welcome_page()
if not already_welcome then
show_welcome_page(explorer)
end
end

-- Re-select the currently viewed file after refresh.
Expand Down Expand Up @@ -339,7 +324,7 @@ function M.refresh(explorer)
else
-- File was committed/removed — show welcome
clear_current_file()
show_welcome_page()
show_welcome_page(explorer)
end
end
end)
Expand Down
123 changes: 112 additions & 11 deletions lua/codediff/ui/explorer/render.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,48 @@ local nodes_module = require("codediff.ui.explorer.nodes")
local tree_module = require("codediff.ui.explorer.tree")
local keymaps_module = require("codediff.ui.explorer.keymaps")
local refresh_module = require("codediff.ui.explorer.refresh")
local welcome = require("codediff.ui.welcome")

local function should_show_welcome(explorer)
if not explorer or not explorer.git_root or explorer.dir1 or explorer.dir2 then
return false
end

local status = explorer.status_result or {}
local total_files = #(status.unstaged or {}) + #(status.staged or {}) + #(status.conflicts or {})
return total_files == 0
end

local function show_welcome_page(explorer)
local lifecycle = require("codediff.ui.lifecycle")
local session = lifecycle.get_session(explorer.tabpage)
if not session then
return false
end

local mod_win = session.modified_win
if not mod_win or not vim.api.nvim_win_is_valid(mod_win) then
return false
end

if session.layout == "inline" then
local welcome_buf = welcome.create_buffer(vim.api.nvim_win_get_width(mod_win), vim.api.nvim_win_get_height(mod_win))
require("codediff.ui.view.inline_view").show_welcome(explorer.tabpage, welcome_buf)
return true
end

local orig_win = session.original_win
local width = vim.api.nvim_win_get_width(mod_win)
local height = vim.api.nvim_win_get_height(mod_win)
if orig_win and vim.api.nvim_win_is_valid(orig_win) then
width = vim.api.nvim_win_get_width(orig_win) + width + 1
height = vim.api.nvim_win_get_height(orig_win)
end

local welcome_buf = welcome.create_buffer(width, height)
require("codediff.ui.view.side_by_side").show_welcome(explorer.tabpage, welcome_buf)
return true
end

function M.create(status_result, git_root, tabpage, width, base_revision, target_revision, opts)
opts = opts or {}
Expand Down Expand Up @@ -132,12 +174,14 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
on_file_select = nil, -- Will be set below
current_file_path = nil, -- Track currently selected file
current_file_group = nil, -- Track currently selected file's group (staged/unstaged)
current_selection = nil, -- Full file selection used to replay current state
is_hidden = false, -- Track visibility state
visible_groups = vim.deepcopy(explorer_config.visible_groups or { staged = true, unstaged = true, conflicts = true }),
}

-- File selection callback - manages its own lifecycle
local function on_file_select(file_data)
local function on_file_select(file_data, opts)
opts = opts or {}
local git = require("codediff.core.git")
local view = require("codediff.ui.view")
local lifecycle = require("codediff.ui.lifecycle")
Expand All @@ -164,7 +208,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target

-- Check if already displaying same file
local session = lifecycle.get_session(tabpage)
if session and session.original_path == original_path and session.modified_path == modified_path then
if not opts.force and session and session.original_path == original_path and session.modified_path == modified_path then
return
end

Expand All @@ -190,7 +234,9 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
vim.schedule(function()
local sess = lifecycle.get_session(tabpage)
if sess and sess.layout == "inline" then
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path)
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path, {
side = "modified",
})
else
require("codediff.ui.view.side_by_side").show_untracked_file(tabpage, abs_path)
end
Expand All @@ -206,19 +252,31 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target

if base_revision and target_revision and target_revision ~= "WORKING" then
if is_inline then
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, { revision = target_revision, git_root = git_root, rel_path = file_path })
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, {
revision = target_revision,
git_root = git_root,
rel_path = file_path,
side = "modified",
})
else
require("codediff.ui.view.side_by_side").show_added_virtual_file(tabpage, git_root, file_path, target_revision)
end
elseif group == "staged" then
if is_inline then
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, { revision = ":0", git_root = git_root, rel_path = file_path })
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, {
revision = ":0",
git_root = git_root,
rel_path = file_path,
side = "modified",
})
else
require("codediff.ui.view.side_by_side").show_added_virtual_file(tabpage, git_root, file_path, ":0")
end
else
if is_inline then
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path)
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path, {
side = "modified",
})
else
require("codediff.ui.view.side_by_side").show_untracked_file(tabpage, abs_path)
end
Expand All @@ -235,14 +293,24 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target

if base_revision and target_revision and target_revision ~= "WORKING" then
if is_inline then
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, { revision = base_revision, git_root = git_root, rel_path = file_path })
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, {
revision = base_revision,
git_root = git_root,
rel_path = file_path,
side = "original",
})
else
require("codediff.ui.view.side_by_side").show_deleted_virtual_file(tabpage, git_root, file_path, base_revision)
end
else
if is_inline then
local revision = (group == "staged") and "HEAD" or ":0"
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, { revision = revision, git_root = git_root, rel_path = file_path })
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, {
revision = revision,
git_root = git_root,
rel_path = file_path,
side = "original",
})
else
require("codediff.ui.view.side_by_side").show_deleted_file(tabpage, git_root, file_path, abs_path, group)
end
Expand All @@ -257,7 +325,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
if session then
local is_same_file = (session.modified_path == abs_path or (session.git_root and session.original_path == file_path))

if is_same_file then
if is_same_file and not opts.force then
-- Check if it's the same diff comparison
local is_staged_diff = group == "staged"
local current_is_staged = session.modified_revision == ":0"
Expand Down Expand Up @@ -391,13 +459,21 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
end

-- Wrap on_file_select to track current file and group
explorer.on_file_select = function(file_data)
explorer.on_file_select = function(file_data, opts)
explorer.current_file_path = file_data.path
explorer.current_file_group = file_data.group
explorer.current_selection = vim.deepcopy(file_data)
selected_path = file_data.path
selected_group = file_data.group
tree:render()
on_file_select(file_data)
on_file_select(file_data, opts)
end

-- Clear selection highlight (used when showing welcome page)
explorer.clear_selection = function()
selected_path = nil
selected_group = nil
tree:render()
end

-- Setup keymaps (delegated to keymaps module)
Expand Down Expand Up @@ -485,6 +561,31 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
return explorer
end

function M.rerender_current(explorer)
if not explorer then
return false
end

if explorer.current_selection then
explorer.on_file_select(vim.deepcopy(explorer.current_selection), { force = true })
return true
end

local lifecycle = require("codediff.ui.lifecycle")
local session = lifecycle.get_session(explorer.tabpage)
if not session then
return false
end

if should_show_welcome(explorer) and show_welcome_page(explorer) then
return true
end

return false
end

M.show_welcome_page = show_welcome_page

-- Setup auto-refresh on file save and focus

return M
1 change: 1 addition & 0 deletions lua/codediff/ui/history/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ local render = require("codediff.ui.history.render")
-- width: optional width override
-- opts: { range, path, ... } original options
M.create = render.create
M.rerender_current = render.rerender_current

-- Navigation (files within expanded commits)
M.navigate_next = render.navigate_next
Expand Down
Loading