initial commit

This commit is contained in:
Vaclav Tvrdik
2026-09-23 21:10:59 +02:00
commit 8c174bc131
23 changed files with 2192 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.venv/
.pytest_cache/
*.sublime-workspace

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.8

35
Default.sublime-keymap Normal file
View File

@@ -0,0 +1,35 @@
[
{ "keys": ["f"], "command": "vdiff_action", "args": {"action": "files"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
"context": [
{"key": "setting.vdiff_view"},
{"key": "panel_visible", "operand": false},
{"key": "overlay_visible", "operand": false},
{"key": "popup_visible", "operand": false},
{"key": "auto_complete_visible", "operand": false},
{"key": "num_selections", "operand": 1}
] }
]

28
README.md Normal file
View File

@@ -0,0 +1,28 @@
# vDiff
Review what a coding agent changed without leaving Sublime Text: the whole file, deletions in red,
insertions in green, in the file's own syntax highlighting. Stage, unstage or discard the file, or jump
to the real file at the caret's line.
Install: symlink this directory into `Packages` as `vDiff` (Python 3.8 plugin host, no dependencies).
Command Palette:
- **vDiff: Changed Files** – uncommitted changes (staged, unstaged, untracked). Arrowing through
the list previews each file; Enter keeps it, Esc goes back to what was shown.
- **vDiff: Recent Commits** – last N commits, then the files of the chosen commit. A key binding to
`vdiff_commits` with `{"sha": "HEAD"}` skips the list and reviews the last commit directly.
Keys inside the review view:
| key | action |
|---|---|
| `f` / `c` | changed files / recent commits panel |
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode) |
| `o` or Enter | open the real file at this line |
| `]` / `[` | next / previous change |
| `n` / `p` | next / previous file of the current list |
| `r` | refresh |
| Esc | close the view |
The header links do the same with the mouse. Settings: `vDiff.sublime-settings`.

29
diff.sublime-project Normal file
View File

@@ -0,0 +1,29 @@
{
"folders":
[
{
"path": "./",
}
],
"settings": {
"python_interpreter": "${project_path}/.venv/bin/python3",
},
"build_systems":
[
{
"file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
"name": "venv Python file",
"selector": "source.python",
"shell_cmd": "source ${project_path}/.venv/bin/activate && ${project_path}/.venv/bin/python3 -u \"$file\""
},
],
"debugger_configurations": [
{
"name": "Run file",
"type": "python",
"request": "launch",
"program": "${file}",
}
],
}

41
main.py Normal file
View File

@@ -0,0 +1,41 @@
""" vDiff plugin entry point: review uncommitted changes and recent commits inside Sublime Text
Sublime reloads a plugin only when this file changes; after editing a submodule, save this file to reload everything.
"""
import logging
import sys
import sublime
# sublime reloads only this file: drop the submodules so an edited one is re-imported below.
# Open review views lose their in-memory state and are rebuilt from their settings by _restore_all.
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
del sys.modules[_name]
# imported for sublime's command discovery, not called here
from .vdiff.commands import (
VdiffActionCommand,
VdiffCommitsCommand,
VdiffFilesCommand,
restore_view,
)
from .vdiff.listeners import VdiffEventListener
from .vdiff.render import VdiffReplaceCommand
from .vdiff import state
from .vdiff.utils import settings
def plugin_loaded():
"""Set the log level and re-render review views restored from the last session"""
level = logging.DEBUG if settings().get("debug", False) else logging.INFO
logging.basicConfig()
logging.getLogger("vdiff").setLevel(level)
# the API is ready, but views are still being restored
sublime.set_timeout(_restore_all, 500)
def _restore_all():
for window in sublime.windows():
for view in window.views():
if state.is_review_view(view) and state.get(view) is None:
restore_view(view)

7
tests/conftest.py Normal file
View File

@@ -0,0 +1,7 @@
import os
import sys
# tests import the package directly; sublime/sublime_plugin come from tests/stubs
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, os.path.join(HERE, "stubs"))

223
tests/stubs/sublime.py Normal file
View File

@@ -0,0 +1,223 @@
""" Minimal sublime API stub so the plugin modules import and header/regions logic runs offline """
OP_EQUAL = 0
OP_NOT_EQUAL = 1
DRAW_NO_OUTLINE = 1
DRAW_NO_FILL = 2
LAYOUT_BLOCK = 2
ENCODED_POSITION = 1
KIND_ID_COLOR_REDISH = 1
KIND_ID_COLOR_ORANGISH = 2
KIND_ID_COLOR_YELLOWISH = 3
KIND_ID_COLOR_GREENISH = 4
KIND_ID_COLOR_CYANISH = 5
KIND_ID_COLOR_BLUISH = 6
KIND_ID_COLOR_PURPLISH = 7
KIND_ID_COLOR_PINKISH = 8
KIND_AMBIGUOUS = (0, "", "")
class Region:
def __init__(self, a, b=None):
self.a = a
self.b = a if b is None else b
def begin(self):
return min(self.a, self.b)
def end(self):
return max(self.a, self.b)
def __eq__(self, other):
return (self.a, self.b) == (other.a, other.b)
def __repr__(self):
return "Region({}, {})".format(self.a, self.b)
class Settings(dict):
def get(self, key, default=None):
return dict.get(self, key, default)
def set(self, key, value):
self[key] = value
def add_on_change(self, tag, callback):
pass
_settings = {}
def load_settings(name):
return _settings.setdefault(name, Settings())
def set_timeout(fn, delay=0):
fn()
def set_timeout_async(fn, delay=0):
fn()
def status_message(text):
pass
def packages_path():
return "/tmp/Packages"
def windows():
return []
class QuickPanelItem:
def __init__(self, trigger, details="", annotation="", kind=KIND_AMBIGUOUS):
self.trigger, self.details, self.annotation, self.kind = trigger, details, annotation, kind
class Phantom:
def __init__(self, region, content, layout, on_navigate=None):
self.region, self.content, self.layout, self.on_navigate = region, content, layout, on_navigate
class Window:
"""Captures the quick panel so tests can drive its callbacks"""
def __init__(self):
self.panel = None
self.commands = []
def focus_view(self, view):
pass
def run_command(self, name, args=None):
self.commands.append((name, args))
def show_quick_panel(self, items, on_select, flags=0, selected=-1, on_highlight=None, placeholder=None):
self.panel = (items, on_select, selected, on_highlight, placeholder)
class View:
"""Text buffer with just the calls render.py makes"""
def __init__(self, text=""):
self.text = text
self.phantoms = {}
self.settings_ = Settings()
self.regions = {}
self.syntax = None
self.read_only = True
self.name_ = ""
self.sel_ = []
self.status = {}
def id(self):
return 1
def settings(self):
return self.settings_
def size(self):
return len(self.text)
def replace(self, edit, region, text):
self.text = self.text[: region.begin()] + text + self.text[region.end() :]
def run_command(self, name, args=None):
if name == "vdiff_replace":
self.replace(None, Region(0, self.size()), args["text"])
def set_read_only(self, flag):
self.read_only = flag
def is_read_only(self):
return self.read_only
def assign_syntax(self, path):
self.syntax = path
def add_regions(self, key, regions, scope="", icon="", flags=0):
self.regions[key] = (list(regions), scope, icon, flags)
def erase_regions(self, key):
self.regions.pop(key, None)
def text_point(self, row, col):
lines = self.text.split("\n")
return sum(len(l) + 1 for l in lines[:row]) + col
def rowcol(self, point):
before = self.text[:point]
row = before.count("\n")
return row, len(before) - (before.rfind("\n") + 1)
def full_line(self, point):
start = self.text.rfind("\n", 0, point) + 1
end = self.text.find("\n", point)
end = len(self.text) if end == -1 else end + 1
return Region(start, end)
def erase_phantoms(self, key):
self.phantoms.pop(key, None)
def add_phantom(self, key, region, content, layout, on_navigate=None):
self.phantoms.setdefault(key, []).append(Phantom(region, content, layout, on_navigate))
return len(self.phantoms[key])
def set_name(self, name):
self.name_ = name
def name(self):
return self.name_
def set_scratch(self, flag):
pass
def set_status(self, key, value):
self.status[key] = value
def set_viewport_position(self, xy, animate=True):
pass
def show_at_center(self, point):
pass
def style_for_scope(self, scope):
return {"foreground": "#ffffff", "background": "#333333"}
def is_valid(self):
return True
class _Sel(list):
def clear(self):
del self[:]
def add(self, region):
self.append(region)
def sel(self):
if not isinstance(self.sel_, View._Sel):
self.sel_ = View._Sel(self.sel_)
return self.sel_
def find_syntax_for_file(path, first_line=""):
class Syntax:
pass
syntax = Syntax()
syntax.path = "Packages/Python/Python.sublime-syntax" if path.endswith(".py") else "Packages/Text/Plain text.tmLanguage"
return syntax
def ui_info():
return {"system": {"style": "dark"}, "color_scheme": {"value": "Mariana.sublime-color-scheme", "resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme"}}
def decode_value(text):
import json
return json.loads(text)

