From bb407b8249f15bb46dbd15af11c524dc3a8ed8c3 Mon Sep 17 00:00:00 2001 From: Julien Vincent Date: Fri, 19 Jul 2024 01:25:34 -0600 Subject: [PATCH] Init --- .github/workflows/release.yaml | 34 +++++ .gitignore | 3 + .luarc.json | 3 + .stylua.toml | 8 ++ Justfile | 56 ++++++++ LICENCE | 21 +++ README.md | 90 +++++++++++++ lua/difftool/api/changeset.lua | 96 ++++++++++++++ lua/difftool/api/diff.lua | 94 ++++++++++++++ lua/difftool/api/fs.lua | 56 ++++++++ lua/difftool/api/highlights.lua | 29 +++++ lua/difftool/api/init.lua | 7 + lua/difftool/api/signs.lua | 38 ++++++ lua/difftool/config.lua | 41 ++++++ lua/difftool/init.lua | 191 +++++++++++++++++++++++++++ lua/difftool/ui/file.lua | 93 ++++++++++++++ lua/difftool/ui/init.lua | 5 + lua/difftool/ui/layout.lua | 55 ++++++++ lua/difftool/ui/tree.lua | 207 ++++++++++++++++++++++++++++++ lua/difftool/utils.lua | 83 ++++++++++++ plugin/difftool.vim | 4 + tests/config.lua | 9 ++ tests/difftool/changeset_spec.lua | 46 +++++++ tests/difftool/diff_spec.lua | 82 ++++++++++++ tests/utils/fixtures.lua | 50 ++++++++ 25 files changed, 1401 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .luarc.json create mode 100644 .stylua.toml create mode 100644 Justfile create mode 100644 LICENCE create mode 100644 README.md create mode 100644 lua/difftool/api/changeset.lua create mode 100644 lua/difftool/api/diff.lua create mode 100644 lua/difftool/api/fs.lua create mode 100644 lua/difftool/api/highlights.lua create mode 100644 lua/difftool/api/init.lua create mode 100644 lua/difftool/api/signs.lua create mode 100644 lua/difftool/config.lua create mode 100644 lua/difftool/init.lua create mode 100644 lua/difftool/ui/file.lua create mode 100644 lua/difftool/ui/init.lua create mode 100644 lua/difftool/ui/layout.lua create mode 100644 lua/difftool/ui/tree.lua create mode 100644 lua/difftool/utils.lua create mode 100644 plugin/difftool.vim create mode 100644 tests/config.lua create mode 100644 tests/difftool/changeset_spec.lua create mode 100644 tests/difftool/diff_spec.lua create mode 100644 tests/utils/fixtures.lua diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..5a23819 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,34 @@ +name: Release +on: [push] + +jobs: + publish: + runs-on: ubuntu-latest + env: + VERSION: ${{ github.ref_name }} + steps: + - name: Checkout git repo + uses: actions/checkout@v3 + + - uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: '21' + + - uses: extractions/setup-just@v1 + + - uses: DeLaGuardo/setup-clojure@10.2 + with: + cli: latest + + - name: Build + run: | + just build + + - name: Release + if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} + env: + CLOJARS_USERNAME: ${{ secrets.CLOJARS_USERNAME }} + CLOJARS_PASSWORD: ${{ secrets.CLOJARS_PASSWORD }} + run: | + just release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..714b0a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store + +.build diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..bf27dfe --- /dev/null +++ b/.luarc.json @@ -0,0 +1,3 @@ +{ + "diagnostics.globals": ["vim", "describe", "it", "before_each", "after_each"] +} diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..435bbc7 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,8 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +collapse_simple_statement = "Never" + diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..6abd4b8 --- /dev/null +++ b/Justfile @@ -0,0 +1,56 @@ +build: + clojure -T:build build + +release: + clojure -T:build release + +[macos] +prepare-nvim channel: + #!/usr/bin/env sh + set -eo pipefail + NVIM_DIR=".build/nvim/{{ channel }}" + + test -d $NVIM_DIR || { + mkdir -p $NVIM_DIR + + curl -L https://github.com/neovim/neovim/releases/download/{{ channel }}/nvim-macos-$(arch).tar.gz > ./.build/nvim-macos.tar.gz + xattr -c ./.build/nvim-macos.tar.gz + tar xzf ./.build/nvim-macos.tar.gz -C $NVIM_DIR --strip-components=1 + rm ./.build/nvim-macos.tar.gz + } + +[linux] +prepare-nvim channel: + #!/usr/bin/env sh + set -eo pipefail + NVIM_DIR=".build/nvim/{{ channel }}" + + test -d $NVIM_DIR || { + mkdir -p $NVIM_DIR + + curl -L https://github.com/neovim/neovim/releases/download/{{ channel }}/nvim-linux64.tar.gz > ./.build/nvim-linux64.tar.gz + tar xzf ./.build/nvim-linux64.tar.gz -C $NVIM_DIR --strip-components=1 + rm ./.build/nvim-linux64.tar.gz + } + +prepare-dependencies: + #!/usr/bin/env sh + set -eo pipefail + test -d .build/dependencies || { + mkdir -p ./.build/dependencies + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ./.build/dependencies/plenary.nvim + git clone --depth 1 https://github.com/MunifTanjim/nui.nvim ./.build/dependencies/nui.nvim + } + +prepare channel: (prepare-nvim channel) prepare-dependencies + +test channel="stable" file="": (prepare channel) + #!/usr/bin/env sh + NVIM_DIR=".build/nvim/{{ channel }}" + + ./$NVIM_DIR/bin/nvim --version + ./$NVIM_DIR/bin/nvim \ + --headless \ + --noplugin \ + -u tests/config.lua \ + -c "PlenaryBustedDirectory tests/difftool/{{ file }} { minimal_init='tests/config.lua', sequential=true }" diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..ef4e78d --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Julien Vincent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e0dd39 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +
+

