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
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
focus_on_select = false, -- Jump to modified pane after selecting a file (default: stay in explorer)
auto_open_on_cursor = false, -- Rebind j/k/Down/Up in the explorer to also open the file under the cursor
status_right_margin = 1, -- Trailing cells between status symbol (M/A/D) and right edge; increase if Nerd Font icons clip it
line_stats = {
enabled = false, -- Fetch and show Git line statistics
count_untracked = false, -- Count untracked file lines as insertions
max_untracked_bytes = 1024 * 1024, -- Skip larger untracked files
},
ellipsis = "…", -- Text appended to truncated Explorer regions
formatters = { -- Optional function(ctx) -> line layout callbacks; omit to use the built-ins
file = nil, -- File rows
Expand Down Expand Up @@ -233,6 +238,10 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e

`diff.filler_text` accepts any non-empty text pattern and repeats it across filler rows. Set it to `""` to hide the decoration while preserving the rows that keep side-by-side and conflict panes aligned. Non-empty patterns use the `CodeDiffFiller` highlight group.

Explorer line statistics are disabled by default because they require extra Git queries and consume space in the default 40-column Explorer. Set `explorer.line_stats.enabled = true` to show per-file Git numstat counts and group totals in status, one-revision, and two-revision modes. Untracked files have no stats unless `count_untracked = true`; files larger than `max_untracked_bytes` are not read (1 MiB by default).

Files use `+12 -4` (`bin` for binary files), and group headings use `Changes (3 · +42 -8)`. Aggregate folder and group stats contain `files_changed`, `insertions`, `deletions`, `binary_files`, and `unavailable_files`.

#### Explorer line formatters

`explorer.formatters.file`, `folder`, and `group` replace the complete corresponding explorer row. Each callback receives row metadata and returns a layout:
Expand All @@ -254,13 +263,13 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
}
```

A region contains styled `segments`. A numeric `truncate_priority` makes it truncatable; lower priorities truncate first. Regions without a priority stay fixed unless all content cannot fit. The renderer measures display cells, truncates with `explorer.ellipsis` (default `…`), right-aligns `right`, and preserves `min_gap` when space permits. The ellipsis can contain multiple characters and is clipped display-width-aware when necessary.
A region contains styled `segments`. A numeric `truncate_priority` makes it truncatable; lower priorities truncate first. Regions without a priority stay fixed unless all content cannot fit. The renderer measures display cells, truncates with `explorer.ellipsis` (default `…`), right-aligns `right`, and preserves `min_gap` when space permits. The built-in file formatter truncates the directory, filename, then stats while keeping status fixed. The ellipsis can contain multiple characters and is clipped display-width-aware when necessary.

Each segment is `{ text = string, hl? = highlight }`. `hl` accepts a Neovim highlight group, a `#RGB`/`#RRGGBB` foreground color, or a highlight definition such as `{ fg = "#3fb950", bold = true }`. Omitted highlights use `Normal`; selected file rows retain their selection background.

File contexts contain `path`, `filename`, `directory`, `old_path`, `group`, `status`, `status_hl`, `status_right_margin`, `indent`, `indent_hl`, `icon`, and `icon_hl`. Folder contexts contain `name`, `path`, `group`, `file_count`, `files`, `indent`, `indent_hl`, `icon`, `icon_hl`, and `expanded`. Group contexts contain `name`, `label`, `file_count`, `files`, and `expanded`.
File contexts contain `path`, `filename`, `directory`, `old_path`, `group`, `stats`, `status`, `status_hl`, `status_right_margin`, `indent`, `indent_hl`, `icon`, and `icon_hl`. Folder contexts contain `name`, `path`, `group`, `file_count`, `stats`, `files`, `indent`, `indent_hl`, `icon`, `icon_hl`, and `expanded`. Group contexts contain `name`, `label`, `file_count`, `stats`, `files`, and `expanded`. `stats` is `nil` when line statistics are disabled.

Folder and group `files` contain `{ path, old_path, group, status }` entries for every represented file. The built-in callbacks are exported by `codediff.ui.explorer.formatters` and return fresh layouts that can be assigned directly or wrapped.
Folder and group `files` contain `{ path, old_path, group, status, stats }` entries for every represented file. The built-in callbacks are exported by `codediff.ui.explorer.formatters` and return fresh layouts that can be assigned directly or wrapped.

