From e001e443b6706a445b4c36bf71b373769cfe3580 Mon Sep 17 00:00:00 2001
From: z <z@rndz.org>
Date: Fri, 15 Feb 2019 18:20:34 +0800
Subject: [PATCH 001/180] fix not working in windows because path (#30)

---
 git-blame.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/git-blame.py b/git-blame.py
index e877393..d983f6e 100644
--- a/git-blame.py
+++ b/git-blame.py
@@ -117,7 +117,7 @@ def __init__(self, view):
     def get_blame(self, line, path):
         try:
             return shell(
-                ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), path],
+                ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)],
                 cwd=os.path.dirname(os.path.realpath(path)),
                 startupinfo=si,
                 stderr=subprocess.STDOUT
@@ -268,7 +268,7 @@ def get_blame_lines(self, path):
         '''
         try:
             # The option --show-name is necessary to force file name display.
-            command = ["git", "blame", "--show-name", "--minimal", "-w", path]
+            command = ["git", "blame", "--show-name", "--minimal", "-w", os.path.basename(path)]
             output = shell(
                 command,
                 cwd=os.path.dirname(os.path.realpath(path)),

From b8686ab65dc58adeceab811ba10b389a75394414 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 6 Apr 2019 20:24:18 +0100
Subject: [PATCH 002/180] Add attribution

---
 .github/ISSUE_TEMPLATE.md | 2 --
 README.md                 | 2 ++
 2 files changed, 2 insertions(+), 2 deletions(-)
 delete mode 100644 .github/ISSUE_TEMPLATE.md

diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
deleted file mode 100644
index 9ebe9ff..0000000
--- a/.github/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,2 +0,0 @@
-<!-- This project is currently unmaintained please see issue #35
-(https://github.com/psykzz/st3-gitblame/issues/35). Thanks! -->
diff --git a/README.md b/README.md
index 0981b92..826763a 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,8 @@ Small plugin to parse git blame and add a view to show the user and datetime of
 
 View on [packagecontrol.io](https://packagecontrol.io/packages/Git%20blame)
 
+This package was originally created by [@psykzz](https://github.com/psykzz) and is now maintained by [@frou](https://github.com/frou)
+
 
 ## Usage
 

From 86db495c8e0061a54cb60bdf3aab4988cdc8dbce Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 7 Apr 2019 17:25:01 +0100
Subject: [PATCH 003/180] context menu: take the mouse position into account

---
 git-blame.py | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

diff --git a/git-blame.py b/git-blame.py
index 7f3d786..10d5165 100644
--- a/git-blame.py
+++ b/git-blame.py
@@ -175,10 +175,21 @@ def on_phantom_close(self, href):
         else:
             self.view.erase_phantoms('git-blame')
 
-    def run(self, edit):
+    def want_event(self):
+        return True
+
+    def run(self, edit, event=None):
         if not view_is_suitable(self.view):
             return
 
+        if event:
+            # This command was ran from the context menu. Before proceeding,
+            # move the caret to where the mouse was clicked.
+            mouse_position = (event["x"], event["y"])
+            text_point = self.view.window_to_text(mouse_position)
+            self.view.sel().clear()
+            self.view.sel().add(sublime.Region(text_point))
+
         phantoms = []
         self.view.erase_phantoms('git-blame')
         # Before adding the phantom, see if the current phantom that is displayed is at the same spot at the selection

From dadeb95eeae4f9af07a5e178555256d996a981c1 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 9 Apr 2019 15:09:53 +0100
Subject: [PATCH 004/180] Revert "context menu: take the mouse position into
 account"

This reverts commit 86db495c8e0061a54cb60bdf3aab4988cdc8dbce.

The way Sublime's stock context menu stuff (e.g. Cut, Copy) works does not clear existing selections.
---
 git-blame.py | 13 +------------
 1 file changed, 1 insertion(+), 12 deletions(-)

diff --git a/git-blame.py b/git-blame.py
index 10d5165..7f3d786 100644
--- a/git-blame.py
+++ b/git-blame.py
@@ -175,21 +175,10 @@ def on_phantom_close(self, href):
         else:
             self.view.erase_phantoms('git-blame')
 
-    def want_event(self):
-        return True
-
-    def run(self, edit, event=None):
+    def run(self, edit):
         if not view_is_suitable(self.view):
             return
 
-        if event:
-            # This command was ran from the context menu. Before proceeding,
-            # move the caret to where the mouse was clicked.
-            mouse_position = (event["x"], event["y"])
-            text_point = self.view.window_to_text(mouse_position)
-            self.view.sel().clear()
-            self.view.sel().add(sublime.Region(text_point))
-
         phantoms = []
         self.view.erase_phantoms('git-blame')
         # Before adding the phantom, see if the current phantom that is displayed is at the same spot at the selection

From 27c74dc25dc085535c24729a1841302041e82433 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 13 Apr 2019 21:10:58 +0100
Subject: [PATCH 005/180] Update URLs for badges

---
 README.md | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 826763a..4b0f018 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,5 @@
-[![GitHub contributors](https://img.shields.io/github/contributors/psykzz/st3-gitblame.svg)](https://github.com/psykzz/st3-gitblame/graphs/contributors)
-[![GitHub issues](https://img.shields.io/github/issues/psykzz/st3-gitblame.svg)](https://github.com/psykzz/st3-gitblame/issues)
-[![CircleCI](https://circleci.com/gh/psykzz/st3-gitblame.svg?style=svg)](https://circleci.com/gh/psykzz/st3-gitblame)
-
+[![GitHub contributors](https://img.shields.io/github/contributors/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/graphs/contributors)
+[![GitHub issues](https://img.shields.io/github/issues/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/issues)
 
 # Git blame - Sublime text 3 plugin
 Sublime text 3 - Git blame the line

From 854c3f3538322b98a4e024636074146b1303e7f7 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Apr 2019 18:08:54 +0100
Subject: [PATCH 006/180] Remove CI:

I can just be the lint gate.
---
 .circleci/config.yml | 36 -----------------
 .gitignore           | 92 --------------------------------------------
 requirements.txt     |  1 -
 tox.ini              |  4 --
 4 files changed, 133 deletions(-)
 delete mode 100644 .circleci/config.yml
 delete mode 100644 .gitignore
 delete mode 100644 requirements.txt
 delete mode 100644 tox.ini

diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 6eb8b08..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-# Python CircleCI 2.0 configuration file
-version: 2
-jobs:
-  build:
-    docker:
-      - image: circleci/python:3
-
-    steps:
-      - checkout
-
-      - restore_cache:
-          keys:
-          - deps-{{ checksum "requirements.txt" }}
-
-      - run:
-          name: install dependencies
-          command: |
-            python3 -m venv venv
-            . venv/bin/activate
-            pip install -r requirements.txt
-
-      - save_cache:
-          paths:
-            - ./venv
-          key: deps-{{ checksum "requirements.txt" }}
-
-      - run:
-          name: run linting and metrics
-          command: |
-            python3 -m venv venv
-            . venv/bin/activate
-            flake8 . --output-file test-reports --tee --statistics
-
-      - store_artifacts:
-          path: test-reports
-          destination: test-reports
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 2724688..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,92 +0,0 @@
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-env/
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-*.egg-info/
-.installed.cfg
-*.egg
-
-# PyInstaller
-#  Usually these files are written by a python script from a template
-#  before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*,cover
-.hypothesis/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-target/
-
-# IPython Notebook
-.ipynb_checkpoints
-
-# pyenv
-.python-version
-
-# celery beat schedule file
-celerybeat-schedule
-
-# dotenv
-.env
-
-# virtualenv
-venv/
-ENV/
-
-# Spyder project settings
-.spyderproject
-
-# Rope project settings
-.ropeproject
-
-# Additions
-test-reports
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 1ae3ec6..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-flake8
\ No newline at end of file
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 1292c64..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,4 +0,0 @@
-[flake8]
-exclude = .git,__pycache__,venv
-max-complexity = 10
-max-line-length = 120
\ No newline at end of file

From 9f07457193bdefa5d7bd2a783e66fa4504f79124 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Apr 2019 18:16:46 +0100
Subject: [PATCH 007/180] Add badge

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index 4b0f018..404473e 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+[![GitHub release](https://img.shields.io/github/release/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/releases)
 [![GitHub contributors](https://img.shields.io/github/contributors/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/graphs/contributors)
 [![GitHub issues](https://img.shields.io/github/issues/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/issues)
 

From 51bfd32cbeb48048441c609c47eb410b2b406def Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Apr 2019 19:05:54 +0100
Subject: [PATCH 008/180] Add a Linux keybinding for blame_show_all:

I looked at Sublime's "Default/Default (Linux).sublime-keymap" and the keybinding I'm choosing does not appear to be already used for anything.
---
 Default (Linux).sublime-keymap | 8 +++-----
 1 file changed, 3 insertions(+), 5 deletions(-)

diff --git a/Default (Linux).sublime-keymap b/Default (Linux).sublime-keymap
index 79a4062..a4462b8 100644
--- a/Default (Linux).sublime-keymap	
+++ b/Default (Linux).sublime-keymap	
@@ -1,6 +1,4 @@
 [
-{
-    "keys": ["ctrl+shift+q"],
-    "command": "blame"
-}
-]
\ No newline at end of file
+  {"keys": ["ctrl+shift+q"], "command": "blame"},
+  {"keys": ["ctrl+shift+c"], "command": "blame_show_all"}
+]

From ad42b624db057a0c30a70e2bcd00a083800ec901 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Apr 2019 19:28:50 +0100
Subject: [PATCH 009/180] Redo the README:

I tried to phrase it so that someone who barely knows what git or blame is might still be able to understand what to do. Also, I replaced the 5 tightly cropped screenshots that showed the same thing in different colours with a single more spacious one.
---
 README.md      |  34 ++++++++++++++++------------------
 screenshot.png | Bin 0 -> 30854 bytes
 2 files changed, 16 insertions(+), 18 deletions(-)
 create mode 100644 screenshot.png

diff --git a/README.md b/README.md
index 404473e..984828b 100644
--- a/README.md
+++ b/README.md
@@ -2,34 +2,32 @@
 [![GitHub contributors](https://img.shields.io/github/contributors/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/graphs/contributors)
 [![GitHub issues](https://img.shields.io/github/issues/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/issues)
 
-# Git blame - Sublime text 3 plugin
-Sublime text 3 - Git blame the line
+# Git blame - Package for Sublime Text 3
 
-Small plugin to parse git blame and add a view to show the user and datetime of the blame. You can click the sha to copy it or click the ✖ to close. Also, this package provides a command to see all the blame result of a file.
+This package enables you to query Git "blame" information for files while you are viewing/editing them in Sublime Text.
 
-View on [packagecontrol.io](https://packagecontrol.io/packages/Git%20blame)
+Blame information tells you who last edited a line, when they did it, and which commit they did it in. You can then choose to show that commit in full including its commit message and diff.
 
-This package was originally created by [@psykzz](https://github.com/psykzz) and is now maintained by [@frou](https://github.com/frou)
+For this package to work, you must already have the `git` command-line tool installed, and be viewing/editing a file that is part of a Git repository on your hard drive.
+
+[View on the Package Control website](https://packagecontrol.io/packages/Git%20blame)
 
+---
 
-## Usage
+This package was originally created by [@psykzz](https://github.com/psykzz) and is now maintained by [@frou](https://github.com/frou)
 
-### Checking the results of `git blame` for selected lines
+## How to use
 
- > CTRL + ALT + B (Q on linux)
+Place the text insertion cursor on the line you are interested in, then press <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>Q</kbd> (Linux). (Instead of pressing the keyboard shortcut, you can alternatively right click and select *Git Blame* from the context menu)
 
- > Right click > Git blame
+![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/screenshot.png)
 
-### Checking the result of `git blame` for the whole file
+The blame information will appear. You can then click `[Show]` to show the commit in full, or `[Copy]` to copy the Commit ID (aka SHA) to your clipboard. Click ✖ or press the keyboard shortcut again to close the blame information.
 
-To show the `git blame` result: Open the command pallette and select `Git Blame Show All`.
+## Advanced use
 
-To erase the `git blame` result: Open the command pallette and select `Git Blame Erase All`. Or, you can click the ✖ icon to erase it. Also, the result is automatically erased when you start to modify the file.
+In combination with Sublime Text's *Multiple Cursor* feature, you can query blame information for more than one line simultaneously by first placing a cursor on each line you are interested in.
 
-## Example
+You can also query blame information for every line in the entire file simultaneously by pressing <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>Shift</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>C</kbd> (Linux). Click ✖ or press the keyboard shortcut again to close them.
 
-<img width="645" alt="screen shot 2017-07-20 at 11 12 51" src="https://user-images.githubusercontent.com/2543659/28410198-331b1ec8-6d3d-11e7-9ac1-57d43fb6ab60.png">
-<img width="672" alt="screen shot 2017-07-20 at 11 13 05" src="https://user-images.githubusercontent.com/2543659/28410200-33312740-6d3d-11e7-8b1e-f46ae7b6925b.png">
-<img width="660" alt="screen shot 2017-07-20 at 11 13 20" src="https://user-images.githubusercontent.com/2543659/28410201-3336ad3c-6d3d-11e7-974c-fa5a1f89ea2b.png">
-<img width="663" alt="screen shot 2017-07-20 at 11 13 29" src="https://user-images.githubusercontent.com/2543659/28410203-3358c444-6d3d-11e7-980d-e4b49958eb9b.png">
-<img width="667" alt="screen shot 2017-07-20 at 11 13 38" src="https://user-images.githubusercontent.com/2543659/28410202-333ccc62-6d3d-11e7-8d7f-ff88067f3cb1.png">
+This package's commands are also made available in the *Command Palette*. Type "Git Blame" into it to find them.
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000000000000000000000000000000000000..8e15ebc24500b4e13baffc4f28b63b2b8aaf1226
GIT binary patch

From 4847f4ce00dec5528cd691ecc3a2149862ad49e4 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Thu, 2 May 2019 21:11:12 +0100
Subject: [PATCH 010/180] README formatting; Add TODO

---
 README.md    | 8 +++-----
 git-blame.py | 1 +
 2 files changed, 4 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 984828b..c44aa25 100644
--- a/README.md
+++ b/README.md
@@ -12,22 +12,20 @@ For this package to work, you must already have the `git` command-line tool inst
 
 [View on the Package Control website](https://packagecontrol.io/packages/Git%20blame)
 
----
-
 This package was originally created by [@psykzz](https://github.com/psykzz) and is now maintained by [@frou](https://github.com/frou)
 
 ## How to use
 
-Place the text insertion cursor on the line you are interested in, then press <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>Q</kbd> (Linux). (Instead of pressing the keyboard shortcut, you can alternatively right click and select *Git Blame* from the context menu)
+Place the text insertion cursor on the line you are interested in, then press <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>Q</kbd> (Linux). Instead of pressing the keyboard shortcut, you can alternatively right click and select *Git Blame* from the context menu.
 
 ![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/screenshot.png)
 
-The blame information will appear. You can then click `[Show]` to show the commit in full, or `[Copy]` to copy the Commit ID (aka SHA) to your clipboard. Click ✖ or press the keyboard shortcut again to close the blame information.
+The blame information will appear. You can then click `[Show]` to show the commit in full, or `[Copy]` to copy the Commit ID (aka SHA) to your clipboard. Click × or press the keyboard shortcut again to close the blame information.
 
 ## Advanced use
 
 In combination with Sublime Text's *Multiple Cursor* feature, you can query blame information for more than one line simultaneously by first placing a cursor on each line you are interested in.
 
-You can also query blame information for every line in the entire file simultaneously by pressing <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>Shift</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>C</kbd> (Linux). Click ✖ or press the keyboard shortcut again to close them.
+You can also query blame information for every line in the entire file simultaneously by pressing <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>Shift</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>C</kbd> (Linux). Click × or press the keyboard shortcut again to close them.
 
 This package's commands are also made available in the *Command Palette*. Type "Git Blame" into it to find them.
diff --git a/git-blame.py b/git-blame.py
index a196bf4..f92c07d 100644
--- a/git-blame.py
+++ b/git-blame.py
@@ -112,6 +112,7 @@ def __init__(self, view):
         self.view = view
         self.phantom_set = sublime.PhantomSet(view, 'git-blame')
 
+    # TODO: Can this cache start providing stale results?
     @functools.lru_cache(128, False)
     def get_blame(self, line, path):
         return subprocess.check_output(

From acaa2ede24dd322a11faa09e1938ca75dd0ce296 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 4 May 2019 07:48:11 +0100
Subject: [PATCH 011/180] Use the user-facing name in messages

---
 git-blame.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git-blame.py b/git-blame.py
index f92c07d..8167dce 100644
--- a/git-blame.py
+++ b/git-blame.py
@@ -372,7 +372,7 @@ def view_is_suitable(view):
 
 
 def communicate_error(e, modal=True):
-    user_msg = "st3-gitblame:\n\n{}".format(e)
+    user_msg = "Git blame:\n\n{}".format(e)
     if isinstance(e, subprocess.CalledProcessError):
         user_msg += "\n\n{}".format(e.output.decode("utf-8"))
 

From fd3bf19d238ee557b64c7c4d4aa123405306e738 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 4 May 2019 08:52:48 +0100
Subject: [PATCH 012/180] Don't use the functools cache:

It could provide incorrect information, e.g. the user...
- Queries blame information for line 5.
- Writes ten lines of new code at the top of the file.
- Saves their changes to disk
- Queries blame information for line 5 again.
- Is shown the same old blame information rather than the "Not Commited" information as would be correct.
---
 git-blame.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/git-blame.py b/git-blame.py
index 8167dce..4b88ca7 100644
--- a/git-blame.py
+++ b/git-blame.py
@@ -2,7 +2,6 @@
 import sublime_plugin
 import os
 import re
-import functools
 import subprocess
 
 PHANTOM_KEY_ALL = 'git-blame-all'
@@ -112,8 +111,6 @@ def __init__(self, view):
         self.view = view
         self.phantom_set = sublime.PhantomSet(view, 'git-blame')
 
-    # TODO: Can this cache start providing stale results?
-    @functools.lru_cache(128, False)
     def get_blame(self, line, path):
         return subprocess.check_output(
             ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)],

From 017a59748dfbb8186357aa1386fb989c62bea83d Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 4 May 2019 09:58:11 +0100
Subject: [PATCH 013/180] WIP

---
 Git blame.sublime-settings | 14 ++++++++++++++
 Main.sublime-menu          | 25 +++++++++++++++++++++++++
 git-blame.py               | 20 +++++++++++++++++++-
 3 files changed, 58 insertions(+), 1 deletion(-)
 create mode 100644 Git blame.sublime-settings
 create mode 100644 Main.sublime-menu

diff --git a/Git blame.sublime-settings b/Git blame.sublime-settings
new file mode 100644
index 0000000..f60b944
--- /dev/null
+++ b/Git blame.sublime-settings	
@@ -0,0 +1,14 @@
+{
+  // Possible values for "content_chasing". The later values may cause querying
+  // blame information to be slower.
+  //
+  // Value                          Corresponding git-blame flags
+  // -----                          -----------------------------
+  // false                          (none)
+  // "same_file_same_commit"        -M
+  // "cross_file_same_commit"       -C
+  // "cross_any_file"               -C -C
+  // "cross_any_historical_file"    -C -C -C
+  //
+  "content_chasing": false
+}
diff --git a/Main.sublime-menu b/Main.sublime-menu
new file mode 100644
index 0000000..a197c96
--- /dev/null
+++ b/Main.sublime-menu
@@ -0,0 +1,25 @@
+[
+  {
+    "id": "preferences",
+    "children": [
+      {
+        "id": "package-settings",
+        "children": [
+          {
+            "caption": "Git blame",
+            "children": [
+              {
+                "caption": "Settings",
+                "command": "edit_settings",
+                "args": {
+                  "base_file": "${packages}/st3-gitblame/Git blame.sublime-settings",
+                  "default": "{\n\t$0\n}\n"
+                }
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+]
diff --git a/git-blame.py b/git-blame.py
index 4b88ca7..e32e073 100644
--- a/git-blame.py
+++ b/git-blame.py
@@ -112,8 +112,26 @@ def __init__(self, view):
         self.phantom_set = sublime.PhantomSet(view, 'git-blame')
 
     def get_blame(self, line, path):
+        cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)]
+        # TODO: Factor out so that BlameShowAllCommand can use the following too.
+        pkg_settings = sublime.load_settings("Git blame.sublime-settings")
+        content_chasing_key = "content_chasing"
+        content_chasing_value = pkg_settings.get(content_chasing_key)
+        if content_chasing_value:
+            if content_chasing_value == "same_file_same_commit":
+                cmd_line += ["-M"]
+            elif content_chasing_value == "cross_file_same_commit":
+                cmd_line += ["-C"]
+            elif content_chasing_value == "cross_any_file":
+                cmd_line += ["-C"] * 2
+            elif content_chasing_value == "cross_any_historical_file":
+                cmd_line += ["-C"] * 3
+            else:
+                communicate_error('Settings file contains unrecognised "{}" value for "{}"'.format(content_chasing_value, content_chasing_key))
+
+        # print(cmd_line)
         return subprocess.check_output(
-            ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)],
+            cmd_line,
             cwd=os.path.dirname(os.path.realpath(path)),
             startupinfo=si,
             stderr=subprocess.STDOUT

From 3be91f2b1d8480cc77c52b245db2fd1f803f9d51 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 12 Jun 2019 12:21:05 +0100
Subject: [PATCH 014/180] Take screenshot of Sublime running in HiDPI:

Fuzzy font rendering begone.
---
 screenshot.png | Bin 30854 -> 147092 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)

diff --git a/screenshot.png b/screenshot.png
index 8e15ebc24500b4e13baffc4f28b63b2b8aaf1226..d7a8aa279ffd99945a338c543341a0075bee2a64 100644
GIT binary patch

From 4a98a1a768e4c5e496dcac5793932dd10ce77dcf Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 12 Jun 2019 12:29:20 +0100
Subject: [PATCH 015/180] Reorder

---
 README.md | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index c44aa25..796036e 100644
--- a/README.md
+++ b/README.md
@@ -16,11 +16,15 @@ This package was originally created by [@psykzz](https://github.com/psykzz) and
 
 ## How to use
 
-Place the text insertion cursor on the line you are interested in, then press <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>Q</kbd> (Linux). Instead of pressing the keyboard shortcut, you can alternatively right click and select *Git Blame* from the context menu.
+Place the text insertion cursor on the line you are interested in, then press <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>Q</kbd> (Linux).
+
+(Instead of pressing the keyboard shortcut, you can alternatively right click and select *Git Blame* from the context menu)
+
+The blame information will appear:
 
 ![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/screenshot.png)
 
-The blame information will appear. You can then click `[Show]` to show the commit in full, or `[Copy]` to copy the Commit ID (aka SHA) to your clipboard. Click × or press the keyboard shortcut again to close the blame information.
+You can then click `[Show]` to show the commit in full, or `[Copy]` to copy the Commit ID (aka SHA) to your clipboard. Click × or press the keyboard shortcut again to close the blame information.
 
 ## Advanced use
 

From e6a41d07c639ff15b1bd505498887ea23f78639a Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 29 Jun 2019 10:04:20 +0100
Subject: [PATCH 016/180] Use new-style ST InputHandlers to set chasing mode

---
 Default.sublime-commands |  4 +++
 chasing.py               | 55 ++++++++++++++++++++++++++++++++++++++++
 git-blame.py             | 26 ++++++++-----------
 3 files changed, 70 insertions(+), 15 deletions(-)
 create mode 100644 chasing.py

diff --git a/Default.sublime-commands b/Default.sublime-commands
index d3739d2..ef2a829 100644
--- a/Default.sublime-commands
+++ b/Default.sublime-commands
@@ -10,5 +10,9 @@
     {
         "caption": "Git Blame Erase All",
         "command": "blame_erase_all"
+    },
+    {
+        "caption": "Git Blame: Set content chasing mode",
+        "command": "blame_set_content_chasing_mode"
     }
 ]
diff --git a/chasing.py b/chasing.py
new file mode 100644
index 0000000..fe572e9
--- /dev/null
+++ b/chasing.py
@@ -0,0 +1,55 @@
+import sublime
+import sublime_plugin
+
+
+class BlameSetContentChasingMode(sublime_plugin.TextCommand):
+    MODE_NONE = "<NONE>"
+    MODE_SAME_FILE_SAME_COMMIT = "same_file_same_commit"
+    MODE_CROSS_FILE_SAME_COMMIT = "cross_file_same_commit"
+    MODE_CROSS_ANY_FILE = "cross_any_file"
+    MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
+
+    GIT_ARGS_FOR_MODES = {
+        MODE_NONE: [],
+        MODE_SAME_FILE_SAME_COMMIT: ["-M"],
+        MODE_CROSS_FILE_SAME_COMMIT: ["-C"],
+        MODE_CROSS_ANY_FILE: ["-C"] * 2,
+        MODE_CROSS_ANY_HISTORICAL_FILE: ["-C"] * 3,
+    }
+
+    def run(self, edit, mode, permanence):
+        if permanence:
+            raise Exception("not implemented")
+        else:
+            self.view.settings().set(self.__class__.__name__, mode)
+
+    def input(self, args):  # noqa: A003
+        return ModeInputHandler()
+
+
+class ModeInputHandler(sublime_plugin.ListInputHandler):
+    def placeholder(self):
+        return "Select a mode"
+
+    def list_items(self):
+        return [
+            BlameSetContentChasingMode.MODE_NONE,
+            BlameSetContentChasingMode.MODE_SAME_FILE_SAME_COMMIT,
+            BlameSetContentChasingMode.MODE_CROSS_FILE_SAME_COMMIT,
+            BlameSetContentChasingMode.MODE_CROSS_ANY_FILE,
+            BlameSetContentChasingMode.MODE_CROSS_ANY_HISTORICAL_FILE,
+        ]
+
+    def next_input(self, args):
+        return PermanenceInputHandler()
+
+    def description(self, value, text):
+        return 'to "{0}"'.format(text)
+
+
+class PermanenceInputHandler(sublime_plugin.ListInputHandler):
+    def list_items(self):
+        return [
+            ("Temporarily (for this open file)", False),
+            ("Permanently (new default for all files)", True),
+        ]
diff --git a/git-blame.py b/git-blame.py
index e32e073..afa2973 100644
--- a/git-blame.py
+++ b/git-blame.py
@@ -4,6 +4,8 @@
 import re
 import subprocess
 
+from .chasing import BlameSetContentChasingMode
+
 PHANTOM_KEY_ALL = 'git-blame-all'
 SETTING_PHANTOM_ALL_DISPLAYED = 'git-blame-all-displayed'
 
@@ -113,21 +115,15 @@ def __init__(self, view):
 
     def get_blame(self, line, path):
         cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)]
-        # TODO: Factor out so that BlameShowAllCommand can use the following too.
-        pkg_settings = sublime.load_settings("Git blame.sublime-settings")
-        content_chasing_key = "content_chasing"
-        content_chasing_value = pkg_settings.get(content_chasing_key)
-        if content_chasing_value:
-            if content_chasing_value == "same_file_same_commit":
-                cmd_line += ["-M"]
-            elif content_chasing_value == "cross_file_same_commit":
-                cmd_line += ["-C"]
-            elif content_chasing_value == "cross_any_file":
-                cmd_line += ["-C"] * 2
-            elif content_chasing_value == "cross_any_historical_file":
-                cmd_line += ["-C"] * 3
-            else:
-                communicate_error('Settings file contains unrecognised "{}" value for "{}"'.format(content_chasing_value, content_chasing_key))
+
+        # TODO: Factor the following out so that BlameShowAllCommand can use it too.
+        chasing_mode = self.view.settings().get(BlameSetContentChasingMode.__name__, None)
+        if chasing_mode is not None:
+            try:
+                cmd_line += BlameSetContentChasingMode.GIT_ARGS_FOR_MODES[chasing_mode]
+            except KeyError:
+                communicate_error("Unexpected content chasing mode: {0}".format(chasing_mode))
+        # sublime.message_dialog(str(cmd_line))
 
         # print(cmd_line)
         return subprocess.check_output(

From e1c62b947361bf990ffead10a6b5ada27c83cbf1 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 29 Jun 2019 10:43:03 +0100
Subject: [PATCH 017/180] Use "A: B" naming for commands in Command Palette

---
 Default.sublime-commands | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/Default.sublime-commands b/Default.sublime-commands
index d3739d2..211e2f4 100644
--- a/Default.sublime-commands
+++ b/Default.sublime-commands
@@ -1,14 +1,14 @@
 [
     {
-        "caption": "Git Blame",
+        "caption": "Git Blame: Show",
         "command": "blame"
     },
     {
-        "caption": "Git Blame Show All",
+        "caption": "Git Blame: Show All",
         "command": "blame_show_all"
     },
     {
-        "caption": "Git Blame Erase All",
+        "caption": "Git Blame: Erase All",
         "command": "blame_erase_all"
     }
 ]

From aff6785a905ee7c004c88be72c392f57b218a2af Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 29 Jun 2019 10:45:05 +0100
Subject: [PATCH 018/180] Rename file

---
 git-blame.py => blame.py | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename git-blame.py => blame.py (100%)

diff --git a/git-blame.py b/blame.py
similarity index 100%
rename from git-blame.py
rename to blame.py

From 0b2bc1a117025bae23a6a9ec4b1c1d0abf37d9c7 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 29 Jun 2019 10:59:18 +0100
Subject: [PATCH 019/180] Add TODOs

---
 blame.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/blame.py b/blame.py
index afa2973..7ef95f9 100644
--- a/blame.py
+++ b/blame.py
@@ -9,6 +9,8 @@
 PHANTOM_KEY_ALL = 'git-blame-all'
 SETTING_PHANTOM_ALL_DISPLAYED = 'git-blame-all-displayed'
 
+# TODO: Move the templates to a seperate source file.
+
 stylesheet_one = '''
     <style>
         div.phantom-arrow {
@@ -375,6 +377,8 @@ def run(self, edit, desc, scratch_view_name):
         view.set_name(scratch_view_name)
 
 
+# TODO: Move the utility functions to a seperate source file.
+
 def view_is_suitable(view):
     ok = view.file_name() and not view.is_dirty()
     if not ok:

From 650fd60191ee488e9b3a02fa93084d34b1171f35 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 29 Jun 2019 13:54:41 +0100
Subject: [PATCH 020/180] Have the underlying value of MODE_NONE differ

---
 chasing.py | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/chasing.py b/chasing.py
index fe572e9..f8556c0 100644
--- a/chasing.py
+++ b/chasing.py
@@ -3,7 +3,7 @@
 
 
 class BlameSetContentChasingMode(sublime_plugin.TextCommand):
-    MODE_NONE = "<NONE>"
+    MODE_NONE = False
     MODE_SAME_FILE_SAME_COMMIT = "same_file_same_commit"
     MODE_CROSS_FILE_SAME_COMMIT = "cross_file_same_commit"
     MODE_CROSS_ANY_FILE = "cross_any_file"
@@ -27,13 +27,17 @@ def input(self, args):  # noqa: A003
         return ModeInputHandler()
 
 
+# TODO: Bump the minimum required Sublime version number to the one that introduced *InputHandlers
+
+
 class ModeInputHandler(sublime_plugin.ListInputHandler):
     def placeholder(self):
         return "Select a mode"
 
+    # TODO: Preselect the mode currently in effect.
     def list_items(self):
         return [
-            BlameSetContentChasingMode.MODE_NONE,
+            ["<NONE>", BlameSetContentChasingMode.MODE_NONE],
             BlameSetContentChasingMode.MODE_SAME_FILE_SAME_COMMIT,
             BlameSetContentChasingMode.MODE_CROSS_FILE_SAME_COMMIT,
             BlameSetContentChasingMode.MODE_CROSS_ANY_FILE,
@@ -51,5 +55,5 @@ class PermanenceInputHandler(sublime_plugin.ListInputHandler):
     def list_items(self):
         return [
             ("Temporarily (for this open file)", False),
-            ("Permanently (new default for all files)", True),
+            ("Permanently (the default in the settings file will be modified)", True),
         ]

From 02d3e7ea0486eb3b6db1569db8b2f7fa7fdc6955 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 29 Jun 2019 16:00:20 +0100
Subject: [PATCH 021/180] Update TODO

---
 chasing.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/chasing.py b/chasing.py
index f8556c0..a44ab2b 100644
--- a/chasing.py
+++ b/chasing.py
@@ -27,7 +27,9 @@ def input(self, args):  # noqa: A003
         return ModeInputHandler()
 
 
-# TODO: Bump the minimum required Sublime version number to the one that introduced *InputHandlers
+# TODO: Bump the minimum required Sublime version to the one that introduced *InputHandlers.
+# 3.1 (BUILD 3170) https://www.sublimetext.com/3
+# https://github.com/wbond/package_control_channel/blob/master/repository/g.json
 
 
 class ModeInputHandler(sublime_plugin.ListInputHandler):

From 774e3d97d7a2f0d391c6c18b0004f7eacc41bfe9 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 10:12:53 +0100
Subject: [PATCH 022/180] Implement temporary/permanent content chase modes

---
 blame.py   | 22 ++++++++++++++++------
 chasing.py | 10 ++++++++--
 common.py  |  4 ++++
 3 files changed, 28 insertions(+), 8 deletions(-)
 create mode 100644 common.py

diff --git a/blame.py b/blame.py
index 7ef95f9..3e0f977 100644
--- a/blame.py
+++ b/blame.py
@@ -4,6 +4,7 @@
 import re
 import subprocess
 
+from .common import *
 from .chasing import BlameSetContentChasingMode
 
 PHANTOM_KEY_ALL = 'git-blame-all'
@@ -119,12 +120,20 @@ def get_blame(self, line, path):
         cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)]
 
         # TODO: Factor the following out so that BlameShowAllCommand can use it too.
-        chasing_mode = self.view.settings().get(BlameSetContentChasingMode.__name__, None)
-        if chasing_mode is not None:
-            try:
-                cmd_line += BlameSetContentChasingMode.GIT_ARGS_FOR_MODES[chasing_mode]
-            except KeyError:
-                communicate_error("Unexpected content chasing mode: {0}".format(chasing_mode))
+        chasing_mode = self.view.settings().get(
+            SETTINGS_KEY_TEMPORARY_CONTENT_CHASING_MODE,
+            None
+        )
+        if chasing_mode is None:
+            settings_file = sublime.load_settings(SETTINGS_FILE_BASENAME)
+            chasing_mode = settings_file.get(
+                SETTINGS_KEY_CONTENT_CHASING_MODE,
+                BlameSetContentChasingMode.MODE_NONE
+            )
+        try:
+            cmd_line += BlameSetContentChasingMode.GIT_ARGS_FOR_MODES[chasing_mode]
+        except KeyError as e:
+            communicate_error("Unexpected content chasing mode: {0}".format(e))
         # sublime.message_dialog(str(cmd_line))
 
         # print(cmd_line)
@@ -222,6 +231,7 @@ def run(self, edit):
         self.phantom_set.update(phantoms)
 
 
+# TODO: Move this to its own source file.
 class BlameShowAllCommand(sublime_plugin.TextCommand):
 
     # The fixed length for author names
diff --git a/chasing.py b/chasing.py
index a44ab2b..f79a4d2 100644
--- a/chasing.py
+++ b/chasing.py
@@ -1,6 +1,8 @@
 import sublime
 import sublime_plugin
 
+from .common import *
+
 
 class BlameSetContentChasingMode(sublime_plugin.TextCommand):
     MODE_NONE = False
@@ -19,9 +21,13 @@ class BlameSetContentChasingMode(sublime_plugin.TextCommand):
 
     def run(self, edit, mode, permanence):
         if permanence:
-            raise Exception("not implemented")
+            sublime.load_settings(SETTINGS_FILE_BASENAME).set(
+                SETTINGS_KEY_CONTENT_CHASING_MODE, mode
+            )
+            sublime.save_settings(SETTINGS_FILE_BASENAME)
+            self.view.settings().erase(SETTINGS_KEY_TEMPORARY_CONTENT_CHASING_MODE)
         else:
-            self.view.settings().set(self.__class__.__name__, mode)
+            self.view.settings().set(SETTINGS_KEY_TEMPORARY_CONTENT_CHASING_MODE, mode)
 
     def input(self, args):  # noqa: A003
         return ModeInputHandler()
diff --git a/common.py b/common.py
new file mode 100644
index 0000000..689043e
--- /dev/null
+++ b/common.py
@@ -0,0 +1,4 @@
+SETTINGS_FILE_BASENAME = "Git blame.sublime-settings"
+
+SETTINGS_KEY_CONTENT_CHASING_MODE = "content_chasing"
+SETTINGS_KEY_TEMPORARY_CONTENT_CHASING_MODE = SETTINGS_KEY_CONTENT_CHASING_MODE

From bd67ccad872fee0e88a6616a9ebf7013341c435e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 11:22:46 +0100
Subject: [PATCH 023/180] Store/present more metadata for each chasing mode

---
 blame.py   |  2 +-
 chasing.py | 36 +++++++++++++++++++++++++-----------
 2 files changed, 26 insertions(+), 12 deletions(-)

diff --git a/blame.py b/blame.py
index 3e0f977..4696a6e 100644
--- a/blame.py
+++ b/blame.py
@@ -131,7 +131,7 @@ def get_blame(self, line, path):
                 BlameSetContentChasingMode.MODE_NONE
             )
         try:
-            cmd_line += BlameSetContentChasingMode.GIT_ARGS_FOR_MODES[chasing_mode]
+            cmd_line += BlameSetContentChasingMode.METADATA_FOR_MODES[chasing_mode]["git_args"]
         except KeyError as e:
             communicate_error("Unexpected content chasing mode: {0}".format(e))
         # sublime.message_dialog(str(cmd_line))
diff --git a/chasing.py b/chasing.py
index f79a4d2..ec14815 100644
--- a/chasing.py
+++ b/chasing.py
@@ -11,12 +11,24 @@ class BlameSetContentChasingMode(sublime_plugin.TextCommand):
     MODE_CROSS_ANY_FILE = "cross_any_file"
     MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
 
-    GIT_ARGS_FOR_MODES = {
-        MODE_NONE: [],
-        MODE_SAME_FILE_SAME_COMMIT: ["-M"],
-        MODE_CROSS_FILE_SAME_COMMIT: ["-C"],
-        MODE_CROSS_ANY_FILE: ["-C"] * 2,
-        MODE_CROSS_ANY_HISTORICAL_FILE: ["-C"] * 3,
+    METADATA_FOR_MODES = {
+        MODE_NONE: {"explanation": "<NONE>", "git_args": []},
+        MODE_SAME_FILE_SAME_COMMIT: {
+            "explanation": "Skip commits that moved/copied the line within a file",
+            "git_args": ["-M"],
+        },
+        MODE_CROSS_FILE_SAME_COMMIT: {
+            "explanation": "Skip commits that moved/copied the line between files",
+            "git_args": ["-C"],
+        },
+        MODE_CROSS_ANY_FILE: {
+            "explanation": "Skip commits that created the file with a line copied from another file",
+            "git_args": ["-C"] * 2,
+        },
+        MODE_CROSS_ANY_HISTORICAL_FILE: {
+            "explanation": "Skip commits that created the file with a line copied another file, including their all historial states",
+            "git_args": ["-C"] * 3,
+        },
     }
 
     def run(self, edit, mode, permanence):
@@ -45,11 +57,13 @@ def placeholder(self):
     # TODO: Preselect the mode currently in effect.
     def list_items(self):
         return [
-            ["<NONE>", BlameSetContentChasingMode.MODE_NONE],
-            BlameSetContentChasingMode.MODE_SAME_FILE_SAME_COMMIT,
-            BlameSetContentChasingMode.MODE_CROSS_FILE_SAME_COMMIT,
-            BlameSetContentChasingMode.MODE_CROSS_ANY_FILE,
-            BlameSetContentChasingMode.MODE_CROSS_ANY_HISTORICAL_FILE,
+            [
+                "{0} (git blame {1})".format(
+                    metadata["explanation"], " ".join(metadata["git_args"])
+                ),
+                mode,
+            ]
+            for mode, metadata in BlameSetContentChasingMode.METADATA_FOR_MODES.items()
         ]
 
     def next_input(self, args):

From c220e875b278c2e6c7ead7e450c4f7644c36aff5 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 11:41:15 +0100
Subject: [PATCH 024/180] Improve presented list items of ModeInputHandler

---
 chasing.py | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/chasing.py b/chasing.py
index ec14815..635d607 100644
--- a/chasing.py
+++ b/chasing.py
@@ -12,21 +12,21 @@ class BlameSetContentChasingMode(sublime_plugin.TextCommand):
     MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
 
     METADATA_FOR_MODES = {
-        MODE_NONE: {"explanation": "<NONE>", "git_args": []},
+        MODE_NONE: {"explanation": "<Disable Skipping>", "git_args": []},
         MODE_SAME_FILE_SAME_COMMIT: {
-            "explanation": "Skip commits that moved/copied the line within a file",
+            "explanation": "... moved/copied the line WITHIN a file",
             "git_args": ["-M"],
         },
         MODE_CROSS_FILE_SAME_COMMIT: {
-            "explanation": "Skip commits that moved/copied the line between files",
+            "explanation": "... moved/copied the line from ANOTHER file modified in the SAME COMMIT",
             "git_args": ["-C"],
         },
         MODE_CROSS_ANY_FILE: {
-            "explanation": "Skip commits that created the file with a line copied from another file",
+            "explanation": "... CREATED the file with a copy of a line from ANY other file",
             "git_args": ["-C"] * 2,
         },
         MODE_CROSS_ANY_HISTORICAL_FILE: {
-            "explanation": "Skip commits that created the file with a line copied another file, including their all historial states",
+            "explanation": "... CREATED the file with a copy of a line from ANY other HISTORICAL file",
             "git_args": ["-C"] * 3,
         },
     }
@@ -57,7 +57,9 @@ def placeholder(self):
     # TODO: Preselect the mode currently in effect.
     def list_items(self):
         return [
-            [
+            [metadata["explanation"], mode]
+            if mode == BlameSetContentChasingMode.MODE_NONE
+            else [
                 "{0} (git blame {1})".format(
                     metadata["explanation"], " ".join(metadata["git_args"])
                 ),

From b47ac54c6be1595362fc34e28838c2ca15dc88e8 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 11:47:56 +0100
Subject: [PATCH 025/180] Rename "Content Chasing" to "Commit Skipping"

---
 Default.sublime-commands   |  4 ++--
 Git blame.sublime-settings | 13 +------------
 blame.py                   | 18 +++++++++---------
 chasing.py                 | 20 ++++++++++----------
 common.py                  |  4 ++--
 5 files changed, 24 insertions(+), 35 deletions(-)

diff --git a/Default.sublime-commands b/Default.sublime-commands
index ef2a829..6865c09 100644
--- a/Default.sublime-commands
+++ b/Default.sublime-commands
@@ -12,7 +12,7 @@
         "command": "blame_erase_all"
     },
     {
-        "caption": "Git Blame: Set content chasing mode",
-        "command": "blame_set_content_chasing_mode"
+        "caption": "Git Blame: Skip commits that...",
+        "command": "blame_set_commit_skipping_mode"
     }
 ]
diff --git a/Git blame.sublime-settings b/Git blame.sublime-settings
index f60b944..7fa9257 100644
--- a/Git blame.sublime-settings	
+++ b/Git blame.sublime-settings	
@@ -1,14 +1,3 @@
 {
-  // Possible values for "content_chasing". The later values may cause querying
-  // blame information to be slower.
-  //
-  // Value                          Corresponding git-blame flags
-  // -----                          -----------------------------
-  // false                          (none)
-  // "same_file_same_commit"        -M
-  // "cross_file_same_commit"       -C
-  // "cross_any_file"               -C -C
-  // "cross_any_historical_file"    -C -C -C
-  //
-  "content_chasing": false
+  "commit_skipping": false
 }
diff --git a/blame.py b/blame.py
index 4696a6e..6c895fd 100644
--- a/blame.py
+++ b/blame.py
@@ -5,7 +5,7 @@
 import subprocess
 
 from .common import *
-from .chasing import BlameSetContentChasingMode
+from .chasing import BlameSetCommitSkippingMode
 
 PHANTOM_KEY_ALL = 'git-blame-all'
 SETTING_PHANTOM_ALL_DISPLAYED = 'git-blame-all-displayed'
@@ -120,20 +120,20 @@ def get_blame(self, line, path):
         cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)]
 
         # TODO: Factor the following out so that BlameShowAllCommand can use it too.
-        chasing_mode = self.view.settings().get(
-            SETTINGS_KEY_TEMPORARY_CONTENT_CHASING_MODE,
+        skipping_mode = self.view.settings().get(
+            SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
             None
         )
-        if chasing_mode is None:
+        if skipping_mode is None:
             settings_file = sublime.load_settings(SETTINGS_FILE_BASENAME)
-            chasing_mode = settings_file.get(
-                SETTINGS_KEY_CONTENT_CHASING_MODE,
-                BlameSetContentChasingMode.MODE_NONE
+            skipping_mode = settings_file.get(
+                SETTINGS_KEY_COMMIT_SKIPPING_MODE,
+                BlameSetCommitSkippingMode.MODE_NONE
             )
         try:
-            cmd_line += BlameSetContentChasingMode.METADATA_FOR_MODES[chasing_mode]["git_args"]
+            cmd_line += BlameSetCommitSkippingMode.METADATA_FOR_MODES[skipping_mode]["git_args"]
         except KeyError as e:
-            communicate_error("Unexpected content chasing mode: {0}".format(e))
+            communicate_error("Unexpected commit skipping mode: {0}".format(e))
         # sublime.message_dialog(str(cmd_line))
 
         # print(cmd_line)
diff --git a/chasing.py b/chasing.py
index 635d607..e55df72 100644
--- a/chasing.py
+++ b/chasing.py
@@ -4,7 +4,7 @@
 from .common import *
 
 
-class BlameSetContentChasingMode(sublime_plugin.TextCommand):
+class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
     MODE_NONE = False
     MODE_SAME_FILE_SAME_COMMIT = "same_file_same_commit"
     MODE_CROSS_FILE_SAME_COMMIT = "cross_file_same_commit"
@@ -14,19 +14,19 @@ class BlameSetContentChasingMode(sublime_plugin.TextCommand):
     METADATA_FOR_MODES = {
         MODE_NONE: {"explanation": "<Disable Skipping>", "git_args": []},
         MODE_SAME_FILE_SAME_COMMIT: {
-            "explanation": "... moved/copied the line WITHIN a file",
+            "explanation": "... moved/copied the line within a file",
             "git_args": ["-M"],
         },
         MODE_CROSS_FILE_SAME_COMMIT: {
-            "explanation": "... moved/copied the line from ANOTHER file modified in the SAME COMMIT",
+            "explanation": "... moved/copied the line from another file modified in the same commit",
             "git_args": ["-C"],
         },
         MODE_CROSS_ANY_FILE: {
-            "explanation": "... CREATED the file with a copy of a line from ANY other file",
+            "explanation": "... created the file with a copy of a line from any other file",
             "git_args": ["-C"] * 2,
         },
         MODE_CROSS_ANY_HISTORICAL_FILE: {
-            "explanation": "... CREATED the file with a copy of a line from ANY other HISTORICAL file",
+            "explanation": "... created the file with a copy of a line from any other historical file",
             "git_args": ["-C"] * 3,
         },
     }
@@ -34,12 +34,12 @@ class BlameSetContentChasingMode(sublime_plugin.TextCommand):
     def run(self, edit, mode, permanence):
         if permanence:
             sublime.load_settings(SETTINGS_FILE_BASENAME).set(
-                SETTINGS_KEY_CONTENT_CHASING_MODE, mode
+                SETTINGS_KEY_COMMIT_SKIPPING_MODE, mode
             )
             sublime.save_settings(SETTINGS_FILE_BASENAME)
-            self.view.settings().erase(SETTINGS_KEY_TEMPORARY_CONTENT_CHASING_MODE)
+            self.view.settings().erase(SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE)
         else:
-            self.view.settings().set(SETTINGS_KEY_TEMPORARY_CONTENT_CHASING_MODE, mode)
+            self.view.settings().set(SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE, mode)
 
     def input(self, args):  # noqa: A003
         return ModeInputHandler()
@@ -58,14 +58,14 @@ def placeholder(self):
     def list_items(self):
         return [
             [metadata["explanation"], mode]
-            if mode == BlameSetContentChasingMode.MODE_NONE
+            if mode == BlameSetCommitSkippingMode.MODE_NONE
             else [
                 "{0} (git blame {1})".format(
                     metadata["explanation"], " ".join(metadata["git_args"])
                 ),
                 mode,
             ]
-            for mode, metadata in BlameSetContentChasingMode.METADATA_FOR_MODES.items()
+            for mode, metadata in BlameSetCommitSkippingMode.METADATA_FOR_MODES.items()
         ]
 
     def next_input(self, args):
diff --git a/common.py b/common.py
index 689043e..37b5dd5 100644
--- a/common.py
+++ b/common.py
@@ -1,4 +1,4 @@
 SETTINGS_FILE_BASENAME = "Git blame.sublime-settings"
 
-SETTINGS_KEY_CONTENT_CHASING_MODE = "content_chasing"
-SETTINGS_KEY_TEMPORARY_CONTENT_CHASING_MODE = SETTINGS_KEY_CONTENT_CHASING_MODE
+SETTINGS_KEY_COMMIT_SKIPPING_MODE = "commit_skipping"
+SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE = SETTINGS_KEY_COMMIT_SKIPPING_MODE

From 6d71537ce4b652c1d1c7aa05bc0a17ec494388c7 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 11:56:59 +0100
Subject: [PATCH 026/180] Fix flake8 issue

---
 blame.py                         | 8 ++++++--
 chasing.py => commit_skipping.py | 8 ++++++--
 2 files changed, 12 insertions(+), 4 deletions(-)
 rename chasing.py => commit_skipping.py (92%)

diff --git a/blame.py b/blame.py
index 6c895fd..4a4986f 100644
--- a/blame.py
+++ b/blame.py
@@ -4,8 +4,12 @@
 import re
 import subprocess
 
-from .common import *
-from .chasing import BlameSetCommitSkippingMode
+from .common import (
+    SETTINGS_FILE_BASENAME,
+    SETTINGS_KEY_COMMIT_SKIPPING_MODE,
+    SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
+)
+from .commit_skipping import BlameSetCommitSkippingMode
 
 PHANTOM_KEY_ALL = 'git-blame-all'
 SETTING_PHANTOM_ALL_DISPLAYED = 'git-blame-all-displayed'
diff --git a/chasing.py b/commit_skipping.py
similarity index 92%
rename from chasing.py
rename to commit_skipping.py
index e55df72..ad0c643 100644
--- a/chasing.py
+++ b/commit_skipping.py
@@ -1,7 +1,11 @@
 import sublime
 import sublime_plugin
 
-from .common import *
+from .common import (
+    SETTINGS_FILE_BASENAME,
+    SETTINGS_KEY_COMMIT_SKIPPING_MODE,
+    SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
+)
 
 
 class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
@@ -79,5 +83,5 @@ class PermanenceInputHandler(sublime_plugin.ListInputHandler):
     def list_items(self):
         return [
             ("Temporarily (for this open file)", False),
-            ("Permanently (the default in the settings file will be modified)", True),
+            ("Permanently (a new default will be written to the settings file)", True),
         ]

From 0afb5bdb40ab8aa27d4b5a036b6101eeb97f9f88 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 11:59:09 +0100
Subject: [PATCH 027/180] Renaming

---
 blame.py           | 2 +-
 commit_skipping.py | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/blame.py b/blame.py
index 4a4986f..f9a43e3 100644
--- a/blame.py
+++ b/blame.py
@@ -135,7 +135,7 @@ def get_blame(self, line, path):
                 BlameSetCommitSkippingMode.MODE_NONE
             )
         try:
-            cmd_line += BlameSetCommitSkippingMode.METADATA_FOR_MODES[skipping_mode]["git_args"]
+            cmd_line += BlameSetCommitSkippingMode.METADATA[skipping_mode]["git_args"]
         except KeyError as e:
             communicate_error("Unexpected commit skipping mode: {0}".format(e))
         # sublime.message_dialog(str(cmd_line))
diff --git a/commit_skipping.py b/commit_skipping.py
index ad0c643..40fe009 100644
--- a/commit_skipping.py
+++ b/commit_skipping.py
@@ -15,7 +15,7 @@ class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
     MODE_CROSS_ANY_FILE = "cross_any_file"
     MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
 
-    METADATA_FOR_MODES = {
+    METADATA = {
         MODE_NONE: {"explanation": "<Disable Skipping>", "git_args": []},
         MODE_SAME_FILE_SAME_COMMIT: {
             "explanation": "... moved/copied the line within a file",
@@ -69,7 +69,7 @@ def list_items(self):
                 ),
                 mode,
             ]
-            for mode, metadata in BlameSetCommitSkippingMode.METADATA_FOR_MODES.items()
+            for mode, metadata in BlameSetCommitSkippingMode.METADATA.items()
         ]
 
     def next_input(self, args):

From 6ee51aeeead46a8d1ee2087b6e5a158c4f7b272e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 12:00:23 +0100
Subject: [PATCH 028/180] Renaming

---
 commit_skipping.py | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/commit_skipping.py b/commit_skipping.py
index 40fe009..c49bcf2 100644
--- a/commit_skipping.py
+++ b/commit_skipping.py
@@ -16,21 +16,21 @@ class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
     MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
 
     METADATA = {
-        MODE_NONE: {"explanation": "<Disable Skipping>", "git_args": []},
+        MODE_NONE: {"elaboration": "<Disable Skipping>", "git_args": []},
         MODE_SAME_FILE_SAME_COMMIT: {
-            "explanation": "... moved/copied the line within a file",
+            "elaboration": "... moved/copied the line within a file",
             "git_args": ["-M"],
         },
         MODE_CROSS_FILE_SAME_COMMIT: {
-            "explanation": "... moved/copied the line from another file modified in the same commit",
+            "elaboration": "... moved/copied the line from another file modified in the same commit",
             "git_args": ["-C"],
         },
         MODE_CROSS_ANY_FILE: {
-            "explanation": "... created the file with a copy of a line from any other file",
+            "elaboration": "... created the file with a copy of a line from any other file",
             "git_args": ["-C"] * 2,
         },
         MODE_CROSS_ANY_HISTORICAL_FILE: {
-            "explanation": "... created the file with a copy of a line from any other historical file",
+            "elaboration": "... created the file with a copy of a line from any other historical file",
             "git_args": ["-C"] * 3,
         },
     }
@@ -61,11 +61,11 @@ def placeholder(self):
     # TODO: Preselect the mode currently in effect.
     def list_items(self):
         return [
-            [metadata["explanation"], mode]
+            [metadata["elaboration"], mode]
             if mode == BlameSetCommitSkippingMode.MODE_NONE
             else [
                 "{0} (git blame {1})".format(
-                    metadata["explanation"], " ".join(metadata["git_args"])
+                    metadata["elaboration"], " ".join(metadata["git_args"])
                 ),
                 mode,
             ]

From 5250fc031d89ff456c14e17443b52fa8f4ec3b6e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 12:57:55 +0100
Subject: [PATCH 029/180] Format TODOs are PDD puzzle markers

---
 blame.py           | 8 ++++----
 commit_skipping.py | 6 ++----
 2 files changed, 6 insertions(+), 8 deletions(-)

diff --git a/blame.py b/blame.py
index f9a43e3..7f745ca 100644
--- a/blame.py
+++ b/blame.py
@@ -14,7 +14,7 @@
 PHANTOM_KEY_ALL = 'git-blame-all'
 SETTING_PHANTOM_ALL_DISPLAYED = 'git-blame-all-displayed'
 
-# TODO: Move the templates to a seperate source file.
+# @todo #0 Move the HTML/CSS templates to a separate source file.
 
 stylesheet_one = '''
     <style>
@@ -123,7 +123,7 @@ def __init__(self, view):
     def get_blame(self, line, path):
         cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)]
 
-        # TODO: Factor the following out so that BlameShowAllCommand can use it too.
+        # @todo #21 Factor out loading of the commit-skipping mode so that BlameShowAllCommand can use it too.
         skipping_mode = self.view.settings().get(
             SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
             None
@@ -235,7 +235,7 @@ def run(self, edit):
         self.phantom_set.update(phantoms)
 
 
-# TODO: Move this to its own source file.
+# @todo #0 Move BlameShowAllCommand to its own source file
 class BlameShowAllCommand(sublime_plugin.TextCommand):
 
     # The fixed length for author names
@@ -391,7 +391,7 @@ def run(self, edit, desc, scratch_view_name):
         view.set_name(scratch_view_name)
 
 
-# TODO: Move the utility functions to a seperate source file.
+# @todo #0 Move the utility functions to their own source file.
 
 def view_is_suitable(view):
     ok = view.file_name() and not view.is_dirty()
diff --git a/commit_skipping.py b/commit_skipping.py
index c49bcf2..5c5de4c 100644
--- a/commit_skipping.py
+++ b/commit_skipping.py
@@ -49,16 +49,14 @@ def input(self, args):  # noqa: A003
         return ModeInputHandler()
 
 
-# TODO: Bump the minimum required Sublime version to the one that introduced *InputHandlers.
-# 3.1 (BUILD 3170) https://www.sublimetext.com/3
-# https://github.com/wbond/package_control_channel/blob/master/repository/g.json
+# @todo #21 Since we now use *InputHandlers, make a PR to package_control_channel to bump the minimum required sublime build to 3170 https://github.com/wbond/package_control_channel/blob/master/repository/g.json
 
 
 class ModeInputHandler(sublime_plugin.ListInputHandler):
     def placeholder(self):
         return "Select a mode"
 
-    # TODO: Preselect the mode currently in effect.
+    # @todo #21 When presenting commit-skipping modes in the Command Palette, preselect the one currently in effect
     def list_items(self):
         return [
             [metadata["elaboration"], mode]

From 9d53f28512bb0242714b8345c48c50f46816293a Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 13:01:33 +0100
Subject: [PATCH 030/180] Add PDD config file

---
 .0pdd.yml | 9 +++++++++
 1 file changed, 9 insertions(+)
 create mode 100644 .0pdd.yml

diff --git a/.0pdd.yml b/.0pdd.yml
new file mode 100644
index 0000000..b2fcf8f
--- /dev/null
+++ b/.0pdd.yml
@@ -0,0 +1,9 @@
+errors:
+  - duncan@holm.scot
+format:
+  - short-title
+  - title-length=100
+alerts:
+  suppress:
+    - on-found-puzzle
+    - on-scope

From 183b169593749ebbe301ad878371d1ea7dcbeb34 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 13:05:20 +0100
Subject: [PATCH 031/180] Add PDD readme badge

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index 796036e..d16cec4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
 [![GitHub release](https://img.shields.io/github/release/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/releases)
 [![GitHub contributors](https://img.shields.io/github/contributors/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/graphs/contributors)
 [![GitHub issues](https://img.shields.io/github/issues/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/issues)
+[![PDD status](http://www.0pdd.com/svg?name=frou/st3-gitblame)](http://www.0pdd.com/p?name=frou/st3-gitblame)
 
 # Git blame - Package for Sublime Text 3
 

From 977b0778cdbc9cd9f993b8f15460d52d79de742e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 13:40:30 +0100
Subject: [PATCH 032/180] Move utility functions

---
 blame.py | 25 +------------------------
 util.py  | 25 +++++++++++++++++++++++++
 2 files changed, 26 insertions(+), 24 deletions(-)
 create mode 100644 util.py

diff --git a/blame.py b/blame.py
index 7f745ca..0b30ea8 100644
--- a/blame.py
+++ b/blame.py
@@ -9,6 +9,7 @@
     SETTINGS_KEY_COMMIT_SKIPPING_MODE,
     SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
 )
+from .util import view_is_suitable, communicate_error
 from .commit_skipping import BlameSetCommitSkippingMode
 
 PHANTOM_KEY_ALL = 'git-blame-all'
@@ -389,27 +390,3 @@ def run(self, edit, desc, scratch_view_name):
         view.set_syntax_file('Packages/Diff/Diff.sublime-syntax')
         view.insert(edit, 0, desc)
         view.set_name(scratch_view_name)
-
-
-# @todo #0 Move the utility functions to their own source file.
-
-def view_is_suitable(view):
-    ok = view.file_name() and not view.is_dirty()
-    if not ok:
-        communicate_error("Please save file changes to disk first.")
-    return ok
-
-
-def communicate_error(e, modal=True):
-    user_msg = "Git blame:\n\n{}".format(e)
-    if isinstance(e, subprocess.CalledProcessError):
-        user_msg += "\n\n{}".format(e.output.decode("utf-8"))
-
-    print()
-    if modal:
-        sublime.error_message(user_msg)
-    else:
-        sublime.status_message(user_msg)
-        # Unlike with the error dialog, a status message is not automatically
-        # persisted in the console too.
-        print(user_msg)
diff --git a/util.py b/util.py
new file mode 100644
index 0000000..e793eae
--- /dev/null
+++ b/util.py
@@ -0,0 +1,25 @@
+import sublime
+
+import subprocess
+
+
+def view_is_suitable(view):
+    ok = view.file_name() and not view.is_dirty()
+    if not ok:
+        communicate_error("Please save file changes to disk first.")
+    return ok
+
+
+def communicate_error(e, modal=True):
+    user_msg = "Git blame:\n\n{}".format(e)
+    if isinstance(e, subprocess.CalledProcessError):
+        user_msg += "\n\n{}".format(e.output.decode("utf-8"))
+
+    print()
+    if modal:
+        sublime.error_message(user_msg)
+    else:
+        sublime.status_message(user_msg)
+        # Unlike with the error dialog, a status message is not automatically
+        # persisted in the console too.
+        print(user_msg)

From bcb05b41f43a1887055af1ee42aefcf3dede1475 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 14:09:27 +0100
Subject: [PATCH 033/180] Move BlameAll stuff into its own module

---
 blame.py     | 195 ------------------------------------------------
 blame_all.py | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++
 util.py      |  12 +++
 3 files changed, 217 insertions(+), 195 deletions(-)
 create mode 100644 blame_all.py

diff --git a/blame.py b/blame.py
index 0b30ea8..1ca047e 100644
--- a/blame.py
+++ b/blame.py
@@ -12,9 +12,6 @@
 from .util import view_is_suitable, communicate_error
 from .commit_skipping import BlameSetCommitSkippingMode
 
-PHANTOM_KEY_ALL = 'git-blame-all'
-SETTING_PHANTOM_ALL_DISPLAYED = 'git-blame-all-displayed'
-
 # @todo #0 Move the HTML/CSS templates to a separate source file.
 
 stylesheet_one = '''
@@ -70,51 +67,6 @@
     </body>
 '''
 
-stylesheet_all = '''
-    <style>
-        div.phantom {
-            padding: 0;
-            margin: 0;
-            background-color: color(var(--bluish) blend(var(--background) 30%));
-        }
-        div.phantom .user {
-            width: 10em;
-        }
-        div.phantom a.close {
-            padding: 0.35rem 0.7rem 0.45rem 0.8rem;
-            position: relative;
-            bottom: 0.05rem;
-            font-weight: bold;
-        }
-        html.dark div.phantom a.close {
-            background-color: #00000018;
-        }
-        html.light div.phantom a.close {
-            background-color: #ffffff18;
-        }
-    </style>
-'''
-
-template_all = '''
-    <body id="inline-git-blame">
-        {stylesheet}
-        <div class="phantom">
-            <span class="message">
-                {sha} (<span class="user">{user}</span> {date} {time})
-                <a class="close" href="close">''' + chr(0x00D7) + '''</a>
-            </span>
-        </div>
-    </body>
-'''
-
-# Sometimes this fails on other OS, just error silently
-try:
-    si = subprocess.STARTUPINFO()
-    si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
-except Exception:
-    si = None
-
-
 class BlameCommand(sublime_plugin.TextCommand):
 
     def __init__(self, view):
@@ -235,153 +187,6 @@ def run(self, edit):
             phantoms.append(phantom)
         self.phantom_set.update(phantoms)
 
-
-# @todo #0 Move BlameShowAllCommand to its own source file
-class BlameShowAllCommand(sublime_plugin.TextCommand):
-
-    # The fixed length for author names
-    NAME_LENGTH = 10
-
-    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
-
-            body = template_all.format(sha=sha,
-                                       user=self.format_name(author),
-                                       date=date,
-                                       time=time,
-                                       stylesheet=stylesheet_all)
-
-            line_point = self.get_line_point(line_number - 1)
-            phantom = sublime.Phantom(line_point,
-                                      body,
-                                      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 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=si,
-            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))
-
-    def on_phantom_close(self, href):
-        '''Closes opened phantoms.
-        '''
-        if href == 'close':
-            self.view.run_command('blame_erase_all')
-
-
-class BlameEraseAllCommand(sublime_plugin.TextCommand):
-
-    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):
-
-    @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)
-
-
 class InsertCommitDescriptionCommand(sublime_plugin.TextCommand):
 
     def run(self, edit, desc, scratch_view_name):
diff --git a/blame_all.py b/blame_all.py
new file mode 100644
index 0000000..3acbfc2
--- /dev/null
+++ b/blame_all.py
@@ -0,0 +1,205 @@
+import sublime
+import sublime_plugin
+
+import os
+import re
+import subprocess
+
+from .util import view_is_suitable, communicate_error, platform_startupinfo
+
+PHANTOM_KEY_ALL = "git-blame-all"
+SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
+
+stylesheet_all = '''
+    <style>
+        div.phantom {
+            padding: 0;
+            margin: 0;
+            background-color: color(var(--bluish) blend(var(--background) 30%));
+        }
+        div.phantom .user {
+            width: 10em;
+        }
+        div.phantom a.close {
+            padding: 0.35rem 0.7rem 0.45rem 0.8rem;
+            position: relative;
+            bottom: 0.05rem;
+            font-weight: bold;
+        }
+        html.dark div.phantom a.close {
+            background-color: #00000018;
+        }
+        html.light div.phantom a.close {
+            background-color: #ffffff18;
+        }
+    </style>
+'''
+
+template_all = '''
+    <body id="inline-git-blame">
+        {stylesheet}
+        <div class="phantom">
+            <span class="message">
+                {sha} (<span class="user">{user}</span> {date} {time})
+                <a class="close" href="close">''' + chr(0x00D7) + '''</a>
+            </span>
+        </div>
+    </body>
+'''
+
+class BlameShowAllCommand(sublime_plugin.TextCommand):
+
+    # The fixed length for author names
+    NAME_LENGTH = 10
+
+    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
+
+            body = template_all.format(
+                sha=sha,
+                user=self.format_name(author),
+                date=date,
+                time=time,
+                stylesheet=stylesheet_all,
+            )
+
+            line_point = self.get_line_point(line_number - 1)
+            phantom = sublime.Phantom(
+                line_point, body, 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 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))
+
+    def on_phantom_close(self, href):
+        """Closes opened phantoms.
+        """
+        if href == "close":
+            self.view.run_command("blame_erase_all")
+
+
+class BlameEraseAllCommand(sublime_plugin.TextCommand):
+    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):
+    @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)
diff --git a/util.py b/util.py
index e793eae..4dfd3a2 100644
--- a/util.py
+++ b/util.py
@@ -1,6 +1,7 @@
 import sublime
 
 import subprocess
+import sys
 
 
 def view_is_suitable(view):
@@ -23,3 +24,14 @@ def communicate_error(e, modal=True):
         # Unlike with the error dialog, a status message is not automatically
         # persisted in the console too.
         print(user_msg)
+
+
+def platform_startupinfo():
+    if sys.platform == "win32":
+        si = subprocess.STARTUPINFO()
+        # Stop a visible console window from appearing.
+        si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+        si.wShowWindow = subprocess.SW_HIDE
+        return si
+    else:
+        return None

From 399b199f4d97734ca343d32e3da9f10470c55d46 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 14:14:29 +0100
Subject: [PATCH 034/180] Use unique escape sequences for the fancy crosses

---
 blame.py     | 2 +-
 blame_all.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/blame.py b/blame.py
index 1ca047e..95edf74 100644
--- a/blame.py
+++ b/blame.py
@@ -61,7 +61,7 @@
                 {sha}
                 <a href="copy-{sha}">[Copy]</a>
                 <a href="show-{sha}">[Show]</a>
-                <a class="close" href="close">''' + chr(0x00D7) + '''</a>
+                <a class="close" href="close">\u00D7</a>
             </span>
         </div>
     </body>
diff --git a/blame_all.py b/blame_all.py
index 3acbfc2..61a3198 100644
--- a/blame_all.py
+++ b/blame_all.py
@@ -41,7 +41,7 @@
         <div class="phantom">
             <span class="message">
                 {sha} (<span class="user">{user}</span> {date} {time})
-                <a class="close" href="close">''' + chr(0x00D7) + '''</a>
+                <a class="close" href="close">\u00D7</a>
             </span>
         </div>
     </body>

From 33fc790c0908b8a82e4d5759c1347f4c0a097d62 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 14:19:50 +0100
Subject: [PATCH 035/180] Format and lint everything

---
 blame.py     | 77 ++++++++++++++++++++++++++++++----------------------
 blame_all.py |  9 +++---
 util.py      |  8 +++---
 3 files changed, 54 insertions(+), 40 deletions(-)

diff --git a/blame.py b/blame.py
index 95edf74..d5c87f5 100644
--- a/blame.py
+++ b/blame.py
@@ -1,7 +1,6 @@
 import sublime
 import sublime_plugin
 import os
-import re
 import subprocess
 
 from .common import (
@@ -9,12 +8,12 @@
     SETTINGS_KEY_COMMIT_SKIPPING_MODE,
     SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
 )
-from .util import view_is_suitable, communicate_error
+from .util import view_is_suitable, communicate_error, platform_startupinfo
 from .commit_skipping import BlameSetCommitSkippingMode
 
 # @todo #0 Move the HTML/CSS templates to a separate source file.
 
-stylesheet_one = '''
+stylesheet_one = """
     <style>
         div.phantom-arrow {
             border-top: 0.4rem solid transparent;
@@ -48,9 +47,9 @@
             background-color: #ffffff18;
         }
     </style>
-'''
+"""
 
-template_one = '''
+template_one = """
     <body id="inline-git-blame">
         {stylesheet}
         <div class="phantom-arrow"></div>
@@ -65,27 +64,32 @@
             </span>
         </div>
     </body>
-'''
+"""
 
-class BlameCommand(sublime_plugin.TextCommand):
 
+class BlameCommand(sublime_plugin.TextCommand):
     def __init__(self, view):
         self.view = view
-        self.phantom_set = sublime.PhantomSet(view, 'git-blame')
+        self.phantom_set = sublime.PhantomSet(view, "git-blame")
 
     def get_blame(self, line, path):
-        cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), os.path.basename(path)]
+        cmd_line = [
+            "git",
+            "blame",
+            "--minimal",
+            "-w",
+            "-L {0},{0}".format(line),
+            os.path.basename(path),
+        ]
 
         # @todo #21 Factor out loading of the commit-skipping mode so that BlameShowAllCommand can use it too.
         skipping_mode = self.view.settings().get(
-            SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
-            None
+            SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE, None
         )
         if skipping_mode is None:
             settings_file = sublime.load_settings(SETTINGS_FILE_BASENAME)
             skipping_mode = settings_file.get(
-                SETTINGS_KEY_COMMIT_SKIPPING_MODE,
-                BlameSetCommitSkippingMode.MODE_NONE
+                SETTINGS_KEY_COMMIT_SKIPPING_MODE, BlameSetCommitSkippingMode.MODE_NONE
             )
         try:
             cmd_line += BlameSetCommitSkippingMode.METADATA[skipping_mode]["git_args"]
@@ -97,15 +101,15 @@ def get_blame(self, line, path):
         return subprocess.check_output(
             cmd_line,
             cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=si,
-            stderr=subprocess.STDOUT
+            startupinfo=platform_startupinfo(),
+            stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
     def parse_blame(self, blame):
         sha, file_path, user, date, time, tz_offset, *_ = blame.split()
 
         # Was part of the inital commit so no updates
-        if file_path[0] == '(':
+        if file_path[0] == "(":
             user, date, time, tz_offset = file_path, user, date, time
             file_path = None
 
@@ -116,18 +120,18 @@ def parse_blame(self, blame):
             user = "{0} {1}".format(user, date)
             date, time = time, tz_offset
 
-        return(sha, user[1:], date, time)
+        return (sha, user[1:], date, time)
 
     def get_commit(self, sha, path):
         return subprocess.check_output(
             ["git", "show", sha],
             cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=si,
-            stderr=subprocess.STDOUT
-        ).decode('utf-8')
+            startupinfo=platform_startupinfo(),
+            stderr=subprocess.STDOUT,
+        ).decode("utf-8")
 
     def on_phantom_close(self, href):
-        href_parts = href.split('-')
+        href_parts = href.split("-")
 
         if len(href_parts) > 1:
             intent = href_parts[0]
@@ -136,11 +140,11 @@ def on_phantom_close(self, href):
             # that it is a "boundary commit". That useful information has
             # already been shown in the phantom, so strip it before going on to
             # use the SHA programmatically.
-            sha = sha.strip('^')
+            sha = sha.strip("^")
 
             if intent == "copy":
                 sublime.set_clipboard(sha)
-                sublime.status_message('Git SHA copied to clipboard')
+                sublime.status_message("Git SHA copied to clipboard")
             elif intent == "show":
                 try:
                     desc = self.get_commit(sha, self.view.file_name())
@@ -149,21 +153,26 @@ def on_phantom_close(self, href):
                     return
 
                 buf = self.view.window().new_file()
-                buf.run_command('insert_commit_description', {'desc': desc, 'scratch_view_name': 'commit ' + sha})
+                buf.run_command(
+                    "insert_commit_description",
+                    {"desc": desc, "scratch_view_name": "commit " + sha},
+                )
             else:
-                self.view.erase_phantoms('git-blame')
+                self.view.erase_phantoms("git-blame")
         else:
-            self.view.erase_phantoms('git-blame')
+            self.view.erase_phantoms("git-blame")
 
     def run(self, edit):
         if not view_is_suitable(self.view):
             return
 
         phantoms = []
-        self.view.erase_phantoms('git-blame')
+        self.view.erase_phantoms("git-blame")
         # Before adding the phantom, see if the current phantom that is displayed is at the same spot at the selection
         if self.phantom_set.phantoms:
-            phantom_exists = self.view.line(self.view.sel()[0]) == self.view.line(self.phantom_set.phantoms[0].region)
+            phantom_exists = self.view.line(self.view.sel()[0]) == self.view.line(
+                self.phantom_set.phantoms[0].region
+            )
             if phantom_exists:
                 self.phantom_set.update(phantoms)
                 return
@@ -181,17 +190,21 @@ def run(self, edit):
 
             sha, user, date, time = self.parse_blame(blame_output)
 
-            body = template_one.format(sha=sha, user=user, date=date, time=time, stylesheet=stylesheet_one)
+            body = template_one.format(
+                sha=sha, user=user, date=date, time=time, stylesheet=stylesheet_one
+            )
 
-            phantom = sublime.Phantom(line, body, sublime.LAYOUT_BLOCK, self.on_phantom_close)
+            phantom = sublime.Phantom(
+                line, body, sublime.LAYOUT_BLOCK, self.on_phantom_close
+            )
             phantoms.append(phantom)
         self.phantom_set.update(phantoms)
 
-class InsertCommitDescriptionCommand(sublime_plugin.TextCommand):
 
+class InsertCommitDescriptionCommand(sublime_plugin.TextCommand):
     def run(self, edit, desc, scratch_view_name):
         view = self.view
         view.set_scratch(True)
-        view.set_syntax_file('Packages/Diff/Diff.sublime-syntax')
+        view.set_syntax_file("Packages/Diff/Diff.sublime-syntax")
         view.insert(edit, 0, desc)
         view.set_name(scratch_view_name)
diff --git a/blame_all.py b/blame_all.py
index 61a3198..29ab3f2 100644
--- a/blame_all.py
+++ b/blame_all.py
@@ -10,7 +10,7 @@
 PHANTOM_KEY_ALL = "git-blame-all"
 SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
 
-stylesheet_all = '''
+stylesheet_all = """
     <style>
         div.phantom {
             padding: 0;
@@ -33,9 +33,9 @@
             background-color: #ffffff18;
         }
     </style>
-'''
+"""
 
-template_all = '''
+template_all = """
     <body id="inline-git-blame">
         {stylesheet}
         <div class="phantom">
@@ -45,7 +45,8 @@
             </span>
         </div>
     </body>
-'''
+"""
+
 
 class BlameShowAllCommand(sublime_plugin.TextCommand):
 
diff --git a/util.py b/util.py
index 4dfd3a2..684d35c 100644
--- a/util.py
+++ b/util.py
@@ -12,18 +12,18 @@ def view_is_suitable(view):
 
 
 def communicate_error(e, modal=True):
-    user_msg = "Git blame:\n\n{}".format(e)
+    user_msg = "Git blame:\n\n{0}".format(e)
     if isinstance(e, subprocess.CalledProcessError):
-        user_msg += "\n\n{}".format(e.output.decode("utf-8"))
+        user_msg += "\n\n{0}".format(e.output.decode("utf-8"))
 
-    print()
+    print()  # noqa: T001
     if modal:
         sublime.error_message(user_msg)
     else:
         sublime.status_message(user_msg)
         # Unlike with the error dialog, a status message is not automatically
         # persisted in the console too.
-        print(user_msg)
+        print(user_msg)  # noqa: T001
 
 
 def platform_startupinfo():

From 69d853733c44265380e5fce70fdb14519d21bd72 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 14:32:18 +0100
Subject: [PATCH 036/180] Make clear distinction between what is HTML vs CSS

---
 blame.py     | 81 ++++++++++++++++++++++++++--------------------------
 blame_all.py | 67 +++++++++++++++++++++----------------------
 2 files changed, 73 insertions(+), 75 deletions(-)

diff --git a/blame.py b/blame.py
index d5c87f5..a609db2 100644
--- a/blame.py
+++ b/blame.py
@@ -13,45 +13,43 @@
 
 # @todo #0 Move the HTML/CSS templates to a separate source file.
 
-stylesheet_one = """
-    <style>
-        div.phantom-arrow {
-            border-top: 0.4rem solid transparent;
-            border-left: 0.5rem solid color(var(--bluish) blend(var(--background) 30%));
-            width: 0;
-            height: 0;
-        }
-        div.phantom {
-            padding: 0.4rem 0 0.4rem 0.7rem;
-            margin: 0 0 0.2rem;
-            border-radius: 0 0.2rem 0.2rem 0.2rem;
-            background-color: color(var(--bluish) blend(var(--background) 30%));
-        }
-        div.phantom span.message {
-            padding-right: 0.7rem;
-        }
-        div.phantom a {
-            text-decoration: inherit;
-        }
-        div.phantom a.close {
-            padding: 0.35rem 0.7rem 0.45rem 0.8rem;
-            position: relative;
-            bottom: 0.05rem;
-            border-radius: 0 0.2rem 0.2rem 0;
-            font-weight: bold;
-        }
-        html.dark div.phantom a.close {
-            background-color: #00000018;
-        }
-        html.light div.phantom a.close {
-            background-color: #ffffff18;
-        }
-    </style>
+blame_phantom_css = """
+    div.phantom-arrow {
+        border-top: 0.4rem solid transparent;
+        border-left: 0.5rem solid color(var(--bluish) blend(var(--background) 30%));
+        width: 0;
+        height: 0;
+    }
+    div.phantom {
+        padding: 0.4rem 0 0.4rem 0.7rem;
+        margin: 0 0 0.2rem;
+        border-radius: 0 0.2rem 0.2rem 0.2rem;
+        background-color: color(var(--bluish) blend(var(--background) 30%));
+    }
+    div.phantom span.message {
+        padding-right: 0.7rem;
+    }
+    div.phantom a {
+        text-decoration: inherit;
+    }
+    div.phantom a.close {
+        padding: 0.35rem 0.7rem 0.45rem 0.8rem;
+        position: relative;
+        bottom: 0.05rem;
+        border-radius: 0 0.2rem 0.2rem 0;
+        font-weight: bold;
+    }
+    html.dark div.phantom a.close {
+        background-color: #00000018;
+    }
+    html.light div.phantom a.close {
+        background-color: #ffffff18;
+    }
 """
 
-template_one = """
+blame_phantom_html_template = """
     <body id="inline-git-blame">
-        {stylesheet}
+        <style>{css}</style>
         <div class="phantom-arrow"></div>
         <div class="phantom">
             <span class="message">
@@ -190,12 +188,13 @@ def run(self, edit):
 
             sha, user, date, time = self.parse_blame(blame_output)
 
-            body = template_one.format(
-                sha=sha, user=user, date=date, time=time, stylesheet=stylesheet_one
-            )
-
             phantom = sublime.Phantom(
-                line, body, sublime.LAYOUT_BLOCK, self.on_phantom_close
+                line,
+                blame_phantom_html_template.format(
+                    css=blame_phantom_css, sha=sha, user=user, date=date, time=time
+                ),
+                sublime.LAYOUT_BLOCK,
+                self.on_phantom_close,
             )
             phantoms.append(phantom)
         self.phantom_set.update(phantoms)
diff --git a/blame_all.py b/blame_all.py
index 29ab3f2..a6f8f02 100644
--- a/blame_all.py
+++ b/blame_all.py
@@ -10,34 +10,32 @@
 PHANTOM_KEY_ALL = "git-blame-all"
 SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
 
-stylesheet_all = """
-    <style>
-        div.phantom {
-            padding: 0;
-            margin: 0;
-            background-color: color(var(--bluish) blend(var(--background) 30%));
-        }
-        div.phantom .user {
-            width: 10em;
-        }
-        div.phantom a.close {
-            padding: 0.35rem 0.7rem 0.45rem 0.8rem;
-            position: relative;
-            bottom: 0.05rem;
-            font-weight: bold;
-        }
-        html.dark div.phantom a.close {
-            background-color: #00000018;
-        }
-        html.light div.phantom a.close {
-            background-color: #ffffff18;
-        }
-    </style>
+blame_all_phantom_css = """
+    div.phantom {
+        padding: 0;
+        margin: 0;
+        background-color: color(var(--bluish) blend(var(--background) 30%));
+    }
+    div.phantom .user {
+        width: 10em;
+    }
+    div.phantom a.close {
+        padding: 0.35rem 0.7rem 0.45rem 0.8rem;
+        position: relative;
+        bottom: 0.05rem;
+        font-weight: bold;
+    }
+    html.dark div.phantom a.close {
+        background-color: #00000018;
+    }
+    html.light div.phantom a.close {
+        background-color: #ffffff18;
+    }
 """
 
-template_all = """
+blame_all_phantom_html_template = """
     <body id="inline-git-blame">
-        {stylesheet}
+        <style>{css}</style>
         <div class="phantom">
             <span class="message">
                 {sha} (<span class="user">{user}</span> {date} {time})
@@ -84,17 +82,18 @@ def run(self, edit):
 
             sha, author, date, time, line_number = parsed
 
-            body = template_all.format(
-                sha=sha,
-                user=self.format_name(author),
-                date=date,
-                time=time,
-                stylesheet=stylesheet_all,
-            )
-
             line_point = self.get_line_point(line_number - 1)
             phantom = sublime.Phantom(
-                line_point, body, sublime.LAYOUT_INLINE, self.on_phantom_close
+                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)
 

From 448ad76a6e7344fe77b6b76aec7e26e7a5da2472 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 14:37:04 +0100
Subject: [PATCH 037/180] Move HTML and CSS templates into their own module

---
 blame.py     | 54 +-------------------------------
 blame_all.py | 36 +--------------------
 templates.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 90 insertions(+), 88 deletions(-)
 create mode 100644 templates.py

diff --git a/blame.py b/blame.py
index a609db2..fd4b657 100644
--- a/blame.py
+++ b/blame.py
@@ -9,61 +9,9 @@
     SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
 )
 from .util import view_is_suitable, communicate_error, platform_startupinfo
+from .templates import blame_phantom_html_template, blame_phantom_css
 from .commit_skipping import BlameSetCommitSkippingMode
 
-# @todo #0 Move the HTML/CSS templates to a separate source file.
-
-blame_phantom_css = """
-    div.phantom-arrow {
-        border-top: 0.4rem solid transparent;
-        border-left: 0.5rem solid color(var(--bluish) blend(var(--background) 30%));
-        width: 0;
-        height: 0;
-    }
-    div.phantom {
-        padding: 0.4rem 0 0.4rem 0.7rem;
-        margin: 0 0 0.2rem;
-        border-radius: 0 0.2rem 0.2rem 0.2rem;
-        background-color: color(var(--bluish) blend(var(--background) 30%));
-    }
-    div.phantom span.message {
-        padding-right: 0.7rem;
-    }
-    div.phantom a {
-        text-decoration: inherit;
-    }
-    div.phantom a.close {
-        padding: 0.35rem 0.7rem 0.45rem 0.8rem;
-        position: relative;
-        bottom: 0.05rem;
-        border-radius: 0 0.2rem 0.2rem 0;
-        font-weight: bold;
-    }
-    html.dark div.phantom a.close {
-        background-color: #00000018;
-    }
-    html.light div.phantom a.close {
-        background-color: #ffffff18;
-    }
-"""
-
-blame_phantom_html_template = """
-    <body id="inline-git-blame">
-        <style>{css}</style>
-        <div class="phantom-arrow"></div>
-        <div class="phantom">
-            <span class="message">
-                <strong>Git Blame</strong> ({user})
-                {date} {time} |
-                {sha}
-                <a href="copy-{sha}">[Copy]</a>
-                <a href="show-{sha}">[Show]</a>
-                <a class="close" href="close">\u00D7</a>
-            </span>
-        </div>
-    </body>
-"""
-
 
 class BlameCommand(sublime_plugin.TextCommand):
     def __init__(self, view):
diff --git a/blame_all.py b/blame_all.py
index a6f8f02..ba49699 100644
--- a/blame_all.py
+++ b/blame_all.py
@@ -6,45 +6,11 @@
 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"
 
-blame_all_phantom_css = """
-    div.phantom {
-        padding: 0;
-        margin: 0;
-        background-color: color(var(--bluish) blend(var(--background) 30%));
-    }
-    div.phantom .user {
-        width: 10em;
-    }
-    div.phantom a.close {
-        padding: 0.35rem 0.7rem 0.45rem 0.8rem;
-        position: relative;
-        bottom: 0.05rem;
-        font-weight: bold;
-    }
-    html.dark div.phantom a.close {
-        background-color: #00000018;
-    }
-    html.light div.phantom a.close {
-        background-color: #ffffff18;
-    }
-"""
-
-blame_all_phantom_html_template = """
-    <body id="inline-git-blame">
-        <style>{css}</style>
-        <div class="phantom">
-            <span class="message">
-                {sha} (<span class="user">{user}</span> {date} {time})
-                <a class="close" href="close">\u00D7</a>
-            </span>
-        </div>
-    </body>
-"""
-
 
 class BlameShowAllCommand(sublime_plugin.TextCommand):
 
diff --git a/templates.py b/templates.py
new file mode 100644
index 0000000..7a31232
--- /dev/null
+++ b/templates.py
@@ -0,0 +1,88 @@
+blame_phantom_html_template = """
+    <body id="inline-git-blame">
+        <style>{css}</style>
+        <div class="phantom-arrow"></div>
+        <div class="phantom">
+            <span class="message">
+                <strong>Git Blame</strong> ({user})
+                {date} {time} |
+                {sha}
+                <a href="copy-{sha}">[Copy]</a>
+                <a href="show-{sha}">[Show]</a>
+                <a class="close" href="close">\u00D7</a>
+            </span>
+        </div>
+    </body>
+"""
+
+blame_phantom_css = """
+    div.phantom-arrow {
+        border-top: 0.4rem solid transparent;
+        border-left: 0.5rem solid color(var(--bluish) blend(var(--background) 30%));
+        width: 0;
+        height: 0;
+    }
+    div.phantom {
+        padding: 0.4rem 0 0.4rem 0.7rem;
+        margin: 0 0 0.2rem;
+        border-radius: 0 0.2rem 0.2rem 0.2rem;
+        background-color: color(var(--bluish) blend(var(--background) 30%));
+    }
+    div.phantom span.message {
+        padding-right: 0.7rem;
+    }
+    div.phantom a {
+        text-decoration: inherit;
+    }
+    div.phantom a.close {
+        padding: 0.35rem 0.7rem 0.45rem 0.8rem;
+        position: relative;
+        bottom: 0.05rem;
+        border-radius: 0 0.2rem 0.2rem 0;
+        font-weight: bold;
+    }
+    html.dark div.phantom a.close {
+        background-color: #00000018;
+    }
+    html.light div.phantom a.close {
+        background-color: #ffffff18;
+    }
+"""
+
+# ------------------------------------------------------------
+
+blame_all_phantom_html_template = """
+    <body id="inline-git-blame">
+        <style>{css}</style>
+        <div class="phantom">
+            <span class="message">
+                {sha} (<span class="user">{user}</span> {date} {time})
+                <a class="close" href="close">\u00D7</a>
+            </span>
+        </div>
+    </body>
+"""
+
+
+blame_all_phantom_css = """
+    div.phantom {
+        padding: 0;
+        margin: 0;
+        background-color: color(var(--bluish) blend(var(--background) 30%));
+    }
+    div.phantom .user {
+        width: 10em;
+    }
+    div.phantom a.close {
+        padding: 0.35rem 0.7rem 0.45rem 0.8rem;
+        position: relative;
+        bottom: 0.05rem;
+        font-weight: bold;
+    }
+    html.dark div.phantom a.close {
+        background-color: #00000018;
+    }
+    html.light div.phantom a.close {
+        background-color: #ffffff18;
+    }
+"""

From eef739a610a940a632855924f51832750306652d Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 14:50:24 +0100
Subject: [PATCH 038/180] Normalise the class names of our Commands

---
 blame.py     | 8 ++++----
 blame_all.py | 4 ++--
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/blame.py b/blame.py
index fd4b657..beb6abd 100644
--- a/blame.py
+++ b/blame.py
@@ -13,7 +13,7 @@
 from .commit_skipping import BlameSetCommitSkippingMode
 
 
-class BlameCommand(sublime_plugin.TextCommand):
+class Blame(sublime_plugin.TextCommand):
     def __init__(self, view):
         self.view = view
         self.phantom_set = sublime.PhantomSet(view, "git-blame")
@@ -28,7 +28,7 @@ def get_blame(self, line, path):
             os.path.basename(path),
         ]
 
-        # @todo #21 Factor out loading of the commit-skipping mode so that BlameShowAllCommand can use it too.
+        # @todo #21 Factor out loading of the commit-skipping mode so that the BlameShowAll command can use it too.
         skipping_mode = self.view.settings().get(
             SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE, None
         )
@@ -100,7 +100,7 @@ def on_phantom_close(self, href):
 
                 buf = self.view.window().new_file()
                 buf.run_command(
-                    "insert_commit_description",
+                    "blame_insert_commit_description",
                     {"desc": desc, "scratch_view_name": "commit " + sha},
                 )
             else:
@@ -148,7 +148,7 @@ def run(self, edit):
         self.phantom_set.update(phantoms)
 
 
-class InsertCommitDescriptionCommand(sublime_plugin.TextCommand):
+class BlameInsertCommitDescription(sublime_plugin.TextCommand):
     def run(self, edit, desc, scratch_view_name):
         view = self.view
         view.set_scratch(True)
diff --git a/blame_all.py b/blame_all.py
index ba49699..b05a4e7 100644
--- a/blame_all.py
+++ b/blame_all.py
@@ -12,7 +12,7 @@
 SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
 
 
-class BlameShowAllCommand(sublime_plugin.TextCommand):
+class BlameShowAll(sublime_plugin.TextCommand):
 
     # The fixed length for author names
     NAME_LENGTH = 10
@@ -149,7 +149,7 @@ def on_phantom_close(self, href):
             self.view.run_command("blame_erase_all")
 
 
-class BlameEraseAllCommand(sublime_plugin.TextCommand):
+class BlameEraseAll(sublime_plugin.TextCommand):
     def run(self, edit):
         """Erases the blame results.
         """

From a6a4f71eeb7e9017ab5ed426b1ad7b7bc2d76fcb Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 15:19:06 +0100
Subject: [PATCH 039/180] Use subdirectories for package guts:

and provide a single plugin (plugins can only be at the top level) that bootstraps things.
---
 .../Default.sublime-commands                        |  0
 .../Default (Linux).sublime-keymap                  |  0
 .../Default (OSX).sublime-keymap                    |  0
 .../Default (Windows).sublime-keymap                |  0
 Context.sublime-menu => Menus/Context.sublime-menu  |  0
 Main.sublime-menu => Menus/Main.sublime-menu        |  0
 boot.py                                             | 13 +++++++++++++
 blame.py => src/blame.py                            |  0
 blame_all.py => src/blame_all.py                    |  0
 commit_skipping.py => src/commit_skipping.py        |  0
 common.py => src/common.py                          |  0
 templates.py => src/templates.py                    |  0
 util.py => src/util.py                              |  0
 13 files changed, 13 insertions(+)
 rename Default.sublime-commands => Commands/Default.sublime-commands (100%)
 rename Default (Linux).sublime-keymap => Keymaps/Default (Linux).sublime-keymap (100%)
 rename Default (OSX).sublime-keymap => Keymaps/Default (OSX).sublime-keymap (100%)
 rename Default (Windows).sublime-keymap => Keymaps/Default (Windows).sublime-keymap (100%)
 rename Context.sublime-menu => Menus/Context.sublime-menu (100%)
 rename Main.sublime-menu => Menus/Main.sublime-menu (100%)
 create mode 100644 boot.py
 rename blame.py => src/blame.py (100%)
 rename blame_all.py => src/blame_all.py (100%)
 rename commit_skipping.py => src/commit_skipping.py (100%)
 rename common.py => src/common.py (100%)
 rename templates.py => src/templates.py (100%)
 rename util.py => src/util.py (100%)

diff --git a/Default.sublime-commands b/Commands/Default.sublime-commands
similarity index 100%
rename from Default.sublime-commands
rename to Commands/Default.sublime-commands
diff --git a/Default (Linux).sublime-keymap b/Keymaps/Default (Linux).sublime-keymap
similarity index 100%
rename from Default (Linux).sublime-keymap
rename to Keymaps/Default (Linux).sublime-keymap
diff --git a/Default (OSX).sublime-keymap b/Keymaps/Default (OSX).sublime-keymap
similarity index 100%
rename from Default (OSX).sublime-keymap
rename to Keymaps/Default (OSX).sublime-keymap
diff --git a/Default (Windows).sublime-keymap b/Keymaps/Default (Windows).sublime-keymap
similarity index 100%
rename from Default (Windows).sublime-keymap
rename to Keymaps/Default (Windows).sublime-keymap
diff --git a/Context.sublime-menu b/Menus/Context.sublime-menu
similarity index 100%
rename from Context.sublime-menu
rename to Menus/Context.sublime-menu
diff --git a/Main.sublime-menu b/Menus/Main.sublime-menu
similarity index 100%
rename from Main.sublime-menu
rename to Menus/Main.sublime-menu
diff --git a/boot.py b/boot.py
new file mode 100644
index 0000000..a9ead88
--- /dev/null
+++ b/boot.py
@@ -0,0 +1,13 @@
+# flake8: noqa
+
+from .src.blame import *
+from .src.blame_all import *
+
+
+def plugin_loaded():
+    pass
+
+
+# Only called when this plugin is modified or deleted, not when Sublime exits.
+def plugin_unloaded():
+    pass
diff --git a/blame.py b/src/blame.py
similarity index 100%
rename from blame.py
rename to src/blame.py
diff --git a/blame_all.py b/src/blame_all.py
similarity index 100%
rename from blame_all.py
rename to src/blame_all.py
diff --git a/commit_skipping.py b/src/commit_skipping.py
similarity index 100%
rename from commit_skipping.py
rename to src/commit_skipping.py
diff --git a/common.py b/src/common.py
similarity index 100%
rename from common.py
rename to src/common.py
diff --git a/templates.py b/src/templates.py
similarity index 100%
rename from templates.py
rename to src/templates.py
diff --git a/util.py b/src/util.py
similarity index 100%
rename from util.py
rename to src/util.py

From 30eba6c886b28aa8e3c2f11a82dcd94253cd57d7 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 15:19:57 +0100
Subject: [PATCH 040/180] Add todo

---
 Menus/Main.sublime-menu | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
index a197c96..ff5eb43 100644
--- a/Menus/Main.sublime-menu
+++ b/Menus/Main.sublime-menu
@@ -23,3 +23,5 @@
     ]
   }
 ]
+
+// @todo #0 Add a Key Bindings entry to the Settings menu that opens a 2-column editor.

From 459ab2b359535f24e7721e60ecba26f119037e6b Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 15:32:49 +0100
Subject: [PATCH 041/180] Default settings files needn't be at root level

---
 Menus/Main.sublime-menu                                         | 2 +-
 .../Git blame.sublime-settings                                  | 0
 src/blame.py                                                    | 1 -
 3 files changed, 1 insertion(+), 2 deletions(-)
 rename Git blame.sublime-settings => Settings/Git blame.sublime-settings (100%)

diff --git a/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
index ff5eb43..f7a1c76 100644
--- a/Menus/Main.sublime-menu
+++ b/Menus/Main.sublime-menu
@@ -12,7 +12,7 @@
                 "caption": "Settings",
                 "command": "edit_settings",
                 "args": {
-                  "base_file": "${packages}/st3-gitblame/Git blame.sublime-settings",
+                  "base_file": "${packages}/st3-gitblame/Settings/Git blame.sublime-settings",
                   "default": "{\n\t$0\n}\n"
                 }
               }
diff --git a/Git blame.sublime-settings b/Settings/Git blame.sublime-settings
similarity index 100%
rename from Git blame.sublime-settings
rename to Settings/Git blame.sublime-settings
diff --git a/src/blame.py b/src/blame.py
index beb6abd..341ceb9 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -41,7 +41,6 @@ def get_blame(self, line, path):
             cmd_line += BlameSetCommitSkippingMode.METADATA[skipping_mode]["git_args"]
         except KeyError as e:
             communicate_error("Unexpected commit skipping mode: {0}".format(e))
-        # sublime.message_dialog(str(cmd_line))
 
         # print(cmd_line)
         return subprocess.check_output(

From 320458e8f0e8c85343e5835cca0d80e46d41f9da Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 15:34:27 +0100
Subject: [PATCH 042/180] Clarify todo

---
 Menus/Main.sublime-menu | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
index f7a1c76..4e6c964 100644
--- a/Menus/Main.sublime-menu
+++ b/Menus/Main.sublime-menu
@@ -24,4 +24,4 @@
   }
 ]
 
-// @todo #0 Add a Key Bindings entry to the Settings menu that opens a 2-column editor.
+// @todo #0 Add a Key Bindings entry to the Settings menu that opens a 2-column editor (DefaultCurrentPlatform,UserCurrentPlatform)

From e219a9841177b6c253437b734b087d405bff1dd8 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 19:57:57 +0100
Subject: [PATCH 043/180] Renaming; Comment

---
 boot.py                | 7 ++++---
 src/blame.py           | 2 +-
 src/commit_skipping.py | 4 ++--
 3 files changed, 7 insertions(+), 6 deletions(-)

diff --git a/boot.py b/boot.py
index a9ead88..3742b19 100644
--- a/boot.py
+++ b/boot.py
@@ -1,5 +1,4 @@
-# flake8: noqa
-
+# Importing our classes that have sublime_plugin superclasses causes Sublime to become aware of them.
 from .src.blame import *
 from .src.blame_all import *
 
@@ -8,6 +7,8 @@ def plugin_loaded():
     pass
 
 
-# Only called when this plugin is modified or deleted, not when Sublime exits.
 def plugin_unloaded():
     pass
+
+
+# flake8: noqa
diff --git a/src/blame.py b/src/blame.py
index 341ceb9..9bcca7f 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -38,7 +38,7 @@ def get_blame(self, line, path):
                 SETTINGS_KEY_COMMIT_SKIPPING_MODE, BlameSetCommitSkippingMode.MODE_NONE
             )
         try:
-            cmd_line += BlameSetCommitSkippingMode.METADATA[skipping_mode]["git_args"]
+            cmd_line += BlameSetCommitSkippingMode.DETAIL[skipping_mode]["git_args"]
         except KeyError as e:
             communicate_error("Unexpected commit skipping mode: {0}".format(e))
 
diff --git a/src/commit_skipping.py b/src/commit_skipping.py
index 5c5de4c..e9a4e28 100644
--- a/src/commit_skipping.py
+++ b/src/commit_skipping.py
@@ -15,7 +15,7 @@ class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
     MODE_CROSS_ANY_FILE = "cross_any_file"
     MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
 
-    METADATA = {
+    DETAIL = {
         MODE_NONE: {"elaboration": "<Disable Skipping>", "git_args": []},
         MODE_SAME_FILE_SAME_COMMIT: {
             "elaboration": "... moved/copied the line within a file",
@@ -67,7 +67,7 @@ def list_items(self):
                 ),
                 mode,
             ]
-            for mode, metadata in BlameSetCommitSkippingMode.METADATA.items()
+            for mode, metadata in BlameSetCommitSkippingMode.DETAIL.items()
         ]
 
     def next_input(self, args):

From 2604fd99a78777fd7e52c2bb6b2835800bd1919d Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 20:35:34 +0100
Subject: [PATCH 044/180] Whitelist specific flake8 errors per-file

Surprisingly, it's not possible to do this using a comment at the top a file. The only thing that can be done that way is to ignore ALL errors in the file (# flake8: noqa)
---
 .flake8 | 3 +++
 boot.py | 3 ---
 2 files changed, 3 insertions(+), 3 deletions(-)
 create mode 100644 .flake8

diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..899d695
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,3 @@
+[flake8]
+per-file-ignores =
+    boot.py:F401,F403
diff --git a/boot.py b/boot.py
index 3742b19..70efa3e 100644
--- a/boot.py
+++ b/boot.py
@@ -9,6 +9,3 @@ def plugin_loaded():
 
 def plugin_unloaded():
     pass
-
-
-# flake8: noqa

From 6b11c7bb7f3b816099dfd3ae98b39e3e1e662f2d Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 30 Jun 2019 20:44:45 +0100
Subject: [PATCH 045/180] Improve text presented in ModeInputHandler

---
 src/commit_skipping.py | 13 +++++--------
 1 file changed, 5 insertions(+), 8 deletions(-)

diff --git a/src/commit_skipping.py b/src/commit_skipping.py
index e9a4e28..0791182 100644
--- a/src/commit_skipping.py
+++ b/src/commit_skipping.py
@@ -16,21 +16,21 @@ class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
     MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
 
     DETAIL = {
-        MODE_NONE: {"elaboration": "<Disable Skipping>", "git_args": []},
+        MODE_NONE: {"elaboration": "<NONE>", "git_args": []},
         MODE_SAME_FILE_SAME_COMMIT: {
-            "elaboration": "... moved/copied the line within a file",
+            "elaboration": "...moved/copied the line within a file",
             "git_args": ["-M"],
         },
         MODE_CROSS_FILE_SAME_COMMIT: {
-            "elaboration": "... moved/copied the line from another file modified in the same commit",
+            "elaboration": "...moved/copied the line from another file modified in the same commit",
             "git_args": ["-C"],
         },
         MODE_CROSS_ANY_FILE: {
-            "elaboration": "... created the file with a copy of a line from any other file",
+            "elaboration": "...created the file with a copy of a line from any other file",
             "git_args": ["-C"] * 2,
         },
         MODE_CROSS_ANY_HISTORICAL_FILE: {
-            "elaboration": "... created the file with a copy of a line from any other historical file",
+            "elaboration": "...created the file with a copy of a line from any other historical file",
             "git_args": ["-C"] * 3,
         },
     }
@@ -73,9 +73,6 @@ def list_items(self):
     def next_input(self, args):
         return PermanenceInputHandler()
 
-    def description(self, value, text):
-        return 'to "{0}"'.format(text)
-
 
 class PermanenceInputHandler(sublime_plugin.ListInputHandler):
     def list_items(self):

From da2fb6eba153c66bd54a6ce5f97eb1d0dba580cd Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 1 Jul 2019 10:16:23 +0100
Subject: [PATCH 046/180] Add a Key Bindings entry to the Settings menu

---
 Menus/Main.sublime-menu | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
index 4e6c964..365af24 100644
--- a/Menus/Main.sublime-menu
+++ b/Menus/Main.sublime-menu
@@ -15,6 +15,14 @@
                   "base_file": "${packages}/st3-gitblame/Settings/Git blame.sublime-settings",
                   "default": "{\n\t$0\n}\n"
                 }
+              },
+              {
+                "caption": "Key Bindings",
+                "command": "edit_settings",
+                "args": {
+                  "base_file": "${packages}/st3-gitblame/Keymaps/Default (${platform}).sublime-keymap",
+                  "default": "[\n\t$0\n]\n"
+                }
               }
             ]
           }
@@ -23,5 +31,3 @@
     ]
   }
 ]
-
-// @todo #0 Add a Key Bindings entry to the Settings menu that opens a 2-column editor (DefaultCurrentPlatform,UserCurrentPlatform)

From a02389295c4f111f9fc140ec669540f17c293e01 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 1 Jul 2019 11:04:35 +0100
Subject: [PATCH 047/180] Comment

---
 boot.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/boot.py b/boot.py
index 70efa3e..257aba5 100644
--- a/boot.py
+++ b/boot.py
@@ -1,4 +1,5 @@
-# Importing our classes that have sublime_plugin superclasses causes Sublime to become aware of them.
+# Only .py files at the top-level of a Sublime package are considered "plugins".
+# Make Sublime aware of our *{Command,Listener,Handler} classes by importing them:
 from .src.blame import *
 from .src.blame_all import *
 

From f15b9d1ed2a029b77db9c00a4a28eb5f3cecf104 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Thu, 11 Jul 2019 10:34:45 +0100
Subject: [PATCH 048/180] Rephrase

---
 README.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index d16cec4..ff09d97 100644
--- a/README.md
+++ b/README.md
@@ -25,12 +25,12 @@ The blame information will appear:
 
 ![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/screenshot.png)
 
-You can then click `[Show]` to show the commit in full, or `[Copy]` to copy the Commit ID (aka SHA) to your clipboard. Click × or press the keyboard shortcut again to close the blame information.
+You can then click `[Show]` to show the commit in full, or `[Copy]` to copy the Commit ID (aka SHA) to your clipboard. To close the blame information, click the `×` icon, or press the keyboard shortcut again.
 
 ## Advanced use
 
 In combination with Sublime Text's *Multiple Cursor* feature, you can query blame information for more than one line simultaneously by first placing a cursor on each line you are interested in.
 
-You can also query blame information for every line in the entire file simultaneously by pressing <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>Shift</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>C</kbd> (Linux). Click × or press the keyboard shortcut again to close them.
+You can also query blame information for every line in the entire file simultaneously by pressing <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>Shift</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>C</kbd> (Linux). To close all of them, click the `×` icon on one of them, or press the keyboard shortcut again.
 
 This package's commands are also made available in the *Command Palette*. Type "Git Blame" into it to find them.

From e8156271e9bbd5aa4a330d0739931480a84d22ee Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 12 Aug 2019 19:44:22 +0100
Subject: [PATCH 049/180] Update 0pdd config

---
 .0pdd.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.0pdd.yml b/.0pdd.yml
index b2fcf8f..86f0554 100644
--- a/.0pdd.yml
+++ b/.0pdd.yml
@@ -6,4 +6,5 @@ format:
 alerts:
   suppress:
     - on-found-puzzle
+    - on-lost-puzzle
     - on-scope

From 8cb638d5f4c50c04be21fa4a13a8748b81a72d6c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Fri, 1 Nov 2019 12:02:25 +0000
Subject: [PATCH 050/180] Add todo

---
 src/blame.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/blame.py b/src/blame.py
index 9bcca7f..dbc733b 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -12,6 +12,9 @@
 from .templates import blame_phantom_html_template, blame_phantom_css
 from .commit_skipping import BlameSetCommitSkippingMode
 
+# @todo #0 Add a [Prev] button to the phantom, that causes it to reflect the previous commit that changed the line.
+#  This has some overlap with the "commit-skipping" feature and possibly obsoletes it?
+
 
 class Blame(sublime_plugin.TextCommand):
     def __init__(self, view):

From 6078b5ef3afdc9a223bc5b8579d1ecbbfd00d4dc Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Fri, 15 Nov 2019 16:33:35 +0000
Subject: [PATCH 051/180] Stop using deprecated method

---
 src/blame.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame.py b/src/blame.py
index dbc733b..ef94f19 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -154,6 +154,6 @@ class BlameInsertCommitDescription(sublime_plugin.TextCommand):
     def run(self, edit, desc, scratch_view_name):
         view = self.view
         view.set_scratch(True)
-        view.set_syntax_file("Packages/Diff/Diff.sublime-syntax")
+        view.assign_syntax("Packages/Diff/Diff.sublime-syntax")
         view.insert(edit, 0, desc)
         view.set_name(scratch_view_name)

From d3d7e17d00d23db7a59f2e249e3e7b069caef2f3 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 19 Nov 2019 16:14:51 +0000
Subject: [PATCH 052/180] Make use of superclass ctor

---
 src/blame.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame.py b/src/blame.py
index ef94f19..c0cb7e9 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -18,7 +18,7 @@
 
 class Blame(sublime_plugin.TextCommand):
     def __init__(self, view):
-        self.view = view
+        super().__init__(view)
         self.phantom_set = sublime.PhantomSet(view, "git-blame")
 
     def get_blame(self, line, path):

From 985bbb16fc1a7049b09d8e491a74448940cb8efb Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 19 Nov 2019 16:22:49 +0000
Subject: [PATCH 053/180] Signpost method overrides

---
 src/blame.py           | 148 ++++++++++++++++++++++-------------------
 src/blame_all.py       |  22 ++++--
 src/commit_skipping.py |   8 +++
 3 files changed, 102 insertions(+), 76 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index c0cb7e9..a6f300f 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -17,10 +17,86 @@
 
 
 class Blame(sublime_plugin.TextCommand):
+
+    # Overrides --------------------------------------------------
+
     def __init__(self, view):
         super().__init__(view)
         self.phantom_set = sublime.PhantomSet(view, "git-blame")
 
+    def run(self, edit):
+        if not view_is_suitable(self.view):
+            return
+
+        phantoms = []
+        self.view.erase_phantoms("git-blame")
+        # Before adding the phantom, see if the current phantom that is displayed is at the same spot at the selection
+        if self.phantom_set.phantoms:
+            phantom_exists = self.view.line(self.view.sel()[0]) == self.view.line(
+                self.phantom_set.phantoms[0].region
+            )
+            if phantom_exists:
+                self.phantom_set.update(phantoms)
+                return
+
+        for region in self.view.sel():
+            line = self.view.line(region)
+            (row, col) = self.view.rowcol(region.begin())
+            full_path = self.view.file_name()
+
+            try:
+                blame_output = self.get_blame(int(row) + 1, full_path)
+            except Exception as e:
+                communicate_error(e)
+                return
+
+            sha, user, date, time = self.parse_blame(blame_output)
+
+            phantom = sublime.Phantom(
+                line,
+                blame_phantom_html_template.format(
+                    css=blame_phantom_css, sha=sha, user=user, date=date, time=time
+                ),
+                sublime.LAYOUT_BLOCK,
+                self.on_phantom_close,
+            )
+            phantoms.append(phantom)
+        self.phantom_set.update(phantoms)
+
+    def on_phantom_close(self, href):
+        href_parts = href.split("-")
+
+        if len(href_parts) > 1:
+            intent = href_parts[0]
+            sha = href_parts[1]
+            # The SHA output by git-blame may have a leading caret to indicate
+            # that it is a "boundary commit". That useful information has
+            # already been shown in the phantom, so strip it before going on to
+            # use the SHA programmatically.
+            sha = sha.strip("^")
+
+            if intent == "copy":
+                sublime.set_clipboard(sha)
+                sublime.status_message("Git SHA copied to clipboard")
+            elif intent == "show":
+                try:
+                    desc = self.get_commit(sha, self.view.file_name())
+                except Exception as e:
+                    communicate_error(e)
+                    return
+
+                buf = self.view.window().new_file()
+                buf.run_command(
+                    "blame_insert_commit_description",
+                    {"desc": desc, "scratch_view_name": "commit " + sha},
+                )
+            else:
+                self.view.erase_phantoms("git-blame")
+        else:
+            self.view.erase_phantoms("git-blame")
+
+    # ------------------------------------------------------------
+
     def get_blame(self, line, path):
         cmd_line = [
             "git",
@@ -78,79 +154,11 @@ def get_commit(self, sha, path):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
-    def on_phantom_close(self, href):
-        href_parts = href.split("-")
-
-        if len(href_parts) > 1:
-            intent = href_parts[0]
-            sha = href_parts[1]
-            # The SHA output by git-blame may have a leading caret to indicate
-            # that it is a "boundary commit". That useful information has
-            # already been shown in the phantom, so strip it before going on to
-            # use the SHA programmatically.
-            sha = sha.strip("^")
-
-            if intent == "copy":
-                sublime.set_clipboard(sha)
-                sublime.status_message("Git SHA copied to clipboard")
-            elif intent == "show":
-                try:
-                    desc = self.get_commit(sha, self.view.file_name())
-                except Exception as e:
-                    communicate_error(e)
-                    return
-
-                buf = self.view.window().new_file()
-                buf.run_command(
-                    "blame_insert_commit_description",
-                    {"desc": desc, "scratch_view_name": "commit " + sha},
-                )
-            else:
-                self.view.erase_phantoms("git-blame")
-        else:
-            self.view.erase_phantoms("git-blame")
-
-    def run(self, edit):
-        if not view_is_suitable(self.view):
-            return
-
-        phantoms = []
-        self.view.erase_phantoms("git-blame")
-        # Before adding the phantom, see if the current phantom that is displayed is at the same spot at the selection
-        if self.phantom_set.phantoms:
-            phantom_exists = self.view.line(self.view.sel()[0]) == self.view.line(
-                self.phantom_set.phantoms[0].region
-            )
-            if phantom_exists:
-                self.phantom_set.update(phantoms)
-                return
-
-        for region in self.view.sel():
-            line = self.view.line(region)
-            (row, col) = self.view.rowcol(region.begin())
-            full_path = self.view.file_name()
-
-            try:
-                blame_output = self.get_blame(int(row) + 1, full_path)
-            except Exception as e:
-                communicate_error(e)
-                return
-
-            sha, user, date, time = self.parse_blame(blame_output)
 
-            phantom = sublime.Phantom(
-                line,
-                blame_phantom_html_template.format(
-                    css=blame_phantom_css, sha=sha, user=user, date=date, time=time
-                ),
-                sublime.LAYOUT_BLOCK,
-                self.on_phantom_close,
-            )
-            phantoms.append(phantom)
-        self.phantom_set.update(phantoms)
+class BlameInsertCommitDescription(sublime_plugin.TextCommand):
 
+    # Overrides --------------------------------------------------
 
-class BlameInsertCommitDescription(sublime_plugin.TextCommand):
     def run(self, edit, desc, scratch_view_name):
         view = self.view
         view.set_scratch(True)
diff --git a/src/blame_all.py b/src/blame_all.py
index b05a4e7..98e7be6 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -17,6 +17,8 @@ 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)
@@ -68,6 +70,14 @@ def run(self, edit):
         # 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.
@@ -142,14 +152,11 @@ def get_line_point(self, line):
         """
         return self.view.line(self.view.text_point(line, 0))
 
-    def on_phantom_close(self, href):
-        """Closes opened phantoms.
-        """
-        if href == "close":
-            self.view.run_command("blame_erase_all")
-
 
 class BlameEraseAll(sublime_plugin.TextCommand):
+
+    # Overrides --------------------------------------------------
+
     def run(self, edit):
         """Erases the blame results.
         """
@@ -158,6 +165,9 @@ def run(self, edit):
 
 
 class BlameEraseAllListener(sublime_plugin.ViewEventListener):
+
+    # Overrides --------------------------------------------------
+
     @classmethod
     def is_applicable(cls, settings):
         """Checks if the blame_erase_all command is applicable.
diff --git a/src/commit_skipping.py b/src/commit_skipping.py
index 0791182..d806ca3 100644
--- a/src/commit_skipping.py
+++ b/src/commit_skipping.py
@@ -35,6 +35,8 @@ class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
         },
     }
 
+    # Overrides --------------------------------------------------
+
     def run(self, edit, mode, permanence):
         if permanence:
             sublime.load_settings(SETTINGS_FILE_BASENAME).set(
@@ -53,6 +55,9 @@ def input(self, args):  # noqa: A003
 
 
 class ModeInputHandler(sublime_plugin.ListInputHandler):
+
+    # Overrides --------------------------------------------------
+
     def placeholder(self):
         return "Select a mode"
 
@@ -75,6 +80,9 @@ def next_input(self, args):
 
 
 class PermanenceInputHandler(sublime_plugin.ListInputHandler):
+
+    # Overrides --------------------------------------------------
+
     def list_items(self):
         return [
             ("Temporarily (for this open file)", False),

From 0de97d929fac42259431e02e6bec4bdc9e7bb50c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 20 Nov 2019 11:33:35 +0000
Subject: [PATCH 054/180] PR was made & merged

GH-40
---
 src/commit_skipping.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/src/commit_skipping.py b/src/commit_skipping.py
index d806ca3..4842463 100644
--- a/src/commit_skipping.py
+++ b/src/commit_skipping.py
@@ -51,9 +51,6 @@ def input(self, args):  # noqa: A003
         return ModeInputHandler()
 
 
-# @todo #21 Since we now use *InputHandlers, make a PR to package_control_channel to bump the minimum required sublime build to 3170 https://github.com/wbond/package_control_channel/blob/master/repository/g.json
-
-
 class ModeInputHandler(sublime_plugin.ListInputHandler):
 
     # Overrides --------------------------------------------------

From 97f320c512cf5f8ecec3fb54539d78bbb5e66d8a Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 20 Nov 2019 11:47:08 +0000
Subject: [PATCH 055/180] Use namedtuple rather than dict for mode metadata

---
 src/commit_skipping.py | 40 +++++++++++++++++++++-------------------
 1 file changed, 21 insertions(+), 19 deletions(-)

diff --git a/src/commit_skipping.py b/src/commit_skipping.py
index 4842463..7f7d517 100644
--- a/src/commit_skipping.py
+++ b/src/commit_skipping.py
@@ -1,6 +1,8 @@
 import sublime
 import sublime_plugin
 
+from collections import namedtuple
+
 from .common import (
     SETTINGS_FILE_BASENAME,
     SETTINGS_KEY_COMMIT_SKIPPING_MODE,
@@ -15,24 +17,24 @@ class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
     MODE_CROSS_ANY_FILE = "cross_any_file"
     MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
 
+    ModeMetadata = namedtuple("ModeMetadata", ["elaboration", "git_args"])
+
     DETAIL = {
-        MODE_NONE: {"elaboration": "<NONE>", "git_args": []},
-        MODE_SAME_FILE_SAME_COMMIT: {
-            "elaboration": "...moved/copied the line within a file",
-            "git_args": ["-M"],
-        },
-        MODE_CROSS_FILE_SAME_COMMIT: {
-            "elaboration": "...moved/copied the line from another file modified in the same commit",
-            "git_args": ["-C"],
-        },
-        MODE_CROSS_ANY_FILE: {
-            "elaboration": "...created the file with a copy of a line from any other file",
-            "git_args": ["-C"] * 2,
-        },
-        MODE_CROSS_ANY_HISTORICAL_FILE: {
-            "elaboration": "...created the file with a copy of a line from any other historical file",
-            "git_args": ["-C"] * 3,
-        },
+        MODE_NONE: ModeMetadata("<NONE>", []),
+        MODE_SAME_FILE_SAME_COMMIT: ModeMetadata(
+            "...moved/copied the line within a file", ["-M"]
+        ),
+        MODE_CROSS_FILE_SAME_COMMIT: ModeMetadata(
+            "...moved/copied the line from another file modified in the same commit",
+            ["-C"],
+        ),
+        MODE_CROSS_ANY_FILE: ModeMetadata(
+            "...created the file with a copy of a line from any other file", ["-C"] * 2
+        ),
+        MODE_CROSS_ANY_HISTORICAL_FILE: ModeMetadata(
+            "...created the file with a copy of a line from any other historical file",
+            ["-C"] * 3,
+        ),
     }
 
     # Overrides --------------------------------------------------
@@ -61,11 +63,11 @@ def placeholder(self):
     # @todo #21 When presenting commit-skipping modes in the Command Palette, preselect the one currently in effect
     def list_items(self):
         return [
-            [metadata["elaboration"], mode]
+            [metadata.elaboration, mode]
             if mode == BlameSetCommitSkippingMode.MODE_NONE
             else [
                 "{0} (git blame {1})".format(
-                    metadata["elaboration"], " ".join(metadata["git_args"])
+                    metadata.elaboration, " ".join(metadata.git_args)
                 ),
                 mode,
             ]

From 78f2a60d21008ee40be3b9cc7b67602712668385 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Fri, 17 Jan 2020 10:44:33 +0000
Subject: [PATCH 056/180] Use setup.cfg for INI style settings

---
 .flake8 => setup.cfg | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename .flake8 => setup.cfg (100%)

diff --git a/.flake8 b/setup.cfg
similarity index 100%
rename from .flake8
rename to setup.cfg

From 3dc7bc997f89409e355481ecbd5a0d87939eacbf Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 15 Jul 2020 16:27:59 +0100
Subject: [PATCH 057/180] Delete 0pdd config file

---
 .0pdd.yml | 10 ----------
 1 file changed, 10 deletions(-)
 delete mode 100644 .0pdd.yml

diff --git a/.0pdd.yml b/.0pdd.yml
deleted file mode 100644
index 86f0554..0000000
--- a/.0pdd.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-errors:
-  - duncan@holm.scot
-format:
-  - short-title
-  - title-length=100
-alerts:
-  suppress:
-    - on-found-puzzle
-    - on-lost-puzzle
-    - on-scope

From bd637a05c2c53c7e05997d454bf4c45f3c442750 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 15 Jul 2020 16:41:48 +0100
Subject: [PATCH 058/180] Remove PDD readme badge

---
 README.md | 1 -
 1 file changed, 1 deletion(-)

diff --git a/README.md b/README.md
index ff09d97..ba82e07 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,6 @@
 [![GitHub release](https://img.shields.io/github/release/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/releases)
 [![GitHub contributors](https://img.shields.io/github/contributors/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/graphs/contributors)
 [![GitHub issues](https://img.shields.io/github/issues/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/issues)
-[![PDD status](http://www.0pdd.com/svg?name=frou/st3-gitblame)](http://www.0pdd.com/p?name=frou/st3-gitblame)
 
 # Git blame - Package for Sublime Text 3
 

From 999a83f237373c9852f1f6784b55748df03d0d1e Mon Sep 17 00:00:00 2001
From: James Doyle <james2doyle@gmail.com>
Date: Mon, 27 Jul 2020 11:24:08 -0700
Subject: [PATCH 059/180] Fixed: dont colorize diff output

---
 src/blame.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame.py b/src/blame.py
index a6f300f..86296e0 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -148,7 +148,7 @@ def parse_blame(self, blame):
 
     def get_commit(self, sha, path):
         return subprocess.check_output(
-            ["git", "show", sha],
+            ["git", "show", "--no-color", sha],
             cwd=os.path.dirname(os.path.realpath(path)),
             startupinfo=platform_startupinfo(),
             stderr=subprocess.STDOUT,

From ff796d148a15d2498df7af33fbf93f42387a056f Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 1 Aug 2020 17:04:11 +0100
Subject: [PATCH 060/180] Delete abandoned todos

---
 src/blame.py           | 1 -
 src/commit_skipping.py | 1 -
 2 files changed, 2 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 86296e0..12c17c8 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -107,7 +107,6 @@ def get_blame(self, line, path):
             os.path.basename(path),
         ]
 
-        # @todo #21 Factor out loading of the commit-skipping mode so that the BlameShowAll command can use it too.
         skipping_mode = self.view.settings().get(
             SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE, None
         )
diff --git a/src/commit_skipping.py b/src/commit_skipping.py
index 7f7d517..cc56db8 100644
--- a/src/commit_skipping.py
+++ b/src/commit_skipping.py
@@ -60,7 +60,6 @@ class ModeInputHandler(sublime_plugin.ListInputHandler):
     def placeholder(self):
         return "Select a mode"
 
-    # @todo #21 When presenting commit-skipping modes in the Command Palette, preselect the one currently in effect
     def list_items(self):
         return [
             [metadata.elaboration, mode]

From e200cbdc9806f2926a094a6401cb1477c898a106 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 1 Aug 2020 17:05:28 +0100
Subject: [PATCH 061/180] Autoformat code

---
 src/blame.py           | 11 ++++++-----
 src/blame_all.py       | 10 +++++-----
 src/commit_skipping.py |  4 ++--
 src/util.py            |  4 ++--
 4 files changed, 15 insertions(+), 14 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 12c17c8..b50f312 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -1,16 +1,17 @@
-import sublime
-import sublime_plugin
 import os
 import subprocess
 
+import sublime
+import sublime_plugin
+
+from .commit_skipping import BlameSetCommitSkippingMode
 from .common import (
     SETTINGS_FILE_BASENAME,
     SETTINGS_KEY_COMMIT_SKIPPING_MODE,
     SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
 )
-from .util import view_is_suitable, communicate_error, platform_startupinfo
-from .templates import blame_phantom_html_template, blame_phantom_css
-from .commit_skipping import BlameSetCommitSkippingMode
+from .templates import blame_phantom_css, blame_phantom_html_template
+from .util import communicate_error, platform_startupinfo, view_is_suitable
 
 # @todo #0 Add a [Prev] button to the phantom, that causes it to reflect the previous commit that changed the line.
 #  This has some overlap with the "commit-skipping" feature and possibly obsoletes it?
diff --git a/src/blame_all.py b/src/blame_all.py
index 98e7be6..187a320 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -1,12 +1,12 @@
-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
+import sublime
+import sublime_plugin
+
+from .templates import blame_all_phantom_css, blame_all_phantom_html_template
+from .util import communicate_error, platform_startupinfo, view_is_suitable
 
 PHANTOM_KEY_ALL = "git-blame-all"
 SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
diff --git a/src/commit_skipping.py b/src/commit_skipping.py
index cc56db8..ea69247 100644
--- a/src/commit_skipping.py
+++ b/src/commit_skipping.py
@@ -1,8 +1,8 @@
+from collections import namedtuple
+
 import sublime
 import sublime_plugin
 
-from collections import namedtuple
-
 from .common import (
     SETTINGS_FILE_BASENAME,
     SETTINGS_KEY_COMMIT_SKIPPING_MODE,
diff --git a/src/util.py b/src/util.py
index 684d35c..a73812e 100644
--- a/src/util.py
+++ b/src/util.py
@@ -1,8 +1,8 @@
-import sublime
-
 import subprocess
 import sys
 
+import sublime
+
 
 def view_is_suitable(view):
     ok = view.file_name() and not view.is_dirty()

From 3469d63027121d59addc85c9007bb61971326942 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 1 Aug 2020 17:39:47 +0100
Subject: [PATCH 062/180] Reconfigure flake8 and mypy

---
 .flake8   | 14 ++++++++++++++
 .mypy.ini | 15 +++++++++++++++
 setup.cfg |  3 ---
 3 files changed, 29 insertions(+), 3 deletions(-)
 create mode 100644 .flake8
 create mode 100644 .mypy.ini
 delete mode 100644 setup.cfg

diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..446ab9b
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,14 @@
+# REF: https://flake8.pycqa.org/en/latest/user/options.html
+
+[flake8]
+#
+# F401: module imported but unused
+# F403: ‘from module import *’ used; unable to detect undefined names
+#
+per-file-ignores =
+    boot.py:F401,F403
+#
+# FS002 - '.format' used
+#
+extend-ignore = FS002
+# @todo Once on ST4 and using Python 3.8 for this packge, stop ignoring flake8 FS002
diff --git a/.mypy.ini b/.mypy.ini
new file mode 100644
index 0000000..5236eed
--- /dev/null
+++ b/.mypy.ini
@@ -0,0 +1,15 @@
+# REF: https://mypy.readthedocs.io/en/latest/config_file.html
+
+# @todo Delete the in-package mypy.ini file once I'm using Python 3.8 in this package.
+#  For Python 3.8 code, I rely on my single global ~/.mypy.ini
+
+# The Python version (3.3) used in ST3 doesn't support the type hints syntax.
+[mypy]
+check_untyped_defs = True
+ignore_missing_imports = True
+
+[mypy-sublime]
+ignore_missing_imports = True
+
+[mypy-sublime_plugin]
+ignore_missing_imports = True
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 899d695..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,3 +0,0 @@
-[flake8]
-per-file-ignores =
-    boot.py:F401,F403

From 343705e98697e7a5020bf34e85ae014d8ca69f06 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 1 Aug 2020 19:10:24 +0100
Subject: [PATCH 063/180] Fix namedtuple attribute access

---
 src/blame.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame.py b/src/blame.py
index b50f312..075035c 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -117,7 +117,7 @@ def get_blame(self, line, path):
                 SETTINGS_KEY_COMMIT_SKIPPING_MODE, BlameSetCommitSkippingMode.MODE_NONE
             )
         try:
-            cmd_line += BlameSetCommitSkippingMode.DETAIL[skipping_mode]["git_args"]
+            cmd_line += BlameSetCommitSkippingMode.DETAIL[skipping_mode].git_args
         except KeyError as e:
             communicate_error("Unexpected commit skipping mode: {0}".format(e))
 

From 929adbeaf7435260dc6ebfe269f7fc969e9514ae Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 1 Aug 2020 20:04:56 +0100
Subject: [PATCH 064/180] Remove unreleased "Commit Skipping" feature

---
 Commands/Default.sublime-commands   |  4 --
 Settings/Git blame.sublime-settings |  2 +-
 src/blame.py                        | 21 +------
 src/commit_skipping.py              | 88 -----------------------------
 src/common.py                       |  3 -
 5 files changed, 2 insertions(+), 116 deletions(-)
 delete mode 100644 src/commit_skipping.py

diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
index f23ed1d..211e2f4 100644
--- a/Commands/Default.sublime-commands
+++ b/Commands/Default.sublime-commands
@@ -10,9 +10,5 @@
     {
         "caption": "Git Blame: Erase All",
         "command": "blame_erase_all"
-    },
-    {
-        "caption": "Git Blame: Skip commits that...",
-        "command": "blame_set_commit_skipping_mode"
     }
 ]
diff --git a/Settings/Git blame.sublime-settings b/Settings/Git blame.sublime-settings
index 7fa9257..0db3279 100644
--- a/Settings/Git blame.sublime-settings	
+++ b/Settings/Git blame.sublime-settings	
@@ -1,3 +1,3 @@
 {
-  "commit_skipping": false
+
 }
diff --git a/src/blame.py b/src/blame.py
index 075035c..62019c6 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -4,12 +4,7 @@
 import sublime
 import sublime_plugin
 
-from .commit_skipping import BlameSetCommitSkippingMode
-from .common import (
-    SETTINGS_FILE_BASENAME,
-    SETTINGS_KEY_COMMIT_SKIPPING_MODE,
-    SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
-)
+from .common import SETTINGS_FILE_BASENAME
 from .templates import blame_phantom_css, blame_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
 
@@ -107,20 +102,6 @@ def get_blame(self, line, path):
             "-L {0},{0}".format(line),
             os.path.basename(path),
         ]
-
-        skipping_mode = self.view.settings().get(
-            SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE, None
-        )
-        if skipping_mode is None:
-            settings_file = sublime.load_settings(SETTINGS_FILE_BASENAME)
-            skipping_mode = settings_file.get(
-                SETTINGS_KEY_COMMIT_SKIPPING_MODE, BlameSetCommitSkippingMode.MODE_NONE
-            )
-        try:
-            cmd_line += BlameSetCommitSkippingMode.DETAIL[skipping_mode].git_args
-        except KeyError as e:
-            communicate_error("Unexpected commit skipping mode: {0}".format(e))
-
         # print(cmd_line)
         return subprocess.check_output(
             cmd_line,
diff --git a/src/commit_skipping.py b/src/commit_skipping.py
deleted file mode 100644
index ea69247..0000000
--- a/src/commit_skipping.py
+++ /dev/null
@@ -1,88 +0,0 @@
-from collections import namedtuple
-
-import sublime
-import sublime_plugin
-
-from .common import (
-    SETTINGS_FILE_BASENAME,
-    SETTINGS_KEY_COMMIT_SKIPPING_MODE,
-    SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE,
-)
-
-
-class BlameSetCommitSkippingMode(sublime_plugin.TextCommand):
-    MODE_NONE = False
-    MODE_SAME_FILE_SAME_COMMIT = "same_file_same_commit"
-    MODE_CROSS_FILE_SAME_COMMIT = "cross_file_same_commit"
-    MODE_CROSS_ANY_FILE = "cross_any_file"
-    MODE_CROSS_ANY_HISTORICAL_FILE = "cross_any_historical_file"
-
-    ModeMetadata = namedtuple("ModeMetadata", ["elaboration", "git_args"])
-
-    DETAIL = {
-        MODE_NONE: ModeMetadata("<NONE>", []),
-        MODE_SAME_FILE_SAME_COMMIT: ModeMetadata(
-            "...moved/copied the line within a file", ["-M"]
-        ),
-        MODE_CROSS_FILE_SAME_COMMIT: ModeMetadata(
-            "...moved/copied the line from another file modified in the same commit",
-            ["-C"],
-        ),
-        MODE_CROSS_ANY_FILE: ModeMetadata(
-            "...created the file with a copy of a line from any other file", ["-C"] * 2
-        ),
-        MODE_CROSS_ANY_HISTORICAL_FILE: ModeMetadata(
-            "...created the file with a copy of a line from any other historical file",
-            ["-C"] * 3,
-        ),
-    }
-
-    # Overrides --------------------------------------------------
-
-    def run(self, edit, mode, permanence):
-        if permanence:
-            sublime.load_settings(SETTINGS_FILE_BASENAME).set(
-                SETTINGS_KEY_COMMIT_SKIPPING_MODE, mode
-            )
-            sublime.save_settings(SETTINGS_FILE_BASENAME)
-            self.view.settings().erase(SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE)
-        else:
-            self.view.settings().set(SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE, mode)
-
-    def input(self, args):  # noqa: A003
-        return ModeInputHandler()
-
-
-class ModeInputHandler(sublime_plugin.ListInputHandler):
-
-    # Overrides --------------------------------------------------
-
-    def placeholder(self):
-        return "Select a mode"
-
-    def list_items(self):
-        return [
-            [metadata.elaboration, mode]
-            if mode == BlameSetCommitSkippingMode.MODE_NONE
-            else [
-                "{0} (git blame {1})".format(
-                    metadata.elaboration, " ".join(metadata.git_args)
-                ),
-                mode,
-            ]
-            for mode, metadata in BlameSetCommitSkippingMode.DETAIL.items()
-        ]
-
-    def next_input(self, args):
-        return PermanenceInputHandler()
-
-
-class PermanenceInputHandler(sublime_plugin.ListInputHandler):
-
-    # Overrides --------------------------------------------------
-
-    def list_items(self):
-        return [
-            ("Temporarily (for this open file)", False),
-            ("Permanently (a new default will be written to the settings file)", True),
-        ]
diff --git a/src/common.py b/src/common.py
index 37b5dd5..7ce8901 100644
--- a/src/common.py
+++ b/src/common.py
@@ -1,4 +1 @@
 SETTINGS_FILE_BASENAME = "Git blame.sublime-settings"
-
-SETTINGS_KEY_COMMIT_SKIPPING_MODE = "commit_skipping"
-SETTINGS_KEY_TEMPORARY_COMMIT_SKIPPING_MODE = SETTINGS_KEY_COMMIT_SKIPPING_MODE

From 58c33ec05bf1e43bf1f998d0ed5c955eb7023404 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 1 Aug 2020 20:10:17 +0100
Subject: [PATCH 065/180] Add pkg_settings.py with deferred loading

---
 src/blame.py        |  1 -
 src/common.py       |  1 -
 src/pkg_settings.py | 11 +++++++++++
 3 files changed, 11 insertions(+), 2 deletions(-)
 delete mode 100644 src/common.py
 create mode 100644 src/pkg_settings.py

diff --git a/src/blame.py b/src/blame.py
index 62019c6..9160565 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -4,7 +4,6 @@
 import sublime
 import sublime_plugin
 
-from .common import SETTINGS_FILE_BASENAME
 from .templates import blame_phantom_css, blame_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
 
diff --git a/src/common.py b/src/common.py
deleted file mode 100644
index 7ce8901..0000000
--- a/src/common.py
+++ /dev/null
@@ -1 +0,0 @@
-SETTINGS_FILE_BASENAME = "Git blame.sublime-settings"
diff --git a/src/pkg_settings.py b/src/pkg_settings.py
new file mode 100644
index 0000000..3d73c08
--- /dev/null
+++ b/src/pkg_settings.py
@@ -0,0 +1,11 @@
+import sublime
+
+
+def pkg_settings():
+    # NOTE: The sublime.load_settings(...) call has to be deferred to this function,
+    # rather than just being called immediately and assigning a module-level variable,
+    # because of: https://www.sublimetext.com/docs/3/api_reference.html#plugin_lifecycle
+    return sublime.load_settings("Git blame.sublime-settings")
+
+
+# PKG_SETTINGS_KEY_FOO = "foo"

From 1def3a15a7f1ad9bf3e23c7c20fd925030bd9c9f Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 1 Aug 2020 20:46:08 +0100
Subject: [PATCH 066/180] DRY phantom erasure for single-line blame

---
 src/blame.py | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 9160565..65fb29e 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -10,6 +10,8 @@
 # @todo #0 Add a [Prev] button to the phantom, that causes it to reflect the previous commit that changed the line.
 #  This has some overlap with the "commit-skipping" feature and possibly obsoletes it?
 
+PHANTOM_KEY = "git-blame"
+
 
 class Blame(sublime_plugin.TextCommand):
 
@@ -17,14 +19,14 @@ class Blame(sublime_plugin.TextCommand):
 
     def __init__(self, view):
         super().__init__(view)
-        self.phantom_set = sublime.PhantomSet(view, "git-blame")
+        self.phantom_set = sublime.PhantomSet(view, PHANTOM_KEY)
 
     def run(self, edit):
         if not view_is_suitable(self.view):
             return
 
         phantoms = []
-        self.view.erase_phantoms("git-blame")
+        self.erase_phantoms()
         # Before adding the phantom, see if the current phantom that is displayed is at the same spot at the selection
         if self.phantom_set.phantoms:
             phantom_exists = self.view.line(self.view.sel()[0]) == self.view.line(
@@ -86,9 +88,9 @@ def on_phantom_close(self, href):
                     {"desc": desc, "scratch_view_name": "commit " + sha},
                 )
             else:
-                self.view.erase_phantoms("git-blame")
+                self.erase_phantoms()
         else:
-            self.view.erase_phantoms("git-blame")
+            self.erase_phantoms()
 
     # ------------------------------------------------------------
 
@@ -134,6 +136,9 @@ def get_commit(self, sha, path):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
+    def erase_phantoms(self):
+        self.view.erase_phantoms(PHANTOM_KEY)
+
 
 class BlameInsertCommitDescription(sublime_plugin.TextCommand):
 

From 4c6fa9fdb837d4dd88fa1f11a1866bf67b9d7e70 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 1 Aug 2020 21:52:36 +0100
Subject: [PATCH 067/180] WIP "Prev" button to step back through line blames

---
 src/blame.py     | 54 +++++++++++++++++++++++++++++++-----------------
 src/templates.py |  1 +
 2 files changed, 36 insertions(+), 19 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 65fb29e..491b399 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -10,25 +10,26 @@
 # @todo #0 Add a [Prev] button to the phantom, that causes it to reflect the previous commit that changed the line.
 #  This has some overlap with the "commit-skipping" feature and possibly obsoletes it?
 
-PHANTOM_KEY = "git-blame"
-
 
 class Blame(sublime_plugin.TextCommand):
 
+    PHANTOM_KEY = "git-blame"
+    SHA_SKIP_LIST_DELIM = "\t"
+
     # Overrides --------------------------------------------------
 
     def __init__(self, view):
         super().__init__(view)
-        self.phantom_set = sublime.PhantomSet(view, PHANTOM_KEY)
+        self.phantom_set = sublime.PhantomSet(view, self.PHANTOM_KEY)
 
-    def run(self, edit):
+    def run(self, edit, sha_skip_list=[], prevving=False):
         if not view_is_suitable(self.view):
             return
 
         phantoms = []
         self.erase_phantoms()
         # Before adding the phantom, see if the current phantom that is displayed is at the same spot at the selection
-        if self.phantom_set.phantoms:
+        if not prevving and self.phantom_set.phantoms:
             phantom_exists = self.view.line(self.view.sel()[0]) == self.view.line(
                 self.phantom_set.phantoms[0].region
             )
@@ -42,7 +43,7 @@ def run(self, edit):
             full_path = self.view.file_name()
 
             try:
-                blame_output = self.get_blame(int(row) + 1, full_path)
+                blame_output = self.get_blame(int(row) + 1, full_path, sha_skip_list)
             except Exception as e:
                 communicate_error(e)
                 return
@@ -52,7 +53,12 @@ def run(self, edit):
             phantom = sublime.Phantom(
                 line,
                 blame_phantom_html_template.format(
-                    css=blame_phantom_css, sha=sha, user=user, date=date, time=time
+                    css=blame_phantom_css,
+                    sha=sha,
+                    user=user,
+                    date=date,
+                    time=time,
+                    sha_skip_list=self.SHA_SKIP_LIST_DELIM.join(sha_skip_list),
                 ),
                 sublime.LAYOUT_BLOCK,
                 self.on_phantom_close,
@@ -65,17 +71,19 @@ def on_phantom_close(self, href):
 
         if len(href_parts) > 1:
             intent = href_parts[0]
-            sha = href_parts[1]
+            operand = href_parts[1]
             # The SHA output by git-blame may have a leading caret to indicate
             # that it is a "boundary commit". That useful information has
             # already been shown in the phantom, so strip it before going on to
             # use the SHA programmatically.
-            sha = sha.strip("^")
+            operand = operand.strip("^")
 
             if intent == "copy":
+                sha = operand
                 sublime.set_clipboard(sha)
                 sublime.status_message("Git SHA copied to clipboard")
             elif intent == "show":
+                sha = operand
                 try:
                     desc = self.get_commit(sha, self.view.file_name())
                 except Exception as e:
@@ -87,6 +95,18 @@ def on_phantom_close(self, href):
                     "blame_insert_commit_description",
                     {"desc": desc, "scratch_view_name": "commit " + sha},
                 )
+            elif intent == "prev":
+                # The user might have moved the caret to a different line to the one the
+                # clicked phantom is for. Need to somehow remember which line the
+                # phantom was for.
+
+                sha, sha_skip_list_packed = operand.split("+")
+                if sha_skip_list_packed:
+                    sha_skip_list = sha_skip_list_packed.split(self.SHA_SKIP_LIST_DELIM)
+                    sha_skip_list.append(sha)
+                else:
+                    sha_skip_list = [sha]
+                self.run(None, sha_skip_list, prevving=True)
             else:
                 self.erase_phantoms()
         else:
@@ -94,15 +114,11 @@ def on_phantom_close(self, href):
 
     # ------------------------------------------------------------
 
-    def get_blame(self, line, path):
-        cmd_line = [
-            "git",
-            "blame",
-            "--minimal",
-            "-w",
-            "-L {0},{0}".format(line),
-            os.path.basename(path),
-        ]
+    def get_blame(self, line, path, sha_skip_list):
+        cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line)]
+        for skipped_sha in sha_skip_list:
+            cmd_line.extend(["--ignore-rev", skipped_sha])
+        cmd_line.append(os.path.basename(path))
         # print(cmd_line)
         return subprocess.check_output(
             cmd_line,
@@ -137,7 +153,7 @@ def get_commit(self, sha, path):
         ).decode("utf-8")
 
     def erase_phantoms(self):
-        self.view.erase_phantoms(PHANTOM_KEY)
+        self.view.erase_phantoms(self.PHANTOM_KEY)
 
 
 class BlameInsertCommitDescription(sublime_plugin.TextCommand):
diff --git a/src/templates.py b/src/templates.py
index 7a31232..6a905a0 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -6,6 +6,7 @@
             <span class="message">
                 <strong>Git Blame</strong> ({user})
                 {date} {time} |
+                <a href="prev-{sha}+{sha_skip_list}">[Prev]</a>
                 {sha}
                 <a href="copy-{sha}">[Copy]</a>
                 <a href="show-{sha}">[Show]</a>

From a43a96544e2717118e9269a9cc206dca793b0109 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 2 Aug 2020 10:53:09 +0100
Subject: [PATCH 068/180] Use URLs for phantom's buttons, not homegrown
 encoding

Closes #50
---
 src/blame.py     | 94 ++++++++++++++++++++++++------------------------
 src/templates.py |  6 ++--
 2 files changed, 49 insertions(+), 51 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 491b399..5b851ba 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -1,5 +1,6 @@
 import os
 import subprocess
+from urllib.parse import parse_qs, quote_plus, urlparse
 
 import sublime
 import sublime_plugin
@@ -7,14 +8,10 @@
 from .templates import blame_phantom_css, blame_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
 
-# @todo #0 Add a [Prev] button to the phantom, that causes it to reflect the previous commit that changed the line.
-#  This has some overlap with the "commit-skipping" feature and possibly obsoletes it?
-
 
 class Blame(sublime_plugin.TextCommand):
 
     PHANTOM_KEY = "git-blame"
-    SHA_SKIP_LIST_DELIM = "\t"
 
     # Overrides --------------------------------------------------
 
@@ -58,7 +55,19 @@ def run(self, edit, sha_skip_list=[], prevving=False):
                     user=user,
                     date=date,
                     time=time,
-                    sha_skip_list=self.SHA_SKIP_LIST_DELIM.join(sha_skip_list),
+                    # The SHA output by `git blame` may have a leading caret to indicate
+                    # that it is a "boundary commit". That needs to be stripped before
+                    # using the SHA programmatically for other purposes.
+                    qs_sha_val=quote_plus(sha.strip("^")),
+                    # Querystrings can contain the same key multiple times. We use that
+                    # functionality to accumulate a list of SHAs to skip over when
+                    # a [Prev] button has been clicked multiple times.
+                    qs_skip_keyvals="&".join(
+                        [
+                            "skip={}".format(quote_plus(skipee))
+                            for skipee in sha_skip_list
+                        ]
+                    ),
                 ),
                 sublime.LAYOUT_BLOCK,
                 self.on_phantom_close,
@@ -67,50 +76,39 @@ def run(self, edit, sha_skip_list=[], prevving=False):
         self.phantom_set.update(phantoms)
 
     def on_phantom_close(self, href):
-        href_parts = href.split("-")
-
-        if len(href_parts) > 1:
-            intent = href_parts[0]
-            operand = href_parts[1]
-            # The SHA output by git-blame may have a leading caret to indicate
-            # that it is a "boundary commit". That useful information has
-            # already been shown in the phantom, so strip it before going on to
-            # use the SHA programmatically.
-            operand = operand.strip("^")
-
-            if intent == "copy":
-                sha = operand
-                sublime.set_clipboard(sha)
-                sublime.status_message("Git SHA copied to clipboard")
-            elif intent == "show":
-                sha = operand
-                try:
-                    desc = self.get_commit(sha, self.view.file_name())
-                except Exception as e:
-                    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 intent == "prev":
-                # The user might have moved the caret to a different line to the one the
-                # clicked phantom is for. Need to somehow remember which line the
-                # phantom was for.
-
-                sha, sha_skip_list_packed = operand.split("+")
-                if sha_skip_list_packed:
-                    sha_skip_list = sha_skip_list_packed.split(self.SHA_SKIP_LIST_DELIM)
-                    sha_skip_list.append(sha)
-                else:
-                    sha_skip_list = [sha]
-                self.run(None, sha_skip_list, prevving=True)
-            else:
-                self.erase_phantoms()
-        else:
+        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(sha, self.view.file_name())
+            except Exception as e:
+                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]
+            sha_skip_list = querystring.get("skip", [])
+            if sha not in sha_skip_list:
+                sha_skip_list.append(sha)
+            self.run(None, sha_skip_list, prevving=True)
+        elif url.path == "close":
             self.erase_phantoms()
+        else:
+            communicate_error(
+                "No handler for URL path '{}' in phantom".format(url.path)
+            )
 
     # ------------------------------------------------------------
 
diff --git a/src/templates.py b/src/templates.py
index 6a905a0..05d75f8 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -6,10 +6,10 @@
             <span class="message">
                 <strong>Git Blame</strong> ({user})
                 {date} {time} |
-                <a href="prev-{sha}+{sha_skip_list}">[Prev]</a>
+                <a href="prev?sha={qs_sha_val}&{qs_skip_keyvals}">[Prev]</a>
                 {sha}
-                <a href="copy-{sha}">[Copy]</a>
-                <a href="show-{sha}">[Show]</a>
+                <a href="copy?sha={qs_sha_val}">[Copy]</a>
+                <a href="show?sha={qs_sha_val}">[Show]</a>
                 <a class="close" href="close">\u00D7</a>
             </span>
         </div>

From f8f74f45c83022db1a99c2a3abaa8720cccee03f Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 2 Aug 2020 11:32:03 +0100
Subject: [PATCH 069/180] on_phantom_close was not an overridden method

---
 src/blame.py | 72 ++++++++++++++++++++++++++--------------------------
 1 file changed, 36 insertions(+), 36 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 5b851ba..8cd5e1b 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -70,46 +70,11 @@ def run(self, edit, sha_skip_list=[], prevving=False):
                     ),
                 ),
                 sublime.LAYOUT_BLOCK,
-                self.on_phantom_close,
+                self.handle_phantom_button,
             )
             phantoms.append(phantom)
         self.phantom_set.update(phantoms)
 
-    def on_phantom_close(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(sha, self.view.file_name())
-            except Exception as e:
-                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]
-            sha_skip_list = querystring.get("skip", [])
-            if sha not in sha_skip_list:
-                sha_skip_list.append(sha)
-            self.run(None, sha_skip_list, prevving=True)
-        elif url.path == "close":
-            self.erase_phantoms()
-        else:
-            communicate_error(
-                "No handler for URL path '{}' in phantom".format(url.path)
-            )
-
     # ------------------------------------------------------------
 
     def get_blame(self, line, path, sha_skip_list):
@@ -150,6 +115,41 @@ def get_commit(self, sha, path):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
+    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(sha, self.view.file_name())
+            except Exception as e:
+                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]
+            sha_skip_list = querystring.get("skip", [])
+            if sha not in sha_skip_list:
+                sha_skip_list.append(sha)
+            self.run(None, sha_skip_list, prevving=True)
+        elif url.path == "close":
+            self.erase_phantoms()
+        else:
+            communicate_error(
+                "No handler for URL path '{}' in phantom".format(url.path)
+            )
+
     def erase_phantoms(self):
         self.view.erase_phantoms(self.PHANTOM_KEY)
 

From fd929cd7792424dea9c309882afc713c0a863d2b Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 2 Aug 2020 11:37:37 +0100
Subject: [PATCH 070/180] Add todo

---
 src/blame.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/blame.py b/src/blame.py
index 8cd5e1b..0f7f84a 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -145,6 +145,7 @@ def handle_phantom_button(self, href):
             self.run(None, sha_skip_list, prevving=True)
         elif url.path == "close":
             self.erase_phantoms()
+            # @todo Fix 2 key presses being needed to show a phantom again after closing it was closed using [x]
         else:
             communicate_error(
                 "No handler for URL path '{}' in phantom".format(url.path)

From 4e8bf935128d42e9c645ee08637f4a86a5858b74 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 2 Aug 2020 13:44:36 +0100
Subject: [PATCH 071/180] Simplify the logic for existing phantoms

Fixes #55
---
 src/blame.py | 90 +++++++++++++++++++++++++++-------------------------
 1 file changed, 46 insertions(+), 44 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 0f7f84a..f935768 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -11,68 +11,70 @@
 
 class Blame(sublime_plugin.TextCommand):
 
-    PHANTOM_KEY = "git-blame"
-
     # Overrides --------------------------------------------------
 
     def __init__(self, view):
         super().__init__(view)
-        self.phantom_set = sublime.PhantomSet(view, self.PHANTOM_KEY)
+        self.phantom_set = sublime.PhantomSet(view, "git-blame")
 
     def run(self, edit, sha_skip_list=[], prevving=False):
         if not view_is_suitable(self.view):
             return
 
+        # The contents of PhantomSet are replaced each time this Command is ran.
         phantoms = []
-        self.erase_phantoms()
-        # Before adding the phantom, see if the current phantom that is displayed is at the same spot at the selection
-        if not prevving and self.phantom_set.phantoms:
-            phantom_exists = self.view.line(self.view.sel()[0]) == self.view.line(
-                self.phantom_set.phantoms[0].region
-            )
-            if phantom_exists:
-                self.phantom_set.update(phantoms)
-                return
 
         for region in self.view.sel():
-            line = self.view.line(region)
-            (row, col) = self.view.rowcol(region.begin())
+            line_region = self.view.line(region)
+
+            # When this Command is ran for a line with a phantom already visible, we
+            # erase the phantom (i.e. toggle it). But if the reason this Command is
+            # being ran is because the user is clicking the [Prev] button, just erasing
+            # the existing phantom is not sufficient, because we need to then display
+            # another phantom with updated content.
+            if self.phantom_exists_for_region(line_region) and not prevving:
+                continue
+
+            (row, _) = self.view.rowcol(region.begin())
+            line = row + 1
             full_path = self.view.file_name()
 
             try:
-                blame_output = self.get_blame(int(row) + 1, full_path, sha_skip_list)
+                blame_output = self.get_blame(line, full_path, sha_skip_list)
             except Exception as e:
                 communicate_error(e)
                 return
 
             sha, user, date, time = self.parse_blame(blame_output)
 
-            phantom = sublime.Phantom(
-                line,
-                blame_phantom_html_template.format(
-                    css=blame_phantom_css,
-                    sha=sha,
-                    user=user,
-                    date=date,
-                    time=time,
-                    # The SHA output by `git blame` may have a leading caret to indicate
-                    # that it is a "boundary commit". That needs to be stripped before
-                    # using the SHA programmatically for other purposes.
-                    qs_sha_val=quote_plus(sha.strip("^")),
-                    # Querystrings can contain the same key multiple times. We use that
-                    # functionality to accumulate a list of SHAs to skip over when
-                    # a [Prev] button has been clicked multiple times.
-                    qs_skip_keyvals="&".join(
-                        [
-                            "skip={}".format(quote_plus(skipee))
-                            for skipee in sha_skip_list
-                        ]
+            phantoms.append(
+                sublime.Phantom(
+                    line_region,
+                    blame_phantom_html_template.format(
+                        css=blame_phantom_css,
+                        sha=sha,
+                        user=user,
+                        date=date,
+                        time=time,
+                        # The SHA output by `git blame` may have a leading caret to indicate
+                        # that it is a "boundary commit". That needs to be stripped before
+                        # using the SHA programmatically for other purposes.
+                        qs_sha_val=quote_plus(sha.strip("^")),
+                        # Querystrings can contain the same key multiple times. We use that
+                        # functionality to accumulate a list of SHAs to skip over when
+                        # a [Prev] button has been clicked multiple times.
+                        qs_skip_keyvals="&".join(
+                            [
+                                "skip={}".format(quote_plus(skipee))
+                                for skipee in sha_skip_list
+                            ]
+                        ),
                     ),
-                ),
-                sublime.LAYOUT_BLOCK,
-                self.handle_phantom_button,
+                    sublime.LAYOUT_BLOCK,
+                    self.handle_phantom_button,
+                )
             )
-            phantoms.append(phantom)
+
         self.phantom_set.update(phantoms)
 
     # ------------------------------------------------------------
@@ -115,6 +117,9 @@ def get_commit(self, sha, path):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
+    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)
@@ -144,16 +149,13 @@ def handle_phantom_button(self, href):
                 sha_skip_list.append(sha)
             self.run(None, sha_skip_list, prevving=True)
         elif url.path == "close":
-            self.erase_phantoms()
-            # @todo Fix 2 key presses being needed to show a phantom again after closing it was closed using [x]
+            # Erase all phantoms
+            self.phantom_set.update([])
         else:
             communicate_error(
                 "No handler for URL path '{}' in phantom".format(url.path)
             )
 
-    def erase_phantoms(self):
-        self.view.erase_phantoms(self.PHANTOM_KEY)
-
 
 class BlameInsertCommitDescription(sublime_plugin.TextCommand):
 

From 94f1caac2e0a94732e322f5cdacf03a13fe6236c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 2 Aug 2020 14:38:20 +0100
Subject: [PATCH 072/180] Resolve flake8 E741

Bit overbearing: https://www.flake8rules.com/rules/E741.html
---
 src/blame_all.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 187a320..d920697 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -43,8 +43,8 @@ def run(self, edit):
             communicate_error(e)
             return
 
-        for l in blame_output.splitlines():
-            parsed = self.parse_blame(l)
+        for line in blame_output.splitlines():
+            parsed = self.parse_blame(line)
             if not parsed:
                 continue
 

From 65e8bb10f8fe0c9cfe905432a72d50aaf2de7599 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 2 Aug 2020 17:10:21 +0100
Subject: [PATCH 073/180] Put boot.py's flake8 ignores in-band

---
 .flake8 | 6 ------
 boot.py | 4 ++--
 2 files changed, 2 insertions(+), 8 deletions(-)

diff --git a/.flake8 b/.flake8
index 446ab9b..5cbb6bd 100644
--- a/.flake8
+++ b/.flake8
@@ -2,12 +2,6 @@
 
 [flake8]
 #
-# F401: module imported but unused
-# F403: ‘from module import *’ used; unable to detect undefined names
-#
-per-file-ignores =
-    boot.py:F401,F403
-#
 # FS002 - '.format' used
 #
 extend-ignore = FS002
diff --git a/boot.py b/boot.py
index 257aba5..737749d 100644
--- a/boot.py
+++ b/boot.py
@@ -1,7 +1,7 @@
 # Only .py files at the top-level of a Sublime package are considered "plugins".
 # Make Sublime aware of our *{Command,Listener,Handler} classes by importing them:
-from .src.blame import *
-from .src.blame_all import *
+from .src.blame import *  # noqa: F401,F403
+from .src.blame_all import *  # noqa: F401,F403
 
 
 def plugin_loaded():

From b9e6d8ceb3f627dd7e9e02135ad0bbc5f10ca935 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 2 Aug 2020 17:12:23 +0100
Subject: [PATCH 074/180] Rename settings related source file

---
 src/{pkg_settings.py => settings.py} | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
 rename src/{pkg_settings.py => settings.py} (86%)

diff --git a/src/pkg_settings.py b/src/settings.py
similarity index 86%
rename from src/pkg_settings.py
rename to src/settings.py
index 3d73c08..b1afcf9 100644
--- a/src/pkg_settings.py
+++ b/src/settings.py
@@ -8,4 +8,4 @@ def pkg_settings():
     return sublime.load_settings("Git blame.sublime-settings")
 
 
-# PKG_SETTINGS_KEY_FOO = "foo"
+PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS = "custom_blame_flags"

From 39d69998f43889007e29aca9f03a89fbcd107320 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 2 Aug 2020 19:24:13 +0100
Subject: [PATCH 075/180] Add ability to specify custom git blame CLI flags via
 Settings

Closes #21
---
 Settings/Git blame.sublime-settings | 9 ++++++++-
 src/blame.py                        | 4 +++-
 src/blame_all.py                    | 9 +++++++--
 3 files changed, 18 insertions(+), 4 deletions(-)

diff --git a/Settings/Git blame.sublime-settings b/Settings/Git blame.sublime-settings
index 0db3279..5408b97 100644
--- a/Settings/Git blame.sublime-settings	
+++ b/Settings/Git blame.sublime-settings	
@@ -1,3 +1,10 @@
 {
-
+    // Use this setting to supply additional flags to the git blame CLI program
+    // when it gets invoked by this package. See `man git-blame`.
+    //
+    // Example values:
+    //     ["-M"]
+    //     ["-C", "-C"]
+    //
+    "custom_blame_flags": []
 }
diff --git a/src/blame.py b/src/blame.py
index f935768..2d56e1a 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -5,6 +5,7 @@
 import sublime
 import sublime_plugin
 
+from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_phantom_css, blame_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
 
@@ -83,7 +84,8 @@ def get_blame(self, line, path, sha_skip_list):
         cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line)]
         for skipped_sha in sha_skip_list:
             cmd_line.extend(["--ignore-rev", skipped_sha])
-        cmd_line.append(os.path.basename(path))
+        cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
+        cmd_line.extend(["--", os.path.basename(path)])
         # print(cmd_line)
         return subprocess.check_output(
             cmd_line,
diff --git a/src/blame_all.py b/src/blame_all.py
index d920697..72022cb 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -5,6 +5,7 @@
 import sublime
 import sublime_plugin
 
+from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
 
@@ -79,9 +80,13 @@ def on_phantom_close(self, href):
     # ------------------------------------------------------------
 
     def get_blame(self, path):
+        # The option --show-name is necessary to force file name display.
+        cmd_line = ["git", "blame", "--show-name", "--minimal", "-w"]
+        cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
+        cmd_line.extend(["--", os.path.basename(path)])
+        # print(cmd_line)
         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)],
+            cmd_line,
             cwd=os.path.dirname(os.path.realpath(path)),
             startupinfo=platform_startupinfo(),
             stderr=subprocess.STDOUT,

From 41aa9e878a4c928c0c6fa2a90cc4652a5b214338 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 10:17:42 +0100
Subject: [PATCH 076/180] Allow Settings to be edited from Command Palette

---
 Commands/Default.sublime-commands | 30 +++++++++++++++++++-----------
 1 file changed, 19 insertions(+), 11 deletions(-)

diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
index 211e2f4..569c69f 100644
--- a/Commands/Default.sublime-commands
+++ b/Commands/Default.sublime-commands
@@ -1,14 +1,22 @@
 [
-    {
-        "caption": "Git Blame: Show",
-        "command": "blame"
-    },
-    {
-        "caption": "Git Blame: Show All",
-        "command": "blame_show_all"
-    },
-    {
-        "caption": "Git Blame: Erase All",
-        "command": "blame_erase_all"
+  {
+    "caption": "Git Blame: Show",
+    "command": "blame"
+  },
+  {
+    "caption": "Git Blame: Show All",
+    "command": "blame_show_all"
+  },
+  {
+    "caption": "Git Blame: Erase All",
+    "command": "blame_erase_all"
+  },
+  {
+    "caption": "Git Blame: Settings",
+    "command": "edit_settings",
+    "args": {
+      "base_file": "${packages}/st3-gitblame/Settings/Git blame.sublime-settings",
+      "default": "{\n\t$0\n}\n"
     }
+  }
 ]

From 24adbe05d39f89f723bbcf49f4e3d53b71a29d9d Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 10:23:19 +0100
Subject: [PATCH 077/180] Add twin comments

---
 Commands/Default.sublime-commands | 1 +
 Menus/Main.sublime-menu           | 1 +
 2 files changed, 2 insertions(+)

diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
index 569c69f..49b0dc9 100644
--- a/Commands/Default.sublime-commands
+++ b/Commands/Default.sublime-commands
@@ -13,6 +13,7 @@
   },
   {
     "caption": "Git Blame: Settings",
+    // ♊️: Entry in "Main.sublime-menu"
     "command": "edit_settings",
     "args": {
       "base_file": "${packages}/st3-gitblame/Settings/Git blame.sublime-settings",
diff --git a/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
index 365af24..b889d96 100644
--- a/Menus/Main.sublime-menu
+++ b/Menus/Main.sublime-menu
@@ -10,6 +10,7 @@
             "children": [
               {
                 "caption": "Settings",
+                // ♊️: Entry in "Default.sublime-commands"
                 "command": "edit_settings",
                 "args": {
                   "base_file": "${packages}/st3-gitblame/Settings/Git blame.sublime-settings",

From 1b83aa847bca9e1de96bdee3e1eec794abc9cb22 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 11:00:54 +0100
Subject: [PATCH 078/180] Make views Show-ing full commits read-only

---
 src/blame.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/blame.py b/src/blame.py
index 2d56e1a..3f18e8e 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -169,3 +169,4 @@ def run(self, edit, desc, scratch_view_name):
         view.assign_syntax("Packages/Diff/Diff.sublime-syntax")
         view.insert(edit, 0, desc)
         view.set_name(scratch_view_name)
+        view.set_read_only(True)

From 04c2bbe0ad6ee461588022764507f8058b7dcc22 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 11:20:47 +0100
Subject: [PATCH 079/180] Allow Key Bindings to be edited from Command Palette

---
 Commands/Default.sublime-commands | 9 +++++++++
 Menus/Main.sublime-menu           | 1 +
 2 files changed, 10 insertions(+)

diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
index 49b0dc9..69c9dee 100644
--- a/Commands/Default.sublime-commands
+++ b/Commands/Default.sublime-commands
@@ -19,5 +19,14 @@
       "base_file": "${packages}/st3-gitblame/Settings/Git blame.sublime-settings",
       "default": "{\n\t$0\n}\n"
     }
+  },
+  {
+    "caption": "Git Blame: Key Bindings",
+    // ♊️: Entry in "Main.sublime-menu"
+    "command": "edit_settings",
+    "args": {
+      "base_file": "${packages}/st3-gitblame/Keymaps/Default (${platform}).sublime-keymap",
+      "default": "[\n\t$0\n]\n"
+    }
   }
 ]
diff --git a/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
index b889d96..3e27559 100644
--- a/Menus/Main.sublime-menu
+++ b/Menus/Main.sublime-menu
@@ -19,6 +19,7 @@
               },
               {
                 "caption": "Key Bindings",
+                // ♊️: Entry in "Default.sublime-commands"
                 "command": "edit_settings",
                 "args": {
                   "base_file": "${packages}/st3-gitblame/Keymaps/Default (${platform}).sublime-keymap",

From d7d28649c8c71a381ac46dcf043cc1ea01e1b756 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 12:01:29 +0100
Subject: [PATCH 080/180] Add version 1.2.0 changelog for Package Control

Closes GH-49
---
 messages.json      |  3 +++
 messages/1.2.0.txt | 17 +++++++++++++++++
 2 files changed, 20 insertions(+)
 create mode 100644 messages.json
 create mode 100644 messages/1.2.0.txt

diff --git a/messages.json b/messages.json
new file mode 100644
index 0000000..9489f68
--- /dev/null
+++ b/messages.json
@@ -0,0 +1,3 @@
+{
+  "1.2.0": "messages/1.2.0.txt"
+}
diff --git a/messages/1.2.0.txt b/messages/1.2.0.txt
new file mode 100644
index 0000000..5e5a2ac
--- /dev/null
+++ b/messages/1.2.0.txt
@@ -0,0 +1,17 @@
+-> 1.2.0
+
+NEW:
+
+- Added a [Prev] button to step back through multiple previous commits that affected a single line. Try this when the initial blame info is not as illuminating as you hoped for.
+
+- Added a Linux key binding (Ctrl-Shift-C) to show blame info for all lines. If you find that to be an unsuitable default, please comment here: https://github.com/frou/st3-gitblame/issues/49
+
+- Added entries for editing this package's Settings & Key Bindings to both the Preferences menu and the Command Palette.
+
+- Added the ability to set additional flags to be passed to the `git blame` CLI. See this package's Settings file.
+
+FIXED:
+
+- Fixed certain git configuration causing ANSI escape codes to be intermixed with the Diff when [Show]-ing a commit. Credit: https://github.com/james2doyle
+
+- Fixed caching issue that could cause incorrect blame info to be shown.

From 1ae44eccc0bd3a333b34dfbb217815d8c9345c70 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 12:19:45 +0100
Subject: [PATCH 081/180] Update issue templates

---
 .github/ISSUE_TEMPLATE/bug_report.md | 28 ++++++++++++++++++++++++++++
 1 file changed, 28 insertions(+)
 create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md

diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..5b10b97
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,28 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+<!-- Please fill in the following first-->
+
+* Sublime Text version:
+    * [e.g. Build 3211]
+
+* How did you install this package?:
+    * [e.g. via Package Control / Manually downloaded]
+
+* Operating System and version:
+    * [e.g. macOS 10.15.6]
+
+* How did you install git itself?
+    * [e.g. already came with my OS / via system package manager / Git-for-Windows]
+
+<!--
+Now please describe the bug.
+
+If it seems like nothing is happening when something should. Check to see if there is an error/exception message in Sublime Text's console (View > Show Console)
+-->

From a077e9a97353ec5b8e4beb541d90b0801fc59a07 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 12:20:58 +0100
Subject: [PATCH 082/180] Update issue templates

---
 .github/ISSUE_TEMPLATE/feature_request.md | 10 ++++++++++
 1 file changed, 10 insertions(+)
 create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md

diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..e46a4c0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,10 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+

From 2cb4debce5e2b463cf98497e7a05e9f8fec00b41 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 13:09:40 +0100
Subject: [PATCH 083/180] Add more screenshots

---
 screenshot-blame-show.png | Bin 0 -> 287475 bytes
 screenshot-blame.png      | Bin 0 -> 256278 bytes
 screenshot-blameall.png   | Bin 0 -> 448650 bytes
 screenshot.png            | Bin 147092 -> 0 bytes
 4 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 screenshot-blame-show.png
 create mode 100644 screenshot-blame.png
 create mode 100644 screenshot-blameall.png
 delete mode 100644 screenshot.png

diff --git a/screenshot-blame-show.png b/screenshot-blame-show.png
new file mode 100644
index 0000000000000000000000000000000000000000..128194b98c5a78be5b2aeeb6562de8eff4a968c6
GIT binary patch
diff --git a/screenshot-blame.png b/screenshot-blame.png
new file mode 100644
index 0000000000000000000000000000000000000000..cc21d2d1c38058f6415524a1a028b274cd7c7dc7
GIT binary patch
diff --git a/screenshot-blameall.png b/screenshot-blameall.png
new file mode 100644
index 0000000000000000000000000000000000000000..12f1ca25096bfa0c34fbb34d81fa9fdf5d6ca6d5
GIT binary patch
diff --git a/screenshot.png b/screenshot.png
deleted file mode 100644
index d7a8aa279ffd99945a338c543341a0075bee2a64..0000000000000000000000000000000000000000
GIT binary patch

From e8cede9fef34af2f6d8f74b57b773730d11a1697 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 13:12:59 +0100
Subject: [PATCH 084/180] Consolidate stuff in a docs directory

---
 .../packagecontrol_changelogs}/1.2.0.txt            |   0
 .../screenshot-blame-show.png                       | Bin
 screenshot-blame.png => docs/screenshot-blame.png   | Bin
 .../screenshot-blameall.png                         | Bin
 messages.json                                       |   2 +-
 5 files changed, 1 insertion(+), 1 deletion(-)
 rename {messages => docs/packagecontrol_changelogs}/1.2.0.txt (100%)
 rename screenshot-blame-show.png => docs/screenshot-blame-show.png (100%)
 rename screenshot-blame.png => docs/screenshot-blame.png (100%)
 rename screenshot-blameall.png => docs/screenshot-blameall.png (100%)

diff --git a/messages/1.2.0.txt b/docs/packagecontrol_changelogs/1.2.0.txt
similarity index 100%
rename from messages/1.2.0.txt
rename to docs/packagecontrol_changelogs/1.2.0.txt
diff --git a/screenshot-blame-show.png b/docs/screenshot-blame-show.png
similarity index 100%
rename from screenshot-blame-show.png
rename to docs/screenshot-blame-show.png
diff --git a/screenshot-blame.png b/docs/screenshot-blame.png
similarity index 100%
rename from screenshot-blame.png
rename to docs/screenshot-blame.png
diff --git a/screenshot-blameall.png b/docs/screenshot-blameall.png
similarity index 100%
rename from screenshot-blameall.png
rename to docs/screenshot-blameall.png
diff --git a/messages.json b/messages.json
index 9489f68..1deb0e9 100644
--- a/messages.json
+++ b/messages.json
@@ -1,3 +1,3 @@
 {
-  "1.2.0": "messages/1.2.0.txt"
+  "1.2.0": "docs/packagecontrol_changelogs/1.2.0.txt"
 }

From 9363995e3fe94321d46e7f68f1dc01d257feec54 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 13:28:12 +0100
Subject: [PATCH 085/180] Add screenshot showing Command Palette usage

---
 docs/screenshot-palette.png | Bin 0 -> 62554 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 docs/screenshot-palette.png

diff --git a/docs/screenshot-palette.png b/docs/screenshot-palette.png
new file mode 100644
index 0000000000000000000000000000000000000000..b37b98dde139cd429a89e95475d3e6ee9d062efb
GIT binary patch

From d6ed9d714afc0280666cfd578bf0670dfedaf573 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 13:41:08 +0100
Subject: [PATCH 086/180] Show more features in the README

Closes GH-39
---
 README.md | 26 ++++++++++++++++++++------
 1 file changed, 20 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md
index ba82e07..74b1c15 100644
--- a/README.md
+++ b/README.md
@@ -16,20 +16,34 @@ This package was originally created by [@psykzz](https://github.com/psykzz) and
 
 ## How to use
 
-Place the text insertion cursor on the line you are interested in, then press <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>Q</kbd> (Linux).
+Place the text cursor on the line you are interested in, then press <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>Q</kbd> (Linux).
 
 (Instead of pressing the keyboard shortcut, you can alternatively right click and select *Git Blame* from the context menu)
 
 The blame information will appear:
 
-![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/screenshot.png)
+![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/docs/screenshot-blame.png)
 
-You can then click `[Show]` to show the commit in full, or `[Copy]` to copy the Commit ID (aka SHA) to your clipboard. To close the blame information, click the `×` icon, or press the keyboard shortcut again.
+If you want, you can then click `[Show]` to show the commit in full:
+
+![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/docs/screenshot-blame-show.png)
+
+...or click `[Copy]` to copy the Commit ID (aka SHA) to your clipboard.
+
+If the latest blame information was not as illuminating as you hoped for, click the `[Prev]` to step back through multiple previous commits that affected the line.
+
+To close the blame information, click the `×` icon, or press the keyboard shortcut again while the text cursor is still on the same line.
 
 ## Advanced use
 
-In combination with Sublime Text's *Multiple Cursor* feature, you can query blame information for more than one line simultaneously by first placing a cursor on each line you are interested in.
+In combination with Sublime Text's *Multiple Selection* feature, you can query blame information for more than one line simultaneously by first placing a text cursor on each line you are interested in, and then running as described above.
+
+You can also query blame information for every line in the entire file simultaneously by pressing <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>Shift</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>C</kbd> (Linux). Doing this shows blame information in a different style (it's located to the left of the content, and more compact, but with fewer features):
+
+![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/docs/screenshot-blameall.png)
+
+To close all of them, click the `×` icon on any one of them, or press the keyboard shortcut again.
 
-You can also query blame information for every line in the entire file simultaneously by pressing <kbd>Ctrl</kbd><kbd>Alt</kbd><kbd>Shift</kbd><kbd>B</kbd> (Mac/Windows) or <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>C</kbd> (Linux). To close all of them, click the `×` icon on one of them, or press the keyboard shortcut again.
+As well as via keyboard shortcuts, this package's commands are also made available in the *Command Palette*. Type "Git Blame" into it to find them:
 
-This package's commands are also made available in the *Command Palette*. Type "Git Blame" into it to find them.
+![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/docs/screenshot-palette.png)

From 1c73bb442dff5625271dbd45ff9ef2ab5ae6e484 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 14:47:57 +0100
Subject: [PATCH 087/180] Fix menu entries to edit settings using outdated
 paths

---
 Commands/Default.sublime-commands | 4 ++--
 Menus/Main.sublime-menu           | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
index 69c9dee..bdba5b7 100644
--- a/Commands/Default.sublime-commands
+++ b/Commands/Default.sublime-commands
@@ -16,7 +16,7 @@
     // ♊️: Entry in "Main.sublime-menu"
     "command": "edit_settings",
     "args": {
-      "base_file": "${packages}/st3-gitblame/Settings/Git blame.sublime-settings",
+      "base_file": "${packages}/Git blame/Settings/Git blame.sublime-settings",
       "default": "{\n\t$0\n}\n"
     }
   },
@@ -25,7 +25,7 @@
     // ♊️: Entry in "Main.sublime-menu"
     "command": "edit_settings",
     "args": {
-      "base_file": "${packages}/st3-gitblame/Keymaps/Default (${platform}).sublime-keymap",
+      "base_file": "${packages}/Git blame/Keymaps/Default (${platform}).sublime-keymap",
       "default": "[\n\t$0\n]\n"
     }
   }
diff --git a/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
index 3e27559..58a2b3c 100644
--- a/Menus/Main.sublime-menu
+++ b/Menus/Main.sublime-menu
@@ -13,7 +13,7 @@
                 // ♊️: Entry in "Default.sublime-commands"
                 "command": "edit_settings",
                 "args": {
-                  "base_file": "${packages}/st3-gitblame/Settings/Git blame.sublime-settings",
+                  "base_file": "${packages}/Git blame/Settings/Git blame.sublime-settings",
                   "default": "{\n\t$0\n}\n"
                 }
               },
@@ -22,7 +22,7 @@
                 // ♊️: Entry in "Default.sublime-commands"
                 "command": "edit_settings",
                 "args": {
-                  "base_file": "${packages}/st3-gitblame/Keymaps/Default (${platform}).sublime-keymap",
+                  "base_file": "${packages}/Git blame/Keymaps/Default (${platform}).sublime-keymap",
                   "default": "[\n\t$0\n]\n"
                 }
               }

From a12a6eb75a1d7fe79bbd4eaddc7662720fb9617e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 Aug 2020 14:52:48 +0100
Subject: [PATCH 088/180] Add changelog for version 1.2.1

---
 docs/packagecontrol_changelogs/1.2.1.txt | 5 +++++
 messages.json                            | 3 ++-
 2 files changed, 7 insertions(+), 1 deletion(-)
 create mode 100644 docs/packagecontrol_changelogs/1.2.1.txt

diff --git a/docs/packagecontrol_changelogs/1.2.1.txt b/docs/packagecontrol_changelogs/1.2.1.txt
new file mode 100644
index 0000000..0f560e6
--- /dev/null
+++ b/docs/packagecontrol_changelogs/1.2.1.txt
@@ -0,0 +1,5 @@
+-> 1.2.1
+
+FIXED:
+
+- Fixed menu and command palette entries for editing package's Settings & Key Bindings using outdated paths.
diff --git a/messages.json b/messages.json
index 1deb0e9..11ca499 100644
--- a/messages.json
+++ b/messages.json
@@ -1,3 +1,4 @@
 {
-  "1.2.0": "docs/packagecontrol_changelogs/1.2.0.txt"
+  "1.2.0": "docs/packagecontrol_changelogs/1.2.0.txt",
+  "1.2.1": "docs/packagecontrol_changelogs/1.2.1.txt"
 }

From 0104ab28546fde11bf96e858110755fab4310f7e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 10 Aug 2020 08:27:51 +0100
Subject: [PATCH 089/180] Show a dialog after exhausting [Prev] possibilities

---
 src/blame.py | 16 +++++++++++-----
 1 file changed, 11 insertions(+), 5 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 3f18e8e..a6e4334 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -22,7 +22,6 @@ def run(self, edit, sha_skip_list=[], prevving=False):
         if not view_is_suitable(self.view):
             return
 
-        # The contents of PhantomSet are replaced each time this Command is ran.
         phantoms = []
 
         for region in self.view.sel():
@@ -47,6 +46,16 @@ def run(self, edit, sha_skip_list=[], prevving=False):
                 return
 
             sha, user, date, time = self.parse_blame(blame_output)
+            # The SHA output by `git blame` may have a leading caret to indicate that it
+            # is a "boundary commit". That needs to be stripped before passing the SHA
+            # back to git CLI commands for other purposes.
+            sha_normalised = sha.strip("^")
+
+            if sha_skip_list:
+                recently_skipped_sha = sha_skip_list[-1]
+                if sha_normalised == recently_skipped_sha:
+                    sublime.message_dialog("No earlier commits affected this line")
+                    return
 
             phantoms.append(
                 sublime.Phantom(
@@ -57,10 +66,7 @@ def run(self, edit, sha_skip_list=[], prevving=False):
                         user=user,
                         date=date,
                         time=time,
-                        # The SHA output by `git blame` may have a leading caret to indicate
-                        # that it is a "boundary commit". That needs to be stripped before
-                        # using the SHA programmatically for other purposes.
-                        qs_sha_val=quote_plus(sha.strip("^")),
+                        qs_sha_val=quote_plus(sha_normalised),
                         # Querystrings can contain the same key multiple times. We use that
                         # functionality to accumulate a list of SHAs to skip over when
                         # a [Prev] button has been clicked multiple times.

From bf2cf430a0fba440a1a3cae1236c2b6fdc6dc60d Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 10 Aug 2020 08:42:57 +0100
Subject: [PATCH 090/180] Show an asterisk beside SHA if it's not the latest

---
 src/blame.py     | 1 +
 src/templates.py | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/blame.py b/src/blame.py
index a6e4334..4a87421 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -63,6 +63,7 @@ def run(self, edit, sha_skip_list=[], prevving=False):
                     blame_phantom_html_template.format(
                         css=blame_phantom_css,
                         sha=sha,
+                        sha_not_latest_indicator=" *" if sha_skip_list else "",
                         user=user,
                         date=date,
                         time=time,
diff --git a/src/templates.py b/src/templates.py
index 05d75f8..482fcb2 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -7,7 +7,7 @@
                 <strong>Git Blame</strong> ({user})
                 {date} {time} |
                 <a href="prev?sha={qs_sha_val}&{qs_skip_keyvals}">[Prev]</a>
-                {sha}
+                {sha}{sha_not_latest_indicator}
                 <a href="copy?sha={qs_sha_val}">[Copy]</a>
                 <a href="show?sha={qs_sha_val}">[Show]</a>
                 <a class="close" href="close">\u00D7</a>

From 7b6f9ef34497426feae62dfbe55e29464834adf3 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 10 Aug 2020 09:39:46 +0100
Subject: [PATCH 091/180] Stop [Prev] being disrupted by moved text cursor

---
 src/blame.py     | 30 ++++++++++++++++++++++++------
 src/templates.py |  2 +-
 2 files changed, 25 insertions(+), 7 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 4a87421..452cdd3 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -18,13 +18,23 @@ def __init__(self, view):
         super().__init__(view)
         self.phantom_set = sublime.PhantomSet(view, "git-blame")
 
-    def run(self, edit, sha_skip_list=[], prevving=False):
+    def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
         if not view_is_suitable(self.view):
             return
 
         phantoms = []
 
-        for region in self.view.sel():
+        if prevving:
+            # We'll be getting blame information for the line whose existing phantom's
+            # [Prev] button was clicked, regardless of where the text cursor(s)
+            # currently are.
+            relevant_regions = [sublime.Region(self.view.text_point(fixed_row_num, 0))]
+        else:
+            # We'll be getting blame information for the lines where text cursor(s)
+            # currently are.
+            relevant_regions = self.view.sel()
+
+        for region in relevant_regions:
             line_region = self.view.line(region)
 
             # When this Command is ran for a line with a phantom already visible, we
@@ -35,12 +45,13 @@ def run(self, edit, sha_skip_list=[], prevving=False):
             if self.phantom_exists_for_region(line_region) and not prevving:
                 continue
 
-            (row, _) = self.view.rowcol(region.begin())
-            line = row + 1
+            row_num, _ = self.view.rowcol(region.begin())
+            line_num = row_num + 1
+
             full_path = self.view.file_name()
 
             try:
-                blame_output = self.get_blame(line, full_path, sha_skip_list)
+                blame_output = self.get_blame(line_num, full_path, sha_skip_list)
             except Exception as e:
                 communicate_error(e)
                 return
@@ -67,6 +78,7 @@ def run(self, edit, sha_skip_list=[], prevving=False):
                         user=user,
                         date=date,
                         time=time,
+                        qs_row_num_val=quote_plus(str(row_num)),
                         qs_sha_val=quote_plus(sha_normalised),
                         # Querystrings can contain the same key multiple times. We use that
                         # functionality to accumulate a list of SHAs to skip over when
@@ -153,10 +165,16 @@ def handle_phantom_button(self, href):
             )
         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, sha_skip_list, prevving=True)
+            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([])
diff --git a/src/templates.py b/src/templates.py
index 482fcb2..93865bb 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -6,7 +6,7 @@
             <span class="message">
                 <strong>Git Blame</strong> ({user})
                 {date} {time} |
-                <a href="prev?sha={qs_sha_val}&{qs_skip_keyvals}">[Prev]</a>
+                <a href="prev?sha={qs_sha_val}&row_num={qs_row_num_val}&{qs_skip_keyvals}">[Prev]</a>
                 {sha}{sha_not_latest_indicator}
                 <a href="copy?sha={qs_sha_val}">[Copy]</a>
                 <a href="show?sha={qs_sha_val}">[Show]</a>

From 4ecda8e8e6ac3fceb76e424cebf87a546445e074 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 10 Aug 2020 09:49:03 +0100
Subject: [PATCH 092/180] Add changelog for version 1.2.2

---
 docs/packagecontrol_changelogs/1.2.2.txt | 6 ++++++
 messages.json                            | 3 ++-
 2 files changed, 8 insertions(+), 1 deletion(-)
 create mode 100644 docs/packagecontrol_changelogs/1.2.2.txt

diff --git a/docs/packagecontrol_changelogs/1.2.2.txt b/docs/packagecontrol_changelogs/1.2.2.txt
new file mode 100644
index 0000000..965c88c
--- /dev/null
+++ b/docs/packagecontrol_changelogs/1.2.2.txt
@@ -0,0 +1,6 @@
+-> 1.2.2
+
+FIXED:
+
+- Improved usability and reliability of [Prev] button that steps back through
+  multiple previous commits that affected a single line.
diff --git a/messages.json b/messages.json
index 11ca499..09dffd0 100644
--- a/messages.json
+++ b/messages.json
@@ -1,4 +1,5 @@
 {
   "1.2.0": "docs/packagecontrol_changelogs/1.2.0.txt",
-  "1.2.1": "docs/packagecontrol_changelogs/1.2.1.txt"
+  "1.2.1": "docs/packagecontrol_changelogs/1.2.1.txt",
+  "1.2.2": "docs/packagecontrol_changelogs/1.2.2.txt"
 }

From 33bc260129dcf909dc8a5665a16f98268a3b0d3e Mon Sep 17 00:00:00 2001
From: Jan Tojnar <jtojnar@gmail.com>
Date: Sun, 23 Aug 2020 02:30:32 +0200
Subject: [PATCH 093/180] Fix missing buttons with ST 4
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

After recent update, the Git Blame line no longer shows the buttons in Sublime Text 4,
and logs the following error in the console:

	Parse Error: _num=24&">[Prev]</a>
	                2addc0883630
	                <a href="copy?sha=2addc0883630">[Copy]</a>
	                <a href="show?sha=2addc0883630">[Show]</a>
	                <a class="close" href="close">×</a>
	            </span>
	        </div>
	    </body>
	 code: Unexpected character

Let's escape the ampersands to prevent this.
---
 src/blame.py     | 2 +-
 src/templates.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 452cdd3..aaa01b8 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -83,7 +83,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                         # Querystrings can contain the same key multiple times. We use that
                         # functionality to accumulate a list of SHAs to skip over when
                         # a [Prev] button has been clicked multiple times.
-                        qs_skip_keyvals="&".join(
+                        qs_skip_keyvals="&amp;".join(
                             [
                                 "skip={}".format(quote_plus(skipee))
                                 for skipee in sha_skip_list
diff --git a/src/templates.py b/src/templates.py
index 93865bb..d0a7c36 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -6,7 +6,7 @@
             <span class="message">
                 <strong>Git Blame</strong> ({user})
                 {date} {time} |
-                <a href="prev?sha={qs_sha_val}&row_num={qs_row_num_val}&{qs_skip_keyvals}">[Prev]</a>
+                <a href="prev?sha={qs_sha_val}&amp;row_num={qs_row_num_val}&amp;{qs_skip_keyvals}">[Prev]</a>
                 {sha}{sha_not_latest_indicator}
                 <a href="copy?sha={qs_sha_val}">[Copy]</a>
                 <a href="show?sha={qs_sha_val}">[Show]</a>

From bc258be47cd72aad497dbd71cb86ee963948bb0e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 23 Aug 2020 19:13:57 +0100
Subject: [PATCH 094/180] Add changelog for version 1.2.3

---
 docs/packagecontrol_changelogs/1.2.3.txt | 6 ++++++
 messages.json                            | 3 ++-
 2 files changed, 8 insertions(+), 1 deletion(-)
 create mode 100644 docs/packagecontrol_changelogs/1.2.3.txt

diff --git a/docs/packagecontrol_changelogs/1.2.3.txt b/docs/packagecontrol_changelogs/1.2.3.txt
new file mode 100644
index 0000000..69d2887
--- /dev/null
+++ b/docs/packagecontrol_changelogs/1.2.3.txt
@@ -0,0 +1,6 @@
+-> 1.2.3
+
+FIXED:
+
+- Fixed [Prev] [Copy] [Show] buttons not appearing in the new Sublime Text beta.
+  Credit: jtojnar https://github.com/frou/st3-gitblame/pull/56
diff --git a/messages.json b/messages.json
index 09dffd0..2e4ddfa 100644
--- a/messages.json
+++ b/messages.json
@@ -1,5 +1,6 @@
 {
   "1.2.0": "docs/packagecontrol_changelogs/1.2.0.txt",
   "1.2.1": "docs/packagecontrol_changelogs/1.2.1.txt",
-  "1.2.2": "docs/packagecontrol_changelogs/1.2.2.txt"
+  "1.2.2": "docs/packagecontrol_changelogs/1.2.2.txt",
+  "1.2.3": "docs/packagecontrol_changelogs/1.2.3.txt"
 }

From 261bd8ba0846dee7a22ad8791784c27f53e30148 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 9 Sep 2020 19:41:58 +0100
Subject: [PATCH 095/180] Add todo

---
 src/blame.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/blame.py b/src/blame.py
index aaa01b8..790953c 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -9,6 +9,9 @@
 from .templates import blame_phantom_css, blame_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
 
+# @todo Make a command to open the latest diff ("CommitDescription") for the current line in a single keystroke.
+# @body Currently it takes a keystroke and then a mouse click on "Show"
+
 
 class Blame(sublime_plugin.TextCommand):
 

From abfdb9fc344ba4aeb84b70d17ae9f571b88eda8c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Thu, 31 Dec 2020 21:15:23 +0000
Subject: [PATCH 096/180] Emphasise it's about line number (not line content)
 in message; Fix lint

---
 src/blame.py | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 790953c..c313fbc 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -68,7 +68,9 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
             if sha_skip_list:
                 recently_skipped_sha = sha_skip_list[-1]
                 if sha_normalised == recently_skipped_sha:
-                    sublime.message_dialog("No earlier commits affected this line")
+                    sublime.message_dialog(
+                        "No earlier commits affected line {0}".format(line_num)
+                    )
                     return
 
             phantoms.append(
@@ -88,7 +90,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                         # a [Prev] button has been clicked multiple times.
                         qs_skip_keyvals="&amp;".join(
                             [
-                                "skip={}".format(quote_plus(skipee))
+                                "skip={0}".format(quote_plus(skipee))
                                 for skipee in sha_skip_list
                             ]
                         ),
@@ -183,7 +185,7 @@ def handle_phantom_button(self, href):
             self.phantom_set.update([])
         else:
             communicate_error(
-                "No handler for URL path '{}' in phantom".format(url.path)
+                "No handler for URL path '{0}' in phantom".format(url.path)
             )
 
 

From 2095c80f33fdd6f119ef5e8870bcc9bf83a67be9 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 16 Feb 2021 21:39:18 +0000
Subject: [PATCH 097/180] Add todo

---
 src/blame.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/blame.py b/src/blame.py
index c313fbc..e359554 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -118,6 +118,8 @@ def get_blame(self, line, path, sha_skip_list):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
+    # @todo Add tests for parse_blame function, to avoid regressions when then improving it
+    # @body https://docs.python.org/3.9/library/unittest.html
     def parse_blame(self, blame):
         sha, file_path, user, date, time, tz_offset, *_ = blame.split()
 

From cd85af47f5232fe208f891c02d373968b5ebed42 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 17 Feb 2021 20:28:36 +0000
Subject: [PATCH 098/180] Format & keep flake8 happy

---
 src/blame_all.py | 28 ++++++++++------------------
 1 file changed, 10 insertions(+), 18 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 72022cb..0c587ab 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -72,8 +72,7 @@ def run(self, edit):
         self.view.set_viewport_position((0.0, self.view.viewport_position()[1]))
 
     def on_phantom_close(self, href):
-        """Closes opened phantoms.
-        """
+        """Closes opened phantoms."""
         if href == "close":
             self.view.run_command("blame_erase_all")
 
@@ -93,8 +92,7 @@ def get_blame(self, path):
         ).decode("utf-8")
 
     def parse_blame(self, blame):
-        """Parses git blame output.
-        """
+        """Parses git blame output."""
         if not self.pattern:
             self.prepare_pattern()
 
@@ -112,15 +110,14 @@ def parse_blame(self, blame):
             return None
 
     def prepare_pattern(self):
-        """Prepares the regex pattern to parse git blame output.
-        """
+        """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_date = r"(?P<date>\d{4}-\d{2}-\d{2})"  # noqa: FS003
+        p_time = r"(?P<time>\d{2}:\d{2}:\d{2})"  # noqa: FS003
         p_timezone = r"(?P<timezone>[\+-]\d+)"
         p_line = r"(?P<line_number>\d+)"
         s = r"\s+"
@@ -144,8 +141,7 @@ def prepare_pattern(self):
         )
 
     def format_name(self, name):
-        """Formats author names so that widths of phantoms become equal.
-        """
+        """Formats author names so that widths of phantoms become equal."""
         ellipsis = "..."
         if len(name) > self.NAME_LENGTH:
             return name[: self.NAME_LENGTH] + ellipsis
@@ -153,8 +149,7 @@ def format_name(self, name):
             return name + "." * (self.NAME_LENGTH - len(name)) + ellipsis
 
     def get_line_point(self, line):
-        """Get the point of specified line in a view.
-        """
+        """Get the point of specified line in a view."""
         return self.view.line(self.view.text_point(line, 0))
 
 
@@ -163,8 +158,7 @@ class BlameEraseAll(sublime_plugin.TextCommand):
     # Overrides --------------------------------------------------
 
     def run(self, edit):
-        """Erases the blame results.
-        """
+        """Erases the blame results."""
         sublime.status_message("The git blame result is cleared.")
         self.view.erase_phantoms(PHANTOM_KEY_ALL)
 
@@ -175,12 +169,10 @@ class BlameEraseAllListener(sublime_plugin.ViewEventListener):
 
     @classmethod
     def is_applicable(cls, settings):
-        """Checks if the blame_erase_all command is applicable.
-        """
+        """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.
-        """
+        """Automatically erases the blame results to prevent mismatches."""
         self.view.run_command("blame_erase_all")
         self.view.settings().erase(SETTING_PHANTOM_ALL_DISPLAYED)

From f280fb09b031a9dd114486db8759aa88b169b2f0 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Feb 2021 14:20:46 +0000
Subject: [PATCH 099/180] BlameAll: Stop truncating names in the phantoms

...and pad out every BlameAll phantom based on the longest name that appears in any of them (so that everything still aligns).

Fixes #59
---
 src/blame_all.py | 53 ++++++++++++++++++------------------------------
 src/templates.py |  5 +----
 2 files changed, 21 insertions(+), 37 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 0c587ab..45bc932 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -15,9 +15,6 @@
 
 class BlameShowAll(sublime_plugin.TextCommand):
 
-    # The fixed length for author names
-    NAME_LENGTH = 10
-
     # Overrides --------------------------------------------------
 
     def __init__(self, view):
@@ -44,22 +41,31 @@ def run(self, edit):
             communicate_error(e)
             return
 
-        for line in blame_output.splitlines():
-            parsed = self.parse_blame(line)
-            if not parsed:
-                continue
-
-            sha, author, date, time, line_number = parsed
+        blames = [self.parse_blame(line) for line in blame_output.splitlines()]
+        blames = [b for b in blames if b]
+        if not blames:
+            communicate_error(
+                "Failed to parse anything for {0}. Has git's output format changed?".format(
+                    self.__class__.__name__
+                )
+            )
+            return
 
+        max_author_len = max(len(b["author"]) for b in blames)
+        for blame in blames:
+            line_number = int(blame["line_number"])
             line_point = self.get_line_point(line_number - 1)
+
+            author = blame["author"]
+
             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,
+                    sha=blame["sha"],
+                    user=author + "&nbsp;" * (max_author_len - len(author)),
+                    date=blame["date"],
+                    time=blame["time"],
                 ),
                 sublime.LAYOUT_INLINE,
                 self.on_phantom_close,
@@ -92,22 +98,11 @@ def get_blame(self, path):
         ).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
+        return m.groupdict()
 
     def prepare_pattern(self):
         """Prepares the regex pattern to parse git blame output."""
@@ -140,14 +135,6 @@ def prepare_pattern(self):
             + 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))
diff --git a/src/templates.py b/src/templates.py
index d0a7c36..728e2f5 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -57,7 +57,7 @@
         <style>{css}</style>
         <div class="phantom">
             <span class="message">
-                {sha} (<span class="user">{user}</span> {date} {time})
+                {sha}&nbsp;&nbsp;{user}&nbsp;&nbsp;{date}&nbsp;&nbsp;{time}
                 <a class="close" href="close">\u00D7</a>
             </span>
         </div>
@@ -71,9 +71,6 @@
         margin: 0;
         background-color: color(var(--bluish) blend(var(--background) 30%));
     }
-    div.phantom .user {
-        width: 10em;
-    }
     div.phantom a.close {
         padding: 0.35rem 0.7rem 0.45rem 0.8rem;
         position: relative;

From eb797e3f7f6f01b4f310432e15031857ae7332e8 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Feb 2021 14:32:00 +0000
Subject: [PATCH 100/180] Add todo

---
 src/blame_all.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/blame_all.py b/src/blame_all.py
index 45bc932..f251075 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -75,6 +75,7 @@ def run(self, edit):
         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.
+        # @todo BlameAll: Automatically scrolling the view to the left doesn't work when the ST window has >1 Group
         self.view.set_viewport_position((0.0, self.view.viewport_position()[1]))
 
     def on_phantom_close(self, href):

From 87bc5fdccc79e15c26df258670c362f90dc714f4 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Feb 2021 18:18:17 +0000
Subject: [PATCH 101/180] Fix calling a method on None

---
 src/blame_all.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index f251075..a0f6801 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -103,7 +103,7 @@ def parse_blame(self, blame):
             self.prepare_pattern()
 
         m = self.pattern.match(blame)
-        return m.groupdict()
+        return m.groupdict() if m else {}
 
     def prepare_pattern(self):
         """Prepares the regex pattern to parse git blame output."""

From a4ae36efbee166d161034e4e153eabdf7a196a71 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Feb 2021 19:51:56 +0000
Subject: [PATCH 102/180] Add unit tests for the existing git CLI output
 parsing, to prevent regressions

Fixes #60
---
 src/blame.py          |  9 +++--
 src/blame_all.py      | 20 ++++-------
 tests/test_parsing.py | 80 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 91 insertions(+), 18 deletions(-)
 create mode 100644 tests/test_parsing.py

diff --git a/src/blame.py b/src/blame.py
index e359554..206f1ec 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -59,7 +59,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                 communicate_error(e)
                 return
 
-            sha, user, date, time = self.parse_blame(blame_output)
+            sha, user, date, time = self.parse_line(blame_output)
             # The SHA output by `git blame` may have a leading caret to indicate that it
             # is a "boundary commit". That needs to be stripped before passing the SHA
             # back to git CLI commands for other purposes.
@@ -118,10 +118,9 @@ def get_blame(self, line, path, sha_skip_list):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
-    # @todo Add tests for parse_blame function, to avoid regressions when then improving it
-    # @body https://docs.python.org/3.9/library/unittest.html
-    def parse_blame(self, blame):
-        sha, file_path, user, date, time, tz_offset, *_ = blame.split()
+    @classmethod
+    def parse_line(cls, blame_line):
+        sha, file_path, user, date, time, tz_offset, *_ = blame_line.split()
 
         # Was part of the inital commit so no updates
         if file_path[0] == "(":
diff --git a/src/blame_all.py b/src/blame_all.py
index a0f6801..63ba002 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -41,7 +41,7 @@ def run(self, edit):
             communicate_error(e)
             return
 
-        blames = [self.parse_blame(line) for line in blame_output.splitlines()]
+        blames = [self.parse_line(line) for line in blame_output.splitlines()]
         blames = [b for b in blames if b]
         if not blames:
             communicate_error(
@@ -98,17 +98,8 @@ def get_blame(self, path):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
-    def parse_blame(self, blame):
-        if not self.pattern:
-            self.prepare_pattern()
-
-        m = self.pattern.match(blame)
-        return m.groupdict() if m else {}
-
-    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".
+    @classmethod
+    def parse_line(cls, blame_line):
         p_sha = r"(?P<sha>\^?\w+)"
         p_file = r"((?P<file>[\S ]+)\s+)"
         p_author = r"(?P<author>.+?)"
@@ -118,7 +109,7 @@ def prepare_pattern(self):
         p_line = r"(?P<line_number>\d+)"
         s = r"\s+"
 
-        self.pattern = re.compile(
+        regex = re.compile(
             r"^"
             + p_sha
             + s
@@ -136,6 +127,9 @@ def prepare_pattern(self):
             + r"\) "
         )
 
+        m = regex.match(blame_line)
+        return m.groupdict() if m else {}
+
     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))
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
new file mode 100644
index 0000000..ca12500
--- /dev/null
+++ b/tests/test_parsing.py
@@ -0,0 +1,80 @@
+import importlib
+import unittest
+
+# REF: https://github.com/SublimeText/UnitTesting/blob/master/README.md
+
+# This strange form of import is required because our ST package name has a space in it.
+blame = importlib.import_module("Git blame.src.blame")
+blame_all = importlib.import_module("Git blame.src.blame_all")
+
+# NOTE: The sample git-blame CLI outputs were taken from the https://github.com/sublimelsp/LSP
+# repo because they exhibit different numbers of components in author names.
+
+
+class TestParsing(unittest.TestCase):
+    def test_blame_parsing(self):
+        samples = [
+            (
+                r"""27399a5b (Tom van Ommeren 2017-10-12 22:53:05 +0200 26)     DiagnosticSeverity.Warning: 'markup.changed.lsp sublimelinter.mark.warning markup.warning.lsp',""",
+                # EXISTING INCORRECT PARSING. Issue #29
+                ("27399a5b", "Tom van", "Ommeren", "2017-10-12"),
+            ),
+            (
+                r"""c937eff9 (Duncan Holm 2020-04-11 14:29:47 +0100 3) import re""",
+                ("c937eff9", "Duncan Holm", "2020-04-11", "14:29:47"),
+            ),
+            (
+                r"""a30330a6 (jwortmann 2020-06-18 15:29:31 +0200 55)                 for file in window_diagnostics:""",
+                ("a30330a6", "jwortmann", "2020-06-18", "15:29:31"),
+            ),
+        ]
+        for cli_output_line, expected_result in samples:
+            self.assertassertEqual(
+                blame.Blame.parse_line(cli_output_line),
+                expected_result,
+            )
+
+    def test_blame_all_parsing(self):
+        samples = [
+            (
+                r"""4a3eb02f plugin/diagnostics.py (Tom van Ommeren  2019-11-27 21:42:13 +0100   1) import html""",
+                {
+                    "author": "Tom van Ommeren",
+                    "date": "2019-11-27",
+                    "file": "plugin/diagnostics.py",
+                    "line_number": "1",
+                    "sha": "4a3eb02f",
+                    "time": "21:42:13",
+                    "timezone": "+0100",
+                },
+            ),
+            (
+                r"""c937eff9 plugin/diagnostics.py (Duncan Holm      2020-04-11 14:29:47 +0100 114)         spaced_message = re.sub(r'(\S)\n(\S)', r'\1 \2', diagnostic.message)""",
+                {
+                    "author": "Duncan Holm",
+                    "date": "2020-04-11",
+                    "file": "plugin/diagnostics.py",
+                    "line_number": "114",
+                    "sha": "c937eff9",
+                    "time": "14:29:47",
+                    "timezone": "+0100",
+                },
+            ),
+            (
+                r"""16a30de1 plugin/diagnostics.py (jwortmann        2020-05-18 08:34:46 +0200 272)                 if settings.diagnostics_gutter_marker == "sign":""",
+                {
+                    "author": "jwortmann",
+                    "date": "2020-05-18",
+                    "file": "plugin/diagnostics.py",
+                    "line_number": "272",
+                    "sha": "16a30de1",
+                    "time": "08:34:46",
+                    "timezone": "+0200",
+                },
+            ),
+        ]
+        for cli_output_line, expected_result in samples:
+            self.assertEqual(
+                blame_all.BlameShowAll.parse_line(cli_output_line),
+                expected_result,
+            )

From 9bb22541f35dd00f011d2a2178cc33529f3a7939 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Feb 2021 20:09:44 +0000
Subject: [PATCH 103/180] Keep mypy happy

---
 src/blame_all.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 63ba002..350dc8e 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -27,7 +27,7 @@ def run(self, edit):
             return
 
         self.view.erase_phantoms(PHANTOM_KEY_ALL)
-        phantoms = []
+        phantoms = []  # type: list[sublime.Phantom]
 
         # If they are currently shown, toggle them off and return.
         if self.view.settings().get(SETTING_PHANTOM_ALL_DISPLAYED, False):

From caa781d29af4fc63295186507adbd795377d0e47 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 20 Feb 2021 20:24:11 +0000
Subject: [PATCH 104/180] Remove unnecessary regex compilation; Fix typo in
 test code; Add todo

---
 src/blame_all.py      | 6 ++++--
 tests/test_parsing.py | 2 +-
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 350dc8e..7b119f4 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -98,6 +98,7 @@ def get_blame(self, path):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
+    # @todo Consolidate the CLI parsing regex into a single string (use r"""(?x)""") and use it in Blame as well as BlameShowAll
     @classmethod
     def parse_line(cls, blame_line):
         p_sha = r"(?P<sha>\^?\w+)"
@@ -109,7 +110,7 @@ def parse_line(cls, blame_line):
         p_line = r"(?P<line_number>\d+)"
         s = r"\s+"
 
-        regex = re.compile(
+        pattern = (
             r"^"
             + p_sha
             + s
@@ -127,7 +128,8 @@ def parse_line(cls, blame_line):
             + r"\) "
         )
 
-        m = regex.match(blame_line)
+        # Regexes compiled by re's module-level functions are cached, so we needn't do it ourselves.
+        m = re.match(pattern, blame_line)
         return m.groupdict() if m else {}
 
     def get_line_point(self, line):
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index ca12500..6a9379e 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -29,7 +29,7 @@ def test_blame_parsing(self):
             ),
         ]
         for cli_output_line, expected_result in samples:
-            self.assertassertEqual(
+            self.assertEqual(
                 blame.Blame.parse_line(cli_output_line),
                 expected_result,
             )

From ccbb17ee10f707cb9e09ead9be45850dfe2247a9 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 21 Feb 2021 09:36:50 +0000
Subject: [PATCH 105/180] Failing test to show existing shortcoming

---
 tests/test_parsing.py | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index 6a9379e..51ccb05 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -16,8 +16,12 @@ def test_blame_parsing(self):
         samples = [
             (
                 r"""27399a5b (Tom van Ommeren 2017-10-12 22:53:05 +0200 26)     DiagnosticSeverity.Warning: 'markup.changed.lsp sublimelinter.mark.warning markup.warning.lsp',""",
-                # EXISTING INCORRECT PARSING. Issue #29
-                ("27399a5b", "Tom van", "Ommeren", "2017-10-12"),
+                (
+                    "27399a5b",
+                    "Tom van Ommeren",
+                    "2017-10-12",
+                    "22:53:05",
+                ),  # Failing. Issue #29
             ),
             (
                 r"""c937eff9 (Duncan Holm 2020-04-11 14:29:47 +0100 3) import re""",

From 3740b012b719c9e67746ca77ba4bcacdc6492f66 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 21 Feb 2021 20:42:19 +0000
Subject: [PATCH 106/180] Consolidate regex pattern

---
 src/blame_all.py      | 38 ++++----------------------------------
 src/parse.py          | 21 +++++++++++++++++++++
 tests/test_parsing.py |  3 ++-
 3 files changed, 27 insertions(+), 35 deletions(-)
 create mode 100644 src/parse.py

diff --git a/src/blame_all.py b/src/blame_all.py
index 7b119f4..2b242c3 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -1,10 +1,10 @@
 import os
-import re
 import subprocess
 
 import sublime
 import sublime_plugin
 
+from .parse import parse_blame_cli_output_line
 from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
@@ -41,7 +41,9 @@ def run(self, edit):
             communicate_error(e)
             return
 
-        blames = [self.parse_line(line) for line in blame_output.splitlines()]
+        blames = [
+            parse_blame_cli_output_line(line) for line in blame_output.splitlines()
+        ]
         blames = [b for b in blames if b]
         if not blames:
             communicate_error(
@@ -99,38 +101,6 @@ def get_blame(self, path):
         ).decode("utf-8")
 
     # @todo Consolidate the CLI parsing regex into a single string (use r"""(?x)""") and use it in Blame as well as BlameShowAll
-    @classmethod
-    def parse_line(cls, blame_line):
-        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})"  # noqa: FS003
-        p_time = r"(?P<time>\d{2}:\d{2}:\d{2})"  # noqa: FS003
-        p_timezone = r"(?P<timezone>[\+-]\d+)"
-        p_line = r"(?P<line_number>\d+)"
-        s = r"\s+"
-
-        pattern = (
-            r"^"
-            + p_sha
-            + s
-            + p_file
-            + r"\("
-            + p_author
-            + s
-            + p_date
-            + s
-            + p_time
-            + s
-            + p_timezone
-            + s
-            + p_line
-            + r"\) "
-        )
-
-        # Regexes compiled by re's module-level functions are cached, so we needn't do it ourselves.
-        m = re.match(pattern, blame_line)
-        return m.groupdict() if m else {}
 
     def get_line_point(self, line):
         """Get the point of specified line in a view."""
diff --git a/src/parse.py b/src/parse.py
new file mode 100644
index 0000000..665e412
--- /dev/null
+++ b/src/parse.py
@@ -0,0 +1,21 @@
+import re
+
+_GIT_BLAME_CLI_OUTPUT_REGEX = re.compile(
+    r"""(?x)
+    ^   (?P<sha>\^?\w+)
+    \s+ (?P<file>[\S ]+)
+    \s+
+    \(  (?P<author>.+?)
+    \s+ (?P<date>\d{4}-\d{2}-\d{2})
+    \s+ (?P<time>\d{2}:\d{2}:\d{2})
+    \s+ (?P<timezone>[\+-]\d+)
+    \s+ (?P<line_number>\d+)
+    \)
+    \s
+    """
+)
+
+
+def parse_blame_cli_output_line(line):
+    m = _GIT_BLAME_CLI_OUTPUT_REGEX.match(line)
+    return m.groupdict() if m else {}
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index 51ccb05..b67115c 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -6,6 +6,7 @@
 # This strange form of import is required because our ST package name has a space in it.
 blame = importlib.import_module("Git blame.src.blame")
 blame_all = importlib.import_module("Git blame.src.blame_all")
+parse = importlib.import_module("Git blame.src.parse")
 
 # NOTE: The sample git-blame CLI outputs were taken from the https://github.com/sublimelsp/LSP
 # repo because they exhibit different numbers of components in author names.
@@ -79,6 +80,6 @@ def test_blame_all_parsing(self):
         ]
         for cli_output_line, expected_result in samples:
             self.assertEqual(
-                blame_all.BlameShowAll.parse_line(cli_output_line),
+                parse.parse_blame_cli_output_line(cli_output_line),
                 expected_result,
             )

From c4e9dddf20ce2ec5a23ace84e8065c4e58bf14cb Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 21 Feb 2021 21:05:39 +0000
Subject: [PATCH 107/180] Use the better parsing from BlameAll in Blame too

Fixes #62
Fixes #29
---
 src/blame.py                 | 42 +++++++++++++++++++-----------------
 src/blame_all.py             |  6 ++----
 src/{parse.py => parsing.py} |  0
 tests/test_parsing.py        | 32 +++------------------------
 4 files changed, 27 insertions(+), 53 deletions(-)
 rename src/{parse.py => parsing.py} (100%)

diff --git a/src/blame.py b/src/blame.py
index 206f1ec..601465d 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -5,6 +5,7 @@
 import sublime
 import sublime_plugin
 
+from .parsing import parse_blame_cli_output_line
 from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_phantom_css, blame_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
@@ -59,7 +60,19 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                 communicate_error(e)
                 return
 
-            sha, user, date, time = self.parse_line(blame_output)
+            blame = parse_blame_cli_output_line(blame_output)
+            if not blame:
+                communicate_error(
+                    "Failed to parse anything for {0}. Has git's output format changed?".format(
+                        self.__class__.__name__
+                    )
+                )
+                return
+            sha = blame["sha"]
+            user = blame["author"]
+            date = blame["date"]
+            time = blame["time"]
+
             # The SHA output by `git blame` may have a leading caret to indicate that it
             # is a "boundary commit". That needs to be stripped before passing the SHA
             # back to git CLI commands for other purposes.
@@ -105,7 +118,14 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
     # ------------------------------------------------------------
 
     def get_blame(self, line, path, sha_skip_list):
-        cmd_line = ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line)]
+        cmd_line = [
+            "git",
+            "blame",
+            "--show-name",
+            "--minimal",
+            "-w",
+            "-L {0},{0}".format(line),
+        ]
         for skipped_sha in sha_skip_list:
             cmd_line.extend(["--ignore-rev", skipped_sha])
         cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
@@ -118,24 +138,6 @@ def get_blame(self, line, path, sha_skip_list):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
-    @classmethod
-    def parse_line(cls, blame_line):
-        sha, file_path, user, date, time, tz_offset, *_ = blame_line.split()
-
-        # Was part of the inital commit so no updates
-        if file_path[0] == "(":
-            user, date, time, tz_offset = file_path, user, date, time
-            file_path = None
-
-        # Fix an issue where the username has a space
-        # Im going to need to do something better though if people
-        # start to have multiple spaces in their names.
-        if not date[0].isdigit():
-            user = "{0} {1}".format(user, date)
-            date, time = time, tz_offset
-
-        return (sha, user[1:], date, time)
-
     def get_commit(self, sha, path):
         return subprocess.check_output(
             ["git", "show", "--no-color", sha],
diff --git a/src/blame_all.py b/src/blame_all.py
index 2b242c3..7f14fa9 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -4,7 +4,7 @@
 import sublime
 import sublime_plugin
 
-from .parse import parse_blame_cli_output_line
+from .parsing import parse_blame_cli_output_line
 from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 from .util import communicate_error, platform_startupinfo, view_is_suitable
@@ -88,7 +88,7 @@ def on_phantom_close(self, href):
     # ------------------------------------------------------------
 
     def get_blame(self, path):
-        # The option --show-name is necessary to force file name display.
+        # The option --show-name is necessary to force file name display even when this file has never been renamed.
         cmd_line = ["git", "blame", "--show-name", "--minimal", "-w"]
         cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
         cmd_line.extend(["--", os.path.basename(path)])
@@ -100,8 +100,6 @@ def get_blame(self, path):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
-    # @todo Consolidate the CLI parsing regex into a single string (use r"""(?x)""") and use it in Blame as well as BlameShowAll
-
     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))
diff --git a/src/parse.py b/src/parsing.py
similarity index 100%
rename from src/parse.py
rename to src/parsing.py
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index b67115c..5341aab 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -6,40 +6,14 @@
 # This strange form of import is required because our ST package name has a space in it.
 blame = importlib.import_module("Git blame.src.blame")
 blame_all = importlib.import_module("Git blame.src.blame_all")
-parse = importlib.import_module("Git blame.src.parse")
+parsing = importlib.import_module("Git blame.src.parsing")
 
 # NOTE: The sample git-blame CLI outputs were taken from the https://github.com/sublimelsp/LSP
 # repo because they exhibit different numbers of components in author names.
 
 
 class TestParsing(unittest.TestCase):
-    def test_blame_parsing(self):
-        samples = [
-            (
-                r"""27399a5b (Tom van Ommeren 2017-10-12 22:53:05 +0200 26)     DiagnosticSeverity.Warning: 'markup.changed.lsp sublimelinter.mark.warning markup.warning.lsp',""",
-                (
-                    "27399a5b",
-                    "Tom van Ommeren",
-                    "2017-10-12",
-                    "22:53:05",
-                ),  # Failing. Issue #29
-            ),
-            (
-                r"""c937eff9 (Duncan Holm 2020-04-11 14:29:47 +0100 3) import re""",
-                ("c937eff9", "Duncan Holm", "2020-04-11", "14:29:47"),
-            ),
-            (
-                r"""a30330a6 (jwortmann 2020-06-18 15:29:31 +0200 55)                 for file in window_diagnostics:""",
-                ("a30330a6", "jwortmann", "2020-06-18", "15:29:31"),
-            ),
-        ]
-        for cli_output_line, expected_result in samples:
-            self.assertEqual(
-                blame.Blame.parse_line(cli_output_line),
-                expected_result,
-            )
-
-    def test_blame_all_parsing(self):
+    def test_git_blame_cli_output_parsing(self):
         samples = [
             (
                 r"""4a3eb02f plugin/diagnostics.py (Tom van Ommeren  2019-11-27 21:42:13 +0100   1) import html""",
@@ -80,6 +54,6 @@ def test_blame_all_parsing(self):
         ]
         for cli_output_line, expected_result in samples:
             self.assertEqual(
-                parse.parse_blame_cli_output_line(cli_output_line),
+                parsing.parse_blame_cli_output_line(cli_output_line),
                 expected_result,
             )

From 5495aa70d18d67975180d8a89b25a495cd2bf85b Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 21 Feb 2021 21:20:42 +0000
Subject: [PATCH 108/180] Centralise the fixed git cli args

---
 src/blame.py     | 17 ++++++++---------
 src/blame_all.py | 10 +++++++---
 src/util.py      |  3 +++
 3 files changed, 18 insertions(+), 12 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 601465d..de2878e 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -8,7 +8,12 @@
 from .parsing import parse_blame_cli_output_line
 from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_phantom_css, blame_phantom_html_template
-from .util import communicate_error, platform_startupinfo, view_is_suitable
+from .util import (
+    CLI_COMMAND_INITIAL_ARGS,
+    communicate_error,
+    platform_startupinfo,
+    view_is_suitable,
+)
 
 # @todo Make a command to open the latest diff ("CommitDescription") for the current line in a single keystroke.
 # @body Currently it takes a keystroke and then a mouse click on "Show"
@@ -118,14 +123,8 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
     # ------------------------------------------------------------
 
     def get_blame(self, line, path, sha_skip_list):
-        cmd_line = [
-            "git",
-            "blame",
-            "--show-name",
-            "--minimal",
-            "-w",
-            "-L {0},{0}".format(line),
-        ]
+        cmd_line = CLI_COMMAND_INITIAL_ARGS.copy()
+        cmd_line.extend(["-L {0},{0}".format(line)])
         for skipped_sha in sha_skip_list:
             cmd_line.extend(["--ignore-rev", skipped_sha])
         cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
diff --git a/src/blame_all.py b/src/blame_all.py
index 7f14fa9..2c93841 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -7,7 +7,12 @@
 from .parsing import parse_blame_cli_output_line
 from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
-from .util import communicate_error, platform_startupinfo, view_is_suitable
+from .util import (
+    CLI_COMMAND_INITIAL_ARGS,
+    communicate_error,
+    platform_startupinfo,
+    view_is_suitable,
+)
 
 PHANTOM_KEY_ALL = "git-blame-all"
 SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
@@ -88,8 +93,7 @@ def on_phantom_close(self, href):
     # ------------------------------------------------------------
 
     def get_blame(self, path):
-        # The option --show-name is necessary to force file name display even when this file has never been renamed.
-        cmd_line = ["git", "blame", "--show-name", "--minimal", "-w"]
+        cmd_line = CLI_COMMAND_INITIAL_ARGS.copy()
         cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
         cmd_line.extend(["--", os.path.basename(path)])
         # print(cmd_line)
diff --git a/src/util.py b/src/util.py
index a73812e..9c213e6 100644
--- a/src/util.py
+++ b/src/util.py
@@ -3,6 +3,9 @@
 
 import sublime
 
+# The option --show-name is necessary to force file name display even when this file has never been renamed.
+CLI_COMMAND_INITIAL_ARGS = ["git", "blame", "--show-name", "--minimal", "-w"]
+
 
 def view_is_suitable(view):
     ok = view.file_name() and not view.is_dirty()

From 93e4157ce7376592084c8ad8401acc427ecdcd77 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 21 Feb 2021 21:22:29 +0000
Subject: [PATCH 109/180] Rename var user -> author

---
 src/blame.py     | 4 ++--
 src/blame_all.py | 2 +-
 src/templates.py | 4 ++--
 3 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index de2878e..f9b4210 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -74,7 +74,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                 )
                 return
             sha = blame["sha"]
-            user = blame["author"]
+            author = blame["author"]
             date = blame["date"]
             time = blame["time"]
 
@@ -98,7 +98,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                         css=blame_phantom_css,
                         sha=sha,
                         sha_not_latest_indicator=" *" if sha_skip_list else "",
-                        user=user,
+                        author=author,
                         date=date,
                         time=time,
                         qs_row_num_val=quote_plus(str(row_num)),
diff --git a/src/blame_all.py b/src/blame_all.py
index 2c93841..3941cce 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -70,7 +70,7 @@ def run(self, edit):
                 blame_all_phantom_html_template.format(
                     css=blame_all_phantom_css,
                     sha=blame["sha"],
-                    user=author + "&nbsp;" * (max_author_len - len(author)),
+                    author=author + "&nbsp;" * (max_author_len - len(author)),
                     date=blame["date"],
                     time=blame["time"],
                 ),
diff --git a/src/templates.py b/src/templates.py
index 728e2f5..6baa284 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -4,7 +4,7 @@
         <div class="phantom-arrow"></div>
         <div class="phantom">
             <span class="message">
-                <strong>Git Blame</strong> ({user})
+                <strong>Git Blame</strong> ({author})
                 {date} {time} |
                 <a href="prev?sha={qs_sha_val}&amp;row_num={qs_row_num_val}&amp;{qs_skip_keyvals}">[Prev]</a>
                 {sha}{sha_not_latest_indicator}
@@ -57,7 +57,7 @@
         <style>{css}</style>
         <div class="phantom">
             <span class="message">
-                {sha}&nbsp;&nbsp;{user}&nbsp;&nbsp;{date}&nbsp;&nbsp;{time}
+                {sha}&nbsp;&nbsp;{author}&nbsp;&nbsp;{date}&nbsp;&nbsp;{time}
                 <a class="close" href="close">\u00D7</a>
             </span>
         </div>

From b0a96fcce7a172ed4817964e380b56e072017e49 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 11:25:56 +0000
Subject: [PATCH 110/180] Move duplicated git CLI running code into a
 base-class

---
 src/base_blame.py | 36 ++++++++++++++++++++++++++++++++++++
 src/blame.py      | 46 ++++++++++++----------------------------------
 src/blame_all.py  | 29 ++++++-----------------------
 src/util.py       |  3 ---
 4 files changed, 54 insertions(+), 60 deletions(-)
 create mode 100644 src/base_blame.py

diff --git a/src/base_blame.py b/src/base_blame.py
new file mode 100644
index 0000000..142131e
--- /dev/null
+++ b/src/base_blame.py
@@ -0,0 +1,36 @@
+import os
+import subprocess
+from abc import ABCMeta, abstractmethod
+
+from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
+from .util import platform_startupinfo
+
+
+class BaseBlame(metaclass=ABCMeta):
+    def get_blame(self, path, **kwargs):
+        # The option --show-name is necessary to force file name display even when this file has never been renamed.
+        cmd_line = ["git", "blame", "--show-name", "--minimal", "-w"]
+        cmd_line.extend(self.extra_cli_args(**kwargs))
+        cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
+        cmd_line.extend(["--", os.path.basename(path)])
+        # print(cmd_line)
+        return subprocess.check_output(
+            cmd_line,
+            cwd=os.path.dirname(os.path.realpath(path)),
+            startupinfo=platform_startupinfo(),
+            stderr=subprocess.STDOUT,
+        ).decode("utf-8")
+
+    def get_commit(self, sha, path):
+        return subprocess.check_output(
+            ["git", "show", "--no-color", sha],
+            cwd=os.path.dirname(os.path.realpath(path)),
+            startupinfo=platform_startupinfo(),
+            stderr=subprocess.STDOUT,
+        ).decode("utf-8")
+
+    # ------------------------------------------------------------
+
+    @abstractmethod
+    def extra_cli_args(self, **kwargs):
+        ...
diff --git a/src/blame.py b/src/blame.py
index f9b4210..1ffa6bf 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -1,25 +1,18 @@
-import os
-import subprocess
 from urllib.parse import parse_qs, quote_plus, urlparse
 
 import sublime
 import sublime_plugin
 
+from .base_blame import BaseBlame
 from .parsing import parse_blame_cli_output_line
-from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_phantom_css, blame_phantom_html_template
-from .util import (
-    CLI_COMMAND_INITIAL_ARGS,
-    communicate_error,
-    platform_startupinfo,
-    view_is_suitable,
-)
+from .util import communicate_error, view_is_suitable
 
 # @todo Make a command to open the latest diff ("CommitDescription") for the current line in a single keystroke.
 # @body Currently it takes a keystroke and then a mouse click on "Show"
 
 
-class Blame(sublime_plugin.TextCommand):
+class Blame(BaseBlame, sublime_plugin.TextCommand):
 
     # Overrides --------------------------------------------------
 
@@ -60,7 +53,9 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
             full_path = self.view.file_name()
 
             try:
-                blame_output = self.get_blame(line_num, full_path, sha_skip_list)
+                blame_output = self.get_blame(
+                    full_path, line_num=line_num, sha_skip_list=sha_skip_list
+                )
             except Exception as e:
                 communicate_error(e)
                 return
@@ -120,30 +115,13 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
 
         self.phantom_set.update(phantoms)
 
-    # ------------------------------------------------------------
-
-    def get_blame(self, line, path, sha_skip_list):
-        cmd_line = CLI_COMMAND_INITIAL_ARGS.copy()
-        cmd_line.extend(["-L {0},{0}".format(line)])
+    def extra_cli_args(self, line_num, sha_skip_list):
+        args = ["-L {0},{0}".format(line_num)]
         for skipped_sha in sha_skip_list:
-            cmd_line.extend(["--ignore-rev", skipped_sha])
-        cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
-        cmd_line.extend(["--", os.path.basename(path)])
-        # print(cmd_line)
-        return subprocess.check_output(
-            cmd_line,
-            cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=platform_startupinfo(),
-            stderr=subprocess.STDOUT,
-        ).decode("utf-8")
-
-    def get_commit(self, sha, path):
-        return subprocess.check_output(
-            ["git", "show", "--no-color", sha],
-            cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=platform_startupinfo(),
-            stderr=subprocess.STDOUT,
-        ).decode("utf-8")
+            args.extend(["--ignore-rev", skipped_sha])
+        return args
+
+    # ------------------------------------------------------------
 
     def phantom_exists_for_region(self, region):
         return any(p.region == region for p in self.phantom_set.phantoms)
diff --git a/src/blame_all.py b/src/blame_all.py
index 3941cce..61fe89e 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -1,24 +1,16 @@
-import os
-import subprocess
-
 import sublime
 import sublime_plugin
 
+from .base_blame import BaseBlame
 from .parsing import parse_blame_cli_output_line
-from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
-from .util import (
-    CLI_COMMAND_INITIAL_ARGS,
-    communicate_error,
-    platform_startupinfo,
-    view_is_suitable,
-)
+from .util import communicate_error, view_is_suitable
 
 PHANTOM_KEY_ALL = "git-blame-all"
 SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
 
 
-class BlameShowAll(sublime_plugin.TextCommand):
+class BlameShowAll(BaseBlame, sublime_plugin.TextCommand):
 
     # Overrides --------------------------------------------------
 
@@ -85,6 +77,9 @@ def run(self, edit):
         # @todo BlameAll: Automatically scrolling the view to the left doesn't work when the ST window has >1 Group
         self.view.set_viewport_position((0.0, self.view.viewport_position()[1]))
 
+    def extra_cli_args(self, **kwargs):
+        return []
+
     def on_phantom_close(self, href):
         """Closes opened phantoms."""
         if href == "close":
@@ -92,18 +87,6 @@ def on_phantom_close(self, href):
 
     # ------------------------------------------------------------
 
-    def get_blame(self, path):
-        cmd_line = CLI_COMMAND_INITIAL_ARGS.copy()
-        cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
-        cmd_line.extend(["--", os.path.basename(path)])
-        # print(cmd_line)
-        return subprocess.check_output(
-            cmd_line,
-            cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=platform_startupinfo(),
-            stderr=subprocess.STDOUT,
-        ).decode("utf-8")
-
     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))
diff --git a/src/util.py b/src/util.py
index 9c213e6..a73812e 100644
--- a/src/util.py
+++ b/src/util.py
@@ -3,9 +3,6 @@
 
 import sublime
 
-# The option --show-name is necessary to force file name display even when this file has never been renamed.
-CLI_COMMAND_INITIAL_ARGS = ["git", "blame", "--show-name", "--minimal", "-w"]
-
 
 def view_is_suitable(view):
     ok = view.file_name() and not view.is_dirty()

From e608b4084c69c3f2192f36f133aac128ee757106 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 11:36:08 +0000
Subject: [PATCH 111/180] -L x,y should be two separate arguments

Surprised that passing them as a single string ever worked.
---
 src/blame.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame.py b/src/blame.py
index 1ffa6bf..661a5e2 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -116,7 +116,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
         self.phantom_set.update(phantoms)
 
     def extra_cli_args(self, line_num, sha_skip_list):
-        args = ["-L {0},{0}".format(line_num)]
+        args = ["-L", "{0},{0}".format(line_num)]
         for skipped_sha in sha_skip_list:
             args.extend(["--ignore-rev", skipped_sha])
         return args

From ba5164160733b2accbe7ee710cd53e1bbf096636 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 12:42:12 +0000
Subject: [PATCH 112/180] Factor out the running of the subprocess

---
 src/base_blame.py | 38 +++++++++++++++++++++++---------------
 src/util.py       | 12 ------------
 2 files changed, 23 insertions(+), 27 deletions(-)

diff --git a/src/base_blame.py b/src/base_blame.py
index 142131e..fab321a 100644
--- a/src/base_blame.py
+++ b/src/base_blame.py
@@ -1,33 +1,41 @@
 import os
 import subprocess
+import sys
 from abc import ABCMeta, abstractmethod
 
 from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
-from .util import platform_startupinfo
 
 
 class BaseBlame(metaclass=ABCMeta):
-    def get_blame(self, path, **kwargs):
-        # The option --show-name is necessary to force file name display even when this file has never been renamed.
-        cmd_line = ["git", "blame", "--show-name", "--minimal", "-w"]
-        cmd_line.extend(self.extra_cli_args(**kwargs))
-        cmd_line.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
-        cmd_line.extend(["--", os.path.basename(path)])
+    def run_git(self, view_file_path, cli_args):
+        if sys.platform == "win32":
+            startup_info = subprocess.STARTUPINFO()
+            # Stop a visible console window from appearing.
+            startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+            startup_info.wShowWindow = subprocess.SW_HIDE
+        else:
+            startup_info = None
+
+        cmd_line = ["git"] + cli_args
         # print(cmd_line)
+
         return subprocess.check_output(
             cmd_line,
-            cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=platform_startupinfo(),
+            cwd=os.path.dirname(os.path.realpath(view_file_path)),
+            startupinfo=startup_info,
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
+    def get_blame(self, path, **kwargs):
+        cli_args = ["blame", "--show-name", "--minimal", "-w"]
+        cli_args.extend(self.extra_cli_args(**kwargs))
+        cli_args.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
+        cli_args.extend(["--", os.path.basename(path)])
+        return self.run_git(path, cli_args)
+
     def get_commit(self, sha, path):
-        return subprocess.check_output(
-            ["git", "show", "--no-color", sha],
-            cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=platform_startupinfo(),
-            stderr=subprocess.STDOUT,
-        ).decode("utf-8")
+        cli_args = ["show", "--no-color", sha]
+        return self.run_git(path, cli_args)
 
     # ------------------------------------------------------------
 
diff --git a/src/util.py b/src/util.py
index a73812e..f59f6c7 100644
--- a/src/util.py
+++ b/src/util.py
@@ -1,5 +1,4 @@
 import subprocess
-import sys
 
 import sublime
 
@@ -24,14 +23,3 @@ def communicate_error(e, modal=True):
         # Unlike with the error dialog, a status message is not automatically
         # persisted in the console too.
         print(user_msg)  # noqa: T001
-
-
-def platform_startupinfo():
-    if sys.platform == "win32":
-        si = subprocess.STARTUPINFO()
-        # Stop a visible console window from appearing.
-        si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
-        si.wShowWindow = subprocess.SW_HIDE
-        return si
-    else:
-        return None

From d363f03c0703d2a5c820e523c2f0db9cb2a8db99 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 13:19:38 +0000
Subject: [PATCH 113/180] Eliminate util module

---
 src/base_blame.py | 24 ++++++++++++++++++++++++
 src/blame.py      | 12 ++++++------
 src/blame_all.py  |  8 ++++----
 src/util.py       | 25 -------------------------
 4 files changed, 34 insertions(+), 35 deletions(-)
 delete mode 100644 src/util.py

diff --git a/src/base_blame.py b/src/base_blame.py
index fab321a..1063a89 100644
--- a/src/base_blame.py
+++ b/src/base_blame.py
@@ -3,6 +3,8 @@
 import sys
 from abc import ABCMeta, abstractmethod
 
+import sublime
+
 from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
 
 
@@ -37,6 +39,28 @@ def get_commit(self, sha, path):
         cli_args = ["show", "--no-color", sha]
         return self.run_git(path, cli_args)
 
+    def has_suitable_view(self):
+        return (
+            hasattr(self, "view") and self.view.file_name() and not self.view.is_dirty()  # type: ignore[attr-defined]
+        )
+
+    def tell_user_to_save(self):
+        self.communicate_error("Please save file changes to disk first.")
+
+    def communicate_error(self, e, modal=True):
+        user_msg = "Git blame:\n\n{0}".format(e)
+        if isinstance(e, subprocess.CalledProcessError):
+            user_msg += "\n\n{0}".format(e.output.decode("utf-8"))
+
+        print()  # noqa: T001
+        if modal:
+            sublime.error_message(user_msg)
+        else:
+            sublime.status_message(user_msg)
+            # Unlike with the error dialog, a status message is not automatically
+            # persisted in the console too.
+            print(user_msg)  # noqa: T001
+
     # ------------------------------------------------------------
 
     @abstractmethod
diff --git a/src/blame.py b/src/blame.py
index 661a5e2..b00dc28 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -6,7 +6,6 @@
 from .base_blame import BaseBlame
 from .parsing import parse_blame_cli_output_line
 from .templates import blame_phantom_css, blame_phantom_html_template
-from .util import communicate_error, view_is_suitable
 
 # @todo Make a command to open the latest diff ("CommitDescription") for the current line in a single keystroke.
 # @body Currently it takes a keystroke and then a mouse click on "Show"
@@ -21,7 +20,8 @@ def __init__(self, view):
         self.phantom_set = sublime.PhantomSet(view, "git-blame")
 
     def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
-        if not view_is_suitable(self.view):
+        if not self.has_suitable_view():
+            self.tell_user_to_save()
             return
 
         phantoms = []
@@ -57,12 +57,12 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                     full_path, line_num=line_num, sha_skip_list=sha_skip_list
                 )
             except Exception as e:
-                communicate_error(e)
+                self.communicate_error(e)
                 return
 
             blame = parse_blame_cli_output_line(blame_output)
             if not blame:
-                communicate_error(
+                self.communicate_error(
                     "Failed to parse anything for {0}. Has git's output format changed?".format(
                         self.__class__.__name__
                     )
@@ -140,7 +140,7 @@ def handle_phantom_button(self, href):
             try:
                 desc = self.get_commit(sha, self.view.file_name())
             except Exception as e:
-                communicate_error(e)
+                self.communicate_error(e)
                 return
 
             buf = self.view.window().new_file()
@@ -164,7 +164,7 @@ def handle_phantom_button(self, href):
             # Erase all phantoms
             self.phantom_set.update([])
         else:
-            communicate_error(
+            self.communicate_error(
                 "No handler for URL path '{0}' in phantom".format(url.path)
             )
 
diff --git a/src/blame_all.py b/src/blame_all.py
index 61fe89e..6b147d1 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -4,7 +4,6 @@
 from .base_blame import BaseBlame
 from .parsing import parse_blame_cli_output_line
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
-from .util import communicate_error, view_is_suitable
 
 PHANTOM_KEY_ALL = "git-blame-all"
 SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
@@ -20,7 +19,8 @@ def __init__(self, view):
         self.pattern = None
 
     def run(self, edit):
-        if not view_is_suitable(self.view):
+        if not self.has_suitable_view():
+            self.tell_user_to_save()
             return
 
         self.view.erase_phantoms(PHANTOM_KEY_ALL)
@@ -35,7 +35,7 @@ def run(self, edit):
         try:
             blame_output = self.get_blame(self.view.file_name())
         except Exception as e:
-            communicate_error(e)
+            self.communicate_error(e)
             return
 
         blames = [
@@ -43,7 +43,7 @@ def run(self, edit):
         ]
         blames = [b for b in blames if b]
         if not blames:
-            communicate_error(
+            self.communicate_error(
                 "Failed to parse anything for {0}. Has git's output format changed?".format(
                     self.__class__.__name__
                 )
diff --git a/src/util.py b/src/util.py
deleted file mode 100644
index f59f6c7..0000000
--- a/src/util.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import subprocess
-
-import sublime
-
-
-def view_is_suitable(view):
-    ok = view.file_name() and not view.is_dirty()
-    if not ok:
-        communicate_error("Please save file changes to disk first.")
-    return ok
-
-
-def communicate_error(e, modal=True):
-    user_msg = "Git blame:\n\n{0}".format(e)
-    if isinstance(e, subprocess.CalledProcessError):
-        user_msg += "\n\n{0}".format(e.output.decode("utf-8"))
-
-    print()  # noqa: T001
-    if modal:
-        sublime.error_message(user_msg)
-    else:
-        sublime.status_message(user_msg)
-        # Unlike with the error dialog, a status message is not automatically
-        # persisted in the console too.
-        print(user_msg)  # noqa: T001

From 4daca57369e39185b9cbb1eda23ab8f6d4727726 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 13:22:18 +0000
Subject: [PATCH 114/180] Rename on_phantom_close (+ it's not an overridden
 method)

---
 src/blame_all.py | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 6b147d1..826dca3 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -67,7 +67,7 @@ def run(self, edit):
                     time=blame["time"],
                 ),
                 sublime.LAYOUT_INLINE,
-                self.on_phantom_close,
+                self.handle_phantom_button,
             )
             phantoms.append(phantom)
 
@@ -80,13 +80,12 @@ def run(self, edit):
     def extra_cli_args(self, **kwargs):
         return []
 
-    def on_phantom_close(self, href):
-        """Closes opened phantoms."""
+    # ------------------------------------------------------------
+
+    def handle_phantom_button(self, href):
         if href == "close":
             self.view.run_command("blame_erase_all")
 
-    # ------------------------------------------------------------
-
     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))

From 7dd1b688af633cea38afdb63b446af672b5c8784 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 13:24:17 +0000
Subject: [PATCH 115/180] Rename get_blame and get_commit methods

---
 src/base_blame.py | 4 ++--
 src/blame.py      | 4 ++--
 src/blame_all.py  | 2 +-
 3 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/base_blame.py b/src/base_blame.py
index 1063a89..98ef2cd 100644
--- a/src/base_blame.py
+++ b/src/base_blame.py
@@ -28,14 +28,14 @@ def run_git(self, view_file_path, cli_args):
             stderr=subprocess.STDOUT,
         ).decode("utf-8")
 
-    def get_blame(self, path, **kwargs):
+    def get_blame_text(self, path, **kwargs):
         cli_args = ["blame", "--show-name", "--minimal", "-w"]
         cli_args.extend(self.extra_cli_args(**kwargs))
         cli_args.extend(pkg_settings().get(PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, []))
         cli_args.extend(["--", os.path.basename(path)])
         return self.run_git(path, cli_args)
 
-    def get_commit(self, sha, path):
+    def get_commit_text(self, sha, path):
         cli_args = ["show", "--no-color", sha]
         return self.run_git(path, cli_args)
 
diff --git a/src/blame.py b/src/blame.py
index b00dc28..4a4d30c 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -53,7 +53,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
             full_path = self.view.file_name()
 
             try:
-                blame_output = self.get_blame(
+                blame_output = self.get_blame_text(
                     full_path, line_num=line_num, sha_skip_list=sha_skip_list
                 )
             except Exception as e:
@@ -138,7 +138,7 @@ def handle_phantom_button(self, href):
         elif url.path == "show":
             sha = querystring["sha"][0]
             try:
-                desc = self.get_commit(sha, self.view.file_name())
+                desc = self.get_commit_text(sha, self.view.file_name())
             except Exception as e:
                 self.communicate_error(e)
                 return
diff --git a/src/blame_all.py b/src/blame_all.py
index 826dca3..c10d019 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -33,7 +33,7 @@ def run(self, edit):
             return
 
         try:
-            blame_output = self.get_blame(self.view.file_name())
+            blame_output = self.get_blame_text(self.view.file_name())
         except Exception as e:
             self.communicate_error(e)
             return

From 612fc2755bfd6ffad0198da76a98f8b66e4e6766 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 13:39:37 +0000
Subject: [PATCH 116/180] Move the parsing function into BaseBlame

---
 src/base_blame.py     | 19 +++++++++++++++++++
 src/blame.py          |  3 +--
 src/blame_all.py      |  5 +----
 src/parsing.py        | 21 ---------------------
 tests/test_parsing.py |  6 ++----
 5 files changed, 23 insertions(+), 31 deletions(-)
 delete mode 100644 src/parsing.py

diff --git a/src/base_blame.py b/src/base_blame.py
index 98ef2cd..34f118c 100644
--- a/src/base_blame.py
+++ b/src/base_blame.py
@@ -1,4 +1,5 @@
 import os
+import re
 import subprocess
 import sys
 from abc import ABCMeta, abstractmethod
@@ -39,6 +40,24 @@ def get_commit_text(self, sha, path):
         cli_args = ["show", "--no-color", sha]
         return self.run_git(path, cli_args)
 
+    @classmethod
+    def parse_line(cls, line):
+        pattern = r"""(?x)
+            ^   (?P<sha>\^?\w+)
+            \s+ (?P<file>[\S ]+)
+            \s+
+            \(  (?P<author>.+?)
+            \s+ (?P<date>\d{4}-\d{2}-\d{2})
+            \s+ (?P<time>\d{2}:\d{2}:\d{2})
+            \s+ (?P<timezone>[\+-]\d+)
+            \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 has_suitable_view(self):
         return (
             hasattr(self, "view") and self.view.file_name() and not self.view.is_dirty()  # type: ignore[attr-defined]
diff --git a/src/blame.py b/src/blame.py
index 4a4d30c..3c7dc0d 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -4,7 +4,6 @@
 import sublime_plugin
 
 from .base_blame import BaseBlame
-from .parsing import parse_blame_cli_output_line
 from .templates import blame_phantom_css, blame_phantom_html_template
 
 # @todo Make a command to open the latest diff ("CommitDescription") for the current line in a single keystroke.
@@ -60,7 +59,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                 self.communicate_error(e)
                 return
 
-            blame = parse_blame_cli_output_line(blame_output)
+            blame = self.parse_line(blame_output)
             if not blame:
                 self.communicate_error(
                     "Failed to parse anything for {0}. Has git's output format changed?".format(
diff --git a/src/blame_all.py b/src/blame_all.py
index c10d019..fb34974 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -2,7 +2,6 @@
 import sublime_plugin
 
 from .base_blame import BaseBlame
-from .parsing import parse_blame_cli_output_line
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 
 PHANTOM_KEY_ALL = "git-blame-all"
@@ -38,9 +37,7 @@ def run(self, edit):
             self.communicate_error(e)
             return
 
-        blames = [
-            parse_blame_cli_output_line(line) for line in blame_output.splitlines()
-        ]
+        blames = [self.parse_line(line) for line in blame_output.splitlines()]
         blames = [b for b in blames if b]
         if not blames:
             self.communicate_error(
diff --git a/src/parsing.py b/src/parsing.py
deleted file mode 100644
index 665e412..0000000
--- a/src/parsing.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import re
-
-_GIT_BLAME_CLI_OUTPUT_REGEX = re.compile(
-    r"""(?x)
-    ^   (?P<sha>\^?\w+)
-    \s+ (?P<file>[\S ]+)
-    \s+
-    \(  (?P<author>.+?)
-    \s+ (?P<date>\d{4}-\d{2}-\d{2})
-    \s+ (?P<time>\d{2}:\d{2}:\d{2})
-    \s+ (?P<timezone>[\+-]\d+)
-    \s+ (?P<line_number>\d+)
-    \)
-    \s
-    """
-)
-
-
-def parse_blame_cli_output_line(line):
-    m = _GIT_BLAME_CLI_OUTPUT_REGEX.match(line)
-    return m.groupdict() if m else {}
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index 5341aab..e6ef7ff 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -4,9 +4,7 @@
 # REF: https://github.com/SublimeText/UnitTesting/blob/master/README.md
 
 # This strange form of import is required because our ST package name has a space in it.
-blame = importlib.import_module("Git blame.src.blame")
-blame_all = importlib.import_module("Git blame.src.blame_all")
-parsing = importlib.import_module("Git blame.src.parsing")
+base_blame = importlib.import_module("Git blame.src.base_blame")
 
 # NOTE: The sample git-blame CLI outputs were taken from the https://github.com/sublimelsp/LSP
 # repo because they exhibit different numbers of components in author names.
@@ -54,6 +52,6 @@ def test_git_blame_cli_output_parsing(self):
         ]
         for cli_output_line, expected_result in samples:
             self.assertEqual(
-                parsing.parse_blame_cli_output_line(cli_output_line),
+                base_blame.BaseBlame.parse_line(cli_output_line),  # type: ignore [attr-defined]
                 expected_result,
             )

From 52e643b1e329bff22349a448abb964fec818ec86 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 13:48:27 +0000
Subject: [PATCH 117/180] Add todo

---
 src/blame_all.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/blame_all.py b/src/blame_all.py
index fb34974..3aa6123 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -7,6 +7,9 @@
 PHANTOM_KEY_ALL = "git-blame-all"
 SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
 
+# @todo Disable view rulers while BlameAll phantoms are visible
+# @body Because they don't make sense while a big blob of phantoms are horizontally offsetting the user's code.
+
 
 class BlameShowAll(BaseBlame, sublime_plugin.TextCommand):
 

From 8d4546413ccc20cf279ae0856d7d2c5dfe591921 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 14:33:09 +0000
Subject: [PATCH 118/180] Rename file

---
 src/{base_blame.py => base.py} | 0
 src/blame.py                   | 2 +-
 src/blame_all.py               | 2 +-
 tests/test_parsing.py          | 4 ++--
 4 files changed, 4 insertions(+), 4 deletions(-)
 rename src/{base_blame.py => base.py} (100%)

diff --git a/src/base_blame.py b/src/base.py
similarity index 100%
rename from src/base_blame.py
rename to src/base.py
diff --git a/src/blame.py b/src/blame.py
index 3c7dc0d..a40e6b2 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -3,7 +3,7 @@
 import sublime
 import sublime_plugin
 
-from .base_blame import BaseBlame
+from .base import BaseBlame
 from .templates import blame_phantom_css, blame_phantom_html_template
 
 # @todo Make a command to open the latest diff ("CommitDescription") for the current line in a single keystroke.
diff --git a/src/blame_all.py b/src/blame_all.py
index 3aa6123..916351e 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -1,7 +1,7 @@
 import sublime
 import sublime_plugin
 
-from .base_blame import BaseBlame
+from .base import BaseBlame
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 
 PHANTOM_KEY_ALL = "git-blame-all"
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index e6ef7ff..b6fd31c 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -4,7 +4,7 @@
 # REF: https://github.com/SublimeText/UnitTesting/blob/master/README.md
 
 # This strange form of import is required because our ST package name has a space in it.
-base_blame = importlib.import_module("Git blame.src.base_blame")
+base = importlib.import_module("Git blame.src.base")
 
 # NOTE: The sample git-blame CLI outputs were taken from the https://github.com/sublimelsp/LSP
 # repo because they exhibit different numbers of components in author names.
@@ -52,6 +52,6 @@ def test_git_blame_cli_output_parsing(self):
         ]
         for cli_output_line, expected_result in samples:
             self.assertEqual(
-                base_blame.BaseBlame.parse_line(cli_output_line),  # type: ignore [attr-defined]
+                base.BaseBlame.parse_line(cli_output_line),  # type: ignore [attr-defined]
                 expected_result,
             )

From 10d38865329ef43ddf29b4a3b35d637346d00e48 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 22 Feb 2021 19:47:11 +0000
Subject: [PATCH 119/180] Use new .flake8 file template

---
 .flake8 | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/.flake8 b/.flake8
index 5cbb6bd..8ae773e 100644
--- a/.flake8
+++ b/.flake8
@@ -1,8 +1,7 @@
-# REF: https://flake8.pycqa.org/en/latest/user/options.html
+# REF: https://flake8.pycqa.org/en/latest/user/configuration.html
+# NOTE: flake8 merges config (commandline > project-lvl-cfg > user-lvl-cfg > defaults)
 
 [flake8]
-#
-# FS002 - '.format' used
-#
+# FS002: '.format' used
+#   Shouldn't complain about str#format use, because f-strings won't be available until if/when the package goes ST4 exclusive.
 extend-ignore = FS002
-# @todo Once on ST4 and using Python 3.8 for this packge, stop ignoring flake8 FS002

From 3c600d9b28b8bd8564d436841a7e19705a6a8620 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 23 Feb 2021 10:54:14 +0000
Subject: [PATCH 120/180] Delete todo

---
 .mypy.ini | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/.mypy.ini b/.mypy.ini
index 5236eed..26c4f97 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -1,7 +1,5 @@
 # REF: https://mypy.readthedocs.io/en/latest/config_file.html
-
-# @todo Delete the in-package mypy.ini file once I'm using Python 3.8 in this package.
-#  For Python 3.8 code, I rely on my single global ~/.mypy.ini
+# NOTE: mypy does not merge config
 
 # The Python version (3.3) used in ST3 doesn't support the type hints syntax.
 [mypy]

From cc831d59ed62eda42b499ee76b0e9fee80e21994 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 23 Feb 2021 22:00:15 +0000
Subject: [PATCH 121/180] Add .envrc

---
 .envrc | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .envrc

diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..588d1d2
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,5 @@
+# REF: https://direnv.net/man/direnv.1.html
+# REF: https://direnv.net/man/direnv-stdlib.1.html
+
+# https://github.com/python/mypy/issues/3377
+export NEAREST_DOT_MYPY_INI="$(find_up .mypy.ini)"

From 80d1ce238c687473cf5d28d7ef7a3e30e46ae97e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 24 Feb 2021 02:48:38 +0000
Subject: [PATCH 122/180] Delete unnecessary use of .envrc

---
 .envrc | 5 -----
 1 file changed, 5 deletions(-)
 delete mode 100644 .envrc

diff --git a/.envrc b/.envrc
deleted file mode 100644
index 588d1d2..0000000
--- a/.envrc
+++ /dev/null
@@ -1,5 +0,0 @@
-# REF: https://direnv.net/man/direnv.1.html
-# REF: https://direnv.net/man/direnv-stdlib.1.html
-
-# https://github.com/python/mypy/issues/3377
-export NEAREST_DOT_MYPY_INI="$(find_up .mypy.ini)"

From b38ef843fd3498540911da961ad498c2126e6b61 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 27 Feb 2021 13:58:19 +0000
Subject: [PATCH 123/180] Rename var

---
 src/blame_all.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 916351e..195280b 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -5,7 +5,7 @@
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 
 PHANTOM_KEY_ALL = "git-blame-all"
-SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
+VIEW_SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
 
 # @todo Disable view rulers while BlameAll phantoms are visible
 # @body Because they don't make sense while a big blob of phantoms are horizontally offsetting the user's code.
@@ -29,9 +29,9 @@ def run(self, edit):
         phantoms = []  # type: list[sublime.Phantom]
 
         # If they are currently shown, toggle them off and return.
-        if self.view.settings().get(SETTING_PHANTOM_ALL_DISPLAYED, False):
+        if self.view.settings().get(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False):
             self.phantom_set.update(phantoms)
-            self.view.settings().set(SETTING_PHANTOM_ALL_DISPLAYED, False)
+            self.view.settings().set(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False)
             return
 
         try:
@@ -72,7 +72,7 @@ def run(self, edit):
             phantoms.append(phantom)
 
         self.phantom_set.update(phantoms)
-        self.view.settings().set(SETTING_PHANTOM_ALL_DISPLAYED, True)
+        self.view.settings().set(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, True)
         # Bring the phantoms into view without the user needing to manually scroll left.
         # @todo BlameAll: Automatically scrolling the view to the left doesn't work when the ST window has >1 Group
         self.view.set_viewport_position((0.0, self.view.viewport_position()[1]))
@@ -108,9 +108,9 @@ class BlameEraseAllListener(sublime_plugin.ViewEventListener):
     @classmethod
     def is_applicable(cls, settings):
         """Checks if the blame_erase_all command is applicable."""
-        return settings.get(SETTING_PHANTOM_ALL_DISPLAYED, False)
+        return settings.get(VIEW_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)
+        self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)

From b8fd48fc85b5936340c8f8dbc53a307241bed548 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 28 Feb 2021 17:26:24 +0000
Subject: [PATCH 124/180] Store and restore view's rulers setting when BlameAll
 is activated and deactivated

Fixes #63
---
 src/blame_all.py | 25 +++++++++++++++++++++++++
 1 file changed, 25 insertions(+)

diff --git a/src/blame_all.py b/src/blame_all.py
index 195280b..b40367a 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -5,8 +5,12 @@
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 
 PHANTOM_KEY_ALL = "git-blame-all"
+
 VIEW_SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
 
+VIEW_SETTING_RULERS = "rulers"
+VIEW_SETTING_RULERS_PREV = "rulers_prev"
+
 # @todo Disable view rulers while BlameAll phantoms are visible
 # @body Because they don't make sense while a big blob of phantoms are horizontally offsetting the user's code.
 
@@ -32,6 +36,7 @@ def run(self, edit):
         if self.view.settings().get(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False):
             self.phantom_set.update(phantoms)
             self.view.settings().set(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False)
+            self.view.run_command("blame_restore_rulers")
             return
 
         try:
@@ -73,6 +78,7 @@ def run(self, edit):
 
         self.phantom_set.update(phantoms)
         self.view.settings().set(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, True)
+        self.store_rulers()
         # Bring the phantoms into view without the user needing to manually scroll left.
         # @todo BlameAll: Automatically scrolling the view to the left doesn't work when the ST window has >1 Group
         self.view.set_viewport_position((0.0, self.view.viewport_position()[1]))
@@ -90,6 +96,13 @@ 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))
 
+    def store_rulers(self):
+        self.view.settings().set(
+            VIEW_SETTING_RULERS_PREV,
+            self.view.settings().get(VIEW_SETTING_RULERS),
+        )
+        self.view.settings().set(VIEW_SETTING_RULERS, [])
+
 
 class BlameEraseAll(sublime_plugin.TextCommand):
 
@@ -99,6 +112,7 @@ def run(self, edit):
         """Erases the blame results."""
         sublime.status_message("The git blame result is cleared.")
         self.view.erase_phantoms(PHANTOM_KEY_ALL)
+        self.view.run_command("blame_restore_rulers")
 
 
 class BlameEraseAllListener(sublime_plugin.ViewEventListener):
@@ -114,3 +128,14 @@ def on_modified_async(self):
         """Automatically erases the blame results to prevent mismatches."""
         self.view.run_command("blame_erase_all")
         self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
+
+
+class BlameRestoreRulers(sublime_plugin.TextCommand):
+
+    # Overrides --------------------------------------------------
+
+    def run(self, edit):
+        self.view.settings().set(
+            VIEW_SETTING_RULERS,
+            self.view.settings().get(VIEW_SETTING_RULERS_PREV),
+        )

From 5b0a4734452fe66cc9ac1dc24c3267395f628c4b Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 28 Feb 2021 17:35:58 +0000
Subject: [PATCH 125/180] Fix the BlameEraseAll command not erasing
 VIEW_SETTING_PHANTOM_ALL_DISPLAYED

---
 src/blame_all.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index b40367a..2317c20 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -35,7 +35,7 @@ def run(self, edit):
         # If they are currently shown, toggle them off and return.
         if self.view.settings().get(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False):
             self.phantom_set.update(phantoms)
-            self.view.settings().set(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False)
+            self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
             self.view.run_command("blame_restore_rulers")
             return
 
@@ -112,6 +112,7 @@ def run(self, edit):
         """Erases the blame results."""
         sublime.status_message("The git blame result is cleared.")
         self.view.erase_phantoms(PHANTOM_KEY_ALL)
+        self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
         self.view.run_command("blame_restore_rulers")
 
 
@@ -127,7 +128,6 @@ def is_applicable(cls, settings):
     def on_modified_async(self):
         """Automatically erases the blame results to prevent mismatches."""
         self.view.run_command("blame_erase_all")
-        self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
 
 
 class BlameRestoreRulers(sublime_plugin.TextCommand):

From b73c2476060a186ac60057146de31cb6c2a308d6 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 28 Feb 2021 17:38:51 +0000
Subject: [PATCH 126/180] Fix viewport scrolling flakiness with good old
 set_timeout(... , 0)

Fixes #61
---
 src/blame_all.py | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 2317c20..19a6876 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -80,8 +80,12 @@ def run(self, edit):
         self.view.settings().set(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, True)
         self.store_rulers()
         # Bring the phantoms into view without the user needing to manually scroll left.
-        # @todo BlameAll: Automatically scrolling the view to the left doesn't work when the ST window has >1 Group
-        self.view.set_viewport_position((0.0, self.view.viewport_position()[1]))
+        sublime.set_timeout(
+            lambda: self.view.set_viewport_position(
+                (0.0, self.view.viewport_position()[1])
+            ),
+            0,
+        )
 
     def extra_cli_args(self, **kwargs):
         return []

From bf9033339d3f083433e0be1dc2e2ea87c4ac8047 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 28 Feb 2021 18:15:29 +0000
Subject: [PATCH 127/180] Delete completed todo

---
 src/blame_all.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 19a6876..77c8bd2 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -11,9 +11,6 @@
 VIEW_SETTING_RULERS = "rulers"
 VIEW_SETTING_RULERS_PREV = "rulers_prev"
 
-# @todo Disable view rulers while BlameAll phantoms are visible
-# @body Because they don't make sense while a big blob of phantoms are horizontally offsetting the user's code.
-
 
 class BlameShowAll(BaseBlame, sublime_plugin.TextCommand):
 

From 2e5d07defa0831a4108869317c522f3803b2beaa Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 28 Feb 2021 18:15:40 +0000
Subject: [PATCH 128/180] Add changelog for version 1.3.0

---
 docs/packagecontrol_changelogs/1.3.0.txt | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)
 create mode 100644 docs/packagecontrol_changelogs/1.3.0.txt

diff --git a/docs/packagecontrol_changelogs/1.3.0.txt b/docs/packagecontrol_changelogs/1.3.0.txt
new file mode 100644
index 0000000..1d65788
--- /dev/null
+++ b/docs/packagecontrol_changelogs/1.3.0.txt
@@ -0,0 +1,18 @@
+-> 1.3.0
+
+NEW:
+
+- Commit author names are now shown in full regardless of their length or number
+  of parts.
+
+- A view's "rulers" are now temporarily disabled while `Git Blame: Show All`
+  information is being shown (their position doesn't make sense while your
+  text/code is being horizontally offset).
+
+FIXED:
+
+- Fixed `Git Blame: Erase All` in the command palette causing the next
+  activation of `Git Blame: Show All` to have to be done twice.
+
+- Fixed the viewport sometimes not being automatically scrolled all the way to
+  the left when `Git Blame: Show All` was activated.

From 917eecdf5300b71c48cf7c2d8ebc1a776ab2a6ce Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 1 Mar 2021 08:27:56 +0000
Subject: [PATCH 129/180] Forgot to hook up new changelog in messages.json

---
 messages.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/messages.json b/messages.json
index 2e4ddfa..4eb711d 100644
--- a/messages.json
+++ b/messages.json
@@ -2,5 +2,6 @@
   "1.2.0": "docs/packagecontrol_changelogs/1.2.0.txt",
   "1.2.1": "docs/packagecontrol_changelogs/1.2.1.txt",
   "1.2.2": "docs/packagecontrol_changelogs/1.2.2.txt",
-  "1.2.3": "docs/packagecontrol_changelogs/1.2.3.txt"
+  "1.2.3": "docs/packagecontrol_changelogs/1.2.3.txt",
+  "1.3.0": "docs/packagecontrol_changelogs/1.3.0.txt"
 }

From fb4f794d4e4d585d9dbb748fbbc7e6ab46b920fc Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 1 Mar 2021 11:56:39 +0000
Subject: [PATCH 130/180] Fix supplying incorrect regions for LAYOUT_INLINE
 phantoms

"Display the phantom in between the region and the point following"
https://www.sublimetext.com/docs/api_reference.html#sublime.Phantom

This was only noticed after testing in ST4 beta. Is ST3 more forgiving about interpreting regions given for LAYOUT_INLINE phantoms?
---
 src/blame_all.py | 10 ++++------
 1 file changed, 4 insertions(+), 6 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 77c8bd2..3dc7a36 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -55,12 +55,10 @@ def run(self, edit):
         max_author_len = max(len(b["author"]) for b in blames)
         for blame in blames:
             line_number = int(blame["line_number"])
-            line_point = self.get_line_point(line_number - 1)
-
             author = blame["author"]
 
             phantom = sublime.Phantom(
-                line_point,
+                self.phantom_region(line_number),
                 blame_all_phantom_html_template.format(
                     css=blame_all_phantom_css,
                     sha=blame["sha"],
@@ -93,9 +91,9 @@ def handle_phantom_button(self, href):
         if href == "close":
             self.view.run_command("blame_erase_all")
 
-    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))
+    def phantom_region(self, line_number):
+        line_begins_pt = self.view.text_point(line_number - 1, 0)
+        return sublime.Region(line_begins_pt)
 
     def store_rulers(self):
         self.view.settings().set(

From 930529be1053437d93865a9a15004ae48023c9ab Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 1 Mar 2021 12:06:24 +0000
Subject: [PATCH 131/180] Add changelog for version 1.3.1

Adding it after tagging the release because this one's not worth bothering the user with a message.
---
 docs/packagecontrol_changelogs/1.3.1.txt | 5 +++++
 messages.json                            | 3 ++-
 2 files changed, 7 insertions(+), 1 deletion(-)
 create mode 100644 docs/packagecontrol_changelogs/1.3.1.txt

diff --git a/docs/packagecontrol_changelogs/1.3.1.txt b/docs/packagecontrol_changelogs/1.3.1.txt
new file mode 100644
index 0000000..2785543
--- /dev/null
+++ b/docs/packagecontrol_changelogs/1.3.1.txt
@@ -0,0 +1,5 @@
+-> 1.3.1
+
+FIXED:
+
+- Fixed `Git Blame: Show All` information not displaying correctly in ST4 beta.
diff --git a/messages.json b/messages.json
index 4eb711d..73f848e 100644
--- a/messages.json
+++ b/messages.json
@@ -3,5 +3,6 @@
   "1.2.1": "docs/packagecontrol_changelogs/1.2.1.txt",
   "1.2.2": "docs/packagecontrol_changelogs/1.2.2.txt",
   "1.2.3": "docs/packagecontrol_changelogs/1.2.3.txt",
-  "1.3.0": "docs/packagecontrol_changelogs/1.3.0.txt"
+  "1.3.0": "docs/packagecontrol_changelogs/1.3.0.txt",
+  "1.3.1": "docs/packagecontrol_changelogs/1.3.1.txt"
 }

From 6ad84bdaff141c7debf12786ddfdde5515467d8c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 9 Mar 2021 12:26:30 +0000
Subject: [PATCH 132/180] bytes#decode() decodes uses UTF-8 by default

---
 src/base.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/base.py b/src/base.py
index 34f118c..ef8aa70 100644
--- a/src/base.py
+++ b/src/base.py
@@ -27,7 +27,7 @@ def run_git(self, view_file_path, cli_args):
             cwd=os.path.dirname(os.path.realpath(view_file_path)),
             startupinfo=startup_info,
             stderr=subprocess.STDOUT,
-        ).decode("utf-8")
+        ).decode()
 
     def get_blame_text(self, path, **kwargs):
         cli_args = ["blame", "--show-name", "--minimal", "-w"]
@@ -69,7 +69,7 @@ def tell_user_to_save(self):
     def communicate_error(self, e, modal=True):
         user_msg = "Git blame:\n\n{0}".format(e)
         if isinstance(e, subprocess.CalledProcessError):
-            user_msg += "\n\n{0}".format(e.output.decode("utf-8"))
+            user_msg += "\n\n{0}".format(e.output.decode())
 
         print()  # noqa: T001
         if modal:

From 5bc5d5ed83194895cdf93b57e64e95b1a8fbe69f Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 5 Apr 2021 20:37:02 +0100
Subject: [PATCH 133/180] Comment

---
 Commands/Default.sublime-commands | 4 ++--
 Menus/Main.sublime-menu           | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
index bdba5b7..9e860cc 100644
--- a/Commands/Default.sublime-commands
+++ b/Commands/Default.sublime-commands
@@ -13,7 +13,7 @@
   },
   {
     "caption": "Git Blame: Settings",
-    // ♊️: Entry in "Main.sublime-menu"
+    // TWIN: Entry in "Main.sublime-menu"
     "command": "edit_settings",
     "args": {
       "base_file": "${packages}/Git blame/Settings/Git blame.sublime-settings",
@@ -22,7 +22,7 @@
   },
   {
     "caption": "Git Blame: Key Bindings",
-    // ♊️: Entry in "Main.sublime-menu"
+    // TWIN: Entry in "Main.sublime-menu"
     "command": "edit_settings",
     "args": {
       "base_file": "${packages}/Git blame/Keymaps/Default (${platform}).sublime-keymap",
diff --git a/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
index 58a2b3c..60b5168 100644
--- a/Menus/Main.sublime-menu
+++ b/Menus/Main.sublime-menu
@@ -10,7 +10,7 @@
             "children": [
               {
                 "caption": "Settings",
-                // ♊️: Entry in "Default.sublime-commands"
+                // TWIN: Entry in "Default.sublime-commands"
                 "command": "edit_settings",
                 "args": {
                   "base_file": "${packages}/Git blame/Settings/Git blame.sublime-settings",
@@ -19,7 +19,7 @@
               },
               {
                 "caption": "Key Bindings",
-                // ♊️: Entry in "Default.sublime-commands"
+                // TWIN: Entry in "Default.sublime-commands"
                 "command": "edit_settings",
                 "args": {
                   "base_file": "${packages}/Git blame/Keymaps/Default (${platform}).sublime-keymap",

From 6459bde6a46920dda66c2ee8df34b60159da1ec9 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Wed, 28 Apr 2021 14:46:51 +0100
Subject: [PATCH 134/180] Not just for ST3 anymore

Unfortunately can't rename the actual GitHub repo to remove to "st3-" prefix because that would cause too much hassle
---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 74b1c15..f5e4ed1 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 [![GitHub contributors](https://img.shields.io/github/contributors/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/graphs/contributors)
 [![GitHub issues](https://img.shields.io/github/issues/frou/st3-gitblame.svg)](https://github.com/frou/st3-gitblame/issues)
 
-# Git blame - Package for Sublime Text 3
+# Git blame - Package for Sublime Text
 
 This package enables you to query Git "blame" information for files while you are viewing/editing them in Sublime Text.
 

From 26091b55f9ee2d41011e7e92e77c5b8b276426ac Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 May 2021 13:39:41 +0100
Subject: [PATCH 135/180] Delete project-level mypy config file

---
 .mypy.ini | 13 -------------
 1 file changed, 13 deletions(-)
 delete mode 100644 .mypy.ini

diff --git a/.mypy.ini b/.mypy.ini
deleted file mode 100644
index 26c4f97..0000000
--- a/.mypy.ini
+++ /dev/null
@@ -1,13 +0,0 @@
-# REF: https://mypy.readthedocs.io/en/latest/config_file.html
-# NOTE: mypy does not merge config
-
-# The Python version (3.3) used in ST3 doesn't support the type hints syntax.
-[mypy]
-check_untyped_defs = True
-ignore_missing_imports = True
-
-[mypy-sublime]
-ignore_missing_imports = True
-
-[mypy-sublime_plugin]
-ignore_missing_imports = True

From 8349b588cbb5d4e4f4448de9b686d916c29bf93f Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 9 May 2021 13:57:51 +0100
Subject: [PATCH 136/180] Delete project-level mypy flake8 file

---
 .flake8 | 7 -------
 1 file changed, 7 deletions(-)
 delete mode 100644 .flake8

diff --git a/.flake8 b/.flake8
deleted file mode 100644
index 8ae773e..0000000
--- a/.flake8
+++ /dev/null
@@ -1,7 +0,0 @@
-# REF: https://flake8.pycqa.org/en/latest/user/configuration.html
-# NOTE: flake8 merges config (commandline > project-lvl-cfg > user-lvl-cfg > defaults)
-
-[flake8]
-# FS002: '.format' used
-#   Shouldn't complain about str#format use, because f-strings won't be available until if/when the package goes ST4 exclusive.
-extend-ignore = FS002

From 3d6a0b908df78d0d2847db16b3db3ec1c243fd52 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Thu, 13 May 2021 15:45:28 +0100
Subject: [PATCH 137/180] Replace hasattr(...) weirdness in class BaseBlame
 with more standard approach

---
 src/base.py      | 9 ++++++---
 src/blame.py     | 3 +++
 src/blame_all.py | 3 +++
 3 files changed, 12 insertions(+), 3 deletions(-)

diff --git a/src/base.py b/src/base.py
index ef8aa70..2ffa0f6 100644
--- a/src/base.py
+++ b/src/base.py
@@ -59,9 +59,8 @@ def parse_line(cls, line):
         return m.groupdict() if m else {}
 
     def has_suitable_view(self):
-        return (
-            hasattr(self, "view") and self.view.file_name() and not self.view.is_dirty()  # type: ignore[attr-defined]
-        )
+        view = self._view()
+        return view.file_name() and not view.is_dirty()
 
     def tell_user_to_save(self):
         self.communicate_error("Please save file changes to disk first.")
@@ -82,6 +81,10 @@ def communicate_error(self, e, modal=True):
 
     # ------------------------------------------------------------
 
+    @abstractmethod
+    def _view(self):
+        ...
+
     @abstractmethod
     def extra_cli_args(self, **kwargs):
         ...
diff --git a/src/blame.py b/src/blame.py
index a40e6b2..e1138e9 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -114,6 +114,9 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
 
         self.phantom_set.update(phantoms)
 
+    def _view(self):
+        return self.view
+
     def extra_cli_args(self, line_num, sha_skip_list):
         args = ["-L", "{0},{0}".format(line_num)]
         for skipped_sha in sha_skip_list:
diff --git a/src/blame_all.py b/src/blame_all.py
index 3dc7a36..d631984 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -82,6 +82,9 @@ def run(self, edit):
             0,
         )
 
+    def _view(self):
+        return self.view
+
     def extra_cli_args(self, **kwargs):
         return []
 

From 43887f51a7302c51565905be9bb3c4b0be38f66b Mon Sep 17 00:00:00 2001
From: Ivan Elfimov <ielfimov@gmail.com>
Date: Thu, 3 Jun 2021 08:18:35 +0300
Subject: [PATCH 138/180] Add inline blame

---
 Settings/Git blame.sublime-settings |  4 +-
 boot.py                             |  2 +-
 src/blame_inline.py                 | 64 +++++++++++++++++++++++++++++
 src/templates.py                    | 22 ++++++++++
 4 files changed, 90 insertions(+), 2 deletions(-)
 create mode 100644 src/blame_inline.py

diff --git a/Settings/Git blame.sublime-settings b/Settings/Git blame.sublime-settings
index 5408b97..14bffc8 100644
--- a/Settings/Git blame.sublime-settings	
+++ b/Settings/Git blame.sublime-settings	
@@ -6,5 +6,7 @@
     //     ["-M"]
     //     ["-C", "-C"]
     //
-    "custom_blame_flags": []
+    "custom_blame_flags": [],
+    "inline_blame_enabled": true,
+    "inline_blame_delay": 300
 }
diff --git a/boot.py b/boot.py
index 737749d..9f9877b 100644
--- a/boot.py
+++ b/boot.py
@@ -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
diff --git a/src/blame_inline.py b/src/blame_inline.py
new file mode 100644
index 0000000..ec6fe19
--- /dev/null
+++ b/src/blame_inline.py
@@ -0,0 +1,64 @@
+import threading
+import sublime
+import sublime_plugin
+
+from .base import BaseBlame
+from .templates import blame_inline_phantom_css, blame_inline_phantom_html_template
+
+
+class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
+    def __init__(self, view):
+        super().__init__(view)
+        self.phantom_set = sublime.PhantomSet(view, 'git-blame')
+        self.timer = None
+
+    def extra_cli_args(self, line_num):
+        args = ["-L", "{0},{0}".format(line_num)]
+        return args
+
+    def _view(self):
+        return self.view
+
+    def show_inline_blame(self):
+        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 (full_line is required)
+            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 as e:
+            return
+        blame = next((self.parse_line(line) for line in blame_output.splitlines()), None)
+        if not blame:
+            return
+        body = blame_inline_phantom_html_template.format(
+            css=blame_inline_phantom_css,
+            author=blame['author'],
+            # TODO: add pretty format of the date, like "3 days ago"
+            date=blame['date'],
+            time=blame['time'] + blame['timezone'],
+            # TODO: add first line of the commit here, but
+            #       it requires porcelain format of git blame
+            #       and further refactoring of BaseBlame
+        )
+        phantom = sublime.Phantom(anchor, body, sublime.LAYOUT_INLINE)
+        phantoms.append(phantom)
+        self.phantom_set.update(phantoms)
+
+    def on_selection_modified_async(self):
+        s = self.view.settings()
+        phantom_blame_enabled = s.get('inline_blame_enabled', True)
+        phantom_blame_delay = s.get('inline_blame_delay', 300)
+        if not phantom_blame_enabled:
+            return
+        self.view.erase_phantoms('git-blame')
+        if self.timer:
+            self.timer.cancel()
+        self.timer = threading.Timer(phantom_blame_delay / 1000, self.show_inline_blame)
+        self.timer.start()
diff --git a/src/templates.py b/src/templates.py
index 6baa284..ab3f68c 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -84,3 +84,25 @@
         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;{time}
+            </span>
+        </div>
+    </body>
+"""
+
+
+blame_inline_phantom_css = """
+    div.phantom {
+        color: gray;
+        padding: 0;
+        margin-left: 50px;
+    }
+"""

From 41fadb73630053b99cb84998bf5ed86fc53b280c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Thu, 3 Jun 2021 10:56:21 +0100
Subject: [PATCH 139/180] Use ViewEventListener::is_applicable to prevent
 instantiation when Inline Blame is disabled

---
 src/blame_inline.py | 14 ++++++++------
 src/settings.py     |  3 +++
 2 files changed, 11 insertions(+), 6 deletions(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index ec6fe19..5c7b033 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -4,13 +4,20 @@
 
 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
 
 
 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, 'git-blame')
         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)]
@@ -52,13 +59,8 @@ def show_inline_blame(self):
         self.phantom_set.update(phantoms)
 
     def on_selection_modified_async(self):
-        s = self.view.settings()
-        phantom_blame_enabled = s.get('inline_blame_enabled', True)
-        phantom_blame_delay = s.get('inline_blame_delay', 300)
-        if not phantom_blame_enabled:
-            return
         self.view.erase_phantoms('git-blame')
         if self.timer:
             self.timer.cancel()
-        self.timer = threading.Timer(phantom_blame_delay / 1000, self.show_inline_blame)
+        self.timer = threading.Timer(self.delay_seconds, self.show_inline_blame)
         self.timer.start()
diff --git a/src/settings.py b/src/settings.py
index b1afcf9..ec1c799 100644
--- a/src/settings.py
+++ b/src/settings.py
@@ -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"

From 44e2e71bc625262531b2316b71b6e01a0f8edc8c Mon Sep 17 00:00:00 2001
From: Ivan Elfimov <ielfimov@gmail.com>
Date: Fri, 4 Jun 2021 09:55:32 +0300
Subject: [PATCH 140/180] Reformat with black, add light color scheme support,
 add phantom key constant

---
 src/blame_inline.py | 41 +++++++++++++++++++++++++++++++----------
 src/templates.py    |  6 +++---
 2 files changed, 34 insertions(+), 13 deletions(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index 5c7b033..d3a5e6c 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -4,20 +4,27 @@
 
 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
+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):
 
+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, 'git-blame')
+        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
+        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)]
@@ -26,6 +33,18 @@ def extra_cli_args(self, line_num):
     def _view(self):
         return self.view
 
+    def get_inline_blame_color(self):
+        """
+        This method inverts the current background color and adds alpha, basically doing:
+        #ffffff -> rgba(0, 0, 0, 0.3)
+        The color is than used inside the style template. That is how we make
+        sure inline blame is visible in both dark and light color schemes.
+        """
+        bg = self.view.style().get("background")[1:]
+        colors = [int(bg[i : i + 2], 16) for i in range(0, len(bg), 2)]
+        colors_inverted = [str(255 - i) for i in colors]
+        return "rgba({}, {}, {}, 0.3)".format(*colors_inverted)
+
     def show_inline_blame(self):
         phantoms = []
         sels = self.view.sel()
@@ -41,15 +60,17 @@ def show_inline_blame(self):
             blame_output = self.get_blame_text(self.view.file_name(), line_num=row + 1)
         except Exception as e:
             return
-        blame = next((self.parse_line(line) for line in blame_output.splitlines()), None)
+        blame = next(
+            (self.parse_line(line) for line in blame_output.splitlines()), None
+        )
         if not blame:
             return
         body = blame_inline_phantom_html_template.format(
-            css=blame_inline_phantom_css,
-            author=blame['author'],
+            css=blame_inline_phantom_css.format(color=self.get_inline_blame_color()),
+            author=blame["author"],
             # TODO: add pretty format of the date, like "3 days ago"
-            date=blame['date'],
-            time=blame['time'] + blame['timezone'],
+            date=blame["date"],
+            time=blame["time"] + blame["timezone"],
             # TODO: add first line of the commit here, but
             #       it requires porcelain format of git blame
             #       and further refactoring of BaseBlame
@@ -59,7 +80,7 @@ def show_inline_blame(self):
         self.phantom_set.update(phantoms)
 
     def on_selection_modified_async(self):
-        self.view.erase_phantoms('git-blame')
+        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)
diff --git a/src/templates.py b/src/templates.py
index ab3f68c..032db68 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -100,9 +100,9 @@
 
 
 blame_inline_phantom_css = """
-    div.phantom {
-        color: gray;
+    div.phantom {{
+        color: {color};
         padding: 0;
         margin-left: 50px;
-    }
+    }}
 """

From c5e3ff590b24996ba0418cc63645fbd2d8649f85 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Fri, 4 Jun 2021 15:45:34 +0100
Subject: [PATCH 141/180] Stop inline blame being shown when there are unsaved
 edits

---
 src/blame_inline.py | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index d3a5e6c..5d3ea76 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -46,6 +46,10 @@ def get_inline_blame_color(self):
         return "rgba({}, {}, {}, 0.3)".format(*colors_inverted)
 
     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])
@@ -77,7 +81,13 @@ def show_inline_blame(self):
         )
         phantom = sublime.Phantom(anchor, body, sublime.LAYOUT_INLINE)
         phantoms.append(phantom)
-        self.phantom_set.update(phantoms)
+
+        # 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 on_selection_modified_async(self):
         self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)

From 8702020bd1d6cb89967b2ed0262e8be94665bf05 Mon Sep 17 00:00:00 2001
From: Ivan Elfimov <ielfimov@gmail.com>
Date: Fri, 4 Jun 2021 21:27:05 +0300
Subject: [PATCH 142/180] Add on_post_save_async and re-use show_inline_blame

---
 src/blame_inline.py | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index 5d3ea76..7c84007 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -89,9 +89,17 @@ def maybe_insert_phantoms(self, phantoms):
         if not self.view.is_dirty():
             self.phantom_set.update(phantoms)
 
-    def on_selection_modified_async(self):
+    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()

From 36bf31ba4c3ba852fb30d0c5de898fb090de83b9 Mon Sep 17 00:00:00 2001
From: Ivan Elfimov <ielfimov@gmail.com>
Date: Sat, 5 Jun 2021 00:49:54 +0300
Subject: [PATCH 143/180] Replace color inverter with css variable

---
 src/blame_inline.py | 14 +-------------
 src/templates.py    |  6 +++---
 2 files changed, 4 insertions(+), 16 deletions(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index 7c84007..e861840 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -33,18 +33,6 @@ def extra_cli_args(self, line_num):
     def _view(self):
         return self.view
 
-    def get_inline_blame_color(self):
-        """
-        This method inverts the current background color and adds alpha, basically doing:
-        #ffffff -> rgba(0, 0, 0, 0.3)
-        The color is than used inside the style template. That is how we make
-        sure inline blame is visible in both dark and light color schemes.
-        """
-        bg = self.view.style().get("background")[1:]
-        colors = [int(bg[i : i + 2], 16) for i in range(0, len(bg), 2)]
-        colors_inverted = [str(255 - i) for i in colors]
-        return "rgba({}, {}, {}, 0.3)".format(*colors_inverted)
-
     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.
@@ -70,7 +58,7 @@ def show_inline_blame(self):
         if not blame:
             return
         body = blame_inline_phantom_html_template.format(
-            css=blame_inline_phantom_css.format(color=self.get_inline_blame_color()),
+            css=blame_inline_phantom_css,
             author=blame["author"],
             # TODO: add pretty format of the date, like "3 days ago"
             date=blame["date"],
diff --git a/src/templates.py b/src/templates.py
index 032db68..268ec37 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -100,9 +100,9 @@
 
 
 blame_inline_phantom_css = """
-    div.phantom {{
-        color: {color};
+    div.phantom {
+        color: color(var(--bluish) blend(var(--background) 60%));
         padding: 0;
         margin-left: 50px;
-    }}
+    }
 """

From a22c03efe2a51dbb9762215fde0c8df4646adebe Mon Sep 17 00:00:00 2001
From: Ivan Elfimov <ielfimov@gmail.com>
Date: Sat, 5 Jun 2021 10:49:05 +0300
Subject: [PATCH 144/180] Add relative date and navigation to inline blames

---
 src/base.py         | 68 +++++++++++++++++++++++++++++++++++++++++++++
 src/blame.py        | 44 -----------------------------
 src/blame_inline.py | 34 +++++++++++++++--------
 src/templates.py    |  5 +++-
 4 files changed, 95 insertions(+), 56 deletions(-)

diff --git a/src/base.py b/src/base.py
index 2ffa0f6..5123d9d 100644
--- a/src/base.py
+++ b/src/base.py
@@ -3,6 +3,7 @@
 import subprocess
 import sys
 from abc import ABCMeta, abstractmethod
+from urllib.parse import parse_qs, quote_plus, urlparse
 
 import sublime
 
@@ -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)
@@ -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()
diff --git a/src/blame.py b/src/blame.py
index e1138e9..6a71b8a 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -1,5 +1,3 @@
-from urllib.parse import parse_qs, quote_plus, urlparse
-
 import sublime
 import sublime_plugin
 
@@ -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):
 
diff --git a/src/blame_inline.py b/src/blame_inline.py
index e861840..10f0538 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -27,7 +27,7 @@ def __init__(self, view):
         )
 
     def extra_cli_args(self, line_num):
-        args = ["-L", "{0},{0}".format(line_num)]
+        args = ["-L", "{0},{0}".format(line_num), "--date=relative"]
         return args
 
     def _view(self):
@@ -43,31 +43,43 @@ def show_inline_blame(self):
         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 (full_line is required)
+            # < 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 as e:
+        except Exception:
             return
         blame = next(
-            (self.parse_line(line) for line in blame_output.splitlines()), None
+            (
+                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"],
-            # TODO: add pretty format of the date, like "3 days ago"
-            date=blame["date"],
-            time=blame["time"] + blame["timezone"],
-            # TODO: add first line of the commit here, but
-            #       it requires porcelain format of git blame
-            #       and further refactoring of BaseBlame
+            date=blame["relative_date"],
+            qs_sha_val=blame["sha"],
+            summary=summary,
+        )
+        phantom = sublime.Phantom(
+            anchor, body, sublime.LAYOUT_INLINE, self.handle_phantom_button
         )
-        phantom = sublime.Phantom(anchor, body, sublime.LAYOUT_INLINE)
         phantoms.append(phantom)
 
         # Dispatch back onto the main thread to serialize a final is_dirty check.
diff --git a/src/templates.py b/src/templates.py
index 268ec37..33df136 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -92,7 +92,7 @@
         <style>{css}</style>
         <div class="phantom">
             <span class="message">
-                {author},&nbsp;{date}&nbsp;{time}
+                {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>
@@ -105,4 +105,7 @@
         padding: 0;
         margin-left: 50px;
     }
+    div.phantom a {
+        text-decoration: inherit;
+    }
 """

From 6ea473b5be38ccff4f980814312b511b1d895042 Mon Sep 17 00:00:00 2001
From: Ivan Elfimov <ielfimov@gmail.com>
Date: Sat, 5 Jun 2021 11:05:09 +0300
Subject: [PATCH 145/180] Disable inline blames by default

---
 Settings/Git blame.sublime-settings | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Settings/Git blame.sublime-settings b/Settings/Git blame.sublime-settings
index 14bffc8..f8e8422 100644
--- a/Settings/Git blame.sublime-settings	
+++ b/Settings/Git blame.sublime-settings	
@@ -7,6 +7,6 @@
     //     ["-C", "-C"]
     //
     "custom_blame_flags": [],
-    "inline_blame_enabled": true,
+    "inline_blame_enabled": false,
     "inline_blame_delay": 300
 }

From 6c9457ee6edb37dec8177127ad6520ac965b7c95 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 6 Jun 2021 11:51:00 +0100
Subject: [PATCH 146/180] Only use the middle-dot separator if there is a
 summary

---
 src/blame_inline.py | 1 +
 src/templates.py    | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index 10f0538..9e89c0c 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -75,6 +75,7 @@ def show_inline_blame(self):
             author=blame["author"],
             date=blame["relative_date"],
             qs_sha_val=blame["sha"],
+            summary_separator=" · " if summary else "",
             summary=summary,
         )
         phantom = sublime.Phantom(
diff --git a/src/templates.py b/src/templates.py
index 33df136..96b61d4 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -92,7 +92,7 @@
         <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>
+                {author},&nbsp;{date}{summary_separator}{summary}&nbsp;<a href="copy?sha={qs_sha_val}">[Copy]</a>&nbsp;<a href="show?sha={qs_sha_val}">[Show]</a>
             </span>
         </div>
     </body>

From 8356d959998de6bab68edd3211b68602a87de7ee Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 6 Jun 2021 11:54:21 +0100
Subject: [PATCH 147/180] Restore missing import of quote_plus function

---
 src/blame.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/blame.py b/src/blame.py
index 6a71b8a..51c0bd4 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -1,3 +1,5 @@
+from urllib.parse import quote_plus
+
 import sublime
 import sublime_plugin
 

From 4bab7a7b6bf13c1b2d420ea41053d98f5988e9a1 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 6 Jun 2021 11:56:09 +0100
Subject: [PATCH 148/180] Only use &nbsp where multiple consecutive spaces are
 needed

---
 src/templates.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/templates.py b/src/templates.py
index 96b61d4..c3634bb 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -92,7 +92,7 @@
         <style>{css}</style>
         <div class="phantom">
             <span class="message">
-                {author},&nbsp;{date}{summary_separator}{summary}&nbsp;<a href="copy?sha={qs_sha_val}">[Copy]</a>&nbsp;<a href="show?sha={qs_sha_val}">[Show]</a>
+                {author}, {date}{summary_separator}{summary} <a href="copy?sha={qs_sha_val}">[Copy]</a> <a href="show?sha={qs_sha_val}">[Show]</a>
             </span>
         </div>
     </body>

From 70f35c6b696910e20c543aa45a90f77eee092328 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 6 Jun 2021 12:05:23 +0100
Subject: [PATCH 149/180] Make sure inline blame colour has contrast against a
 blue background

---
 src/templates.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/templates.py b/src/templates.py
index c3634bb..78e18c4 100644
--- a/src/templates.py
+++ b/src/templates.py
@@ -101,7 +101,7 @@
 
 blame_inline_phantom_css = """
     div.phantom {
-        color: color(var(--bluish) blend(var(--background) 60%));
+        color: color(color(var(--bluish) blend(var(--background) 60%)) min-contrast(var(--background) 2.0));
         padding: 0;
         margin-left: 50px;
     }

From d30f1ae616686f4c9a22b4b28e9a38d99c6a5bb2 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 6 Jun 2021 12:17:05 +0100
Subject: [PATCH 150/180] Add todo

---
 README.md | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/README.md b/README.md
index f5e4ed1..364b8c7 100644
--- a/README.md
+++ b/README.md
@@ -46,4 +46,11 @@ To close all of them, click the `×` icon on any one of them, or press the keybo
 
 As well as via keyboard shortcuts, this package's commands are also made available in the *Command Palette*. Type "Git Blame" into it to find them:
 
+<!--
+@todo Add a Development section to the README with tips for contributors
+@body Because of the layout of the package (single "plugin", `boot.py`, and the result of the code in `src/`) it's recommended to use https://packagecontrol.io/packages/AutomaticPackageReloader so that changes you make during development get picked up without needing to restart ST.
+@body Try to format the Python code using https://github.com/psf/black.
+@body Be mindful of ST API versions https://www.sublimetext.com/docs/api_reference.html. We support ST3 (build 3211+) for now, but may go ST4 exclusive in the future.
+-->
+
 ![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/docs/screenshot-palette.png)

From b687f1e0e767614e3a84b9a2a488bcc666618636 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 6 Jun 2021 12:27:17 +0100
Subject: [PATCH 151/180] Add todo

---
 src/blame_inline.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index 9e89c0c..c97ea53 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -40,6 +40,8 @@ def show_inline_blame(self):
 
         phantoms = []
         sels = self.view.sel()
+        # @todo Support showing inline blame for multiple carets?
+        # @body Maybe with a sanity check that there aren't too many (more than 10?)
         line = self.view.line(sels[0])
         if line.size() < 2:
             # avoid weird behaviour of regions on empty lines

From 54e171fa60d8ec0c2aadac09990d627a53ed7d28 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 09:54:02 +0000
Subject: [PATCH 152/180] Use full-on Git Log syntax for [Show]ing a commit,
 not just the Diff syntax

The Git Log syntax definition was introduced in ST 3.1 (May 2018)
---
 src/blame.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame.py b/src/blame.py
index 51c0bd4..7605e6b 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -136,7 +136,7 @@ class BlameInsertCommitDescription(sublime_plugin.TextCommand):
     def run(self, edit, desc, scratch_view_name):
         view = self.view
         view.set_scratch(True)
-        view.assign_syntax("Packages/Diff/Diff.sublime-syntax")
+        view.assign_syntax("Packages/Git Formats/Git Log.sublime-syntax")
         view.insert(edit, 0, desc)
         view.set_name(scratch_view_name)
         view.set_read_only(True)

From bbd280c4c255ce959c1f56b2aec6430800633d2c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 10:34:01 +0000
Subject: [PATCH 153/180] WIP fix issues identified by mypy

---
 src/base.py         | 6 +++---
 src/blame_all.py    | 2 +-
 src/blame_inline.py | 8 +++++---
 3 files changed, 9 insertions(+), 7 deletions(-)

diff --git a/src/base.py b/src/base.py
index 5123d9d..6c0f05f 100644
--- a/src/base.py
+++ b/src/base.py
@@ -3,7 +3,7 @@
 import subprocess
 import sys
 from abc import ABCMeta, abstractmethod
-from urllib.parse import parse_qs, quote_plus, urlparse
+from urllib.parse import parse_qs, urlparse
 
 import sublime
 
@@ -96,12 +96,12 @@ def handle_phantom_button(self, href):
         elif url.path == "show":
             sha = querystring["sha"][0]
             try:
-                desc = self.get_commit_text(sha, self.view.file_name())
+                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 = self._view().window().new_file()
             buf.run_command(
                 "blame_insert_commit_description",
                 {"desc": desc, "scratch_view_name": "commit " + sha},
diff --git a/src/blame_all.py b/src/blame_all.py
index d631984..bbafea6 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -27,7 +27,7 @@ def run(self, edit):
             return
 
         self.view.erase_phantoms(PHANTOM_KEY_ALL)
-        phantoms = []  # type: list[sublime.Phantom]
+        phantoms = []  # type: list[sublime.Phantom] # type: ignore[misc]
 
         # If they are currently shown, toggle them off and return.
         if self.view.settings().get(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False):
diff --git a/src/blame_inline.py b/src/blame_inline.py
index c97ea53..8ddae28 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -1,14 +1,15 @@
 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,
+    PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED,
+    pkg_settings,
 )
+from .templates import blame_inline_phantom_css, blame_inline_phantom_html_template
 
 INLINE_BLAME_PHANTOM_SET_KEY = "git-blame-inline"
 
@@ -16,6 +17,7 @@
 class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
     @classmethod
     def is_applicable(cls, settings):
+        # @todo Fix inline blame (sometimes?) remaining enabled when the user setting for it is edited from true -> false
         return pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED)
 
     def __init__(self, view):

From b7d350a98f5feb2027d3395177f43e848dca9557 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 11:19:36 +0000
Subject: [PATCH 154/180] Fix remaining issues identified by mypy

---
 src/base.py         | 10 +++++++++-
 src/blame.py        |  3 +++
 src/blame_all.py    |  3 +++
 src/blame_inline.py |  9 ++++++++-
 4 files changed, 23 insertions(+), 2 deletions(-)

diff --git a/src/base.py b/src/base.py
index 6c0f05f..899d672 100644
--- a/src/base.py
+++ b/src/base.py
@@ -120,7 +120,7 @@ def handle_phantom_button(self, href):
             )
         elif url.path == "close":
             # Erase all phantoms
-            self.phantom_set.update([])
+            self._phantom_set().update([])
         else:
             self.communicate_error(
                 "No handler for URL path '{0}' in phantom".format(url.path)
@@ -153,6 +153,14 @@ def communicate_error(self, e, modal=True):
     def _view(self):
         ...
 
+    @abstractmethod
+    def _phantom_set(self):
+        ...
+
     @abstractmethod
     def extra_cli_args(self, **kwargs):
         ...
+
+    @abstractmethod
+    def run(self, edit, **kwargs):
+        ...
diff --git a/src/blame.py b/src/blame.py
index 7605e6b..7f9333c 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -117,6 +117,9 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
     def _view(self):
         return self.view
 
+    def _phantom_set(self):
+        return self.phantom_set
+
     def extra_cli_args(self, line_num, sha_skip_list):
         args = ["-L", "{0},{0}".format(line_num)]
         for skipped_sha in sha_skip_list:
diff --git a/src/blame_all.py b/src/blame_all.py
index bbafea6..1f80001 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -85,6 +85,9 @@ def run(self, edit):
     def _view(self):
         return self.view
 
+    def _phantom_set(self):
+        return self.phantom_set
+
     def extra_cli_args(self, **kwargs):
         return []
 
diff --git a/src/blame_inline.py b/src/blame_inline.py
index 8ddae28..2635a04 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -35,6 +35,13 @@ def extra_cli_args(self, line_num):
     def _view(self):
         return self.view
 
+    def _phantom_set(self):
+        return self.phantom_set
+
+    def run(self, edit):
+        # Unlike the other BaseBlame subclasses, we are reactive, not a sublime_plugin.Command
+        pass
+
     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.
@@ -72,7 +79,7 @@ def show_inline_blame(self):
                 summary = self.get_commit_message_first_line(
                     blame["sha"], self.view.file_name()
                 )
-            except Exception as e:
+            except Exception:
                 return
         body = blame_inline_phantom_html_template.format(
             css=blame_inline_phantom_css,

From 55f2811147c9c3a6f3babf6a53aff2ebf41b30ba Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 11:58:44 +0000
Subject: [PATCH 155/180] Abstract the phantom close button handler, rather
 than a phantom_set accessor

---
 src/base.py         | 5 ++---
 src/blame.py        | 4 ++--
 src/blame_all.py    | 8 ++------
 src/blame_inline.py | 5 +++--
 4 files changed, 9 insertions(+), 13 deletions(-)

diff --git a/src/base.py b/src/base.py
index 899d672..8bf7d60 100644
--- a/src/base.py
+++ b/src/base.py
@@ -119,8 +119,7 @@ def handle_phantom_button(self, href):
                 sha_skip_list=sha_skip_list,
             )
         elif url.path == "close":
-            # Erase all phantoms
-            self._phantom_set().update([])
+            self.handle_phantom_close_button()
         else:
             self.communicate_error(
                 "No handler for URL path '{0}' in phantom".format(url.path)
@@ -154,7 +153,7 @@ def _view(self):
         ...
 
     @abstractmethod
-    def _phantom_set(self):
+    def handle_phantom_close_button(self):
         ...
 
     @abstractmethod
diff --git a/src/blame.py b/src/blame.py
index 7f9333c..4fec331 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -117,8 +117,8 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
     def _view(self):
         return self.view
 
-    def _phantom_set(self):
-        return self.phantom_set
+    def handle_phantom_close_button(self):
+        self.phantom_set.update([])
 
     def extra_cli_args(self, line_num, sha_skip_list):
         args = ["-L", "{0},{0}".format(line_num)]
diff --git a/src/blame_all.py b/src/blame_all.py
index 1f80001..1414464 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -85,17 +85,13 @@ def run(self, edit):
     def _view(self):
         return self.view
 
-    def _phantom_set(self):
-        return self.phantom_set
-
     def extra_cli_args(self, **kwargs):
         return []
 
     # ------------------------------------------------------------
 
-    def handle_phantom_button(self, href):
-        if href == "close":
-            self.view.run_command("blame_erase_all")
+    def handle_phantom_close_button(self):
+        self.view.run_command("blame_erase_all")
 
     def phantom_region(self, line_number):
         line_begins_pt = self.view.text_point(line_number - 1, 0)
diff --git a/src/blame_inline.py b/src/blame_inline.py
index 2635a04..5c9695f 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -35,8 +35,9 @@ def extra_cli_args(self, line_num):
     def _view(self):
         return self.view
 
-    def _phantom_set(self):
-        return self.phantom_set
+    def handle_phantom_close_button(self):
+        # Inline Blame phantoms close automatically when appropriate.
+        pass
 
     def run(self, edit):
         # Unlike the other BaseBlame subclasses, we are reactive, not a sublime_plugin.Command

From f75d39c3a61c64903ad17e2b951417cd32775aeb Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 13:03:35 +0000
Subject: [PATCH 156/180] Workaround empty space sometimes remaining when
 BlameAll phantoms are erased

---
 src/blame_all.py | 21 +++++++++++++++------
 1 file changed, 15 insertions(+), 6 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index 1414464..cb03c2e 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -14,6 +14,8 @@
 
 class BlameShowAll(BaseBlame, sublime_plugin.TextCommand):
 
+    HORIZONTAL_SCROLL_DELAY_MS = 100
+
     # Overrides --------------------------------------------------
 
     def __init__(self, view):
@@ -34,6 +36,9 @@ def run(self, edit):
             self.phantom_set.update(phantoms)
             self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
             self.view.run_command("blame_restore_rulers")
+            # Workaround a visible empty space sometimes remaining in the viewport.
+            self.horizontal_scroll_to_limit(left=False)
+            self.horizontal_scroll_to_limit(left=True)
             return
 
         try:
@@ -75,12 +80,7 @@ def run(self, edit):
         self.view.settings().set(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, True)
         self.store_rulers()
         # Bring the phantoms into view without the user needing to manually scroll left.
-        sublime.set_timeout(
-            lambda: self.view.set_viewport_position(
-                (0.0, self.view.viewport_position()[1])
-            ),
-            0,
-        )
+        self.horizontal_scroll_to_limit(left=True)
 
     def _view(self):
         return self.view
@@ -104,6 +104,15 @@ def store_rulers(self):
         )
         self.view.settings().set(VIEW_SETTING_RULERS, [])
 
+    def horizontal_scroll_to_limit(self, *, left):
+        x = 0.0 if left else self.view.layout_extent()[0]
+        y = self.view.viewport_position()[1]
+        # NOTE: The scrolling doesn't seem to work if called inline (or with a 0ms timeout).
+        sublime.set_timeout(
+            lambda: self.view.set_viewport_position((x, y)),
+            self.HORIZONTAL_SCROLL_DELAY_MS,
+        )
+
 
 class BlameEraseAll(sublime_plugin.TextCommand):
 

From 25e6e7463f4c4f077e071d98aba2a4021cf23006 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 13:13:07 +0000
Subject: [PATCH 157/180] Revert "Use full-on Git Log syntax for [Show]ing a
 commit, not just the Diff syntax"

This reverts commit 54e171fa60d8ec0c2aadac09990d627a53ed7d28.

Actually it's so visually busy that it makes things harder to read than just the normal Diff syntax
---
 src/blame.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/blame.py b/src/blame.py
index 4fec331..c3bb704 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -139,7 +139,7 @@ class BlameInsertCommitDescription(sublime_plugin.TextCommand):
     def run(self, edit, desc, scratch_view_name):
         view = self.view
         view.set_scratch(True)
-        view.assign_syntax("Packages/Git Formats/Git Log.sublime-syntax")
+        view.assign_syntax("Packages/Diff/Diff.sublime-syntax")
         view.insert(edit, 0, desc)
         view.set_name(scratch_view_name)
         view.set_read_only(True)

From 192c988727dab6542d90dd0773c8c7eb67e6869e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 18:48:27 +0000
Subject: [PATCH 158/180] Make Inline-Blame reactive to "inline_blame_enabled"
 being edited in the "Git blame.sublime-settings" file

Fixes #69
---
 src/blame_inline.py | 38 ++++++++++++++++++++++++++++++++++++--
 1 file changed, 36 insertions(+), 2 deletions(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index 5c9695f..b4d7bb1 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -15,11 +15,43 @@
 
 
 class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
+
+    pkg_setting_callback_added = False
+
     @classmethod
-    def is_applicable(cls, settings):
-        # @todo Fix inline blame (sometimes?) remaining enabled when the user setting for it is edited from true -> false
+    def is_applicable(cls, view_settings):
+        if not cls.pkg_setting_callback_added:
+            pkg_settings().add_on_change(
+                PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED,
+                cls.on_pkg_setting_changed,
+            )
+            cls.pkg_setting_callback_added = True
+
         return pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED)
 
+    @classmethod
+    def on_pkg_setting_changed(cls):
+        # This variable can be elimited once targeting ST4+ only, and the latter lambda body inlined.
+        view_is_editor = (
+            # REF: https://github.com/sublimehq/sublime_text/issues/3167
+            lambda view: not view.settings().get("is_widget")
+            if sublime.version().startswith("3")
+            else lambda view: view.element() is None
+        )
+        all_editor_views = [
+            view
+            for window in sublime.windows()
+            for view in window.views()
+            if view_is_editor(view)
+        ]
+        inline_blame_enabled = pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED)
+        for view in all_editor_views:
+            if not inline_blame_enabled:
+                view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
+            # Do a dummy modification to the view's settings to induce the ViewEventListener applicability check to happen again.
+            view.settings().set(cls.__name__, "")
+            view.settings().erase(cls.__name__)
+
     def __init__(self, view):
         super().__init__(view)
         self.phantom_set = sublime.PhantomSet(view, INLINE_BLAME_PHANTOM_SET_KEY)
@@ -27,6 +59,8 @@ def __init__(self, view):
         self.delay_seconds = (
             pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_DELAY) / 1000
         )
+        # Show it immediately for the initially selected line.
+        self.show_inline_blame()
 
     def extra_cli_args(self, line_num):
         args = ["-L", "{0},{0}".format(line_num), "--date=relative"]

From d1df455c6ebed5f1c373541055b6d2f3fc1b5dc8 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 18:52:17 +0000
Subject: [PATCH 159/180] Add a Command to toggle Inline-Blame without having
 to edit settings file

---
 Keymaps/Default (OSX).sublime-keymap |  4 +++-
 src/blame_inline.py                  | 13 +++++++++++++
 2 files changed, 16 insertions(+), 1 deletion(-)

diff --git a/Keymaps/Default (OSX).sublime-keymap b/Keymaps/Default (OSX).sublime-keymap
index 7581456..75c53c1 100644
--- a/Keymaps/Default (OSX).sublime-keymap	
+++ b/Keymaps/Default (OSX).sublime-keymap	
@@ -1,4 +1,6 @@
 [
   {"keys": ["ctrl+alt+b"],       "command": "blame"},
-  {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"}
+  {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"},
+
+  //{"keys": ["f8"], "command": "toggle_inline_git_blame"},
 ]
diff --git a/src/blame_inline.py b/src/blame_inline.py
index b4d7bb1..c323d02 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -14,6 +14,19 @@
 INLINE_BLAME_PHANTOM_SET_KEY = "git-blame-inline"
 
 
+class ToggleInlineGitBlame(sublime_plugin.TextCommand):
+    # Overrides begin ------------------------------------------------------------------
+
+    def run(self, edit):
+        settings = pkg_settings()
+        settings.set(
+            PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED,
+            not settings.get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED),
+        )
+
+    # Overrides end --------------------------------------------------------------------
+
+
 class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
 
     pkg_setting_callback_added = False

From 3fc81f63a19328b4a38075208e82f9ec4a06fc8a Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 20:31:27 +0000
Subject: [PATCH 160/180] Change Inline-Blame toggling to work at the
 individual view level

Editing the "inline_blame_enabled" value in the package settings file erases any view-level customisation
---
 Commands/Default.sublime-commands |  4 +++
 src/blame_inline.py               | 48 ++++++++++++++++++++-----------
 2 files changed, 36 insertions(+), 16 deletions(-)

diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
index 9e860cc..fba9860 100644
--- a/Commands/Default.sublime-commands
+++ b/Commands/Default.sublime-commands
@@ -1,4 +1,8 @@
 [
+  {
+    "caption": "Git Blame: Toggle Inline Blame",
+    "command": "toggle_inline_git_blame"
+  },
   {
     "caption": "Git Blame: Show",
     "command": "blame"
diff --git a/src/blame_inline.py b/src/blame_inline.py
index c323d02..6ee5f98 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -14,19 +14,6 @@
 INLINE_BLAME_PHANTOM_SET_KEY = "git-blame-inline"
 
 
-class ToggleInlineGitBlame(sublime_plugin.TextCommand):
-    # Overrides begin ------------------------------------------------------------------
-
-    def run(self, edit):
-        settings = pkg_settings()
-        settings.set(
-            PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED,
-            not settings.get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED),
-        )
-
-    # Overrides end --------------------------------------------------------------------
-
-
 class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
 
     pkg_setting_callback_added = False
@@ -40,7 +27,16 @@ def is_applicable(cls, view_settings):
             )
             cls.pkg_setting_callback_added = True
 
-        return pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED)
+        return cls.determine_enablement(view_settings)
+
+    @classmethod
+    def determine_enablement(cls, view_settings):
+        enabled = view_settings.get(
+            ToggleInlineGitBlame.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED
+        )
+        if enabled is None:
+            enabled = pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED)
+        return enabled
 
     @classmethod
     def on_pkg_setting_changed(cls):
@@ -57,9 +53,9 @@ def on_pkg_setting_changed(cls):
             for view in window.views()
             if view_is_editor(view)
         ]
-        inline_blame_enabled = pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED)
         for view in all_editor_views:
-            if not inline_blame_enabled:
+            ToggleInlineGitBlame.erase_customization(view)
+            if not pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED):
                 view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
             # Do a dummy modification to the view's settings to induce the ViewEventListener applicability check to happen again.
             view.settings().set(cls.__name__, "")
@@ -163,3 +159,23 @@ 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()
+
+
+class ToggleInlineGitBlame(sublime_plugin.TextCommand):
+
+    # Might as well reuse the same settings key, but at the view-level.
+    VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED = PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED
+
+    # Overrides begin ------------------------------------------------------------------
+
+    def run(self, edit):
+        enabled = not BlameInlineListener.determine_enablement(self.view.settings())
+        if not enabled:
+            self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
+        self.view.settings().set(self.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED, enabled)
+
+    # Overrides end --------------------------------------------------------------------
+
+    @classmethod
+    def erase_customization(cls, view):
+        view.settings().erase(cls.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED)

From 35aa693c28ce8902106302fa7228c30a6f4623e5 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 20:48:30 +0000
Subject: [PATCH 161/180] Cosmetic (delimit better what are overrides)

---
 src/blame.py        |  8 +++--
 src/blame_all.py    | 21 +++++++-----
 src/blame_inline.py | 82 ++++++++++++++++++++++++---------------------
 3 files changed, 60 insertions(+), 51 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index c3bb704..5cb4ce2 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -12,7 +12,7 @@
 
 class Blame(BaseBlame, sublime_plugin.TextCommand):
 
-    # Overrides --------------------------------------------------
+    # Overrides begin ------------------------------------------------------------------
 
     def __init__(self, view):
         super().__init__(view)
@@ -126,7 +126,7 @@ def extra_cli_args(self, line_num, sha_skip_list):
             args.extend(["--ignore-rev", skipped_sha])
         return args
 
-    # ------------------------------------------------------------
+    # Overrides end --------------------------------------------------------------------
 
     def phantom_exists_for_region(self, region):
         return any(p.region == region for p in self.phantom_set.phantoms)
@@ -134,7 +134,7 @@ def phantom_exists_for_region(self, region):
 
 class BlameInsertCommitDescription(sublime_plugin.TextCommand):
 
-    # Overrides --------------------------------------------------
+    # Overrides begin ------------------------------------------------------------------
 
     def run(self, edit, desc, scratch_view_name):
         view = self.view
@@ -143,3 +143,5 @@ def run(self, edit, desc, scratch_view_name):
         view.insert(edit, 0, desc)
         view.set_name(scratch_view_name)
         view.set_read_only(True)
+
+    # Overrides end --------------------------------------------------------------------
diff --git a/src/blame_all.py b/src/blame_all.py
index cb03c2e..cbb50b7 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -16,7 +16,7 @@ class BlameShowAll(BaseBlame, sublime_plugin.TextCommand):
 
     HORIZONTAL_SCROLL_DELAY_MS = 100
 
-    # Overrides --------------------------------------------------
+    # Overrides begin ------------------------------------------------------------------
 
     def __init__(self, view):
         super().__init__(view)
@@ -88,11 +88,11 @@ def _view(self):
     def extra_cli_args(self, **kwargs):
         return []
 
-    # ------------------------------------------------------------
-
     def handle_phantom_close_button(self):
         self.view.run_command("blame_erase_all")
 
+    # Overrides end --------------------------------------------------------------------
+
     def phantom_region(self, line_number):
         line_begins_pt = self.view.text_point(line_number - 1, 0)
         return sublime.Region(line_begins_pt)
@@ -116,36 +116,39 @@ def horizontal_scroll_to_limit(self, *, left):
 
 class BlameEraseAll(sublime_plugin.TextCommand):
 
-    # Overrides --------------------------------------------------
+    # Overrides begin ------------------------------------------------------------------
 
     def run(self, edit):
-        """Erases the blame results."""
         sublime.status_message("The git blame result is cleared.")
         self.view.erase_phantoms(PHANTOM_KEY_ALL)
         self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
         self.view.run_command("blame_restore_rulers")
 
+    # Overrides end --------------------------------------------------------------------
+
 
 class BlameEraseAllListener(sublime_plugin.ViewEventListener):
 
-    # Overrides --------------------------------------------------
+    # Overrides begin ------------------------------------------------------------------
 
     @classmethod
     def is_applicable(cls, settings):
-        """Checks if the blame_erase_all command is applicable."""
         return settings.get(VIEW_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")
 
+    # Overrides end --------------------------------------------------------------------
+
 
 class BlameRestoreRulers(sublime_plugin.TextCommand):
 
-    # Overrides --------------------------------------------------
+    # Overrides begin ------------------------------------------------------------------
 
     def run(self, edit):
         self.view.settings().set(
             VIEW_SETTING_RULERS,
             self.view.settings().get(VIEW_SETTING_RULERS_PREV),
         )
+
+    # Overrides end --------------------------------------------------------------------
diff --git a/src/blame_inline.py b/src/blame_inline.py
index 6ee5f98..c3e4d0c 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -18,6 +18,22 @@ class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
 
     pkg_setting_callback_added = False
 
+    # Overrides begin ------------------------------------------------------------------
+
+    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
+        )
+        # Show it immediately for the initially selected line.
+        self.show_inline_blame()
+
+    def run(self, edit):
+        # Unlike the other BaseBlame subclasses, we are reactive, not a sublime_plugin.Command
+        pass
+
     @classmethod
     def is_applicable(cls, view_settings):
         if not cls.pkg_setting_callback_added:
@@ -29,6 +45,27 @@ def is_applicable(cls, view_settings):
 
         return cls.determine_enablement(view_settings)
 
+    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()
+
+    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 handle_phantom_close_button(self):
+        # Inline Blame phantoms close automatically when appropriate.
+        pass
+
+    # Overrides end --------------------------------------------------------------------
+
     @classmethod
     def determine_enablement(cls, view_settings):
         enabled = view_settings.get(
@@ -61,30 +98,12 @@ def on_pkg_setting_changed(cls):
             view.settings().set(cls.__name__, "")
             view.settings().erase(cls.__name__)
 
-    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
-        )
-        # Show it immediately for the initially selected line.
-        self.show_inline_blame()
-
-    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 handle_phantom_close_button(self):
-        # Inline Blame phantoms close automatically when appropriate.
-        pass
-
-    def run(self, edit):
-        # Unlike the other BaseBlame subclasses, we are reactive, not a sublime_plugin.Command
-        pass
+    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 show_inline_blame(self):
         if self.view.is_dirty():
@@ -145,21 +164,6 @@ 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()
-
 
 class ToggleInlineGitBlame(sublime_plugin.TextCommand):
 

From 4be4a26eafd61fb20984fc9f6b6c4ef7c91da806 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 21:17:11 +0000
Subject: [PATCH 162/180] Revise BaseBlame's abstract methods

Simply rename one, and rename another to stop the conflation of BaseBlame#run(...) with sublime_plugin.TextCommand#run(...)
---
 src/base.py         | 9 ++++-----
 src/blame.py        | 5 ++++-
 src/blame_all.py    | 5 ++++-
 src/blame_inline.py | 9 ++++++---
 4 files changed, 18 insertions(+), 10 deletions(-)

diff --git a/src/base.py b/src/base.py
index 8bf7d60..6fbac7a 100644
--- a/src/base.py
+++ b/src/base.py
@@ -112,14 +112,13 @@ def handle_phantom_button(self, href):
             sha_skip_list = querystring.get("skip", [])
             if sha not in sha_skip_list:
                 sha_skip_list.append(sha)
-            self.run(
-                None,
+            self.recurse(
                 prevving=True,
                 fixed_row_num=int(row_num),
                 sha_skip_list=sha_skip_list,
             )
         elif url.path == "close":
-            self.handle_phantom_close_button()
+            self.close_by_user_request()
         else:
             self.communicate_error(
                 "No handler for URL path '{0}' in phantom".format(url.path)
@@ -153,7 +152,7 @@ def _view(self):
         ...
 
     @abstractmethod
-    def handle_phantom_close_button(self):
+    def close_by_user_request(self):
         ...
 
     @abstractmethod
@@ -161,5 +160,5 @@ def extra_cli_args(self, **kwargs):
         ...
 
     @abstractmethod
-    def run(self, edit, **kwargs):
+    def recurse(self, *args, **kwargs):
         ...
diff --git a/src/blame.py b/src/blame.py
index 5cb4ce2..45cc7d0 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -117,7 +117,7 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
     def _view(self):
         return self.view
 
-    def handle_phantom_close_button(self):
+    def close_by_user_request(self):
         self.phantom_set.update([])
 
     def extra_cli_args(self, line_num, sha_skip_list):
@@ -126,6 +126,9 @@ def extra_cli_args(self, line_num, sha_skip_list):
             args.extend(["--ignore-rev", skipped_sha])
         return args
 
+    def recurse(self, *args, **kwargs):
+        self.run(None, **kwargs)
+
     # Overrides end --------------------------------------------------------------------
 
     def phantom_exists_for_region(self, region):
diff --git a/src/blame_all.py b/src/blame_all.py
index cbb50b7..c680f94 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -88,9 +88,12 @@ def _view(self):
     def extra_cli_args(self, **kwargs):
         return []
 
-    def handle_phantom_close_button(self):
+    def close_by_user_request(self):
         self.view.run_command("blame_erase_all")
 
+    def recurse(self, *args, **kwargs):
+        raise NotImplementedError()
+
     # Overrides end --------------------------------------------------------------------
 
     def phantom_region(self, line_number):
diff --git a/src/blame_inline.py b/src/blame_inline.py
index c3e4d0c..b2a5ba0 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -60,9 +60,12 @@ def extra_cli_args(self, line_num):
     def _view(self):
         return self.view
 
-    def handle_phantom_close_button(self):
-        # Inline Blame phantoms close automatically when appropriate.
-        pass
+    def close_by_user_request(self):
+        # Inline Blame phantoms do not have a user-accessible close UI.
+        raise NotImplementedError()
+
+    def recurse(self, *args, **kwargs):
+        raise NotImplementedError()
 
     # Overrides end --------------------------------------------------------------------
 

From fb06fd86de9fd3a36befed088ecd9950c3b0c05f Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sun, 5 Dec 2021 21:38:19 +0000
Subject: [PATCH 163/180] Early return if there are no selections

Saw an IndexError happen while testing in ST3
---
 src/blame_inline.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index b2a5ba0..9ea0b0d 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -115,6 +115,9 @@ def show_inline_blame(self):
 
         phantoms = []
         sels = self.view.sel()
+        if len(sels) < 1:
+            return
+
         # @todo Support showing inline blame for multiple carets?
         # @body Maybe with a sanity check that there aren't too many (more than 10?)
         line = self.view.line(sels[0])

From c5d7486d371abf2718e41837fc8b5c0014e1078e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 09:35:08 +0000
Subject: [PATCH 164/180] Delete now unnecessary method

---
 src/blame_inline.py | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index 9ea0b0d..0de273a 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -30,10 +30,6 @@ def __init__(self, view):
         # Show it immediately for the initially selected line.
         self.show_inline_blame()
 
-    def run(self, edit):
-        # Unlike the other BaseBlame subclasses, we are reactive, not a sublime_plugin.Command
-        pass
-
     @classmethod
     def is_applicable(cls, view_settings):
         if not cls.pkg_setting_callback_added:

From 88ae3b273eaa238daa20e4794e0ed90547432e4e Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 09:40:38 +0000
Subject: [PATCH 165/180] Comment

---
 src/blame.py        | 4 +++-
 src/blame_all.py    | 5 ++++-
 src/blame_inline.py | 7 +++++--
 3 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/src/blame.py b/src/blame.py
index 45cc7d0..487b999 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -12,7 +12,7 @@
 
 class Blame(BaseBlame, sublime_plugin.TextCommand):
 
-    # Overrides begin ------------------------------------------------------------------
+    # Overrides (TextCommand) ----------------------------------------------------------
 
     def __init__(self, view):
         super().__init__(view)
@@ -114,6 +114,8 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
 
         self.phantom_set.update(phantoms)
 
+    # Overrides (BaseBlame) ------------------------------------------------------------
+
     def _view(self):
         return self.view
 
diff --git a/src/blame_all.py b/src/blame_all.py
index c680f94..fb241f6 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -16,7 +16,7 @@ class BlameShowAll(BaseBlame, sublime_plugin.TextCommand):
 
     HORIZONTAL_SCROLL_DELAY_MS = 100
 
-    # Overrides begin ------------------------------------------------------------------
+    # Overrides (TextCommand) ----------------------------------------------------------
 
     def __init__(self, view):
         super().__init__(view)
@@ -82,6 +82,8 @@ def run(self, edit):
         # Bring the phantoms into view without the user needing to manually scroll left.
         self.horizontal_scroll_to_limit(left=True)
 
+    # Overrides (BaseBlame) ------------------------------------------------------------
+
     def _view(self):
         return self.view
 
@@ -92,6 +94,7 @@ def close_by_user_request(self):
         self.view.run_command("blame_erase_all")
 
     def recurse(self, *args, **kwargs):
+        # BlameAll doesn't need to rerun itself.
         raise NotImplementedError()
 
     # Overrides end --------------------------------------------------------------------
diff --git a/src/blame_inline.py b/src/blame_inline.py
index 0de273a..ebbb8d7 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -18,7 +18,7 @@ class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
 
     pkg_setting_callback_added = False
 
-    # Overrides begin ------------------------------------------------------------------
+    # Overrides (ViewEventListener) ----------------------------------------------------
 
     def __init__(self, view):
         super().__init__(view)
@@ -49,6 +49,8 @@ def on_post_save_async(self):
         # no call to on_selection_modified_async after save.
         self.show_inline_blame_handler()
 
+    # Overrides (BaseBlame) ------------------------------------------------------------
+
     def extra_cli_args(self, line_num):
         args = ["-L", "{0},{0}".format(line_num), "--date=relative"]
         return args
@@ -57,10 +59,11 @@ def _view(self):
         return self.view
 
     def close_by_user_request(self):
-        # Inline Blame phantoms do not have a user-accessible close UI.
+        # Inline Blame phantoms doesn't have a user-accessible close UI.
         raise NotImplementedError()
 
     def recurse(self, *args, **kwargs):
+        # Inline Blame doesn't need to rerun itself.
         raise NotImplementedError()
 
     # Overrides end --------------------------------------------------------------------

From b3ee82e86d70495becbe9e9b6190c80f6107b642 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 10:07:01 +0000
Subject: [PATCH 166/180] Rename methods

---
 src/blame_inline.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index ebbb8d7..6d82d2c 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -42,12 +42,12 @@ def is_applicable(cls, view_settings):
         return cls.determine_enablement(view_settings)
 
     def on_selection_modified_async(self):
-        self.show_inline_blame_handler()
+        self.restart_timer()
 
     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()
+        self.restart_timer()
 
     # Overrides (BaseBlame) ------------------------------------------------------------
 
@@ -93,14 +93,14 @@ def on_pkg_setting_changed(cls):
             if view_is_editor(view)
         ]
         for view in all_editor_views:
-            ToggleInlineGitBlame.erase_customization(view)
+            ToggleInlineGitBlame.erase_viewlevel_customization(view)
             if not pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED):
                 view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
             # Do a dummy modification to the view's settings to induce the ViewEventListener applicability check to happen again.
             view.settings().set(cls.__name__, "")
             view.settings().erase(cls.__name__)
 
-    def show_inline_blame_handler(self):
+    def restart_timer(self):
         self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
         if self.timer:
             self.timer.cancel()
@@ -186,5 +186,5 @@ def run(self, edit):
     # Overrides end --------------------------------------------------------------------
 
     @classmethod
-    def erase_customization(cls, view):
+    def erase_viewlevel_customization(cls, view):
         view.settings().erase(cls.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED)

From ba973acb1f75ef50849bdd19825e9476e59c4a35 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 10:18:12 +0000
Subject: [PATCH 167/180] Add todo

---
 src/base.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/base.py b/src/base.py
index 6fbac7a..b1f5996 100644
--- a/src/base.py
+++ b/src/base.py
@@ -63,6 +63,7 @@ def parse_line(cls, line):
         m = re.match(pattern, line)
         return m.groupdict() if m else {}
 
+    # @todo Add a test for the `parse_line_with_relative_date` function in test_parsing.py
     @classmethod
     def parse_line_with_relative_date(cls, line):
         """

From a3c564375498ba413b91efe20bee50d63a9b9a49 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 10:41:58 +0000
Subject: [PATCH 168/180] Also use `git show`'s `--no-color` flag in
 `get_commit_message_subject`

Just in case. See #51 for why it was already added to `get_commit_fulltext` previously.
---
 src/base.py         | 8 ++++----
 src/blame_inline.py | 2 +-
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/base.py b/src/base.py
index b1f5996..28eb754 100644
--- a/src/base.py
+++ b/src/base.py
@@ -37,12 +37,12 @@ def get_blame_text(self, path, **kwargs):
         cli_args.extend(["--", os.path.basename(path)])
         return self.run_git(path, cli_args)
 
-    def get_commit_text(self, sha, path):
+    def get_commit_fulltext(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"]
+    def get_commit_message_subject(self, sha, path):
+        cli_args = ["show", "--no-color", sha, "--pretty=format:%s", "--no-patch"]
         return self.run_git(path, cli_args)
 
     @classmethod
@@ -97,7 +97,7 @@ def handle_phantom_button(self, href):
         elif url.path == "show":
             sha = querystring["sha"][0]
             try:
-                desc = self.get_commit_text(sha, self._view().file_name())
+                desc = self.get_commit_fulltext(sha, self._view().file_name())
             except Exception as e:
                 self.communicate_error(e)
                 return
diff --git a/src/blame_inline.py b/src/blame_inline.py
index 6d82d2c..ca78d93 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -144,7 +144,7 @@ def show_inline_blame(self):
         # Uncommitted changes have only zeros in sha
         if blame["sha"] != "00000000":
             try:
-                summary = self.get_commit_message_first_line(
+                summary = self.get_commit_message_subject(
                     blame["sha"], self.view.file_name()
                 )
             except Exception:

From 090398e5dbf9fbd22ae65f1430668abdc332cd74 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 11:22:18 +0000
Subject: [PATCH 169/180] Rename recurse method and ackshually InlineBlame does
 implement it

---
 src/base.py         |  4 ++--
 src/blame.py        |  2 +-
 src/blame_all.py    |  4 ++--
 src/blame_inline.py | 25 +++++++++++--------------
 4 files changed, 16 insertions(+), 19 deletions(-)

diff --git a/src/base.py b/src/base.py
index 28eb754..036beef 100644
--- a/src/base.py
+++ b/src/base.py
@@ -113,7 +113,7 @@ def handle_phantom_button(self, href):
             sha_skip_list = querystring.get("skip", [])
             if sha not in sha_skip_list:
                 sha_skip_list.append(sha)
-            self.recurse(
+            self.rerun(
                 prevving=True,
                 fixed_row_num=int(row_num),
                 sha_skip_list=sha_skip_list,
@@ -161,5 +161,5 @@ def extra_cli_args(self, **kwargs):
         ...
 
     @abstractmethod
-    def recurse(self, *args, **kwargs):
+    def rerun(self, **kwargs):
         ...
diff --git a/src/blame.py b/src/blame.py
index 487b999..5ffc9b5 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -128,7 +128,7 @@ def extra_cli_args(self, line_num, sha_skip_list):
             args.extend(["--ignore-rev", skipped_sha])
         return args
 
-    def recurse(self, *args, **kwargs):
+    def rerun(self, **kwargs):
         self.run(None, **kwargs)
 
     # Overrides end --------------------------------------------------------------------
diff --git a/src/blame_all.py b/src/blame_all.py
index fb241f6..a76c20b 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -93,8 +93,8 @@ def extra_cli_args(self, **kwargs):
     def close_by_user_request(self):
         self.view.run_command("blame_erase_all")
 
-    def recurse(self, *args, **kwargs):
-        # BlameAll doesn't need to rerun itself.
+    def rerun(self, **kwargs):
+        # BlameAll rerunning itself doesn't make sense.
         raise NotImplementedError()
 
     # Overrides end --------------------------------------------------------------------
diff --git a/src/blame_inline.py b/src/blame_inline.py
index ca78d93..a237eae 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -42,12 +42,13 @@ def is_applicable(cls, view_settings):
         return cls.determine_enablement(view_settings)
 
     def on_selection_modified_async(self):
-        self.restart_timer()
+        self.rerun()
 
     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.restart_timer()
+        # When the file view goes from having unsaved changes to having no unsaved
+        # changes, it becomes eligible for Inline Blame to be shown again. Act on that
+        # fact now, rather than waiting for the next time the caret gets moved.
+        self.rerun()
 
     # Overrides (BaseBlame) ------------------------------------------------------------
 
@@ -62,9 +63,12 @@ def close_by_user_request(self):
         # Inline Blame phantoms doesn't have a user-accessible close UI.
         raise NotImplementedError()
 
-    def recurse(self, *args, **kwargs):
-        # Inline Blame doesn't need to rerun itself.
-        raise NotImplementedError()
+    def rerun(self, **kwargs):
+        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()
 
     # Overrides end --------------------------------------------------------------------
 
@@ -100,13 +104,6 @@ def on_pkg_setting_changed(cls):
             view.settings().set(cls.__name__, "")
             view.settings().erase(cls.__name__)
 
-    def restart_timer(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 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.

From d567a412ebd8864480d6e45c4084419a89aebb24 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 11:38:38 +0000
Subject: [PATCH 170/180] Remove unnecessary NotImplementedErrors

---
 src/blame_all.py    | 3 +--
 src/blame_inline.py | 8 +++-----
 2 files changed, 4 insertions(+), 7 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index a76c20b..ef39094 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -94,8 +94,7 @@ def close_by_user_request(self):
         self.view.run_command("blame_erase_all")
 
     def rerun(self, **kwargs):
-        # BlameAll rerunning itself doesn't make sense.
-        raise NotImplementedError()
+        self.run(None)
 
     # Overrides end --------------------------------------------------------------------
 
diff --git a/src/blame_inline.py b/src/blame_inline.py
index a237eae..eaea7b8 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -53,18 +53,16 @@ def on_post_save_async(self):
     # Overrides (BaseBlame) ------------------------------------------------------------
 
     def extra_cli_args(self, line_num):
-        args = ["-L", "{0},{0}".format(line_num), "--date=relative"]
-        return args
+        return ["-L", "{0},{0}".format(line_num), "--date=relative"]
 
     def _view(self):
         return self.view
 
     def close_by_user_request(self):
-        # Inline Blame phantoms doesn't have a user-accessible close UI.
-        raise NotImplementedError()
+        self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
 
     def rerun(self, **kwargs):
-        self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
+        self.close_by_user_request()
         if self.timer:
             self.timer.cancel()
         self.timer = threading.Timer(self.delay_seconds, self.show_inline_blame)

From 9097b85c2d5df6917f9a092290c804947fa71ad7 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 11:42:19 +0000
Subject: [PATCH 171/180] The rerun method is not necessarily ran by user
 request

---
 src/blame_inline.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index eaea7b8..4288ce6 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -42,9 +42,11 @@ def is_applicable(cls, view_settings):
         return cls.determine_enablement(view_settings)
 
     def on_selection_modified_async(self):
+        self.close_by_user_request()
         self.rerun()
 
     def on_post_save_async(self):
+        self.close_by_user_request()
         # When the file view goes from having unsaved changes to having no unsaved
         # changes, it becomes eligible for Inline Blame to be shown again. Act on that
         # fact now, rather than waiting for the next time the caret gets moved.
@@ -62,7 +64,6 @@ def close_by_user_request(self):
         self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
 
     def rerun(self, **kwargs):
-        self.close_by_user_request()
         if self.timer:
             self.timer.cancel()
         self.timer = threading.Timer(self.delay_seconds, self.show_inline_blame)

From a6f503a17515cc6892f9cb99861c78b21ae73e33 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Mon, 6 Dec 2021 13:13:54 +0000
Subject: [PATCH 172/180] Create a per-class PhantomSet key automatically

---
 src/base.py         |  4 ++++
 src/blame.py        |  2 +-
 src/blame_all.py    |  8 +++-----
 src/blame_inline.py | 10 ++++------
 4 files changed, 12 insertions(+), 12 deletions(-)

diff --git a/src/base.py b/src/base.py
index 036beef..c6d797a 100644
--- a/src/base.py
+++ b/src/base.py
@@ -146,6 +146,10 @@ def communicate_error(self, e, modal=True):
             # persisted in the console too.
             print(user_msg)  # noqa: T001
 
+    @classmethod
+    def phantom_set_key(cls):
+        return "git-blame" + cls.__name__
+
     # ------------------------------------------------------------
 
     @abstractmethod
diff --git a/src/blame.py b/src/blame.py
index 5ffc9b5..7e6c6b7 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -16,7 +16,7 @@ class Blame(BaseBlame, sublime_plugin.TextCommand):
 
     def __init__(self, view):
         super().__init__(view)
-        self.phantom_set = sublime.PhantomSet(view, "git-blame")
+        self.phantom_set = sublime.PhantomSet(view, self.phantom_set_key())
 
     def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
         if not self.has_suitable_view():
diff --git a/src/blame_all.py b/src/blame_all.py
index ef39094..fa6da82 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -4,8 +4,6 @@
 from .base import BaseBlame
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 
-PHANTOM_KEY_ALL = "git-blame-all"
-
 VIEW_SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
 
 VIEW_SETTING_RULERS = "rulers"
@@ -20,7 +18,7 @@ class BlameShowAll(BaseBlame, sublime_plugin.TextCommand):
 
     def __init__(self, view):
         super().__init__(view)
-        self.phantom_set = sublime.PhantomSet(self.view, PHANTOM_KEY_ALL)
+        self.phantom_set = sublime.PhantomSet(self.view, self.phantom_set_key())
         self.pattern = None
 
     def run(self, edit):
@@ -28,7 +26,7 @@ def run(self, edit):
             self.tell_user_to_save()
             return
 
-        self.view.erase_phantoms(PHANTOM_KEY_ALL)
+        self.view.erase_phantoms(self.phantom_set_key())
         phantoms = []  # type: list[sublime.Phantom] # type: ignore[misc]
 
         # If they are currently shown, toggle them off and return.
@@ -125,7 +123,7 @@ class BlameEraseAll(sublime_plugin.TextCommand):
 
     def run(self, edit):
         sublime.status_message("The git blame result is cleared.")
-        self.view.erase_phantoms(PHANTOM_KEY_ALL)
+        self.view.erase_phantoms(BlameShowAll.phantom_set_key())
         self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
         self.view.run_command("blame_restore_rulers")
 
diff --git a/src/blame_inline.py b/src/blame_inline.py
index 4288ce6..a3b972b 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -11,8 +11,6 @@
 )
 from .templates import blame_inline_phantom_css, blame_inline_phantom_html_template
 
-INLINE_BLAME_PHANTOM_SET_KEY = "git-blame-inline"
-
 
 class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
 
@@ -22,7 +20,7 @@ class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
 
     def __init__(self, view):
         super().__init__(view)
-        self.phantom_set = sublime.PhantomSet(view, INLINE_BLAME_PHANTOM_SET_KEY)
+        self.phantom_set = sublime.PhantomSet(view, self.phantom_set_key())
         self.timer = None
         self.delay_seconds = (
             pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_DELAY) / 1000
@@ -61,7 +59,7 @@ def _view(self):
         return self.view
 
     def close_by_user_request(self):
-        self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
+        self.view.erase_phantoms(self.phantom_set_key())
 
     def rerun(self, **kwargs):
         if self.timer:
@@ -98,7 +96,7 @@ def on_pkg_setting_changed(cls):
         for view in all_editor_views:
             ToggleInlineGitBlame.erase_viewlevel_customization(view)
             if not pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED):
-                view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
+                view.erase_phantoms(cls.phantom_set_key())
             # Do a dummy modification to the view's settings to induce the ViewEventListener applicability check to happen again.
             view.settings().set(cls.__name__, "")
             view.settings().erase(cls.__name__)
@@ -176,7 +174,7 @@ class ToggleInlineGitBlame(sublime_plugin.TextCommand):
     def run(self, edit):
         enabled = not BlameInlineListener.determine_enablement(self.view.settings())
         if not enabled:
-            self.view.erase_phantoms(INLINE_BLAME_PHANTOM_SET_KEY)
+            self.view.erase_phantoms(BlameInlineListener.phantom_set_key())
         self.view.settings().set(self.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED, enabled)
 
     # Overrides end --------------------------------------------------------------------

From ba2b6f798045ee2c6acef7c78c014c9a469daa6c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 7 Dec 2021 10:32:09 +0000
Subject: [PATCH 173/180] Rename constants

---
 src/blame_all.py | 27 +++++++++++++--------------
 1 file changed, 13 insertions(+), 14 deletions(-)

diff --git a/src/blame_all.py b/src/blame_all.py
index fa6da82..f86439f 100644
--- a/src/blame_all.py
+++ b/src/blame_all.py
@@ -4,10 +4,9 @@
 from .base import BaseBlame
 from .templates import blame_all_phantom_css, blame_all_phantom_html_template
 
-VIEW_SETTING_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
-
-VIEW_SETTING_RULERS = "rulers"
-VIEW_SETTING_RULERS_PREV = "rulers_prev"
+VIEW_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED = "git-blame-all-displayed"
+VIEW_SETTINGS_KEY_RULERS = "rulers"  # A stock ST setting
+VIEW_SETTINGS_KEY_RULERS_PREV = "rulers_prev"  # Made up by us
 
 
 class BlameShowAll(BaseBlame, sublime_plugin.TextCommand):
@@ -30,9 +29,9 @@ def run(self, edit):
         phantoms = []  # type: list[sublime.Phantom] # type: ignore[misc]
 
         # If they are currently shown, toggle them off and return.
-        if self.view.settings().get(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False):
+        if self.view.settings().get(VIEW_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED, False):
             self.phantom_set.update(phantoms)
-            self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
+            self.view.settings().erase(VIEW_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED)
             self.view.run_command("blame_restore_rulers")
             # Workaround a visible empty space sometimes remaining in the viewport.
             self.horizontal_scroll_to_limit(left=False)
@@ -75,7 +74,7 @@ def run(self, edit):
             phantoms.append(phantom)
 
         self.phantom_set.update(phantoms)
-        self.view.settings().set(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, True)
+        self.view.settings().set(VIEW_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED, True)
         self.store_rulers()
         # Bring the phantoms into view without the user needing to manually scroll left.
         self.horizontal_scroll_to_limit(left=True)
@@ -102,10 +101,10 @@ def phantom_region(self, line_number):
 
     def store_rulers(self):
         self.view.settings().set(
-            VIEW_SETTING_RULERS_PREV,
-            self.view.settings().get(VIEW_SETTING_RULERS),
+            VIEW_SETTINGS_KEY_RULERS_PREV,
+            self.view.settings().get(VIEW_SETTINGS_KEY_RULERS),
         )
-        self.view.settings().set(VIEW_SETTING_RULERS, [])
+        self.view.settings().set(VIEW_SETTINGS_KEY_RULERS, [])
 
     def horizontal_scroll_to_limit(self, *, left):
         x = 0.0 if left else self.view.layout_extent()[0]
@@ -124,7 +123,7 @@ class BlameEraseAll(sublime_plugin.TextCommand):
     def run(self, edit):
         sublime.status_message("The git blame result is cleared.")
         self.view.erase_phantoms(BlameShowAll.phantom_set_key())
-        self.view.settings().erase(VIEW_SETTING_PHANTOM_ALL_DISPLAYED)
+        self.view.settings().erase(VIEW_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED)
         self.view.run_command("blame_restore_rulers")
 
     # Overrides end --------------------------------------------------------------------
@@ -136,7 +135,7 @@ class BlameEraseAllListener(sublime_plugin.ViewEventListener):
 
     @classmethod
     def is_applicable(cls, settings):
-        return settings.get(VIEW_SETTING_PHANTOM_ALL_DISPLAYED, False)
+        return settings.get(VIEW_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED, False)
 
     def on_modified_async(self):
         self.view.run_command("blame_erase_all")
@@ -150,8 +149,8 @@ class BlameRestoreRulers(sublime_plugin.TextCommand):
 
     def run(self, edit):
         self.view.settings().set(
-            VIEW_SETTING_RULERS,
-            self.view.settings().get(VIEW_SETTING_RULERS_PREV),
+            VIEW_SETTINGS_KEY_RULERS,
+            self.view.settings().get(VIEW_SETTINGS_KEY_RULERS_PREV),
         )
 
     # Overrides end --------------------------------------------------------------------

From 99d0bef7fdbabb22c942bc24112a1a8385905461 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 7 Dec 2021 11:23:01 +0000
Subject: [PATCH 174/180] Do the SHA normalisation in a central place

---
 src/base.py           | 16 ++++++++++++++--
 src/blame.py          |  6 +-----
 tests/test_parsing.py | 25 ++++++++++++++++++++++---
 3 files changed, 37 insertions(+), 10 deletions(-)

diff --git a/src/base.py b/src/base.py
index c6d797a..ced834f 100644
--- a/src/base.py
+++ b/src/base.py
@@ -61,7 +61,7 @@ def parse_line(cls, line):
             """
         # 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 {}
+        return cls.postprocess_parse_result(m)
 
     # @todo Add a test for the `parse_line_with_relative_date` function in test_parsing.py
     @classmethod
@@ -83,7 +83,19 @@ def parse_line_with_relative_date(cls, line):
             """
         # 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 {}
+        return cls.postprocess_parse_result(m)
+
+    @classmethod
+    def postprocess_parse_result(cls, match):
+        if match:
+            d = match.groupdict()
+            # The SHA output by `git blame` may have a leading caret to indicate that it
+            # is a "boundary commit". That needs to be stripped before passing the SHA
+            # back to git CLI commands for other purposes.
+            d["sha_normalised"] = d["sha"].strip("^")
+            return d
+        else:
+            return {}
 
     def handle_phantom_button(self, href):
         url = urlparse(href)
diff --git a/src/blame.py b/src/blame.py
index 7e6c6b7..bf602a1 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -68,15 +68,11 @@ def run(self, edit, prevving=False, fixed_row_num=None, sha_skip_list=[]):
                 )
                 return
             sha = blame["sha"]
+            sha_normalised = blame["sha_normalised"]
             author = blame["author"]
             date = blame["date"]
             time = blame["time"]
 
-            # The SHA output by `git blame` may have a leading caret to indicate that it
-            # is a "boundary commit". That needs to be stripped before passing the SHA
-            # back to git CLI commands for other purposes.
-            sha_normalised = sha.strip("^")
-
             if sha_skip_list:
                 recently_skipped_sha = sha_skip_list[-1]
                 if sha_normalised == recently_skipped_sha:
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index b6fd31c..2e0292e 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -6,13 +6,13 @@
 # This strange form of import is required because our ST package name has a space in it.
 base = importlib.import_module("Git blame.src.base")
 
-# NOTE: The sample git-blame CLI outputs were taken from the https://github.com/sublimelsp/LSP
-# repo because they exhibit different numbers of components in author names.
-
 
 class TestParsing(unittest.TestCase):
     def test_git_blame_cli_output_parsing(self):
         samples = [
+            # NOTE: This section of sample git-blame CLI outputs were taken from the
+            # https://github.com/sublimelsp/LSP repo because they exhibit different
+            # numbers of components in author names:
             (
                 r"""4a3eb02f plugin/diagnostics.py (Tom van Ommeren  2019-11-27 21:42:13 +0100   1) import html""",
                 {
@@ -21,6 +21,7 @@ def test_git_blame_cli_output_parsing(self):
                     "file": "plugin/diagnostics.py",
                     "line_number": "1",
                     "sha": "4a3eb02f",
+                    "sha_normalised": "4a3eb02f",
                     "time": "21:42:13",
                     "timezone": "+0100",
                 },
@@ -33,6 +34,7 @@ def test_git_blame_cli_output_parsing(self):
                     "file": "plugin/diagnostics.py",
                     "line_number": "114",
                     "sha": "c937eff9",
+                    "sha_normalised": "c937eff9",
                     "time": "14:29:47",
                     "timezone": "+0100",
                 },
@@ -45,10 +47,27 @@ def test_git_blame_cli_output_parsing(self):
                     "file": "plugin/diagnostics.py",
                     "line_number": "272",
                     "sha": "16a30de1",
+                    "sha_normalised": "16a30de1",
                     "time": "08:34:46",
                     "timezone": "+0200",
                 },
             ),
+            #
+            # A "boundary commit" SHA:
+            #
+            (
+                r"""^ad61094 main.go (Duncan Holm 2016-01-18 21:22:16 +0000 1) package main""",
+                {
+                    "author": "Duncan Holm",
+                    "date": "2016-01-18",
+                    "file": "main.go",
+                    "line_number": "1",
+                    "sha": "^ad61094",
+                    "sha_normalised": "ad61094",
+                    "time": "21:22:16",
+                    "timezone": "+0000",
+                },
+            ),
         ]
         for cli_output_line, expected_result in samples:
             self.assertEqual(

From 251cacca951cc97388fffd2140c4bfb5e530c967 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 7 Dec 2021 11:32:27 +0000
Subject: [PATCH 175/180] Add new "Insta Diff" command to do [Show] immediately

Closes #57
---
 Keymaps/Default (Linux).sublime-keymap   |  4 +-
 Keymaps/Default (OSX).sublime-keymap     |  4 +-
 Keymaps/Default (Windows).sublime-keymap |  4 +-
 boot.py                                  |  2 +
 src/blame.py                             |  3 --
 src/blame_instadiff.py                   | 60 ++++++++++++++++++++++++
 6 files changed, 70 insertions(+), 7 deletions(-)
 create mode 100644 src/blame_instadiff.py

diff --git a/Keymaps/Default (Linux).sublime-keymap b/Keymaps/Default (Linux).sublime-keymap
index a4462b8..9651f08 100644
--- a/Keymaps/Default (Linux).sublime-keymap	
+++ b/Keymaps/Default (Linux).sublime-keymap	
@@ -1,4 +1,6 @@
 [
   {"keys": ["ctrl+shift+q"], "command": "blame"},
-  {"keys": ["ctrl+shift+c"], "command": "blame_show_all"}
+  {"keys": ["ctrl+shift+c"], "command": "blame_show_all"},
+  //{"keys": ["f5"],           "command": "toggle_inline_git_blame"},
+  //{"keys": ["f8"],           "command": "blame_insta_diff"},
 ]
diff --git a/Keymaps/Default (OSX).sublime-keymap b/Keymaps/Default (OSX).sublime-keymap
index 75c53c1..cbdcb6a 100644
--- a/Keymaps/Default (OSX).sublime-keymap	
+++ b/Keymaps/Default (OSX).sublime-keymap	
@@ -1,6 +1,6 @@
 [
   {"keys": ["ctrl+alt+b"],       "command": "blame"},
   {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"},
-
-  //{"keys": ["f8"], "command": "toggle_inline_git_blame"},
+  //{"keys": ["f5"],               "command": "toggle_inline_git_blame"},
+  //{"keys": ["f8"],               "command": "blame_insta_diff"},
 ]
diff --git a/Keymaps/Default (Windows).sublime-keymap b/Keymaps/Default (Windows).sublime-keymap
index 7581456..7a4c08d 100644
--- a/Keymaps/Default (Windows).sublime-keymap	
+++ b/Keymaps/Default (Windows).sublime-keymap	
@@ -1,4 +1,6 @@
 [
   {"keys": ["ctrl+alt+b"],       "command": "blame"},
-  {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"}
+  {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"},
+  // {"keys": ["f5"],               "command": "toggle_inline_git_blame"},
+  // {"keys": ["f8"],               "command": "blame_insta_diff"},
 ]
diff --git a/boot.py b/boot.py
index 9f9877b..aedfe6d 100644
--- a/boot.py
+++ b/boot.py
@@ -3,6 +3,8 @@
 from .src.blame import *  # noqa: F401,F403
 from .src.blame_all import *  # noqa: F401,F403
 from .src.blame_inline import *  # noqa: F401,F403
+from .src.blame_instadiff import *  # noqa: F401,F403
+
 
 def plugin_loaded():
     pass
diff --git a/src/blame.py b/src/blame.py
index bf602a1..9886aa5 100644
--- a/src/blame.py
+++ b/src/blame.py
@@ -6,9 +6,6 @@
 from .base import BaseBlame
 from .templates import blame_phantom_css, blame_phantom_html_template
 
-# @todo Make a command to open the latest diff ("CommitDescription") for the current line in a single keystroke.
-# @body Currently it takes a keystroke and then a mouse click on "Show"
-
 
 class Blame(BaseBlame, sublime_plugin.TextCommand):
 
diff --git a/src/blame_instadiff.py b/src/blame_instadiff.py
new file mode 100644
index 0000000..6f4fbb8
--- /dev/null
+++ b/src/blame_instadiff.py
@@ -0,0 +1,60 @@
+from urllib.parse import quote_plus
+
+import sublime_plugin
+
+from .base import BaseBlame
+
+
+class BlameInstaDiff(BaseBlame, sublime_plugin.TextCommand):
+
+    # Overrides (TextCommand) ----------------------------------------------------------
+
+    def run(self, edit):
+        if not self.has_suitable_view():
+            self.tell_user_to_save()
+            return
+
+        if len(self.view.sel()) != 1:
+            self.communicate_error(
+                "{0} requires there to be exactly 1 selection".format(
+                    self.__class__.__name__
+                )
+            )
+            return
+        sel0 = self.view.sel()[0]
+
+        row_num, _ = self.view.rowcol(sel0.begin())
+        line_num = row_num + 1
+        try:
+            blame_output = self.get_blame_text(self.view.file_name(), line_num=line_num)
+        except Exception as e:
+            self.communicate_error(e)
+            return
+
+        blame = self.parse_line(blame_output)
+        if not blame:
+            self.communicate_error(
+                "Failed to parse anything for {0}. Has git's output format changed?".format(
+                    self.__class__.__name__
+                )
+            )
+            return
+
+        href = "show?sha={0}".format(quote_plus(blame["sha_normalised"]))
+        self.handle_phantom_button(href)
+
+    # Overrides (BaseBlame) ------------------------------------------------------------
+
+    def _view(self):
+        return self.view
+
+    def close_by_user_request(self):
+        raise NotImplementedError()
+
+    def extra_cli_args(self, line_num):
+        return ["-L", "{0},{0}".format(line_num)]
+
+    def rerun(self, **kwargs):
+        self.run(None)
+
+    # Overrides end --------------------------------------------------------------------

From 9d13116096831774ddb1e6601f69e378daa43a16 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 7 Dec 2021 11:49:04 +0000
Subject: [PATCH 176/180] Rename commands and add Instadiff to Command Palette

---
 Commands/Default.sublime-commands        | 12 ++++++++----
 Keymaps/Default (Linux).sublime-keymap   |  4 ++--
 Keymaps/Default (OSX).sublime-keymap     |  4 ++--
 Keymaps/Default (Windows).sublime-keymap |  4 ++--
 src/blame_inline.py                      |  6 +++---
 src/blame_instadiff.py                   |  2 +-
 6 files changed, 18 insertions(+), 14 deletions(-)

diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
index fba9860..aa80e8e 100644
--- a/Commands/Default.sublime-commands
+++ b/Commands/Default.sublime-commands
@@ -1,8 +1,4 @@
 [
-  {
-    "caption": "Git Blame: Toggle Inline Blame",
-    "command": "toggle_inline_git_blame"
-  },
   {
     "caption": "Git Blame: Show",
     "command": "blame"
@@ -15,6 +11,14 @@
     "caption": "Git Blame: Erase All",
     "command": "blame_erase_all"
   },
+  {
+    "caption": "Git Blame: Toggle Inline Blame",
+    "command": "blame_toggle_inline"
+  },
+  {
+    "caption": "Git Blame: Instadiff",
+    "command": "blame_instadiff"
+  },
   {
     "caption": "Git Blame: Settings",
     // TWIN: Entry in "Main.sublime-menu"
diff --git a/Keymaps/Default (Linux).sublime-keymap b/Keymaps/Default (Linux).sublime-keymap
index 9651f08..34cdc2f 100644
--- a/Keymaps/Default (Linux).sublime-keymap	
+++ b/Keymaps/Default (Linux).sublime-keymap	
@@ -1,6 +1,6 @@
 [
   {"keys": ["ctrl+shift+q"], "command": "blame"},
   {"keys": ["ctrl+shift+c"], "command": "blame_show_all"},
-  //{"keys": ["f5"],           "command": "toggle_inline_git_blame"},
-  //{"keys": ["f8"],           "command": "blame_insta_diff"},
+  //{"keys": ["f5"],           "command": "blame_toggle_inline"},
+  //{"keys": ["f8"],           "command": "blame_instadiff"},
 ]
diff --git a/Keymaps/Default (OSX).sublime-keymap b/Keymaps/Default (OSX).sublime-keymap
index cbdcb6a..ad395a1 100644
--- a/Keymaps/Default (OSX).sublime-keymap	
+++ b/Keymaps/Default (OSX).sublime-keymap	
@@ -1,6 +1,6 @@
 [
   {"keys": ["ctrl+alt+b"],       "command": "blame"},
   {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"},
-  //{"keys": ["f5"],               "command": "toggle_inline_git_blame"},
-  //{"keys": ["f8"],               "command": "blame_insta_diff"},
+  //{"keys": ["f5"],               "command": "blame_toggle_inline"},
+  //{"keys": ["f8"],               "command": "blame_instadiff"},
 ]
diff --git a/Keymaps/Default (Windows).sublime-keymap b/Keymaps/Default (Windows).sublime-keymap
index 7a4c08d..9b93b63 100644
--- a/Keymaps/Default (Windows).sublime-keymap	
+++ b/Keymaps/Default (Windows).sublime-keymap	
@@ -1,6 +1,6 @@
 [
   {"keys": ["ctrl+alt+b"],       "command": "blame"},
   {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"},
-  // {"keys": ["f5"],               "command": "toggle_inline_git_blame"},
-  // {"keys": ["f8"],               "command": "blame_insta_diff"},
+  // {"keys": ["f5"],               "command": "blame_toggle_inline"},
+  // {"keys": ["f8"],               "command": "blame_instadiff"},
 ]
diff --git a/src/blame_inline.py b/src/blame_inline.py
index a3b972b..a1d74de 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -72,7 +72,7 @@ def rerun(self, **kwargs):
     @classmethod
     def determine_enablement(cls, view_settings):
         enabled = view_settings.get(
-            ToggleInlineGitBlame.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED
+            BlameToggleInline.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED
         )
         if enabled is None:
             enabled = pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED)
@@ -94,7 +94,7 @@ def on_pkg_setting_changed(cls):
             if view_is_editor(view)
         ]
         for view in all_editor_views:
-            ToggleInlineGitBlame.erase_viewlevel_customization(view)
+            BlameToggleInline.erase_viewlevel_customization(view)
             if not pkg_settings().get(PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED):
                 view.erase_phantoms(cls.phantom_set_key())
             # Do a dummy modification to the view's settings to induce the ViewEventListener applicability check to happen again.
@@ -164,7 +164,7 @@ def maybe_insert_phantoms(self, phantoms):
             self.phantom_set.update(phantoms)
 
 
-class ToggleInlineGitBlame(sublime_plugin.TextCommand):
+class BlameToggleInline(sublime_plugin.TextCommand):
 
     # Might as well reuse the same settings key, but at the view-level.
     VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED = PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED
diff --git a/src/blame_instadiff.py b/src/blame_instadiff.py
index 6f4fbb8..bfb5748 100644
--- a/src/blame_instadiff.py
+++ b/src/blame_instadiff.py
@@ -5,7 +5,7 @@
 from .base import BaseBlame
 
 
-class BlameInstaDiff(BaseBlame, sublime_plugin.TextCommand):
+class BlameInstadiff(BaseBlame, sublime_plugin.TextCommand):
 
     # Overrides (TextCommand) ----------------------------------------------------------
 

From dce993cd0f1daa877537cf47f06a2720b7d015b8 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 7 Dec 2021 18:17:03 +0000
Subject: [PATCH 177/180] InlineBlame: when user's selection spans multiple
 lines, show info for & at the line with the caret

i.e. respect the direction of the selection and choose either the first or last line, rather than always the last line
---
 src/blame_inline.py | 48 +++++++++++++++++++++++++++++++++------------
 1 file changed, 36 insertions(+), 12 deletions(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index a1d74de..5ebb9eb 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -107,22 +107,22 @@ def show_inline_blame(self):
             return
 
         phantoms = []
-        sels = self.view.sel()
-        if len(sels) < 1:
-            return
 
+        sels = self.view.sel()
         # @todo Support showing inline blame for multiple carets?
         # @body Maybe with a sanity check that there aren't too many (more than 10?)
-        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
+        if len(sels) != 1:
             return
-        pos = line.end()
-        row, _ = self.view.rowcol(line.begin())
-        anchor = sublime.Region(pos, pos)
+        sel0 = sels[0]
+
+        phantom_pos, caret_line_num = self.calculate_positions(sel0)
+        if not phantom_pos:
+            return
+
         try:
-            blame_output = self.get_blame_text(self.view.file_name(), line_num=row + 1)
+            blame_output = self.get_blame_text(
+                self.view.file_name(), line_num=caret_line_num
+            )
         except Exception:
             return
         blame = next(
@@ -152,13 +152,37 @@ def show_inline_blame(self):
             summary=summary,
         )
         phantom = sublime.Phantom(
-            anchor, body, sublime.LAYOUT_INLINE, self.handle_phantom_button
+            sublime.Region(phantom_pos),
+            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 calculate_positions(self, user_selection):
+        selection_goes_backwards = user_selection.a > user_selection.b
+
+        row, _ = self.view.rowcol(
+            user_selection.begin() if selection_goes_backwards else user_selection.end()
+        )
+        caret_line_num = row + 1
+
+        # Quantise the arbitrary user selection to line(s)
+        selection = self.view.line(user_selection)
+        if selection.size() <= 1:
+            # Not worth showing a blame phantom for an empty line.
+            return (None, caret_line_num)
+
+        if selection_goes_backwards:
+            phantom_pos = self.view.line(selection.begin()).end()
+        else:
+            phantom_pos = selection.end()
+
+        return (phantom_pos, caret_line_num)
+
     def maybe_insert_phantoms(self, phantoms):
         if not self.view.is_dirty():
             self.phantom_set.update(phantoms)

From 154163dc5e08eb37a3f7181f500719d577b61cfa Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Tue, 7 Dec 2021 19:32:42 +0000
Subject: [PATCH 178/180] Simplify show_inline_blame(...) function

---
 src/blame_inline.py | 47 +++++++++++++++++++--------------------------
 1 file changed, 20 insertions(+), 27 deletions(-)

diff --git a/src/blame_inline.py b/src/blame_inline.py
index 5ebb9eb..4b5dae4 100644
--- a/src/blame_inline.py
+++ b/src/blame_inline.py
@@ -123,37 +123,30 @@ def show_inline_blame(self):
             blame_output = self.get_blame_text(
                 self.view.file_name(), line_num=caret_line_num
             )
-        except Exception:
+        except Exception:  # Don't want to spam Console on failures.
             return
-        blame = next(
-            (
-                self.parse_line_with_relative_date(line)
-                for line in blame_output.splitlines()
-            ),
-            None,
-        )
-        if not blame:
+
+        blame = self.parse_line_with_relative_date(blame_output)
+        if not blame or blame["sha"] == "00000000":  # All zeros means uncommited change
             return
-        summary = ""
-        # Uncommitted changes have only zeros in sha
-        if blame["sha"] != "00000000":
-            try:
-                summary = self.get_commit_message_subject(
-                    blame["sha"], self.view.file_name()
-                )
-            except Exception:
-                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_separator=" · " if summary else "",
-            summary=summary,
-        )
+
+        try:
+            summary = self.get_commit_message_subject(
+                blame["sha"], self.view.file_name()
+            )
+        except Exception:  # Don't want to spam Console on failures.
+            return
+
         phantom = sublime.Phantom(
             sublime.Region(phantom_pos),
-            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_separator=" · " if summary else "",
+                summary=summary,
+            ),
             sublime.LAYOUT_INLINE,
             self.handle_phantom_button,
         )

From 42179a999d03b19d9f20b85d2f30fe5c288d0f59 Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Sat, 30 Apr 2022 20:08:16 +0100
Subject: [PATCH 179/180] Linter flake8-print is using a different error code
 since v5.0

---
 src/base.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/base.py b/src/base.py
index ced834f..f486e22 100644
--- a/src/base.py
+++ b/src/base.py
@@ -149,14 +149,14 @@ def communicate_error(self, e, modal=True):
         if isinstance(e, subprocess.CalledProcessError):
             user_msg += "\n\n{0}".format(e.output.decode())
 
-        print()  # noqa: T001
+        print()  # noqa: T201
         if modal:
             sublime.error_message(user_msg)
         else:
             sublime.status_message(user_msg)
             # Unlike with the error dialog, a status message is not automatically
             # persisted in the console too.
-            print(user_msg)  # noqa: T001
+            print(user_msg)  # noqa: T201
 
     @classmethod
     def phantom_set_key(cls):

From c44c0751dbf1ffdeeb050f6ef00762f8dec9e51c Mon Sep 17 00:00:00 2001
From: Duncan Holm <duncan@holm.scot>
Date: Fri, 8 Jul 2022 18:36:42 +0100
Subject: [PATCH 180/180] Stop defining plugin_{,un}loaded functions that
 aren't doing anything

---
 boot.py | 10 ++++------
 1 file changed, 4 insertions(+), 6 deletions(-)

diff --git a/boot.py b/boot.py
index aedfe6d..32db535 100644
--- a/boot.py
+++ b/boot.py
@@ -5,10 +5,8 @@
 from .src.blame_inline import *  # noqa: F401,F403
 from .src.blame_instadiff import *  # noqa: F401,F403
 
+# def plugin_loaded():
+#     pass
 
-def plugin_loaded():
-    pass
-
-
-def plugin_unloaded():
-    pass
+# def plugin_unloaded():
+#     pass
