├── LICENSE ├── README.md ├── ftplugin └── defx.vim └── rplugin └── python3 └── defx └── column └── git.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kristijan Husak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # defx-git 2 | 3 | Git status implementation for [defx.nvim](http://github.com/Shougo/defx.nvim). 4 | 5 | ## Usage 6 | 7 | Just append `git` to your columns when starting defx: 8 | 9 | ```viml 10 | :Defx -columns=git:mark:filename:type 11 | ``` 12 | 13 | ## Options 14 | 15 | ### Indicators 16 | 17 | Which indicators (icons) to use for each status. These are the defaults: 18 | 19 | ```viml 20 | call defx#custom#column('git', 'indicators', { 21 | \ 'Modified' : '✹', 22 | \ 'Staged' : '✚', 23 | \ 'Untracked' : '✭', 24 | \ 'Renamed' : '➜', 25 | \ 'Unmerged' : '═', 26 | \ 'Ignored' : '☒', 27 | \ 'Deleted' : '✖', 28 | \ 'Unknown' : '?' 29 | \ }) 30 | ``` 31 | 32 | ### Column Length 33 | 34 | How many space should git column take. Default is `1` (Defx adds a single space between columns): 35 | 36 | ```viml 37 | call defx#custom#column('git', 'column_length', 1) 38 | ``` 39 | 40 | Missing characters to match this length are populated with spaces, which means 41 | `✹` becomes `✹ `, etc. 42 | 43 | Note: Make sure indicators are not longer than the column_length 44 | 45 | ### Show ignored 46 | 47 | This flag determines if ignored files should be marked with indicator. Default is `false`: 48 | 49 | ```viml 50 | call defx#custom#column('git', 'show_ignored', 0) 51 | ``` 52 | 53 | ### Raw Mode 54 | 55 | Show git status in raw mode (Same as first two chars of `git status --porcelain` command). Default is `0`: 56 | 57 | ```viml 58 | call defx#custom#column('git', 'raw_mode', 0) 59 | ``` 60 | 61 | ### Change git commit 62 | 63 | Change the git commit the files are diffed against (SHA-1, branchname, etc.). Default is `'HEAD'`: 64 | 65 | ```viml 66 | call defx#custom#column('git', 'git_commit', 'HEAD') 67 | ``` 68 | 69 | ### Max Indicator Width 70 | 71 | The number of characters to pad the git column. If not specified, the default 72 | will be the width of the longest indicator character. 73 | 74 | ```viml 75 | call defx#custom#column('git', 'max_indicator_width', 2) 76 | ``` 77 | 78 | ## Highlighting 79 | 80 | Each indicator type can be overridden with the custom highlight. These are the defaults: 81 | 82 | ```viml 83 | hi Defx_git_Untracked guibg=NONE guifg=NONE ctermbg=NONE ctermfg=NONE 84 | hi Defx_git_Ignored guibg=NONE guifg=NONE ctermbg=NONE ctermfg=NONE 85 | hi Defx_git_Unknown guibg=NONE guifg=NONE ctermbg=NONE ctermfg=NONE 86 | hi Defx_git_Renamed ctermfg=214 guifg=#fabd2f 87 | hi Defx_git_Modified ctermfg=214 guifg=#fabd2f 88 | hi Defx_git_Unmerged ctermfg=167 guifg=#fb4934 89 | hi Defx_git_Deleted ctermfg=167 guifg=#fb4934 90 | hi Defx_git_Staged ctermfg=142 guifg=#b8bb26 91 | ``` 92 | 93 | To use for example red for untracked files, add this **after** your colorscheme setup: 94 | 95 | ```viml 96 | colorscheme gruvbox 97 | hi Defx_git_Untracked guifg=#FF0000 98 | ``` 99 | 100 | ## Mappings 101 | 102 | There are 5 mappings: 103 | 104 | * `(defx-git-next)` - Goes to the next file that has a git status 105 | * `(defx-git-prev)` - Goes to the previous file that has a git status 106 | * `(defx-git-stage)` - Stages the file/directory under cursor 107 | * `(defx-git-reset)` - Unstages the file/directory under cursor 108 | * `(defx-git-discard)` - Discards all changes to file/directory under cursor 109 | 110 | If these are not manually mapped by the user, defaults are: 111 | ```viml 112 | nnoremap [c (defx-git-prev) 113 | nnoremap ]c (defx-git-next) 114 | nnoremap ]a (defx-git-stage) 115 | nnoremap ]r (defx-git-reset) 116 | nnoremap ]d (defx-git-discard) 117 | ``` 118 | -------------------------------------------------------------------------------- /ftplugin/defx.vim: -------------------------------------------------------------------------------- 1 | if exists('*defx#redraw') 2 | augroup defx_git 3 | autocmd! 4 | autocmd BufWritePost * call defx#redraw() 5 | augroup END 6 | endif 7 | 8 | scriptencoding utf-8 9 | if exists('b:defx_git_loaded') 10 | finish 11 | endif 12 | 13 | let b:defx_git_loaded = 1 14 | 15 | function! s:search(dir) abort 16 | let l:icons = get(g:, 'defx_git_indicators', {}) 17 | let l:icons_pattern = join(values(l:icons), '\|') 18 | 19 | if !empty(l:icons_pattern) 20 | let l:direction = a:dir > 0 ? 'w' : 'bw' 21 | return search(printf('\(%s\)', l:icons_pattern), l:direction) 22 | endif 23 | endfunction 24 | 25 | function! s:git_cmd(cmd) abort 26 | let l:actions = { 27 | \ 'stage': 'git add', 28 | \ 'reset': 'git reset', 29 | \ 'discard': 'git checkout --' 30 | \ } 31 | let l:candidate = defx#get_candidate() 32 | let l:path = shellescape(get(l:candidate, 'action__path')) 33 | let l:word = get(l:candidate, 'word') 34 | let l:is_dir = get(l:candidate, 'is_directory') 35 | if empty(l:path) 36 | return 37 | endif 38 | 39 | let l:cmd = l:actions[a:cmd].' '.l:path 40 | if a:cmd !=? 'discard' 41 | call system(l:cmd) 42 | return defx#call_action('redraw') 43 | endif 44 | 45 | let l:choice = confirm('Are you sure you want to discard all changes to '.l:word.'? ', "&Yes\n&No") 46 | if l:choice !=? 1 47 | return 48 | endif 49 | let l:status = system('git status --porcelain '.l:path) 50 | " File must be unstaged before discarding 51 | if !empty(l:status[0]) 52 | call system(l:actions['reset'].' '.l:path) 53 | endif 54 | 55 | call system(l:cmd) 56 | return defx#call_action('redraw') 57 | endfunction 58 | 59 | nnoremap (defx-git-next) :call search(1) 60 | nnoremap (defx-git-prev) :call search(-1) 61 | nnoremap (defx-git-stage) :call git_cmd('stage') 62 | nnoremap (defx-git-reset) :call git_cmd('reset') 63 | nnoremap (defx-git-discard) :call git_cmd('discard') 64 | 65 | if !hasmapto('(defx-git-prev)') && maparg('[c', 'n') ==? '' 66 | silent! nmap [c (defx-git-prev) 67 | endif 68 | 69 | if !hasmapto('(defx-git-next)') && maparg(']c', 'n') ==? '' 70 | silent! nmap ]c (defx-git-next) 71 | endif 72 | 73 | if !hasmapto('(defx-git-stage)') && maparg(']a', 'n') ==? '' 74 | silent! nmap ]a (defx-git-stage) 75 | endif 76 | 77 | if !hasmapto('(defx-git-reset)') && maparg(']r', 'n') ==? '' 78 | silent! nmap ]r (defx-git-reset) 79 | endif 80 | 81 | if !hasmapto('(defx-git-discard)') && maparg(']d', 'n') ==? '' 82 | silent! nmap ]d (defx-git-discard) 83 | endif 84 | -------------------------------------------------------------------------------- /rplugin/python3/defx/column/git.py: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # FILE: git.py 3 | # AUTHOR: Kristijan Husak 4 | # License: MIT license 5 | # ============================================================================ 6 | 7 | import typing 8 | import subprocess 9 | from defx.base.column import Base, Highlights 10 | from defx.context import Context 11 | from defx.view import View 12 | from defx.util import Nvim, Candidate, len_bytes 13 | from functools import cmp_to_key 14 | from pathlib import PurePath 15 | 16 | 17 | class Column(Base): 18 | 19 | def __init__(self, vim: Nvim) -> None: 20 | super().__init__(vim) 21 | 22 | self.name = 'git' 23 | self.has_get_with_highlights = True 24 | self.vars = { 25 | 'indicators': { 26 | 'Modified': '✹', 27 | 'Staged': '✚', 28 | 'Untracked': '✭', 29 | 'Renamed': '➜', 30 | 'Unmerged': '═', 31 | 'Ignored': '☒', 32 | 'Deleted': '✖', 33 | 'Unknown': '?' 34 | }, 35 | 'column_length': 1, 36 | 'show_ignored': False, 37 | 'raw_mode': False, 38 | 'max_indicator_width': None, 39 | 'git_commit': 'HEAD', 40 | } 41 | 42 | custom_opts = ['indicators', 'column_length', 'show_ignored', 43 | 'raw_mode', 'max_indicator_width'] 44 | 45 | for opt in custom_opts: 46 | if 'defx_git#' + opt in self.vim.vars: 47 | self.vars[opt] = self.vim.vars['defx_git#' + opt] 48 | 49 | self.cache: typing.List[str] = [] 50 | self.git_root = '' 51 | self.colors = { 52 | 'Modified': { 53 | 'color': 'guifg=#fabd2f ctermfg=214', 54 | 'match': ' M' 55 | }, 56 | 'Staged': { 57 | 'color': 'guifg=#b8bb26 ctermfg=142', 58 | 'match': '\(M\|A\|C\).' 59 | }, 60 | 'Renamed': { 61 | 'color': 'guifg=#fabd2f ctermfg=214', 62 | 'match': 'R.' 63 | }, 64 | 'Unmerged': { 65 | 'color': 'guifg=#fb4934 ctermfg=167', 66 | 'match': '\(UU\|AA\|DD\)' 67 | }, 68 | 'Deleted': { 69 | 'color': 'guifg=#fb4934 ctermfg=167', 70 | 'match': ' D' 71 | }, 72 | 'Untracked': { 73 | 'color': 'guifg=NONE guibg=NONE ctermfg=NONE ctermbg=NONE', 74 | 'match': '??' 75 | }, 76 | 'Ignored': { 77 | 'color': 'guifg=NONE guibg=NONE ctermfg=NONE ctermbg=NONE', 78 | 'match': '!!' 79 | }, 80 | 'Unknown': { 81 | 'color': 'guifg=NONE guibg=NONE ctermfg=NONE ctermbg=NONE', 82 | 'match': 'X ' 83 | } 84 | } 85 | 86 | def on_init(self, view: View, context: Context) -> None: 87 | # Set vim global variable for search mappings matching indicators 88 | self.vim.vars['defx_git_indicators'] = self.vars['indicators'] 89 | 90 | if not self.vars.get('max_indicator_width'): 91 | # Find longest indicator 92 | self.vars['max_indicator_width'] = len( 93 | max(self.vars['indicators'].values(), key=len)) 94 | 95 | min_column_length = 2 if self.vars['raw_mode'] else 1 96 | self.column_length = max(min_column_length, self.vars['column_length']) 97 | 98 | def get_with_highlights( 99 | self, context: Context, candidate: Candidate 100 | ) -> typing.Tuple[str, Highlights]: 101 | default = ( 102 | ''.ljust(self.column_length + self.vars['max_indicator_width'] - 1), 103 | [] 104 | ) 105 | if candidate.get('is_root', False): 106 | self.cache_status(candidate['action__path']) 107 | return default 108 | 109 | if not self.cache: 110 | return default 111 | 112 | entry = self.find_in_cache(candidate) 113 | 114 | if not entry: 115 | return default 116 | 117 | return self.get_indicator(entry) 118 | 119 | def get_indicator(self, entry: str) -> str: 120 | state = self.get_indicator_name(entry[0], entry[1]) 121 | 122 | if self.vars['raw_mode']: 123 | return self.format(entry[:2], state) 124 | 125 | return self.format(self.vars['indicators'][state], state) 126 | 127 | def length(self, context: Context) -> int: 128 | return self.column_length 129 | 130 | def highlight_commands(self) -> typing.List[str]: 131 | commands: typing.List[str] = [] 132 | for name, icon in self.vars['indicators'].items(): 133 | commands.append(('silent! syntax clear {0}_{1}') 134 | .format(self.highlight_name, name)) 135 | if self.vars['raw_mode']: 136 | commands.append(( 137 | 'syntax match {0}_{1} /{2}/ contained containedin={3}' 138 | ).format(self.highlight_name, name, self.colors[name]['match'], 139 | self.syntax_name)) 140 | else: 141 | commands.append(( 142 | 'syntax match {0}_{1} /[{2}]/ contained containedin={3}' 143 | ).format(self.highlight_name, name, icon, self.syntax_name)) 144 | 145 | commands.append('highlight default {0}_{1} {2}'.format( 146 | self.highlight_name, name, self.colors[name]['color'] 147 | )) 148 | 149 | return commands 150 | 151 | def find_in_cache(self, candidate: dict) -> str: 152 | action_path = PurePath(candidate['action__path']).as_posix() 153 | path = str(action_path).replace(f'{self.git_root}/', '') 154 | path += '/' if candidate['is_directory'] else '' 155 | for item in self.cache: 156 | item_path = item[3:] 157 | if ' ' in item_path and item_path[0] == '"': 158 | item_path = item_path.strip('"') 159 | 160 | if item[0] == 'R': 161 | item_path = item_path.split(' -> ')[1] 162 | 163 | if item_path.startswith(path): 164 | return item 165 | 166 | return '' 167 | 168 | def cache_status(self, path: str) -> None: 169 | self.cache = [] 170 | 171 | if not self.git_root or not str(path).startswith(self.git_root): 172 | self.git_root = PurePath(self.run_cmd( 173 | ['git', 'rev-parse', '--show-toplevel'], path 174 | )).as_posix() 175 | 176 | if not self.git_root: 177 | return None 178 | 179 | if self.vars['git_commit'].upper() != 'HEAD': 180 | diff_cmd = ['git', 'diff', '--name-status', self.vars['git_commit']] 181 | results = [ 182 | f" {line}".replace("\t", " ") 183 | for line 184 | in self.run_cmd(diff_cmd, self.git_root).split('\n') 185 | if line != '' 186 | ] 187 | 188 | untracked_cmd = ['git', 'ls-files', '--exclude-standard', '--others'] 189 | if self.vars['show_ignored']: 190 | untracked_cmd += ['--ignored'] 191 | 192 | results += [ 193 | f"?? {line}" 194 | for line 195 | in self.run_cmd(untracked_cmd, self.git_root).split('\n') 196 | if line != '' 197 | ] 198 | 199 | else: 200 | cmd = ['git', 'status', '--porcelain', '-u'] 201 | if self.vars['show_ignored']: 202 | cmd += ['--ignored'] 203 | status = self.run_cmd(cmd, self.git_root) 204 | 205 | results = [line.replace('\t', ' ') for line in status.split('\n') if line != ''] 206 | 207 | self.cache = sorted(results, key=cmp_to_key(self.sort)) 208 | 209 | def sort(self, a, b) -> int: 210 | if a[0] == 'U' or a[1] == 'U': 211 | return -1 212 | 213 | if (a[0] == 'M' or a[1] == 'M') and not (b[0] == 'U' or b[1] == 'U'): 214 | return -1 215 | 216 | if ((a[0] == '?' and a[1] == '?') and not 217 | (b[0] in ['M', 'U'] or b[1] in ['M', 'U'])): 218 | return -1 219 | 220 | return 1 221 | 222 | def format(self, column: str, indicator_name) -> str: 223 | icon = format(column, f'<{self.column_length}') 224 | return (icon, 225 | [( 226 | f'{self.highlight_name}_{indicator_name}', 227 | self.start, len_bytes(icon) 228 | )] 229 | ) 230 | 231 | def get_indicator_name(self, us: str, them: str) -> str: 232 | if us == '?' and them == '?': 233 | return 'Untracked' 234 | elif us == ' ' and them == 'M': 235 | return 'Modified' 236 | elif us in ['M', 'A', 'C']: 237 | return 'Staged' 238 | elif us == 'R': 239 | return 'Renamed' 240 | elif us == '!': 241 | return 'Ignored' 242 | elif (us == 'U' or them == 'U' or us == 'A' and them == 'A' 243 | or us == 'D' and them == 'D'): 244 | return 'Unmerged' 245 | elif them == 'D': 246 | return 'Deleted' 247 | else: 248 | return 'Unknown' 249 | 250 | def run_cmd(self, cmd: typing.List[str], cwd=None) -> str: 251 | try: 252 | p = subprocess.run(cmd, stdout=subprocess.PIPE, 253 | stderr=subprocess.DEVNULL, cwd=cwd) 254 | except: 255 | return '' 256 | 257 | decoded = p.stdout.decode('utf-8') 258 | 259 | if not decoded: 260 | return '' 261 | 262 | return decoded.strip('\n') 263 | --------------------------------------------------------------------------------