initial commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
*.sublime-workspace
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.8
|
||||||
35
Default.sublime-keymap
Normal file
35
Default.sublime-keymap
Normal 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
28
README.md
Normal 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
29
diff.sublime-project
Normal 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
41
main.py
Normal 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
7
tests/conftest.py
Normal 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
223
tests/stubs/sublime.py
Normal 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)
|
||||||
12
tests/stubs/sublime_plugin.py
Normal file
12
tests/stubs/sublime_plugin.py
Normal 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
126
tests/test_commands.py
Normal 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
148
tests/test_git.py
Normal 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
117
tests/test_parse.py
Normal 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
112
tests/test_render.py
Normal 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 <x>" 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
4
vDiff.sublime-commands
Normal 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
15
vDiff.sublime-settings
Normal 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
0
vdiff/__init__.py
Normal file
427
vdiff/commands.py
Normal file
427
vdiff/commands.py
Normal 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
248
vdiff/git.py
Normal 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
23
vdiff/listeners.py
Normal 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
119
vdiff/parse.py
Normal 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
333
vdiff/render.py
Normal 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 += ' <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 += ' <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
90
vdiff/state.py
Normal 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
49
vdiff/utils.py
Normal 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)
|
||||||
Reference in New Issue
Block a user