|
1 | 1 | import sublime
|
2 | 2 | import sublime_plugin
|
3 | 3 | import os
|
| 4 | +import re |
4 | 5 | import functools
|
5 | 6 | import subprocess
|
6 | 7 | from subprocess import check_output as shell
|
7 | 8 |
|
8 |
| -stylesheet = ''' |
| 9 | +PHANTOM_KEY_ALL = 'git-blame-all' |
| 10 | + |
| 11 | +stylesheet_one = ''' |
9 | 12 | <style>
|
10 | 13 | div.phantom-arrow {
|
11 | 14 | border-top: 0.4rem solid transparent;
|
|
41 | 44 | </style>
|
42 | 45 | '''
|
43 | 46 |
|
44 |
| -template = ''' |
| 47 | +template_one = ''' |
45 | 48 | <body id="inline-git-blame">
|
46 | 49 | {stylesheet}
|
47 | 50 | <div class="phantom-arrow"></div>
|
|
58 | 61 | </body>
|
59 | 62 | '''
|
60 | 63 |
|
| 64 | +stylesheet_all = ''' |
| 65 | + <style> |
| 66 | + div.phantom { |
| 67 | + padding: 0; |
| 68 | + margin: 0; |
| 69 | + background-color: color(var(--bluish) blend(var(--background) 30%)); |
| 70 | + } |
| 71 | + div.phantom .user { |
| 72 | + width: 10em; |
| 73 | + } |
| 74 | + div.phantom a.close { |
| 75 | + padding: 0.35rem 0.7rem 0.45rem 0.8rem; |
| 76 | + position: relative; |
| 77 | + bottom: 0.05rem; |
| 78 | + font-weight: bold; |
| 79 | + } |
| 80 | + html.dark div.phantom a.close { |
| 81 | + background-color: #00000018; |
| 82 | + } |
| 83 | + html.light div.phantom a.close { |
| 84 | + background-color: #ffffff18; |
| 85 | + } |
| 86 | + </style> |
| 87 | +''' |
| 88 | + |
| 89 | +template_all = ''' |
| 90 | + <body id="inline-git-blame"> |
| 91 | + {stylesheet} |
| 92 | + <div class="phantom"> |
| 93 | + <span class="message"> |
| 94 | + {sha} (<span class="user">{user}</span> {date} {time}) |
| 95 | + <a class="close" href="close">''' + chr(0x00D7) + '''</a> |
| 96 | + </span> |
| 97 | + </div> |
| 98 | + </body> |
| 99 | +''' |
| 100 | + |
61 | 101 | # Sometimes this fails on other OS, just error silently
|
62 | 102 | try:
|
63 | 103 | si = subprocess.STARTUPINFO()
|
64 | 104 | si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
65 | 105 | except:
|
66 | 106 | si = None
|
67 | 107 |
|
| 108 | + |
68 | 109 | class BlameCommand(sublime_plugin.TextCommand):
|
69 | 110 |
|
70 | 111 | def __init__(self, view):
|
@@ -148,13 +189,149 @@ def run(self, edit):
|
148 | 189 |
|
149 | 190 | sha, user, date, time = self.parse_blame(result)
|
150 | 191 |
|
151 |
| - body = template.format(sha=sha, user=user, date=date, time=time, stylesheet=stylesheet) |
| 192 | + body = template_one.format(sha=sha, user=user, date=date, time=time, stylesheet=stylesheet_one) |
152 | 193 |
|
153 | 194 | phantom = sublime.Phantom(line, body, sublime.LAYOUT_BLOCK, self.on_phantom_close)
|
154 | 195 | phantoms.append(phantom)
|
155 | 196 | self.phantom_set.update(phantoms)
|
156 | 197 |
|
157 | 198 |
|
| 199 | +class BlameShowAllCommand(sublime_plugin.TextCommand): |
| 200 | + |
| 201 | + # The fixed length for author names |
| 202 | + NAME_LENGTH = 10 |
| 203 | + |
| 204 | + def __init__(self, view): |
| 205 | + super().__init__(view) |
| 206 | + self.phantom_set = sublime.PhantomSet(self.view, PHANTOM_KEY_ALL) |
| 207 | + self.pattern = None |
| 208 | + |
| 209 | + def run(self, edit): |
| 210 | + if self.view.is_dirty(): |
| 211 | + sublime.status_message("The file needs to be saved for git blame.") |
| 212 | + return |
| 213 | + |
| 214 | + self.view.erase_phantoms(PHANTOM_KEY_ALL) |
| 215 | + |
| 216 | + blame_lines = self.get_blame_lines(self.view.file_name()) |
| 217 | + |
| 218 | + if not blame_lines: |
| 219 | + return |
| 220 | + |
| 221 | + phantoms = [] |
| 222 | + for l in blame_lines: |
| 223 | + parsed = self.parse_blame(l) |
| 224 | + if not parsed: |
| 225 | + continue |
| 226 | + |
| 227 | + sha, author, date, time, line_number = parsed |
| 228 | + |
| 229 | + body = template_all.format(sha=sha, |
| 230 | + user=self.format_name(author), |
| 231 | + date=date, |
| 232 | + time=time, |
| 233 | + stylesheet=stylesheet_all) |
| 234 | + |
| 235 | + line_point = self.get_line_point(line_number - 1) |
| 236 | + phantom = sublime.Phantom(line_point, |
| 237 | + body, |
| 238 | + sublime.LAYOUT_INLINE, |
| 239 | + self.on_phantom_close) |
| 240 | + phantoms.append(phantom) |
| 241 | + |
| 242 | + self.phantom_set.update(phantoms) |
| 243 | + |
| 244 | + def get_blame_lines(self, path): |
| 245 | + '''Run `git blame` and get the output lines. |
| 246 | + ''' |
| 247 | + try: |
| 248 | + # The option --show-name is necessary to force file name display. |
| 249 | + command = ["git", "blame", "--show-name", "--minimal", "-w", path] |
| 250 | + output = shell(command, |
| 251 | + cwd=os.path.dirname(os.path.realpath(path)), |
| 252 | + startupinfo=si, |
| 253 | + stderr=subprocess.STDOUT) |
| 254 | + return output.decode("UTF-8").splitlines() |
| 255 | + except subprocess.CalledProcessError as e: |
| 256 | + print("Git blame: git error {}:\n{}".format(e.returncode, e.output.decode("UTF-8"))) |
| 257 | + except Exception as e: |
| 258 | + print("Git blame: Unexpected error:", e) |
| 259 | + |
| 260 | + def parse_blame(self, blame): |
| 261 | + '''Parses git blame output. |
| 262 | + ''' |
| 263 | + if not self.pattern: |
| 264 | + self.prepare_pattern() |
| 265 | + |
| 266 | + m = self.pattern.match(blame) |
| 267 | + if m: |
| 268 | + sha = m.group('sha') |
| 269 | + # Currently file is not used. |
| 270 | + # file = m.group('file') |
| 271 | + author = m.group('author') |
| 272 | + date = m.group('date') |
| 273 | + time = m.group('time') |
| 274 | + line_number = int(m.group('line_number')) |
| 275 | + return sha, author, date, time, line_number |
| 276 | + else: |
| 277 | + return None |
| 278 | + |
| 279 | + def prepare_pattern(self): |
| 280 | + '''Prepares the regex pattern to parse git blame output. |
| 281 | + ''' |
| 282 | + # The SHA output by git-blame may have a leading caret to indicate |
| 283 | + # that it is a "boundary commit". |
| 284 | + p_sha = r'(?P<sha>\^?\w+)' |
| 285 | + p_file = r'((?P<file>[\S ]+)\s+)' |
| 286 | + p_author = r'(?P<author>.+?)' |
| 287 | + p_date = r'(?P<date>\d{4}-\d{2}-\d{2})' |
| 288 | + p_time = r'(?P<time>\d{2}:\d{2}:\d{2})' |
| 289 | + p_timezone = r'(?P<timezone>[\+-]\d+)' |
| 290 | + p_line = r'(?P<line_number>\d+)' |
| 291 | + s = r'\s+' |
| 292 | + |
| 293 | + self.pattern = re.compile(r'^' + p_sha + s + p_file + r'\(' + |
| 294 | + p_author + s + p_date + s + p_time + s + |
| 295 | + p_timezone + s + p_line + r'\) ') |
| 296 | + |
| 297 | + def format_name(self, name): |
| 298 | + '''Formats author names so that widths of phantoms become equal. |
| 299 | + ''' |
| 300 | + ellipsis = '...' |
| 301 | + if len(name) > self.NAME_LENGTH: |
| 302 | + return name[:self.NAME_LENGTH] + ellipsis |
| 303 | + else: |
| 304 | + return name + '.' * (self.NAME_LENGTH - len(name)) + ellipsis |
| 305 | + |
| 306 | + def get_line_point(self, line): |
| 307 | + '''Get the point of specified line in a view. |
| 308 | + ''' |
| 309 | + return self.view.line(self.view.text_point(line, 0)) |
| 310 | + |
| 311 | + def on_phantom_close(self, href): |
| 312 | + '''Closes opened phantoms. |
| 313 | + ''' |
| 314 | + if href == 'close': |
| 315 | + self.view.run_command('blame_erase_all') |
| 316 | + |
| 317 | + |
| 318 | +class BlameEraseAllCommand(sublime_plugin.TextCommand): |
| 319 | + |
| 320 | + def run(self, edit): |
| 321 | + '''Erases the blame results. |
| 322 | + ''' |
| 323 | + sublime.status_message("The git blame result is cleared.") |
| 324 | + self.view.erase_phantoms(PHANTOM_KEY_ALL) |
| 325 | + |
| 326 | + |
| 327 | +class BlameEraseAllListener(sublime_plugin.ViewEventListener): |
| 328 | + |
| 329 | + def on_modified(self): |
| 330 | + '''Automatically erases the blame results to prevent mismatches. |
| 331 | + ''' |
| 332 | + self.view.run_command('blame_erase_all') |
| 333 | + |
| 334 | + |
158 | 335 | class InsertCommitDescriptionCommand(sublime_plugin.TextCommand):
|
159 | 336 |
|
160 | 337 | def run(self, edit, desc):
|
|
0 commit comments