From 5da3929480057f271d36ea56d4ef1cba793c53d6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 11 Mar 2026 14:01:28 -0400 Subject: [PATCH] feat(neojj): add neojj (jujutsu) integration Problem: diffs.nvim has no support for neojj, the neogit-like TUI for jujutsu VCS. Users switching to jj get no syntax highlighting in neojj status/diff buffers. Solution: Mirror the existing neogit integration pattern for neojj. Register `NeojjStatus`, `NeojjCommitView`, `NeojjDiffView` filetypes, set `vim.b.neojj_disable_hunk_highlight` on attach, listen for `User NeojjDiffLoaded` events, and detect jj repo root via `neojj.lib.jj.repo.worktree_root`. Add parser patterns for neojj's `added`, `updated`, `changed`, and `unmerged` filename labels. --- README.md | 16 ++- doc/diffs.nvim.txt | 22 ++++ lua/diffs/init.lua | 44 +++++++- lua/diffs/parser.lua | 16 +++ spec/neojj_integration_spec.lua | 173 ++++++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 spec/neojj_integration_spec.lua diff --git a/README.md b/README.md index 753a778..159e9b0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ highlighting driven by treesitter. ## Features -- Treesitter syntax highlighting in vim-fugitive, Neogit, and `diff` filetype +- Treesitter syntax highlighting in + [vim-fugitive](https://github.com/tpope/vim-fugitive), + [Neogit](https://github.com/NeogitOrg/neogit), builtin `diff` filetype, and + more! - Character-level intra-line diff highlighting (with optional [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for word-level accuracy) @@ -58,15 +61,18 @@ luarocks install diffs.nvim Do not lazy load `diffs.nvim` with `event`, `lazy`, `ft`, `config`, or `keys` to control loading - `diffs.nvim` lazy-loads itself. -**Q: Does diffs.nvim support vim-fugitive/Neogit/gitsigns?** +**Q: Does diffs.nvim support vim-fugitive/Neogit/neojj/gitsigns?** Yes. Enable integrations in your config: ```lua vim.g.diffs = { - fugitive = true, - neogit = true, - gitsigns = true, + integrations = { + fugitive = true, + neogit = true, + neojj = true, + gitsigns = true, + } } ``` diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index a3c5c9c..61897e2 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -40,6 +40,7 @@ CONTENTS *diffs-contents* 7. Integrations ..................................... |diffs-integrations| Fugitive .......................................... |diffs-fugitive| Neogit .............................................. |diffs-neogit| + Neojj ............................................... |diffs-neojj| Gitsigns .......................................... |diffs-gitsigns| Telescope ........................................ |diffs-telescope| 8. Conflict Resolution .................................... |diffs-conflict| @@ -84,6 +85,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: integrations = { fugitive = false, neogit = false, + neojj = false, gitsigns = false, committia = false, telescope = false, @@ -180,6 +182,14 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: integrations = { neogit = true } < + {neojj} (boolean|table, default: false) + Enable neojj integration. When active, + `NeojjStatus`, `NeojjCommitView`, and + `NeojjDiffView` filetypes are registered. + See |diffs-neojj|. >lua + integrations = { neojj = true } +< + {gitsigns} (boolean|table, default: false) Enable gitsigns.nvim blame popup highlighting. See |diffs-gitsigns|. >lua @@ -536,6 +546,7 @@ each integration's filetypes and attaches automatically. integrations = { fugitive = true, neogit = true, + neojj = true, gitsigns = true, }, } @@ -624,6 +635,17 @@ Expanding a diff in a Neogit buffer (e.g., TAB on a file in the status view) applies treesitter syntax highlighting and intra-line diffs to the hunk lines. +------------------------------------------------------------------------------ +NEOJJ *diffs-neojj* + +Enable neojj (https://github.com/NicholasZolton/neojj) support: >lua + vim.g.diffs = { integrations = { neojj = true } } +< + +Expanding a diff in a neojj buffer (e.g., TAB on a file in the status +view) applies treesitter syntax highlighting and intra-line diffs to the +hunk lines. + ------------------------------------------------------------------------------ GITSIGNS *diffs-gitsigns* diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 1244a3a..6975af1 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -39,6 +39,8 @@ ---@class diffs.NeogitConfig +---@class diffs.NeojjConfig + ---@class diffs.GitsignsConfig ---@class diffs.CommittiaConfig @@ -65,6 +67,7 @@ ---@class diffs.IntegrationsConfig ---@field fugitive diffs.FugitiveConfig|false ---@field neogit diffs.NeogitConfig|false +---@field neojj diffs.NeojjConfig|false ---@field gitsigns diffs.GitsignsConfig|false ---@field committia diffs.CommittiaConfig|false ---@field telescope diffs.TelescopeConfig|false @@ -77,6 +80,7 @@ ---@field integrations diffs.IntegrationsConfig ---@field fugitive? diffs.FugitiveConfig|false deprecated: use integrations.fugitive ---@field neogit? diffs.NeogitConfig|false deprecated: use integrations.neogit +---@field neojj? diffs.NeojjConfig|false deprecated: use integrations.neojj ---@field gitsigns? diffs.GitsignsConfig|false deprecated: use integrations.gitsigns ---@field committia? diffs.CommittiaConfig|false deprecated: use integrations.committia ---@field telescope? diffs.TelescopeConfig|false deprecated: use integrations.telescope @@ -161,6 +165,7 @@ local default_config = { integrations = { fugitive = false, neogit = false, + neojj = false, gitsigns = false, committia = false, telescope = false, @@ -239,6 +244,15 @@ function M.compute_filetypes(opts) table.insert(fts, 'NeogitCommitView') table.insert(fts, 'NeogitDiffView') end + local njj = intg.neojj + if njj == nil then + njj = opts.neojj + end + if njj == true or type(njj) == 'table' then + table.insert(fts, 'NeojjStatus') + table.insert(fts, 'NeojjCommitView') + table.insert(fts, 'NeojjDiffView') + end if type(opts.extra_filetypes) == 'table' then for _, ft in ipairs(opts.extra_filetypes) do table.insert(fts, ft) @@ -610,7 +624,7 @@ local function compute_highlight_groups(is_default) end end -local integration_keys = { 'fugitive', 'neogit', 'gitsigns', 'committia', 'telescope' } +local integration_keys = { 'fugitive', 'neogit', 'neojj', 'gitsigns', 'committia', 'telescope' } local function migrate_integrations(opts) if opts.integrations then @@ -674,6 +688,10 @@ local function init() intg.neogit = {} end + if intg.neojj == true then + intg.neojj = {} + end + if intg.gitsigns == true then intg.gitsigns = {} end @@ -1032,6 +1050,21 @@ function M.attach(bufnr) }) end + local neojj_augroup = nil + if config.integrations.neojj and vim.bo[bufnr].filetype:match('^Neojj') then + vim.b[bufnr].neojj_disable_hunk_highlight = true + neojj_augroup = vim.api.nvim_create_augroup('diffs_neojj_' .. bufnr, { clear = true }) + vim.api.nvim_create_autocmd('User', { + pattern = 'NeojjDiffLoaded', + group = neojj_augroup, + callback = function() + if vim.api.nvim_buf_is_valid(bufnr) and attached_buffers[bufnr] then + M.refresh(bufnr) + end + end, + }) + end + dbg('attaching to buffer %d', bufnr) ensure_cache(bufnr) @@ -1045,6 +1078,9 @@ function M.attach(bufnr) if neogit_augroup then pcall(vim.api.nvim_del_augroup_by_id, neogit_augroup) end + if neojj_augroup then + pcall(vim.api.nvim_del_augroup_by_id, neojj_augroup) + end end, }) end @@ -1101,6 +1137,12 @@ function M.get_fugitive_config() return config.integrations.fugitive end +---@return diffs.NeojjConfig|false +function M.get_neojj_config() + init() + return config.integrations.neojj +end + ---@return diffs.CommittiaConfig|false function M.get_committia_config() init() diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 8147e65..6f84b83 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -129,6 +129,18 @@ local function get_repo_root(bufnr) return vim.fn.fnamemodify(neogit_git_dir, ':h') end + if vim.bo[bufnr].filetype:match('^Neojj') then + local jj_ok, jj_mod = pcall(require, 'neojj.lib.jj') + if jj_ok then + local rok, repo = pcall(function() + return jj_mod.repo + end) + if rok and repo and repo.worktree_root then + return repo.worktree_root + end + end + end + local cwd = vim.fn.getcwd() local git = require('diffs.git') return git.get_repo_root(cwd .. '/.') @@ -244,6 +256,10 @@ function M.parse_buffer(bufnr) or (not logical:match('^deleted file mode') and logical:match('^deleted%s+(.+)$')) or logical:match('^renamed%s+(.+)$') or logical:match('^copied%s+(.+)$') + or logical:match('^added%s+(.+)$') + or logical:match('^updated%s+(.+)$') + or logical:match('^changed%s+(.+)$') + or logical:match('^unmerged%s+(.+)$') local bare_file = not hunk_start and logical:match('^([^%s]+%.[^%s]+)$') local filename = logical:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file diff --git a/spec/neojj_integration_spec.lua b/spec/neojj_integration_spec.lua new file mode 100644 index 0000000..2088305 --- /dev/null +++ b/spec/neojj_integration_spec.lua @@ -0,0 +1,173 @@ +require('spec.helpers') + +vim.g.diffs = { integrations = { neojj = true } } + +local diffs = require('diffs') +local parser = require('diffs.parser') + +local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +describe('neojj_integration', function() + describe('neojj_disable_hunk_highlight', function() + it('sets neojj_disable_hunk_highlight on NeojjStatus buffer after attach', function() + local bufnr = create_buffer({ + 'modified test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + vim.api.nvim_set_option_value('filetype', 'NeojjStatus', { buf = bufnr }) + diffs.attach(bufnr) + + assert.is_true(vim.b[bufnr].neojj_disable_hunk_highlight) + + delete_buffer(bufnr) + end) + + it('does not set neojj_disable_hunk_highlight on non-Neojj buffer', function() + local bufnr = create_buffer({}) + vim.api.nvim_set_option_value('filetype', 'git', { buf = bufnr }) + diffs.attach(bufnr) + + assert.is_not_true(vim.b[bufnr].neojj_disable_hunk_highlight) + + delete_buffer(bufnr) + end) + end) + + describe('NeojjStatus buffer attach', function() + it('populates hunk_cache for NeojjStatus buffer with diff content', function() + local bufnr = create_buffer({ + 'modified hello.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + vim.api.nvim_set_option_value('filetype', 'NeojjStatus', { buf = bufnr }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.are.equal(1, #entry.hunks) + assert.are.equal('hello.lua', entry.hunks[1].filename) + delete_buffer(bufnr) + end) + + it('populates hunk_cache for NeojjDiffView buffer', function() + local bufnr = create_buffer({ + 'new file newmod.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + }) + vim.api.nvim_set_option_value('filetype', 'NeojjDiffView', { buf = bufnr }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.are.equal(1, #entry.hunks) + delete_buffer(bufnr) + end) + end) + + describe('parser neojj patterns', function() + it('detects added prefix via parser', function() + local bufnr = create_buffer({ + 'added utils.py', + '@@ -0,0 +1,2 @@', + '+def hello():', + '+ pass', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('utils.py', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('detects updated prefix via parser', function() + local bufnr = create_buffer({ + 'updated config.toml', + '@@ -1,2 +1,3 @@', + ' [section]', + '+key = "val"', + ' other = 1', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('config.toml', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('detects changed prefix via parser', function() + local bufnr = create_buffer({ + 'changed main.rs', + '@@ -1,1 +1,2 @@', + ' fn main() {}', + '+fn helper() {}', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('main.rs', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('detects unmerged prefix via parser', function() + local bufnr = create_buffer({ + 'unmerged conflict.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('conflict.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('parses multi-file neojj buffer with modified and added', function() + local bufnr = create_buffer({ + 'modified test.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + 'added utils.py', + '@@ -0,0 +1,2 @@', + '+def hello():', + '+ pass', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(2, #hunks) + assert.are.equal('test.lua', hunks[1].filename) + assert.are.equal('utils.py', hunks[2].filename) + delete_buffer(bufnr) + end) + end) + + describe('compute_filetypes', function() + it('includes Neojj filetypes when neojj integration is enabled', function() + local fts = diffs.compute_filetypes({ integrations = { neojj = true } }) + assert.is_true(vim.tbl_contains(fts, 'NeojjStatus')) + assert.is_true(vim.tbl_contains(fts, 'NeojjCommitView')) + assert.is_true(vim.tbl_contains(fts, 'NeojjDiffView')) + end) + + it('excludes Neojj filetypes when neojj integration is disabled', function() + local fts = diffs.compute_filetypes({ integrations = { neojj = false } }) + assert.is_false(vim.tbl_contains(fts, 'NeojjStatus')) + assert.is_false(vim.tbl_contains(fts, 'NeojjCommitView')) + assert.is_false(vim.tbl_contains(fts, 'NeojjDiffView')) + end) + end) +end)