Skip to content

Add inline blame (like in VSCode) #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Settings/Git blame.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
// ["-M"]
// ["-C", "-C"]
//
"custom_blame_flags": []
"custom_blame_flags": [],
"inline_blame_enabled": false,
"inline_blame_delay": 300
}
2 changes: 1 addition & 1 deletion boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Make Sublime aware of our *{Command,Listener,Handler} classes by importing them:
from .src.blame import * # noqa: F401,F403
from .src.blame_all import * # noqa: F401,F403

from .src.blame_inline import * # noqa: F401,F403

def plugin_loaded():
pass
Expand Down
68 changes: 68 additions & 0 deletions src/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import subprocess
import sys
from abc import ABCMeta, abstractmethod
from urllib.parse import parse_qs, quote_plus, urlparse

import sublime

Expand Down Expand Up @@ -40,6 +41,10 @@ def get_commit_text(self, sha, path):
cli_args = ["show", "--no-color", sha]
return self.run_git(path, cli_args)

def get_commit_message_first_line(self, sha, path):
cli_args = ["show", sha, "--pretty=format:%s", "--no-patch"]
return self.run_git(path, cli_args)

@classmethod
def parse_line(cls, line):
pattern = r"""(?x)
Expand All @@ -58,6 +63,69 @@ def parse_line(cls, line):
m = re.match(pattern, line)
return m.groupdict() if m else {}

@classmethod
def parse_line_with_relative_date(cls, line):
"""
The difference from parse_line is that date/time/timezone are replaced with relative_date
to be able to parse human readable format
https://github.com/git/git/blob/c09b6306c6ca275ed9d0348a8c8014b2ff723cfb/date.c#L131
"""
pattern = r"""(?x)
^ (?P<sha>\^?\w+)
\s+ (?P<file>[\S ]+)
\s+
\( (?P<author>.+?)
\s+ (?P<relative_date>\d+.+ago)
\s+ (?P<line_number>\d+)
\)
\s
"""
# re's module-level functions like match(...) internally cache the compiled form of pattern strings.
m = re.match(pattern, line)
return m.groupdict() if m else {}

def handle_phantom_button(self, href):
url = urlparse(href)
querystring = parse_qs(url.query)
# print(url)
# print(querystring)

if url.path == "copy":
sublime.set_clipboard(querystring["sha"][0])
sublime.status_message("Git SHA copied to clipboard")
elif url.path == "show":
sha = querystring["sha"][0]
try:
desc = self.get_commit_text(sha, self.view.file_name())
except Exception as e:
self.communicate_error(e)
return

buf = self.view.window().new_file()
buf.run_command(
"blame_insert_commit_description",
{"desc": desc, "scratch_view_name": "commit " + sha},
)
elif url.path == "prev":
sha = querystring["sha"][0]
row_num = querystring["row_num"][0]
sha_skip_list = querystring.get("skip", [])
if sha not in sha_skip_list:
sha_skip_list.append(sha)
self.run(
None,
prevving=True,
fixed_row_num=int(row_num),
sha_skip_list=sha_skip_list,
)
elif url.path == "close":
# Erase all phantoms
self.phantom_set.update([])
else:
self.communicate_error(
"No handler for URL path '{0}' in phantom".format(url.path)
)

def has_suitable_view(self):
view = self._view()
return view.file_name() and not view.is_dirty()
Expand Down
44 changes: 0 additions & 44 deletions src/blame.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from urllib.parse import parse_qs, quote_plus, urlparse

import sublime
import sublime_plugin

Expand Down Expand Up @@ -128,48 +126,6 @@ def extra_cli_args(self, line_num, sha_skip_list):
def phantom_exists_for_region(self, region):
return any(p.region == region for p in self.phantom_set.phantoms)

def handle_phantom_button(self, href):
url = urlparse(href)
querystring = parse_qs(url.query)
# print(url)
# print(querystring)

if url.path == "copy":
sublime.set_clipboard(querystring["sha"][0])
sublime.status_message("Git SHA copied to clipboard")
elif url.path == "show":
sha = querystring["sha"][0]
try:
desc = self.get_commit_text(sha, self.view.file_name())
except Exception as e:
self.communicate_error(e)
return

buf = self.view.window().new_file()
buf.run_command(
"blame_insert_commit_description",
{"desc": desc, "scratch_view_name": "commit " + sha},
)
elif url.path == "prev":
sha = querystring["sha"][0]
row_num = querystring["row_num"][0]
sha_skip_list = querystring.get("skip", [])
if sha not in sha_skip_list:
sha_skip_list.append(sha)
self.run(
None,
prevving=True,
fixed_row_num=int(row_num),
sha_skip_list=sha_skip_list,
)
elif url.path == "close":
# Erase all phantoms
self.phantom_set.update([])
else:
self.communicate_error(
"No handler for URL path '{0}' in phantom".format(url.path)
)


class BlameInsertCommitDescription(sublime_plugin.TextCommand):

Expand Down
105 changes: 105 additions & 0 deletions src/blame_inline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import threading
import sublime
import sublime_plugin

from .base import BaseBlame
from .templates import blame_inline_phantom_css, blame_inline_phantom_html_template
from .settings import (
pkg_settings,
PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED,
PKG_SETTINGS_KEY_INLINE_BLAME_DELAY,
)

INLINE_BLAME_PHANTOM_SET_KEY = "git-blame-inline"


class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
@classmethod
def is_applicable(cls, settings):
return pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED)

def __init__(self, view):
super().__init__(view)
self.phantom_set = sublime.PhantomSet(view, INLINE_BLAME_PHANTOM_SET_KEY)
self.timer = None
self.delay_seconds = (
pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_DELAY) / 1000
)

def extra_cli_args(self, line_num):
args = ["-L", "{0},{0}".format(line_num), "--date=relative"]
return args

def _view(self):
return self.view

def show_inline_blame(self):
if self.view.is_dirty():
# If there have already been unsaved edits, stop the git child process from being ran at all.
return

phantoms = []
sels = self.view.sel()
line = self.view.line(sels[0])
if line.size() < 2:
# avoid weird behaviour of regions on empty lines
# < 2 is to check for newline character
return
pos = line.end()
row, _ = self.view.rowcol(line.begin())
anchor = sublime.Region(pos, pos)
try:
blame_output = self.get_blame_text(self.view.file_name(), line_num=row + 1)
except Exception:
return
blame = next(
(
self.parse_line_with_relative_date(line)
for line in blame_output.splitlines()
),
None,
)
if not blame:
return
summary = ""
# Uncommitted changes have only zeros in sha
if blame["sha"] != "00000000":
try:
summary = self.get_commit_message_first_line(
blame["sha"], self.view.file_name()
)
except Exception as e:
return
body = blame_inline_phantom_html_template.format(
css=blame_inline_phantom_css,
author=blame["author"],
date=blame["relative_date"],
qs_sha_val=blame["sha"],
summary=summary,
)
phantom = sublime.Phantom(
anchor, body, sublime.LAYOUT_INLINE, self.handle_phantom_button
)
phantoms.append(phantom)

# Dispatch back onto the main thread to serialize a final is_dirty check.
sublime.set_timeout(lambda: self.maybe_insert_phantoms(phantoms), 0)

def maybe_insert_phantoms(self, phantoms):
if not self.view.is_dirty():
self.phantom_set.update(phantoms)

def show_inline_blame_handler(self):
self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
if self.timer:
self.timer.cancel()
self.timer = threading.Timer(self.delay_seconds, self.show_inline_blame)
self.timer.start()

def on_selection_modified_async(self):
self.show_inline_blame_handler()

def on_post_save_async(self):
# Redisplay the blame after the file is saved, because there will be
# no call to on_selection_modified_async after save.
self.show_inline_blame_handler()
3 changes: 3 additions & 0 deletions src/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ def pkg_settings():


PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS = "custom_blame_flags"

PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED = "inline_blame_enabled"
PKG_SETTINGS_KEY_INLINE_BLAME_DELAY = "inline_blame_delay"
25 changes: 25 additions & 0 deletions src/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,28 @@
background-color: #ffffff18;
}
"""

# ------------------------------------------------------------

blame_inline_phantom_html_template = """
<body id="inline-git-blame">
<style>{css}</style>
<div class="phantom">
<span class="message">
{author},&nbsp;{date}&nbsp;&#183;&nbsp;{summary}&nbsp;<a href="copy?sha={qs_sha_val}">[Copy]</a>&nbsp;<a href="show?sha={qs_sha_val}">[Show]</a>
</span>
</div>
</body>
"""


blame_inline_phantom_css = """
div.phantom {
color: color(var(--bluish) blend(var(--background) 60%));
padding: 0;
margin-left: 50px;
}
div.phantom a {
text-decoration: inherit;
}
"""