From 8c174bc131e0f338655c214f0e80f546e914cd93 Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Wed, 23 Sep 2026 21:10:59 +0200 Subject: [PATCH] initial commit --- .gitignore | 5 + .python-version | 1 + Default.sublime-keymap | 35 +++ README.md | 28 +++ diff.sublime-project | 29 +++ main.py | 41 ++++ tests/conftest.py | 7 + tests/stubs/sublime.py | 223 ++++++++++++++++++ tests/stubs/sublime_plugin.py | 12 + tests/test_commands.py | 126 ++++++++++ tests/test_git.py | 148 ++++++++++++ tests/test_parse.py | 117 ++++++++++ tests/test_render.py | 112 +++++++++ vDiff.sublime-commands | 4 + vDiff.sublime-settings | 15 ++ vdiff/__init__.py | 0 vdiff/commands.py | 427 ++++++++++++++++++++++++++++++++++ vdiff/git.py | 248 ++++++++++++++++++++ vdiff/listeners.py | 23 ++ vdiff/parse.py | 119 ++++++++++ vdiff/render.py | 333 ++++++++++++++++++++++++++ vdiff/state.py | 90 +++++++ vdiff/utils.py | 49 ++++ 23 files changed, 2192 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Default.sublime-keymap create mode 100644 README.md create mode 100644 diff.sublime-project create mode 100644 main.py create mode 100644 tests/conftest.py create mode 100644 tests/stubs/sublime.py create mode 100644 tests/stubs/sublime_plugin.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_git.py create mode 100644 tests/test_parse.py create mode 100644 tests/test_render.py create mode 100644 vDiff.sublime-commands create mode 100644 vDiff.sublime-settings create mode 100644 vdiff/__init__.py create mode 100644 vdiff/commands.py create mode 100644 vdiff/git.py create mode 100644 vdiff/listeners.py create mode 100644 vdiff/parse.py create mode 100644 vdiff/render.py create mode 100644 vdiff/state.py create mode 100644 vdiff/utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e51033 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +*.sublime-workspace diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..cc1923a --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8 diff --git a/Default.sublime-keymap b/Default.sublime-keymap new file mode 100644 index 0000000..b2acaab --- /dev/null +++ b/Default.sublime-keymap @@ -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} + ] } +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..21af1c1 --- /dev/null +++ b/README.md @@ -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`. diff --git a/diff.sublime-project b/diff.sublime-project new file mode 100644 index 0000000..a9d0589 --- /dev/null +++ b/diff.sublime-project @@ -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}", + } + ], +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..367b9da --- /dev/null +++ b/main.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1d5cef5 --- /dev/null +++ b/tests/conftest.py @@ -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")) diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py new file mode 100644 index 0000000..a0c9786 --- /dev/null +++ b/tests/stubs/sublime.py @@ -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) diff --git a/tests/stubs/sublime_plugin.py b/tests/stubs/sublime_plugin.py new file mode 100644 index 0000000..2d9ef99 --- /dev/null +++ b/tests/stubs/sublime_plugin.py @@ -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 diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..23fb891 --- /dev/null +++ b/tests/test_commands.py @@ -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'"] diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..0e1310c --- /dev/null +++ b/tests/test_git.py @@ -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 diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000..4b10c6d --- /dev/null +++ b/tests/test_parse.py @@ -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 diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..6a6f108 --- /dev/null +++ b/tests/test_render.py @@ -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 ", "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 diff --git a/vDiff.sublime-commands b/vDiff.sublime-commands new file mode 100644 index 0000000..577892c --- /dev/null +++ b/vDiff.sublime-commands @@ -0,0 +1,4 @@ +[ + { "caption": "vDiff: Changed Files", "command": "vdiff_files" }, + { "caption": "vDiff: Recent Commits", "command": "vdiff_commits" } +] diff --git a/vDiff.sublime-settings b/vDiff.sublime-settings new file mode 100644 index 0000000..c7d6807 --- /dev/null +++ b/vDiff.sublime-settings @@ -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 +} diff --git a/vdiff/__init__.py b/vdiff/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vdiff/commands.py b/vdiff/commands.py new file mode 100644 index 0000000..598e40d --- /dev/null +++ b/vdiff/commands.py @@ -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 diff --git a/vdiff/git.py b/vdiff/git.py new file mode 100644 index 0000000..1d23ac1 --- /dev/null +++ b/vdiff/git.py @@ -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]) diff --git a/vdiff/listeners.py b/vdiff/listeners.py new file mode 100644 index 0000000..dadf30a --- /dev/null +++ b/vdiff/listeners.py @@ -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) diff --git a/vdiff/parse.py b/vdiff/parse.py new file mode 100644 index 0000000..8631f6b --- /dev/null +++ b/vdiff/parse.py @@ -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 diff --git a/vdiff/render.py b/vdiff/render.py new file mode 100644 index 0000000..041282d --- /dev/null +++ b/vdiff/render.py @@ -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 = 'vDiff' + else: + title = '{}'.format(html.escape(change.path)) + if change.old_path: + title += ' ← {}'.format(html.escape(change.old_path)) + title += '  -{} +{}'.format(dele, ins) + if not st.in_commit: + state = "staged" if change.staged else STATUS_NAMES.get(change.status, "") + if state: + title += '  {}'.format(state) + + actions = [] + if change is not None and not st.in_commit: + actions.append('Stage') + actions.append('Unstage') + actions.append('Discard') + if change is not None: + actions.append('Open') + actions.append('Files') + actions.append('Commits') + + commit = "" + if st.in_commit and st.commit: + info = st.commit + commit = '
{} · {} · {} · {}'.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 += ' — {}'.format(html.escape(body[0])) + commit += "
" + + return '
{}{}
{}'.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" diff --git a/vdiff/state.py b/vdiff/state.py new file mode 100644 index 0000000..996f123 --- /dev/null +++ b/vdiff/state.py @@ -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)) diff --git a/vdiff/utils.py b/vdiff/utils.py new file mode 100644 index 0000000..9f239d2 --- /dev/null +++ b/vdiff/utils.py @@ -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)