From 73f15b6269f65b59634e6cc4171e3b7feedd8d4d Mon Sep 17 00:00:00 2001 From: Richard Gill Date: Wed, 29 Jul 2026 06:42:14 +0100 Subject: [PATCH 1/2] feat(explorer): add optional Git line statistics --- README.md | 19 +- doc/codediff.txt | 40 +++- doc/tags | 1 + lua/codediff/commands.lua | 6 +- lua/codediff/config.lua | 5 + lua/codediff/core/git.lua | 166 +++++++++++++ lua/codediff/ui/explorer/formatters.lua | 65 ++++- lua/codediff/ui/explorer/line_stats.lua | 25 ++ lua/codediff/ui/explorer/nodes.lua | 15 ++ lua/codediff/ui/explorer/refresh.lua | 6 +- lua/codediff/ui/explorer/tree.lua | 2 + lua/codediff/ui/highlights.lua | 7 + tests/core/git_line_stats_spec.lua | 251 ++++++++++++++++++++ tests/ui/explorer/explorer_modules_spec.lua | 7 + tests/ui/explorer/line_stats_spec.lua | 152 ++++++++++++ tests/ui/highlights_spec.lua | 13 + 16 files changed, 753 insertions(+), 27 deletions(-) create mode 100644 lua/codediff/ui/explorer/line_stats.lua create mode 100644 tests/core/git_line_stats_spec.lua create mode 100644 tests/ui/explorer/line_stats_spec.lua diff --git a/README.md b/README.md index 54dccd4e..5b48bbf6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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({ @@ -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
📸 Visual Examples (click to collapse) diff --git a/doc/codediff.txt b/doc/codediff.txt index 0b662b33..9f577e63 100644 --- a/doc/codediff.txt +++ b/doc/codediff.txt @@ -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, @@ -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 @@ -365,8 +383,9 @@ 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 @@ -374,15 +393,16 @@ 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. @@ -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 diff --git a/doc/tags b/doc/tags index 38439aa0..f1c5ef03 100644 --- a/doc/tags +++ b/doc/tags @@ -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* diff --git a/lua/codediff/commands.lua b/lua/codediff/commands.lua index 7c7546f5..bf5f5244 100644 --- a/lua/codediff/commands.lua +++ b/lua/codediff/commands.lua @@ -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) @@ -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) diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index 1b1c17e0..a248d426 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -74,6 +74,11 @@ 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 file = nil, -- File rows: function(ctx) -> layout diff --git a/lua/codediff/core/git.lua b/lua/codediff/core/git.lua index 2bf4bb0d..6c758fe5 100644 --- a/lua/codediff/core/git.lua +++ b/lua/codediff/core/git.lua @@ -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 diff --git a/lua/codediff/ui/explorer/formatters.lua b/lua/codediff/ui/explorer/formatters.lua index dfc57e28..269b4186 100644 --- a/lua/codediff/ui/explorer/formatters.lua +++ b/lua/codediff/ui/explorer/formatters.lua @@ -9,6 +9,45 @@ local function prefix(ctx) return segments end +local function stat_segments(stats) + if not stats then + return {} + end + if stats.binary then + return { { text = "bin", hl = "CodeDiffExplorerStatBinary" } } + end + + local segments = {} + if (stats.insertions or 0) > 0 then + segments[#segments + 1] = { text = "+" .. stats.insertions, hl = "CodeDiffExplorerStatInsertions" } + end + if (stats.deletions or 0) > 0 then + if #segments > 0 then + segments[#segments + 1] = { text = " ", hl = "Normal" } + end + segments[#segments + 1] = { text = "-" .. stats.deletions, hl = "CodeDiffExplorerStatDeletions" } + end + return segments +end + +local function group_summary(ctx) + local count_hl = ctx.stats and "CodeDiffExplorerStatFiles" or "CodeDiffExplorerTreeGroup" + local segments = { + { text = " (", hl = "CodeDiffExplorerTreeGroup" }, + { text = tostring(ctx.file_count), hl = count_hl }, + } + if ctx.stats and ctx.stats.insertions > 0 then + segments[#segments + 1] = { text = " · ", hl = "CodeDiffExplorerTreeGroup" } + segments[#segments + 1] = { text = "+" .. ctx.stats.insertions, hl = "CodeDiffExplorerStatInsertions" } + end + if ctx.stats and ctx.stats.deletions > 0 then + segments[#segments + 1] = { text = ctx.stats.insertions > 0 and " " or " · ", hl = "CodeDiffExplorerTreeGroup" } + segments[#segments + 1] = { text = "-" .. ctx.stats.deletions, hl = "CodeDiffExplorerStatDeletions" } + end + segments[#segments + 1] = { text = ")", hl = "CodeDiffExplorerTreeGroup" } + return segments +end + function M.file(ctx) local left = { { segments = prefix(ctx) }, @@ -20,23 +59,29 @@ function M.file(ctx) if ctx.directory ~= "" then left[#left + 1] = { segments = { - { text = " ", hl = "Normal" }, + { text = " ", hl = "Normal" }, { text = ctx.directory, hl = "ExplorerDirectorySmall" }, }, truncate_priority = 1, } end + local right = {} + local stats = stat_segments(ctx.stats) + if #stats > 0 then + stats[#stats + 1] = { text = " ", hl = "Normal" } + right[#right + 1] = { segments = stats, truncate_priority = 3 } + end + right[#right + 1] = { + segments = { + { text = ctx.status, hl = ctx.status_hl }, + { text = string.rep(" ", ctx.status_right_margin), hl = "Normal" }, + }, + } + return { left = left, - right = { - { - segments = { - { text = ctx.status, hl = ctx.status_hl }, - { text = string.rep(" ", ctx.status_right_margin), hl = "Normal" }, - }, - }, - }, + right = right, min_gap = 2, } end @@ -64,7 +109,7 @@ function M.group(ctx) truncate_priority = 2, }, { - segments = { { text = string.format(" (%d)", ctx.file_count), hl = "CodeDiffExplorerTreeGroup" } }, + segments = group_summary(ctx), truncate_priority = 1, }, }, diff --git a/lua/codediff/ui/explorer/line_stats.lua b/lua/codediff/ui/explorer/line_stats.lua new file mode 100644 index 00000000..f9c443db --- /dev/null +++ b/lua/codediff/ui/explorer/line_stats.lua @@ -0,0 +1,25 @@ +local M = {} + +function M.sum(files) + local total = { + files_changed = #files, + insertions = 0, + deletions = 0, + binary_files = 0, + unavailable_files = 0, + } + for _, file in ipairs(files) do + local stats = file.line_stats + if not stats then + total.unavailable_files = total.unavailable_files + 1 + elseif stats.binary then + total.binary_files = total.binary_files + 1 + else + total.insertions = total.insertions + (stats.insertions or 0) + total.deletions = total.deletions + (stats.deletions or 0) + end + end + return total +end + +return M diff --git a/lua/codediff/ui/explorer/nodes.lua b/lua/codediff/ui/explorer/nodes.lua index 95974dce..dae0e576 100644 --- a/lua/codediff/ui/explorer/nodes.lua +++ b/lua/codediff/ui/explorer/nodes.lua @@ -5,6 +5,7 @@ local M = {} local Tree = require("codediff.ui.lib.tree") local config = require("codediff.config") local line_layout = require("codediff.ui.explorer.line_layout") +local line_stats = require("codediff.ui.explorer.line_stats") local default_formatters = require("codediff.ui.explorer.formatters") -- Merge artifact patterns (created by git mergetool) @@ -88,6 +89,13 @@ function M.get_folder_icon(is_open) end end +local function context_stats(stats) + if not config.options.explorer.line_stats.enabled then + return nil + end + return vim.deepcopy(stats) +end + local function normalize_files(files, group) local normalized = {} for _, file in ipairs(files or {}) do @@ -96,6 +104,7 @@ local function normalize_files(files, group) old_path = file.old_path, group = group or file.group, status = file.status, + stats = context_stats(file.line_stats), } end return normalized @@ -120,6 +129,7 @@ function M.create_file_nodes(files, git_root, group) status_color = status_info.color, git_root = git_root, group = group, + line_stats = file.line_stats, }, }) end @@ -225,6 +235,7 @@ function M.create_tree_file_nodes(files, git_root, group) group = group, indent_state = node_indent_state, file_count = #item._files, + stats = line_stats.sum(item._files), files = item._files, }, }, children) @@ -247,6 +258,7 @@ function M.create_tree_file_nodes(files, git_root, group) git_root = git_root, group = group, indent_state = node_indent_state, + line_stats = file.line_stats, }, }) end @@ -287,6 +299,7 @@ local function group_context(node, data) name = data.name, label = data.label, file_count = data.file_count, + stats = context_stats(data.stats), files = normalize_files(data.files, data.name), expanded = node:is_expanded(), } @@ -300,6 +313,7 @@ local function folder_context(node, data, explorer_config) path = data.dir_path, group = data.group, file_count = data.file_count, + stats = context_stats(data.stats), files = normalize_files(data.files, data.group), indent = indent, indent_hl = indent_hl, @@ -320,6 +334,7 @@ local function file_context(node, data, explorer_config) directory = directory, old_path = data.old_path, group = data.group, + stats = context_stats(data.line_stats), status = data.status_symbol or "", status_hl = data.status_color, status_right_margin = math.max(0, explorer_config.status_right_margin or 1), diff --git a/lua/codediff/ui/explorer/refresh.lua b/lua/codediff/ui/explorer/refresh.lua index ec1266d9..a9e58970 100644 --- a/lua/codediff/ui/explorer/refresh.lua +++ b/lua/codediff/ui/explorer/refresh.lua @@ -329,11 +329,11 @@ function M.refresh(explorer) local diff = dir_mod.diff_directories(explorer.dir1, explorer.dir2) process_result(nil, diff.status_result) elseif explorer.base_revision and explorer.target_revision and explorer.target_revision ~= "WORKING" then - git.get_diff_revisions(explorer.base_revision, explorer.target_revision, explorer.git_root, process_result, explorer.pathspec) + git.get_diff_revisions_with_line_stats(explorer.base_revision, explorer.target_revision, explorer.git_root, process_result, explorer.pathspec) elseif explorer.base_revision then - git.get_diff_revision(explorer.base_revision, explorer.git_root, process_result, explorer.pathspec) + git.get_diff_revision_with_line_stats(explorer.base_revision, explorer.git_root, process_result, explorer.pathspec) else - git.get_status(explorer.git_root, process_result, explorer.pathspec) + git.get_status_with_line_stats(explorer.git_root, process_result, explorer.pathspec) end end diff --git a/lua/codediff/ui/explorer/tree.lua b/lua/codediff/ui/explorer/tree.lua index d0330d28..5e9e91a3 100644 --- a/lua/codediff/ui/explorer/tree.lua +++ b/lua/codediff/ui/explorer/tree.lua @@ -5,6 +5,7 @@ local M = {} local Tree = require("codediff.ui.lib.tree") local config = require("codediff.config") local filter = require("codediff.ui.explorer.filter") +local line_stats = require("codediff.ui.explorer.line_stats") local nodes = require("codediff.ui.explorer.nodes") -- Filter files based on explorer.file_filter config @@ -25,6 +26,7 @@ local function create_group_node(label, name, files, children) name = name, label = label, file_count = #files, + stats = line_stats.sum(files), files = files, }, }, children) diff --git a/lua/codediff/ui/highlights.lua b/lua/codediff/ui/highlights.lua index 52dec55f..9a350b98 100644 --- a/lua/codediff/ui/highlights.lua +++ b/lua/codediff/ui/highlights.lua @@ -237,6 +237,13 @@ function M.setup() return ok and hl and (hl.fg or hl.foreground) end + local insertion_highlight = hl_exists("Added") and "Added" or "DiagnosticOk" + local deletion_highlight = hl_exists("Removed") and "Removed" or "DiagnosticError" + vim.api.nvim_set_hl(0, "CodeDiffExplorerStatFiles", { link = "Number", default = true }) + vim.api.nvim_set_hl(0, "CodeDiffExplorerStatInsertions", { link = insertion_highlight, default = true }) + vim.api.nvim_set_hl(0, "CodeDiffExplorerStatDeletions", { link = deletion_highlight, default = true }) + vim.api.nvim_set_hl(0, "CodeDiffExplorerStatBinary", { link = "NonText", default = true }) + -- Helper to set conflict sign highlight with user config as priority 0 -- @param hl_name string The highlight group name to set -- @param user_value string|nil User config value (highlight group or hex color) diff --git a/tests/core/git_line_stats_spec.lua b/tests/core/git_line_stats_spec.lua new file mode 100644 index 00000000..7df7e9ba --- /dev/null +++ b/tests/core/git_line_stats_spec.lua @@ -0,0 +1,251 @@ +local config = require("codediff.config") +local git = require("codediff.core.git") +local h = dofile("tests/helpers.lua") + +local get_file = function(files, path) + for _, file in ipairs(files or {}) do + if file.path == path then + return file + end + end +end + +local await_result = function(invoke) + local done = false + local callback_error + local result + invoke(function(err, value) + callback_error = err + result = value + done = true + end) + assert.is_true(vim.wait(3000, function() + return done + end, 20)) + return callback_error, result +end + +local expect_result = function(invoke) + local err, result = await_result(invoke) + assert.is_nil(err) + assert.is_not_nil(result) + return result +end + +local write_raw_file = function(path, contents) + local uv = vim.uv or vim.loop + local fd = assert(uv.fs_open(path, "w", 420)) + uv.fs_write(fd, contents, 0) + uv.fs_close(fd) +end + +local commit_all = function(repo, message) + repo.git("add -A") + repo.git("commit -m " .. message) + return vim.trim(repo.git("rev-parse HEAD")) +end + +describe("Git line stats", function() + local repo + local base_revision + + before_each(function() + config.options = vim.deepcopy(config.defaults) + config.options.explorer.line_stats.enabled = true + repo = h.create_temp_git_repo() + repo.write_file("tracked.txt", { "one", "two" }) + repo.write_file("renamed.txt", { "same" }) + repo.write_file("deleted.txt", { "gone", "soon" }) + repo.write_file("conflict.txt", { "base" }) + repo.write_file("src/included.txt", { "base" }) + repo.write_file("outside.txt", { "base" }) + base_revision = commit_all(repo, "initial") + end) + + after_each(function() + config.options = vim.deepcopy(config.defaults) + repo.cleanup() + end) + + it("adds separate staged, unstaged, rename, deletion, and binary stats", function() + repo.write_file("tracked.txt", { "one", "staged", "three" }) + repo.git("add tracked.txt") + repo.write_file("tracked.txt", { "one", "working", "three", "four" }) + repo.write_file("staged.txt", { "alpha", "beta" }) + write_raw_file(repo.path("binary.dat"), "binary\0content") + repo.git("add staged.txt binary.dat") + repo.git("mv renamed.txt moved.txt") + repo.git("rm deleted.txt") + + local result = expect_result(function(callback) + git.get_status_with_line_stats(repo.dir, callback) + end) + + assert.same({ insertions = 2, deletions = 1, binary = false }, get_file(result.unstaged, "tracked.txt").line_stats) + assert.same({ insertions = 2, deletions = 1, binary = false }, get_file(result.staged, "tracked.txt").line_stats) + assert.same({ insertions = 2, deletions = 0, binary = false }, get_file(result.staged, "staged.txt").line_stats) + assert.same({ insertions = 0, deletions = 0, binary = false }, get_file(result.staged, "moved.txt").line_stats) + assert.same({ insertions = 0, deletions = 2, binary = false }, get_file(result.staged, "deleted.txt").line_stats) + assert.same({ insertions = 0, deletions = 0, binary = true }, get_file(result.staged, "binary.dat").line_stats) + end) + + it("optionally counts bounded untracked text and binary files", function() + write_raw_file(repo.path("untracked.txt"), "first\nsecond") + write_raw_file(repo.path("untracked.bin"), "binary\0content") + + local result = expect_result(function(callback) + git.get_status_with_line_stats(repo.dir, callback) + end) + assert.is_nil(get_file(result.unstaged, "untracked.txt").line_stats) + + config.options.explorer.line_stats.count_untracked = true + config.options.explorer.line_stats.max_untracked_bytes = 1 + result = expect_result(function(callback) + git.get_status_with_line_stats(repo.dir, callback) + end) + assert.is_nil(get_file(result.unstaged, "untracked.txt").line_stats) + + config.options.explorer.line_stats.max_untracked_bytes = 1024 * 1024 + result = expect_result(function(callback) + git.get_status_with_line_stats(repo.dir, callback) + end) + assert.same({ insertions = 2, deletions = 0, binary = false }, get_file(result.unstaged, "untracked.txt").line_stats) + assert.same({ insertions = 0, deletions = 0, binary = true }, get_file(result.unstaged, "untracked.bin").line_stats) + end) + + it("adds aggregate working tree stats relative to one revision", function() + repo.write_file("tracked.txt", { "one", "changed", "three" }) + repo.write_file("staged.txt", { "alpha", "beta" }) + repo.git("add staged.txt") + repo.write_file("untracked.txt", { "first", "second" }) + + local result = expect_result(function(callback) + git.get_diff_revision_with_line_stats(base_revision, repo.dir, callback) + end) + assert.same({ insertions = 2, deletions = 1, binary = false }, get_file(result.unstaged, "tracked.txt").line_stats) + assert.same({ insertions = 2, deletions = 0, binary = false }, get_file(result.unstaged, "staged.txt").line_stats) + assert.is_nil(get_file(result.unstaged, "untracked.txt").line_stats) + + config.options.explorer.line_stats.count_untracked = true + result = expect_result(function(callback) + git.get_diff_revision_with_line_stats(base_revision, repo.dir, callback) + end) + assert.same({ insertions = 2, deletions = 0, binary = false }, get_file(result.unstaged, "untracked.txt").line_stats) + end) + + it("adds destination-path stats between two revisions", function() + repo.write_file("tracked.txt", { "one", "changed", "three" }) + repo.git("mv renamed.txt moved.txt") + write_raw_file(repo.path("binary.dat"), "binary\0content") + local target_revision = commit_all(repo, "changes") + + local result = expect_result(function(callback) + git.get_diff_revisions_with_line_stats(base_revision, target_revision, repo.dir, callback) + end) + assert.same({ insertions = 2, deletions = 1, binary = false }, get_file(result.unstaged, "tracked.txt").line_stats) + assert.same({ insertions = 0, deletions = 0, binary = false }, get_file(result.unstaged, "moved.txt").line_stats) + assert.same({ insertions = 0, deletions = 0, binary = true }, get_file(result.unstaged, "binary.dat").line_stats) + assert.is_nil(get_file(result.unstaged, "renamed.txt")) + end) + + it("attaches stats to conflicts", function() + repo.git("checkout -b conflict-side") + repo.write_file("conflict.txt", { "side" }) + commit_all(repo, "side") + repo.git("checkout main") + repo.write_file("conflict.txt", { "main" }) + commit_all(repo, "main") + repo.git("merge conflict-side") + + local result = expect_result(function(callback) + git.get_status_with_line_stats(repo.dir, callback) + end) + local stats = get_file(result.conflicts, "conflict.txt").line_stats + assert.is_not_nil(stats) + assert.is_number(stats.insertions) + assert.is_number(stats.deletions) + assert.is_false(stats.binary) + end) + + it("preserves pathspecs in all explorer modes", function() + repo.write_file("src/included.txt", { "base", "included" }) + repo.write_file("outside.txt", { "base", "outside" }) + + local status = expect_result(function(callback) + git.get_status_with_line_stats(repo.dir, callback, { "src" }) + end) + assert.is_not_nil(get_file(status.unstaged, "src/included.txt").line_stats) + assert.is_nil(get_file(status.unstaged, "outside.txt")) + + local one_revision = expect_result(function(callback) + git.get_diff_revision_with_line_stats(base_revision, repo.dir, callback, { "src" }) + end) + assert.is_not_nil(get_file(one_revision.unstaged, "src/included.txt").line_stats) + assert.is_nil(get_file(one_revision.unstaged, "outside.txt")) + + local target_revision = commit_all(repo, "pathspec") + local two_revisions = expect_result(function(callback) + git.get_diff_revisions_with_line_stats(base_revision, target_revision, repo.dir, callback, { "src" }) + end) + assert.is_not_nil(get_file(two_revisions.unstaged, "src/included.txt").line_stats) + assert.is_nil(get_file(two_revisions.unstaged, "outside.txt")) + end) + + it("returns unchanged base results when disabled", function() + repo.write_file("tracked.txt", { "one", "committed", "three" }) + local target_revision = commit_all(repo, "target") + repo.write_file("tracked.txt", { "one", "working", "three", "four" }) + config.options.explorer.line_stats.enabled = false + local cases = { + { + base = function(callback) + git.get_status(repo.dir, callback, { "tracked.txt" }) + end, + with_stats = function(callback) + git.get_status_with_line_stats(repo.dir, callback, { "tracked.txt" }) + end, + }, + { + base = function(callback) + git.get_diff_revision(target_revision, repo.dir, callback, { "tracked.txt" }) + end, + with_stats = function(callback) + git.get_diff_revision_with_line_stats(target_revision, repo.dir, callback, { "tracked.txt" }) + end, + }, + { + base = function(callback) + git.get_diff_revisions(base_revision, target_revision, repo.dir, callback, { "tracked.txt" }) + end, + with_stats = function(callback) + git.get_diff_revisions_with_line_stats(base_revision, target_revision, repo.dir, callback, { "tracked.txt" }) + end, + }, + } + + for _, case in ipairs(cases) do + assert.same(expect_result(case.base), expect_result(case.with_stats)) + end + end) + + it("propagates repository and revision errors", function() + local missing_revision = "missing-line-stats-revision" + local cases = { + function(callback) + git.get_status_with_line_stats(repo.path("missing"), callback) + end, + function(callback) + git.get_diff_revision_with_line_stats(missing_revision, repo.dir, callback) + end, + function(callback) + git.get_diff_revisions_with_line_stats(base_revision, missing_revision, repo.dir, callback) + end, + } + + for _, invoke in ipairs(cases) do + local err, result = await_result(invoke) + assert.is_not_nil(err) + assert.is_nil(result) + end + end) +end) diff --git a/tests/ui/explorer/explorer_modules_spec.lua b/tests/ui/explorer/explorer_modules_spec.lua index 223a8e02..2777e298 100644 --- a/tests/ui/explorer/explorer_modules_spec.lua +++ b/tests/ui/explorer/explorer_modules_spec.lua @@ -37,12 +37,15 @@ describe("Explorer submodules", function() local formatters_ok, formatters = pcall(require, "codediff.ui.explorer.formatters") local highlights_ok, highlights = pcall(require, "codediff.ui.explorer.line_highlights") local layout_ok, layout = pcall(require, "codediff.ui.explorer.line_layout") + local stats_ok, stats = pcall(require, "codediff.ui.explorer.line_stats") assert.is_true(formatters_ok, "Failed to require codediff.ui.explorer.formatters") assert.is_true(highlights_ok, "Failed to require codediff.ui.explorer.line_highlights") assert.is_true(layout_ok, "Failed to require codediff.ui.explorer.line_layout") + assert.is_true(stats_ok, "Failed to require codediff.ui.explorer.line_stats") assert.is_not_nil(formatters) assert.is_not_nil(highlights) assert.is_not_nil(layout) + assert.is_not_nil(stats) end) it("loads init facade", function() @@ -94,5 +97,9 @@ describe("Explorer submodules", function() assert.is_function(mod.folder) assert.is_function(mod.group) end) + + it("line stats exports aggregation", function() + assert.is_function(require("codediff.ui.explorer.line_stats").sum) + end) end) end) diff --git a/tests/ui/explorer/line_stats_spec.lua b/tests/ui/explorer/line_stats_spec.lua new file mode 100644 index 00000000..cec6cba6 --- /dev/null +++ b/tests/ui/explorer/line_stats_spec.lua @@ -0,0 +1,152 @@ +local config = require("codediff.config") +local line_stats = require("codediff.ui.explorer.line_stats") +local nodes = require("codediff.ui.explorer.nodes") +local tree = require("codediff.ui.explorer.tree") + +local reset_config = function() + config.options = vim.deepcopy(config.defaults) +end + +local fixed_layout = function(text) + return { + left = { { segments = { { text = text } } } }, + right = {}, + } +end + +describe("Explorer line stats", function() + before_each(reset_config) + after_each(reset_config) + + it("is disabled by default without importing formatter defaults into config", function() + local options = config.options.explorer + assert.same({ enabled = false, count_untracked = false, max_untracked_bytes = 1024 * 1024 }, options.line_stats) + assert.is_nil(options.formatters.file) + assert.is_nil(options.formatters.folder) + assert.is_nil(options.formatters.group) + end) + + it("aggregates text, binary, and unavailable files", function() + assert.same( + { + files_changed = 3, + insertions = 3, + deletions = 2, + binary_files = 1, + unavailable_files = 1, + }, + line_stats.sum({ + { line_stats = { insertions = 3, deletions = 2, binary = false } }, + { line_stats = { insertions = 0, deletions = 0, binary = true } }, + {}, + }) + ) + end) + + it("renders right-aligned file stats and aggregate group totals", function() + config.options.explorer.line_stats.enabled = true + local files = { + { path = "added.lua", status = "A", line_stats = { insertions = 20, deletions = 0, binary = false } }, + { path = "changed.lua", status = "M", line_stats = { insertions = 22, deletions = 8, binary = false } }, + { path = "image.png", status = "M", line_stats = { insertions = 0, deletions = 0, binary = true } }, + } + local root = tree.create_tree_data({ unstaged = files, staged = {}, conflicts = {} }, "/repo", nil, false, { + unstaged = true, + staged = false, + })[1] + + local group_line = nodes.prepare_node(root, 50, nil, nil) + assert.equals(" Changes (3 · +42 -8)", group_line:content()) + local file_nodes = nodes.create_file_nodes(files, "/repo", "unstaged") + local changed_line = nodes.prepare_node(file_nodes[2], 50, nil, nil) + local changed = changed_line:content() + assert.matches("changed%.lua%s+%+22 %-8 M%s*$", changed) + assert.equals(50, vim.fn.strdisplaywidth(changed)) + local binary_line = nodes.prepare_node(file_nodes[3], 50, nil, nil) + assert.matches("image%.png%s+bin M%s*$", binary_line:content()) + + local highlights = {} + for _, line in ipairs({ group_line, changed_line, binary_line }) do + for _, segment in ipairs(line._segments) do + highlights[segment.hl] = true + end + end + assert.is_true(highlights.CodeDiffExplorerStatFiles) + assert.is_true(highlights.CodeDiffExplorerStatInsertions) + assert.is_true(highlights.CodeDiffExplorerStatDeletions) + assert.is_true(highlights.CodeDiffExplorerStatBinary) + end) + + it("exposes stats in existing file, folder, and group formatter metadata", function() + config.options.explorer.view_mode = "tree" + config.options.explorer.flatten_dirs = false + config.options.explorer.line_stats.enabled = true + local contexts = {} + config.options.explorer.formatters = { + file = function(ctx) + contexts.file = ctx + return fixed_layout("file") + end, + folder = function(ctx) + contexts.folder = ctx + return fixed_layout("folder") + end, + group = function(ctx) + contexts.group = ctx + return fixed_layout("group") + end, + } + + local files = { + { path = "src/one.lua", status = "M", line_stats = { insertions = 3, deletions = 2, binary = false } }, + { path = "src/two.lua", status = "A", line_stats = { insertions = 4, deletions = 0, binary = false } }, + } + local root = tree.create_tree_data({ unstaged = files, staged = {}, conflicts = {} }, "/repo", nil, false, { + unstaged = true, + staged = false, + })[1] + local folder = root._children[1] + local file = folder._children[1] + + nodes.prepare_node(root, 40, nil, nil) + nodes.prepare_node(folder, 40, nil, nil) + nodes.prepare_node(file, 40, nil, nil) + local aggregate = { files_changed = 2, insertions = 7, deletions = 2, binary_files = 0, unavailable_files = 0 } + assert.same(aggregate, contexts.group.stats) + assert.same(aggregate, contexts.folder.stats) + assert.same({ insertions = 3, deletions = 2, binary = false }, contexts.file.stats) + assert.same({ + path = "src/one.lua", + group = "unstaged", + status = "M", + stats = { insertions = 3, deletions = 2, binary = false }, + }, contexts.group.files[1]) + assert.same(contexts.group.files, contexts.folder.files) + + config.options.explorer.line_stats.enabled = false + nodes.prepare_node(root, 40, nil, nil) + nodes.prepare_node(folder, 40, nil, nil) + nodes.prepare_node(file, 40, nil, nil) + assert.is_nil(contexts.group.stats) + assert.is_nil(contexts.folder.stats) + assert.is_nil(contexts.file.stats) + assert.is_nil(contexts.group.files[1].stats) + end) + + it("truncates stats before the fixed status", function() + config.options.explorer.line_stats.enabled = true + local file = { + path = "a-very-long-filename-that-needs-truncation.lua", + status = "??", + line_stats = { insertions = 123, deletions = 45, binary = false }, + } + local node = nodes.create_file_nodes({ file }, "/repo", "unstaged")[1] + local wide = nodes.prepare_node(node, 40, nil, nil):content() + assert.matches("…%s+%+123 %-45 %?%? %s*$", wide) + assert.equals(40, vim.fn.strdisplaywidth(wide)) + + local narrow = nodes.prepare_node(node, 3, nil, nil):content() + assert.equals("?? ", narrow) + assert.is_nil(narrow:find("+", 1, true)) + end) +end) diff --git a/tests/ui/highlights_spec.lua b/tests/ui/highlights_spec.lua index 9a15ae25..315fca2b 100644 --- a/tests/ui/highlights_spec.lua +++ b/tests/ui/highlights_spec.lua @@ -19,6 +19,19 @@ end describe("highlights.lua color derivation", function() before_each(reset_codediff) + it("defines semantic explorer stat highlights", function() + highlights.setup() + + local files = vim.api.nvim_get_hl(0, { name = "CodeDiffExplorerStatFiles", link = true }) + local insertions = vim.api.nvim_get_hl(0, { name = "CodeDiffExplorerStatInsertions", link = true }) + local deletions = vim.api.nvim_get_hl(0, { name = "CodeDiffExplorerStatDeletions", link = true }) + local binary = vim.api.nvim_get_hl(0, { name = "CodeDiffExplorerStatBinary", link = true }) + assert.equals("Number", files.link) + assert.is_true(insertions.link == "Added" or insertions.link == "DiagnosticOk") + assert.is_true(deletions.link == "Removed" or deletions.link == "DiagnosticError") + assert.equals("NonText", binary.link) + end) + it("reads bg directly for colorschemes that use bg-based diff highlights", function() -- Mimic the default convention: DiffAdd uses bg + (optional) fg, no reverse vim.api.nvim_set_hl(0, "DiffAdd", { bg = 0x123456 }) From a3bb2b6f3c90d4d2c6b6e01030a3af56f77cc4bb Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Fri, 31 Jul 2026 22:28:47 -0400 Subject: [PATCH 2/2] refactor(explorer): split default formatters into module folder Move lua/codediff/ui/explorer/formatters.lua into lua/codediff/ui/explorer/formatters/ so the built-in default row functions and their helpers each live in their own file: init.lua module entry; re-exports M.{file,folder,group} common.lua shared prefix() helper (always active) stats.lua line-stats rendering helpers (active only when explorer.line_stats.enabled = true) file.lua default file row folder.lua default folder row group.lua default group row This matches the folder-module style used by the rest of lua/codediff/ui/explorer/ and makes it visible from the directory listing which pieces are always used vs. line-stats specific. The public require path stays codediff.ui.explorer.formatters, so nodes.lua, tests, README, and doc references are unchanged. Pure code motion; no behavior change (89 tests pass across the affected specs: line_stats, line_formatters, explorer_modules, explorer, explorer_staging, explorer_file_filter, highlights, git_line_stats). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lua/codediff/config.lua | 2 +- lua/codediff/ui/explorer/formatters.lua | 121 ------------------ .../ui/explorer/formatters/common.lua | 16 +++ lua/codediff/ui/explorer/formatters/file.lua | 43 +++++++ .../ui/explorer/formatters/folder.lua | 17 +++ lua/codediff/ui/explorer/formatters/group.lua | 23 ++++ lua/codediff/ui/explorer/formatters/init.lua | 22 ++++ lua/codediff/ui/explorer/formatters/stats.lua | 52 ++++++++ 8 files changed, 174 insertions(+), 122 deletions(-) delete mode 100644 lua/codediff/ui/explorer/formatters.lua create mode 100644 lua/codediff/ui/explorer/formatters/common.lua create mode 100644 lua/codediff/ui/explorer/formatters/file.lua create mode 100644 lua/codediff/ui/explorer/formatters/folder.lua create mode 100644 lua/codediff/ui/explorer/formatters/group.lua create mode 100644 lua/codediff/ui/explorer/formatters/init.lua create mode 100644 lua/codediff/ui/explorer/formatters/stats.lua diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index a248d426..09351b6c 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -80,7 +80,7 @@ M.defaults = { 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 diff --git a/lua/codediff/ui/explorer/formatters.lua b/lua/codediff/ui/explorer/formatters.lua deleted file mode 100644 index 269b4186..00000000 --- a/lua/codediff/ui/explorer/formatters.lua +++ /dev/null @@ -1,121 +0,0 @@ -local M = {} - -local function prefix(ctx) - local segments = { { text = ctx.indent, hl = ctx.indent_hl } } - if ctx.icon ~= "" then - segments[#segments + 1] = { text = ctx.icon, hl = ctx.icon_hl } - segments[#segments + 1] = { text = " ", hl = "Normal" } - end - return segments -end - -local function stat_segments(stats) - if not stats then - return {} - end - if stats.binary then - return { { text = "bin", hl = "CodeDiffExplorerStatBinary" } } - end - - local segments = {} - if (stats.insertions or 0) > 0 then - segments[#segments + 1] = { text = "+" .. stats.insertions, hl = "CodeDiffExplorerStatInsertions" } - end - if (stats.deletions or 0) > 0 then - if #segments > 0 then - segments[#segments + 1] = { text = " ", hl = "Normal" } - end - segments[#segments + 1] = { text = "-" .. stats.deletions, hl = "CodeDiffExplorerStatDeletions" } - end - return segments -end - -local function group_summary(ctx) - local count_hl = ctx.stats and "CodeDiffExplorerStatFiles" or "CodeDiffExplorerTreeGroup" - local segments = { - { text = " (", hl = "CodeDiffExplorerTreeGroup" }, - { text = tostring(ctx.file_count), hl = count_hl }, - } - if ctx.stats and ctx.stats.insertions > 0 then - segments[#segments + 1] = { text = " · ", hl = "CodeDiffExplorerTreeGroup" } - segments[#segments + 1] = { text = "+" .. ctx.stats.insertions, hl = "CodeDiffExplorerStatInsertions" } - end - if ctx.stats and ctx.stats.deletions > 0 then - segments[#segments + 1] = { text = ctx.stats.insertions > 0 and " " or " · ", hl = "CodeDiffExplorerTreeGroup" } - segments[#segments + 1] = { text = "-" .. ctx.stats.deletions, hl = "CodeDiffExplorerStatDeletions" } - end - segments[#segments + 1] = { text = ")", hl = "CodeDiffExplorerTreeGroup" } - return segments -end - -function M.file(ctx) - local left = { - { segments = prefix(ctx) }, - { - segments = { { text = ctx.filename, hl = "Normal" } }, - truncate_priority = 2, - }, - } - if ctx.directory ~= "" then - left[#left + 1] = { - segments = { - { text = " ", hl = "Normal" }, - { text = ctx.directory, hl = "ExplorerDirectorySmall" }, - }, - truncate_priority = 1, - } - end - - local right = {} - local stats = stat_segments(ctx.stats) - if #stats > 0 then - stats[#stats + 1] = { text = " ", hl = "Normal" } - right[#right + 1] = { segments = stats, truncate_priority = 3 } - end - right[#right + 1] = { - segments = { - { text = ctx.status, hl = ctx.status_hl }, - { text = string.rep(" ", ctx.status_right_margin), hl = "Normal" }, - }, - } - - return { - left = left, - right = right, - min_gap = 2, - } -end - -function M.folder(ctx) - return { - left = { - { segments = prefix(ctx) }, - { - segments = { { text = ctx.name, hl = "Directory" } }, - truncate_priority = 1, - }, - }, - right = {}, - min_gap = 2, - } -end - -function M.group(ctx) - return { - left = { - { segments = { { text = " ", hl = "CodeDiffExplorerTreeGroup" } } }, - { - segments = { { text = ctx.label, hl = "CodeDiffExplorerTreeGroup" } }, - truncate_priority = 2, - }, - { - segments = group_summary(ctx), - truncate_priority = 1, - }, - }, - right = {}, - min_gap = 2, - } -end - -return M diff --git a/lua/codediff/ui/explorer/formatters/common.lua b/lua/codediff/ui/explorer/formatters/common.lua new file mode 100644 index 00000000..628f48f4 --- /dev/null +++ b/lua/codediff/ui/explorer/formatters/common.lua @@ -0,0 +1,16 @@ +-- Helpers shared across multiple default row formatters. Always active, +-- independent of any explorer feature toggles. + +local M = {} + +-- Row prefix: indent + optional icon + trailing space. +function M.prefix(ctx) + local segments = { { text = ctx.indent, hl = ctx.indent_hl } } + if ctx.icon ~= "" then + segments[#segments + 1] = { text = ctx.icon, hl = ctx.icon_hl } + segments[#segments + 1] = { text = " ", hl = "Normal" } + end + return segments +end + +return M diff --git a/lua/codediff/ui/explorer/formatters/file.lua b/lua/codediff/ui/explorer/formatters/file.lua new file mode 100644 index 00000000..f7ef6b1b --- /dev/null +++ b/lua/codediff/ui/explorer/formatters/file.lua @@ -0,0 +1,43 @@ +-- Default file row: `[indent] [icon] filename [directory] [stats] [status]`. +-- The `[stats]` segment renders only when `explorer.line_stats.enabled = true`. + +local common = require("codediff.ui.explorer.formatters.common") +local stats = require("codediff.ui.explorer.formatters.stats") + +return function(ctx) + local left = { + { segments = common.prefix(ctx) }, + { + segments = { { text = ctx.filename, hl = "Normal" } }, + truncate_priority = 2, + }, + } + if ctx.directory ~= "" then + left[#left + 1] = { + segments = { + { text = " ", hl = "Normal" }, + { text = ctx.directory, hl = "ExplorerDirectorySmall" }, + }, + truncate_priority = 1, + } + end + + local right = {} + local file_stats = stats.file_segments(ctx.stats) + if #file_stats > 0 then + file_stats[#file_stats + 1] = { text = " ", hl = "Normal" } + right[#right + 1] = { segments = file_stats, truncate_priority = 3 } + end + right[#right + 1] = { + segments = { + { text = ctx.status, hl = ctx.status_hl }, + { text = string.rep(" ", ctx.status_right_margin), hl = "Normal" }, + }, + } + + return { + left = left, + right = right, + min_gap = 2, + } +end diff --git a/lua/codediff/ui/explorer/formatters/folder.lua b/lua/codediff/ui/explorer/formatters/folder.lua new file mode 100644 index 00000000..9655d6bc --- /dev/null +++ b/lua/codediff/ui/explorer/formatters/folder.lua @@ -0,0 +1,17 @@ +-- Default folder row: `[indent] [icon] name`. + +local common = require("codediff.ui.explorer.formatters.common") + +return function(ctx) + return { + left = { + { segments = common.prefix(ctx) }, + { + segments = { { text = ctx.name, hl = "Directory" } }, + truncate_priority = 1, + }, + }, + right = {}, + min_gap = 2, + } +end diff --git a/lua/codediff/ui/explorer/formatters/group.lua b/lua/codediff/ui/explorer/formatters/group.lua new file mode 100644 index 00000000..06125d29 --- /dev/null +++ b/lua/codediff/ui/explorer/formatters/group.lua @@ -0,0 +1,23 @@ +-- Default group row: `[folder-icon] label (N · +42 -8)`. +-- The `· +42 -8` suffix renders only when `explorer.line_stats.enabled = true` +-- populates `ctx.stats`. + +local stats = require("codediff.ui.explorer.formatters.stats") + +return function(ctx) + return { + left = { + { segments = { { text = " ", hl = "CodeDiffExplorerTreeGroup" } } }, + { + segments = { { text = ctx.label, hl = "CodeDiffExplorerTreeGroup" } }, + truncate_priority = 2, + }, + { + segments = stats.group_summary(ctx), + truncate_priority = 1, + }, + }, + right = {}, + min_gap = 2, + } +end diff --git a/lua/codediff/ui/explorer/formatters/init.lua b/lua/codediff/ui/explorer/formatters/init.lua new file mode 100644 index 00000000..1a5daf59 --- /dev/null +++ b/lua/codediff/ui/explorer/formatters/init.lua @@ -0,0 +1,22 @@ +-- Default explorer row formatters. +-- +-- Used when `explorer.formatters.{file,folder,group}` is nil. Users can also +-- `require("codediff.ui.explorer.formatters")` to grab these defaults and wrap +-- them in a custom formatter. +-- +-- Layout: +-- init.lua - module entry: re-exports the three default row functions. +-- common.lua - helpers shared across row types (indent + icon prefix). +-- stats.lua - line-stats rendering, active only when +-- `explorer.line_stats.enabled = true` populates `ctx.stats`. +-- file.lua - default file row. +-- folder.lua - default folder row. +-- group.lua - default group row. + +local M = {} + +M.file = require("codediff.ui.explorer.formatters.file") +M.folder = require("codediff.ui.explorer.formatters.folder") +M.group = require("codediff.ui.explorer.formatters.group") + +return M diff --git a/lua/codediff/ui/explorer/formatters/stats.lua b/lua/codediff/ui/explorer/formatters/stats.lua new file mode 100644 index 00000000..555ade74 --- /dev/null +++ b/lua/codediff/ui/explorer/formatters/stats.lua @@ -0,0 +1,52 @@ +-- Line-stats rendering helpers. +-- +-- Active only when `explorer.line_stats.enabled = true` — that config path is +-- what populates `ctx.stats` on file, folder, and group contexts. When the +-- feature is disabled, `ctx.stats` is nil and each helper returns a shape that +-- makes the default rows collapse back to their pre-line-stats output. + +local M = {} + +-- File row: right-hand `+N -N` segments (or `bin` for binary files, or an +-- empty list when no stats are attached). +function M.file_segments(stats) + if not stats then + return {} + end + if stats.binary then + return { { text = "bin", hl = "CodeDiffExplorerStatBinary" } } + end + + local segments = {} + if (stats.insertions or 0) > 0 then + segments[#segments + 1] = { text = "+" .. stats.insertions, hl = "CodeDiffExplorerStatInsertions" } + end + if (stats.deletions or 0) > 0 then + if #segments > 0 then + segments[#segments + 1] = { text = " ", hl = "Normal" } + end + segments[#segments + 1] = { text = "-" .. stats.deletions, hl = "CodeDiffExplorerStatDeletions" } + end + return segments +end + +-- Group row: headline `(N)` (feature disabled) or `(N · +42 -8)` (enabled). +function M.group_summary(ctx) + local count_hl = ctx.stats and "CodeDiffExplorerStatFiles" or "CodeDiffExplorerTreeGroup" + local segments = { + { text = " (", hl = "CodeDiffExplorerTreeGroup" }, + { text = tostring(ctx.file_count), hl = count_hl }, + } + if ctx.stats and ctx.stats.insertions > 0 then + segments[#segments + 1] = { text = " · ", hl = "CodeDiffExplorerTreeGroup" } + segments[#segments + 1] = { text = "+" .. ctx.stats.insertions, hl = "CodeDiffExplorerStatInsertions" } + end + if ctx.stats and ctx.stats.deletions > 0 then + segments[#segments + 1] = { text = ctx.stats.insertions > 0 and " " or " · ", hl = "CodeDiffExplorerTreeGroup" } + segments[#segments + 1] = { text = "-" .. ctx.stats.deletions, hl = "CodeDiffExplorerStatDeletions" } + end + segments[#segments + 1] = { text = ")", hl = "CodeDiffExplorerTreeGroup" } + return segments +end + +return M