├── README.md ├── fnl ├── repl.fnl └── repl.history.fnl ├── lua └── gitabra │ ├── async.lua │ ├── config.lua │ ├── git_commit.lua │ ├── git_status.lua │ ├── init.lua │ ├── job.lua │ ├── list.lua │ ├── md5.lua │ ├── outliner.lua │ ├── outliner_util.lua │ ├── patch_parser.lua │ ├── promise.lua │ ├── rev_buffer.lua │ ├── rev_file_buffer.lua │ ├── util │ ├── color.lua │ ├── functional.lua │ ├── init.lua │ ├── string_splitter.lua │ └── table.lua │ └── zipper.lua └── plugin └── gitabra.vim /README.md: -------------------------------------------------------------------------------- 1 | # Gitabra 2 | 3 | A little Magit in neovim 4 | 5 | ![Demo Animation](../assets/gitabra-general-demo.gif?raw=true) 6 | 7 | ## Quick Start 8 | `:Gitabra` to bring up the or refresh the status buffer 9 | 10 | While in the status buffer: 11 | 12 | - `` to expand/collapse the node under the cursor 13 | - `` to visit the thing under the cursor 14 | - `s` to stage hunk or file under the cursor (partial hunk supported) 15 | - `S` stage all 16 | - `u` to unstage hunk or file under the cursor (partial hunk supported) 17 | - `U` unstage all 18 | - `x` to discard the hunk under the cursor (partial hunk supported) 19 | - `q` to quit the buffer and close the window 20 | - `cc` to start editing the commit message 21 | - `ca` to start editing the last commit message 22 | 23 | While editing the commit message: 24 | 25 | Gitabra will pass on the contents of COMMIT_EDITMSG to git when the buffer is 26 | written to, or when the window is closed. Feel free to use ":w", ":wq", ":q", 27 | or your favorite vim command. 28 | 29 | ## Installation 30 | [vim-plug](https://github.com/junegunn/vim-plug) 31 | ``` 32 | Plug 'Odie/gitabra' 33 | 34 | lua << EOF 35 | require("gitabra").setup { 36 | -- Optional call to `setup` 37 | -- Leave empty to use defaults 38 | } 39 | EOF 40 | 41 | ``` 42 | 43 | [Packer](https://github.com/junegunn/vim-plug) 44 | ``` 45 | use {'Odie/gitabra', 46 | opt = true, 47 | cmd = {'Gitabra'}, 48 | config = function() 49 | require("gitabra").setup { 50 | -- Optional call to `setup` 51 | -- Leave empty to use defaults 52 | } 53 | end 54 | } 55 | ``` 56 | 57 | ## Configuration 58 | Gitabra currently use the following defaults. 59 | ``` 60 | { 61 | disclosure_sign = { 62 | collapsed = ">", 63 | expanded = "⋁" 64 | } 65 | } 66 | ``` 67 | Modified settings can be set via `gitabra.setup()`. This needs to be done before activating 68 | the plugin via the `Gitabra` command the first time. 69 | 70 | ## WARNING! 71 | This is alpha quality software! 72 | 73 | It *shouldn't* but might discard data you did not intend to. 74 | Be careful! 75 | -------------------------------------------------------------------------------- /fnl/repl.fnl: -------------------------------------------------------------------------------- 1 | (module repl 2 | {require { 3 | u gitabra.util 4 | st gitabra.git_status 5 | job gitabra.job 6 | outliner gitabra.outliner 7 | nvim aniseed.nvim 8 | lpeg lpeg 9 | zipper gitabra.zipper 10 | co gitabra.git_commit 11 | patch_parser gitabra.patch_parser 12 | }}) 13 | 14 | 15 | (def api vim.api) 16 | 17 | (def plugin_name "gitabra") 18 | 19 | (defn unload 20 | [] 21 | (let [dir (.. plugin_name "/") 22 | dot (.. plugin_name ".")] 23 | (each [key _ (pairs package.loaded)] 24 | (if (or (vim.startswith key dir) 25 | (vim.startswith key dot) 26 | (= key plugin_name)) 27 | (do 28 | (tset package.loaded key nil) 29 | (print "Unloaded: " key)))))) 30 | 31 | (defn cleanup-buffer 32 | [buf-name] 33 | (let [bufnr (vim.api.nvim_eval (string.format "bufnr(\"%s\")" buf-name))] 34 | (when (not= bufnr -1) 35 | (vim.api.nvim_buf_delete bufnr {})))) 36 | 37 | (defn clean-unnamed-buffers 38 | [] 39 | (each [_ bn (pairs (vim.api.nvim_list_bufs))] 40 | (if (= (vim.api.nvim_buf_get_name bn) "") 41 | (vim.api.nvim_buf_delete bn {})))) 42 | 43 | 44 | 45 | (comment 46 | (unload) 47 | 48 | vim 49 | vim.fn 50 | api 51 | ) 52 | -------------------------------------------------------------------------------- /fnl/repl.history.fnl: -------------------------------------------------------------------------------- 1 | (comment 2 | 3 | (st.status_info) 4 | (do 5 | (api.nvim_command ":message clear") 6 | (cleanup-buffer "GitabraStatus") 7 | (st.gitabra_status) 8 | ) 9 | 10 | (st.get_sole_status_screen) 11 | 12 | (def sc (st.get_sole_status_screen)) 13 | 14 | (api.nvim_buf_get_extmark_by_id sc.outline.buffer 15 | outliner.namespace_id 16 | 1 17 | {}) 18 | 19 | outliner 20 | outline 21 | (getmetatable outline) 22 | (setmetatable outline outliner) 23 | 24 | (def outline (outliner.new)) 25 | 26 | outline 27 | 28 | (outline:node_by_id "status header") 29 | 30 | (outline:close) 31 | 32 | (def t (u.system_async "git diff")) 33 | (patch_parser.parse_patch (. t.output 1)) 34 | 35 | (st.hunk_infos) 36 | 37 | ;; Excercises the `next` function of the zipper api 38 | (let [root {:id :root 39 | :children [{:id :c1 40 | :children [{:id :c1-c1} 41 | {:id :c1-c2} 42 | {:id :c1-c3}]} 43 | {:id :c2 44 | :children [{:id :c2-c1} 45 | {:id :c2-c2} 46 | {:id :c2-c3}]} 47 | {:id :c3 48 | :children [{:id :c3-c1} 49 | {:id :c3-c2} 50 | {:id :c3-c3}]}]} 51 | z (zipper.new root :children) 52 | cur_path (fn [z] 53 | (let [r {}] 54 | (each [_ node (ipairs z.path)] 55 | (u.table_push r node.id)) 56 | r))] 57 | (while (not (z:at_end)) 58 | (z:next) 59 | (print (vim.inspect (cur_path z)))) 60 | 61 | (print "is at end?" (z:at_end)) 62 | (z:remove_end_marker) 63 | z) 64 | 65 | ;; Excercises the `next` in such a way as to prune certain branches from 66 | ;; a basic depth first traversal 67 | (let [root {:id :root 68 | :children [{:id :c1 69 | :children [{:id :c1-c1} 70 | {:id :c1-c2} 71 | {:id :c1-c3}]} 72 | {:id :c2 73 | :do-not-visit true 74 | :children [{:id :c2-c1} 75 | {:id :c2-c2} 76 | {:id :c2-c3}]} 77 | {:id :c3 78 | :children [{:id :c3-c1} 79 | {:id :c3-c2} 80 | {:id :c3-c3}]}]} 81 | z (zipper.new root :children) 82 | cur_path (fn [z] 83 | (let [r {}] 84 | (each [_ node (ipairs z.path)] 85 | (u.table_push r node.id)) 86 | r))] 87 | (while (not (z:at_end)) 88 | (let [node (z:node)] 89 | (if (. node :do-not-visit) 90 | (do 91 | (z:right) 92 | (print "skipping node:" (. node :id))) 93 | (do 94 | (print (vim.inspect (cur_path z))) 95 | (tset node :visited true) 96 | (z:next))))) 97 | 98 | (print "is at end?" (z:at_end)) 99 | (z:remove_end_marker) 100 | z) 101 | 102 | (let [root {:id :root 103 | :children [{:id :c1 104 | :children [{:id :c1-c1} 105 | {:id :c1-c2} 106 | {:id :c1-c3}]} 107 | {:id :c2 108 | :do-not-visit true 109 | :children [{:id :c2-c1} 110 | {:id :c2-c2} 111 | {:id :c2-c3}]} 112 | {:id :c3 113 | :children [{:id :c3-c1} 114 | {:id :c3-c2} 115 | {:id :c3-c3}]}]} 116 | z (zipper.new root :children) 117 | cur_path (fn [z] 118 | (let [r {}] 119 | (each [_ node (ipairs z.path)] 120 | (u.table_push r node.id)) 121 | r))] 122 | (z:down) 123 | 124 | 125 | (print "is at end?" (z:at_end)) 126 | (z:remove_end_marker) 127 | ; (cur_path z) 128 | (z:parent_node) 129 | ) 130 | 131 | 132 | (let [root (u.get_in (st.get_sole_status_screen) [:outline :root]) 133 | z (zipper.new root "children") 134 | cur_path (fn [z] 135 | (let [r {}] 136 | (each [_ node (ipairs z.path)] 137 | (u.table_push r (or node.id node.text))) 138 | r))] 139 | 140 | (while (not (z:at_end)) 141 | (print (vim.inspect (cur_path z))) 142 | (z:next) 143 | ) 144 | 145 | (print "is at end?" (z:at_end)) 146 | (z:remove_end_marker) 147 | ; (cur_path z) 148 | ; z 149 | ) 150 | 151 | (let [outline (u.get_in (st.get_sole_status_screen) [:outline]) 152 | z (zipper.new outline.root "children") 153 | ] 154 | ; (outline:node_zipper_at_lineno 0) 155 | (z:down) 156 | z 157 | ) 158 | 159 | (let [outline (u.get_in (st.get_sole_status_screen) [:outline]) 160 | start (u.nanotime) 161 | z (outline:node_zipper_at_lineno 9) 162 | stop (u.nanotime) 163 | ] 164 | (if z 165 | (do 166 | (print (string.format "elapsed [%f]" (- stop start))) 167 | (z:node)) 168 | (print "node_zipper_at_lineno returned nil!") 169 | ) 170 | ) 171 | 172 | (let [outline (u.get_in (st.get_sole_status_screen) [:outline]) 173 | node (u.get_in outline [:root :children 2]) 174 | ] 175 | (tset node :collapsed true) 176 | node 177 | (outline:refresh) 178 | ) 179 | 180 | (let [outline (u.get_in (st.get_sole_status_screen) [:outline]) 181 | z (zipper.new outline.root "children") ] 182 | 183 | (z:next) 184 | (z:next) 185 | (z:next) 186 | (z:next) 187 | (z:children) 188 | ) 189 | 190 | (st.get_sole_status_screen) 191 | 192 | (let [outline (u.get_in (st.get_sole_status_screen) [:outline]) 193 | start (u.nanotime) 194 | _ (outline:refresh) 195 | stop (u.nanotime)] 196 | (print (string.format "Refresh took [%f]" (- stop start))) 197 | ) 198 | 199 | api 200 | vim.fn 201 | 202 | (u.nvim_create_augroups {:ReleaseGitcommit ["BufWritePost lua require()" 203 | "BufDelete lua require()" ]}) 204 | (co.test) 205 | 206 | (u.remove_trailing_newlines "hello world\n\n") 207 | 208 | (u.table_key_diff 209 | (u.table_array_to_set [{:id "node-1" :hello :world} 210 | {:id "node-2" :hello :there} 211 | ] 212 | (fn [x] 213 | (. x :id))) 214 | (u.table_array_to_set [{:id "node-1" :hello :world} 215 | {:id "node-3" :hello :universe} 216 | ] 217 | (fn [x] 218 | (. x :id)))) 219 | 220 | (let [lm (require "gitabra.list") 221 | l (lm.new)] 222 | (print "1 empty?" (l:is_empty)) 223 | (l:push_right :hello) 224 | (l:push_right :world) 225 | (print "2 empty?" (l:is_empty)) 226 | (l:pop_left) 227 | (l:pop_left) 228 | (print "3 empty?" (l:is_empty)) 229 | l 230 | ) 231 | 232 | (let [md5 (require "gitabra.md5") 233 | start (u.nanotime) 234 | checksum (md5.sumhexa "hello\n") 235 | stop (u.nanotime) 236 | ] 237 | (print "elapsed:" (- stop start)) 238 | checksum 239 | 240 | ) 241 | 242 | (st.reconcile_status_state {:outline (outliner.new {:root {:children [{:text ["hello" "world"] 243 | :hello :world 244 | :children [{:text ["Traveler"] 245 | :name "Odie"} 246 | {:text ["Planes"] 247 | :count 5} 248 | {:text ["Trains"] 249 | :arrival :on-time}]} 250 | {:text ["hello" "there"] 251 | :hello :there}]}})} 252 | 253 | {:outline (outliner.new {:root {:children [{:text ["pretty" "good"]} 254 | {:text ["hello" "world"] 255 | :children [{:text ["Traveler"]} 256 | {:text ["UFOs"]} 257 | {:text ["Planes"]} 258 | {:text ["Greys"]}]} 259 | {:text ["what's" "up"]}]} })} 260 | ) 261 | 262 | ) 263 | -------------------------------------------------------------------------------- /lua/gitabra/async.lua: -------------------------------------------------------------------------------- 1 | -- Adapted from https://github.com/ms-jpq/neovim-async-tutorial 2 | local co = coroutine 3 | local uv = vim.loop 4 | local promise = require("gitabra.promise") 5 | 6 | -- In libuv async style programming, a function signature usually looks like: 7 | -- function(param1, param2..., callback) 8 | -- The idea when the function is called is that an operation will be kicked 9 | -- off with an unknown completion time. *When* it is completed, the supplied 10 | -- callback function will be fired. 11 | -- 12 | -- To flatten out the callbacks, we use Lua's coroutine and define a `step` 13 | -- function that does the following: 14 | -- - resume coroutine/thread 15 | -- - get back the next thunk to be executed (and waited on libuv style) 16 | -- - execute it and supply it with the `step` function as a callback 17 | -- 18 | -- This way, we're either immediately executing some lua code, or returning 19 | -- a thunk to be executed and waited upon using the step function. 20 | -- 21 | 22 | -- use with wrap 23 | local function pong(func, callback) 24 | assert(type(func) == "function", "type error :: expected func") 25 | local thread = co.create(func) 26 | 27 | local step = nil 28 | 29 | -- This step function will be passed around an used as the 30 | -- function to call when 31 | step = function(...) 32 | -- We are going to be locked in a sort of co-recursive loop here. 33 | -- Here, we're going to repeatedly resume the coroutine... 34 | local stat, ret = co.resume(thread, ...) 35 | assert(stat, ret) 36 | 37 | -- If the thread is done... 38 | if co.status(thread) == "dead" then 39 | -- Call the supplied `callback` 40 | (callback or function () end)(ret) 41 | else 42 | -- If the thread is not done yet... 43 | -- Someone should have passed back another thunk to us, sent via 44 | -- an `await` call. 45 | assert(type(ret) == "function", "type error :: expected func") 46 | 47 | -- Execute the given thunk. 48 | -- Note that we're giving it the step function here. 49 | ret(step) 50 | end 51 | end 52 | 53 | -- Start step function chain 54 | step() 55 | end 56 | 57 | 58 | -- Wrap any function that accepts a callback into a thunk 59 | -- Returns a function of the same signature, sans the callback. 60 | -- 61 | -- When the returned function is called, we're basically performing a 62 | -- partial function application of all params except the last, 63 | -- which would be a callback function. 64 | -- 65 | -- This "thunk" is then waited/yielded on, it is received by the 66 | -- step function, which will then bind the last param and execute the 67 | -- thunk. 68 | local function wrap(func) 69 | assert(type(func) == "function", "type error :: expected func") 70 | return function (...) 71 | local params = {...} 72 | 73 | -- The thunk will be called/resumed 74 | local thunk = function (step) 75 | table.insert(params, step) 76 | return func(unpack(params)) 77 | end 78 | return thunk 79 | end 80 | end 81 | 82 | 83 | -- many thunks -> single thunk 84 | local function join(thunks) 85 | local len = table.getn(thunks) 86 | local done = 0 87 | local acc = {} 88 | 89 | local thunk = function (step) 90 | if len == 0 then 91 | return step() 92 | end 93 | for i, tk in ipairs(thunks) do 94 | assert(type(tk) == "function", "thunk must be function") 95 | local callback = function (...) 96 | acc[i] = {...} 97 | done = done + 1 98 | if done == len then 99 | step(unpack(acc)) 100 | end 101 | end 102 | tk(callback) 103 | end 104 | end 105 | return thunk 106 | end 107 | 108 | 109 | -- sugar over coroutine 110 | local function await(defer) 111 | assert(type(defer) == "function", "type error :: expected func") 112 | return co.yield(defer) 113 | end 114 | 115 | 116 | local function await_all(defer) 117 | assert(type(defer) == "table", "type error :: expected table") 118 | return co.yield(join(defer)) 119 | end 120 | 121 | local sleep_ms = function(ms, callback) 122 | local timer = uv.new_timer() 123 | uv.timer_start(timer, ms, 0, function () 124 | uv.timer_stop(timer) 125 | uv.close(timer) 126 | callback() 127 | end) 128 | end 129 | 130 | -- Run a thunk and wait for the result 131 | local function run(defer, ms) 132 | local result 133 | local result_available = false 134 | 135 | defer(function (ret) 136 | result = ret 137 | result_available = true 138 | end) 139 | 140 | vim.wait(ms or 1000, 141 | function() 142 | return result_available 143 | end, 5) 144 | assert(result_available) 145 | 146 | return result 147 | end 148 | 149 | -- Starts the async task and returns promise where the result will be delivered 150 | local function as_promise(defer) 151 | local p = promise.new({}) 152 | 153 | defer(function (ret) 154 | p:deliver(ret) 155 | end) 156 | 157 | return p 158 | end 159 | 160 | local function main_loop(f) 161 | vim.schedule(f) 162 | end 163 | 164 | return { 165 | step = pong, 166 | run = run, 167 | sync = wrap(pong), 168 | wait = await, 169 | wait_all = await_all, 170 | wrap = wrap, 171 | sleep_ms = wrap(sleep_ms), 172 | main_loop = main_loop, 173 | as_promise = as_promise, 174 | } 175 | -------------------------------------------------------------------------------- /lua/gitabra/config.lua: -------------------------------------------------------------------------------- 1 | local u = require("gitabra.util") 2 | 3 | local M = {} 4 | 5 | local default_config = { 6 | disclosure_sign = { 7 | -- ">": Unicode: U+003E, UTF-8: 3E 8 | collapsed = ">", 9 | 10 | -- "⋁": Unicode U+22C1, UTF-8: E2 8B 81 11 | expanded = "⋁" 12 | } 13 | } 14 | 15 | local config = u.table_copy_into_recursive({}, default_config) 16 | M.config = config 17 | 18 | function M.setup(opts) 19 | config = u.table_copy_into_recursive({}, default_config, opts) 20 | M.config = config; 21 | end 22 | 23 | return M 24 | -------------------------------------------------------------------------------- /lua/gitabra/git_commit.lua: -------------------------------------------------------------------------------- 1 | local u = require("gitabra.util") 2 | local job = require("gitabra.job") 3 | local api = vim.api 4 | local promise = require("gitabra.promise") 5 | 6 | local singleton 7 | 8 | local function git_has_staged_changes() 9 | local p = u.system_as_promise({"git", "diff", "--cached", "--exit-code"}, {split_lines=true}) 10 | p:wait(1000) 11 | assert(p:is_realized()) 12 | return p.job.exit_code 13 | end 14 | 15 | -- This script used together with `git commit` to make sure 16 | -- that `git commit` is held open/running until a specific external 17 | -- signal is received. In this case, we're waiting the existance of a 18 | -- file to be the signal to exit. 19 | local function make_editor_script(params) 20 | return u.interp([[sh -c " 21 | echo $1 22 | while [ ! -f ${temppath}.exit ]; 23 | do sleep 0.10 24 | done 25 | exit 0"]], params) 26 | end 27 | 28 | local function finish_commit() 29 | local commit_state = singleton 30 | if commit_state then 31 | singleton = nil 32 | u.system({'touch', commit_state.temppath..".exit"}) 33 | promise.wait(commit_state.promise, 1000) 34 | if commit_state.promise.job.exit_code ~= 0 and #commit_state.promise.job.err_output ~= 0 then 35 | vim.cmd(string.format("echom '%s'", 36 | u.remove_trailing_newlines(table.concat(commit_state.promise.job.err_output)))) 37 | end 38 | end 39 | end 40 | 41 | local function gitabra_commit(mode) 42 | if mode ~= "amend" then 43 | local has_changes = git_has_staged_changes() 44 | if has_changes == 0 then 45 | print("No staged changes to commit") 46 | return 47 | end 48 | end 49 | 50 | -- Start `git commit` and hold the process open 51 | local temppath = vim.fn.tempname() 52 | 53 | local cmd = {"git", "commit"} 54 | if mode == "amend" then 55 | table.insert(cmd, "--amend") 56 | end 57 | 58 | local p = u.system_as_promise(cmd, { 59 | env = { 60 | -- Give `git commit` a shell command to run. 61 | -- We will signal the shell command to terminate after the user 62 | -- saves and exits from editing the commit message 63 | string.format("GIT_EDITOR=%s", make_editor_script({temppath=temppath})), 64 | 65 | -- Help git locate the user's .gitconfig file 66 | string.format("HOME=%s", vim.fn.expand("~")) 67 | } 68 | }) 69 | 70 | -- The shell script should send back the location of the commit message file 71 | p:wait_for(3000, function() 72 | if not u.table_is_empty(p.job.output) then 73 | return true 74 | end 75 | end) 76 | assert(not u.str_is_really_empty(p.job.output[1]), "Expected to receive the EDITMSG file path, but timedout") 77 | 78 | -- Start editing the commit message file 79 | -- vim.cmd(string.format('e %s', vim.fn.fnameescape(u.git_dot_git_dir().."/COMMIT_EDITMSG"))) 80 | vim.cmd(string.format('e %s', vim.fn.fnameescape(u.remove_trailing_newlines(p.job.output[1])))) 81 | 82 | -- Make sure this buffer goes away once it is hidden 83 | local bufnr = api.nvim_get_current_buf() 84 | vim.bo[bufnr].bufhidden = "wipe" 85 | vim.bo[bufnr].swapfile = false 86 | 87 | u.nvim_commands([[ 88 | augroup ReleaseGitCommit 89 | autocmd! * 90 | autocmd BufWritePost lua require('gitabra.git_commit').finish_commit('WritePost') 91 | autocmd BufWinLeave lua require('gitabra.git_commit').finish_commit('BufWinLeave') 92 | autocmd BufWipeout lua require('gitabra.git_commit').finish_commit('BufWipeout') 93 | augroup END 94 | ]], true) 95 | 96 | singleton = { 97 | temppath = temppath, 98 | promise = p, 99 | } 100 | end 101 | 102 | return { 103 | make_editor_script = make_editor_script, 104 | gitabra_commit = gitabra_commit, 105 | finish_commit = finish_commit, 106 | } 107 | -------------------------------------------------------------------------------- /lua/gitabra/git_status.lua: -------------------------------------------------------------------------------- 1 | local u = require("gitabra.util") 2 | local outliner = require("gitabra.outliner") 3 | local ou = require("gitabra.outliner_util") 4 | local api = vim.api 5 | local patch_parser = require("gitabra.patch_parser") 6 | local md5 = require("gitabra.md5") 7 | local promise = require("gitabra.promise") 8 | 9 | local function git_get_branch() 10 | return u.system_as_promise('git branch --show-current', {split_lines=true}) 11 | end 12 | 13 | local function git_branch_commit_msg() 14 | return u.system_as_promise({"git", "show", "--no-patch", "--format='%h %s'"}, {split_lines=true}) 15 | end 16 | 17 | local function git_status() 18 | return u.system_as_promise("git status --porcelain", {split_lines=true}) 19 | end 20 | 21 | local function git_diff_unstaged() 22 | return u.system_as_promise("git diff", {merge_output=true}) 23 | end 24 | 25 | local function git_diff_staged() 26 | return u.system_as_promise("git diff --cached", {merge_output=true}) 27 | end 28 | 29 | local function git_apply_patch(direction, patch_text) 30 | local cmd = {"git", "apply", "--cached", "--whitespace=nowarn"} 31 | if direction == "unstage" then 32 | table.insert(cmd, "--reverse") 33 | end 34 | table.insert(cmd, "-") 35 | 36 | local j = u.system(cmd) 37 | j.job:send(patch_text) 38 | return j 39 | end 40 | 41 | local function git_discard_hunk(include_staged, patch_text) 42 | local cmd = {"git", "apply", "--reverse", "--whitespace=nowarn"} 43 | if include_staged then 44 | table.insert(cmd, "--index") 45 | end 46 | table.insert(cmd, "-") 47 | 48 | local j = u.system(cmd) 49 | j.job:send(patch_text) 50 | return j 51 | end 52 | 53 | local function git_add(rel_filepath) 54 | return u.system({"git", "add", rel_filepath}) 55 | end 56 | 57 | local function git_reset_file(rel_filepath) 58 | return u.system({"git", "reset", rel_filepath}) 59 | end 60 | 61 | local function git_log_recents() 62 | return u.system_as_promise("git log --oneline -n 10 --decorate=short", {split_lines=true}) 63 | end 64 | 65 | local function git_stash_list() 66 | return u.system_as_promise("git stash list", {split_lines=true}) 67 | end 68 | 69 | local function parse_log_recent_entry(log_entry_str) 70 | -- A recent commit line will look like either: 71 | -- "abcdefg (ref1, ref2, ...) some commit message here" 72 | -- or 73 | -- "abcdefg some commit message here" 74 | local rev, refs, msg 75 | 76 | rev, refs, msg = string.match(log_entry_str, "^(%w+) %((.-)%) (.*)$") 77 | if not rev then 78 | rev, msg = string.match(log_entry_str, "^(%w+) (.*)$") 79 | end 80 | assert(rev and msg, "Unable to parse log lines") 81 | 82 | local item = { 83 | rev = rev, 84 | msg = msg 85 | } 86 | if refs then 87 | -- item.refs = u.map(u.string_split_by_pattern(refs, ","), ou.parse_ref) 88 | item.refs = ou.parse_refs(refs) 89 | end 90 | return item 91 | end 92 | 93 | local function format_log_recent_entry(log_entry) 94 | local entry = u.markup({{ 95 | text = log_entry.rev, 96 | group = "GitabraRev", 97 | }}) 98 | 99 | if log_entry.refs then 100 | u.table_concat(entry, u.map(log_entry.refs, ou.format_ref)) 101 | end 102 | 103 | table.insert(entry, log_entry.msg) 104 | return entry 105 | end 106 | 107 | local function parse_stash_entry(str) 108 | local rev, msg = string.match(str, "^(stash@{%d+}):(.*)$") 109 | return { 110 | stash_rev = rev, 111 | msg = u.trim(msg), 112 | } 113 | end 114 | 115 | local function format_stash_entry(entry) 116 | return u.markup({ 117 | { 118 | group = "GitabraStashRev", 119 | text = entry.stash_rev, 120 | }, 121 | entry.msg, 122 | }) 123 | end 124 | 125 | local function status_letter_name(letter) 126 | if "M" == letter then return "modified" 127 | elseif "A" == letter then return "added" 128 | elseif "D" == letter then return "deleted" 129 | elseif "R" == letter then return "renamed" 130 | elseif "C" == letter then return "copied" 131 | elseif "U" == letter then return "umerged" 132 | elseif "?" == letter then return "untracked" 133 | else return "" 134 | end 135 | end 136 | 137 | local module_initialized = false 138 | local function module_initialize() 139 | if module_initialized then 140 | return 141 | end 142 | 143 | vim.cmd("highlight link GitabraBranch Blue") 144 | vim.cmd("highlight link GitabraRemoteRef Green") 145 | vim.cmd("highlight link GitabraRev Blue") 146 | vim.cmd("highlight link GitabraStashRev Blue") 147 | 148 | local attrs = u.hl_group_attrs("Green") 149 | attrs.gui = "underline" 150 | vim.cmd(string.format("highlight GitabraCurrentBranch %s", u.hl_group_attrs_to_str(attrs))) 151 | 152 | attrs = u.hl_group_attrs("Yellow") 153 | attrs.gui = "bold" 154 | attrs.cterm = "bold" 155 | vim.cmd(string.format("highlight GitabraStatusSection %s", u.hl_group_attrs_to_str(attrs))) 156 | 157 | attrs = u.hl_group_attrs("White") 158 | attrs.gui = "bold" 159 | attrs.cterm = "bold" 160 | vim.cmd(string.format("highlight GitabraStatusFile %s", u.hl_group_attrs_to_str(attrs))) 161 | 162 | -- Set the node content highlight 163 | -- Disable the highlight if we can't retrieve the guibg color 164 | attrs = u.hl_group_attrs("Normal") 165 | if not attrs.guibg then 166 | vim.cmd("highlight link GitabraNodeActiveHeader Normal") 167 | else 168 | -- Otherwise, make it slightly brighter than the normal background 169 | local h, s, l = u.Hex_to_HSL(attrs.guibg) 170 | vim.cmd(string.format("highlight GitabraNodeActiveHeader %s", u.hl_group_attrs_to_str({guibg = u.HSL_to_Hex(h, s+5.2, l+10.0)}))) 171 | end 172 | vim.fn.sign_define("GitabraNodeActiveHeader", {linehl = "GitabraNodeActiveHeader"}) 173 | 174 | module_initialized = true 175 | end 176 | 177 | 178 | local function status_info() 179 | local root_dir_p = u.git_root_dir_p() 180 | local branch_p = git_get_branch() 181 | local branch_msg_p = git_branch_commit_msg() 182 | local status_p = git_status() 183 | local recents_p = git_log_recents() 184 | local stash_list_p = git_stash_list() 185 | 186 | local ps = {root_dir_p, branch_p, branch_msg_p, status_p, recents_p, stash_list_p} 187 | 188 | local wait_result = promise.wait_all(ps, 2000) 189 | if not wait_result then 190 | local funcname = debug.getinfo(1, "n").name 191 | print(vim.inspect(ps)) 192 | error(string.format("%s: unable to complete git commands within the alotted time", funcname)) 193 | end 194 | 195 | 196 | -------------------------------------------------------------------- 197 | 198 | local files = {} 199 | local untracked = {} 200 | local staged = {} 201 | local unstaged = {} 202 | 203 | for _, line in ipairs(status_p.job.output) do 204 | local fstat = line:sub(1, 2) 205 | local fname = line:sub(4) 206 | fname = fname:match("\"(.-)\"") or fname 207 | 208 | local entry = { 209 | index = status_letter_name(fstat:sub(1, 1)), 210 | working = status_letter_name(fstat:sub(2, 2)), 211 | name = fname, 212 | } 213 | 214 | if entry.working == "untracked" then 215 | table.insert(untracked, entry) 216 | end 217 | 218 | if entry.index ~= "" and entry.index ~= "untracked" then 219 | table.insert(staged, entry) 220 | end 221 | 222 | if entry.working ~= "" and entry.working ~= "untracked" then 223 | table.insert(unstaged, entry) 224 | end 225 | 226 | table.insert(files, entry) 227 | end 228 | 229 | local commit_msg = branch_msg_p.job.output[1] 230 | commit_msg = commit_msg and commit_msg:sub(2, -2) or "No commits yet" 231 | 232 | return { 233 | git_root = root_dir_p.job.output[1], 234 | branch = branch_p.job.output[1], 235 | last_commit_msg = commit_msg, 236 | files = files, 237 | untracked = untracked, 238 | staged = staged, 239 | unstaged = unstaged, 240 | recents = recents_p.job.output, 241 | stash_list = stash_list_p.job.output, 242 | } 243 | end 244 | 245 | local function patch_infos() 246 | local unstaged_p = git_diff_unstaged() 247 | local staged_p = git_diff_staged() 248 | 249 | local ps = {unstaged_p, staged_p} 250 | local wait_result = promise.wait_all(ps, 2000) 251 | if not wait_result then 252 | local funcname = debug.getinfo(1, "n").name 253 | error(string.format("%s: unable to complete git commands within the alotted time", funcname)) 254 | end 255 | 256 | local infos = { 257 | unstaged = patch_parser.parse(unstaged_p.job.output[1]), 258 | staged = patch_parser.parse(staged_p.job.output[1]), 259 | } 260 | 261 | return infos 262 | end 263 | 264 | local function setup_window() 265 | vim.cmd(":topleft vsplit") 266 | local win = api.nvim_get_current_win() 267 | vim.wo[win].number = false 268 | vim.wo[win].relativenumber = false 269 | return win 270 | end 271 | 272 | local function setup_keybinds(bufnr) 273 | local function set_keymap(...) vim.api.nvim_buf_set_keymap(bufnr, ...) end 274 | local opts = { noremap=true, silent=true } 275 | set_keymap('n', '', 'lua require("gitabra.git_status").toggle_fold_at_current_line()', opts) 276 | 277 | set_keymap('n', 's', 'lua require("gitabra.git_status").stage()', opts) 278 | set_keymap('v', 's', 'lua require("gitabra.git_status").stage()', opts) 279 | set_keymap('n', 'S', 'lua require("gitabra.git_status").stage_all()', opts) 280 | 281 | set_keymap('n', 'u', 'lua require("gitabra.git_status").unstage()', opts) 282 | set_keymap('v', 'u', 'lua require("gitabra.git_status").unstage()', opts) 283 | set_keymap('n', 'U', 'lua require("gitabra.git_status").unstage_all()', opts) 284 | 285 | set_keymap('n', '', 'lua require("gitabra.git_status").jump_to_location()', opts) 286 | set_keymap('n', 'x', 'lua require("gitabra.git_status").discard_hunk()', opts) 287 | set_keymap('v', 'x', 'lua require("gitabra.git_status").discard_hunk()', opts) 288 | set_keymap('n', 'q', 'close', opts) 289 | set_keymap('n', 'cc', 'lua require("gitabra.git_commit").gitabra_commit()', opts) 290 | set_keymap('n', 'ca', 'lua require("gitabra.git_commit").gitabra_commit("amend")', opts) 291 | set_keymap('n', 'j', 'lua require("gitabra.git_status").next_line()', opts) 292 | set_keymap('n', 'k', 'lua require("gitabra.git_status").prev_line()', opts) 293 | end 294 | 295 | local status_buf_name = "gitabra:////gitabra_status" 296 | 297 | -- Looks through all available buffers and returns the gitabra status buffer if found 298 | -- This helps us recover the bufnr if it becomes lost. This usually happens when the 299 | -- module is reloaded and the `current_status_screen` gets lost. 300 | local function find_existing_status_buffer() 301 | for _, bufnr in ipairs(api.nvim_list_bufs()) do 302 | if api.nvim_buf_get_name(bufnr) == status_buf_name then 303 | return bufnr 304 | end 305 | end 306 | end 307 | 308 | local function find_window_for_buffer(bufnr) 309 | for _, winnr in ipairs(api.nvim_list_wins()) do 310 | if api.nvim_win_get_buf(winnr) == bufnr then 311 | return winnr 312 | end 313 | end 314 | end 315 | 316 | local function setup_buffer() 317 | local buf = vim.api.nvim_create_buf(false, true) 318 | api.nvim_buf_set_name(buf, status_buf_name) 319 | vim.bo[buf].swapfile = false 320 | vim.bo[buf].buftype = 'nofile' 321 | vim.bo[buf].filetype = 'GitabraStatus' 322 | vim.bo[buf].syntax = 'diff' 323 | vim.bo[buf].modifiable = false 324 | setup_keybinds(buf) 325 | return buf 326 | end 327 | 328 | local function setup_window_and_buffer() 329 | local buf = setup_buffer() 330 | local win = setup_window() 331 | api.nvim_set_current_buf(buf) 332 | return { 333 | winnr = win, 334 | bufnr = buf 335 | } 336 | end 337 | 338 | local current_status_screen 339 | 340 | local function setup_status_screen(sc) 341 | -- Create a new window if the old one is invalid 342 | if not (sc.bufnr and api.nvim_buf_is_valid(sc.bufnr)) then 343 | local bufnr = find_existing_status_buffer() 344 | if bufnr then 345 | sc.bufnr = bufnr 346 | else 347 | sc.bufnr = setup_buffer() 348 | end 349 | end 350 | 351 | -- Create a new buffer if the old one is invalid 352 | if not (sc.winnr and api.nvim_win_is_valid(sc.winnr)) then 353 | local winnr = find_window_for_buffer(sc.bufnr) 354 | if winnr then 355 | sc.winnr = winnr 356 | else 357 | sc.winnr = setup_window() 358 | end 359 | end 360 | 361 | api.nvim_set_current_win(sc.winnr) 362 | api.nvim_set_current_buf(sc.bufnr) 363 | return sc 364 | end 365 | 366 | local function get_sole_status_screen() 367 | local sc = current_status_screen or {} 368 | current_status_screen = setup_status_screen(sc) 369 | return current_status_screen 370 | end 371 | 372 | local function toggle_fold_at_current_line() 373 | ou.outline_toggle_fold_at_current_line(get_sole_status_screen().outline) 374 | end 375 | 376 | -- Assuming we have a zipper that's targetting a hunk, 377 | -- build a description to make it easier ask useful question with 378 | local function hunk_context(z) 379 | local hc = u.zipper_picks_by_type(z) 380 | 381 | -- The zipper may have pointed to a hunk header 382 | -- Since a hunk header should only have a single hunk content node attached, 383 | -- it's straightforward to fill that in now. 384 | -- The rest of the code will not have to deal with these cases. 385 | if hc[ou.type_hunk_content] == nil and hc[ou.type_hunk_header] then 386 | hc[ou.type_hunk_content] = hc[ou.type_hunk_header].children[1] 387 | end 388 | 389 | return hc 390 | end 391 | 392 | local function hc_target_full_filepath(hc) 393 | local file = hc[ou.type_file] 394 | if file then 395 | return string.format("%s/%s", u.git_root_dir(), file.filename) 396 | end 397 | end 398 | 399 | local function hc_target_rel_filepath(hc) 400 | local file = hc[ou.type_file] 401 | if file then 402 | return file.filename 403 | end 404 | end 405 | 406 | local function outline_zipper_at_current_line() 407 | local lineno = vim.fn.line(".") - 1 408 | local outline = get_sole_status_screen().outline 409 | if not outline then 410 | return 411 | end 412 | 413 | return outline:node_zipper_at_lineno(lineno) 414 | end 415 | 416 | local function any_win_except(win_avoid) 417 | local wins = api.nvim_list_wins() 418 | local target_win 419 | for _, win in ipairs(wins) do 420 | if win ~= win_avoid then 421 | target_win = win 422 | break 423 | end 424 | end 425 | return target_win 426 | end 427 | 428 | local function activate_any_win_except(win_avoid) 429 | local w = any_win_except(win_avoid) 430 | if w and api.nvim_win_is_valid(w) then 431 | api.nvim_set_current_win(w) 432 | return w 433 | end 434 | end 435 | 436 | local function activate_any_win_or_new(win_avoid, new_cmd) 437 | local w = activate_any_win_except(win_avoid) 438 | if not w then 439 | new_cmd = new_cmd or ":botright vsplit" 440 | w = vim.cmd(new_cmd) 441 | api.nvim_set_current_win(w) 442 | end 443 | end 444 | 445 | -- Jumps to the file and line of the hunk line under the cursor 446 | local function jump_to_location() 447 | local lineno = vim.fn.line(".") - 1 448 | local sc = get_sole_status_screen() 449 | local outline = sc.outline 450 | if not outline then 451 | return 452 | end 453 | 454 | local z = outline:node_zipper_at_lineno(lineno) 455 | if not z then 456 | return 457 | end 458 | 459 | local hc = hunk_context(z) 460 | 461 | -- Is the user targetting a specific line of a hunk? 462 | if hc[ou.type_hunk_content] then 463 | local hunk_start = patch_parser.parse_hunk_header(hc[ou.type_hunk_header].text[1])[2].start 464 | local rellineno = lineno - hc[ou.type_hunk_content].lineno + 1 465 | local line_type = ou.hunk_line_type(hc[ou.type_hunk_content].text[rellineno]) 466 | if line_type == "-" then 467 | line_type = "common" 468 | end 469 | local count = ou.hunk_lines_count_type(hc[ou.type_hunk_content].text, line_type, rellineno) 470 | activate_any_win_or_new(sc.winnr) 471 | vim.cmd(string.format("e +%i %s", hunk_start+count-1, hc_target_full_filepath(hc))) 472 | elseif hc[ou.type_hunk_header] then 473 | local hunk_start = patch_parser.parse_hunk_header(hc[ou.type_hunk_header].text[1])[2].start 474 | activate_any_win_or_new(sc.winnr) 475 | vim.cmd(string.format("e +%i %s", hunk_start, hc_target_full_filepath(hc))) 476 | elseif hc[ou.type_file] then 477 | activate_any_win_or_new(sc.winnr) 478 | vim.cmd(string.format("e %s", hc_target_full_filepath(hc))) 479 | elseif hc[ou.type_recent_commit] then 480 | local target_win = any_win_except(sc.winnr) 481 | 482 | require("gitabra.rev_buffer").show({ 483 | git_root = sc.git_root, 484 | winnr = target_win, 485 | rev = hc[ou.type_recent_commit].rev, 486 | }) 487 | 488 | end 489 | end 490 | 491 | local function make_recent_commit_node(log_entry) 492 | return { 493 | text = {format_log_recent_entry(log_entry)}, 494 | rev = log_entry.rev, 495 | type = ou.type_recent_commit, 496 | } 497 | end 498 | 499 | local function node_id(node) 500 | if node.id then 501 | return node.id 502 | elseif node.md5 then 503 | return node.md5 504 | else 505 | node.md5 = md5.sumhexa(table.concat(node.text, "\n")) 506 | return node.md5 507 | end 508 | end 509 | 510 | local function reconcile_outline(old_outline, new_outline) 511 | local q = require('gitabra.list').new() 512 | 513 | -- We're going to start a breadth first traversal that walks 514 | -- through the two trees at the same time. 515 | q:push_right({old_outline.root, new_outline.root}) 516 | 517 | while not q:is_empty() do 518 | -- print("iteration starts") 519 | local node_o, node_n = unpack(q:pop_left()) 520 | -- print("working on:", node_o, node_n) 521 | -- print("working on:", node_id(node_o), node_id(node_n)) 522 | 523 | if node_o.children or node_n.children then 524 | local cs_o = node_o.children or {} 525 | local cs_n = node_n.children or {} 526 | 527 | local diff = u.table_key_diff( 528 | u.table_array_items_by_id(cs_o, node_id), 529 | u.table_array_items_by_id(cs_n, node_id)) 530 | 531 | 532 | -- Since we have cs_n(children of this node in new tree), 533 | -- we know the exact order the nodes should be in. 534 | -- All we have to do is to build an array that appear to have the 535 | -- same order, but using existing old nodes whenever possible. 536 | -- This allows us to carry old states over. 537 | local cs_o_by_id = u.table_array_items_by_id(cs_o, node_id) 538 | local cs_n_by_id = u.table_array_items_by_id(cs_n, node_id) 539 | 540 | local children = {} 541 | for _, node in ipairs(cs_n) do 542 | table.insert(children, cs_o_by_id[node_id(node)] or node) 543 | end 544 | node_o.children = children 545 | 546 | -- Continue our breadth first traversal. 547 | -- Usually, we do this by adding all current children of this node 548 | -- into the queue of nodes to be visited. 549 | -- 550 | -- However, we do not need to visit newly added nodes, since they are 551 | -- nodes from the new tree that have been linked in directly. All child 552 | -- nodes stemming from there will be identical to content in the new tree. 553 | -- We also do not need to visit the removed nodes, since they are no longer 554 | -- part of the tree. 555 | -- 556 | -- So, to continue our bread first traversal, and reconcile the rest of the 557 | -- tree, we only need to visit the nodes appears to have not changed. 558 | 559 | for _, id in ipairs(diff.common) do 560 | -- print("adding to q:", id) 561 | -- print("adding nodes:", cs_o_by_id[id], cs_n_by_id[id]) 562 | q:push_right({cs_o_by_id[id], cs_n_by_id[id]}) 563 | end 564 | -- print("before:", vim.inspect(cs_o)) 565 | -- print("after:", vim.inspect(children)) 566 | end 567 | 568 | end 569 | 570 | return old_outline 571 | end 572 | 573 | local update_hunk_hint_hl 574 | 575 | local function gitabra_status() 576 | module_initialize() 577 | 578 | local st_info = status_info() 579 | local patches = patch_infos() 580 | 581 | local sc_o = get_sole_status_screen() 582 | local sc_n = { 583 | bufnr = sc_o.bufnr, 584 | winnr = sc_o.winnr 585 | } 586 | 587 | -------------------------------------------------------------------- 588 | local outline = outliner.new({buffer = sc_n.bufnr}) 589 | 590 | -- We're going to add a bunch nodes/content to the buffer. 591 | -- For each node, we're going to add some text and an extmark on it. 592 | -- The extmark will be used for us to: 593 | -- 1. Figure out where to insert new nodes and text 594 | -- 2. Figure out the fold level of each line 595 | -- 596 | -- We're disabling the folding here because of #2. If we don't 597 | -- disable folding here, nvim is going to immidately call `get_fold_level` 598 | -- as new lines are inserted. This will be before the extmark is setup, 599 | -- which means `get_fold_level` will not work properly. 600 | -- 601 | -- This means that each time we're adding new content to the outline, 602 | -- we should disable the folding and re-enable it after all the 603 | -- inserts are done. 604 | 605 | if st_info.branch and st_info.last_commit_msg then 606 | local header = u.markup({ 607 | "Head: ", 608 | {group = "GitabraBranch", 609 | text = st_info.branch}, 610 | st_info.last_commit_msg, 611 | }) 612 | 613 | outline:add_node(nil, {text = {header}}) 614 | end 615 | 616 | if not u.table_is_empty(st_info.untracked) then 617 | local section = outline:add_node(nil, { 618 | text = u.markup({{ 619 | group = "GitabraStatusSection", 620 | text = "Untracked" 621 | }}), 622 | type = ou.type_section, 623 | id = "untracked", 624 | padlines_before = 1, 625 | show_child_count = true, 626 | }) 627 | for _, file in pairs(st_info.untracked) do 628 | outline:add_node(section, ou.make_file_node(file.name)) 629 | end 630 | end 631 | 632 | if not u.table_is_empty(st_info.unstaged) then 633 | local section = outline:add_node(nil, { 634 | text = u.markup({{ 635 | group = "GitabraStatusSection", 636 | text = "Unstaged" 637 | }}), 638 | type = ou.type_section, 639 | id = "unstaged", 640 | padlines_before = 1, 641 | show_child_count = true, 642 | }) 643 | for _, file in pairs(st_info.unstaged) do 644 | local file_node = outline:add_node(section, ou.make_file_node(file.name, file.working)) 645 | ou.populate_hunks_by_filepath(outline, file_node, patches.unstaged, file.name) 646 | file_node.collapsed = true 647 | end 648 | end 649 | 650 | if not u.table_is_empty(st_info.staged) then 651 | local section = outline:add_node(nil, { 652 | text = u.markup({{ 653 | group = "GitabraStatusSection", 654 | text = "Staged" 655 | }}), 656 | type = ou.type_section, 657 | id = "staged", 658 | padlines_before = 1, 659 | show_child_count = true, 660 | }) 661 | for _, file in pairs(st_info.staged) do 662 | local file_node = outline:add_node(section, ou.make_file_node(file.name, file.index)) 663 | ou.populate_hunks_by_filepath(outline, file_node, patches.staged, file.name) 664 | file_node.collapsed = true 665 | end 666 | end 667 | 668 | if not u.table_is_empty(st_info.stash_list) then 669 | local section = outline:add_node(nil, { 670 | text = u.markup({{ 671 | group = "GitabraStatusSection", 672 | text = "Stashes" 673 | }}), 674 | type = ou.type_section, 675 | id = "stashes", 676 | padlines_before = 1, 677 | collapsed = true, 678 | show_child_count = true, 679 | }) 680 | 681 | for _, stash_entry_str in ipairs(st_info.stash_list) do 682 | outline:add_node(section, { 683 | text = format_stash_entry(parse_stash_entry(stash_entry_str)), 684 | type = ou.type_stash_entry, 685 | }) 686 | end 687 | end 688 | 689 | if not u.table_is_empty(st_info.recents) then 690 | local section = outline:add_node(nil, { 691 | text = u.markup({{ 692 | group = "GitabraStatusSection", 693 | text = "Recent commits" 694 | }}), 695 | type = ou.type_section, 696 | id = "recents", 697 | padlines_before = 1, 698 | collapsed = true, 699 | }) 700 | 701 | for _, log_entry_str in ipairs(st_info.recents) do 702 | outline:add_node(section, make_recent_commit_node(parse_log_recent_entry(log_entry_str))) 703 | end 704 | end 705 | 706 | -- Place the new outline into the global sc before any nodes & content are added. 707 | -- `get_fold_level` will be called by nvim as nodes are added to the outline. 708 | -- The global sc is the only way that function can find the currently active outline. 709 | sc_n.outline = outline 710 | sc_n.patches = patches 711 | sc_n.git_root = st_info.git_root 712 | -------------------------------------------------------------------- 713 | 714 | -- Looking at different git repo than last time? Use the new state 715 | local is_same_git_root = sc_o.git_root == sc_n.git_root 716 | if not is_same_git_root then 717 | current_status_screen = sc_n 718 | 719 | -- Is it the first time we're starting gitabra_status? 720 | -- The old state would have no outline it was displaying. 721 | elseif not sc_o.outline then 722 | current_status_screen = sc_n 723 | 724 | -- The user have presumably slightly updated the outline tree. 725 | -- Figure out what changed. 726 | else 727 | current_status_screen.outline = reconcile_outline(sc_o.outline, sc_n.outline) 728 | current_status_screen.patches = sc_n.patches 729 | end 730 | 731 | -- Retain the current lineno or move to the beginning of the buffer 732 | -- depending on if we're refreshing the outline or building a completely new one 733 | local lineno 734 | if is_same_git_root then 735 | lineno = vim.fn.line(".") 736 | else 737 | lineno = 0 738 | end 739 | 740 | current_status_screen.outline:refresh() 741 | 742 | vim.cmd(tostring(lineno)) 743 | 744 | update_hunk_hint_hl() 745 | end 746 | 747 | -- Given the contents of a hunk and the region that was selected (0 indexed), 748 | -- return relevant lines and line count info that can be used to build 749 | -- the hunk header and the hunk content 750 | local function partial_hunk(hunk_content, region, for_discard) 751 | local unmarked_lines = 0 752 | local removed_lines = 0 753 | local added_lines = 0 754 | local content = {} 755 | 756 | for i, line in ipairs(hunk_content) do 757 | i = i - 1 758 | local type = hunk_line_type(line) 759 | 760 | if u.within_region(region, i) then 761 | if type == "+" then 762 | added_lines = added_lines + 1 763 | table.insert(content, line) 764 | elseif type == "-" then 765 | removed_lines = removed_lines + 1 766 | table.insert(content, line) 767 | end 768 | else 769 | -- If we're trying to unstage or discard something, any lines 770 | -- that have been added will already be in the index or working tree. 771 | -- If those lines have not been selected for unstaging or discarding, 772 | -- then they need to become the context lines. Otherwise, applying 773 | -- a reverse patch would fail becaue the context lines seem incorrect. 774 | if for_discard and type == "+" then 775 | unmarked_lines = unmarked_lines + 1 776 | table.insert(content, " "..line:sub(2)) 777 | end 778 | end 779 | 780 | if type == "common" then 781 | unmarked_lines = unmarked_lines + 1 782 | table.insert(content, line) 783 | end 784 | end 785 | 786 | return { 787 | added = added_lines, 788 | removed = removed_lines, 789 | unmarked = unmarked_lines, 790 | content = content, 791 | } 792 | end 793 | 794 | local function in_visual_mode() 795 | if vim.fn.mode() == "V" then 796 | return true 797 | end 798 | end 799 | 800 | -- Using the given hunk_context and the currently selected lines, 801 | -- prepare a patch that can be used with git-apply. 802 | -- 803 | -- Patch generation will behave slightly different depending on 804 | -- the "direction". If we're trying to discard or unstage something 805 | -- AND have selected only part of the hunk, a little bit more work 806 | -- is required to get the correct context lines so the patch can 807 | -- be applied cleanly. See `partial_hunk` for details. 808 | -- 809 | local function patch_from_selected_hunk(hc, for_discard) 810 | local patches = get_sole_status_screen().patches 811 | local patch = patches[hc[ou.type_section].id] 812 | 813 | -- Generate a patch for the selected hunk 814 | -- To do that, we need a file diff header, the hunk header, and the hunk contents 815 | -- 816 | -- To get the file diff header, we're going to extract whatever was returned from the `git diff` 817 | -- result for this particular file. 818 | -- 819 | -- To get the hunk header and hunk content, we're going to grab both from the outline directly. 820 | 821 | local file_diff = patch_parser.find_file(patch.patch_info, hc_target_rel_filepath(hc)) 822 | local diff_header = patch_parser.file_diff_get_header_contents(file_diff, patch.patch_text) 823 | diff_header = u.remove_trailing_newlines(diff_header) 824 | 825 | local hunk_header = hc[ou.type_hunk_header].text[1] 826 | local hunk_content = hc[ou.type_hunk_content].text 827 | 828 | -- If the user has selected just part of the hunk, we need to 829 | -- make some adjustments to the header and the content 830 | if in_visual_mode() then 831 | local region = u.selected_region() 832 | local offset = hc[ou.type_hunk_content].lineno 833 | local result = partial_hunk(hc[ou.type_hunk_content].text, {region[1]-offset, region[2]-offset}, for_discard) 834 | local hh = patch_parser.parse_hunk_header(hc[ou.type_hunk_header].text[1]) 835 | hh[1].count = result.unmarked + result.removed 836 | hh[2].count = result.unmarked + result.added 837 | 838 | hunk_header = patch_parser.make_hunk_header(hh) 839 | hunk_content = result.content 840 | 841 | -- Exit visual mode for the user 842 | -- TODO: Move this somewhere else. This shouldn't be done in the middle of generating a patch 843 | local mode = vim.fn.mode() 844 | if mode == "v" or mode == "V" then 845 | api.nvim_input("") 846 | end 847 | end 848 | 849 | local lines = {diff_header, hunk_header} 850 | for _,v in ipairs(hunk_content) do 851 | table.insert(lines, v) 852 | end 853 | table.insert(lines, "") 854 | return table.concat(lines, "\n") 855 | end 856 | 857 | local function print_job_error(j) 858 | print(table.concat(j.err_output)) 859 | end 860 | 861 | local function stage_hunk(hc) 862 | local direction 863 | if hc[ou.type_section].id == "unstaged" then 864 | direction = "stage" 865 | else 866 | direction = "unstage" 867 | end 868 | 869 | local patch = patch_from_selected_hunk(hc, direction=="unstage") 870 | 871 | 872 | local j = git_apply_patch(direction, patch) 873 | u.system_job_wait(j, 100) 874 | 875 | if not u.table_is_empty(j.err_output) then 876 | print_job_error(j) 877 | print(string.format("%s failed", direction)) 878 | else 879 | -- The state of the hunks have changed 880 | -- Simply refreshing the outline will not reflect the new state 881 | -- We need to run `git diff` again and rebuild everything 882 | gitabra_status() 883 | end 884 | end 885 | 886 | local function stage_file(hc) 887 | local j = git_add(hc_target_rel_filepath(hc)) 888 | u.system_job_wait(j, 500) 889 | if not u.table_is_empty(j.err_output) then 890 | print_job_error(j) 891 | else 892 | gitabra_status() 893 | end 894 | end 895 | 896 | local function unstage_file(hc) 897 | local j = git_reset_file(hc_target_rel_filepath(hc)) 898 | u.system_job_wait(j, 500) 899 | if not u.table_is_empty(j.err_output) then 900 | print_job_error(j) 901 | else 902 | gitabra_status() 903 | end 904 | end 905 | 906 | -- Try to stage the item under the cursor 907 | local function stage() 908 | local z = outline_zipper_at_current_line() 909 | local node = z:node() 910 | local hc = hunk_context(z) 911 | 912 | -- Are we pointing at a hunk in the unstaged section? 913 | if (node.type == ou.type_hunk_content or node.type == ou.type_hunk_header) and hc[ou.type_section].id == "unstaged" then 914 | stage_hunk(hc) 915 | 916 | -- Are we pointing at a file in the untracked or unstaged section? 917 | elseif node.type == ou.type_file and (hc[ou.type_section].id == "untracked" or hc[ou.type_section].id == "unstaged") then 918 | stage_file(hc) 919 | end 920 | end 921 | 922 | local function unstage() 923 | local z = outline_zipper_at_current_line() 924 | local node = z:node() 925 | local hc = hunk_context(z) 926 | 927 | -- We're only going to deal with items in the staged section 928 | if hc[ou.type_section].id ~= "staged" then 929 | return 930 | end 931 | 932 | if node.type == ou.type_hunk_content or node.type == ou.type_hunk_header then 933 | stage_hunk(hc) 934 | elseif node.type == ou.type_file then 935 | unstage_file(hc) 936 | end 937 | end 938 | 939 | local function stage_all() 940 | local j = u.system('git add -A', {split_lines=true}) 941 | u.system_job_wait(j, 1000) 942 | if not u.table_is_empty(j.err_output) then 943 | print_job_error(j) 944 | else 945 | gitabra_status() 946 | end 947 | end 948 | 949 | local function unstage_all() 950 | local choice = vim.fn.confirm("Unstage all changes?", "y\nN", 2) 951 | if choice ~= 1 then 952 | return 953 | end 954 | 955 | local j = u.system('git reset', {split_lines=true}) 956 | u.system_job_wait(j, 1000) 957 | if not u.table_is_empty(j.err_output) then 958 | print_job_error(j) 959 | else 960 | gitabra_status() 961 | end 962 | end 963 | 964 | local function discard_hunk() 965 | local z = outline_zipper_at_current_line() 966 | local node = z:node() 967 | if not (node.type == ou.type_hunk_content or node.type == ou.type_hunk_header) then 968 | print("Oops... Don't know how to discard this yet...") 969 | return 970 | end 971 | 972 | local choice 973 | if in_visual_mode() then 974 | local region = u.selected_region() 975 | local count = region[2] - region[1] + 1 976 | choice = vim.fn.confirm(string.format("Really discard selected lines (%i)?", count), "y\nN", 2) 977 | else 978 | choice = vim.fn.confirm("Really discard this hunk?", "y\nN", 2) 979 | end 980 | if choice ~= 1 then 981 | return 982 | end 983 | 984 | local hc = hunk_context(z) 985 | local patch = patch_from_selected_hunk(hc, true) 986 | local include_staged = hc[ou.type_section].id == "staged" 987 | 988 | local j = git_discard_hunk(include_staged, patch) 989 | u.system_job_wait(j, 1000) 990 | if not u.table_is_empty(j.err_output) then 991 | vim.cmd("redraw | echom 'Discard failed'") 992 | else 993 | gitabra_status() 994 | end 995 | end 996 | 997 | local hunk_hint = { 998 | last_buf_changetick = nil, 999 | node_ids = {}, 1000 | lines = {}, 1001 | signs = {}, 1002 | } 1003 | 1004 | local hunk_hint_sign_group = "Hunk Hint" 1005 | 1006 | local function unplace_hunk_hints() 1007 | if hunk_hint.signs[1] or hunk_hint.signs[2] then 1008 | vim.fn.sign_unplace(hunk_hint_sign_group) 1009 | hunk_hint.signs[1] = nil 1010 | hunk_hint.signs[2] = nil 1011 | hunk_hint.lines[1] = nil 1012 | hunk_hint.lines[2] = nil 1013 | end 1014 | end 1015 | 1016 | local function update_hunk_hint_internal() 1017 | local sc = get_sole_status_screen() 1018 | -- If the buffer was not changed since the last time we updated the hunk hints, 1019 | -- then we can just check if the current line is within the known 1020 | if hunk_hint.last_buf_changetick == api.nvim_buf_get_changedtick(sc.bufnr) then 1021 | local lineno = u.nvim_line_zero_idx(".") 1022 | if hunk_hint.lines[1] and hunk_hint.lines[2] and 1023 | hunk_hint.lines[1] <= lineno and lineno < hunk_hint.lines[2] then 1024 | return 1025 | end 1026 | end 1027 | local z = outline_zipper_at_current_line() 1028 | if not z then 1029 | return "unplace" 1030 | end 1031 | 1032 | local hc = hunk_context(z:clone()) 1033 | if not hc[ou.type_section] then 1034 | return "unplace" 1035 | end 1036 | if hc[ou.type_section].id ~= "unstaged" and hc[ou.type_section].id ~= "staged" then 1037 | return "unplace" 1038 | end 1039 | 1040 | local n1 = hc[ou.type_hunk_header] 1041 | if not n1 then 1042 | return "unplace" 1043 | end 1044 | if n1.collapsed then 1045 | return "unplace" 1046 | end 1047 | 1048 | local n2 1049 | while z:node() ~= n1 and z:node() ~= z.root do 1050 | z:up() 1051 | end 1052 | 1053 | if z:right() then 1054 | n2 = z:node() 1055 | elseif z:next_up_right() then 1056 | n2 = z:node() 1057 | end 1058 | 1059 | if n1 and n2 then 1060 | if node_id(n1) ~= hunk_hint.node_ids[1] or node_id(n2) ~= hunk_hint.node_ids[2] then 1061 | unplace_hunk_hints() 1062 | local lineno1 = n1.lineno+1 1063 | local lineno2 = n2.lineno+1 1064 | if n2.type == ou.type_section then 1065 | lineno2 = lineno2 - n2.padlines_before 1066 | end 1067 | hunk_hint.node_ids[1] = n1.id 1068 | hunk_hint.node_ids[2] = n2.id 1069 | hunk_hint.signs[1] = vim.fn.sign_place(0, hunk_hint_sign_group, "GitabraNodeActiveHeader", sc.bufnr, {lnum = lineno1}) 1070 | hunk_hint.signs[2] = vim.fn.sign_place(0, hunk_hint_sign_group, "GitabraNodeActiveHeader", sc.bufnr, {lnum = lineno2}) 1071 | hunk_hint.lines[1] = n1.lineno 1072 | hunk_hint.lines[2] = n2.lineno 1073 | hunk_hint.last_buf_changetick = api.nvim_buf_get_changedtick(sc.bufnr) 1074 | end 1075 | else 1076 | if node_id(n1) ~= hunk_hint.node_ids[1] then 1077 | vim.fn.sign_unplace(hunk_hint_sign_group) 1078 | local lineno1 = n1.lineno+1 1079 | hunk_hint.node_ids[1] = n1.id 1080 | hunk_hint.signs[1] = vim.fn.sign_place(0, hunk_hint_sign_group, "GitabraNodeActiveHeader", sc.bufnr, {lnum = lineno1}) 1081 | hunk_hint.lines[1] = n1.lineno 1082 | hunk_hint.last_buf_changetick = api.nvim_buf_get_changedtick(sc.bufnr) 1083 | end 1084 | end 1085 | end 1086 | 1087 | update_hunk_hint_hl = function() 1088 | local result = update_hunk_hint_internal() 1089 | if result == "unplace" then 1090 | unplace_hunk_hints() 1091 | end 1092 | end 1093 | 1094 | local function next_line() 1095 | -- Move to the next line 1096 | local lineno = u.nvim_line_zero_idx(".")+1 1097 | if lineno >= api.nvim_buf_line_count(get_sole_status_screen().bufnr) then 1098 | return 1099 | end 1100 | vim.cmd(tostring(lineno+1)) 1101 | update_hunk_hint_hl() 1102 | end 1103 | 1104 | local function prev_line() 1105 | -- Move to the prev line 1106 | local lineno = u.nvim_line_zero_idx(".")-1 1107 | if lineno < 0 then 1108 | return 1109 | end 1110 | vim.cmd(tostring(lineno+1)) 1111 | update_hunk_hint_hl() 1112 | end 1113 | 1114 | return { 1115 | status_info = status_info, 1116 | patch_infos = patch_infos, 1117 | gitabra_status = gitabra_status, 1118 | setup_window_and_buffer = setup_window_and_buffer, 1119 | get_sole_status_screen = get_sole_status_screen, 1120 | toggle_fold_at_current_line = toggle_fold_at_current_line, 1121 | stage = stage, 1122 | unstage = unstage, 1123 | stage_all = stage_all, 1124 | unstage_all = unstage_all, 1125 | jump_to_location = jump_to_location, 1126 | discard_hunk = discard_hunk, 1127 | next_line = next_line, 1128 | prev_line = prev_line, 1129 | } 1130 | -------------------------------------------------------------------------------- /lua/gitabra/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local function require_or_nil(req_path) 4 | local status, result = pcall(require, req_path) 5 | if not status then 6 | print(result) 7 | return nil 8 | else 9 | return result 10 | end 11 | end 12 | 13 | local function export(export_table, export_targets) 14 | for _, item in ipairs(export_targets) do 15 | local module = require_or_nil(item[1]) 16 | if module then 17 | export_table[item[2]] = module[item[2]] 18 | end 19 | end 20 | end 21 | 22 | export(M, { 23 | {'gitabra.git_status', 'gitabra_status'}, 24 | {'gitabra.config', 'setup'} 25 | }) 26 | 27 | if vim.g.gitabra_dev == 1 then 28 | local plugin_name = 'gitabra' 29 | 30 | local function unload() 31 | local dir = plugin_name .. "/" 32 | local dot = plugin_name .. "." 33 | for key in pairs(package.loaded) do 34 | if (vim.startswith(key, dir) or vim.startswith(key, dot) or key == plugin_name) then 35 | package.loaded[key] = nil 36 | print("Unloaded: ", key) 37 | end 38 | end 39 | end 40 | 41 | local function reload() 42 | unload() 43 | require(plugin_name) 44 | end 45 | 46 | M.reload = reload 47 | vim.api.nvim_set_keymap('n', ',r', 'lua require("gitabra").reload()', {}) 48 | vim.api.nvim_set_keymap('n', ',gs', 'lua require("gitabra").gitabra_status()', {}) 49 | vim.api.nvim_set_keymap('n', ',gl', 'lua print(vim.inspect(package.loaded["gitabra"]))', {}) 50 | end 51 | 52 | return M 53 | -------------------------------------------------------------------------------- /lua/gitabra/job.lua: -------------------------------------------------------------------------------- 1 | -- Adapted from https://github.com/TravonteD/luajob 2 | local ut = require('gitabra.util.table') 3 | 4 | local M = {} 5 | M.__index = M 6 | 7 | local function shallow_copy(t) 8 | local t2 = {} 9 | for k,v in pairs(t) do 10 | t2[k] = v 11 | end 12 | return t2 13 | end 14 | 15 | local function close_safely(handle) 16 | if not handle:is_closing() then 17 | handle:close() 18 | end 19 | end 20 | 21 | local function wrap_ctx(ctx, callback) 22 | return function(err, data) 23 | callback(ctx, err, data) 24 | end 25 | end 26 | 27 | function M.new(o) 28 | setmetatable(o, M) 29 | return o 30 | end 31 | 32 | function M:send(data) 33 | self.stdin:write(data) 34 | self.stdin:shutdown() 35 | end 36 | 37 | function M:stop() 38 | close_safely(self.stdin) 39 | close_safely(self.stderr) 40 | close_safely(self.stdout) 41 | close_safely(self.handle) 42 | end 43 | 44 | function M:shutdown(code, signal) 45 | if self.on_exit then 46 | self:on_exit(code, signal) 47 | end 48 | if self.on_stdout then 49 | self.stdout:read_stop() 50 | end 51 | if self.on_stderr then 52 | self.stderr:read_stop() 53 | end 54 | self:stop() 55 | end 56 | 57 | function M:options() 58 | local options = {} 59 | 60 | self.stdin = vim.loop.new_pipe(false) 61 | self.stdout = vim.loop.new_pipe(false) 62 | self.stderr = vim.loop.new_pipe(false) 63 | 64 | local args 65 | if type(self.cmd) == "string" then 66 | args = vim.fn.split(self.cmd, ' ') 67 | else 68 | args = shallow_copy(self.cmd) 69 | end 70 | 71 | options.command = table.remove(args, 1) 72 | options.args = args 73 | 74 | options.stdio = { 75 | self.stdin, 76 | self.stdout, 77 | self.stderr 78 | } 79 | 80 | if self.opt then 81 | ut.table_copy_into(options, self.opt) 82 | end 83 | 84 | return options 85 | end 86 | 87 | function M:start() 88 | local options = self:options() 89 | self.handle = vim.loop.spawn(options.command, 90 | options, 91 | vim.schedule_wrap(wrap_ctx(self, self.shutdown))) 92 | if self.on_stdout then 93 | self.stdout:read_start(vim.schedule_wrap(wrap_ctx(self, self.on_stdout))) 94 | end 95 | if self.on_stderr then 96 | self.stderr:read_start(vim.schedule_wrap(wrap_ctx(self, self.on_stderr))) 97 | end 98 | end 99 | 100 | 101 | return M 102 | -------------------------------------------------------------------------------- /lua/gitabra/list.lua: -------------------------------------------------------------------------------- 1 | -- Taken from PIL: http://www.lua.org/pil/11.4.html 2 | List = {} 3 | List.__index = List 4 | 5 | function List.new() 6 | return setmetatable({first = 0, last = -1}, List) 7 | end 8 | 9 | function List.push_left(list, value) 10 | local first = list.first - 1 11 | list.first = first 12 | list[first] = value 13 | end 14 | 15 | function List.push_right(list, value) 16 | local last = list.last + 1 17 | list.last = last 18 | list[last] = value 19 | end 20 | 21 | function List.pop_left(list) 22 | local first = list.first 23 | if first > list.last then error("list is empty") end 24 | local value = list[first] 25 | list[first] = nil -- to allow garbage collection 26 | list.first = first + 1 27 | return value 28 | end 29 | 30 | function List.pop_right(list) 31 | local last = list.last 32 | if list.first > last then error("list is empty") end 33 | local value = list[last] 34 | list[last] = nil -- to allow garbage collection 35 | list.last = last - 1 36 | return value 37 | end 38 | 39 | function List.is_empty(list) 40 | if list.first - 1 == list.last then 41 | return true 42 | else 43 | return false 44 | end 45 | end 46 | 47 | return List 48 | -------------------------------------------------------------------------------- /lua/gitabra/md5.lua: -------------------------------------------------------------------------------- 1 | local md5 = { 2 | _VERSION = "md5.lua 1.1.0", 3 | _DESCRIPTION = "MD5 computation in Lua (5.1-3, LuaJIT)", 4 | _URL = "https://github.com/kikito/md5.lua", 5 | _LICENSE = [[ 6 | MIT LICENSE 7 | 8 | Copyright (c) 2013 Enrique García Cota + Adam Baldwin + hanzao + Equi 4 Software 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a 11 | copy of this software and associated documentation files (the 12 | "Software"), to deal in the Software without restriction, including 13 | without limitation the rights to use, copy, modify, merge, publish, 14 | distribute, sublicense, and/or sell copies of the Software, and to 15 | permit persons to whom the Software is furnished to do so, subject to 16 | the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included 19 | in all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 22 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 23 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 24 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 25 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 26 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 27 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 28 | ]] 29 | } 30 | 31 | -- bit lib implementions 32 | 33 | local char, byte, format, rep, sub = 34 | string.char, string.byte, string.format, string.rep, string.sub 35 | local bit_or, bit_and, bit_not, bit_xor, bit_rshift, bit_lshift 36 | 37 | local ok, bit = pcall(require, 'bit') 38 | if ok then 39 | bit_or, bit_and, bit_not, bit_xor, bit_rshift, bit_lshift = bit.bor, bit.band, bit.bnot, bit.bxor, bit.rshift, bit.lshift 40 | else 41 | ok, bit = pcall(require, 'bit32') 42 | 43 | if ok then 44 | 45 | bit_not = bit.bnot 46 | 47 | local tobit = function(n) 48 | return n <= 0x7fffffff and n or -(bit_not(n) + 1) 49 | end 50 | 51 | local normalize = function(f) 52 | return function(a,b) return tobit(f(tobit(a), tobit(b))) end 53 | end 54 | 55 | bit_or, bit_and, bit_xor = normalize(bit.bor), normalize(bit.band), normalize(bit.bxor) 56 | bit_rshift, bit_lshift = normalize(bit.rshift), normalize(bit.lshift) 57 | 58 | else 59 | 60 | local function tbl2number(tbl) 61 | local result = 0 62 | local power = 1 63 | for i = 1, #tbl do 64 | result = result + tbl[i] * power 65 | power = power * 2 66 | end 67 | return result 68 | end 69 | 70 | local function expand(t1, t2) 71 | local big, small = t1, t2 72 | if(#big < #small) then 73 | big, small = small, big 74 | end 75 | -- expand small 76 | for i = #small + 1, #big do 77 | small[i] = 0 78 | end 79 | end 80 | 81 | local to_bits -- needs to be declared before bit_not 82 | 83 | bit_not = function(n) 84 | local tbl = to_bits(n) 85 | local size = math.max(#tbl, 32) 86 | for i = 1, size do 87 | if(tbl[i] == 1) then 88 | tbl[i] = 0 89 | else 90 | tbl[i] = 1 91 | end 92 | end 93 | return tbl2number(tbl) 94 | end 95 | 96 | -- defined as local above 97 | to_bits = function (n) 98 | if(n < 0) then 99 | -- negative 100 | return to_bits(bit_not(math.abs(n)) + 1) 101 | end 102 | -- to bits table 103 | local tbl = {} 104 | local cnt = 1 105 | local last 106 | while n > 0 do 107 | last = n % 2 108 | tbl[cnt] = last 109 | n = (n-last)/2 110 | cnt = cnt + 1 111 | end 112 | 113 | return tbl 114 | end 115 | 116 | bit_or = function(m, n) 117 | local tbl_m = to_bits(m) 118 | local tbl_n = to_bits(n) 119 | expand(tbl_m, tbl_n) 120 | 121 | local tbl = {} 122 | for i = 1, #tbl_m do 123 | if(tbl_m[i]== 0 and tbl_n[i] == 0) then 124 | tbl[i] = 0 125 | else 126 | tbl[i] = 1 127 | end 128 | end 129 | 130 | return tbl2number(tbl) 131 | end 132 | 133 | bit_and = function(m, n) 134 | local tbl_m = to_bits(m) 135 | local tbl_n = to_bits(n) 136 | expand(tbl_m, tbl_n) 137 | 138 | local tbl = {} 139 | for i = 1, #tbl_m do 140 | if(tbl_m[i]== 0 or tbl_n[i] == 0) then 141 | tbl[i] = 0 142 | else 143 | tbl[i] = 1 144 | end 145 | end 146 | 147 | return tbl2number(tbl) 148 | end 149 | 150 | bit_xor = function(m, n) 151 | local tbl_m = to_bits(m) 152 | local tbl_n = to_bits(n) 153 | expand(tbl_m, tbl_n) 154 | 155 | local tbl = {} 156 | for i = 1, #tbl_m do 157 | if(tbl_m[i] ~= tbl_n[i]) then 158 | tbl[i] = 1 159 | else 160 | tbl[i] = 0 161 | end 162 | end 163 | 164 | return tbl2number(tbl) 165 | end 166 | 167 | bit_rshift = function(n, bits) 168 | local high_bit = 0 169 | if(n < 0) then 170 | -- negative 171 | n = bit_not(math.abs(n)) + 1 172 | high_bit = 0x80000000 173 | end 174 | 175 | local floor = math.floor 176 | 177 | for i=1, bits do 178 | n = n/2 179 | n = bit_or(floor(n), high_bit) 180 | end 181 | return floor(n) 182 | end 183 | 184 | bit_lshift = function(n, bits) 185 | if(n < 0) then 186 | -- negative 187 | n = bit_not(math.abs(n)) + 1 188 | end 189 | 190 | for i=1, bits do 191 | n = n*2 192 | end 193 | return bit_and(n, 0xFFFFFFFF) 194 | end 195 | end 196 | end 197 | 198 | -- convert little-endian 32-bit int to a 4-char string 199 | local function lei2str(i) 200 | local f=function (s) return char( bit_and( bit_rshift(i, s), 255)) end 201 | return f(0)..f(8)..f(16)..f(24) 202 | end 203 | 204 | -- convert raw string to big-endian int 205 | local function str2bei(s) 206 | local v=0 207 | for i=1, #s do 208 | v = v * 256 + byte(s, i) 209 | end 210 | return v 211 | end 212 | 213 | -- convert raw string to little-endian int 214 | local function str2lei(s) 215 | local v=0 216 | for i = #s,1,-1 do 217 | v = v*256 + byte(s, i) 218 | end 219 | return v 220 | end 221 | 222 | -- cut up a string in little-endian ints of given size 223 | local function cut_le_str(s,...) 224 | local o, r = 1, {} 225 | local args = {...} 226 | for i=1, #args do 227 | table.insert(r, str2lei(sub(s, o, o + args[i] - 1))) 228 | o = o + args[i] 229 | end 230 | return r 231 | end 232 | 233 | local swap = function (w) return str2bei(lei2str(w)) end 234 | 235 | -- An MD5 mplementation in Lua, requires bitlib (hacked to use LuaBit from above, ugh) 236 | -- 10/02/2001 jcw@equi4.com 237 | 238 | local CONSTS = { 239 | 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 240 | 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 241 | 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 242 | 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 243 | 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 244 | 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 245 | 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 246 | 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 247 | 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 248 | 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 249 | 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 250 | 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 251 | 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 252 | 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 253 | 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 254 | 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, 255 | 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476 256 | } 257 | 258 | local f=function (x,y,z) return bit_or(bit_and(x,y),bit_and(-x-1,z)) end 259 | local g=function (x,y,z) return bit_or(bit_and(x,z),bit_and(y,-z-1)) end 260 | local h=function (x,y,z) return bit_xor(x,bit_xor(y,z)) end 261 | local i=function (x,y,z) return bit_xor(y,bit_or(x,-z-1)) end 262 | local z=function (ff,a,b,c,d,x,s,ac) 263 | a=bit_and(a+ff(b,c,d)+x+ac,0xFFFFFFFF) 264 | -- be *very* careful that left shift does not cause rounding! 265 | return bit_or(bit_lshift(bit_and(a,bit_rshift(0xFFFFFFFF,s)),s),bit_rshift(a,32-s))+b 266 | end 267 | 268 | local function transform(A,B,C,D,X) 269 | local a,b,c,d=A,B,C,D 270 | local t=CONSTS 271 | 272 | a=z(f,a,b,c,d,X[ 0], 7,t[ 1]) 273 | d=z(f,d,a,b,c,X[ 1],12,t[ 2]) 274 | c=z(f,c,d,a,b,X[ 2],17,t[ 3]) 275 | b=z(f,b,c,d,a,X[ 3],22,t[ 4]) 276 | a=z(f,a,b,c,d,X[ 4], 7,t[ 5]) 277 | d=z(f,d,a,b,c,X[ 5],12,t[ 6]) 278 | c=z(f,c,d,a,b,X[ 6],17,t[ 7]) 279 | b=z(f,b,c,d,a,X[ 7],22,t[ 8]) 280 | a=z(f,a,b,c,d,X[ 8], 7,t[ 9]) 281 | d=z(f,d,a,b,c,X[ 9],12,t[10]) 282 | c=z(f,c,d,a,b,X[10],17,t[11]) 283 | b=z(f,b,c,d,a,X[11],22,t[12]) 284 | a=z(f,a,b,c,d,X[12], 7,t[13]) 285 | d=z(f,d,a,b,c,X[13],12,t[14]) 286 | c=z(f,c,d,a,b,X[14],17,t[15]) 287 | b=z(f,b,c,d,a,X[15],22,t[16]) 288 | 289 | a=z(g,a,b,c,d,X[ 1], 5,t[17]) 290 | d=z(g,d,a,b,c,X[ 6], 9,t[18]) 291 | c=z(g,c,d,a,b,X[11],14,t[19]) 292 | b=z(g,b,c,d,a,X[ 0],20,t[20]) 293 | a=z(g,a,b,c,d,X[ 5], 5,t[21]) 294 | d=z(g,d,a,b,c,X[10], 9,t[22]) 295 | c=z(g,c,d,a,b,X[15],14,t[23]) 296 | b=z(g,b,c,d,a,X[ 4],20,t[24]) 297 | a=z(g,a,b,c,d,X[ 9], 5,t[25]) 298 | d=z(g,d,a,b,c,X[14], 9,t[26]) 299 | c=z(g,c,d,a,b,X[ 3],14,t[27]) 300 | b=z(g,b,c,d,a,X[ 8],20,t[28]) 301 | a=z(g,a,b,c,d,X[13], 5,t[29]) 302 | d=z(g,d,a,b,c,X[ 2], 9,t[30]) 303 | c=z(g,c,d,a,b,X[ 7],14,t[31]) 304 | b=z(g,b,c,d,a,X[12],20,t[32]) 305 | 306 | a=z(h,a,b,c,d,X[ 5], 4,t[33]) 307 | d=z(h,d,a,b,c,X[ 8],11,t[34]) 308 | c=z(h,c,d,a,b,X[11],16,t[35]) 309 | b=z(h,b,c,d,a,X[14],23,t[36]) 310 | a=z(h,a,b,c,d,X[ 1], 4,t[37]) 311 | d=z(h,d,a,b,c,X[ 4],11,t[38]) 312 | c=z(h,c,d,a,b,X[ 7],16,t[39]) 313 | b=z(h,b,c,d,a,X[10],23,t[40]) 314 | a=z(h,a,b,c,d,X[13], 4,t[41]) 315 | d=z(h,d,a,b,c,X[ 0],11,t[42]) 316 | c=z(h,c,d,a,b,X[ 3],16,t[43]) 317 | b=z(h,b,c,d,a,X[ 6],23,t[44]) 318 | a=z(h,a,b,c,d,X[ 9], 4,t[45]) 319 | d=z(h,d,a,b,c,X[12],11,t[46]) 320 | c=z(h,c,d,a,b,X[15],16,t[47]) 321 | b=z(h,b,c,d,a,X[ 2],23,t[48]) 322 | 323 | a=z(i,a,b,c,d,X[ 0], 6,t[49]) 324 | d=z(i,d,a,b,c,X[ 7],10,t[50]) 325 | c=z(i,c,d,a,b,X[14],15,t[51]) 326 | b=z(i,b,c,d,a,X[ 5],21,t[52]) 327 | a=z(i,a,b,c,d,X[12], 6,t[53]) 328 | d=z(i,d,a,b,c,X[ 3],10,t[54]) 329 | c=z(i,c,d,a,b,X[10],15,t[55]) 330 | b=z(i,b,c,d,a,X[ 1],21,t[56]) 331 | a=z(i,a,b,c,d,X[ 8], 6,t[57]) 332 | d=z(i,d,a,b,c,X[15],10,t[58]) 333 | c=z(i,c,d,a,b,X[ 6],15,t[59]) 334 | b=z(i,b,c,d,a,X[13],21,t[60]) 335 | a=z(i,a,b,c,d,X[ 4], 6,t[61]) 336 | d=z(i,d,a,b,c,X[11],10,t[62]) 337 | c=z(i,c,d,a,b,X[ 2],15,t[63]) 338 | b=z(i,b,c,d,a,X[ 9],21,t[64]) 339 | 340 | return bit_and(A+a,0xFFFFFFFF),bit_and(B+b,0xFFFFFFFF), 341 | bit_and(C+c,0xFFFFFFFF),bit_and(D+d,0xFFFFFFFF) 342 | end 343 | 344 | ---------------------------------------------------------------- 345 | 346 | local function md5_update(self, s) 347 | self.pos = self.pos + #s 348 | s = self.buf .. s 349 | for ii = 1, #s - 63, 64 do 350 | local X = cut_le_str(sub(s,ii,ii+63),4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4) 351 | assert(#X == 16) 352 | X[0] = table.remove(X,1) -- zero based! 353 | self.a,self.b,self.c,self.d = transform(self.a,self.b,self.c,self.d,X) 354 | end 355 | self.buf = sub(s, math.floor(#s/64)*64 + 1, #s) 356 | return self 357 | end 358 | 359 | local function md5_finish(self) 360 | local msgLen = self.pos 361 | local padLen = 56 - msgLen % 64 362 | 363 | if msgLen % 64 > 56 then padLen = padLen + 64 end 364 | 365 | if padLen == 0 then padLen = 64 end 366 | 367 | local s = char(128) .. rep(char(0),padLen-1) .. lei2str(bit_and(8*msgLen, 0xFFFFFFFF)) .. lei2str(math.floor(msgLen/0x20000000)) 368 | md5_update(self, s) 369 | 370 | assert(self.pos % 64 == 0) 371 | return lei2str(self.a) .. lei2str(self.b) .. lei2str(self.c) .. lei2str(self.d) 372 | end 373 | 374 | ---------------------------------------------------------------- 375 | 376 | function md5.new() 377 | return { a = CONSTS[65], b = CONSTS[66], c = CONSTS[67], d = CONSTS[68], 378 | pos = 0, 379 | buf = '', 380 | update = md5_update, 381 | finish = md5_finish } 382 | end 383 | 384 | function md5.tohex(s) 385 | return format("%08x%08x%08x%08x", str2bei(sub(s, 1, 4)), str2bei(sub(s, 5, 8)), str2bei(sub(s, 9, 12)), str2bei(sub(s, 13, 16))) 386 | end 387 | 388 | function md5.sum(s) 389 | return md5.new():update(s):finish() 390 | end 391 | 392 | function md5.sumhexa(s) 393 | return md5.tohex(md5.sum(s)) 394 | end 395 | 396 | return md5 397 | -------------------------------------------------------------------------------- /lua/gitabra/outliner.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | local u = require('gitabra.util') 3 | local zipper = require('gitabra.zipper') 4 | local conf = require('gitabra.config') 5 | 6 | local M = {} 7 | M.__index = M 8 | 9 | local namespace_id = api.nvim_create_namespace("gitabra.outliner") 10 | local linemark_ns = api.nvim_create_namespace("gitabra.outliner/linemark") 11 | local highlight_ns = api.nvim_create_namespace("gitabra.outliner/highlight") 12 | M.namespace_id = namespace_id 13 | 14 | local disclosure_sign_group = "disclosure_sign_group" 15 | local collapsed_sign_name = "gitabra_outline_collapsed" 16 | local expanded_sign_name = "gitabra_outline_expanded" 17 | 18 | M.module_initialized = false 19 | local function module_initialize() 20 | if M.module_initialized then 21 | return 22 | end 23 | 24 | vim.fn.sign_define(collapsed_sign_name, {text = conf.config.disclosure_sign.collapsed}) 25 | vim.fn.sign_define(expanded_sign_name, {text = conf.config.disclosure_sign.expanded}) 26 | M.module_initialized = true 27 | end 28 | 29 | local function node_debug_text(node) 30 | local text = node.id or node.text 31 | if type(text) == "table" then 32 | text = vim.inspect(text) 33 | end 34 | if #text > 100 then 35 | text = string.sub(text, 1, 97).."..." 36 | end 37 | return text 38 | end 39 | 40 | local function print_cs(cs) 41 | for _, n in ipairs(cs) do 42 | print(node_debug_text(n)) 43 | end 44 | end 45 | 46 | local function debug_print(cond, ...) 47 | if cond then 48 | print(...) 49 | end 50 | end 51 | 52 | -- Given some `target_node` and its `parent_node`, determine 53 | -- which line the target_node's text should start on 54 | local function determine_node_lineno(self, parent_node, target_node) 55 | 56 | -- The root node has no text 57 | -- There is no line where it can start 58 | if target_node == self.root then 59 | assert(true, "target_node should not be root") 60 | return 0 61 | end 62 | 63 | -- print("Scanning all nodes that come before the target node") 64 | -- Look through the parent node and see if we can find a node that sits just 65 | -- before the target node, which has a valid linemark 66 | local last_row = 0 67 | local matched_node = nil 68 | 69 | -- Look for the last visible item in the tree that appears just before the 70 | -- target node. 71 | -- This will visit all sibling nodes and their descendents. 72 | -- It might be better if we implemented `prev` in the zipper. 73 | local z = zipper.new(parent_node, "children") 74 | local enable_print = false 75 | -- debug_print(enable_print, ">>>>> Laying out:", vim.inspect(z.path_idxs)) 76 | -- debug_print(enable_print, "layout target:", vim.inspect(target_node)) 77 | 78 | while not z:at_end() do 79 | local node = z:node() 80 | 81 | -- The document tree is already fully formed... 82 | -- We want to figure out where content that comes before 83 | -- the target node has been placed. 84 | -- If we come across the target_node, we're done 85 | if node == target_node then 86 | break 87 | end 88 | 89 | -- debug_print(enable_print, "-----------------------------------------------------------") 90 | -- debug_print(enable_print, "@", vim.inspect(z.path_idxs)) 91 | -- debug_print(enable_print, "@", vim.inspect(node)) 92 | 93 | if node.linemark ~= nil then 94 | -- print("located a node with linemark") 95 | local position = api.nvim_buf_get_extmark_by_id(self.buffer, linemark_ns, node.linemark, {}) 96 | 97 | if position[1] then 98 | -- if position[1] ~= node.lineno then 99 | -- debug_print(enable_print, "^^^^^^^^^^^^^^^^^^^^^^^^^^^^ linemark position and recorded lineno does not match!") 100 | -- end 101 | last_row = math.max(last_row, position[1]) 102 | matched_node = node 103 | debug_print(enable_print, "position for node is:", vim.inspect(position)) 104 | debug_print(enable_print, "** new last row:", last_row) 105 | else 106 | debug_print(enable_print, "linemark is invalid") 107 | end 108 | end 109 | 110 | -- if node.lineno ~= nil then 111 | -- -- print("located a node with linemark") 112 | -- last_row = math.max(last_row, node.lineno) 113 | -- matched_node = node 114 | -- debug_print(enable_print, "position for node is:", vim.inspect(node.lineno)) 115 | -- debug_print(enable_print, "** new last row:", last_row) 116 | -- end 117 | z:next() 118 | end 119 | 120 | if not matched_node then 121 | -- debug_print(enable_print, "no matched node, returning 0") 122 | return 0 123 | end 124 | 125 | -- local result = last_row + #matched_node.text 126 | local result = matched_node.lineno + #matched_node.text 127 | -- debug_print(enable_print, "result:", result, "last_row:", last_row, "#matched_node.text:", #matched_node.text) 128 | if target_node.padlines_before then 129 | result = result + target_node.padlines_before 130 | end 131 | -- debug_print(enable_print, "result:", result) 132 | -- debug_print(enable_print, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") 133 | return result 134 | end 135 | 136 | local function lineno_from_linemark(self, linemark) 137 | local position = api.nvim_buf_get_extmark_by_id(self.buffer, linemark_ns, linemark, {}) 138 | return position[1] 139 | end 140 | 141 | function M:lineno_from_node(node) 142 | if node.linemark then 143 | return lineno_from_linemark(node.linemark) 144 | end 145 | end 146 | 147 | function M.new(o_in) 148 | module_initialize() 149 | local o = o_in or {} 150 | o.root = o.root or {} 151 | o.root.id = "outliner-root" 152 | if o.root.depth == nil then 153 | o.root.depth = 0 154 | end 155 | o.root.children = o.root.children or {} 156 | setmetatable(o, M) 157 | return o 158 | end 159 | 160 | function M:node_zipper() 161 | return zipper.new(self.root, "children") 162 | end 163 | 164 | function node_text_has_markup(lines) 165 | for _, line in ipairs(lines) do 166 | if u.is_markup(line) then 167 | return true 168 | end 169 | end 170 | return false 171 | end 172 | 173 | 174 | -- Add a `child_node` into the `parent_node` 175 | -- Return the added child node 176 | function M:add_node(parent_node, child_node) 177 | -- Use the root node if a parent node is not specified 178 | if not parent_node then 179 | parent_node = self.root 180 | end 181 | 182 | if not child_node.children then 183 | child_node.children = {} 184 | end 185 | 186 | -- Add the child node 187 | -- the `text` field in a node indicates a array of lines 188 | -- Each line might also be a 'markup, which is itself an array 189 | -- For convenience, we'll allow the field to contain just a single 190 | -- line input. 191 | if type(child_node.text) == "string" then 192 | child_node.text = u.lines_array(child_node.text) 193 | elseif u.is_markup(child_node.text) then 194 | child_node.text = { child_node.text } 195 | end 196 | 197 | if node_text_has_markup(child_node.text) then 198 | child_node.has_markup = true 199 | 200 | local text_hls = u.table_copy_into({}, child_node.text) 201 | u.map(text_hls, u.markup_flatten, " ") 202 | local text_lines = {} 203 | for _, item in ipairs(text_hls) do 204 | table.insert(text_lines, u.remove_trailing_newlines(item.text)) 205 | end 206 | 207 | child_node.markup = child_node.text 208 | child_node.text = text_lines 209 | child_node.text_hls = text_hls 210 | end 211 | 212 | child_node.depth = parent_node.depth + 1 213 | table.insert(parent_node.children, child_node) 214 | 215 | return child_node 216 | end 217 | 218 | function delete_all_linemarks(self) 219 | api.nvim_buf_clear_namespace(self.buffer, linemark_ns, 0, -1) 220 | end 221 | 222 | function delete_all_disclosure_signs(self) 223 | vim.fn.sign_unplace(disclosure_sign_group, {buffer = self.buffer}) 224 | end 225 | 226 | function delete_all_text(self) 227 | api.nvim_buf_set_lines(self.buffer, 0, -1, true, {}) 228 | end 229 | 230 | -- Clean up node data that should be temporary. 231 | function cleanup_node_transients(self) 232 | for node in u.table_depth_first_visit(self.root) do 233 | if node.linemark then 234 | node.linemark = nil 235 | node.sign_id = nil 236 | end 237 | end 238 | end 239 | 240 | function M:refresh() 241 | local modifiable = vim.bo[self.buffer].modifiable 242 | if not modifiable then 243 | vim.bo[self.buffer].modifiable = true 244 | end 245 | 246 | cleanup_node_transients(self) 247 | delete_all_linemarks(self) 248 | delete_all_disclosure_signs(self) 249 | delete_all_text(self) 250 | -- We should also delete all highlights here 251 | -- Looks like the highlights are deleted along with the text though. 252 | 253 | -- Start a new zipper and move to the first child of the root node 254 | local z = self:node_zipper() 255 | z:down() 256 | 257 | -- print("About to start loop") 258 | while not z:at_end() do 259 | -- print("loop start") 260 | local node = z:node() 261 | local parent = z:parent_node() 262 | 263 | -- print("node:", vim.inspect(node)) 264 | -- print(">>>>>>>>>>>>>>>>>>>>>>> looking to place:", node_debug_text(node)) 265 | -- Determine where we should be placing the text 266 | local lineno = determine_node_lineno(self, parent, node) 267 | -- print("**** extmark & content] adding at line:", lineno) 268 | 269 | -- Place the text into the buffer at said location 270 | u.buf_padlines_to(self.buffer, lineno+#node.text) 271 | api.nvim_buf_set_lines(self.buffer, lineno, lineno+#node.text, true, node.text) 272 | 273 | if node.text_hls then 274 | for i, line in ipairs(node.text_hls) do 275 | for _, hl in ipairs(line.hl) do 276 | -- print(string.format("setting line %i [%i,%i] to %s", lineno+i-1, hl.start, hl.stop, hl.group)) 277 | api.nvim_buf_add_highlight(self.buffer, highlight_ns, hl.group, lineno+i-1, hl.start, hl.stop) 278 | end 279 | end 280 | end 281 | 282 | -- Add extmark at the same location 283 | node.linemark = api.nvim_buf_set_extmark(self.buffer, linemark_ns, lineno, 0, {}) 284 | node.lineno = lineno 285 | 286 | -- Place a sign depending on if there are child nodes or not 287 | local cs = z:children() 288 | if cs and not u.table_is_empty(cs) then 289 | local sign_name 290 | if node.collapsed then 291 | sign_name = collapsed_sign_name 292 | else 293 | sign_name = expanded_sign_name 294 | end 295 | node.sign_id = vim.fn.sign_place(0, disclosure_sign_group, sign_name, self.buffer, {lnum = lineno+1}) 296 | end 297 | 298 | if node.show_child_count and cs then 299 | api.nvim_buf_set_virtual_text(self.buffer, -1, lineno, {{string.format("(%i)", #cs)}}, {}) 300 | end 301 | 302 | -- If this node has been collapsed, move on to the next sibling branch 303 | if node.collapsed then 304 | -- print("moving right") 305 | if not z:right() then 306 | -- print("moving right failed, no more siblings") 307 | z:next_up_right() 308 | end 309 | else 310 | -- Otherwise, continue the depth-first traversal 311 | -- print("moving next") 312 | z:next() 313 | end 314 | end 315 | 316 | if not modifiable then 317 | vim.bo[self.buffer].modifiable = modifiable 318 | end 319 | end 320 | 321 | -- Returns a {start, end} tuple where the content of the node resides 322 | function M:region_occupied(node) 323 | local position = api.nvim_buf_get_extmark_by_id(self.buffer, linemark_ns, node.linemark, {}) 324 | if position[1] then 325 | return {position[1], position[1] + #node.text - 1} 326 | end 327 | end 328 | 329 | function M:node_zipper_at_lineno(lineno) 330 | local z = self:node_zipper() 331 | local cs = z:children() 332 | 333 | while cs and #cs ~= 0 do 334 | -- print("loop start") 335 | -- print("path so far:", vim.inspect(z.path_idxs)) 336 | -- print("cs @") 337 | -- print_cs(cs) 338 | 339 | -- Filter for children with linemarks 340 | cs = u.filter(cs, function(c) 341 | if c.linemark then 342 | return true 343 | end 344 | end) 345 | 346 | -- print("filtered @") 347 | -- print_cs(cs) 348 | 349 | -- Retrieve/update all starting lineno of children 350 | u.map(cs, function(c) 351 | c.lineno = lineno_from_linemark(self, c.linemark) 352 | return c 353 | end) 354 | 355 | -- print("mapped @") 356 | -- print_cs(cs) 357 | 358 | -- From the valid child candidates, pick the node that seems to contain the lineno 359 | -- we're searching for 360 | local candidate = nil 361 | if #cs == 1 then 362 | -- print("Picking only child") 363 | if lineno >= cs[1].lineno then 364 | candidate = cs[1] 365 | end 366 | else 367 | -- print("choosing best match from children") 368 | -- Using the starting line number for each sibling pair, 369 | -- try to figure out which node/path we should go down towards 370 | for c1, c2 in u.partition_iterator(cs, 2, 1) do 371 | -- print("c1", c1.lineno, node_debug_text(c1)) 372 | -- print("c2", c2.lineno, node_debug_text(c2)) 373 | 374 | if c1.lineno <= lineno and lineno < c2.lineno then 375 | -- print("Picking c1") 376 | candidate = c1 377 | break 378 | end 379 | end 380 | 381 | -- If we found no results after examining all pairs, we'll 382 | -- assume it's because the last node is the matching node 383 | if not candidate then 384 | -- print("Picking last child") 385 | candidate = cs[#cs] 386 | end 387 | end 388 | 389 | -- Can't drill down any further? 390 | -- Use the path we have so far as the result 391 | if not candidate then 392 | -- print("No available candidates this loop") 393 | break 394 | end 395 | 396 | -- print("navigating to child @", node_debug_text(candidate)) 397 | z:to_child_node(candidate) 398 | 399 | if candidate.lineno == lineno then 400 | break 401 | end 402 | cs = z:children() 403 | end 404 | 405 | -- print("No more child nodes") 406 | -- print("picked node:", node_debug_text(z:node())) 407 | 408 | -- At this point, the zipper should be pointing at a node 409 | -- that we think contains the target linenumber. 410 | if u.within_region(self:region_occupied(z:node()) , lineno) then 411 | -- print("is in region") 412 | return z 413 | else 414 | -- print("not within region") 415 | return nil 416 | end 417 | end 418 | 419 | function M:close() 420 | api.nvim_buf_delete(self.buffer, {}) 421 | end 422 | 423 | 424 | function M:find_node(pred) 425 | return u.table_find_node(self.root, pred) 426 | end 427 | 428 | function M:node_children(node) 429 | return node.children 430 | end 431 | 432 | function M:set_node_children(node, new_children) 433 | node.children = new_children 434 | end 435 | 436 | 437 | -- Walk the document/outline and find an item with 438 | -- the given `name` in the `id` field. 439 | function M:node_by_id(name) 440 | return u.table_find_node(self.root, function(node) 441 | if node.id == name then 442 | return true 443 | end 444 | end) 445 | end 446 | 447 | return M 448 | -------------------------------------------------------------------------------- /lua/gitabra/outliner_util.lua: -------------------------------------------------------------------------------- 1 | local u = require("gitabra.util") 2 | local patch_parser = require("gitabra.patch_parser") 3 | 4 | -- Node types 5 | local type_section = "section" 6 | local type_file = "file" 7 | local type_hunk_header = "hunk header" 8 | local type_hunk_content = "hunk content" 9 | local type_stash_entry = "stash entry" 10 | local type_recent_commit = "recent commit" 11 | 12 | local function make_file_node(filename, mod_type) 13 | local heading 14 | if mod_type then 15 | heading = string.format("%s %s", mod_type, filename) 16 | else 17 | heading = filename 18 | end 19 | return { 20 | text = u.markup({{ 21 | group = "GitabraStatusFile", 22 | text = heading 23 | }}), 24 | filename = filename, 25 | type = type_file, 26 | } 27 | end 28 | 29 | 30 | local function populate_hunks(outline, file_node, patch, entry) 31 | for _, hunk in ipairs(entry.hunks) do 32 | -- Add hunk header as its own node 33 | -- These look something like "@@ -16,10 +17,14 @@" 34 | local heading = outline:add_node(file_node, { 35 | text = hunk.header_text, 36 | type = type_hunk_header, 37 | }) 38 | 39 | -- Add the content of the hunk 40 | outline:add_node(heading, { 41 | text = string.sub(patch.patch_text, hunk.content_start, hunk.content_end), 42 | type = type_hunk_content, 43 | }) 44 | end 45 | end 46 | 47 | local function populate_hunks_by_filepath(outline, file_node, patch, filepath) 48 | local diff = patch_parser.find_file(patch.patch_info, filepath) 49 | if diff then 50 | return populate_hunks(outline, file_node, patch, diff) 51 | end 52 | end 53 | 54 | -- WARNING: depeds on currently active bufnr 55 | local function outline_toggle_fold_at_current_line(outline) 56 | if not outline then 57 | return 58 | end 59 | local lineno = vim.fn.line(".") - 1 60 | 61 | local z = outline:node_zipper_at_lineno(lineno) 62 | if not z then 63 | return 64 | end 65 | 66 | local node 67 | 68 | -- Which node do we actually want to collapse? 69 | -- If the user is targetting a leaf node, like the 70 | -- contents of a hunk, that node itself cannot be 71 | -- collapsed. Instead, we're going to collapse it's 72 | -- parent/header. 73 | if not z:has_children() then 74 | node = z:parent_node() 75 | if node == outline.root then 76 | return 77 | end 78 | else 79 | node = z:node() 80 | end 81 | node.collapsed = not node.collapsed 82 | 83 | -- Refresh the buffer 84 | outline:refresh() 85 | 86 | -- Move the cursor to whatever we've just collapsed 87 | -- All visible node's lineno should have been updated, 88 | -- so we can just use the contents of that field directly 89 | vim.cmd(tostring(node.lineno+1)) 90 | end 91 | 92 | 93 | -- Parse reference names, such as "origin/master" 94 | local function parse_ref(ref_str) 95 | ref_str = u.trim(ref_str) 96 | local result = {} 97 | 98 | local name = string.match(ref_str, "^HEAD %-> refs/heads/(.*)$") or 99 | string.match(ref_str, "^HEAD %-> (.*)$") 100 | 101 | if name then 102 | result.name = name 103 | result.current_branch = true 104 | return result 105 | end 106 | 107 | name = string.match(ref_str, "^refs/heads/(.*)$") 108 | if name then 109 | result.name = name 110 | result["local"] = true 111 | return result 112 | end 113 | 114 | local remote 115 | remote, name = string.match(ref_str, "^(.-)/(.*)$") 116 | if remote and name then 117 | result.name = name 118 | result.remote = remote 119 | return result 120 | end 121 | 122 | result.name = ref_str 123 | return result 124 | end 125 | 126 | local function parse_refs(refs_str) 127 | return u.map(u.string_split_by_pattern(refs_str, ","), parse_ref) 128 | end 129 | 130 | -- Format data returned from `parse_ref` into format that's usable by an outline 131 | local function format_ref(ref) 132 | local result = u.markup({ text = ref.name }) 133 | if ref.current_branch then 134 | result.group = "GitabraCurrentBranch" 135 | elseif ref.remote then 136 | result.group = "GitabraRemoteRef" 137 | result.text = string.format("%s/%s", ref.remote, ref.name) 138 | else 139 | result.group = "GitabraBranch" 140 | end 141 | return result 142 | end 143 | 144 | local function outline_zipper_at_current_line(outline) 145 | return outline:node_zipper_at_lineno(u.nvim_line_zero_idx(".")) 146 | end 147 | 148 | -- Return the type of hunk line we are looking at. 149 | -- Returns either "+", "-", or "common" 150 | local function hunk_line_type(line) 151 | local char = line:sub(1,1) 152 | if char == "+" then 153 | return "+" 154 | elseif char == "-" then 155 | return "-" 156 | else 157 | return "common" 158 | end 159 | end 160 | 161 | -- Given some hunk lines, count (up to line_limit) the number of 162 | -- lines relavant to a specific type 163 | local function hunk_lines_count_type(lines, line_type, line_limit) 164 | local count = 0 165 | if not line_limit then 166 | line_limit = #lines 167 | else 168 | line_limit = u.math_clamp(line_limit, 1, #lines) 169 | end 170 | 171 | for i=1, line_limit do 172 | local line = lines[i] 173 | local t = hunk_line_type(line) 174 | if (line_type == "+" or line_type == "common") and (t == "+" or t == "common") then 175 | count = count + 1 176 | elseif line_type == "-" and (t == "-" or t == "common") then 177 | count = count + 1 178 | end 179 | end 180 | 181 | return count 182 | end 183 | 184 | return { 185 | type_section = type_section, 186 | type_file = type_file, 187 | type_hunk_header = type_hunk_header, 188 | type_hunk_content = type_hunk_content, 189 | type_stash_entry = type_stash_entry, 190 | type_recent_commit = type_recent_commit, 191 | 192 | make_file_node = make_file_node, 193 | populate_hunks_by_filepath = populate_hunks_by_filepath, 194 | populate_hunks = populate_hunks, 195 | outline_toggle_fold_at_current_line = outline_toggle_fold_at_current_line, 196 | outline_zipper_at_current_line = outline_zipper_at_current_line, 197 | 198 | parse_refs = parse_refs, 199 | parse_ref = parse_ref, 200 | format_ref = format_ref, 201 | 202 | hunk_line_type = hunk_line_type, 203 | hunk_lines_count_type = hunk_lines_count_type, 204 | } 205 | -------------------------------------------------------------------------------- /lua/gitabra/patch_parser.lua: -------------------------------------------------------------------------------- 1 | local u = require("gitabra.util") 2 | local bit = require("bit") 3 | 4 | local function diff_iter(text, index) 5 | local s, e 6 | if index == 0 then 7 | s, e = text:find("^diff %-%-git a/.- b/.-\n", index+1) 8 | else 9 | s, e = text:find("\ndiff %-%-git a/.- b/.-\n", index+1) 10 | if s then 11 | s = s + 1 12 | end 13 | end 14 | return e, s, e 15 | end 16 | 17 | local function diff_indices(text_in) 18 | return diff_iter, text_in, 0 19 | end 20 | 21 | local function hunk_iter(text, index) 22 | -- A hunk header looks something like "@@ -1,5 +1,5 @@" 23 | -- But sometimes, it can look like "@@ -1 +1 @@" 24 | local s, e = text:find("\n@@ .- @@.-\n", index+1) 25 | 26 | -- Neither pattern matched? 27 | if s == nil then 28 | return e, s, e 29 | else 30 | return e, s+1, e 31 | end 32 | end 33 | 34 | local function hunk_indices(text) 35 | return hunk_iter, text, 0 36 | end 37 | 38 | local function strip_leading_signs(str) 39 | local s, e = string.find(str, "^[+-]*") 40 | if s then 41 | return string.sub(str, e+1) 42 | else 43 | return str 44 | end 45 | end 46 | 47 | local function parse_hunk_header(text) 48 | -- Split header by whitespace 49 | local tokens = u.string_split_by_pattern(text, "%s+") 50 | 51 | -- Tokens should look something like {"@@","-1,5","+1,5","@@"} 52 | assert(#tokens >= 4, vim.inspect(tokens)) 53 | assert(tokens[1] == "@@") 54 | assert(tokens[4] == "@@") 55 | 56 | local range1 = u.string_split_by_pattern(tokens[2], ",") 57 | local range2 = u.string_split_by_pattern(tokens[3], ",") 58 | 59 | range1[1] = strip_leading_signs(range1[1]) 60 | range2[1] = strip_leading_signs(range2[1]) 61 | u.map(range1, tonumber) 62 | u.map(range2, tonumber) 63 | 64 | return {{start=range1[1], count=range1[2]}, {start=range2[1], count=range2[2]}} 65 | end 66 | 67 | local function build_hunk_header_component(entry) 68 | if entry.count then 69 | return string.format("%i,%i", entry.start, entry.count) 70 | else 71 | return string.format("%i", entry.start) 72 | end 73 | end 74 | 75 | local function make_hunk_header(hh) 76 | local c1 = build_hunk_header_component(hh[1]) 77 | local c2 = build_hunk_header_component(hh[2]) 78 | return string.format("@@ -%s +%s @@", c1, c2) 79 | end 80 | 81 | local function file_diff_get_header_contents(file_diff, patch_text) 82 | local header_start = file_diff.header_start 83 | local header_end = file_diff.hunks[1].header_start-1 84 | return string.sub(patch_text, header_start, header_end) 85 | end 86 | 87 | -- Locates where file diffs and hunks and their starting positions 88 | local function patch_locate_headers(patch_text) 89 | local result = {} 90 | 91 | local text = patch_text 92 | local diff_fn = diff_indices(text) 93 | local hunk_fn = hunk_indices(text) 94 | 95 | local cur_diff 96 | local d_cursor = 0 97 | local d_start, d_end, d_next_start 98 | local h_cursor = 0 99 | local h_start, h_end 100 | 101 | local next_hunk = 1 102 | local next_diff = 2 103 | 104 | local process_entries 105 | process_entries = function (actions) 106 | 107 | -- Retrieve the next file diff if asked 108 | if bit.band(actions, next_diff) ~= 0 then 109 | 110 | d_cursor, d_start, d_end = diff_fn(text, d_cursor) 111 | if not d_cursor then 112 | result.error = "expected more diffs, but found none" 113 | return result 114 | end 115 | 116 | -- Accumulate into result, the newly found file diff 117 | cur_diff = { 118 | match_start = d_start, 119 | match_stop = d_end, 120 | hunks = {} 121 | } 122 | table.insert(result, cur_diff) 123 | 124 | -- Figure out where the next file diff starts 125 | d_cursor, d_next_start, _ = diff_fn(text, d_cursor) 126 | end 127 | 128 | -- Retrieve the next hunk if asked 129 | if bit.band(actions, next_hunk) ~= 0 then 130 | h_cursor, h_start, h_end = hunk_fn(text, h_cursor) 131 | if not h_start then 132 | return result 133 | end 134 | 135 | -- If the hunk seems to belong to the next file diff... 136 | -- Move on to the next file diff 137 | if d_next_start and h_start >= d_next_start then 138 | d_cursor = d_next_start -100 139 | return process_entries(next_diff) 140 | end 141 | end 142 | 143 | -- We've located a hunk that belongs to the current diff 144 | -- Attach the hunk 145 | local cur_hunk = { 146 | match_start = h_start, 147 | match_stop = h_end, 148 | text = string.sub(text, h_start, h_end) 149 | } 150 | table.insert(cur_diff.hunks, cur_hunk) 151 | 152 | -- Move on to the next hunk 153 | return process_entries(next_hunk) 154 | end 155 | 156 | return process_entries(next_diff + next_hunk) 157 | end 158 | 159 | -- Given the patch as text, return a collection that describes 160 | -- the contained file diffs and hunks. 161 | local function patch_info(patch_text) 162 | local text = patch_text 163 | if text == nil then 164 | return {} 165 | end 166 | 167 | local headers = patch_locate_headers(patch_text) 168 | local result = {} 169 | for i, diff_header in ipairs(headers) do 170 | local diff = { 171 | header_start = diff_header.match_start, 172 | content_start = diff_header.match_stop, 173 | hunks = {} 174 | } 175 | diff.a_file, diff.b_file = string.match(text, "diff %-%-git a/(.-) b/(.-)\n", diff_header.match_start) 176 | table.insert(result, diff) 177 | 178 | if i ~= #headers then 179 | diff.content_end = headers[i+1].match_start-1 180 | else 181 | diff.content_end = #patch_text 182 | end 183 | 184 | for j, hunk_header in ipairs(diff_header.hunks) do 185 | local hunk = { 186 | header_text = hunk_header.text, 187 | header_start = hunk_header.match_start, 188 | content_start = hunk_header.match_stop+1, 189 | } 190 | if j ~= #diff_header.hunks then 191 | hunk.content_end = diff_header.hunks[j+1].match_start-2 192 | else 193 | hunk.content_end = diff.content_end 194 | end 195 | table.insert(diff.hunks, hunk) 196 | -- hunk.text = string.sub(text, hunk.content_start, hunk.content_end) 197 | end 198 | end 199 | 200 | return result 201 | end 202 | 203 | local extended_headers_patterns = { 204 | old_mode = "old mode (.-)\n", 205 | new_mode = "new mode (.-)\n", 206 | deleted_file_mode = "deleted file mode (.-)\n", 207 | new_file_mode = "new file mode (.-)\n", 208 | copy_from = "copy from (.-)\n", 209 | copy_to = "copy to (.-)\n", 210 | rename_from = "rename from (.-)\n", 211 | rename_to = "rename to (.-)\n", 212 | similarity_index = "similarity index (.-)\n", 213 | dissimilarity_index = "dissimilarity index (.-)\n", 214 | } 215 | 216 | local function parse_extended_headers(ext_headers_text) 217 | local result = {} 218 | for name, pattern in pairs(extended_headers_patterns) do 219 | local capture = string.match(ext_headers_text, pattern) 220 | if capture then 221 | result[name] = capture 222 | end 223 | end 224 | 225 | local old_rev, new_rev, mode = string.match(ext_headers_text, "index (.-)%.%.(.-) (.-)\n") 226 | if old_rev and new_rev and mode then 227 | result.index = { 228 | old_rev = old_rev, 229 | new_rev = new_rev, 230 | mode = mode 231 | } 232 | end 233 | 234 | return result 235 | end 236 | 237 | local function parse(patch_text) 238 | local info = patch_info(patch_text) 239 | 240 | for _, diff in ipairs(info) do 241 | local ext_headers_text = string.sub(patch_text, diff.content_start, diff.hunks[1].header_start-1) 242 | local ext_headers = parse_extended_headers(ext_headers_text) 243 | diff.ext_headers = ext_headers 244 | end 245 | 246 | return { 247 | patch_info = info, 248 | patch_text = patch_text, 249 | } 250 | end 251 | 252 | -- Given the an output from `patch_info`, 253 | -- return a file diff entry that matches given `filepath` 254 | local function find_file(infos, filepath) 255 | for _, hunk in ipairs(infos) do 256 | if hunk.b_file == filepath then 257 | return hunk 258 | end 259 | end 260 | end 261 | 262 | local function find_hunk(file_diff, header_text) 263 | for _, hunk in ipairs(file_diff.hunks) do 264 | if hunk.header_text == header_text then 265 | return hunk 266 | end 267 | end 268 | end 269 | 270 | return { 271 | hunk_indices = hunk_indices, 272 | diff_indices = diff_indices, 273 | patch_info = patch_info, 274 | find_file = find_file, 275 | find_hunk = find_hunk, 276 | parse_hunk_header = parse_hunk_header, 277 | make_hunk_header = make_hunk_header, 278 | file_diff_get_header_contents = file_diff_get_header_contents, 279 | parse_extended_headers = parse_extended_headers, 280 | parse = parse, 281 | } 282 | -------------------------------------------------------------------------------- /lua/gitabra/promise.lua: -------------------------------------------------------------------------------- 1 | -- A promise is a point of coordination for a computation result that will be 2 | -- delivered some time in the future. We're not saying anything about how the 3 | -- computation is performed. This is closer to clojure's idea of a `promise`. 4 | -- 5 | -- In gitabra, this is used to help flatten out computation that may return 6 | -- some value at a future time. This can be used to help to wait on calls to 7 | -- `util.system` and other async computations in the same way. 8 | -- 9 | -- The idea is that after setting up any long running computation, we'd wait on 10 | -- one or more of them to be completed before proceeding with the rest of the 11 | -- program in a linear fashion. 12 | -- 13 | 14 | local Promise = {} 15 | Promise.__index = Promise 16 | 17 | function Promise.new(o) 18 | return setmetatable(o, Promise) 19 | end 20 | 21 | function Promise:deliver(v) 22 | if not self.realized then 23 | self.value = v 24 | self.realized = true 25 | end 26 | end 27 | 28 | function Promise:is_realized() 29 | return self.realized 30 | end 31 | 32 | function Promise:wait_for(ms, predicate) 33 | return vim.wait(ms, 34 | function() 35 | return predicate(self) 36 | end 37 | , 5) 38 | end 39 | 40 | -- Wait for a promise to be delivered 41 | function Promise:wait(ms) 42 | return vim.wait(ms, 43 | function() 44 | return self.realized 45 | end, 5) 46 | end 47 | 48 | -- Wait for multiple promises to be delivered 49 | function Promise.wait_all(promises, ms) 50 | return vim.wait(ms, 51 | function() 52 | for _, j in pairs(promises) do 53 | if not j.realized then 54 | return false 55 | end 56 | end 57 | return true 58 | end, 5) 59 | end 60 | 61 | return Promise 62 | -------------------------------------------------------------------------------- /lua/gitabra/rev_buffer.lua: -------------------------------------------------------------------------------- 1 | local u = require("gitabra.util") 2 | local a = require("gitabra.async") 3 | local outliner = require("gitabra.outliner") 4 | local ou = require("gitabra.outliner_util") 5 | local patch_parser = require("gitabra.patch_parser") 6 | local promise = require("gitabra.promise") 7 | local api = vim.api 8 | 9 | -- List of all file revisions being viewed. 10 | -- filepath => revision_context 11 | local active_rev_bufs = {} 12 | 13 | local function rev_buf_id(opts) 14 | return string.format("%s/%s", opts.git_root, opts.rev) 15 | end 16 | 17 | local function find_active_rev_buf(opts) 18 | assert(opts.git_root) 19 | assert(opts.rev) 20 | return active_rev_bufs[rev_buf_id(opts)] 21 | end 22 | 23 | local function get_current_rev_buf() 24 | assert(vim.b.rev_buf_id) 25 | return active_rev_bufs[vim.b.rev_buf_id] 26 | end 27 | 28 | local function remove_rev_buf_by_bufnr(bufnr) 29 | bufnr = tonumber(bufnr) 30 | local id = vim.api.nvim_buf_get_var(bufnr, "rev_buf_id") 31 | active_rev_bufs[id] = nil 32 | end 33 | 34 | local module_initialized = false 35 | local function module_initialize() 36 | if module_initialized then 37 | return 38 | end 39 | 40 | local attrs = u.hl_group_attrs("Yellow") 41 | attrs.gui = "bold" 42 | attrs.cterm = "bold" 43 | vim.cmd(string.format("highlight GitabraRevBufID %s", u.hl_group_attrs_to_str(attrs))) 44 | 45 | module_initialized = true 46 | end 47 | 48 | -- The only reason why we're running these jobs with the async library is to 49 | -- try to pull the locations where the data is requested closer to the place where 50 | -- data is parsed. 51 | -- 52 | -- The revision buffer seems to make a lot of calls to git to assemble all the information 53 | -- it wants to display. In the case of `git_commit_parent` it'll literally ask git for two strings. 54 | -- When the data is is returned, we're supposed to take the output via stdout, split by whitespace to 55 | -- retrieve the two values. 56 | -- 57 | -- It makes the code much more readable if the code for fetching and parsing were placed side-by-side. 58 | -- 59 | 60 | local function commit_summary(commit_summary_lines) 61 | local header = u.table_slice(commit_summary_lines, 1, 5) 62 | local stat_summary = u.trim(u.table_get_last(commit_summary_lines)) 63 | local stat_entry_count = string.match(stat_summary, "(%d+) file") 64 | local stat_start = stat_entry_count * -1 - 1 65 | local stat_details = u.map(u.table_slice(commit_summary_lines, stat_start, -2), u.trim) 66 | 67 | return { 68 | commit_id = u.string_split_by_pattern(u.table_first(header), " ")[2], 69 | header = u.table_rest(header), 70 | stat_summary = stat_summary, 71 | stat_details = stat_details, 72 | } 73 | end 74 | 75 | local function task_git_commit_summary(rev) 76 | return a.sync(function () 77 | local j = a.wait(u.system_async({"git", "show", "--pretty=fuller", "--stat", rev}, {split_lines=true})) 78 | return commit_summary(j.output) 79 | end) 80 | end 81 | 82 | local function git_commit_patch(rev) 83 | return u.system_as_promise({"git", "show", "--format=", rev}, {merge_output=true}) 84 | end 85 | 86 | local function task_git_commit_parent(rev) 87 | return a.sync(function () 88 | local j = a.wait(u.system_async({"git", "rev-list", "-1", "--parents", rev}, {split_lines=true})) 89 | return u.string_split_by_pattern(j.output[1], " ")[2] 90 | end) 91 | end 92 | 93 | local function git_show_with_format(format, rev) 94 | return u.system_as_promise({"git", "show", string.format("--format=%s", format), "--no-patch", "--decorate=short", rev}, {split_lines=true}) 95 | end 96 | 97 | local function git_commit_msg(rev) 98 | return git_show_with_format("%B", rev) 99 | end 100 | 101 | local function git_ref_labels(rev) 102 | return git_show_with_format("%D", rev) 103 | end 104 | 105 | local function toggle_fold_at_current_line() 106 | local rev_buf = get_current_rev_buf() 107 | if rev_buf then 108 | ou.outline_toggle_fold_at_current_line(rev_buf.outline) 109 | end 110 | end 111 | 112 | local function jump_to_location() 113 | local rev_buf = get_current_rev_buf() 114 | if not rev_buf then 115 | return 116 | end 117 | 118 | local lineno = vim.fn.line(".") - 1 119 | local z = ou.outline_zipper_at_current_line(rev_buf.outline) 120 | local picks = u.zipper_picks_by_type(z) 121 | 122 | local file_buf = require("gitabra.rev_file_buffer") 123 | if picks[ou.type_hunk_content] then 124 | local rellineno = lineno - picks[ou.type_hunk_content].lineno + 1 125 | local line_type = ou.hunk_line_type(picks[ou.type_hunk_content].text[rellineno]) 126 | local opts = { 127 | git_root = rev_buf.git_root, 128 | commit_rev = u.git_shorten_sha(rev_buf.rev), 129 | filename = picks[ou.type_file].filename, 130 | } 131 | if line_type == "-" then 132 | opts.rev = picks[ou.type_file].old_rev 133 | opts.commit_rev = opts.commit_rev.."^" 134 | else 135 | opts.rev = picks[ou.type_file].new_rev 136 | end 137 | file_buf.show(opts) 138 | 139 | local hunk_start = patch_parser.parse_hunk_header(picks[ou.type_hunk_header].text[1])[2].start 140 | local count = ou.hunk_lines_count_type(picks[ou.type_hunk_content].text, line_type, rellineno) 141 | vim.cmd(tostring(hunk_start+count-1)) 142 | u.nvim_center_current_line() 143 | 144 | elseif picks[ou.type_hunk_header] then 145 | file_buf.show({ 146 | git_root = rev_buf.git_root, 147 | commit_rev = rev_buf.rev, 148 | rev = picks[ou.type_file].new_rev, 149 | filename = picks[ou.type_file].filename, 150 | }) 151 | 152 | local hunk_start = patch_parser.parse_hunk_header(picks[ou.type_hunk_header].text[1])[2].start 153 | vim.cmd(tostring(hunk_start)) 154 | u.nvim_center_current_line() 155 | elseif picks[ou.type_file] then 156 | file_buf.show({ 157 | git_root = rev_buf.git_root, 158 | commit_rev = rev_buf.rev, 159 | rev = picks[ou.type_file].new_rev, 160 | filename = picks[ou.type_file].filename, 161 | }) 162 | end 163 | 164 | end 165 | 166 | local function close_rev_buf(skip_buf_delete) 167 | local rev_buf = get_current_rev_buf() 168 | if rev_buf then 169 | active_rev_bufs[rev_buf.id] = nil 170 | 171 | -- When the revision buffer was created, it probably kicked another buffer 172 | -- out of the current window. 173 | -- Try to activate that buffer if possible 174 | if rev_buf and rev_buf.old_bufnr and api.nvim_buf_is_valid(rev_buf.old_bufnr) then 175 | api.nvim_set_current_buf(rev_buf.old_bufnr) 176 | end 177 | 178 | if not skip_buf_delete then 179 | api.nvim_buf_delete(rev_buf.bufnr, {}) 180 | end 181 | end 182 | end 183 | 184 | local function setup_keybinds(bufnr) 185 | local function set_keymap(...) vim.api.nvim_buf_set_keymap(bufnr, ...) end 186 | local opts = { noremap=true, silent=true } 187 | set_keymap('n', '', 'lua require("gitabra.rev_buffer").toggle_fold_at_current_line()', opts) 188 | set_keymap('n', '', 'lua require("gitabra.rev_buffer").jump_to_location()', opts) 189 | set_keymap('n', 'q', 'lua require("gitabra.rev_buffer").close_rev_buf()', opts) 190 | end 191 | 192 | local function setup_buffer() 193 | local buf = vim.api.nvim_create_buf(false, true) 194 | vim.bo[buf].filetype = 'GitabraRevision' 195 | vim.bo[buf].swapfile = false 196 | vim.bo[buf].buftype = 'nofile' 197 | vim.bo[buf].syntax = 'diff' 198 | vim.bo[buf].modifiable = false 199 | setup_keybinds(buf) 200 | return buf 201 | end 202 | 203 | local function gather_info(rev) 204 | local commit_summary_p = a.as_promise(task_git_commit_summary(rev)) 205 | local commit_patch_p = git_commit_patch(rev) 206 | local ref_labels_p = git_ref_labels(rev) 207 | local msg_p = git_commit_msg(rev) 208 | local ps = {commit_summary_p, commit_patch_p, ref_labels_p, msg_p} 209 | 210 | local wait_result = promise.wait_all(ps, 2000) 211 | if not wait_result then 212 | local funcname = debug.getinfo(1, "n").name 213 | print(vim.inspect(ps)) 214 | error(string.format("%s: unable to complete git commands within the alotted time", funcname)) 215 | end 216 | 217 | return { 218 | summary = commit_summary_p.value, 219 | patch = commit_patch_p.value[1], 220 | ref_labels = ref_labels_p.value[1], 221 | msg = msg_p.value, 222 | } 223 | end 224 | 225 | local function setup_window() 226 | vim.cmd(":botright vsplit") 227 | return api.nvim_get_current_win() 228 | end 229 | 230 | local function rev_buf_activate(rev_buf) 231 | if api.nvim_win_is_valid(rev_buf.winnr) then 232 | api.nvim_set_current_win(rev_buf.winnr) 233 | end 234 | rev_buf.old_bufnr = api.nvim_get_current_buf() 235 | api.nvim_set_current_buf(rev_buf.bufnr) 236 | rev_buf.outline:refresh() 237 | end 238 | 239 | local function show_inner(opts) 240 | module_initialize() 241 | 242 | local existing_rev_buf = find_active_rev_buf(opts) 243 | if existing_rev_buf then 244 | rev_buf_activate(existing_rev_buf) 245 | return 246 | end 247 | 248 | local rev_buf = u.table_clone(opts) 249 | 250 | if not rev_buf.winnr then 251 | rev_buf.winnr = setup_window() 252 | end 253 | 254 | if not rev_buf.bufnr then 255 | rev_buf.bufnr = setup_buffer() 256 | end 257 | 258 | local rev_info = gather_info(opts.rev) 259 | local patch = patch_parser.parse(rev_info.patch) 260 | 261 | local outline = outliner.new({buffer = rev_buf.bufnr}) 262 | outline.type = "RevBuffer" 263 | rev_buf.outline = outline 264 | 265 | ---------------------------------------------------------------- 266 | -- Commit ID 267 | outline:add_node(nil, { 268 | text = u.markup({{ 269 | group = "GitabraRevBufID", 270 | text = string.format("commit %s", u.git_shorten_sha(rev_info.summary.commit_id)), 271 | }}), 272 | type = ou.type_section, 273 | id = "RevBufID", 274 | } 275 | ) 276 | 277 | ---------------------------------------------------------------- 278 | -- Author info 279 | local rev_text = u.markup({}) 280 | if rev_info.ref_labels then 281 | u.table_copy_into(rev_text, u.map(ou.parse_refs(rev_info.ref_labels), ou.format_ref)) 282 | end 283 | table.insert(rev_text, { 284 | group = "GitabraRev", 285 | text = rev_info.summary.commit_id, 286 | }) 287 | local commit_header_node = outline:add_node(nil, { 288 | text = rev_text, 289 | type = ou.type_section, 290 | id = "CommitHeader", 291 | } 292 | ) 293 | outline:add_node(commit_header_node, { 294 | text = u.table_copy_into({}, 295 | rev_info.summary.header 296 | ) 297 | }) 298 | 299 | ---------------------------------------------------------------- 300 | -- Commit message 301 | local msg_subject = u.table_first(rev_info.msg) 302 | local msg_body = u.table_rest(rev_info.msg) 303 | 304 | if msg_subject then 305 | local msg_node = outline:add_node(nil, { 306 | text = msg_subject, 307 | type = "CommitMessage", 308 | padlines_before = 1, 309 | }) 310 | 311 | if not u.table_is_empty(msg_body) then 312 | outline:add_node(msg_node, { 313 | text = msg_body 314 | }) 315 | end 316 | end 317 | 318 | ---------------------------------------------------------------- 319 | -- Commit stats 320 | local stat_node = outline:add_node(nil, { 321 | text = rev_info.summary.stat_summary, 322 | type = "CommitStat", 323 | padlines_before = 1, 324 | }) 325 | 326 | if not u.table_is_empty(rev_info.summary.stat_details) then 327 | outline:add_node(stat_node, { 328 | text = rev_info.summary.stat_details, 329 | }) 330 | end 331 | 332 | ---------------------------------------------------------------- 333 | -- Commit diff 334 | if not u.table_is_empty(patch.patch_info) then 335 | for _, entry in pairs(patch.patch_info) do 336 | local file_node = outline:add_node(nil, ou.make_file_node(entry.b_file)) 337 | file_node.old_rev = entry.ext_headers.index.old_rev 338 | file_node.new_rev = entry.ext_headers.index.new_rev 339 | file_node.padlines_before = 1 340 | ou.populate_hunks(outline, file_node, patch, entry) 341 | end 342 | end 343 | 344 | local id = rev_buf_id(opts) 345 | rev_buf.id = id 346 | active_rev_bufs[rev_buf.id] = rev_buf 347 | api.nvim_buf_set_name(rev_buf.bufnr, rev_buf.id) 348 | rev_buf_activate(rev_buf) 349 | 350 | -- The vim buffer is now "current". 351 | -- Attach the buffer id 352 | vim.b.rev_buf_id = rev_buf.id 353 | 354 | -- Attach autocmd to cleanup this rev_buf when it is closed 355 | -- While we do provide a command to close the rev buffer, the user may also try to close 356 | -- the buffer with other vim commands. 357 | u.nvim_commands([[ 358 | augroup CleanupRevBuffer 359 | autocmd! * 360 | autocmd BufUnload lua require('gitabra.rev_buffer').remove_rev_buf_by_bufnr(vim.fn.expand("")) 361 | augroup END 362 | ]], true) 363 | end 364 | 365 | local function show(opts) 366 | local ok, res = xpcall(show_inner, debug.traceback, opts) 367 | if not ok then 368 | print(res) 369 | end 370 | end 371 | 372 | return { 373 | show = show, 374 | toggle_fold_at_current_line = toggle_fold_at_current_line, 375 | jump_to_location = jump_to_location, 376 | close_rev_buf = close_rev_buf, 377 | active_rev_bufs = active_rev_bufs, 378 | remove_rev_buf_by_bufnr = remove_rev_buf_by_bufnr, 379 | } 380 | -------------------------------------------------------------------------------- /lua/gitabra/rev_file_buffer.lua: -------------------------------------------------------------------------------- 1 | local u = require("gitabra.util") 2 | local promise = require("gitabra.promise") 3 | local api = vim.api 4 | 5 | local active_bufs = {} 6 | 7 | local function buf_id(opts) 8 | return string.format("%s/%s~%s~", opts.git_root, opts.filename, opts.rev) 9 | end 10 | 11 | local function get_current_rev_file_buf() 12 | assert(vim.b.rev_file_buf_id) 13 | return active_bufs[vim.b.rev_file_buf_id] 14 | end 15 | 16 | local function remove_rev_file_buf_by_bufnr(bufnr) 17 | bufnr = tonumber(bufnr) 18 | local id = vim.api.nvim_buf_get_var(bufnr, "rev_file_buf_id") 19 | active_bufs[id] = nil 20 | end 21 | 22 | local function git_file_from_rev(rev, filename) 23 | print(vim.inspect( {"git", "show", rev, filename} )) 24 | return u.system_as_promise({"git", "show", rev, filename}, {split_lines=true}) 25 | end 26 | 27 | local function close_buf(skip_buf_delete) 28 | local rev_buf = get_current_rev_file_buf() 29 | if rev_buf then 30 | active_bufs[rev_buf.id] = nil 31 | 32 | if not skip_buf_delete then 33 | api.nvim_buf_delete(rev_buf.bufnr, {}) 34 | end 35 | end 36 | end 37 | 38 | local function setup_keybinds(bufnr) 39 | local function set_keymap(...) vim.api.nvim_buf_set_keymap(bufnr, ...) end 40 | local opts = { noremap=true, silent=true } 41 | set_keymap('n', 'q', 'lua require("gitabra.rev_file_buffer").close_buf()', opts) 42 | end 43 | 44 | local function setup_buffer() 45 | local buf = vim.api.nvim_create_buf(false, true) 46 | vim.bo[buf].swapfile = false 47 | vim.bo[buf].buftype = 'nofile' 48 | setup_keybinds(buf) 49 | return buf 50 | end 51 | 52 | local function show(opts) 53 | local id = buf_id(opts) 54 | local existing_buf = active_bufs[id] 55 | if existing_buf then 56 | api.nvim_set_current_buf(existing_buf.bufnr) 57 | return existing_buf 58 | end 59 | 60 | -- Get the contents of the file 61 | local file_content_p = git_file_from_rev(opts.rev, opts.filename) 62 | local wait_result = promise.wait(file_content_p, 2000) 63 | if not wait_result then 64 | local funcname = debug.getinfo(1, "n").name 65 | print(vim.inspect(file_content_p)) 66 | error(string.format("%s: unable to complete git commands within the alotted time", funcname)) 67 | end 68 | 69 | local buf = u.table_copy_into({}, opts, { 70 | id = id, 71 | bufnr = setup_buffer() 72 | }) 73 | api.nvim_set_current_buf(buf.bufnr) 74 | 75 | api.nvim_buf_set_lines(buf.bufnr, 0, #file_content_p.value+1, false, file_content_p.value) 76 | 77 | vim.b.rev_file_buf_id = buf.id 78 | 79 | active_bufs[buf.id] = buf 80 | vim.bo[buf.bufnr].modifiable = false 81 | 82 | -- Ask vim to detect the file type 83 | -- We want to: 84 | -- 1) Have a proper file name for vim to work with, but 85 | -- 2) Not to get it confused with another buffer with the same name 86 | -- 3) Assign some arbitrary filename after filetype detection is completed 87 | api.nvim_buf_set_name(buf.bufnr, "gitabra_temp://"..buf.filename) 88 | vim.cmd("filetype detect") 89 | api.nvim_buf_set_name(buf.bufnr, string.format("%s.~%s~", buf.filename, buf.commit_rev)) 90 | 91 | u.nvim_commands([[ 92 | augroup CleanupRevFileBuffer 93 | autocmd! * 94 | autocmd BufUnload lua require('gitabra.rev_file_buffer').remove_rev_file_buf_by_bufnr(vim.fn.expand("")) 95 | augroup END 96 | ]], true) 97 | 98 | return buf 99 | end 100 | 101 | return { 102 | show = u.wrap__call_or_print_stacktrace(show), 103 | close_buf = close_buf, 104 | remove_rev_file_buf_by_bufnr = remove_rev_file_buf_by_bufnr, 105 | } 106 | 107 | -------------------------------------------------------------------------------- /lua/gitabra/util/color.lua: -------------------------------------------------------------------------------- 1 | -- Ported from https://css-tricks.com/converting-color-spaces-in-javascript/ 2 | 3 | local bit = require("bit") 4 | 5 | local function RGB_to_Hex(r, g, b) 6 | return string.format("#%02x%02x%02x", r, g, b) 7 | end 8 | 9 | local function Hex_to_RGB(hex) 10 | local r = tonumber(string.sub(hex, 2, 3), 16) 11 | local g = tonumber(string.sub(hex, 4, 5), 16) 12 | local b = tonumber(string.sub(hex, 6, 7), 16) 13 | 14 | return r, g, b 15 | end 16 | 17 | local function Bin_to_RGB(color) 18 | local b = bit.band(color, 255) 19 | local g = bit.band(bit.rshift(color, 8), 255) 20 | local r = bit.band(bit.rshift(color, 16), 255) 21 | 22 | return r, g, b 23 | end 24 | 25 | local function RGB_to_Bin(r, g, b) 26 | return bit.bor(bit.lshift(r, 16), bit.lshift(g, 8), b) 27 | end 28 | 29 | local function math_sign(v) 30 | return (v >= 0 and 1) or -1 31 | end 32 | 33 | local function math_round(v, bracket) 34 | bracket = bracket or 1 35 | return math.floor(v/bracket + math_sign(v) * 0.5) * bracket 36 | end 37 | 38 | local function RGB_to_HSL(r, g, b) 39 | r = r / 255 40 | g = g / 255 41 | b = b / 255 42 | 43 | -- Find greatest and smallest channel values 44 | local cmin = math.min(r,g,b) 45 | local cmax = math.max(r,g,b) 46 | local delta = cmax - cmin 47 | local h = 0 48 | local s = 0 49 | local l = 0 50 | 51 | -- Calculate hue 52 | -- No difference 53 | if delta == 0 then 54 | h = 0 55 | 56 | -- Red is max 57 | elseif cmax == r then 58 | h = ((g - b) / delta) % 6; 59 | 60 | -- Green is max 61 | elseif cmax == g then 62 | h = (b - r) / delta + 2; 63 | 64 | -- Blue is max 65 | else 66 | h = (r - g) / delta + 4; 67 | end 68 | 69 | h = math_round(h * 60); 70 | 71 | -- Make negative hues positive behind 360° 72 | if (h < 0) then 73 | h = h + 360 74 | end 75 | 76 | -- Calculate lightness 77 | l = (cmax + cmin) / 2 78 | 79 | -- Calculate saturation 80 | if delta == 0 then 81 | s = 0 82 | else 83 | s = delta / (1 - math.abs(2 * l - 1)) 84 | end 85 | 86 | -- Multiply l and s by 100 87 | s = math.abs(s) * 100 88 | l = l * 100 89 | return h, s, l 90 | end 91 | 92 | local function HSL_to_RGB(h, s, l) 93 | s = s / 100 94 | l = l / 100 95 | 96 | local c = (1 - math.abs(2 * l - 1)) * s 97 | local x = c * (1 - math.abs((h / 60) % 2 - 1)) 98 | local m = l - c/2 99 | local r = 0 100 | local g = 0 101 | local b = 0; 102 | 103 | if (0 <= h and h < 60) then 104 | r = c; g = x; b = 0; 105 | elseif (60 <= h and h < 120) then 106 | r = x; g = c; b = 0; 107 | elseif (120 <= h and h < 180) then 108 | r = 0; g = c; b = x; 109 | elseif (180 <= h and h < 240) then 110 | r = 0; g = x; b = c; 111 | elseif (240 <= h and h < 300) then 112 | r = x; g = 0; b = c; 113 | elseif (300 <= h and h < 360) then 114 | r = c; g = 0; b = x; 115 | end 116 | 117 | r = math_round((r + m) * 255) 118 | g = math_round((g + m) * 255) 119 | b = math_round((b + m) * 255) 120 | return r, g, b 121 | end 122 | 123 | local function Hex_to_HSL(hex) 124 | return RGB_to_HSL(Hex_to_RGB(hex)) 125 | end 126 | 127 | local function HSL_to_Hex(h, s, l) 128 | return RGB_to_Hex(HSL_to_RGB(h, s, l)) 129 | end 130 | 131 | return { 132 | RGB_to_Hex = RGB_to_Hex, 133 | Hex_to_RGB = Hex_to_RGB, 134 | RGB_to_HSL = RGB_to_HSL, 135 | HSL_to_RGB = HSL_to_RGB, 136 | Hex_to_HSL = Hex_to_HSL, 137 | HSL_to_Hex = HSL_to_Hex, 138 | Bin_to_RGB = Bin_to_RGB, 139 | RGB_to_Bin = RGB_to_Bin, 140 | } 141 | -------------------------------------------------------------------------------- /lua/gitabra/util/functional.lua: -------------------------------------------------------------------------------- 1 | local function ident(a) 2 | return a 3 | end 4 | 5 | local function filter_iter(t, iter, pred) 6 | local result = {} 7 | for _, v in iter(t) do 8 | if pred(v) then 9 | table.insert(result, v) 10 | end 11 | end 12 | return result 13 | end 14 | 15 | -- Returns a new table with that contains all items where the predicate returned true 16 | local function filter(t, pred) 17 | return filter_iter(t, ipairs, pred) 18 | end 19 | 20 | local function filter_kv(t, pred) 21 | return filter_iter(t, pairs, pred) 22 | end 23 | 24 | -- Applies `func` to each value in the table 25 | -- Note that this alters the values in-place 26 | local function map(t, func, ...) 27 | for i, v in ipairs(t) do 28 | t[i] = func(v, ...) 29 | end 30 | return t 31 | end 32 | 33 | local function reduce(t, func, accum) 34 | if accum then 35 | for _, v in ipairs(t) do 36 | accum = func(accum, v) 37 | end 38 | else 39 | -- In the case where has not been provided, try to use the first item 40 | -- as the initial accum value, then resume the rest of the for-loop. 41 | -- 42 | -- We're using a slightly altered version of lua's "generic for" here. 43 | local _f, _s, _var = ipairs(t) 44 | local i, v = _f(_s, _var) 45 | _var = i 46 | accum = v 47 | if _var ~= nil then 48 | while true do 49 | i, v = _f(_s, _var) 50 | _var = i 51 | if _var == nil then break end 52 | accum = func(accum, v) 53 | end 54 | end 55 | end 56 | return accum 57 | end 58 | 59 | local function reduce_kv(t, func, accum) 60 | for k, v in ipairs(t) do 61 | accum = func(accum, k, v) 62 | end 63 | 64 | return accum 65 | end 66 | 67 | return { 68 | ident = ident, 69 | filter_iter = filter_iter, 70 | filter = filter, 71 | filter_kv = filter_kv, 72 | map = map, 73 | reduce = reduce, 74 | reduce_kv = reduce_kv, 75 | } 76 | -------------------------------------------------------------------------------- /lua/gitabra/util/init.lua: -------------------------------------------------------------------------------- 1 | local job = require('gitabra.job') 2 | local api = vim.api 3 | local ut = require('gitabra.util.table') 4 | local a = require('gitabra.async') 5 | local promise = require('gitabra.promise') 6 | local uf = require('gitabra.util.functional') 7 | local splitter = require('gitabra.util.string_splitter') 8 | 9 | -- Returns an iterator over each line in `str` 10 | -- Note that this will eat empty lines 11 | -- TODO: Modify this to process empty lines correctly 12 | local function lines(str) 13 | return str:gmatch("[^\r\n]+") 14 | end 15 | 16 | local function lines_array(str) 17 | local result = {} 18 | for line in lines(str) do 19 | table.insert(result, line) 20 | end 21 | return result 22 | end 23 | 24 | local function interp(s, params) 25 | return (s:gsub('($%b{})', function(w) return params[w:sub(3, -2)] or w end)) 26 | end 27 | 28 | local function string_split_by_pattern(str, pattern) 29 | local tokens = {} 30 | local last_e = 0 31 | local s = 0 32 | local e = 0 33 | while true do 34 | s, e = string.find(str, pattern, e+1) 35 | 36 | -- No more matches... 37 | -- Put all contents from the end of the last match up to end of string into tokens 38 | if s == nil then 39 | if last_e ~= string.len(str) then 40 | table.insert(tokens, string.sub(str, last_e+1, string.len(str))) 41 | end 42 | break 43 | 44 | -- If the next match came immediately after the last_e, 45 | -- we've encountered a case where two deliminator patterns were placed side-by-side. 46 | -- Place an empty string in the tokens array to indicate an empty field was found 47 | elseif last_e+1 == s then 48 | table.insert(tokens, "") 49 | 50 | -- Otherwise, extract all string contents starting from the last_e up to where this 51 | -- match was found 52 | else 53 | table.insert(tokens, string.sub(str, last_e+1, s-1)) 54 | end 55 | last_e = e 56 | end 57 | 58 | return tokens 59 | end 60 | 61 | local function nanotime() 62 | return vim.loop.hrtime() / 1000000000 63 | end 64 | 65 | ------------------------------------------------------------------------------- 66 | -- Running programs in a separate process 67 | -- 68 | -- This works a bit like neovim's job controls system, but built 69 | -- directly over vim.loop in lua. 70 | 71 | -- Execute the given command asynchronously 72 | -- Returns a `results` table. 73 | -- `done` field indicates if the job has finished running 74 | -- `output` table stores the output of the executed command 75 | -- `job` field contains a `gitabra.job` object used to run the command 76 | -- 77 | local function system(cmd, opt, callback) 78 | local result = { 79 | job = nil, 80 | output = {}, 81 | err_output = {}, 82 | done = false 83 | } 84 | opt = opt or {} 85 | if opt.split_lines then 86 | result.stdout_splitter = splitter.new("\n") 87 | result.stderr_splitter = splitter.new("\n") 88 | end 89 | 90 | 91 | local j = job.new({ 92 | cmd = cmd, 93 | opt = opt, 94 | on_stdout = function(_, err, data) 95 | if err then 96 | print("ERROR: "..err) 97 | end 98 | if data then 99 | if opt.split_lines then 100 | result.stdout_splitter:add(data) 101 | if not ut.table_is_empty(result.stdout_splitter.result) then 102 | ut.table_concat(result.output, result.stdout_splitter.result) 103 | result.stdout_splitter.result = {} 104 | end 105 | else 106 | table.insert(result.output, data) 107 | end 108 | end 109 | end, 110 | on_stderr = function(_, err, data) 111 | if err then 112 | print("ERROR: "..err) 113 | end 114 | if data then 115 | if opt.split_lines then 116 | result.stdout_splitter:add(data) 117 | if not ut.table_is_empty(result.stdout_splitter.result) then 118 | ut.table_concat(result.output, result.stdout_splitter.result) 119 | result.stdout_splitter.result = {} 120 | end 121 | else 122 | table.insert(result.err_output, data) 123 | end 124 | end 125 | end, 126 | on_exit = function(_, code, _) 127 | if opt.split_lines then 128 | result.stdout_splitter:stop() 129 | ut.table_concat(result.output, result.stdout_splitter.result) 130 | 131 | result.stderr_splitter:stop() 132 | ut.table_concat(result.err_output, result.stderr_splitter.result) 133 | elseif opt.merge_output and #result.output > 1 then 134 | result.output = { table.concat(result.output) } 135 | end 136 | result.exit_code = code 137 | result.done = true 138 | result.stop_time = nanotime() 139 | result.elapsed_time = result.stop_time - result.start_time 140 | if callback then 141 | callback(result) 142 | end 143 | end 144 | }) 145 | 146 | result.start_time = nanotime() 147 | j:start() 148 | 149 | result.job = j 150 | return result 151 | end 152 | 153 | -- A version of `system` meant to work with async mechanims of `gitabra.async` 154 | local system_async = a.wrap(system) 155 | 156 | -- Returns the `system` call as a promise. 157 | local function system_as_promise(cmd, opt, p) 158 | p = p or promise.new({}) 159 | p.job = system(cmd, opt, function(j) p:deliver(j.output) end) 160 | return p 161 | end 162 | 163 | local function system_job_is_done(j) 164 | return j.done 165 | end 166 | 167 | local function system_jobs_are_done(jobs) 168 | -- If any of the jobs are not done yet, 169 | -- we're not done 170 | for _, j in pairs(jobs) do 171 | if j.done == false then 172 | return false 173 | end 174 | end 175 | 176 | -- All of the jobs are done... 177 | return true 178 | end 179 | 180 | -- Wait until either `ms` has elapsed or when `predicate` returns true 181 | local function system_job_wait_for(j, ms, predicate) 182 | return vim.wait(ms, predicate, 5) 183 | end 184 | 185 | local function system_job_wait(j, ms) 186 | return vim.wait(ms, 187 | function() 188 | return j.done 189 | end, 5) 190 | end 191 | 192 | -- Wait up to `ms` approximately milliseconds until all the jobs are done 193 | function system_job_wait_all(jobs, ms) 194 | return vim.wait(ms, 195 | function() 196 | return M.are_jobs_done(jobs) 197 | end, 5) 198 | end 199 | 200 | 201 | -- Given the `root` of a tree of tables, 202 | -- 203 | -- Returns the node sitting at the `path`, which should be 204 | -- an array of keys. 205 | -- 206 | -- Returns nil if the path cannot be fully traversed 207 | local function node_from_path(root, path) 208 | local cursor = root 209 | 210 | for _, key in ipairs(path) do 211 | -- Try to traverse further into the path. 212 | -- This means we *need* something that can traversed. 213 | -- In lua, the only thing we can traverse is a table. 214 | -- So if we're not looking at a table, we've failed 215 | -- traversing the path. 216 | if type(cursor) ~= "table" then 217 | return nil 218 | end 219 | 220 | local candidate = cursor[key] 221 | cursor = candidate 222 | end 223 | 224 | return cursor 225 | end 226 | 227 | local get_in = node_from_path 228 | 229 | -- Given a `root` and the `target_node` we're looking for, 230 | -- Return the path of the first occurance of the node. 231 | -- Returns nil if the node cannot be found 232 | -- 233 | -- Note that only the node/table pointer is being compared here. 234 | local function path_from_node_compare(root, target_node, compare_fn) 235 | local path = {} 236 | 237 | local depth_first_match 238 | depth_first_match = function(cur_node) 239 | if compare_fn(cur_node, target_node) then 240 | return true 241 | end 242 | 243 | -- If we can traverse further into the tree... 244 | if type(cur_node) ~= "table" then 245 | return false 246 | end 247 | 248 | -- Check each child... 249 | for k, v in pairs(cur_node) do 250 | ut.table_push(path, k) 251 | 252 | -- If we found a match, return immediately without messing up 253 | -- the path that has been accumulated 254 | local match = depth_first_match(v) 255 | if match == true then 256 | return match 257 | end 258 | ut.table_pop(path) 259 | end 260 | end 261 | 262 | local result = depth_first_match(root) 263 | if result then 264 | return path 265 | else 266 | return nil 267 | end 268 | end 269 | 270 | 271 | local function path_from_node(root, target_node) 272 | return path_from_node_compare(root, target_node, 273 | function(cur_node, target_node) 274 | if cur_node == target_node then 275 | return true 276 | else 277 | return false 278 | end 279 | end) 280 | end 281 | 282 | -- Make sure we have at least `lineno` lines in the buffer 283 | -- This helps when we're trying to insert lines at a position beyond the 284 | -- current end of the buffer 285 | local function buf_padlines_to(buf, lineno) 286 | local count = api.nvim_buf_line_count(buf) 287 | if (lineno > count) then 288 | local empty_lines = {} 289 | for _ = 1, lineno-count do 290 | table.insert(empty_lines, "") 291 | end 292 | api.nvim_buf_set_lines(buf, count, count, true, empty_lines) 293 | end 294 | end 295 | 296 | local function partition(table, tuple_size, step) 297 | local result = {} 298 | 299 | if not step then 300 | step = tuple_size 301 | end 302 | 303 | for i = 1, #table-tuple_size+1, step do 304 | local tuple = {} 305 | for j = 0, tuple_size-1 do 306 | ut.table_push(tuple, table[i+j]) 307 | end 308 | ut.table_push(result, tuple) 309 | end 310 | return result 311 | end 312 | 313 | local function partition_iterator(table, tuple_size, step) 314 | local t_size = #table 315 | if not step then 316 | step = tuple_size 317 | end 318 | local v = 1-step 319 | 320 | return function() 321 | v = v+step 322 | local end_idx = v + tuple_size-1 323 | 324 | -- Can we construct more tuples starting from the requested position? 325 | if end_idx > t_size then 326 | return nil 327 | end 328 | 329 | -- If so, grab all items into a tupe and return it unpacked 330 | local result = {} 331 | for i = v, end_idx do 332 | ut.table_push(result, table[i]) 333 | end 334 | return unpack(result) 335 | end 336 | end 337 | 338 | local function remove_trailing_newlines(str) 339 | local result, _ = string.gsub(str, "[\r\n]+$", "") 340 | return result 341 | end 342 | 343 | local function trim(str) 344 | return string.match(str, "^[\n\r%s]*(.-)[\n\r%s]*$") 345 | end 346 | 347 | local function selected_region() 348 | return {vim.fn.line("v")-1, vim.fn.line(".")-1} 349 | end 350 | 351 | local function within_region(region, lineno) 352 | if region[1] <= lineno and lineno <= region[2] then 353 | return true 354 | else 355 | return false 356 | end 357 | end 358 | 359 | local function git_root_dir_p() 360 | return system_as_promise("git rev-parse --show-toplevel", {split_lines=true}) 361 | end 362 | 363 | local function git_root_dir() 364 | local p = git_root_dir_p() 365 | p:wait(500) 366 | return p.job.output[1] 367 | end 368 | 369 | local function git_dot_git_dir() 370 | return git_root_dir() .. "/.git" 371 | end 372 | 373 | local function str_is_empty(str) 374 | return str == nil or str == "" 375 | end 376 | 377 | local function str_is_really_empty(str) 378 | if str_is_empty(str) then 379 | return true 380 | end 381 | 382 | if string.match(str, "^%s*$") then 383 | return true 384 | end 385 | 386 | return false 387 | end 388 | 389 | local function first_nonwhitespace_idx(str) 390 | return string.find(str, "[^%s]") 391 | end 392 | 393 | local function nvim_commands(str, strip_leading_whitespace) 394 | if not strip_leading_whitespace then 395 | for line in lines(str) do 396 | print(line) 397 | end 398 | else 399 | local idx = -1 400 | 401 | for line in lines(str) do 402 | local empty = str_is_really_empty(line) 403 | if idx == -1 and not empty then 404 | idx = first_nonwhitespace_idx(str) 405 | end 406 | vim.cmd(line:sub(idx)) 407 | end 408 | end 409 | end 410 | 411 | local function math_clamp(v, min_val, max_val) 412 | return math.min(math.max(v, min_val), max_val) 413 | end 414 | 415 | local function markup_fn(m) 416 | m.type = "markup" 417 | return m 418 | end 419 | 420 | local function is_markup(markup) 421 | if type(markup) == "table" and markup.type == "markup" then 422 | return true 423 | else 424 | return false 425 | end 426 | end 427 | 428 | -- Given a `markup` that represents a string with text with highlghts on specific sections, 429 | -- Construct the entire string itself, and the locations on the string where highlights should start and stop 430 | local function markup_flatten(markup, concat_separator) 431 | local text = {} 432 | local hl = {} 433 | 434 | -- If we're called with a string, 435 | -- return a dummy result that we can still process like a markup. 436 | if type(markup) == "string" then 437 | return {text=markup, hl=hl} 438 | end 439 | 440 | local sep_len = 0 441 | if concat_separator then 442 | sep_len = concat_separator:len() 443 | end 444 | 445 | local cursor = 0 446 | for _, item in ipairs(markup) do 447 | if type(item) == "string" then 448 | table.insert(text, item) 449 | cursor = cursor + item:len() 450 | elseif type(item) == "table" then 451 | table.insert(text, item.text) 452 | local start = cursor 453 | cursor = cursor + item.text:len() 454 | table.insert(hl, { 455 | start = start, 456 | stop = cursor, 457 | group = item.group 458 | }) 459 | end 460 | cursor = cursor + sep_len 461 | end 462 | 463 | return {text=table.concat(text, concat_separator), hl=hl} 464 | end 465 | 466 | local function nvim_synIDattr(synID, what, mode) 467 | if not mode then 468 | return api.nvim_eval(string.format("synIDattr(%i,'%s')", synID, what)) 469 | else 470 | return api.nvim_eval(string.format("synIDattr(%i,'%s','%s')", synID, what, mode)) 471 | end 472 | end 473 | 474 | 475 | local function hl_group_attrs(group_name, target_attrs) 476 | local hl_group_id = api.nvim_get_hl_id_by_name(group_name) 477 | if not target_attrs then 478 | target_attrs = { 479 | {"fg", "gui"}, 480 | {"bg", "gui"}, 481 | {"fg", "cterm"}, 482 | {"bg", "cterm"}, 483 | } 484 | end 485 | 486 | -- Fetch highlight group attributes 487 | -- Only keep entries that returned non-emtpy values 488 | local attrs = {} 489 | for _, item in ipairs(target_attrs) do 490 | local val = nvim_synIDattr(hl_group_id, unpack(item)) 491 | if not str_is_empty(val) then 492 | attrs[string.format("%s%s", item[2], item[1])] = val 493 | end 494 | end 495 | return attrs 496 | end 497 | 498 | local function hl_group_attrs_to_str(attrs) 499 | local result = {} 500 | for k, v in pairs(attrs) do 501 | table.insert(result, string.format("%s=%s", k, v)) 502 | end 503 | return table.concat(result, " ") 504 | end 505 | 506 | local function nvim_line_zero_idx(place) 507 | return vim.fn.line(place)-1 508 | end 509 | 510 | local function zipper_picks_by_type(z_in) 511 | local z = z_in:clone() 512 | local picks = {} 513 | local node = z:node() 514 | 515 | while true do 516 | if node.type then 517 | picks[node.type] = node 518 | end 519 | if not z:up() then 520 | break 521 | end 522 | node = z:node() 523 | end 524 | 525 | return picks 526 | end 527 | 528 | local function git_shorten_sha(sha) 529 | return string.sub(sha, 1, 7) 530 | end 531 | 532 | local function call_or_print_stacktrace(func, ...) 533 | local ok, res = xpcall(func, debug.traceback, ...) 534 | if not ok then 535 | print(res) 536 | return nil 537 | else 538 | return res 539 | end 540 | end 541 | 542 | local function wrap__call_or_print_stacktrace(func) 543 | return function(...) 544 | return call_or_print_stacktrace(func, ...) 545 | end 546 | end 547 | 548 | local function filter_empty_strings(strs) 549 | return uf.filter(strs, function(s) return not str_is_empty(s) end) 550 | end 551 | 552 | local function array_remove_trailing_empty_lines(strs) 553 | local last 554 | for cursor = #strs, 1, -1 do 555 | if not str_is_empty(strs[cursor]) then 556 | last = cursor 557 | break 558 | end 559 | end 560 | 561 | if last == nil then 562 | return {} 563 | else 564 | return ut.table_slice(strs, 1, last) 565 | end 566 | end 567 | 568 | local function nvim_center_current_line() 569 | vim.cmd("normal! zz") 570 | end 571 | 572 | return ut.table_copy_into({ 573 | lines = lines, 574 | lines_array = lines_array, 575 | interp = interp, 576 | 577 | system = system, 578 | system_async = system_async, 579 | system_as_promise = system_as_promise, 580 | system_job_is_done = system_job_is_done, 581 | system_jobs_are_done = system_jobs_are_done, 582 | system_job_wait_for = system_job_wait_for, 583 | system_job_wait = system_job_wait, 584 | system_job_wait_all = system_job_wait_all, 585 | 586 | node_from_path = node_from_path, 587 | get_in = node_from_path, 588 | path_from_node = path_from_node, 589 | buf_padlines_to = buf_padlines_to, 590 | partition = partition, 591 | partition_iterator = partition_iterator, 592 | remove_trailing_newlines = remove_trailing_newlines, 593 | trim = trim, 594 | selected_region = selected_region, 595 | within_region = within_region, 596 | nanotime = nanotime, 597 | git_root_dir_p = git_root_dir_p, 598 | git_root_dir = git_root_dir, 599 | git_dot_git_dir = git_dot_git_dir, 600 | nvim_commands = nvim_commands, 601 | math_clamp = math_clamp, 602 | str_is_empty = str_is_empty, 603 | str_is_really_empty = str_is_really_empty, 604 | markup = markup_fn, 605 | is_markup = is_markup, 606 | markup_flatten = markup_flatten, 607 | nvim_synIDattr = nvim_synIDattr, 608 | string_split_by_pattern = string_split_by_pattern, 609 | hl_group_attrs = hl_group_attrs, 610 | hl_group_attrs_to_str = hl_group_attrs_to_str, 611 | nvim_line_zero_idx = nvim_line_zero_idx, 612 | zipper_picks_by_type = zipper_picks_by_type, 613 | git_shorten_sha = git_shorten_sha, 614 | call_or_print_stacktrace = call_or_print_stacktrace, 615 | wrap__call_or_print_stacktrace = wrap__call_or_print_stacktrace, 616 | filter_empty_strings = filter_empty_strings, 617 | array_remove_trailing_empty_lines = array_remove_trailing_empty_lines, 618 | nvim_center_current_line = nvim_center_current_line, 619 | }, 620 | ut, 621 | require('gitabra.util.functional'), 622 | require('gitabra.util.color')) 623 | -------------------------------------------------------------------------------- /lua/gitabra/util/string_splitter.lua: -------------------------------------------------------------------------------- 1 | -- Meant to work with content returned by libuv. 2 | -- Sometimes, `on_stdout` returns the result of running programs in 3 | -- smaller chunks that do not break at newline boundaries. In particular, 4 | -- this happens when we ask git for a file that is slightly large. 5 | 6 | local M = {} 7 | M.__index = M 8 | 9 | function M.new(pattern) 10 | local o = { 11 | pending = nil, 12 | result = {}, 13 | pattern = pattern, 14 | } 15 | setmetatable(o, M) 16 | return o 17 | end 18 | 19 | local function insert_result(self, str) 20 | if self.pending then 21 | table.insert(self.result, self.pending .. str) 22 | self.pending = nil 23 | else 24 | table.insert(self.result, str) 25 | end 26 | end 27 | 28 | local function store_pending(self, str) 29 | if self.pending then 30 | self.pending = self.pending .. str 31 | else 32 | self.pending = str 33 | end 34 | end 35 | 36 | function M:add(str) 37 | local last_e = 0 38 | local s = 0 39 | local e = 0 40 | while true do 41 | s, e = string.find(str, self.pattern, e+1) 42 | 43 | -- No more matches... 44 | -- Put all contents from the end of the last match up to end of string into tokens 45 | if s == nil then 46 | if last_e ~= string.len(str) then 47 | store_pending(self, string.sub(str, last_e+1, string.len(str))) 48 | end 49 | break 50 | 51 | -- If the next match came immediately after the last_e, 52 | -- we've encountered a case where two deliminator patterns were placed side-by-side. 53 | -- Place an empty string in the tokens array to indicate an empty field was found 54 | elseif last_e+1 == s then 55 | insert_result(self, "") 56 | 57 | -- Otherwise, extract all string contents starting from the last_e up to where this 58 | -- match was found 59 | else 60 | insert_result(self, string.sub(str, last_e+1, s-1)) 61 | end 62 | last_e = e 63 | end 64 | end 65 | 66 | function M:stop() 67 | if self.pending then 68 | table.insert(self.result, self.pending) 69 | self.pending = nil 70 | end 71 | end 72 | 73 | return M 74 | -------------------------------------------------------------------------------- /lua/gitabra/util/table.lua: -------------------------------------------------------------------------------- 1 | local uf = require('gitabra.util.functional') 2 | 3 | -- Performs a shallow copy of a list of hashtables into the `target` 4 | local function table_copy_into(target, ...) 5 | local tables = {...} 6 | for _, t in ipairs(tables) do 7 | if type(t) == "table" then 8 | for k,v in pairs(t) do 9 | target[k] = v 10 | end 11 | end 12 | end 13 | return target 14 | end 15 | 16 | local function table_copy_into_recursive_single(target, src) 17 | for k, sv in pairs(src) do 18 | local tv = target[k] 19 | 20 | if type(sv) == "table" then 21 | local target_table = tv or {} 22 | target[k] = table_copy_into_recursive_single(target_table, sv) 23 | else 24 | target[k] = sv 25 | end 26 | end 27 | return target 28 | end 29 | 30 | local function table_copy_into_recursive(target, ...) 31 | local tables = {...} 32 | for _, src in ipairs(tables) do 33 | table_copy_into_recursive_single(target, src) 34 | end 35 | return target 36 | end 37 | 38 | 39 | local function table_concat(target, ...) 40 | local tables = {...} 41 | for _, t in ipairs(tables) do 42 | if type(t) == "table" then 43 | for _,v in ipairs(t) do 44 | table.insert(target, v) 45 | end 46 | end 47 | end 48 | end 49 | 50 | -- Alias for better code clarity 51 | local table_push = table.insert 52 | local table_pop = table.remove 53 | local function table_get_last(t) 54 | return t[#t] 55 | end 56 | 57 | local function table_clone(t) 58 | return table_copy_into({}, t) 59 | end 60 | 61 | local function table_lazy_get(table, key, default) 62 | local v = table[key] 63 | if not v then 64 | table[key] = default 65 | v = default 66 | end 67 | return v 68 | end 69 | 70 | local function table_depth_first_visit(root_table, children_fieldname) 71 | local stack = {} 72 | 73 | table_push(stack, { 74 | node = root_table, 75 | visited = false 76 | }) 77 | 78 | local depth_first_visit 79 | depth_first_visit = function() 80 | local node = table_get_last(stack) 81 | 82 | -- If there are no more nodes to visit, 83 | -- we're done! 84 | if node == nil then 85 | return nil 86 | end 87 | 88 | -- Visit the node itself 89 | if not node.visited then 90 | node.visited = true 91 | return node.node 92 | end 93 | 94 | -- Grab the iterator for the current node 95 | local iter = node.iter 96 | if not node.iter then 97 | local children 98 | if children_fieldname then 99 | children = node.node[children_fieldname] 100 | else 101 | children = node.node 102 | end 103 | 104 | node.iter = {pairs(children)} 105 | -- We should get back a function `f`, an invariant `s`, and a control variable `v` 106 | 107 | iter = node.iter 108 | end 109 | 110 | -- Grab a child node 111 | while true do 112 | local k, next_node = iter[1](iter[2], iter[3]) -- f(s, v) 113 | iter[3] = k -- update the control var to prep for next iteration 114 | 115 | -- Do we have more child nodes to visit? 116 | if k == nil then 117 | -- If not, continue visits at the parent 118 | table_pop(stack) 119 | return depth_first_visit() 120 | end 121 | 122 | -- Visit child nodes 123 | if type(next_node) == "table" then 124 | table_push(stack, { 125 | node = next_node, 126 | visted = false 127 | }) 128 | return depth_first_visit() 129 | end 130 | end 131 | end 132 | 133 | return depth_first_visit 134 | end 135 | 136 | 137 | local function table_find_node(t, pred) 138 | for node in table_depth_first_visit(t) do 139 | if pred(node) then 140 | return node 141 | end 142 | end 143 | return nil 144 | end 145 | 146 | local function table_key_diff(t1, t2) 147 | local added = {} 148 | local removed = {} 149 | local common = {} 150 | 151 | -- For every k in t1... 152 | for k, v in pairs(t1) do 153 | if v ~= nil then 154 | -- If that k is also in t2, then tables 155 | -- have the key in common 156 | if t2[k] then 157 | table_push(common, k) 158 | else 159 | -- If that k is not in t2, the key has been removed 160 | table_push(removed, k) 161 | end 162 | end 163 | end 164 | 165 | for k, v in pairs(t2) do 166 | if v ~= nil then 167 | if not t1[k] then 168 | table_push(added, k) 169 | end 170 | end 171 | end 172 | 173 | return { 174 | added = added, 175 | removed = removed, 176 | common = common, 177 | } 178 | end 179 | 180 | local function table_array_to_set(t, id_func) 181 | if not id_func then 182 | id_func = uf.ident 183 | end 184 | local set = {} 185 | for _, v in ipairs(t) do 186 | local k = id_func(v) 187 | if k then 188 | set[k] = v 189 | end 190 | end 191 | return set 192 | end 193 | 194 | local table_array_items_by_id = table_array_to_set 195 | 196 | 197 | local function table_array_find_by(t, target_val, val_func) 198 | if not val_func then 199 | val_func = uf.ident 200 | end 201 | for i, e in ipairs(t) do 202 | if val_func(e) == target_val then 203 | return i 204 | end 205 | end 206 | end 207 | 208 | local function table_is_empty(t) 209 | if next(t) == nil then 210 | return true 211 | else 212 | return false 213 | end 214 | end 215 | 216 | local function table_approximate_type(t) 217 | local approx_types = {} 218 | 219 | if type(t) ~= "table" then 220 | return type(t) 221 | end 222 | 223 | local all_numbers = true 224 | for k, _ in pairs(t) do 225 | if type(k) ~= "number" then 226 | all_numbers = false 227 | end 228 | end 229 | if all_numbers then 230 | table.insert(approx_types, "array") 231 | else 232 | table.insert(approx_types, "hashtable") 233 | end 234 | 235 | local value_types = {} 236 | for _, v in pairs(t) do 237 | if type(v) ~= "table" then 238 | value_types[type(v)] = true 239 | elseif t.type then 240 | value_types[t.type] = true 241 | else 242 | value_types["?"] = true 243 | end 244 | end 245 | 246 | for k, _ in pairs(value_types) do 247 | table.insert(approx_types, k) 248 | end 249 | 250 | return approx_types 251 | end 252 | 253 | local function interpret_idx(idx, max) 254 | if idx < 0 then 255 | while idx < 0 do 256 | idx = idx + max 257 | end 258 | idx = idx + 1 259 | elseif idx > max then 260 | idx = max 261 | end 262 | return idx 263 | end 264 | 265 | -- Matches the behavior of string.sub 266 | local function table_slice(t, first, last) 267 | local count = #t 268 | 269 | if first > count then 270 | return {} 271 | end 272 | 273 | first = interpret_idx(first, count) 274 | if last == nil then 275 | last = count 276 | else 277 | last = interpret_idx(last, count) 278 | end 279 | 280 | if last < first then 281 | return {} 282 | end 283 | 284 | local result = {} 285 | local i = first 286 | 287 | while i <= last do 288 | table.insert(result, t[i]) 289 | i = i + 1 290 | end 291 | 292 | return result 293 | end 294 | 295 | local function table_first(t) 296 | return t[1] 297 | end 298 | 299 | local function table_rest(t) 300 | return table_slice(t, 2) 301 | end 302 | 303 | 304 | return { 305 | table_copy_into = table_copy_into, 306 | table_copy_into_recursive = table_copy_into_recursive, 307 | table_concat = table_concat, 308 | table_depth_first_visit = table_depth_first_visit, 309 | table_lazy_get = table_lazy_get, 310 | table_find_node = table_find_node, 311 | table_push = table_push, 312 | table_pop = table_pop, 313 | table_get_last = table_get_last, 314 | table_clone = table_clone, 315 | table_key_diff = table_key_diff, 316 | table_array_to_set = table_array_to_set, 317 | table_array_items_by_id = table_array_items_by_id, 318 | table_array_find_by = table_array_find_by, 319 | table_is_empty = table_is_empty, 320 | table_approximate_type = table_approximate_type, 321 | table_slice = table_slice, 322 | table_first = table_first, 323 | table_rest = table_rest, 324 | } 325 | -------------------------------------------------------------------------------- /lua/gitabra/zipper.lua: -------------------------------------------------------------------------------- 1 | -- Hierarchical zipper 2 | -- Mostly mirrors the clojure.zip api 3 | local ut = require("gitabra.util.table") 4 | 5 | local M = {} 6 | M.__index = M 7 | 8 | local function get_children(self, node) 9 | if type(self.children_fn) == "function" then 10 | return self.children_fn(node) 11 | elseif type(self.children_fn) == "string" then 12 | return node[self.children_fn] 13 | else 14 | error("children_fn is invalid") 15 | end 16 | end 17 | 18 | function M.new(root, children_fn) 19 | local o = { 20 | children_fn = children_fn, 21 | path = {root}, 22 | path_idxs = {0}, 23 | root = root, 24 | } 25 | setmetatable(o, M) 26 | return o 27 | end 28 | 29 | function M:clone() 30 | local o = { 31 | children_fn = self.children_fn, 32 | path = ut.table_clone(self.path), 33 | path_idxs = ut.table_clone(self.path_idxs), 34 | root = root, 35 | } 36 | setmetatable(o, M) 37 | return o 38 | end 39 | 40 | function M:set_new_root(node) 41 | path = {node} 42 | path_idxs = {0} 43 | root = root 44 | end 45 | 46 | function M:set_path(path_to_node) 47 | local path = {self.root} 48 | local path_idxs = {0} 49 | 50 | for _, target_idx in ipairs(path_to_node) do 51 | local curnode = ut.table_get_last(path) 52 | local children = get_children(self, curnode) 53 | local target_node = children[target_idx] 54 | ut.table_push(path, target_node) 55 | ut.table_push(path_idxs, target_idx) 56 | end 57 | 58 | self.path = path 59 | self.path_idxs = path_idxs 60 | end 61 | 62 | -- Move to the parent of the current node 63 | function M:up() 64 | if #self.path > 1 then 65 | table.remove(self.path) 66 | table.remove(self.path_idxs) 67 | return true 68 | end 69 | return false 70 | end 71 | 72 | -- Move to the first/leftmost child of the current node 73 | -- Returns if the move was performed successfully 74 | function M:down() 75 | local cs = self:children() 76 | 77 | if cs and #cs >= 1 then 78 | ut.table_push(self.path, cs[1]) 79 | ut.table_push(self.path_idxs, 1) 80 | return true 81 | end 82 | return false 83 | end 84 | 85 | -- Navigate to the given child `child_node`. 86 | -- This will only work if the child node is actually a child of the current node. 87 | -- Returns true if navigation succeeded 88 | function M:to_child_node(child_node) 89 | local cs = self:children() 90 | if child_node == self then 91 | return false 92 | end 93 | for i, c in ipairs(cs) do 94 | if c == child_node then 95 | ut.table_push(self.path, c) 96 | ut.table_push(self.path_idxs, i) 97 | return true 98 | end 99 | end 100 | return false 101 | end 102 | 103 | -- Moves to the right sibling of the current node 104 | function M:right() 105 | -- If there is no parent for the current node, 106 | -- there is nothing to do 107 | if #self.path == 1 then 108 | return false 109 | end 110 | local parent = self.path[#self.path-1] 111 | 112 | local cur_idx = ut.table_get_last(self.path_idxs) 113 | local target_sibling = cur_idx + 1 114 | 115 | local siblings = get_children(self, parent) 116 | if #siblings >= target_sibling then 117 | ut.table_pop(self.path) 118 | ut.table_push(self.path, siblings[target_sibling]) 119 | ut.table_pop(self.path_idxs) 120 | ut.table_push(self.path_idxs, target_sibling) 121 | return true 122 | end 123 | 124 | return false 125 | end 126 | 127 | -- Moves to the left sibling of the current node 128 | function M:left() 129 | if #self.path == 1 then 130 | return false 131 | end 132 | 133 | local cur_idx = ut.table_get_last(self.path_idxs) 134 | local target_sibling = cur_idx - 1 135 | if target_sibling < 1 then 136 | return false 137 | end 138 | 139 | local parent = self.path[#self.path-1] 140 | local siblings = get_children(self, parent) 141 | 142 | ut.table_pop(self.path) 143 | ut.table_push(self.path, siblings[target_sibling]) 144 | ut.table_pop(self.path_idxs) 145 | ut.table_push(self.path_idxs, target_sibling) 146 | return true 147 | end 148 | 149 | -- Get a list of children of the current node 150 | function M:children() 151 | local curnode = self:node() 152 | return get_children(self, curnode) 153 | end 154 | 155 | function M:set_children(cs) 156 | local curnode = self:node() 157 | assert(type(self.children_fn) == "string") 158 | curnode[self.children_fn] = cs 159 | end 160 | 161 | -- Returns whether the current node has child nodes or not 162 | function M:has_children() 163 | local cs = self:children() 164 | return cs and #cs ~= 0 165 | end 166 | 167 | -- Get the current node 168 | function M:node() 169 | return ut.table_get_last(self.path) 170 | end 171 | 172 | function M:parent_node() 173 | local target_idx = #self.path-1 174 | if target_idx < 1 then 175 | return nil 176 | else 177 | return self.path[target_idx] 178 | end 179 | end 180 | 181 | -- Returns if the path is indicating it is at an and of a depth first traversal 182 | function M:at_end() 183 | if #self.path >= 2 and self.path[2] == "end" and self.path_idxs[2] == -1 then 184 | return true 185 | else 186 | return false 187 | end 188 | end 189 | 190 | -- Removes the end marker in the path if found 191 | function M:remove_end_marker() 192 | if self:at_end() then 193 | ut.table_pop(self.path) 194 | ut.table_pop(self.path_idxs) 195 | end 196 | end 197 | 198 | -- Moves to the next node in the tree, depth-first. 199 | -- 200 | -- If all nodes have been traversed, and end marker will be added 201 | -- to the `path`. `at_end()` will return true. 202 | -- The end marker can be removed with `remove_end_marker()` to 203 | -- restore the zipper to a state where additional navigation 204 | -- can be performed. 205 | function M:next() 206 | -- Don't do anything if we're at the end of a depth first traversal 207 | if self:at_end() then 208 | return false 209 | end 210 | 211 | -- Try to go down the tree... 212 | if self:has_children() and self:down() then 213 | return true 214 | end 215 | 216 | -- If that wasn't possible, try to go right 217 | if self:right() then 218 | return true 219 | end 220 | 221 | return self:next_up_right() 222 | end 223 | 224 | -- Moves to the next valid item by try `up` and `right` continuously 225 | -- 226 | -- This is part of the behavior of `next`. 227 | -- It's included here to make it easier to replicate a next-like 228 | -- movement. 229 | -- 230 | -- This is useful, for example, when trying to perform a dfs visit, 231 | -- but reject going down certain branches altogether. 232 | function M:next_up_right() 233 | -- Move up the tree while trying to go right 234 | -- Here, we either end up finding some ancestor node where 235 | -- moving right succeeded... 236 | -- Or, we end up at the root with an "end" marker in the path 237 | while true do 238 | if not self:up() then 239 | ut.table_push(self.path, "end") 240 | ut.table_push(self.path_idxs, -1) 241 | return false 242 | end 243 | 244 | if self:right() then 245 | return true 246 | end 247 | 248 | -- Moving up and right didn't seem to work 249 | -- Try again... 250 | end 251 | end 252 | 253 | return M 254 | -------------------------------------------------------------------------------- /plugin/gitabra.vim: -------------------------------------------------------------------------------- 1 | command Gitabra lua require'gitabra'.gitabra_status() 2 | --------------------------------------------------------------------------------