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/.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/.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)
+-->
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: ''
+
+---
+
+
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/Commands/Default.sublime-commands b/Commands/Default.sublime-commands
new file mode 100644
index 0000000..aa80e8e
--- /dev/null
+++ b/Commands/Default.sublime-commands
@@ -0,0 +1,40 @@
+[
+  {
+    "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: Toggle Inline Blame",
+    "command": "blame_toggle_inline"
+  },
+  {
+    "caption": "Git Blame: Instadiff",
+    "command": "blame_instadiff"
+  },
+  {
+    "caption": "Git Blame: Settings",
+    // TWIN: Entry in "Main.sublime-menu"
+    "command": "edit_settings",
+    "args": {
+      "base_file": "${packages}/Git blame/Settings/Git blame.sublime-settings",
+      "default": "{\n\t$0\n}\n"
+    }
+  },
+  {
+    "caption": "Git Blame: Key Bindings",
+    // TWIN: Entry in "Main.sublime-menu"
+    "command": "edit_settings",
+    "args": {
+      "base_file": "${packages}/Git blame/Keymaps/Default (${platform}).sublime-keymap",
+      "default": "[\n\t$0\n]\n"
+    }
+  }
+]
diff --git a/Default (Linux).sublime-keymap b/Default (Linux).sublime-keymap
deleted file mode 100644
index 79a4062..0000000
--- a/Default (Linux).sublime-keymap	
+++ /dev/null
@@ -1,6 +0,0 @@
-[
-{
-    "keys": ["ctrl+shift+q"],
-    "command": "blame"
-}
-]
\ No newline at end of file
diff --git a/Default (OSX).sublime-keymap b/Default (OSX).sublime-keymap
deleted file mode 100644
index 7581456..0000000
--- a/Default (OSX).sublime-keymap	
+++ /dev/null
@@ -1,4 +0,0 @@
-[
-  {"keys": ["ctrl+alt+b"],       "command": "blame"},
-  {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"}
-]
diff --git a/Default (Windows).sublime-keymap b/Default (Windows).sublime-keymap
deleted file mode 100644
index 7581456..0000000
--- a/Default (Windows).sublime-keymap	
+++ /dev/null
@@ -1,4 +0,0 @@
-[
-  {"keys": ["ctrl+alt+b"],       "command": "blame"},
-  {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"}
-]
diff --git a/Default.sublime-commands b/Default.sublime-commands
deleted file mode 100644
index d3739d2..0000000
--- a/Default.sublime-commands
+++ /dev/null
@@ -1,14 +0,0 @@
-[
-    {
-        "caption": "Git Blame",
-        "command": "blame"
-    },
-    {
-        "caption": "Git Blame Show All",
-        "command": "blame_show_all"
-    },
-    {
-        "caption": "Git Blame Erase All",
-        "command": "blame_erase_all"
-    }
-]
diff --git a/Keymaps/Default (Linux).sublime-keymap b/Keymaps/Default (Linux).sublime-keymap
new file mode 100644
index 0000000..34cdc2f
--- /dev/null
+++ b/Keymaps/Default (Linux).sublime-keymap	
@@ -0,0 +1,6 @@
+[
+  {"keys": ["ctrl+shift+q"], "command": "blame"},
+  {"keys": ["ctrl+shift+c"], "command": "blame_show_all"},
+  //{"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
new file mode 100644
index 0000000..ad395a1
--- /dev/null
+++ b/Keymaps/Default (OSX).sublime-keymap	
@@ -0,0 +1,6 @@
+[
+  {"keys": ["ctrl+alt+b"],       "command": "blame"},
+  {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"},
+  //{"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
new file mode 100644
index 0000000..9b93b63
--- /dev/null
+++ b/Keymaps/Default (Windows).sublime-keymap	
@@ -0,0 +1,6 @@
+[
+  {"keys": ["ctrl+alt+b"],       "command": "blame"},
+  {"keys": ["ctrl+alt+shift+b"], "command": "blame_show_all"},
+  // {"keys": ["f5"],               "command": "blame_toggle_inline"},
+  // {"keys": ["f8"],               "command": "blame_instadiff"},
+]
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/Menus/Main.sublime-menu b/Menus/Main.sublime-menu
new file mode 100644
index 0000000..60b5168
--- /dev/null
+++ b/Menus/Main.sublime-menu
@@ -0,0 +1,35 @@
+[
+  {
+    "id": "preferences",
+    "children": [
+      {
+        "id": "package-settings",
+        "children": [
+          {
+            "caption": "Git blame",
+            "children": [
+              {
+                "caption": "Settings",
+                // TWIN: Entry in "Default.sublime-commands"
+                "command": "edit_settings",
+                "args": {
+                  "base_file": "${packages}/Git blame/Settings/Git blame.sublime-settings",
+                  "default": "{\n\t$0\n}\n"
+                }
+              },
+              {
+                "caption": "Key Bindings",
+                // TWIN: Entry in "Default.sublime-commands"
+                "command": "edit_settings",
+                "args": {
+                  "base_file": "${packages}/Git blame/Keymaps/Default (${platform}).sublime-keymap",
+                  "default": "[\n\t$0\n]\n"
+                }
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+]
diff --git a/README.md b/README.md
index 0981b92..364b8c7 100644
--- a/README.md
+++ b/README.md
@@ -1,34 +1,56 @@
-[![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 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)
 
+# Git blame - Package for Sublime Text
 
-# Git blame - Sublime text 3 plugin
-Sublime text 3 - Git blame the line
+This package enables you to query Git "blame" information for files while you are viewing/editing them in Sublime Text.
 
-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.
+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.
 
-View on [packagecontrol.io](https://packagecontrol.io/packages/Git%20blame)
+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 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).
 
- > Right click > Git blame
+(Instead of pressing the keyboard shortcut, you can alternatively right click and select *Git Blame* from the context menu)
 
-### Checking the result of `git blame` for the whole file
+The blame information will appear:
 
-To show the `git blame` result: Open the command pallette and select `Git Blame Show All`.
+![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/docs/screenshot-blame.png)
 
-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.
+If you want, you can then click `[Show]` to show the commit in full:
 
-## Example
+![Screenshot](https://raw.githubusercontent.com/frou/st3-gitblame/master/docs/screenshot-blame-show.png)
 
-<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">
+...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 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.
+
+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)
diff --git a/Settings/Git blame.sublime-settings b/Settings/Git blame.sublime-settings
new file mode 100644
index 0000000..f8e8422
--- /dev/null
+++ b/Settings/Git blame.sublime-settings	
@@ -0,0 +1,12 @@
+{
+    // 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": [],
+    "inline_blame_enabled": false,
+    "inline_blame_delay": 300
+}
diff --git a/boot.py b/boot.py
new file mode 100644
index 0000000..32db535
--- /dev/null
+++ b/boot.py
@@ -0,0 +1,12 @@
+# 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 *  # 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
+
+# def plugin_unloaded():
+#     pass
diff --git a/docs/packagecontrol_changelogs/1.2.0.txt b/docs/packagecontrol_changelogs/1.2.0.txt
new file mode 100644
index 0000000..5e5a2ac
--- /dev/null
+++ b/docs/packagecontrol_changelogs/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.
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/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/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/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.
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/docs/screenshot-blame-show.png b/docs/screenshot-blame-show.png
new file mode 100644
index 0000000..128194b
Binary files /dev/null and b/docs/screenshot-blame-show.png differ
diff --git a/docs/screenshot-blame.png b/docs/screenshot-blame.png
new file mode 100644
index 0000000..cc21d2d
Binary files /dev/null and b/docs/screenshot-blame.png differ
diff --git a/docs/screenshot-blameall.png b/docs/screenshot-blameall.png
new file mode 100644
index 0000000..12f1ca2
Binary files /dev/null and b/docs/screenshot-blameall.png differ
diff --git a/docs/screenshot-palette.png b/docs/screenshot-palette.png
new file mode 100644
index 0000000..b37b98d
Binary files /dev/null and b/docs/screenshot-palette.png differ
diff --git a/git-blame.py b/git-blame.py
deleted file mode 100644
index 7f3d786..0000000
--- a/git-blame.py
+++ /dev/null
@@ -1,385 +0,0 @@
-import sublime
-import sublime_plugin
-import os
-import re
-import functools
-import subprocess
-
-PHANTOM_KEY_ALL = 'git-blame-all'
-SETTING_PHANTOM_ALL_DISPLAYED = 'git-blame-all-displayed'
-
-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>
-'''
-
-template_one = '''
-    <body id="inline-git-blame">
-        {stylesheet}
-        <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">''' + chr(0x00D7) + '''</a>
-            </span>
-        </div>
-    </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):
-        self.view = view
-        self.phantom_set = sublime.PhantomSet(view, 'git-blame')
-
-    @functools.lru_cache(128, False)
-    def get_blame(self, line, path):
-        return subprocess.check_output(
-            ["git", "blame", "--minimal", "-w", "-L {0},{0}".format(line), path],
-            cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=si,
-            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] == '(':
-            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", sha],
-            cwd=os.path.dirname(os.path.realpath(path)),
-            startupinfo=si,
-            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('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)
-
-            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)
-            phantoms.append(phantom)
-        self.phantom_set.update(phantoms)
-
-
-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", 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):
-        view = self.view
-        view.set_scratch(True)
-        view.set_syntax_file('Packages/Diff/Diff.sublime-syntax')
-        view.insert(edit, 0, desc)
-        view.set_name(scratch_view_name)
-
-
-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 = "st3-gitblame:\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/messages.json b/messages.json
new file mode 100644
index 0000000..73f848e
--- /dev/null
+++ b/messages.json
@@ -0,0 +1,8 @@
+{
+  "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.3.0": "docs/packagecontrol_changelogs/1.3.0.txt",
+  "1.3.1": "docs/packagecontrol_changelogs/1.3.1.txt"
+}
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/src/base.py b/src/base.py
new file mode 100644
index 0000000..f486e22
--- /dev/null
+++ b/src/base.py
@@ -0,0 +1,181 @@
+import os
+import re
+import subprocess
+import sys
+from abc import ABCMeta, abstractmethod
+from urllib.parse import parse_qs, urlparse
+
+import sublime
+
+from .settings import PKG_SETTINGS_KEY_CUSTOMBLAMEFLAGS, pkg_settings
+
+
+class BaseBlame(metaclass=ABCMeta):
+    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(view_file_path)),
+            startupinfo=startup_info,
+            stderr=subprocess.STDOUT,
+        ).decode()
+
+    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_fulltext(self, sha, path):
+        cli_args = ["show", "--no-color", sha]
+        return self.run_git(path, cli_args)
+
+    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
+    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 cls.postprocess_parse_result(m)
+
+    # @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):
+        """
+        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 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)
+        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_fulltext(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.rerun(
+                prevving=True,
+                fixed_row_num=int(row_num),
+                sha_skip_list=sha_skip_list,
+            )
+        elif url.path == "close":
+            self.close_by_user_request()
+        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()
+
+    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())
+
+        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: T201
+
+    @classmethod
+    def phantom_set_key(cls):
+        return "git-blame" + cls.__name__
+
+    # ------------------------------------------------------------
+
+    @abstractmethod
+    def _view(self):
+        ...
+
+    @abstractmethod
+    def close_by_user_request(self):
+        ...
+
+    @abstractmethod
+    def extra_cli_args(self, **kwargs):
+        ...
+
+    @abstractmethod
+    def rerun(self, **kwargs):
+        ...
diff --git a/src/blame.py b/src/blame.py
new file mode 100644
index 0000000..9886aa5
--- /dev/null
+++ b/src/blame.py
@@ -0,0 +1,145 @@
+from urllib.parse import quote_plus
+
+import sublime
+import sublime_plugin
+
+from .base import BaseBlame
+from .templates import blame_phantom_css, blame_phantom_html_template
+
+
+class Blame(BaseBlame, sublime_plugin.TextCommand):
+
+    # Overrides (TextCommand) ----------------------------------------------------------
+
+    def __init__(self, view):
+        super().__init__(view)
+        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():
+            self.tell_user_to_save()
+            return
+
+        phantoms = []
+
+        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
+            # 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_num, _ = self.view.rowcol(region.begin())
+            line_num = row_num + 1
+
+            full_path = self.view.file_name()
+
+            try:
+                blame_output = self.get_blame_text(
+                    full_path, line_num=line_num, sha_skip_list=sha_skip_list
+                )
+            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
+            sha = blame["sha"]
+            sha_normalised = blame["sha_normalised"]
+            author = blame["author"]
+            date = blame["date"]
+            time = blame["time"]
+
+            if sha_skip_list:
+                recently_skipped_sha = sha_skip_list[-1]
+                if sha_normalised == recently_skipped_sha:
+                    sublime.message_dialog(
+                        "No earlier commits affected line {0}".format(line_num)
+                    )
+                    return
+
+            phantoms.append(
+                sublime.Phantom(
+                    line_region,
+                    blame_phantom_html_template.format(
+                        css=blame_phantom_css,
+                        sha=sha,
+                        sha_not_latest_indicator=" *" if sha_skip_list else "",
+                        author=author,
+                        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
+                        # a [Prev] button has been clicked multiple times.
+                        qs_skip_keyvals="&amp;".join(
+                            [
+                                "skip={0}".format(quote_plus(skipee))
+                                for skipee in sha_skip_list
+                            ]
+                        ),
+                    ),
+                    sublime.LAYOUT_BLOCK,
+                    self.handle_phantom_button,
+                )
+            )
+
+        self.phantom_set.update(phantoms)
+
+    # Overrides (BaseBlame) ------------------------------------------------------------
+
+    def _view(self):
+        return self.view
+
+    def close_by_user_request(self):
+        self.phantom_set.update([])
+
+    def extra_cli_args(self, line_num, sha_skip_list):
+        args = ["-L", "{0},{0}".format(line_num)]
+        for skipped_sha in sha_skip_list:
+            args.extend(["--ignore-rev", skipped_sha])
+        return args
+
+    def rerun(self, **kwargs):
+        self.run(None, **kwargs)
+
+    # Overrides end --------------------------------------------------------------------
+
+    def phantom_exists_for_region(self, region):
+        return any(p.region == region for p in self.phantom_set.phantoms)
+
+
+class BlameInsertCommitDescription(sublime_plugin.TextCommand):
+
+    # Overrides begin ------------------------------------------------------------------
+
+    def run(self, edit, desc, scratch_view_name):
+        view = self.view
+        view.set_scratch(True)
+        view.assign_syntax("Packages/Diff/Diff.sublime-syntax")
+        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
new file mode 100644
index 0000000..f86439f
--- /dev/null
+++ b/src/blame_all.py
@@ -0,0 +1,156 @@
+import sublime
+import sublime_plugin
+
+from .base import BaseBlame
+from .templates import blame_all_phantom_css, blame_all_phantom_html_template
+
+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):
+
+    HORIZONTAL_SCROLL_DELAY_MS = 100
+
+    # Overrides (TextCommand) ----------------------------------------------------------
+
+    def __init__(self, view):
+        super().__init__(view)
+        self.phantom_set = sublime.PhantomSet(self.view, self.phantom_set_key())
+        self.pattern = None
+
+    def run(self, edit):
+        if not self.has_suitable_view():
+            self.tell_user_to_save()
+            return
+
+        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.
+        if self.view.settings().get(VIEW_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED, False):
+            self.phantom_set.update(phantoms)
+            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)
+            self.horizontal_scroll_to_limit(left=True)
+            return
+
+        try:
+            blame_output = self.get_blame_text(self.view.file_name())
+        except Exception as e:
+            self.communicate_error(e)
+            return
+
+        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(
+                "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"])
+            author = blame["author"]
+
+            phantom = sublime.Phantom(
+                self.phantom_region(line_number),
+                blame_all_phantom_html_template.format(
+                    css=blame_all_phantom_css,
+                    sha=blame["sha"],
+                    author=author + "&nbsp;" * (max_author_len - len(author)),
+                    date=blame["date"],
+                    time=blame["time"],
+                ),
+                sublime.LAYOUT_INLINE,
+                self.handle_phantom_button,
+            )
+            phantoms.append(phantom)
+
+        self.phantom_set.update(phantoms)
+        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)
+
+    # Overrides (BaseBlame) ------------------------------------------------------------
+
+    def _view(self):
+        return self.view
+
+    def extra_cli_args(self, **kwargs):
+        return []
+
+    def close_by_user_request(self):
+        self.view.run_command("blame_erase_all")
+
+    def rerun(self, **kwargs):
+        self.run(None)
+
+    # 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)
+
+    def store_rulers(self):
+        self.view.settings().set(
+            VIEW_SETTINGS_KEY_RULERS_PREV,
+            self.view.settings().get(VIEW_SETTINGS_KEY_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]
+        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):
+
+    # Overrides begin ------------------------------------------------------------------
+
+    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_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED)
+        self.view.run_command("blame_restore_rulers")
+
+    # Overrides end --------------------------------------------------------------------
+
+
+class BlameEraseAllListener(sublime_plugin.ViewEventListener):
+
+    # Overrides begin ------------------------------------------------------------------
+
+    @classmethod
+    def is_applicable(cls, settings):
+        return settings.get(VIEW_SETTINGS_KEY_PHANTOM_ALL_DISPLAYED, False)
+
+    def on_modified_async(self):
+        self.view.run_command("blame_erase_all")
+
+    # Overrides end --------------------------------------------------------------------
+
+
+class BlameRestoreRulers(sublime_plugin.TextCommand):
+
+    # Overrides begin ------------------------------------------------------------------
+
+    def run(self, edit):
+        self.view.settings().set(
+            VIEW_SETTINGS_KEY_RULERS,
+            self.view.settings().get(VIEW_SETTINGS_KEY_RULERS_PREV),
+        )
+
+    # Overrides end --------------------------------------------------------------------
diff --git a/src/blame_inline.py b/src/blame_inline.py
new file mode 100644
index 0000000..4b5dae4
--- /dev/null
+++ b/src/blame_inline.py
@@ -0,0 +1,201 @@
+import threading
+
+import sublime
+import sublime_plugin
+
+from .base import BaseBlame
+from .settings import (
+    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
+
+
+class BlameInlineListener(BaseBlame, sublime_plugin.ViewEventListener):
+
+    pkg_setting_callback_added = False
+
+    # Overrides (ViewEventListener) ----------------------------------------------------
+
+    def __init__(self, view):
+        super().__init__(view)
+        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
+        )
+        # Show it immediately for the initially selected line.
+        self.show_inline_blame()
+
+    @classmethod
+    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 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.
+        self.rerun()
+
+    # Overrides (BaseBlame) ------------------------------------------------------------
+
+    def extra_cli_args(self, line_num):
+        return ["-L", "{0},{0}".format(line_num), "--date=relative"]
+
+    def _view(self):
+        return self.view
+
+    def close_by_user_request(self):
+        self.view.erase_phantoms(self.phantom_set_key())
+
+    def rerun(self, **kwargs):
+        if self.timer:
+            self.timer.cancel()
+        self.timer = threading.Timer(self.delay_seconds, self.show_inline_blame)
+        self.timer.start()
+
+    # Overrides end --------------------------------------------------------------------
+
+    @classmethod
+    def determine_enablement(cls, view_settings):
+        enabled = view_settings.get(
+            BlameToggleInline.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):
+        # 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)
+        ]
+        for view in all_editor_views:
+            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.
+            view.settings().set(cls.__name__, "")
+            view.settings().erase(cls.__name__)
+
+    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()
+        # @todo Support showing inline blame for multiple carets?
+        # @body Maybe with a sanity check that there aren't too many (more than 10?)
+        if len(sels) != 1:
+            return
+        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=caret_line_num
+            )
+        except Exception:  # Don't want to spam Console on failures.
+            return
+
+        blame = self.parse_line_with_relative_date(blame_output)
+        if not blame or blame["sha"] == "00000000":  # All zeros means uncommited change
+            return
+
+        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),
+            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,
+        )
+        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)
+
+
+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
+
+    # Overrides begin ------------------------------------------------------------------
+
+    def run(self, edit):
+        enabled = not BlameInlineListener.determine_enablement(self.view.settings())
+        if not enabled:
+            self.view.erase_phantoms(BlameInlineListener.phantom_set_key())
+        self.view.settings().set(self.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED, enabled)
+
+    # Overrides end --------------------------------------------------------------------
+
+    @classmethod
+    def erase_viewlevel_customization(cls, view):
+        view.settings().erase(cls.VIEW_SETTINGS_KEY_INLINE_BLAME_ENABLED)
diff --git a/src/blame_instadiff.py b/src/blame_instadiff.py
new file mode 100644
index 0000000..bfb5748
--- /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 --------------------------------------------------------------------
diff --git a/src/settings.py b/src/settings.py
new file mode 100644
index 0000000..ec1c799
--- /dev/null
+++ b/src/settings.py
@@ -0,0 +1,14 @@
+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_CUSTOMBLAMEFLAGS = "custom_blame_flags"
+
+PKG_SETTINGS_KEY_INLINE_BLAME_ENABLED = "inline_blame_enabled"
+PKG_SETTINGS_KEY_INLINE_BLAME_DELAY = "inline_blame_delay"
diff --git a/src/templates.py b/src/templates.py
new file mode 100644
index 0000000..78e18c4
--- /dev/null
+++ b/src/templates.py
@@ -0,0 +1,111 @@
+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> ({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}
+                <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>
+    </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}&nbsp;&nbsp;{author}&nbsp;&nbsp;{date}&nbsp;&nbsp;{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 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_inline_phantom_html_template = """
+    <body id="inline-git-blame">
+        <style>{css}</style>
+        <div class="phantom">
+            <span class="message">
+                {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>
+"""
+
+
+blame_inline_phantom_css = """
+    div.phantom {
+        color: color(color(var(--bluish) blend(var(--background) 60%)) min-contrast(var(--background) 2.0));
+        padding: 0;
+        margin-left: 50px;
+    }
+    div.phantom a {
+        text-decoration: inherit;
+    }
+"""
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
new file mode 100644
index 0000000..2e0292e
--- /dev/null
+++ b/tests/test_parsing.py
@@ -0,0 +1,76 @@
+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.
+base = importlib.import_module("Git blame.src.base")
+
+
+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""",
+                {
+                    "author": "Tom van Ommeren",
+                    "date": "2019-11-27",
+                    "file": "plugin/diagnostics.py",
+                    "line_number": "1",
+                    "sha": "4a3eb02f",
+                    "sha_normalised": "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",
+                    "sha_normalised": "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",
+                    "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(
+                base.BaseBlame.parse_line(cli_output_line),  # type: ignore [attr-defined]
+                expected_result,
+            )
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