View File

@@ -0,0 +1,12 @@
class TextCommand:
def __init__(self, view):
self.view = view
class WindowCommand:
def __init__(self, window):
self.window = window
class EventListener:
pass

126
tests/test_commands.py Normal file
View File

@@ -0,0 +1,126 @@
import sublime # the stub, via conftest
from vdiff import commands, git, state
def change(path, status="M"):
return git.FileChange(path, status, False, 1, 1, None)
def flush_previews():
"""The stub runs set_timeout synchronously, so previews are already shown"""
def test_esc_restores_previous_file_and_mode(monkeypatch):
shown = []
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append((st.sha, c.path)))
view = sublime.View()
st = state.ViewState("/repo")
st.change = change("render.py")
st.files = [st.change]
# switch to a commit, as open_commit does; the panel gets the pre-switch snapshot
kept = st.enter("abc", git.CommitInfo("abc", "abc1234", "A", "d", "s", ""))
assert st.change is None and kept.change.path == "render.py"
files = [change("notes.txt")]
window = sublime.Window()
commands.show_files_panel(window, view, st, files, kept, placeholder="x")
items, on_select, selected, on_highlight, _ = window.panel
assert [i.trigger for i in items] == ["notes.txt"] and selected == 0
on_highlight(0)
assert shown[-1] == ("abc", "notes.txt")
on_select(-1)
assert st.sha is None and st.commit is None and st.change.path == "render.py" and st.files == [st.change]
assert shown[-1] == (None, "render.py")
def test_esc_with_nothing_kept_keeps_preview(monkeypatch):
shown = []
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(c.path))
st = state.ViewState("/repo")
window = sublime.Window()
commands.show_files_panel(window, sublime.View(), st, [change("a.py"), change("b.py")], st.kept())
_, on_select, _, on_highlight, _ = window.panel
on_highlight(1)
on_select(-1)
assert shown == ["b.py"]
def test_enter_skips_refetch_when_preview_is_current(monkeypatch):
shown = []
def fake_show(view, st, c):
st.token += 1
st.change = c
st.rendered_token = st.token
shown.append(c.path)
monkeypatch.setattr(commands, "show_file", fake_show)
st = state.ViewState("/repo")
window = sublime.Window()
files = [change("a.py"), change("b.py")]
commands.show_files_panel(window, sublime.View(), st, files, st.kept())
_, on_select, _, on_highlight, _ = window.panel
on_highlight(1)
on_select(1)
assert shown == ["b.py"] and st.files[1].path == "b.py"
on_select(0)
assert shown == ["b.py", "a.py"]
def test_on_cancel_goes_back_to_commit_list(monkeypatch):
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
st = state.ViewState("/repo")
kept = st.enter("abc", None)
window = sublime.Window()
back = []
commands.show_files_panel(window, sublime.View(), st, [change("a.py")], kept, on_cancel=lambda: back.append(1))
window.panel[1](-1)
assert back == [1] and st.sha is None
def test_step_change_uses_blocks():
view = sublime.View()
st = state.ViewState("/repo")
st.blocks = [(2, 4), (7, 8)]
view.text = "\n" + "\n".join("l{}".format(i) for i in range(10))
view.sel().add(sublime.Region(commands.render.index_point(view, 0)))
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 2
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 7
commands.ACTIONS["prev_change"](view, None, st)
assert commands.render.caret_index(view) == 2
def test_file_items_annotations():
items = commands.file_items([git.FileChange("x.py", "?", False, 3, 0, None), git.FileChange("i.png", "M", True, None, None, None)])
assert items[0].annotation == "-0 +3" and items[0].details == "untracked" and items[0].kind[1] == "?"
assert items[1].annotation == "binary" and items[1].details == "modified, staged"
def test_git_actions_refuse_commit_mode(monkeypatch):
calls = []
monkeypatch.setattr(commands, "run_async", lambda work, done: calls.append(work))
st = state.ViewState("/repo")
st.enter("abc", None)
st.change = change("a.py")
commands.ACTIONS["stage"](sublime.View(), None, st)
assert calls == []
st.enter()
st.change = change("a.py")
commands.ACTIONS["stage"](sublime.View(), None, st)
assert len(calls) == 1
def test_run_async_reports_git_error(monkeypatch):
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
def boom():
raise git.GitError("fatal: bad revision 'nope'\nmore", 128)
commands.run_async(boom, lambda r: messages.append("done"))
assert messages == ["vDiff: fatal: bad revision 'nope'"]

148
tests/test_git.py Normal file
View File