difftool.nvim

+
+ +
+

+ A tool for editing diffs in Neovim +

+
+ +--- + +This is a Neovim tool for splitting/editing diffs. It operates over a `left` and `right` directory, producing a diff of +the two which can subsiquently be inspected and modified. The `DiffEditor` allows selecting changes by file, hunk or +individual line to produce a new partial diff. + +This was primarilly built to be used with [jujutsu](https://github.com/martinvonz/jj) as an alternative diff-editor to +it's `:builtin` option, but it's designed generically enough that it can be used for other usecases. + +To use it you need to give it two to three directories: a `left`, a `right`, and optionally and `output` directory. +These directories will then be read in by the diffeditor and used to produce a set of diffs between the two directories. +You will then be presented with the left and right side of each file and can select the lines from each diff hunk you +would like to keep. + +When you are happy with your selection you can accept changes and the diffeditor will modify the `output` directory (or +the `right` directory if no output is provided) to match your selection. + +## Installation + +### Using [folke/lazy.vim](https://github.com/folke/lazy.nvim) + +```lua +{ + "julienvincent/difftool.nvim", + cmd = { "DiffEditor" }, + config = function() + require("difftool").setup() + end, + dependencies = { + { "MunifTanjim/nui.nvim" } + } +} +``` + +## Configuration + +```lua +local difftool = require("difftool") +difftool.setup({ + keys = { + global = { + quit = { "q" }, + accept = { "" }, + }, + + tree = { + expand_node = { "l", "" }, + collapse_node = { "h", "" }, + + open_file = { "" }, + + toggle_file = { "a" }, + }, + + diff = { + toggle_line = { "a" }, + toggle_hunk = { "A" }, + }, + }, + + icons = { + selected = "󰡖", + deselected = "", + }, + + hooks = { + on_tree_mount = function() end, + on_diff_mount = function() end, + } +}) +``` + +## Configuring Jujutsu + +Add the following to your jujutsu `config.toml`: + +```toml +[ui] +diff-editor = ["nvim", "-c", "DiffEditor $left $right $output"] +``` diff --git a/lua/difftool/api/changeset.lua b/lua/difftool/api/changeset.lua new file mode 100644 index 0000000..12f20c8 --- /dev/null +++ b/lua/difftool/api/changeset.lua @@ -0,0 +1,96 @@ +local diff = require("difftool.api.diff") +local utils = require("difftool.utils") +local fs = require("difftool.api.fs") + +local M = {} + +local function merge_lists(a, b) + local seen = {} + + local function add_unique(list) + for _, item in ipairs(list) do + if not seen[item] then + seen[item] = true + end + end + end + + add_unique(a) + add_unique(b) + + return utils.get_keys(seen) +end + +function M.load_changeset(left, right) + local left_files = fs.list_files_recursively(left) + local right_files = fs.list_files_recursively(right) + local files = merge_lists(left_files, right_files) + + local changeset = {} + + for _, file in ipairs(files) do + local has_left = utils.included_in_table(left_files, file) + local has_right = utils.included_in_table(right_files, file) + + local type = "modified" + if not has_left then + type = "added" + end + if not has_right then + type = "deleted" + end + + local left_filepath = left .. "/" .. file + local right_filepath = right .. "/" .. file + + changeset[file] = { + type = type, + + left_filepath = left_filepath, + right_filepath = right_filepath, + filepath = file, + + selected = false, + selected_lines = { + left = {}, + right = {}, + }, + hunks = diff.diff_file(left_filepath, right_filepath), + } + end + + return changeset, files +end + +function M.write_changeset(changeset, output_dir) + vim.fn.mkdir(output_dir, "p") + + for _, change in pairs(changeset) do + local any_selected = utils.any_lines_selected(change) + local output_file = output_dir .. "/" .. change.filepath + + if change.type == "deleted" and not change.selected and not any_selected then + -- copy file from left to output + vim.fn.system("cp " .. change.left_filepath .. " " .. output_file) + elseif change.type ~= "deleted" and change.selected then + -- copy file from right to output + vim.fn.system("cp " .. change.right_filepath .. " " .. output_file) + elseif change.type == "deleted" and utils.all_lines_selected(change) then + vim.fn.system("rm " .. output_file) + elseif any_selected then + local left_file_content = fs.read_file_as_lines(change.left_filepath) + local right_file_content = fs.read_file_as_lines(change.right_filepath) + local result = diff.apply_diff(left_file_content, right_file_content, change) + fs.write_file(output_dir .. "/" .. change.filepath, result) + return + else + if change.type == "added" then + vim.fn.system("rm " .. output_file) + else + vim.fn.system("cp " .. change.left_filepath .. " " .. output_file) + end + end + end +end + +return M diff --git a/lua/difftool/api/diff.lua b/lua/difftool/api/diff.lua new file mode 100644 index 0000000..81873ef --- /dev/null +++ b/lua/difftool/api/diff.lua @@ -0,0 +1,94 @@ +local fs = require("difftool.api.fs") + +local M = {} + +function M.diff_file(left, right) + local left_content = fs.read_file(left) or "" + local right_content = fs.read_file(right) or "" + local hunks = vim.diff(left_content, right_content, { + result_type = "indices", + }) + + if type(hunks) ~= "table" then + return {} + end + + return vim.tbl_map(function(hunk) + return { + left = { hunk[1], hunk[2] }, + right = { hunk[3], hunk[4] }, + } + end, hunks) +end + +function M.apply_diff(left, right, change) + local hunks = change.hunks + local selected_lines = change.selected_lines + + local result = {} + + local left_index = 1 + local hunk_index = 1 + local hunk = hunks[hunk_index] + + if change.type == "added" then + for i = hunk.right[1], hunk.right[1] + hunk.right[2] - 1 do + if selected_lines.right[i] then + table.insert(result, right[i]) + end + end + return result + end + + while left_index <= #left do + if hunk and left_index == hunk.left[1] then + for i = left_index, left_index + hunk.left[2] - 1 do + left_index = i + if not selected_lines.left[i] then + table.insert(result, left[i]) + end + end + + if hunk.left[2] == 0 then + table.insert(result, left[left_index]) + end + + for i = hunk.right[1], hunk.right[1] + hunk.right[2] - 1 do + if selected_lines.right[i] then + table.insert(result, right[i]) + end + end + + hunk_index = hunk_index + 1 + hunk = hunks[hunk_index] + else + table.insert(result, left[left_index]) + end + + left_index = left_index + 1 + end + + return result +end + +-- local hunks = { +-- { +-- left = { 1, 4 }, +-- right = { 1, 1 }, +-- }, +-- { +-- left = { 6, 0 }, +-- right = { 4, 3 }, +-- }, +-- } +-- +-- M.apply_diff({ "a", "b", "c", "f", "e", "f" }, { "a1", "e", "f", "g", "h", "i" }, hunks, { +-- left = { [1] = true, [2] = true, [3] = true, [4] = true }, +-- right = { [1] = true, [4] = true, [5] = true, [6] = true }, +-- }) +-- +-- vim.diff("a\nb\nc\nf\ne\nf\n", "a1\ne\nf\ng\nh\ni\n", { +-- result_type = "indices", +-- }) + +return M diff --git a/lua/difftool/api/fs.lua b/lua/difftool/api/fs.lua new file mode 100644 index 0000000..ab415b0 --- /dev/null +++ b/lua/difftool/api/fs.lua @@ -0,0 +1,56 @@ +local M = {} + +function M.list_files_recursively(dir) + local files = {} + local p = io.popen('find "' .. dir .. '" -type f') + if not p then + return {} + end + for file in p:lines() do + table.insert(files, file) + end + p:close() + return vim.tbl_map(function(file) + return string.sub(file, #dir + 2) + end, files) +end + +function M.read_file(file_path) + local file = io.open(file_path, "r") + if not file then + return nil + end + local content = file:read("*a") + file:close() + return content +end + +function M.read_file_as_lines(file_path) + local content = vim.split(M.read_file(file_path) or "", "\n") + if content[#content] == "" then + table.remove(content, #content) + end + return content +end + +function M.make_parents(file_path) + local parent_dir = file_path:match("(.*/)") + vim.fn.mkdir(parent_dir, "p") +end + +function M.write_file(file_path, content) + M.make_parents(file_path) + + local file = io.open(file_path, "w") + if not file then + return + end + + for _, line in ipairs(content) do + file:write(line .. "\n") + end + + file:close() +end + +return M diff --git a/lua/difftool/api/highlights.lua b/lua/difftool/api/highlights.lua new file mode 100644 index 0000000..c80a9fb --- /dev/null +++ b/lua/difftool/api/highlights.lua @@ -0,0 +1,29 @@ +local M = {} + +function M.set_win_hl(winid, highlights) + vim.api.nvim_set_option_value("winhl", table.concat(highlights, ","), { + win = winid, + }) +end + +function M.define_highlights() + local diff_delete_highlight = vim.api.nvim_get_hl(0, { + name = "DiffDelete", + link = true, + }) + + vim.api.nvim_set_hl(0, "DiffToolDiffAddAsDelete", { + bg = string.format("#%06x", diff_delete_highlight.bg), + }) + + vim.api.nvim_set_hl(0, "DiffToolDiffDeleteDim", { + default = true, + link = "Comment", + }) + + vim.api.nvim_set_hl(0, "DiffToolDiffDelete", { + link = "DiffToolDiffDeleteDim", + }) +end + +return M diff --git a/lua/difftool/api/init.lua b/lua/difftool/api/init.lua new file mode 100644 index 0000000..3394673 --- /dev/null +++ b/lua/difftool/api/init.lua @@ -0,0 +1,7 @@ +return { + diff = require("difftool.api.diff"), + changeset = require("difftool.api.changeset"), + fs = require("difftool.api.fs"), + signs = require("difftool.api.signs"), + highlights = require("difftool.api.highlights"), +} diff --git a/lua/difftool/api/signs.lua b/lua/difftool/api/signs.lua new file mode 100644 index 0000000..b868b3f --- /dev/null +++ b/lua/difftool/api/signs.lua @@ -0,0 +1,38 @@ +local config = require("difftool.config") + +local M = { + signs = { + selected = { + name = "DiffToolLineSelected", + hl = "DiffToolSignSelected", + }, + deselected = { + name = "DiffToolLineDeselected", + hl = "DiffToolSignDeselected", + }, + }, +} + +function M.place_sign(buf, sign, linenr) + vim.fn.sign_place(0, "DiffTool", sign.name, buf, { + lnum = linenr, + priority = 100, + }) +end + +function M.define_signs() + vim.fn.sign_define({ + { + name = M.signs.selected.name, + text = config.icons.selected, + texthl = M.signs.selected.hl, + }, + { + name = M.signs.deselected.name, + text = config.icons.deselected, + texthl = M.signs.deselected.hl, + }, + }) +end + +return M diff --git a/lua/difftool/config.lua b/lua/difftool/config.lua new file mode 100644 index 0000000..821bf64 --- /dev/null +++ b/lua/difftool/config.lua @@ -0,0 +1,41 @@ +local M = { + keys = { + global = { + quit = { "q" }, + accept = { "" }, + }, + + tree = { + expand_node = { "l", "" }, + collapse_node = { "h", "" }, + + open_file = { "" }, + + toggle_file = { "a" }, + }, + + diff = { + toggle_line = { "a" }, + toggle_hunk = { "A" }, + }, + }, + + icons = { + selected = "󰡖", + deselected = "", + }, + + hooks = { + on_tree_mount = function() end, + on_diff_mount = function() end, + } +} + +function M.update_config(new_config) + local config = vim.tbl_deep_extend("force", M, new_config) + for key, value in pairs(config) do + M[key] = value + end +end + +return M diff --git a/lua/difftool/init.lua b/lua/difftool/init.lua new file mode 100644 index 0000000..575842b --- /dev/null +++ b/lua/difftool/init.lua @@ -0,0 +1,191 @@ +local config = require("difftool.config") +local utils = require("difftool.utils") +local api = require("difftool.api") +local ui = require("difftool.ui") + +local M = {} + +local CONTEXT + +local function toggle_file(change) + for _, hunk in ipairs(change.hunks) do + for i = hunk.left[1], hunk.left[1] + hunk.left[2] - 1 do + change.selected_lines.left[i] = not change.selected + end + + for i = hunk.right[1], hunk.right[1] + hunk.right[2] - 1 do + change.selected_lines.right[i] = not change.selected + end + end + + change.selected = not change.selected +end + +local function toggle_lines(change, side, lines, value) + for _, line in ipairs(lines) do + if value ~= nil then + change.selected_lines[side][line] = value + else + change.selected_lines[side][line] = not change.selected_lines[side][line] + end + end + + if utils.all_lines_selected(change) then + change.selected = true + else + change.selected = false + end +end + +local function toggle_hunk(change, side, line) + local hunk + for _, current_hunk in ipairs(change.hunks) do + local start_line = current_hunk[side][1] + local end_line = start_line + current_hunk[side][2] + if line <= end_line and line >= start_line then + hunk = current_hunk + break + end + end + + if not hunk then + return + end + + local left_lines = {} + for i = hunk.left[1], hunk.left[1] + hunk.left[2] - 1 do + table.insert(left_lines, i) + end + + local right_lines = {} + for i = hunk.right[1], hunk.right[1] + hunk.right[2] - 1 do + table.insert(right_lines, i) + end + + local any_selected = utils.all_lines_selected_in_hunk(change, hunk) + + toggle_lines(change, "left", left_lines, not any_selected) + toggle_lines(change, "right", right_lines, not any_selected) +end + +local function set_global_bindings(buf) + for _, chord in ipairs(utils.into_table(config.keys.global.accept)) do + vim.keymap.set("n", chord, function() + api.changeset.write_changeset(CONTEXT.changeset, CONTEXT.output or CONTEXT.right) + vim.cmd("qa") + end, { + buffer = buf, + }) + end + + for _, chord in ipairs(utils.into_table(config.keys.global.quit)) do + vim.keymap.set("n", chord, function() + vim.cmd("qa") + end, { + buffer = buf, + }) + end +end + +local function open_file(layout, tree, change) + local left_file + local right_file + + local function on_file_event(event) + if event.type == "toggle-lines" then + toggle_lines(change, event.file.side, event.lines) + event.file.render() + tree.render() + return + end + + if event.type == "toggle-hunk" then + toggle_hunk(change, event.file.side, event.line) + left_file.render() + right_file.render() + tree.render() + return + end + end + + left_file = ui.file.create(layout.left, { + side = "left", + change = change, + on_event = on_file_event, + }) + + right_file = ui.file.create(layout.right, { + side = "right", + change = change, + on_event = on_file_event, + }) + + set_global_bindings(left_file.buf) + set_global_bindings(right_file.buf) + + return left_file, right_file +end + +function M.start(left, right, output) + local changeset = api.changeset.load_changeset(left, right) + local files = utils.get_keys(changeset) + + local layout = ui.layout.create_layout() + + CONTEXT = { + changeset = changeset, + left = left, + right = right, + output = output, + } + + local current_change = changeset[files[1]] + local left_file, right_file, tree + + tree = ui.tree.create({ + winid = layout.tree, + changeset = changeset, + on_open = function(change) + current_change = change + left_file, right_file = open_file(layout, tree, change) + vim.api.nvim_set_current_win(layout.right) + end, + on_preview = function(change) + current_change = change + left_file, right_file = open_file(layout, tree, change) + vim.api.nvim_set_current_win(layout.tree) + end, + on_toggle = function(change) + toggle_file(change) + + left_file.render() + right_file.render() + tree.render() + end, + }) + + tree.render() + + left_file, right_file = open_file(layout, tree, current_change) + vim.api.nvim_set_current_win(layout.tree) + + set_global_bindings(tree.buf) +end + +function M.setup() + api.signs.define_signs() + api.highlights.define_highlights() + + vim.api.nvim_create_user_command("DiffEditor", function(params) + local args = params.fargs + if #args ~= 2 then + vim.notify("Error: DiffTool expects three arguments (left, right[, output])", vim.log.levels.ERROR) + return + end + M.start(args[1], args[2], args[3] or args[2]) + end, { + nargs = "*", + }) +end + +return M diff --git a/lua/difftool/ui/file.lua b/lua/difftool/ui/file.lua new file mode 100644 index 0000000..193eb22 --- /dev/null +++ b/lua/difftool/ui/file.lua @@ -0,0 +1,93 @@ +local config = require("difftool.config") +local utils = require("difftool.utils") +local api = require("difftool.api") + +local M = {} + +function M.create(window, params) + vim.api.nvim_set_current_win(window) + vim.cmd("diffoff") + vim.cmd("edit " .. params.change[params.side .. "_filepath"]) + vim.cmd("diffthis") + + local buf = vim.api.nvim_get_current_buf() + + local File = { + buf = buf, + win = window, + side = params.side, + change = params.change, + } + + vim.api.nvim_set_option_value("modifiable", false, { + buf = buf, + }) + vim.api.nvim_set_option_value("readonly", true, { + buf = buf, + }) + + for _, chord in ipairs(utils.into_table(config.keys.diff.toggle_line)) do + vim.keymap.set("n", chord, function() + local line = vim.api.nvim_win_get_cursor(window)[1] + params.on_event({ + type = "toggle-lines", + lines = { line }, + file = File, + }) + end, { buffer = buf }) + end + + for _, chord in ipairs(utils.into_table(config.keys.diff.toggle_hunk)) do + vim.keymap.set("n", chord, function() + params.on_event({ + type = "toggle-hunk", + line = vim.api.nvim_win_get_cursor(window)[1], + file = File, + }) + end, { buffer = buf }) + + vim.keymap.set("v", chord, function() + vim.cmd("normal! ") + + local start_line = vim.fn.getpos("'<")[2] + local end_line = vim.fn.getpos("'>")[2] + + local lines = {} + for i = start_line, start_line + end_line do + table.insert(lines, i) + end + + params.on_event({ + type = "toggle-lines", + lines = lines, + file = File, + }) + end, { buffer = buf }) + end + + local function apply_signs() + for _, hunk in ipairs(params.change.hunks) do + local local_hunk = hunk[params.side] + for i = local_hunk[1], local_hunk[1] + local_hunk[2] - 1 do + local is_selected = params.change.selected_lines[params.side][i] + local sign + if is_selected then + sign = api.signs.signs.selected + else + sign = api.signs.signs.deselected + end + api.signs.place_sign(buf, sign, i) + end + end + end + + function File.render() + apply_signs() + end + + apply_signs() + + return File +end + +return M diff --git a/lua/difftool/ui/init.lua b/lua/difftool/ui/init.lua new file mode 100644 index 0000000..ef654ba --- /dev/null +++ b/lua/difftool/ui/init.lua @@ -0,0 +1,5 @@ +return { + layout = require("difftool.ui.layout"), + tree = require("difftool.ui.tree"), + file = require("difftool.ui.file"), +} diff --git a/lua/difftool/ui/layout.lua b/lua/difftool/ui/layout.lua new file mode 100644 index 0000000..9995489 --- /dev/null +++ b/lua/difftool/ui/layout.lua @@ -0,0 +1,55 @@ +local highlights = require("difftool.api.highlights") + +local M = {} + +local function create_vertical_split() + vim.api.nvim_command("vsplit") + local winid = vim.api.nvim_get_current_win() + -- vim.api.nvim_set_option_value("diff", true, { + -- win = winid, + -- }) + return winid +end + +local function resize_tree(tree, left, right, size) + local total_width = vim.api.nvim_get_option_value("columns", {}) + local remaining_width = total_width - size + local equal_width = math.floor(remaining_width / 2) + + vim.api.nvim_win_set_width(tree, 30) + vim.api.nvim_win_set_width(left, equal_width) + vim.api.nvim_win_set_width(right, equal_width) +end + +function M.create_layout() + local tree_window = vim.api.nvim_get_current_win() + + local left_diff = create_vertical_split() + local right_diff = create_vertical_split() + + highlights.set_win_hl(left_diff, { + "DiffAdd:DiffToolDiffAddAsDelete", + "DiffDelete:DiffToolDiffDeleteDim", + + "DiffToolSignSelected:Red", + "DiffToolSignDeselected:Red", + }) + + highlights.set_win_hl(right_diff, { + "DiffDelete:DiffToolDiffDeleteDim", + "DiffToolSignSelected:Green", + "DiffToolSignDeselected:Green", + }) + + resize_tree(tree_window, left_diff, right_diff, 30) + + vim.api.nvim_set_current_win(tree_window) + + return { + tree = tree_window, + left = left_diff, + right = right_diff, + } +end + +return M diff --git a/lua/difftool/ui/tree.lua b/lua/difftool/ui/tree.lua new file mode 100644 index 0000000..fcb82aa --- /dev/null +++ b/lua/difftool/ui/tree.lua @@ -0,0 +1,207 @@ +local signs = require("difftool.api.signs") +local config = require("difftool.config") +local utils = require("difftool.utils") + +local NuiTree = require("nui.tree") +local Text = require("nui.text") +local Line = require("nui.line") + +local function get_file_extension(path) + local extension = path:match("^.+(%..+)$") + if not extension then + return "" + end + return string.sub(extension, 2) or "" +end + +local function split_path(path) + local parts = {} + for part in string.gmatch(path, "([^/]+)") do + table.insert(parts, part) + end + return parts +end + +local function insert_path(tree, change) + local parts = split_path(change.filepath) + local node = tree + for i, part in ipairs(parts) do + local is_last = i == #parts + local found = false + + for _, child in ipairs(node.children) do + if child.name == part and child.type == "dir" then + node = child + found = true + break + end + end + + if not found then + local new_node = { + name = part, + type = is_last and "file" or "dir", + change = change, + children = {}, + } + table.insert(node.children, new_node) + node = new_node + end + end +end + +local function build_file_tree(changeset) + local tree = { children = {} } + for _, change in pairs(changeset) do + insert_path(tree, change) + end + return tree.children +end + +local function file_tree_to_nodes(file_tree) + return vim.tbl_map(function(node) + local line = {} + + if node.type == "file" then + local path = node.change.filepath + local icon = require("nvim-web-devicons").get_icon(path, get_file_extension(path), {}) + if icon then + table.insert(line, Text(icon .. " ")) + end + end + + local highlight = "Blue" + if node.type == "dir" then + highlight = "Green" + end + table.insert(line, Text(node.name, highlight)) + + local children = file_tree_to_nodes(node.children) + + local ui_node = NuiTree.Node({ + line = line, + change = node.change, + type = node.type, + }, children) + ui_node:expand() + return ui_node + end, file_tree) +end + +local function apply_signs(tree, buf, nodes) + nodes = nodes or tree:get_nodes() + for _, node in pairs(nodes) do + if node.type == "file" then + if type(node) ~= "table" then + node = tree:get_node(node) + end + local _, linenr = tree:get_node(node:get_id()) + if linenr then + local sign + if node.change.selected then + sign = signs.signs.selected + else + sign = signs.signs.deselected + end + signs.place_sign(buf, sign, linenr) + end + else + apply_signs( + tree, + buf, + vim.tbl_map(function(id) + return tree:get_node(id) + end, node:get_child_ids()) + ) + end + end +end + +local M = {} + +function M.create(opts) + local tree = NuiTree({ + winid = opts.winid, + nodes = {}, + + prepare_node = function(node) + local line = Line() + + line:append(string.rep(" ", node:get_depth() - 1)) + + if node:has_children() then + if node:is_expanded() then + line:append(" ", "Comment") + else + line:append(" ", "Comment") + end + else + line:append(" ") + end + + for _, text in ipairs(node.line) do + line:append(text) + end + + return line + end, + }) + + local buf = vim.api.nvim_win_get_buf(opts.winid) + + local Component = { + buf = buf, + } + + function Component.render() + tree:render() + apply_signs(tree, buf) + end + + for _, chord in ipairs(utils.into_table(config.keys.tree.open_file)) do + vim.keymap.set("n", chord, function() + local node = tree:get_node() + if node.type == "file" then + opts.on_open(node.change) + end + end, { buffer = buf }) + end + + for _, chord in ipairs(utils.into_table(config.keys.tree.expand_node)) do + vim.keymap.set("n", chord, function() + local node = tree:get_node() + if node.type == "file" then + opts.on_preview(node.change) + end + if node.type == "dir" and not node:is_expanded() then + node:expand() + Component.render() + end + end, { buffer = buf }) + end + + for _, chord in ipairs(utils.into_table(config.keys.tree.collapse_node)) do + vim.keymap.set("n", chord, function() + local node = tree:get_node() + if node.type == "dir" and node:is_expanded() then + node:collapse() + Component.render() + end + end, { buffer = buf }) + end + + for _, chord in ipairs(utils.into_table(config.keys.tree.toggle_file)) do + vim.keymap.set("n", chord, function() + local node = tree:get_node() + if node.type == "file" then + opts.on_toggle(node.change) + end + end, { buffer = buf }) + end + + tree:set_nodes(file_tree_to_nodes(build_file_tree(opts.changeset))) + + return Component +end + +return M diff --git a/lua/difftool/utils.lua b/lua/difftool/utils.lua new file mode 100644 index 0000000..2676b88 --- /dev/null +++ b/lua/difftool/utils.lua @@ -0,0 +1,83 @@ +local M = {} + +function M.get_keys(tbl) + local keys = {} + for key, _ in pairs(tbl) do + table.insert(keys, key) + end + return keys +end + +function M.included_in_table(tbl, element) + for _, item in ipairs(tbl) do + if item == element then + return true + end + end + return false +end + +-- Ensures a value is a table. +-- +-- If given a table it will be returned unmodified. +-- If given a non-table it will be wrapped in a table +function M.into_table(value) + if type(value) == "table" then + return value + end + return { value } +end + +function M.all_lines_selected_in_hunk(change, hunk) + for i = hunk.left[1], hunk.left[1] + hunk.left[2] - 1 do + if not change.selected_lines.left[i] then + return false + end + end + + for i = hunk.right[1], hunk.right[1] + hunk.right[2] - 1 do + if not change.selected_lines.right[i] then + return false + end + end + + return true +end + +function M.all_lines_selected(change) + for _, hunk in ipairs(change.hunks) do + if not M.all_lines_selected_in_hunk(change, hunk) then + return false + end + end + + return true +end + +function M.any_lines_selected_in_hunk(change, hunk) + for i = hunk.left[1], hunk.left[1] + hunk.left[2] - 1 do + if change.selected_lines.left[i] then + return true + end + end + + for i = hunk.right[1], hunk.right[1] + hunk.right[2] - 1 do + if change.selected_lines.right[i] then + return true + end + end + + return false +end + +function M.any_lines_selected(change) + for _, hunk in ipairs(change.hunks) do + if M.any_lines_selected_in_hunk(change, hunk) then + return true + end + end + + return false +end + +return M diff --git a/plugin/difftool.vim b/plugin/difftool.vim new file mode 100644 index 0000000..e82f3da --- /dev/null +++ b/plugin/difftool.vim @@ -0,0 +1,4 @@ +if exists("g:loaded_difftool_nvim") + finish +endif +let g:loaded_difftool_nvim = 1 diff --git a/tests/config.lua b/tests/config.lua new file mode 100644 index 0000000..3ef8f22 --- /dev/null +++ b/tests/config.lua @@ -0,0 +1,9 @@ +vim.opt.runtimepath:append("./.build/dependencies/plenary.nvim") +vim.opt.runtimepath:append("./.build/dependencies/nui.nvim") +vim.opt.runtimepath:append(".") + +vim.cmd.runtime({ "plugin/plenary.vim", bang = true }) +vim.cmd.runtime({ "plugin/nui.nvim", bang = true }) + +vim.o.swapfile = false +vim.bo.swapfile = false diff --git a/tests/difftool/changeset_spec.lua b/tests/difftool/changeset_spec.lua new file mode 100644 index 0000000..f23e398 --- /dev/null +++ b/tests/difftool/changeset_spec.lua @@ -0,0 +1,46 @@ +local fixtures = require("tests.utils.fixtures") +local api = require("difftool.api") +local utils = require("difftool.utils") + +describe("changesets", function() + fixtures.with_workspace(function(workspace) + fixtures.prepare_simple_workspace(workspace) + + local changeset, paths = api.changeset.load_changeset(workspace.left, workspace.right) + + it("contains all files from both sides of diff", function() + assert.is_true(utils.included_in_table(paths, "added")) + assert.is_true(utils.included_in_table(paths, "modified")) + assert.is_true(utils.included_in_table(paths, "deleted")) + assert.are.equal(#paths, 3) + end) + + it("creates a correct modified change", function() + local change = changeset["modified"] + assert.are.equal(change.filepath, "modified") + assert.are.equal(change.type, "modified") + assert.are.same(change.hunks, { + { left = { 1, 2 }, right = { 1, 1 } }, + { left = { 3, 0 }, right = { 3, 2 } }, + }) + end) + + it("creates a correct added change", function() + local change = changeset["added"] + assert.are.equal(change.filepath, "added") + assert.are.equal(change.type, "added") + assert.are.same(change.hunks, { + { left = { 0, 0 }, right = { 1, 3 } }, + }) + end) + + it("creates a correct deleted change", function() + local change = changeset["deleted"] + assert.are.equal(change.filepath, "deleted") + assert.are.equal(change.type, "deleted") + assert.are.same(change.hunks, { + { left = { 1, 3 }, right = { 0, 0 } }, + }) + end) + end) +end) diff --git a/tests/difftool/diff_spec.lua b/tests/difftool/diff_spec.lua new file mode 100644 index 0000000..3b0f728 --- /dev/null +++ b/tests/difftool/diff_spec.lua @@ -0,0 +1,82 @@ +local fixtures = require("tests.utils.fixtures") +local api = require("difftool.api") + +describe("diff patching", function() + fixtures.with_workspace(function(workspace) + fixtures.prepare_simple_workspace(workspace) + + local changeset = api.changeset.load_changeset(workspace.left, workspace.right) + + it("should do nothing if no lines are selected", function() + local change = changeset.modified + local left_file_content = api.fs.read_file_as_lines(change.left_filepath) + local right_file_content = api.fs.read_file_as_lines(change.right_filepath) + local result = api.diff.apply_diff(left_file_content, right_file_content, change) + assert.are.same(result, left_file_content) + end) + + it("should apply left before right", function() + local change = changeset.modified + local left_file_content = api.fs.read_file_as_lines(change.left_filepath) + local right_file_content = api.fs.read_file_as_lines(change.right_filepath) + change.selected_lines = { + left = { + [1] = false, + [2] = true, + [3] = true, + }, + right = { + [1] = true, + [2] = true, + [3] = true, + }, + } + local result = api.diff.apply_diff(left_file_content, right_file_content, change) + assert.are.same(result, { + "a", + "a1", + "c", + "d", + }) + end) + + it("should apply files with no left correctly", function() + local change = changeset.added + local left_file_content = api.fs.read_file_as_lines(change.left_filepath) + local right_file_content = api.fs.read_file_as_lines(change.right_filepath) + change.selected_lines = { + left = {}, + right = { + [1] = true, + [2] = true, + [3] = true, + }, + } + local result = api.diff.apply_diff(left_file_content, right_file_content, change) + assert.are.same(result, { + "a", + "b", + "c", + }) + end) + + it("should apply files with no right correctly", function() + local change = changeset.deleted + local left_file_content = api.fs.read_file_as_lines(change.left_filepath) + local right_file_content = api.fs.read_file_as_lines(change.right_filepath) + change.selected_lines = { + left = { + [1] = false, + [2] = false, + [3] = true, + }, + right = {}, + } + local result = api.diff.apply_diff(left_file_content, right_file_content, change) + assert.are.same(result, { + "a", + "b", + }) + end) + end) +end) diff --git a/tests/utils/fixtures.lua b/tests/utils/fixtures.lua new file mode 100644 index 0000000..d96d486 --- /dev/null +++ b/tests/utils/fixtures.lua @@ -0,0 +1,50 @@ +local fs = require("difftool.api.fs") + +local M = {} + +local function create_temp_dir() + local temp_dir = vim.fn.tempname() + vim.fn.mkdir(temp_dir) + return temp_dir +end + +function M.with_workspace(cb) + local workspace = { + left = create_temp_dir(), + right = create_temp_dir(), + output = create_temp_dir(), + } + + cb(workspace) + + vim.fn.system("rm -r " .. workspace.left) + vim.fn.system("rm -r " .. workspace.right) + vim.fn.system("rm -r " .. workspace.output) +end + +M.with_workspace(function(workspace) + print(workspace.left) +end) + +function M.prepare_workspace(workspace, left, right) + for path, content in pairs(left) do + fs.write_file(workspace.left .. "/" .. path, content) + end + + for path, content in pairs(right) do + fs.write_file(workspace.right .. "/" .. path, content) + fs.write_file(workspace.output .. "/" .. path, content) + end +end + +function M.prepare_simple_workspace(workspace) + M.prepare_workspace(workspace, { + ["modified"] = { "a", "b", "c" }, + ["deleted"] = { "a", "b", "c" }, + }, { + ["modified"] = { "a1", "c", "d", "e" }, + ["added"] = { "a", "b", "c" }, + }) +end + +return M