import sublime import sublime_plugin import os import re import subprocess from .util import view_is_suitable, communicate_error, platform_startupinfo from .templates import blame_all_phantom_html_template, blame_all_phantom_css PHANTOM_KEY_ALL = "git-blame-all" SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed" class BlameShowAll(sublime_plugin.TextCommand): # The fixed length for author names NAME_LENGTH = 10 # Overrides -------------------------------------------------- def __init__(self, view): super().__init__(view) self.phantom_set = sublime.PhantomSet(self.view, PHANTOM_KEY_ALL) self.pattern = None def run(self, edit): if not view_is_suitable(self.view): return self.view.erase_phantoms(PHANTOM_KEY_ALL) phantoms = [] # If they are currently shown, toggle them off and return. if self.view.settings().get(SETTING_PHANTOM_ALL_DISPLAYED, False): self.phantom_set.update(phantoms) self.view.settings().set(SETTING_PHANTOM_ALL_DISPLAYED, False) return try: blame_output = self.get_blame(self.view.file_name()) except Exception as e: communicate_error(e) return for l in blame_output.splitlines(): parsed = self.parse_blame(l) if not parsed: continue sha, author, date, time, line_number = parsed line_point = self.get_line_point(line_number - 1) phantom = sublime.Phantom( line_point, blame_all_phantom_html_template.format( css=blame_all_phantom_css, sha=sha, user=self.format_name(author), date=date, time=time, ), sublime.LAYOUT_INLINE, self.on_phantom_close, ) phantoms.append(phantom) self.phantom_set.update(phantoms) self.view.settings().set(SETTING_PHANTOM_ALL_DISPLAYED, True) # Bring the phantoms into view without the user needing to manually scroll left. self.view.set_viewport_position((0.0, self.view.viewport_position()[1])) def on_phantom_close(self, href): """Closes opened phantoms. """ if href == "close": self.view.run_command("blame_erase_all") # ------------------------------------------------------------ def get_blame(self, path): return subprocess.check_output( # The option --show-name is necessary to force file name display. ["git", "blame", "--show-name", "--minimal", "-w", os.path.basename(path)], cwd=os.path.dirname(os.path.realpath(path)), startupinfo=platform_startupinfo(), stderr=subprocess.STDOUT, ).decode("utf-8") def parse_blame(self, blame): """Parses git blame output. """ if not self.pattern: self.prepare_pattern() m = self.pattern.match(blame) if m: sha = m.group("sha") # Currently file is not used. # file = m.group('file') author = m.group("author") date = m.group("date") time = m.group("time") line_number = int(m.group("line_number")) return sha, author, date, time, line_number else: return None def prepare_pattern(self): """Prepares the regex pattern to parse git blame output. """ # The SHA output by git-blame may have a leading caret to indicate # that it is a "boundary commit". p_sha = r"(?P<sha>\^?\w+)" p_file = r"((?P<file>[\S ]+)\s+)" p_author = r"(?P<author>.+?)" p_date = r"(?P<date>\d{4}-\d{2}-\d{2})" p_time = r"(?P<time>\d{2}:\d{2}:\d{2})" p_timezone = r"(?P<timezone>[\+-]\d+)" p_line = r"(?P<line_number>\d+)" s = r"\s+" self.pattern = re.compile( r"^" + p_sha + s + p_file + r"\(" + p_author + s + p_date + s + p_time + s + p_timezone + s + p_line + r"\) " ) def format_name(self, name): """Formats author names so that widths of phantoms become equal. """ ellipsis = "..." if len(name) > self.NAME_LENGTH: return name[: self.NAME_LENGTH] + ellipsis else: return name + "." * (self.NAME_LENGTH - len(name)) + ellipsis def get_line_point(self, line): """Get the point of specified line in a view. """ return self.view.line(self.view.text_point(line, 0)) class BlameEraseAll(sublime_plugin.TextCommand): # Overrides -------------------------------------------------- def run(self, edit): """Erases the blame results. """ sublime.status_message("The git blame result is cleared.") self.view.erase_phantoms(PHANTOM_KEY_ALL) class BlameEraseAllListener(sublime_plugin.ViewEventListener): # Overrides -------------------------------------------------- @classmethod def is_applicable(cls, settings): """Checks if the blame_erase_all command is applicable. """ return settings.get(SETTING_PHANTOM_ALL_DISPLAYED, False) def on_modified_async(self): """Automatically erases the blame results to prevent mismatches. """ self.view.run_command("blame_erase_all") self.view.settings().erase(SETTING_PHANTOM_ALL_DISPLAYED)