@@ -0,0 +1,148 @@
import os
import subprocess
import pytest
from vdiff import git, parse
def sh(repo, *args):
subprocess.run(["git"] + list(args), cwd=repo, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
def write(repo, name, text):
path = os.path.join(repo, name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(text)
@pytest.fixture
def repo(tmp_path):
repo = str(tmp_path)
sh(repo, "init", "-q", "-b", "main")
sh(repo, "config", "user.email", "t@example.com")
sh(repo, "config", "user.name", "Test")
write(repo, "a.py", "def f():\n return 1\n\n\ndef g():\n return 2\n")
write(repo, "b.txt", "keep\n")
write(repo, "gone.py", "bye\n")
sh(repo, "add", ".")
sh(repo, "commit", "-q", "-m", "initial")
return repo
def test_repo_root(repo):
assert git.repo_root(os.path.join(repo, "a.py")) == os.path.realpath(repo)
assert git.repo_root(repo) == os.path.realpath(repo)
assert git.repo_root("/") is None
def test_changed_files_all_kinds(repo):
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n") # unstaged M
write(repo, "b.txt", "keep\nmore\n")
sh(repo, "add", "b.txt") # staged M
os.remove(os.path.join(repo, "gone.py")) # unstaged D
write(repo, "sub/new.py", "x = 1\ny = 2") # untracked, no trailing newline
changes = {c.path: c for c in git.changed_files(repo)}
assert set(changes) == {"a.py", "b.txt", "gone.py", "sub/new.py"}
assert changes["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None)
assert changes["b.txt"] == git.FileChange("b.txt", "M", True, 1, 0, None)
assert changes["gone.py"].status == "D" and changes["gone.py"].deletions == 1
assert changes["sub/new.py"] == git.FileChange("sub/new.py", "?", False, 2, 0, None)
def test_full_diff_working_tree(repo):
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
diff = parse.parse_unified(git.full_diff(repo, "a.py"))
assert [l.kind for l in diff.lines] == [" ", "-", "+", " ", " ", " ", " "]
assert diff.lines[2].text == " return 10"
write(repo, "n.py", "one\ntwo\n")
new = parse.parse_unified(git.full_diff(repo, "n.py", untracked=True))
assert [l.text for l in new.lines] == ["one", "two"] and all(l.kind == "+" for l in new.lines)
def test_stage_unstage_discard(repo):
write(repo, "a.py", "changed\n")
write(repo, "n.py", "new\n")
changes = {c.path: c for c in git.changed_files(repo)}
git.stage(repo, changes["a.py"])
changes = {c.path: c for c in git.changed_files(repo)}
assert changes["a.py"].staged
git.unstage(repo, changes["a.py"])
changes = {c.path: c for c in git.changed_files(repo)}
assert not changes["a.py"].staged
git.discard(repo, changes["a.py"])
git.discard(repo, git.FileChange("n.py", "?", False, 1, 0, None))
assert git.changed_files(repo) == []
assert not os.path.exists(os.path.join(repo, "n.py"))
# staged new file: discard removes it from index and disk
write(repo, "s.py", "s\n")
git.stage(repo, git.FileChange("s.py", "?", False, 1, 0, None))
change = git.changed_files(repo)[0]
assert change.status == "A"
git.discard(repo, change)
assert git.changed_files(repo) == []
def test_commits(repo):
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
write(repo, "c.py", "c\n")
os.remove(os.path.join(repo, "gone.py"))
sh(repo, "add", "-A")
sh(repo, "commit", "-q", "-m", "second\n\nbody text")
commits = git.recent_commits(repo, 10)
assert [c.subject for c in commits] == ["second", "initial"]
info, files = git.commit_with_files(repo, commits[0].sha)
assert info.subject == "second" and info.body.strip() == "body text" and info.author == "Test"
assert info.sha == commits[0].sha and len(info.date) == 16
files = {c.path: c for c in files}
assert files["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None)
assert files["c.py"].status == "A" and files["c.py"].insertions == 1
assert files["gone.py"].status == "D" and files["gone.py"].deletions == 1
diff = parse.parse_unified(git.full_diff(repo, "a.py", sha=commits[0].sha))
assert [l.kind for l in diff.lines] == [" ", "-", "+", " ", " ", " ", " "]
# root commit diffs against the empty tree
_, root_files = git.commit_with_files(repo, commits[1].sha)
assert {c.path: c.status for c in root_files} == {"a.py": "A", "b.txt": "A", "gone.py": "A"}
# the short form resolves too (used by "review HEAD")
info, _ = git.commit_with_files(repo, "HEAD")
assert info.sha == commits[0].sha
def test_no_head_repo(tmp_path):
repo = str(tmp_path)
sh(repo, "init", "-q")
write(repo, "x.py", "x\n")
assert git.recent_commits(repo, 5) == []
changes = git.changed_files(repo)
assert changes[0].status == "?"
git.stage(repo, changes[0])
changes = git.changed_files(repo)
assert changes[0].status == "A" and changes[0].staged
diff = parse.parse_unified(git.full_diff(repo, "x.py"))
assert [l.kind for l in diff.lines] == ["+"]
git.unstage(repo, changes[0])
assert git.changed_files(repo)[0].status == "?"
def test_rename_in_commit(repo):
sh(repo, "mv", "b.txt", "moved.txt")
sh(repo, "commit", "-q", "-m", "rename")
_, files = git.commit_with_files(repo, "HEAD")
assert files == [git.FileChange("moved.txt", "R", False, 0, 0, "b.txt")]
def test_git_error_message(repo):
with pytest.raises(git.GitError) as info:
git.run(repo, ["rev-parse", "--verify", "nope"])
assert "\n" not in str(info.value) and info.value.stderr
def test_count_lines(tmp_path):
p = str(tmp_path / "f")
open(p, "wb").write(b"a\n" * 3000000)
assert git._count_lines(p) == 3000000
open(p, "wb").write(b"x\0y")
assert git._count_lines(p) is None
open(p, "wb").write(b"")
assert git._count_lines(p) == 0

117
tests/test_parse.py Normal file
View File

@@ -0,0 +1,117 @@
from vdiff import parse
TWO_HUNKS = """diff --git a/x.py b/x.py
index 111..222 100644
--- a/x.py
+++ b/x.py
@@ -1,3 +1,3 @@
a
-b
+B
c
@@ -10,2 +10,3 @@
j
+k
l
"""
NO_NEWLINE = """--- a/t.txt
+++ b/t.txt
@@ -1 +1 @@
-old
\\ No newline at end of file
+new
\\ No newline at end of file
"""
UNTRACKED = """diff --git a/dev/null b/n.py
new file mode 100644
--- /dev/null
+++ b/n.py
@@ -0,0 +1,2 @@
+one
+two
"""
DELETED = """--- a/gone.py
+++ /dev/null
@@ -1,2 +0,0 @@
-one
-two
"""
BINARY = """diff --git a/i.png b/i.png
index 111..222 100644
Binary files a/i.png and b/i.png differ
"""
EMPTY_CONTEXT = """--- a/e.py
+++ b/e.py
@@ -1,3 +1,3 @@
x
-\ty
+\tz
"""
def test_two_hunks_numbering():
diff = parse.parse_unified(TWO_HUNKS)
kinds = [l.kind for l in diff.lines]
assert kinds == [" ", "-", "+", " ", " ", "+", " "]
assert diff.lines[1] == parse.Line("-", 2, None, "b")
assert diff.lines[2] == parse.Line("+", None, 2, "B")
assert diff.lines[3] == parse.Line(" ", 3, 3, "c")
assert diff.lines[4] == parse.Line(" ", 10, 10, "j")
assert diff.lines[5] == parse.Line("+", None, 11, "k")
assert diff.lines[6] == parse.Line(" ", 11, 12, "l")
assert not diff.binary
assert parse.counts(diff.lines) == (2, 1)
def test_no_newline_marker_ignored():
diff = parse.parse_unified(NO_NEWLINE)
assert [l.text for l in diff.lines] == ["old", "new"]
def test_untracked_and_deleted_files():
new = parse.parse_unified(UNTRACKED)
assert [l.new_no for l in new.lines] == [1, 2] and all(l.kind == "+" for l in new.lines)
gone = parse.parse_unified(DELETED)
assert all(l.kind == "-" for l in gone.lines) and [l.old_no for l in gone.lines] == [1, 2]
def test_binary():
diff = parse.parse_unified(BINARY)
assert diff.binary and diff.lines == []
def test_empty_context_line_and_tabs():
diff = parse.parse_unified(EMPTY_CONTEXT)
assert [l.text for l in diff.lines] == ["x", "", "\ty", "\tz"]
def test_change_blocks_and_pairs():
lines = parse.parse_unified(TWO_HUNKS).lines
blocks = parse.change_blocks(lines)
assert blocks == [(1, 3), (5, 6)]
assert parse.paired_lines(lines, blocks[0]) == [(1, 2)]
assert parse.paired_lines(lines, blocks[1]) == []
def test_char_ranges():
old_r, new_r = parse.char_ranges(" self.viewport = xy", " self.viewport = (x, y)")
assert old_r == []
assert new_r == [(20, 21), (22, 24), (25, 26)]
assert parse.char_ranges("foo = bar", "foo = baz") == ([(8, 9)], [(8, 9)])
assert parse.char_ranges("completely", "different!!!") == ([], [])
assert parse.char_ranges("a" * 1500, "a" * 1499 + "b") == ([], [])
def test_line_for_open():
lines = parse.parse_unified(TWO_HUNKS).lines
assert parse.line_for_open(lines, 1) == 2 # deleted row -> following line
assert parse.line_for_open(lines, 6) == 12
assert parse.line_for_open([], 5) == 1
only_deleted = parse.parse_unified(DELETED).lines
assert parse.line_for_open(only_deleted, 1) == 1

112
tests/test_render.py Normal file
View File

@@ -0,0 +1,112 @@
import sublime # the stub, via conftest
from vdiff import commands, git, listeners, parse, render, state # noqa: F401
DIFF = """--- a/x.py
+++ b/x.py
@@ -1,6 +1,7 @@
def f():
- return 1
-
+ return 10
+ # added
+ # more
def g():
pass
"""
def make_state(diff_text=DIFF):
st = state.ViewState("/repo")
st.change = git.FileChange("x.py", "M", False, 3, 2, None)
diff = parse.parse_unified(diff_text)
st.lines, st.binary = diff.lines, diff.binary
st.blocks = parse.change_blocks(diff.lines)
return st
def header(view):
return view.phantoms[render.PHANTOM_KEY][0].content
def test_render_text_regions_and_header():
view = sublime.View()
render.init_view(view)
st = make_state()
render.render(view, st)
assert view.text.split("\n") == ["", "def f():", " return 1", "", " return 10", " # added", " # more", "", "def g():", " pass"]
assert view.is_read_only()
assert view.syntax.endswith("Python.sublime-syntax")
deleted, scope, _, flags = view.regions[render.KEY_DEL]
assert scope == "diff.deleted" and flags == sublime.DRAW_NO_OUTLINE
# rows 2-3 merged into one region, newline included; offsets match the view's own row arithmetic
assert deleted == [sublime.Region(view.text_point(2, 0), view.text_point(4, 0))]
inserted = view.regions[render.KEY_INS][0]
assert inserted == [sublime.Region(view.text_point(4, 0), view.text_point(7, 0))]
marks = view.regions[render.KEY_INS_GUTTER]
assert [r.a for r in marks[0]] == [view.text_point(4, 0), view.text_point(5, 0), view.text_point(6, 0)]
assert marks[2] == "dot"
# 2 deleted vs 3 inserted lines: not a paired block, no char marks
assert view.regions[render.KEY_INS_CHAR][0] == []
html = header(view)
assert "x.py" in html and "-2" in html and "+3" in html and 'href="stage"' in html and "modified" in html
assert len(view.phantoms[render.PHANTOM_KEY]) == 1
assert view.name() == "⟐ x.py"
assert view.settings().get(state.VIEW_DATA) == {"repo": "/repo", "sha": None, "path": "x.py", "status": "M", "staged": False}
assert view.sel()[0].a == view.text_point(1, 0)
def test_last_line_region_stops_at_buffer_end():
view = sublime.View()
render.init_view(view)
st = make_state("--- a/y\n+++ b/y\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n")
render.render(view, st)
assert view.text == "\na\nb\nc"
assert view.regions[render.KEY_INS][0] == [sublime.Region(view.text_point(3, 0), len(view.text))]
render.render(view, st) # re-render replaces, never duplicates, the header
assert len(view.phantoms[render.PHANTOM_KEY]) == 1
def test_char_marks_for_paired_lines():
view = sublime.View()
render.init_view(view)
st = make_state("--- a/y\n+++ b/y\n@@ -1 +1 @@\n-foo = bar\n+foo = baz\n")
render.render(view, st)
assert view.regions[render.KEY_DEL_CHAR][0] == [sublime.Region(view.text_point(1, 8), view.text_point(1, 9))]
assert view.regions[render.KEY_INS_CHAR][0] == [sublime.Region(view.text_point(2, 8), view.text_point(2, 9))]
def test_commit_header_and_message():
view = sublime.View()
render.init_view(view)
st = make_state()
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann <x>", "2026-09-23 10:00", "Fix things", "details\nmore"))
st.change = git.FileChange("x.py", "M", False, 3, 2, None)
render.render(view, st)
html = header(view)
assert "abc1234" in html and "Ann &lt;x&gt;" in html and "Fix things" in html and "details" in html
assert 'href="stage"' not in html and 'href="open"' in html
render.render_message(view, st, "nothing")
assert view.text == "\nnothing" and st.change is None and view.name() == "vDiff"
def test_binary_and_caret_index():
view = sublime.View()
render.init_view(view)
st = make_state()
st.binary, st.lines, st.blocks = True, [], []
render.render(view, st)
assert view.text == "\n(binary file)" and render.KEY_DEL not in view.regions
view.sel().clear()
view.sel().add(sublime.Region(render.index_point(view, 0)))
assert render.caret_index(view) == 0
view.sel().clear()
view.sel().add(sublime.Region(0))
assert render.caret_index(view) == -1

4
vDiff.sublime-commands Normal file
View File

@@ -0,0 +1,4 @@
[
{ "caption": "vDiff: Changed Files", "command": "vdiff_files" },
{ "caption": "vDiff: Recent Commits", "command": "vdiff_commits" }
]

15
vDiff.sublime-settings Normal file
View File

@@ -0,0 +1,15 @@
{
// number of commits listed by "vDiff: Recent Commits"
"commits_count": 30,
// ask before discarding a file's changes
"confirm_discard": true,
// mark the changed characters inside paired deleted/inserted lines
"word_diff": true,
// git executable; sublime's PATH may not include a user-installed git
"git_binary": "git",
"debug": false
}

0
vdiff/__init__.py Normal file
View File

427
vdiff/commands.py Normal file
View File

@@ -0,0 +1,427 @@
""" Commands: the two quick panels (changed files, recent commits) and the actions of a review view """
import bisect
import os
import sublime
import sublime_plugin
from . import git, parse, render, state
from .utils import run_async, settings, status
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES
STATUS_COLOURS = {
"M": sublime.KIND_ID_COLOR_ORANGISH,
"A": sublime.KIND_ID_COLOR_GREENISH,
"?": sublime.KIND_ID_COLOR_GREENISH,
"D": sublime.KIND_ID_COLOR_REDISH,
"R": sublime.KIND_ID_COLOR_BLUISH,
"C": sublime.KIND_ID_COLOR_BLUISH,
"T": sublime.KIND_ID_COLOR_PURPLISH,
"U": sublime.KIND_ID_COLOR_REDISH,
}
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
# highlight-to-preview delay: holding an arrow key must not spawn a git process per row
PREVIEW_DELAY_MS = 60
# directory -> repository root; roots do not move, and resolving runs on the UI thread
_roots = {}
def resolve_repo(window):
"""Repository of the review view or active file, else of the first project folder that is one"""
view = window.active_view()
candidates = []
if view is not None:
if state.is_review_view(view):
st = state.get(view) or restore_view(view)
if st.repo:
return st.repo
elif view.file_name():
candidates.append(os.path.dirname(view.file_name()))
candidates.extend(window.folders())
for directory in candidates:
root = _roots.get(directory)
if root is None:
root = git.repo_root(directory)
if root:
_roots[directory] = root
if root:
return root
return None
def find_review_view(window):
for view in window.views():
if state.is_review_view(view):
return view
return None
def open_review(window):
"""(view, state) of the window's single review view for the resolved repo, focused; None without a repo"""
repo = resolve_repo(window)
if not repo:
status("no git repository for the active file or project folders")
return None
view = find_review_view(window)
if view is None:
view = window.new_file()
render.init_view(view)
st = state.get(view)
if st is None or st.repo != repo:
# a different repository: browsing state no longer applies
st = state.put(view, state.ViewState(repo))
window.focus_view(view)
return view, st
def show_file(view, st, change):
"""Fetch and parse the file's diff on the worker thread, then render it"""
st.token += 1
token = st.token
repo, sha = st.repo, st.sha
def work():
if token != st.token:
return None # superseded before it started: skip the subprocess
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
diff = parse.parse_unified(text)
return diff, parse.change_blocks(diff.lines)
def done(result):
if result is None or token != st.token or not view.is_valid():
return
diff, blocks = result
st.change = change
st.lines, st.blocks, st.binary = diff.lines, blocks, diff.binary
render.render(view, st)
st.rendered_token = token
run_async(work, done)
def file_items(files):
items = []
for change in files:
description = git.STATUS_NAMES.get(change.status, "")
colour = STATUS_COLOURS.get(change.status)
kind = (colour, change.status, description) if colour else sublime.KIND_AMBIGUOUS
details = description
if change.staged:
details += ", staged"
if change.old_path:
details += " ← " + change.old_path
if change.insertions is None or change.deletions is None:
annotation = "binary"
else:
annotation = "-{} +{}".format(change.deletions, change.insertions)
items.append(sublime.QuickPanelItem(change.path, details, annotation, kind))
return items
def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder="Changed files"):
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
st.files = files
selected = max(0, st.index())
window.focus_view(view)
def on_select(index):
st.highlight += 1 # cancel a pending preview
if index == -1:
if on_cancel is not None:
st.restore(kept)
on_cancel()
elif kept.change is not None:
# go back to the file that was kept before the panel opened
st.restore(kept)
show_file(view, st, kept.change)
# nothing was kept before: the last previewed file stays
return
change = files[index]
if st.change is change and st.rendered_token == st.token:
return # the preview already shows it
show_file(view, st, change)
def on_highlight(index):
if not 0 <= index < len(files):
return
st.highlight += 1
ticket = st.highlight
sublime.set_timeout(lambda: ticket == st.highlight and show_file(view, st, files[index]), PREVIEW_DELAY_MS)
window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)
def refresh_listing(view, st, message=None):
"""Re-list the working tree changes and show the same file, or the next one when it is gone"""
if message:
status(message)
previous_index = max(0, st.index())
path = st.change.path if st.change else None
def done(files):
if not view.is_valid():
return
st.files = files
index = state.find_change(files, path)
if index >= 0:
show_file(view, st, files[index])
elif files:
show_file(view, st, files[min(previous_index, len(files) - 1)])
else:
render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
run_async(lambda: git.changed_files(st.repo), done)
def refresh_if_changed(view, st):
"""Re-render a working-tree view when the file or the index changed since it was drawn"""
if st.in_commit or st.change is None:
return
if render.fingerprint(st) != st.fingerprint:
refresh_listing(view, st)
class VdiffFilesCommand(sublime_plugin.WindowCommand):
"""List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
def run(self):
window = self.window
opened = open_review(window)
if not opened:
return
view, st = opened
def done(files):
if not view.is_valid():
return
kept = st.enter()
if not files:
render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
status("no uncommitted changes")
return
placeholder = "Changed files in {}".format(os.path.basename(st.repo))
show_files_panel(window, view, st, files, kept, placeholder=placeholder)
run_async(lambda: git.changed_files(st.repo), done)
class VdiffCommitsCommand(sublime_plugin.WindowCommand):
"""List recent commits, then the files of the chosen commit; `sha` skips straight to one commit"""
def run(self, selected=0, sha=None):
opened = open_review(self.window)
if not opened:
return
view, st = opened
if sha:
self.open_commit(view, st, sha, None)
return
count = int(settings().get("commits_count", 30) or 30)
def done(commits):
if not view.is_valid():
return
if not commits:
status("no commits yet")
return
self.show_commits(view, st, commits, selected)
run_async(lambda: git.recent_commits(st.repo, count), done)
def show_commits(self, view, st, commits, selected):
items = [
sublime.QuickPanelItem(c.subject or "(no subject)", "{} · {}".format(c.author, c.date), c.short, COMMIT_KIND)
for c in commits
]
for i, c in enumerate(commits):
if c.sha == st.sha:
selected = i
def on_select(index):
if index != -1:
self.open_commit(view, st, commits[index].sha, index)
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
def open_commit(self, view, st, sha, index):
"""Files panel of one commit; Esc goes back to the commit list when it came from there (index given)"""
window = self.window
def done(result):
if not view.is_valid():
return
info, files = result
kept = st.enter(info.sha, info)
if not files:
render.render_message(view, st, "Commit {} touches no files".format(info.short))
return
def back():
window.run_command("vdiff_commits", {"selected": index})
placeholder = "{} · {}".format(info.short, info.subject)
on_cancel = back if index is not None else None
show_files_panel(window, view, st, files, kept, on_cancel=on_cancel, placeholder=placeholder)
run_async(lambda: git.commit_with_files(st.repo, sha), done)
# --- review view actions ----------------------------------------------------
def _act_files(view, window, st):
window.run_command("vdiff_files")
def _act_commits(view, window, st):
window.run_command("vdiff_commits")
def _act_close(view, window, st):
view.close()
def _git_action(verb, done_verb, operation, confirm=None):
"""Action running `operation(repo, change)` on the shown working-tree file, then re-listing"""
def act(view, window, st):
if st.in_commit or st.change is None:
status("{} works on uncommitted changes only".format(verb))
return
change = st.change
if confirm is not None and not confirm(change):
return
run_async(lambda: operation(st.repo, change), lambda _: refresh_listing(view, st, "{} {}".format(done_verb, change.path)))
return act
def _confirm_discard(change):
if not settings().get("confirm_discard", True):
return True
if change.status == "?":
message = "Delete the untracked file {}?\n\nThis cannot be undone.".format(change.path)
else:
message = "Discard all changes to {}?\n\nThe file goes back to its last committed version. This cannot be undone.".format(change.path)
return sublime.ok_cancel_dialog(message, "Discard")
def _act_refresh(view, window, st):
if st.change is None:
return
if st.in_commit:
show_file(view, st, st.change)
else:
refresh_listing(view, st)
def _act_open(view, window, st):
if st.change is None:
return
path = os.path.join(st.repo, st.change.path)
if not os.path.exists(path):
status("{} does not exist in the working tree".format(st.change.path))
return
line = parse.line_for_open(st.lines, render.caret_index(view))
window.open_file("{}:{}".format(path, line), sublime.ENCODED_POSITION)
def _step_file(delta):
def act(view, window, st):
if not st.files:
status("no file list, press f or c")
return
index = st.index() + delta
if not 0 <= index < len(st.files):
status("no more files")
return
show_file(view, st, st.files[index])
return act
def _step_change(delta):
def act(view, window, st):
starts = [start for start, _ in st.blocks]
row = render.caret_index(view)
position = bisect.bisect_right(starts, row) if delta > 0 else bisect.bisect_left(starts, row) - 1
if not 0 <= position < len(starts):
status("no more changes")
return
point = render.index_point(view, starts[position])
view.sel().clear()
view.sel().add(sublime.Region(point))
view.show_at_center(point)
return act
ACTIONS = {
"files": _act_files,
"commits": _act_commits,
"close": _act_close,
"stage": _git_action("stage", "staged", git.stage),
"unstage": _git_action("unstage", "unstaged", git.unstage),
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
"refresh": _act_refresh,
"open": _act_open,
"next_file": _step_file(1),
"prev_file": _step_file(-1),
"next_change": _step_change(1),
"prev_change": _step_change(-1),
}
class VdiffActionCommand(sublime_plugin.TextCommand):
"""Every key/link action of a review view, dispatched by name through ACTIONS"""
def is_enabled(self, action=None):
return state.is_review_view(self.view)
def run(self, edit, action):
act = ACTIONS.get(action)
if act is None:
status("unknown action {}".format(action))
return
st = state.get(self.view) or restore_view(self.view)
act(self.view, self.view.window(), st)
def restore_view(view):
"""Rebuild the state of a review view restored from a previous session; re-renders asynchronously"""
data = view.settings().get(state.VIEW_DATA) or {}
repo = data.get("repo")
render.init_view(view)
if not repo or not os.path.isdir(repo):
st = state.put(view, state.ViewState(""))
render.render_message(view, st, "Repository not found. Press f for changed files, c for commits.")
return st
st = state.put(view, state.ViewState(repo))
st.sha = data.get("sha")
path = data.get("path")
if not path:
render.render_message(view, st, "Press f for changed files, c for commits.")
return st
change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None)
if st.in_commit:
def done(result):
st.commit, st.files = result
show_file(view, st, change)
run_async(lambda: git.commit_with_files(repo, st.sha), done)
else:
def done(files):
st.files = files
index = state.find_change(files, path)
if index >= 0:
show_file(view, st, files[index])
else:
render.render_message(view, st, "{} has no uncommitted changes any more".format(path))
run_async(lambda: git.changed_files(repo), done)
return st

248
vdiff/git.py Normal file
View File

@@ -0,0 +1,248 @@
""" Git access: every call is one subprocess; results are plain tuples, no sublime imports (testable) """
import os
import subprocess
from collections import namedtuple
# path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source
FileChange = namedtuple("FileChange", "path status staged insertions deletions old_path")
Commit = namedtuple("Commit", "sha short author date subject")
CommitInfo = namedtuple("CommitInfo", "sha short author date subject body")
# hash of the empty tree: the diff base for a repository without commits
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
# huge context turns a unified diff into the whole file with changes interleaved
FULL_CONTEXT = "-U999999"
# overridable from settings (sublime's PATH may not have a user-installed git)
GIT_BINARY = "git"
STATUS_NAMES = {
"M": "modified",
"A": "added",
"D": "deleted",
"R": "renamed",
"C": "copied",
"T": "type changed",
"U": "unmerged",
"?": "untracked",
}
_INFO_FORMAT = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%b"
_DATE_FORMAT = "--date=format:%Y-%m-%d %H:%M"
class GitError(Exception):
"""git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it"""
def __init__(self, stderr, code):
self.stderr = stderr
self.code = code
first = stderr.strip().splitlines()
super().__init__(first[0] if first else "git exited with {}".format(code))
def run(repo, args, ok_codes=(0,)):
"""Run git in `repo`, return stdout as text"""
cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args)
proc = subprocess.run(cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode not in ok_codes:
raise GitError(proc.stderr.decode("utf-8", "replace"), proc.returncode)
return proc.stdout.decode("utf-8", "replace")
def repo_root(path):
"""Top-level directory of the repository containing `path`, or None"""
if not path:
return None
directory = path if os.path.isdir(path) else os.path.dirname(path)
if not os.path.isdir(directory):
return None
try:
root = run(directory, ["rev-parse", "--show-toplevel"]).strip()
except (GitError, OSError):
return None
return root or None
def has_head(repo):
"""False in a repository without commits"""
try:
run(repo, ["rev-parse", "--verify", "-q", "HEAD"])
return True
except GitError:
return False
def base_ref(repo):
"""HEAD, or the empty tree when there is no commit yet"""
return "HEAD" if has_head(repo) else EMPTY_TREE
def _parse_numstat(text):
"""`--numstat -z` output -> {path: (insertions, deletions, old_path)}"""
result = {}
fields = iter(text.split("\0"))
for entry in fields:
parts = entry.split("\t")
if len(parts) < 3:
continue
ins, dele, path = parts[:3]
old_path = None
if path == "":
# rename: counts, then old and new path as separate NUL fields
old_path = next(fields, None)
path = next(fields, "")
result[path] = (None if ins == "-" else int(ins), None if dele == "-" else int(dele), old_path)
return result
def _parse_name_status(text):
"""`--name-status -z` output -> [(status letter, path, old_path)]"""
result = []
fields = iter(text.split("\0"))
for status in fields:
if not status:
continue
path = next(fields, "")
old_path = None
if status[0] in "RC":
old_path, path = path, next(fields, "")
result.append((status[0], path, old_path))
return result
def _count_lines(path):
"""Line count of a working-tree file, None when binary or unreadable"""
try:
with open(path, "rb") as f:
head = f.read(8000)
if b"\0" in head:
return None
count = head.count(b"\n")
last = head[-1:]
while True:
chunk = f.read(1 << 20)
if not chunk:
break
count += chunk.count(b"\n")
last = chunk[-1:]
except OSError:
return None
if not last:
return 0
return count + (0 if last == b"\n" else 1)
def changed_files(repo):
"""Working tree changes vs HEAD: staged, unstaged and untracked, sorted by path"""
status = run(repo, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--no-renames"])
entries = [e for e in status.split("\0") if e]
# "# branch.oid (initial)" means there is no commit yet
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base]))
changes = []
for entry in entries:
kind = entry[0]
if kind == "1":
xy, path = entry.split(" ", 8)[1], entry.split(" ", 8)[-1]
elif kind == "u":
xy, path = "UU", entry.split(" ", 10)[-1]
elif kind == "?":
xy, path = ".?", entry[2:]
else:
continue
x, y = xy[0], xy[1]
staged = x not in (".", "?")
letter = y if y != "." else x
if letter == "?":
lines = _count_lines(os.path.join(repo, path))
ins, dele, old = lines, (None if lines is None else 0), None
else:
ins, dele, old = counts.get(path, (0, 0, None))
changes.append(FileChange(path, letter, staged, ins, dele, old))
changes.sort(key=lambda c: c.path)
return changes
def recent_commits(repo, count):
"""Last `count` commits, newest first; empty list without commits"""
if not has_head(repo):
return []
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s"])
commits = []
for line in text.split("\n"):
parts = line.split("\x1f")
if len(parts) == 5:
commits.append(Commit(*parts))
return commits
def commit_with_files(repo, sha):
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
show = ["show", "--first-parent", "-m", "-z"]
text = run(repo, show + ["--format={}%x00".format(_INFO_FORMAT), _DATE_FORMAT, "--name-status", sha])
# git terminates the header with its own NUL and a newline before the file list
info_text, _, files_text = text.partition("\0")
files_text = files_text.lstrip("\0\n")
parts = info_text.split("\x1f")
while len(parts) < 6:
parts.append("")
info = CommitInfo(*parts[:6])
counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha]))
changes = []
for status, path, old_path in _parse_name_status(files_text):
ins, dele, _ = counts.get(path, (None, None, None))
changes.append(FileChange(path, status, False, ins, dele, old_path))
changes.sort(key=lambda c: c.path)
return info, changes
def _in_tree(repo, ref, path):
"""Whether `path` exists in the tree of `ref`"""
try:
run(repo, ["cat-file", "-e", "{}:{}".format(ref, path)])
return True
except GitError:
return False
def full_diff(repo, path, sha=None, untracked=False):
"""Unified diff of one file with the whole file as context
Working tree vs HEAD (staged and unstaged merged) by default; a commit vs its
first parent when `sha` is given; the whole file as insertions when untracked.
"""
common = ["--no-color", "--no-ext-diff", FULL_CONTEXT]
if untracked:
return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1))
if sha:
return run(repo, ["show", "--format=", "--first-parent", "-m"] + common + [sha, "--", path])
return run(repo, ["diff"] + common + [base_ref(repo), "--", path])
def stage(repo, change):
"""Stage the whole file, including its deletion"""
run(repo, ["add", "-A", "--", change.path])
def unstage(repo, change):
"""Move the file's staged changes back to the working tree"""
if has_head(repo):
run(repo, ["reset", "-q", "--", change.path])
else:
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
def discard(repo, change):
"""Throw away the file's changes, staged and unstaged, restoring the HEAD version"""
if change.status == "?":
run(repo, ["clean", "-f", "--", change.path])
return
base = base_ref(repo)
if change.status == "A" or not _in_tree(repo, base, change.path):
run(repo, ["rm", "-r", "-f", "-q", "--", change.path])
return
run(repo, ["checkout", base, "--", change.path])

23
vdiff/listeners.py Normal file
View File

@@ -0,0 +1,23 @@
""" Event listener: state cleanup, refresh when the tab comes back or a file is saved """
import sublime
import sublime_plugin
from . import state
from .commands import refresh_if_changed, restore_view
class VdiffEventListener(sublime_plugin.EventListener):
def on_pre_close(self, view):
state.drop(view)
def on_activated(self, view):
"""Re-render when the tab comes back and the file or index changed meanwhile (agent edits)"""
if state.is_review_view(view):
refresh_if_changed(view, state.get(view) or restore_view(view))
def on_post_save(self, view):
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
for view_id, st in list(state.STATES.items()):
review = sublime.View(view_id)
if review.is_valid():
refresh_if_changed(review, st)

119
vdiff/parse.py Normal file
View File

@@ -0,0 +1,119 @@
""" Unified diff -> lines with old/new numbers, change blocks, word-level ranges; no sublime imports """
import difflib
import re
from collections import namedtuple
# kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None
Line = namedtuple("Line", "kind old_no new_no text")
Diff = namedtuple("Diff", "lines binary")
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
CHAR_DIFF_MIN_RATIO = 0.5
CHAR_DIFF_MAX_CHARS = 2000
_HUNK = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)")
def parse_unified(text):
"""Parse one file's unified diff (any number of hunks) into a Diff"""
lines = []
binary = False
old_no = new_no = 0
in_hunk = False
for raw in text.split("\n"):
if raw.startswith("@@"):
match = _HUNK.match(raw)
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
in_hunk = True
continue
if not in_hunk:
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
binary = True
continue
if raw == "":
# only the trailing split remainder; an empty context line is " "
continue
marker, body = raw[0], raw[1:]
if marker == " ":
lines.append(Line(" ", old_no, new_no, body))
old_no += 1
new_no += 1
elif marker == "-":
lines.append(Line("-", old_no, None, body))
old_no += 1
elif marker == "+":
lines.append(Line("+", None, new_no, body))
new_no += 1
elif raw.startswith("diff "):
# a second file in the output; we asked for one, ignore the rest
break
# "\ No newline at end of file" and anything else is skipped
return Diff(lines, binary)
def change_blocks(lines):
"""[(start, end)) index ranges of consecutive changed lines"""
blocks = []
start = None
for i, line in enumerate(lines):
if line.kind != " ":
if start is None:
start = i
elif start is not None:
blocks.append((start, i))
start = None
if start is not None:
blocks.append((start, len(lines)))
return blocks
def counts(lines):
"""(insertions, deletions)"""
ins = dele = 0
for line in lines:
if line.kind == "+":
ins += 1
elif line.kind == "-":
dele += 1
return ins, dele
def paired_lines(lines, block):
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
start, end = block
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
inserted = [i for i in range(start, end) if lines[i].kind == "+"]
if not deleted or len(deleted) != len(inserted):
return []
return list(zip(deleted, inserted))
def char_ranges(old, new):
"""Character ranges that differ: ([(a, b) in old], [(a, b) in new]); empty when too different or too long"""
if len(old) + len(new) > CHAR_DIFF_MAX_CHARS:
return [], []
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
return [], []
old_ranges = []
new_ranges = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag in ("replace", "delete") and i2 > i1:
old_ranges.append((i1, i2))
if tag in ("replace", "insert") and j2 > j1:
new_ranges.append((j1, j2))
return old_ranges, new_ranges
def line_for_open(lines, index):
"""New-file line number to open for a row: a deleted row maps to the next surviving line"""
if not lines:
return 1
index = max(0, min(index, len(lines) - 1))
for line in lines[index:]:
if line.new_no is not None:
return line.new_no
for line in reversed(lines[:index]):
if line.new_no is not None:
return line.new_no
return 1

333
vdiff/render.py Normal file
View File

@@ -0,0 +1,333 @@
""" Draw a parsed diff into a review view: the file text, coloured line regions, gutter marks, header phantom """
import html
import json
import logging
import os
import sublime
import sublime_plugin
from . import parse
from .git import STATUS_NAMES
from .state import VIEW_FLAG
from .utils import settings
LOG = logging.getLogger("vdiff")
KEY_DEL = "vdiff.del"
KEY_INS = "vdiff.ins"
KEY_DEL_GUTTER = "vdiff.del.gutter"
KEY_INS_GUTTER = "vdiff.ins.gutter"
KEY_DEL_CHAR = "vdiff.del.char"
KEY_INS_CHAR = "vdiff.ins.char"
REGION_KEYS = (KEY_DEL, KEY_INS, KEY_DEL_GUTTER, KEY_INS_GUTTER, KEY_DEL_CHAR, KEY_INS_CHAR)
PHANTOM_KEY = "vdiff.header"
STATUS_KEY = "vdiff"
# a block phantom anchored at point 0 is drawn below row 0, so row 0 stays empty and the file starts at row 1.
# Only index_point / caret_index know about it.
_HEADER_ROWS = 1
# scopes with a background in every bundled colour scheme (Mariana, Monokai, Breakers, Celeste, Sixteen)
SCOPE_DEL = "diff.deleted"
SCOPE_INS = "diff.inserted"
SCOPE_DEL_CHAR = "diff.deleted.char"
SCOPE_INS_CHAR = "diff.inserted.char"
# gutter dots use the scope's foreground
SCOPE_DEL_MARK = "markup.deleted"
SCOPE_INS_MARK = "markup.inserted"
# rules written into a colour-scheme override when the active scheme lacks the scopes above
FALLBACK_RULES = [
{"scope": SCOPE_DEL, "background": "hsla(357, 45%, 60%, 0.15)", "foreground_adjust": "l(+ 5%)"},
{"scope": SCOPE_DEL_CHAR, "background": "hsla(357, 60%, 60%, 0.30)", "foreground_adjust": "l(+ 10%)"},
{"scope": SCOPE_INS, "background": "hsla(114, 31%, 68%, 0.15)", "foreground_adjust": "l(+ 5%)"},
{"scope": SCOPE_INS_CHAR, "background": "hsla(114, 45%, 68%, 0.30)", "foreground_adjust": "l(+ 10%)"},
]
KEY_HINTS = "f files c commits s stage u unstage d discard o open ] [ change n p file r refresh"
HEADER_CSS = """
body { margin: 0; padding: 0.5rem 0.8rem; background-color: color(var(--background) blend(var(--foreground) 93%)); }
.path { font-weight: bold; }
.old { color: color(var(--foreground) alpha(0.6)); }
.del { color: var(--redish); font-weight: bold; }
.ins { color: var(--greenish); font-weight: bold; }
.state { color: var(--orangish); }
.actions { margin-left: 1rem; }
a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-radius: 3px;
background-color: color(var(--foreground) alpha(0.12)); color: var(--foreground); }
.danger { color: var(--redish); }
.commit { margin-top: 0.4rem; color: color(var(--foreground) alpha(0.75)); }
.hash { color: var(--bluish); font-weight: bold; }
.body { color: color(var(--foreground) alpha(0.6)); }
"""
class VdiffReplaceCommand(sublime_plugin.TextCommand):
"""Replace the whole buffer; the only writer of a review view"""
def run(self, edit, text):
self.view.replace(edit, sublime.Region(0, self.view.size()), text)
def init_view(view):
"""Settings a review view needs, applied once when it is created or restored"""
s = view.settings()
s.set(VIEW_FLAG, True)
# native line numbers would be wrong: deleted lines have none
s.set("line_numbers", False)
s.set("word_wrap", False)
s.set("gutter", True)
s.set("highlight_line", True)
s.set("scroll_past_end", False)
s.set("detect_indentation", False)
s.set("translate_tabs_to_spaces", False)
s.set("show_definitions", False)
s.set("mini_diff", False)
s.set("spell_check", False)
view.set_scratch(True)
view.set_read_only(True)
if not view.name():
view.set_name("vDiff")
view.set_status(STATUS_KEY, KEY_HINTS)
ensure_scheme_scopes(view)
def index_point(view, index, col=0):
"""Text point of column `col` on the row showing st.lines[index]"""
return view.text_point(index + _HEADER_ROWS, col)
def caret_index(view):
"""Index into st.lines of the caret's row (the header row maps to -1)"""
if not len(view.sel()):
return 0
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
def fingerprint(st):
"""Cheap "did anything change" key for working-tree mode: mtimes of the file and of the git index"""
if st.in_commit or st.change is None:
return None
return (_mtime(os.path.join(st.repo, st.change.path)), _mtime(os.path.join(st.repo, ".git", "index")))
def _mtime(path):
try:
return os.stat(path).st_mtime
except OSError:
return None
def render_message(view, st, message):
"""Show a one-line notice instead of a file (nothing to review, binary, ...)"""
st.change = None
st.lines = []
st.blocks = []
st.binary = False
_replace(view, message)
view.assign_syntax("scope:text.plain")
_finish(view, st, 0, 0, "vDiff")
def render(view, st):
"""Draw st.lines for st.change into the view"""
lines = st.lines
if st.binary:
text = "(binary file)"
elif not lines:
text = "(no textual changes)"
else:
text = "\n".join(line.text for line in lines)
_replace(view, text)
path = os.path.join(st.repo, st.change.path)
_apply_syntax(view, path if lines and not st.binary else None)
if lines and not st.binary:
starts = _row_starts(lines)
deleted, inserted = _line_regions(lines, starts)
view.add_regions(KEY_DEL, deleted, SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, inserted, SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"]
ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"]
view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags)
view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags)
if settings().get("word_diff", True):
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
view.add_regions(KEY_DEL_CHAR, del_chars, SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS_CHAR, ins_chars, SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE)
ins, dele = parse.counts(lines)
_finish(view, st, ins, dele, "⟐ {}".format(os.path.basename(st.change.path)))
view.sel().clear()
view.sel().add(sublime.Region(index_point(view, 0)))
view.set_viewport_position((0, 0), False)
def _finish(view, st, ins, dele, name):
"""Header, tab name, status bar and bookkeeping shared by render and render_message"""
_header(view, st, ins, dele)
view.set_name(name)
view.set_status(STATUS_KEY, KEY_HINTS)
st.persist(view)
st.fingerprint = fingerprint(st)
def _replace(view, text):
view.set_read_only(False)
try:
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
finally:
view.set_read_only(True)
for key in REGION_KEYS:
view.erase_regions(key)
def _apply_syntax(view, path):
"""The real file's syntax, so the diff is highlighted like the file itself"""
syntax = None
if path:
try:
syntax = sublime.find_syntax_for_file(path)
except Exception:
LOG.debug("find_syntax_for_file failed", exc_info=True)
view.assign_syntax(syntax.path if syntax else "scope:text.plain")
def _row_starts(lines):
"""Text point where each line starts; computed from the text we wrote, no view round-trips"""
starts = []
point = _HEADER_ROWS
for line in lines:
starts.append(point)
point += len(line.text) + 1
starts.append(point - 1) # end of the buffer (the last line has no newline)
return starts
def _line_regions(lines, starts):
"""Full-line regions of deleted and inserted rows, consecutive rows merged"""
regions = {"-": [], "+": []}
run_kind = None
run_start = 0
for i in range(len(lines) + 1):
kind = lines[i].kind if i < len(lines) else None
if kind == run_kind:
continue
if run_kind in regions:
# min(): the last line's region stops at the buffer end instead of one past it
regions[run_kind].append(sublime.Region(starts[run_start], min(starts[i], starts[-1])))
run_kind, run_start = kind, i
return regions["-"], regions["+"]
def _char_regions(lines, blocks, starts):
"""Changed characters inside paired deleted/inserted lines"""
deleted = []
inserted = []
for block in blocks:
for del_row, ins_row in parse.paired_lines(lines, block):
old_ranges, new_ranges = parse.char_ranges(lines[del_row].text, lines[ins_row].text)
deleted.extend(sublime.Region(starts[del_row] + a, starts[del_row] + b) for a, b in old_ranges)
inserted.extend(sublime.Region(starts[ins_row] + a, starts[ins_row] + b) for a, b in new_ranges)
return deleted, inserted
def header_html(st, ins, dele):
"""The minihtml header: path, counts, state, action links and the commit line"""
change = st.change
if change is None:
title = '<span class="path">vDiff</span>'
else:
title = '<span class="path">{}</span>'.format(html.escape(change.path))
if change.old_path:
title += ' <span class="old">← {}</span>'.format(html.escape(change.old_path))
title += '&nbsp;&nbsp;<span class="del">-{}</span> <span class="ins">+{}</span>'.format(dele, ins)
if not st.in_commit:
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
if state:
title += '&nbsp;&nbsp;<span class="state">{}</span>'.format(state)
actions = []
if change is not None and not st.in_commit:
actions.append('<a href="stage">Stage</a>')
actions.append('<a href="unstage">Unstage</a>')
actions.append('<a href="discard" class="danger">Discard</a>')
if change is not None:
actions.append('<a href="open">Open</a>')
actions.append('<a href="files">Files</a>')
actions.append('<a href="commits">Commits</a>')
commit = ""
if st.in_commit and st.commit:
info = st.commit
commit = '<div class="commit"><span class="hash">{}</span> · {} · {} · {}'.format(
html.escape(info.short), html.escape(info.author), html.escape(info.date), html.escape(info.subject)
)
body = info.body.strip().splitlines()
if body:
commit += ' <span class="body">— {}</span>'.format(html.escape(body[0]))
commit += "</div>"
return '<body id="vdiff-header"><style>{}</style><div>{}<span class="actions">{}</span></div>{}</body>'.format(
HEADER_CSS, title, "".join(actions), commit
)
def _header(view, st, ins, dele):
"""One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep"""
def navigate(href):
view.run_command("vdiff_action", {"action": href})
view.erase_phantoms(PHANTOM_KEY)
view.add_phantom(PHANTOM_KEY, sublime.Region(0), header_html(st, ins, dele), sublime.LAYOUT_BLOCK, navigate)
# --- colour scheme fallback -------------------------------------------------
_scheme_checked = set()
def ensure_scheme_scopes(view):
"""Write a scheme override with diff backgrounds when the active scheme has none (third-party schemes)"""
name = _scheme_override_name(view)
if not name or name in _scheme_checked:
return
_scheme_checked.add(name)
try:
if view.style_for_scope(SCOPE_INS).get("background"):
return
except Exception:
return
path = os.path.join(sublime.packages_path(), "User", name)
try:
scheme = {}
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
scheme = sublime.decode_value(f.read()) or {}
rules = scheme.setdefault("rules", [])
present = {rule.get("scope") for rule in rules}
rules.extend(rule for rule in FALLBACK_RULES if rule["scope"] not in present)
with open(path, "w", encoding="utf-8") as f:
json.dump(scheme, f, indent=4)
LOG.info("wrote diff scopes into colour scheme override %s", path)
except Exception:
LOG.warning("could not write colour scheme override %s", path, exc_info=True)
def _scheme_override_name(view):
"""File name in Packages/User that overrides the active scheme (same base name, .sublime-color-scheme)"""
try:
# already resolved through "auto" and dark/light settings by sublime
name = sublime.ui_info()["color_scheme"]["resolved_value"]
except Exception:
name = view.settings().get("color_scheme") or ""
if not name or name == "auto":
return None
root, _ = os.path.splitext(os.path.basename(name))
return root + ".sublime-color-scheme"

