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