```lua
require("codediff").setup({
Expand Down Expand Up @@ -701,6 +710,10 @@ The plugin defines highlight groups matching VSCode's diff colors:
- `CodeDiffFiller` - Gray foreground for non-empty filler line patterns
- `CodeDiffLineMove` - Background for moved code lines (derived from DiffChange)
- `CodeDiffMoveTo` - Sign column and annotation color for move indicators
- `CodeDiffExplorerStatFiles` - Explorer file counts
- `CodeDiffExplorerStatInsertions` - Explorer insertion counts
- `CodeDiffExplorerStatDeletions` - Explorer deletion counts
- `CodeDiffExplorerStatBinary` - Explorer binary-file labels

<details open>
<summary><b>📸 Visual Examples</b> (click to collapse)</summary>
Expand Down
40 changes: 32 additions & 8 deletions doc/codediff.txt
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ Setup entry point:
},
untracked = "all", -- "all", "normal" (collapse dirs), or "no" (skip untracked; use for huge work trees like GIT_WORK_TREE=$HOME, #389)
status_right_margin = 1,
line_stats = {
enabled = false,
count_untracked = false,
max_untracked_bytes = 1024 * 1024,
},
ellipsis = "…",
formatters = {
file = nil,
Expand Down Expand Up @@ -339,6 +344,19 @@ Setup entry point:
})
<

EXPLORER LINE STATISTICS *codediff-explorer-line-statistics*

Explorer line statistics are disabled by default because they require extra Git
queries and consume space in the default 40-column Explorer. With `enabled =
true`, tracked files show Git numstat counts and group headings show totals in
status, one-revision, and two-revision modes. Untracked files have no stats
unless `count_untracked = true`; files larger than `max_untracked_bytes` are not
read (1 MiB by default).

Files use `+12 -4` (`bin` for binary files); group headings use `Changes (3 ·
+42 -8)`. Aggregate folder and group stats contain `files_changed`,
`insertions`, `deletions`, `binary_files`, and `unavailable_files`.

EXPLORER LINE FORMATTERS *codediff-explorer-line-formatters*

`explorer.formatters.file`, `folder`, and `group` replace the complete
Expand All @@ -365,24 +383,26 @@ A region contains styled `segments`. A numeric `truncate_priority` makes it
truncatable; lower priorities truncate first. Regions without one stay fixed
unless all content cannot fit. The renderer measures display cells, truncates
with `explorer.ellipsis` (default `…`), right-aligns `right`, and preserves
`min_gap` when space permits. The ellipsis can contain multiple characters and
is clipped display-width-aware when necessary.
`min_gap` when space permits. The built-in file formatter truncates the
directory, filename, then stats while keeping status fixed. The ellipsis can
contain multiple characters and is clipped display-width-aware when necessary.

Each segment is `{ text = string, hl? = highlight }`. `hl` accepts a Neovim
highlight group, a `#RGB`/`#RRGGBB` foreground color, or a highlight definition
such as `{ fg = "#3fb950", bold = true }`. Omitted highlights use `Normal`;
selected file rows retain their selection background.

File contexts contain `path`, `filename`, `directory`, `old_path`, `group`,
`status`, `status_hl`, `status_right_margin`, `indent`, `indent_hl`, `icon`, and
`icon_hl`.
`stats`, `status`, `status_hl`, `status_right_margin`, `indent`, `indent_hl`,
`icon`, and `icon_hl`.

Folder contexts contain `name`, `path`, `group`, `file_count`, `files`,
Folder contexts contain `name`, `path`, `group`, `file_count`, `stats`, `files`,
`indent`, `indent_hl`, `icon`, `icon_hl`, and `expanded`. Group contexts contain
`name`, `label`, `file_count`, `files`, and `expanded`.
`name`, `label`, `file_count`, `stats`, `files`, and `expanded`. `stats` is nil
when line statistics are disabled.

Folder and group `files` contain `{ path, old_path, group, status }` entries for
every represented file. Built-in callbacks are exported by
Folder and group `files` contain `{ path, old_path, group, status, stats }`
entries for every represented file. Built-in callbacks are exported by
`codediff.ui.explorer.formatters` and return fresh layouts that can be assigned
directly or wrapped.