90
vdiff/state.py Normal file
View File

@@ -0,0 +1,90 @@
""" Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore """
from collections import namedtuple
# view setting marking a review view (keymap context "setting.vdiff_view")
VIEW_FLAG = "vdiff_view"
# view setting holding what the view shows, so a restored tab can re-render
VIEW_DATA = "vdiff"
# what a quick panel goes back to on Esc
Kept = namedtuple("Kept", "sha commit change files")
def find_change(files, path):
"""Index of the FileChange with `path`, or -1"""
for i, change in enumerate(files):
if change.path == path:
return i
return -1
class ViewState:
"""Everything a review view needs to re-render itself and act on its file
`sha` is None in working-tree mode and the reviewed commit's hash otherwise.
"""
def __init__(self, repo):
self.repo = repo
self.sha = None
self.commit = None # git.CommitInfo when sha is set
self.change = None # git.FileChange currently shown
self.files = [] # git.FileChange list the user is browsing (next/prev file)
self.lines = [] # parse.Line list currently rendered
self.blocks = [] # parse.change_blocks(lines)
self.binary = False
self.token = 0 # bumped per show request; a stale async result is dropped
self.rendered_token = -1 # token of the request that produced the current render
self.highlight = 0 # bumped per quick-panel highlight, to debounce previews
self.fingerprint = None # (file mtime, index mtime) at render time, working-tree mode
@property
def in_commit(self):
return self.sha is not None
def index(self):
"""Position of the shown file in `files`, or -1"""
return find_change(self.files, self.change.path) if self.change else -1
def kept(self):
return Kept(self.sha, self.commit, self.change, list(self.files))
def enter(self, sha=None, commit=None):
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
kept = self.kept()
if sha != self.sha:
self.change = None
self.sha, self.commit = sha, commit
return kept
def restore(self, kept):
self.sha, self.commit, self.change, self.files = kept
def persist(self, view):
"""Mirror the essentials into view settings (survives a restart)"""
data = {"repo": self.repo, "sha": self.sha}
if self.change:
data["path"] = self.change.path
data["status"] = self.change.status
data["staged"] = self.change.staged
view.settings().set(VIEW_DATA, data)
STATES = {}
def get(view):
return STATES.get(view.id())
def put(view, st):
STATES[view.id()] = st
return st
def drop(view):
STATES.pop(view.id(), None)
def is_review_view(view):
return bool(view and view.settings().get(VIEW_FLAG))

49
vdiff/utils.py Normal file
View File

@@ -0,0 +1,49 @@
""" Small helpers shared across vdiff modules """
import logging
import sublime
from . import git
SETTINGS_FILE = "vDiff.sublime-settings"
LOG = logging.getLogger("vdiff")
_settings = None
def settings():
"""Cached settings handle; a sublime Settings object stays live"""
global _settings
if _settings is None:
_settings = sublime.load_settings(SETTINGS_FILE)
_settings.add_on_change("vdiff.git_binary", apply_git_binary)
apply_git_binary()
return _settings
def apply_git_binary():
"""Push the configured git executable into the (sublime-free) git module"""
git.GIT_BINARY = settings().get("git_binary") or "git"
def status(message):
"""Status-bar message with the plugin prefix"""
sublime.status_message("vDiff: {}".format(message))
def run_async(work, done):
"""Run `work()` on sublime's worker thread, hand the result to `done` on the main thread"""
def job():
try:
result = work()
except (git.GitError, OSError) as error:
LOG.warning("git failed: %s", error)
# `error` is unbound once the except block ends; the callback runs later
message = str(error) or error.__class__.__name__
sublime.set_timeout(lambda: status(message))
return
sublime.set_timeout(lambda: done(result))
sublime.set_timeout_async(job)