Expand Down Expand Up @@ -464,6 +484,10 @@ Explorer & history git status highlights (customizable):
- CodeDiffStatusUntracked Untracked files (links to DiagnosticInfo)
- CodeDiffStatusConflict Conflicting files (links to DiagnosticError)
- CodeDiffExplorerSelected Selected file background (links to Visual)
- CodeDiffExplorerStatFiles File counts (links to Number)
- CodeDiffExplorerStatInsertions Insertions (links to Added or DiagnosticOk)
- CodeDiffExplorerStatDeletions Deletions (links to Removed or DiagnosticError)
- CodeDiffExplorerStatBinary Binary labels (links to NonText)

Override in your config or colorscheme:
>lua
Expand Down
1 change: 1 addition & 0 deletions doc/tags
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ codediff-commands codediff.txt /*codediff-commands*
codediff-configuration codediff.txt /*codediff-configuration*
codediff-events codediff.txt /*codediff-events*
codediff-explorer-line-formatters codediff.txt /*codediff-explorer-line-formatters*
codediff-explorer-line-statistics codediff.txt /*codediff-explorer-line-statistics*
codediff-filler-text codediff.txt /*codediff-filler-text*
codediff-highlight-groups codediff.txt /*codediff-highlight-groups*
codediff-history codediff.txt /*codediff-history*
Expand Down
6 changes: 3 additions & 3 deletions lua/codediff/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ local function handle_explorer(revision, revision2, global_opts, pathspec)
return
end

git.get_diff_revisions(commit_hash, commit_hash2, git_root, function(err_status, status_result)
git.get_diff_revisions_with_line_stats(commit_hash, commit_hash2, git_root, function(err_status, status_result)
process_status(err_status, status_result, commit_hash, commit_hash2)
end, pathspec)
end)
Expand All @@ -436,13 +436,13 @@ local function handle_explorer(revision, revision2, global_opts, pathspec)
end

-- Get diff between revision and working tree
git.get_diff_revision(commit_hash, git_root, function(err_status, status_result)
git.get_diff_revision_with_line_stats(commit_hash, git_root, function(err_status, status_result)
process_status(err_status, status_result, commit_hash, "WORKING")
end, pathspec)
end)
else
-- Get git status (current changes)
git.get_status(git_root, function(err_status, status_result)
git.get_status_with_line_stats(git_root, function(err_status, status_result)
-- Pass nil for revisions to enable "Status Mode" in explorer (separate Staged/Unstaged groups)
process_status(err_status, status_result, nil, nil)
end, pathspec)
Expand Down
7 changes: 6 additions & 1 deletion lua/codediff/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,13 @@ M.defaults = {
auto_open_on_cursor = false, -- Rebind j/k/Down/Up in the explorer to also open the file under the cursor
flatten_dirs = true, -- Flatten single-child directory chains in tree view (e.g., src/components/ui/)
status_right_margin = 1, -- Trailing cells between the status symbol (M/A/D) and the right edge; increase if Nerd Font icons clip it
line_stats = {
enabled = false,
count_untracked = false,
max_untracked_bytes = 1024 * 1024,
},
ellipsis = "…", -- Text appended to truncated Explorer regions
formatters = { -- nil = use the built-in from lua/codediff/ui/explorer/formatters.lua
formatters = { -- nil = use the built-in from lua/codediff/ui/explorer/formatters/
file = nil, -- File rows: function(ctx) -> layout
folder = nil, -- Directory rows in tree view: function(ctx) -> layout
group = nil, -- Section headers such as Changes and Staged: function(ctx) -> layout
Expand Down
166 changes: 166 additions & 0 deletions lua/codediff/core/git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,172 @@ local function run_git_async(args, opts, callback)
end
end

local function parse_numstat(output)
local stats = {}
local records = vim.split(output or "", "\0", { plain = true })
for index, record in ipairs(records) do
local insertions, deletions, path = record:match("^([^\t]+)\t([^\t]+)\t(.*)$")
if insertions then
path = path ~= "" and path or records[index + 2]
if path and path ~= "" then
stats[path] = insertions == "-" and { insertions = 0, deletions = 0, binary = true }
or { insertions = tonumber(insertions) or 0, deletions = tonumber(deletions) or 0, binary = false }
end
end
end
return stats
end

local function get_untracked_line_stats(path, max_bytes)
local uv = vim.uv or vim.loop
local stat = uv.fs_stat(path)
if not stat or stat.type ~= "file" or stat.size > max_bytes then
return nil
end

local fd = uv.fs_open(path, "r", 438)
if not fd then
return nil
end
local data = uv.fs_read(fd, stat.size, 0) or ""
uv.fs_close(fd)
if data:find("\0", 1, true) then
return { insertions = 0, deletions = 0, binary = true }
end

local _, newlines = data:gsub("\n", "")
local final_line = #data > 0 and data:sub(-1) ~= "\n" and 1 or 0
return { insertions = newlines + final_line, deletions = 0, binary = false }
end

local function attach_line_stats(entries, stats)
for _, entry in ipairs(entries or {}) do
entry.line_stats = stats[entry.path]
end
end

local function attach_untracked_line_stats(entries, git_root, max_bytes)
for _, entry in ipairs(entries or {}) do
if entry.status == "??" then
entry.line_stats = get_untracked_line_stats(git_root .. "/" .. entry.path, max_bytes)
end
end
end

local function collect_line_stats(git_root, requests, callback)
local remaining = #requests
local first_error
for _, request in ipairs(requests) do
run_git_async(request.args, { cwd = git_root }, function(err, output)
first_error = first_error or err
if not err then
local stats = parse_numstat(output)
for _, entries in ipairs(request.entries) do
attach_line_stats(entries, stats)
end
end
remaining = remaining - 1
if remaining == 0 then
callback(first_error)
end
end)
end
end

local function line_stats_options()
return (config.options.explorer or {}).line_stats or {}
end

function M.get_status_with_line_stats(git_root, callback, pathspec)
local options = line_stats_options()
if not options.enabled then
M.get_status(git_root, callback, pathspec)
return
end

M.get_status(git_root, function(err, result)
if err then
callback(err, nil)
return
end
collect_line_stats(git_root, {
{
args = vim.list_extend({ "diff", "--numstat", "-z", "-M", "--" }, pathspec or {}),
entries = { result.unstaged, result.conflicts },
},
{
args = vim.list_extend({ "diff", "--cached", "--numstat", "-z", "-M", "--" }, pathspec or {}),
entries = { result.staged },
},
}, function(stats_err)
if stats_err then
callback(stats_err, nil)
return
end
if options.count_untracked then
attach_untracked_line_stats(result.unstaged, git_root, options.max_untracked_bytes or 1024 * 1024)
end
callback(nil, result)
end)
end, pathspec)
end

function M.get_diff_revision_with_line_stats(revision, git_root, callback, pathspec)
local options = line_stats_options()
if not options.enabled then
M.get_diff_revision(revision, git_root, callback, pathspec)
return
end

M.get_diff_revision(revision, git_root, function(err, result)
if err then
callback(err, nil)
return
end
collect_line_stats(git_root, {
{
args = vim.list_extend({ "diff", "--numstat", "-z", "-M", revision, "--" }, pathspec or {}),
entries = { result.unstaged },
},
}, function(stats_err)
if stats_err then
callback(stats_err, nil)
return
end
if options.count_untracked then
attach_untracked_line_stats(result.unstaged, git_root, options.max_untracked_bytes or 1024 * 1024)
end
callback(nil, result)
end)
end, pathspec)
end

function M.get_diff_revisions_with_line_stats(rev1, rev2, git_root, callback, pathspec)
if not line_stats_options().enabled then
M.get_diff_revisions(rev1, rev2, git_root, callback, pathspec)
return
end

M.get_diff_revisions(rev1, rev2, git_root, function(err, result)
if err then
callback(err, nil)
return
end
collect_line_stats(git_root, {
{
args = vim.list_extend({ "diff", "--numstat", "-z", "-M", rev1, rev2, "--" }, pathspec or {}),
entries = { result.unstaged },
},
}, function(stats_err)
if stats_err then
callback(stats_err, nil)
return
end
callback(nil, result)
end)
end, pathspec)
end

-- ATOMIC ASYNC OPERATIONS
-- All functions below are simple, atomic git operations

Expand Down
Loading
Loading