5 Commits

Author SHA1 Message Date
Vaclav Tvrdik
dc11d4e688 Branches view initial version 2026-10-14 10:25:06 +02:00
Vaclav Tvrdik
cf120991bc in all in one view mode show file status (modified/staged/added/deleted/undecided/?) 2026-10-10 15:02:39 +02:00
Vaclav Tvrdik
f18bad79cd default file view mode and some colors now configurable in settings 2026-10-10 14:27:25 +02:00
Vaclav Tvrdik
2d9db745b0 stage/unstage all actions added 2026-10-10 14:27:25 +02:00
Vaclav Tvrdik
904b941be7 stage/unstage all actions added 2026-10-10 10:24:15 +02:00
21 changed files with 3253 additions and 280 deletions

View File

@@ -3,42 +3,10 @@
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["v"], "command": "vdiff_action", "args": {"action": "view"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["right"], "command": "vdiff_action", "args": {"action": "expand"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["left"], "command": "vdiff_action", "args": {"action": "collapse"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["z"], "command": "vdiff_action", "args": {"action": "fold_all"},
{ "keys": ["b"], "command": "vdiff_action", "args": {"action": "branches"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["j"], "command": "vdiff_action", "args": {"action": "line_down"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "line_up"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["m"], "command": "vdiff_action", "args": {"action": "amend"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
@@ -49,5 +17,69 @@
{"key": "popup_visible", "operand": false},
{"key": "auto_complete_visible", "operand": false},
{"key": "num_selections", "operand": 1}
] }
] },
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["S"], "command": "vdiff_action", "args": {"action": "stage_all"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["U"], "command": "vdiff_action", "args": {"action": "unstage_all"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["v"], "command": "vdiff_action", "args": {"action": "view"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["right"], "command": "vdiff_action", "args": {"action": "expand"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["left"], "command": "vdiff_action", "args": {"action": "collapse"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["z"], "command": "vdiff_action", "args": {"action": "fold_all"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["j"], "command": "vdiff_action", "args": {"action": "line_down"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "line_up"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["m"], "command": "vdiff_action", "args": {"action": "amend"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
{ "keys": ["J"], "command": "vdiff_action", "args": {"action": "next_branch"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["K"], "command": "vdiff_action", "args": {"action": "prev_branch"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["j"], "command": "vdiff_action", "args": {"action": "next_commit"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "prev_commit"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open_commit"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["v"], "command": "vdiff_action", "args": {"action": "open_commit"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "checkout"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["shift+enter"], "command": "vdiff_action", "args": {"action": "checkout_detached"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["L"], "command": "vdiff_action", "args": {"action": "pull"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["P"], "command": "vdiff_action", "args": {"action": "push"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["F"], "command": "vdiff_action", "args": {"action": "fetch"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "delete_branch"},
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] }
]

View File

@@ -45,13 +45,17 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov
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.
- **vDiff: Branches** – local branches and remotes on the left, the commits of the selected branch on
the right; checkout, pull, push, fetch and delete from the keyboard.
## Keys inside the review view
| key | action |
|---|---|
| `f` / `c` | changed files / recent commits panel |
| `b` | the branches view |
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file |
| `S` / `U` | stage all unstaged files / unstage all staged files |
| `C` | commit the staged files, typing the message in an input panel |
| `m` | amend the last commit: edit its subject, keep its body, fold in anything staged |
| `o` or Enter | open the real file at this line |
@@ -65,7 +69,39 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov
| `?` | show this key list in a popup |
| Esc | close the view |
The header links do the same with the mouse. The all-files view shows five context lines around each change by default; configure this with `all_files_context` in `vDiff.sublime-settings`.
The header links do the same with the mouse.
## Keys inside the branches view
`b` turns the review view into the branches view: local branches and remotes in the left column, the
last N commits (`commits_count`) of the selected branch in the right one, each with its author, date,
tags and diffstat. A local branch shows how it stands against its upstream on the right of its cell:
`↑2` commits ahead, `↓1` behind, `gone` when the upstream was deleted, nothing when level or untracked.
The right column starts with the working tree: how many files are staged and unstaged (a partially
staged file counts in both) and their diffstat, or `No Changes`. The selected branch is the tinted cell;
the caret marks a commit, or that entry.
| key | action |
|---|---|
| `J` / `K` | select the next / previous branch, local ones first, then each remote's |
| `j` / `k` | move to the next / previous commit |
| Enter | checkout the selected branch; a remote branch becomes a local branch of the same name tracking it (an existing one is reused) |
| `shift+enter` | checkout a remote branch detached |
| `L` | pull: `git pull` on the checked-out branch, a fast-forward from the upstream for any other local branch |
| `P` | push to the upstream, or with `-u` to the branch's remote (origin by default) when it has none |
| `F` | fetch all remotes and prune the branches they dropped |
| `d` | delete the selected local branch, asking first (and again when it is not fully merged) |
| `o` or `v` | review the commit under the caret, or the changed files when it is on the working-tree entry |
| `f` / `c` | back to the changed files / recent commits; Esc in that panel returns here |
| `r` / `b` | reload |
| `?` | show this key list in a popup |
| Esc | close the view |
Pull, push and fetch run without a terminal, so credentials must come from a credential helper or an
ssh agent: git fails at once instead of waiting for a password. The selected branch takes its tint from
`file_header_background`; the -/+ counts and tags use `file_header_deleted_foreground` and
`file_header_inserted_foreground`, after the restart that loads their colour-scheme rules. The view reloads when it comes back into focus after a checkout, commit,
fetch or branch change was made elsewhere. A remote whose name contains a slash is listed wrongly. The all-files view shows five context lines around each change by default; configure this with `all_files_context` in `vDiff.sublime-settings`. The same file sets the background of deleted and inserted lines (`deleted_line_background`, `inserted_line_background`) and the colours of the all-files line-number column (`line_numbers_foreground`, `line_numbers_background`); left empty, they follow the colour scheme. Each all-files file heading shows the file's status letter (`s` when anything is staged, else `m`, `d`, `a`, `?` ...) before its -/+ counts, and takes its colours from `file_header_background`, `file_header_status_foreground`, `file_header_deleted_foreground` and `file_header_inserted_foreground`.
## The channel

View File

@@ -6,8 +6,7 @@
## New functionality
- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?)
- Add pull and push action
- Add tags into commits list (and branches?)
- Add tags into commits list (the branches view shows them already)
- Add stash action ..

View File

@@ -1,4 +1,4 @@
"""vDiff plugin entry point: review uncommitted changes and recent commits inside Sublime Text
"""vDiff plugin entry point: review uncommitted changes, recent commits and branches inside Sublime Text
Sublime reloads a plugin only when this file changes, so save it after editing a submodule
"""
@@ -14,6 +14,7 @@ for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
# command and listener classes are imported for sublime's discovery
from .vdiff.commands import (
VdiffActionCommand,
VdiffBranchesCommand,
VdiffCommitsCommand,
VdiffFilesCommand,
restore_view,

View File

@@ -23,3 +23,25 @@ def scheme_override_sandbox(tmp_path, monkeypatch):
palette.reset()
monkeypatch.setattr(state, "STATES", {})
monkeypatch.setattr(sublime, "packages_path", lambda: str(tmp_path))
@pytest.fixture
def rings(monkeypatch):
"""Record visual bells instead of drawing them"""
import sublime # noqa: F401 (the stub must be importable before vdiff.render)
from vdiff import render
bells = []
monkeypatch.setattr(render, "flash", lambda view: bells.append(view))
return bells
def put_caret(view, row):
"""Move the stub view's caret to the start of `row` (0 is the first row below the header)"""
import sublime
from vdiff import render
view.sel().clear()
view.sel().add(sublime.Region(render.index_point(view, row)))

View File

@@ -70,6 +70,16 @@ def status_message(text):
pass
# what ok_cancel_dialog answers; a test flips it to model Cancel
dialog_answer = True
dialogs = []
def ok_cancel_dialog(message, ok_title="", title=""):
dialogs.append((message, ok_title))
return dialog_answer
def packages_path():
return "/tmp/Packages"
@@ -146,6 +156,7 @@ class View:
self.regions = {}
self.syntax = None
self.read_only = True
self.changes = 0
self.name_ = ""
self.sel_ = []
self.status = {}
@@ -168,6 +179,10 @@ class View:
def replace(self, edit, region, text):
self.text = self.text[: region.begin()] + text + self.text[region.end() :]
self.changes += 1
def change_count(self):
return self.changes
def run_command(self, name, args=None):
if name == "vdiff_replace":
@@ -252,6 +267,19 @@ class View:
def em_width(self):
return 8.0
def style(self):
# the scheme's globals; no background here, so palette falls back as it did before this was stubbed
return {"line_highlight": "#3b4350"}
def line(self, point):
region = self.full_line(point)
end = region.b - 1 if self.text[region.b - 1:region.b] == "\n" else region.b
return Region(region.a, end)
def viewport_extent(self):
# 100 columns at the stub's em width
return (800.0, 600.0)
def extract_tokens_with_scopes(self, region):
"""One token per run of word characters and one per run of anything else, scoped by its shape

763
tests/test_branches.py Normal file
View File

@@ -0,0 +1,763 @@
import pytest
import sublime # the stub, via conftest
from conftest import put_caret # noqa: E402
from test_render import header, with_loaded_override # noqa: E402
from vdiff import branches, commands, git, listeners, palette, render, state
def ref(name, remote=None, upstream=None, ahead=0, behind=0, gone=False, current=False, sha="abc1234", upstream_remote=""):
"""A Ref; the upstream's remote is read off its name unless given (None for a local upstream)"""
branch = name.partition("/")[2] if remote else name
if upstream_remote == "":
upstream_remote = upstream.partition("/")[0] if upstream else None
return git.Ref(name, branch, remote, sha, upstream, ahead, behind, gone, current, upstream_remote)
def commit(subject, tags=(), files=8, insertions=260, deletions=58, sha="1" * 40):
return git.Commit(sha, sha[:7], "vtvrdik", "20.12.2025", subject, files, insertions, deletions, tuple(tags))
REFS = [
ref("feature"),
ref("main", upstream="origin/main", ahead=1, current=True),
ref("github/main", remote="github"),
ref("origin/main", remote="origin"),
]
COMMITS = [
commit("default file view mode and some colors now configurable in settings", tags=("v1.1.0",), sha="a" * 40),
commit("next commit message", files=None, insertions=None, deletions=None, sha="b" * 40),
]
def make_state(refs=REFS, commits=COMMITS, selected="main", remotes=("origin", "github")):
st = state.ViewState("/repo/vdiff")
st.mode = state.MODE_BRANCHES
st.branch_refs = list(refs)
st.branch_remotes = list(remotes)
st.branch_commits = list(commits)
st.branch_ref = selected
st.branch_head_sha = "abc1234"
return st
def rendered(st=None, live=False):
"""A drawn branches view; `live` models a scheme that has picked up the override, as after a restart"""
view = with_loaded_override(sublime.View()) if live else sublime.View()
render.init_view(view)
st = st or make_state()
branches.render_branches(view, st)
return view, st
def rows(view):
return view.text.split("\n")[1:]
class Calls:
"""Records the git helper calls an action makes; `answer` is what a worker returns"""
def __init__(self, monkeypatch, answer=None):
self.calls = []
self.answer = answer
for name in ("checkout", "checkout_detached", "pull", "fetch_into", "push", "fetch_all", "delete_branch"):
monkeypatch.setattr(branches.git, name, self._record(name))
self.loads = record_loads(monkeypatch)
def _record(self, name):
def call(*args, **kwargs):
self.calls.append((name, args[1:], kwargs))
if isinstance(self.answer, Exception):
raise self.answer
return self.answer
return call
def record_loads(monkeypatch):
"""Replace branches.load with a recorder of (branch, mode, message, keep_caret) per call"""
loads = []
def fake_load(view, st, message=None, keep_caret=False):
loads.append((st.branch_ref, st.mode, message, keep_caret))
monkeypatch.setattr(branches, "load", fake_load)
return loads
@pytest.fixture
def calls(monkeypatch):
return Calls(monkeypatch)
@pytest.fixture
def loads(monkeypatch):
return record_loads(monkeypatch)
@pytest.fixture
def dialogs(monkeypatch):
monkeypatch.setattr(sublime, "dialogs", [])
monkeypatch.setattr(sublime, "dialog_answer", True)
return sublime.dialogs
# --- drawing ----------------------------------------------------------------
def test_two_columns_are_laid_out_as_in_the_mockup():
view, st = rendered()
lines = rows(view)
# the left column is as wide as its longest cell plus the padding; the gap follows it
width = len(" feature") + branches.LEFT_PAD
assert all(line[width:width + 2] == "| " for line in lines)
right = 100 - width - 2
# the working tree comes first, five rows like a commit; nothing changed here
assert lines[0] == f"{'BRANCHES':<{width}}| No Changes"
assert lines[1] == f"{' feature':<{width}}| "
# main is one commit ahead of its upstream: the count sits on the right edge of its cell
assert lines[2] == f"{'* main':<{width - 3}}↑1 | "
assert lines[3] == f"{'REMOTES':<{width}}| "
assert lines[4] == f"{'origin':<{width}}| " + "-" * right
assert lines[5] == f"{' main':<{width}}| default file view mode and some colors now configurable in settings"
assert lines[6].startswith(f"{'github':<{width}}| Author: vtvrdik")
# the tag sits on the right edge of the author row
assert lines[6].endswith("v1.1.0") and len(lines[6]) == width + 2 + right
assert lines[7] == f"{' main':<{width}}| Date: 20.12.2025"
# the left column ran out, so the cells are blank
assert lines[8] == f"{'':<{width}}| Stats: 8 files changed -58 +260"
assert lines[9] == f"{'':<{width}}| " + "-" * right
assert lines[10] == f"{'':<{width}}| next commit message"
assert lines[11].rstrip() == f"{'':<{width}}| Author: vtvrdik"
# no diffstat for a merge or an empty commit
assert lines[13] == f"{'':<{width}}| Stats: (no diffstat)"
assert lines[14] == f"{'':<{width}}| " + "-" * right
assert len(lines) == 15
assert view.syntax == "scope:text.plain"
assert view.name_ == "⟐ branches"
CHANGES = [
git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True),
git.FileChange("b.txt", "M", True, 1, 0, None),
git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True), # partially staged: counted on both sides
git.FileChange("img.png", "M", True, None, None, None), # binary: no counts
]
def test_the_working_tree_entry_counts_the_staged_and_unstaged_files_and_their_diffstat():
st = make_state()
st.branch_changes = list(CHANGES)
view, st = rendered(st)
lines = rows(view)
width = len(" feature") + branches.LEFT_PAD
assert lines[0] == f"{'BRANCHES':<{width}}| 3 staged files, 2 unstaged files"
assert lines[1] == f"{' feature':<{width}}| Stats: 4 files changed -1 +4"
assert lines[2] == f"{'* main':<{width - 3}}↑1 | " and lines[3] == f"{'REMOTES':<{width}}| "
assert lines[4].endswith("-" * 10) and lines[5].endswith("| default file view mode and some colors now configurable in settings")
# its counts are coloured like the commits', in row order
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_DEL][0]] == ["-1", "-58"]
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["+4", "v1.1.0", "+260"]
# the singular
st.branch_changes = [CHANGES[0]]
branches.render_branches(view, st)
assert rows(view)[0] == f"{'BRANCHES':<{width}}| 0 staged files, 1 unstaged file"
st.branch_changes = [CHANGES[1]]
branches.render_branches(view, st)
assert rows(view)[0] == f"{'BRANCHES':<{width}}| 1 staged file, 0 unstaged files"
def test_local_branches_show_their_standing_against_the_upstream():
refs = [ref("level", upstream="origin/level"), ref("ahead", upstream="origin/ahead", ahead=3, current=True),
ref("both", upstream="origin/both", ahead=12, behind=4), ref("lost", upstream="origin/lost", gone=True),
ref("untracked"), ref("origin/level", remote="origin")]
view, st = rendered(make_state(refs=refs, selected="ahead", remotes=("origin",)))
cells = [line.split("| ")[0] for line in rows(view)]
width = len(" both ↑12 ↓4") + branches.LEFT_PAD
# in the listing's order; level and untracked branches carry nothing, nor do remote ones
assert cells[1:6] == [f"{' level':<{width}}", f"{'* ahead':<{width - 3}}↑3 ", f"{' both':<{width - 7}}↑12 ↓4 ",
f"{' lost':<{width - 5}}gone ", f"{' untracked':<{width}}"]
assert cells[7] == f"{'origin':<{width}}" and cells[8] == f"{' level':<{width}}"
# ahead is green, behind and gone red; the selected branch's standing is painted apart, on its background
def painted(key):
"""The coloured texts under a key"""
return [view.text[r.a:r.b] for r in view.regions[key][0]]
assert painted(render.KEY_BRANCH_STANDING_INS) == ["↑12"] and painted(render.KEY_BRANCH_STANDING_DEL) == ["↓4", "gone"]
assert painted(render.KEY_BRANCH_SEL_INS) == ["↑3"] and painted(render.KEY_BRANCH_SEL_DEL) == []
# moving the selection moves the standing between the keys
branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == "both"
assert painted(render.KEY_BRANCH_STANDING_INS) == ["↑3"] and painted(render.KEY_BRANCH_STANDING_DEL) == ["gone"]
assert painted(render.KEY_BRANCH_SEL_INS) == ["↑12"] and painted(render.KEY_BRANCH_SEL_DEL) == ["↓4"]
# a long name gives way to its counts, never the other way round
long = ref("feature/" + "x" * 60, upstream="origin/x", behind=2)
view, st = rendered(make_state(refs=[ref("main", current=True), long], selected="main"))
cell = rows(view)[2][:branches.LEFT_MAX]
assert cell.endswith("↓2 ") and "…" in cell and len(cell) == branches.LEFT_MAX
def test_rows_know_their_branch_and_commit():
view, st = rendered()
# item 0 is the working tree, the commits follow from 1
assert [(r.ref and r.ref.name, r.commit) for r in st.branch_rows] == [
(None, 0), ("feature", 0), ("main", 0), (None, 0), (None, 0),
("origin/main", 1), (None, 1), ("github/main", 1), (None, 1), (None, 1),
(None, 2), (None, 2), (None, 2), (None, 2), (None, 2),
]
def test_a_remote_without_fetched_branches_keeps_its_row():
view, st = rendered(make_state(refs=[ref("main", current=True)], remotes=("origin", "gitea")))
names = [line.split("|")[0].rstrip() for line in rows(view)]
assert names[:5] == ["BRANCHES", "* main", "REMOTES", "origin", "gitea"]
def test_long_branch_names_are_clipped_at_the_cap():
long = "feature/" + "x" * 60
view, st = rendered(make_state(refs=[ref("main", current=True), ref(long)], selected="main"))
width = branches.LEFT_MAX
assert rows(view)[2].startswith(f"{' ' + long[:width - 4]}…" + " | ") or rows(view)[2][width:width + 2] == "| "
assert rows(view)[2][:width].rstrip().endswith("…")
def test_selection_headings_tags_and_counts_are_painted():
view, st = rendered()
width = len(" feature") + branches.LEFT_PAD
starts = render._row_starts_for_text(rows(view))
selected, scope, _, _ = view.regions[render.KEY_BRANCH_SEL]
assert selected == [sublime.Region(starts[2], starts[2] + width)]
assert scope == render._file_header_scope(view)
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_DEL][0]] == ["-58"]
# the tags are green like the insertions, in row order; main's ↑1 sits on the selected row
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["v1.1.0", "+260"]
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_SEL_INS][0]] == ["↑1"]
assert view.regions[render.KEY_BRANCH_STANDING_INS][0] == []
# filled regions, since only those take the scope's text colour; the scheme's tint stands in until the
# override's red/green-on-background rules are live, after which the text is red / green on the editor
# background, or on the heading background along the selected row so its tint runs through the standing
assert not view.regions[render.KEY_BRANCH_DEL][3] & sublime.DRAW_NO_FILL
assert view.regions[render.KEY_BRANCH_DEL][1] == palette.SCOPE_DEL
assert view.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_INS
assert view.regions[render.KEY_BRANCH_SEL_INS][1] == palette.SCOPE_INS
live, _ = rendered(live=True)
assert live.regions[render.KEY_BRANCH_DEL][1] == palette.SCOPE_TEXT_DEL
assert live.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_TEXT_INS
assert live.regions[render.KEY_BRANCH_STANDING_DEL][1] == palette.SCOPE_TEXT_DEL
assert live.regions[render.KEY_BRANCH_SEL_DEL][1] == palette.SCOPE_FILE_DEL_COUNT
assert live.regions[render.KEY_BRANCH_SEL_INS][1] == palette.SCOPE_FILE_INS_COUNT
def test_caret_starts_on_the_working_tree_entry_and_stays_on_its_item_across_a_refresh():
view, st = rendered()
assert view.sel()[0].a == render.index_point(view, 0) and st.branch_rows[0].commit == branches.WORKTREE
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == branches.ROWS_PER_COMMIT
branches.render_branches(view, st, keep_caret=True)
assert render.caret_index(view) == branches.ROWS_PER_COMMIT
# a plain redraw goes back to the top
branches.render_branches(view, st)
assert render.caret_index(view) == 0
def test_no_commits_and_no_branches_still_draw():
view, st = rendered(make_state(refs=[], commits=[], selected=None, remotes=()))
rule = "-" * (100 - len("BRANCHES | "))
assert rows(view) == ["BRANCHES | No Changes", "REMOTES | ", " | ", " | ", " | " + rule,
" | (no commits)"]
# the one item to step through is the working tree
assert view.sel()[0].a == render.index_point(view, 0)
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == 0
assert view.regions[render.KEY_BRANCH_SEL][0] == []
assert "no branch selected" in header(view)
def test_render_sets_the_mode_and_persists_the_branch():
view, st = rendered()
assert view.settings().get(state.MODE_SETTING) == "branches"
assert view.settings().get(state.VIEW_DATA) == {"repo": "/repo/vdiff", "sha": None, "mode": "branches", "ref": "main"}
assert view.status[render.STATUS_KEY] == render.key_hints("branches")
assert st.fingerprint == ((), (None,)) # no ref paths and no index outside a repository
# a diff render switches back
st.change = git.FileChange("x.py", "M", False, 1, 1, None)
render.render_message(view, st, "nothing")
assert view.settings().get(state.MODE_SETTING) == "diff"
assert "mode" not in view.settings().get(state.VIEW_DATA)
assert view.status[render.STATUS_KEY] == render.key_hints()
assert render.KEY_BRANCH_SEL not in view.regions
def test_an_unchanged_header_is_not_redrawn():
view, st = rendered()
first = view.phantoms[render.PHANTOM_KEY][0]
# the same header over the same text keeps the phantom in place
render._header(view, st, st.header[0])
assert view.phantoms[render.PHANTOM_KEY] == [first]
# new text may have dropped the phantom, so it is drawn again even for the same header
branches.render_branches(view, st, keep_caret=True)
assert view.phantoms[render.PHANTOM_KEY][0] is not first and len(view.phantoms[render.PHANTOM_KEY]) == 1
def test_header_shows_head_upstream_and_the_links_of_the_selected_kind():
view, st = rendered()
text = header(view)
assert "Branches · vdiff" in text and ">main<" in text and "on <span" in text
# the standing as the cell shows it: a zero count is left out
assert "→ origin/main" in text and '<span class="ins">↑1</span>' in text and "↓0" not in text
st.branch_refs[1] = ref("main", upstream="origin/main", behind=2, current=True)
branches.render_branches(view, st)
assert '<span class="del">↓2</span>' in header(view) and "↑0" not in header(view)
st.branch_refs[1] = ref("main", upstream="origin/main", current=True)
branches.render_branches(view, st)
assert "up to date" in header(view)
st.branch_refs[1] = REFS[1]
branches.render_branches(view, st)
for link in ("checkout", "pull", "push", "fetch", "delete_branch", "files", "commits", "help"):
assert f'href="{link}"' in text
assert 'href="checkout_detached"' not in text
st.branch_ref = "origin/main"
branches.render_branches(view, st)
text = header(view)
assert "remote branch of origin" in text and 'href="checkout_detached"' in text
assert 'href="delete_branch"' not in text and 'href="pull"' not in text
st.branch_ref = "feature"
st.branch_refs[1] = ref("main", upstream="origin/main", ahead=1) # nothing checked out: detached
branches.render_branches(view, st)
text = header(view)
assert "detached at" in text and "abc1234" in text and "→" not in text
st.branch_head_sha = ""
branches.render_branches(view, st)
assert "no commits" in header(view)
st.branch_refs[0] = ref("feature", upstream="origin/feature", gone=True)
branches.render_branches(view, st)
assert ">gone<" in header(view)
# --- navigation -------------------------------------------------------------
def test_j_and_k_step_commits_and_ring_at_the_ends(rings):
view, st = rendered()
# the working tree, then the two commits
branches.ACTIONS["prev_commit"](view, None, st)
assert len(rings) == 1 and render.caret_index(view) == 0
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == 5
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == 10
branches.ACTIONS["next_commit"](view, None, st)
assert len(rings) == 2 and render.caret_index(view) == 10
branches.ACTIONS["prev_commit"](view, None, st)
assert render.caret_index(view) == 5
# off the items (the caret moved by the arrows onto a left-column-only row): k goes to the last, j to the first
view, st = rendered(make_state(refs=REFS + [ref("x"), ref("y"), ref("z")], commits=COMMITS[:1]))
assert st.branch_rows[10].commit == -1
put_caret(view, 10)
branches.ACTIONS["prev_commit"](view, None, st)
assert render.caret_index(view) == 5
put_caret(view, 10)
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == 0
def test_J_and_K_select_branches_skipping_headings_and_reload_the_log(monkeypatch, rings):
logged = []
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: logged.append(name) or [COMMITS[1]])
view, st = rendered()
assert st.branch_ref == "main"
branches.ACTIONS["prev_branch"](view, None, st)
assert st.branch_ref == "feature" and logged == ["feature"]
branches.ACTIONS["prev_branch"](view, None, st)
assert st.branch_ref == "feature" and len(rings) == 1
for expected in ("main", "github/main", "origin/main"):
branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == expected
branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == "origin/main" and len(rings) == 2
# the stub runs the worker at once, so the log was redrawn with the new selection
assert rows(view)[5].endswith("| next commit message")
selected = view.regions[render.KEY_BRANCH_SEL][0]
assert view.text[selected[0].a:selected[0].b].strip() == "main"
# the caret went back to the working-tree entry
assert render.caret_index(view) == 0 and st.branch_rows[0].commit == branches.WORKTREE
def test_a_stale_log_is_dropped(monkeypatch):
pending = []
monkeypatch.setattr(branches, "run_async", lambda work, done: pending.append((work, done)))
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: [commit(name)])
view, st = rendered()
branches.ACTIONS["prev_branch"](view, None, st) # feature
branches.ACTIONS["next_branch"](view, None, st) # main
assert [name for name in (st.branch_ref,)] == ["main"] and len(pending) == 2
# the older answer lands first, but the token moved on
work, done = pending[0]
done(work())
assert "| feature" not in view.text
work, done = pending[1]
done(work())
assert rows(view)[5].endswith("| main")
def test_load_lists_everything_and_picks_the_branch(monkeypatch):
monkeypatch.setattr(branches.git, "head_sha", lambda repo: "abc1234")
monkeypatch.setattr(branches.git, "ref_paths", lambda repo: ())
monkeypatch.setattr(branches.git, "branches", lambda repo: list(REFS))
monkeypatch.setattr(branches.git, "remotes", lambda repo: ["origin", "github"])
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: [commit(f"log of {name}")])
monkeypatch.setattr(branches.git, "index_path", lambda repo: None)
monkeypatch.setattr(branches.git, "changed_files", lambda repo: [CHANGES[2]])
view = sublime.View()
render.init_view(view)
st = state.ViewState("/repo/vdiff")
branches.load(view, st)
# nothing wanted: the checked-out branch; the working tree above its log
assert st.branch_ref == "main" and rows(view)[5].endswith("| log of main")
assert rows(view)[0].endswith("| 1 staged file, 1 unstaged file") and rows(view)[1].endswith("| Stats: 1 file changed -0 +2")
assert st.branch_changes == [CHANGES[2]]
assert st.branch_remotes == ["origin", "github"] and st.branch_head_sha == "abc1234"
assert st.token == st.rendered_token
# a wanted branch that vanished falls back the same way
st.branch_ref = "deleted"
branches.load(view, st)
assert st.branch_ref == "main"
st.branch_ref = "origin/main"
branches.load(view, st)
assert st.branch_ref == "origin/main" and rows(view)[5].endswith("| log of origin/main")
def test_pick_prefers_the_wanted_then_the_current_then_the_first_local_then_the_first():
assert branches._pick(REFS, "origin/main").name == "origin/main"
assert branches._pick(REFS, "gone").name == "main"
detached = [ref("feature"), ref("zeta"), ref("origin/x", remote="origin")]
assert branches._pick(detached, None).name == "feature"
assert branches._pick([ref("origin/x", remote="origin")], None).name == "origin/x"
assert branches._pick([], None) is None
# --- actions ----------------------------------------------------------------
def test_checkout_routes_local_current_and_remote_branches(calls):
view, st = rendered()
branches.ACTIONS["checkout"](view, None, st)
assert calls.calls == [] and calls.loads == [] # already on main
st.branch_ref = "feature"
branches.ACTIONS["checkout"](view, None, st)
assert calls.calls[-1] == ("checkout", (REFS[0], ["feature", "main"]), {})
assert calls.loads[-1] == ("feature", "branches", "checked out feature", False)
st.branch_ref = "github/main"
branches.ACTIONS["checkout"](view, None, st)
assert calls.calls[-1][1][0].name == "github/main"
assert st.branch_ref == "main" # the local branch of that name is what got checked out
st.branch_ref = "origin/main"
branches.ACTIONS["checkout_detached"](view, None, st)
assert calls.calls[-1] == ("checkout_detached", ("origin/main",), {})
assert calls.loads[-1][2] == "checked out origin/main detached"
st.branch_ref = "feature"
branches.ACTIONS["checkout_detached"](view, None, st)
assert calls.calls[-1][0] == "checkout_detached" and len(calls.calls) == 3 # refused for a local branch
def test_pull_pulls_the_current_branch_and_fast_forwards_another(calls):
view, st = rendered(make_state(refs=REFS + [ref("tracked", upstream="origin/tracked", behind=2)]))
branches.ACTIONS["pull"](view, None, st)
assert calls.calls[-1] == ("pull", (), {}) and calls.loads[-1][2] == "pulled main"
st.branch_ref = "tracked"
branches.ACTIONS["pull"](view, None, st)
assert calls.calls[-1] == ("fetch_into", ("origin", "tracked", "tracked"), {})
assert calls.loads[-1][2] == "fast-forwarded tracked to origin/tracked"
# the upstream's own name on its remote, not the local one
st.branch_refs.append(ref("foo", upstream="origin/bar", behind=1))
st.branch_ref = "foo"
branches.ACTIONS["pull"](view, None, st)
assert calls.calls[-1] == ("fetch_into", ("origin", "bar", "foo"), {})
st.branch_refs.append(ref("local", upstream="main", upstream_remote=None, behind=1))
for name in ("feature", "origin/main", "local"):
st.branch_ref = name
branches.ACTIONS["pull"](view, None, st)
assert len(calls.calls) == 3 # no upstream, a remote row and a local upstream: all refused
def test_push_uses_the_upstream_or_sets_one_on_origin(calls):
view, st = rendered()
branches.ACTIONS["push"](view, None, st)
assert calls.calls[-1] == ("push", ("origin", "main"), {"remote_branch": "main"}) and calls.loads[-1][2] == "pushed main to origin"
# to the upstream itself when it is called differently there
st.branch_refs.append(ref("foo", upstream="gitea/bar", ahead=2))
st.branch_ref = "foo"
branches.ACTIONS["push"](view, None, st)
assert calls.calls[-1] == ("push", ("gitea", "foo"), {"remote_branch": "bar"})
st.branch_ref = "feature"
branches.ACTIONS["push"](view, None, st)
assert calls.calls[-1] == ("push", ("origin", "feature"), {"set_upstream": True})
# without origin the first remote takes it; without any, nothing happens; nor for a local upstream
st.branch_remotes = ["gitea"]
branches.ACTIONS["push"](view, None, st)
assert calls.calls[-1] == ("push", ("gitea", "feature"), {"set_upstream": True})
st.branch_remotes = []
branches.ACTIONS["push"](view, None, st)
st.branch_ref = "origin/main"
st.branch_remotes = ["origin"]
branches.ACTIONS["push"](view, None, st)
st.branch_refs.append(ref("local", upstream="main", upstream_remote=None, ahead=1))
st.branch_ref = "local"
branches.ACTIONS["push"](view, None, st)
assert len(calls.calls) == 4
def test_fetch_fetches_everything_and_keeps_the_cursor(calls):
view, st = rendered()
branches.ACTIONS["fetch"](view, None, st)
assert calls.calls == [("fetch_all", (), {})] and calls.loads == [("main", "branches", "fetched all remotes", True)]
def test_delete_asks_then_deletes_and_asks_again_for_an_unmerged_branch(calls, dialogs, monkeypatch):
view, st = rendered()
st.branch_ref = "feature"
branches.ACTIONS["delete_branch"](view, None, st)
assert dialogs == [("Delete the branch feature?", "Delete")]
assert calls.calls == [("delete_branch", ("feature",), {})] and calls.loads == [("feature", "branches", "deleted feature", False)]
# not fully merged: a second question, then the forced deletion
calls.answer = git.GitError("error: The branch 'feature' is not fully merged.\n", 1)
del dialogs[:]
branches.ACTIONS["delete_branch"](view, None, st)
assert [d[1] for d in dialogs] == ["Delete", "Delete anyway"]
assert calls.calls[-1] == ("delete_branch", ("feature",), {"force": True})
# Cancel on the first question does nothing; so does a remote or the checked-out branch
monkeypatch.setattr(sublime, "dialog_answer", False)
count = len(calls.calls)
branches.ACTIONS["delete_branch"](view, None, st)
st.branch_ref = "main"
branches.ACTIONS["delete_branch"](view, None, st)
st.branch_ref = "origin/main"
branches.ACTIONS["delete_branch"](view, None, st)
assert len(calls.calls) == count
def test_other_git_errors_from_delete_still_reach_the_status_bar(calls, dialogs, monkeypatch):
messages = []
monkeypatch.setattr(branches, "status", lambda m: messages.append(m))
monkeypatch.setattr("vdiff.utils.status", lambda m: messages.append(m))
calls.answer = git.GitError("fatal: something else\n", 1)
view, st = rendered()
st.branch_ref = "feature"
branches.ACTIONS["delete_branch"](view, None, st)
assert messages[-1] == "fatal: something else" and calls.loads == []
def test_o_reviews_the_commit_under_the_caret():
view, st = rendered()
window = sublime.Window()
# the working-tree entry opens the files view, which itself stays put when there is nothing to list
branches.ACTIONS["open_commit"](view, window, st)
assert window.commands == [("vdiff_files", None)]
branches.ACTIONS["next_commit"](view, window, st)
branches.ACTIONS["open_commit"](view, window, st)
branches.ACTIONS["next_commit"](view, window, st)
branches.ACTIONS["open_commit"](view, window, st)
assert window.commands[1:] == [("vdiff_commits", {"sha": "a" * 40}), ("vdiff_commits", {"sha": "b" * 40})]
# a row without an item (the left column outran the log) opens nothing
view, st = rendered(make_state(refs=REFS + [ref("x"), ref("y"), ref("z")], commits=COMMITS[:1]))
put_caret(view, 10)
branches.ACTIONS["open_commit"](view, window, st)
assert len(window.commands) == 3
def test_branch_actions_refuse_a_diff_view(calls):
view, st = rendered()
st.mode = state.MODE_DIFF
for name in branches.ACTIONS:
branches.ACTIONS[name](view, sublime.Window(), st)
assert calls.calls == [] and calls.loads == []
# --- wiring -----------------------------------------------------------------
def test_b_opens_the_branches_command_and_refresh_reloads_in_place(loads):
view, st = rendered()
window = sublime.Window()
commands.ACTIONS["branches"](view, window, st)
assert window.commands == [("vdiff_branches", None)]
commands.ACTIONS["refresh"](view, window, st)
assert loads == [("main", "branches", None, True)]
def test_restore_view_comes_back_in_branches_mode(loads, tmp_path):
view = sublime.View()
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "mode": "branches", "ref": "feature"})
st = commands.restore_view(view)
assert loads == [("feature", "branches", None, False)] and view.settings().get(state.MODE_SETTING) == "branches"
# while the load (stubbed here) is in flight an activation does not start a second one
st.token += 1
commands.refresh_if_changed(view, st)
assert len(loads) == 1
def test_esc_from_the_files_panel_goes_back_to_the_branches(monkeypatch, loads):
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
view, st = rendered()
kept = st.enter()
assert kept.mode == "branches"
window = sublime.Window()
files = [git.FileChange("a.py", "M", False, 1, 1, None)]
commands.show_files_panel(window, view, st, files, kept, placeholder="x")
_, on_select, _, on_highlight, _ = window.panel
on_highlight(0)
st.mode = state.MODE_DIFF # the preview drew a diff
on_select(-1)
assert [m for _, m, _, _ in loads] == ["branches"] and st.in_branches
def test_keymap_context_answers_the_mode():
listener = listeners.VdiffEventListener()
view, st = rendered()
assert view.settings().get(state.MODE_SETTING) == "branches"
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_EQUAL, "branches", False) is True
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_EQUAL, "diff", False) is False
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_NOT_EQUAL, "diff", False) is True
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_NOT_EQUAL, "branches", False) is False
# other keys and other views are someone else's business
assert listener.on_query_context(view, "setting.vdiff_mode", sublime.OP_EQUAL, "branches", False) is None
assert listener.on_query_context(sublime.View(), "vdiff_mode", sublime.OP_EQUAL, "diff", False) is None
# a review view without the setting (an older session) is a diff view
plain = sublime.View()
plain.settings().set(state.VIEW_FLAG, True)
assert listener.on_query_context(plain, "vdiff_mode", sublime.OP_EQUAL, "diff", False) is True
def test_branches_view_reloads_when_the_refs_changed(monkeypatch):
mtimes = {"/repo/.git/HEAD": 1.0, "/repo/.git/refs/heads": 2.0, "/repo/vdiff/a.py": 5.0}
monkeypatch.setattr(branches.git, "ref_paths", lambda repo: ("/repo/.git/HEAD", "/repo/.git/refs/heads"))
monkeypatch.setattr(branches.git, "index_path", lambda repo: "/repo/.git/index")
monkeypatch.setattr(git, "mtime", lambda path: mtimes.get(path))
loads = record_loads(monkeypatch)
worktree_loads = []
monkeypatch.setattr(branches, "load_worktree", lambda view, st: worktree_loads.append(st.branch_ref))
st = make_state()
st.branch_changes = [CHANGES[0]]
view, st = rendered(st)
st.rendered_token = st.token
# the refs, then the changed files of the working-tree entry and the index they are staged in
assert st.fingerprint == ((1.0, 2.0), (5.0, None))
commands.refresh_if_changed(view, st)
assert loads == [] and worktree_loads == []
mtimes["/repo/.git/HEAD"] = 3.0 # a checkout elsewhere: everything is relisted
commands.refresh_if_changed(view, st)
assert loads == [("main", "branches", None, True)]
st.rendered_token = st.token
st.fingerprint = branches.fingerprint(st)
# the changed file saved, something staged: only the working tree is relisted
mtimes["/repo/vdiff/a.py"] = 6.0
commands.refresh_if_changed(view, st)
assert len(loads) == 1 and worktree_loads == ["main"]
st.fingerprint = branches.fingerprint(st)
mtimes["/repo/.git/index"] = 7.0
commands.refresh_if_changed(view, st)
assert len(loads) == 1 and worktree_loads == ["main", "main"]
# not while a load is in flight
st.token += 1
mtimes["/repo/.git/HEAD"] = 4.0
commands.refresh_if_changed(view, st)
assert len(loads) == 1 and len(worktree_loads) == 2
def test_load_worktree_relists_only_the_changes_and_keeps_the_caret(monkeypatch):
listed = []
monkeypatch.setattr(branches.git, "changed_files", lambda repo: listed.append(repo) or [CHANGES[1]])
monkeypatch.setattr(branches.git, "index_path", lambda repo: None)
view, st = rendered()
branches.ACTIONS["next_commit"](view, None, st)
branches.load_worktree(view, st)
assert listed == ["/repo/vdiff"] and st.branch_changes == [CHANGES[1]]
assert rows(view)[0].endswith("| 1 staged file, 0 unstaged files") and rows(view)[5].endswith("| default file view mode and some colors now configurable in settings")
assert render.caret_index(view) == 5 and st.token == st.rendered_token
# a later load wins over a slower relisting
pending = []
monkeypatch.setattr(branches, "run_async", lambda work, done: pending.append((work, done)))
branches.load_worktree(view, st)
st.token += 1
work, done = pending[0]
done([])
assert st.branch_changes == [CHANGES[1]]
def test_the_commit_cursor_tints_only_the_right_column_once_its_rule_is_live():
# before the restart: Sublime's line highlight, no cursor regions
view, st = rendered()
assert view.settings().get("highlight_line") is True
assert render.KEY_BRANCH_CURSOR not in view.regions
# after it: the four cells of the caret's commit, from the gap to the end of the row
view, st = rendered(live=True)
assert view.settings().get("highlight_line") is False
regions, scope, _, _ = view.regions[render.KEY_BRANCH_CURSOR]
assert scope == palette.SCOPE_CURSOR
width = len(" feature") + branches.LEFT_PAD + 2
lines = rows(view)
assert [view.text[r.a:r.b] for r in regions] == [line[width:] for line in lines[:4]]
# the commits' counts and tags keep the plain red / green
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["v1.1.0", "+260"]
assert view.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_TEXT_INS
assert view.regions[render.KEY_BRANCH_CURSOR_INS][0] == [] and view.regions[render.KEY_BRANCH_CURSOR_DEL][0] == []
branches.ACTIONS["next_commit"](view, None, st)
regions = view.regions[render.KEY_BRANCH_CURSOR][0]
assert regions[0].a == render.index_point(view, 5) + width
# the tint leaves out the counts and tags, which get the cursor's background with their own colour instead:
# overlapping regions would let the later one recolour the text
text = lambda key: [view.text[r.a:r.b] for r in view.regions[key][0]] # noqa: E731
assert text(render.KEY_BRANCH_CURSOR_INS) == ["v1.1.0", "+260"] and text(render.KEY_BRANCH_CURSOR_DEL) == ["-58"]
assert view.regions[render.KEY_BRANCH_CURSOR_INS][1] == palette.SCOPE_CURSOR_INS
assert text(render.KEY_BRANCH_INS) == [] and text(render.KEY_BRANCH_DEL) == []
pieces = sorted(sum((list(view.regions[key][0]) for key in (render.KEY_BRANCH_CURSOR, render.KEY_BRANCH_CURSOR_INS,
render.KEY_BRANCH_CURSOR_DEL)), []), key=lambda r: r.a)
assert all(a.b <= b.a for a, b in zip(pieces, pieces[1:]))
by_row = {}
for r in pieces:
by_row.setdefault(view.rowcol(r.a)[0] - 1, []).append(view.text[r.a:r.b])
assert {row: "".join(parts) for row, parts in by_row.items()} == {i: lines[i][width:] for i in range(5, 9)}
# back in a diff the line highlight returns
st.change = git.FileChange("x.py", "M", False, 1, 1, None)
render.render_message(view, st, "nothing")
assert view.settings().get("highlight_line") is True
def test_holding_J_fetches_the_log_once_the_key_rests(monkeypatch):
timers = []
monkeypatch.setattr(sublime, "set_timeout", lambda fn, delay=0: timers.append((fn, delay)))
logged = []
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: logged.append(name) or [])
view, st = rendered()
branches.ACTIONS["prev_branch"](view, None, st)
branches.ACTIONS["next_branch"](view, None, st)
branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == "github/main" and logged == []
assert [delay for _, delay in timers] == [branches.DEBOUNCE_MS] * 3
for fn, _ in timers:
fn()
# only the last selection's fetch ran; the cell moved at once each time
assert logged == ["github/main"]
selected = view.regions[render.KEY_BRANCH_SEL][0]
assert view.text[selected[0].a:selected[0].b].strip() == "main"
def test_entering_the_branches_view_vacates_the_diff_state(monkeypatch):
monkeypatch.setattr(branches, "run_async", lambda work, done: None)
view = sublime.View()
render.init_view(view)
st = state.ViewState("/repo/vdiff")
st.sha, st.change = "abc", git.FileChange("x.py", "M", False, 1, 1, None)
st.files = [st.change]
branches.load(view, st)
assert st.in_branches and st.sha is None and st.change is None and st.requested is None and st.lines == []
assert st.files == [st.change] or st.files == [git.FileChange("x.py", "M", False, 1, 1, None)]

View File

@@ -1,7 +1,10 @@
import os
import pytest
import sublime # the stub, via conftest
from conftest import put_caret # noqa: E402
from vdiff import commands, git, state, utils
@@ -92,6 +95,64 @@ def test_enter_skips_refetch_when_preview_is_current(monkeypatch):
assert shown == ["b.py", "a.py"]
@pytest.mark.parametrize("mode, all_mode", [("file_by_file", False), ("all_in_one", True), (None, False)])
def test_a_new_review_view_starts_in_the_default_view_mode(monkeypatch, mode, all_mode):
option(monkeypatch, "default_view_mode", mode)
monkeypatch.setattr(commands, "resolve_repo", lambda window: "/repo")
monkeypatch.setattr(commands, "find_review_view", lambda window: sublime.View())
_, st = commands.open_review(sublime.Window())
assert st.all_mode is all_mode
def test_panel_in_all_in_one_mode_scrolls_instead_of_loading_files(monkeypatch):
loads, focused = [], []
def fake_show_all(view, st, focus=None):
# load at once: what show_all's done does once the diffs land
st.token += 1
st.rendered_token = st.token
loads.append(st.change.path)
monkeypatch.setattr(commands, "show_all", fake_show_all)
monkeypatch.setattr(commands, "show_file", lambda view, st, c: pytest.fail("loaded a single file"))
monkeypatch.setattr(commands.render, "render_all", lambda view, st, focus=None: focused.append(focus))
st = state.ViewState("/repo", all_mode=True)
st.change = change("kept.py")
st.files = [st.change]
files = [change("a.py"), change("b.py")]
st.collapsed = {"b.py"}
window = sublime.Window()
commands.show_files_panel(window, sublime.View(), st, files, st.kept())
_, on_select, _, on_highlight, _ = window.panel
assert loads == ["a.py"]
# a highlight unfolds and scrolls to its file; Enter on it needs no redraw
on_highlight(1)
assert focused == ["b.py"] and st.change.path == "b.py" and "b.py" not in st.collapsed
on_select(1)
assert focused == ["b.py"] and loads == ["a.py"]
# Esc goes back to the kept file, still in the all-in-one view
on_select(-1)
assert st.all_mode and st.change.path == "kept.py" and loads == ["a.py", "kept.py"]
def test_esc_goes_back_to_the_kept_view_mode(monkeypatch):
calls = []
monkeypatch.setattr(commands, "show_file", lambda view, st, c: calls.append(("file", c.path)))
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: calls.append(("all", st.change.path)))
st = state.ViewState("/repo")
st.change = change("kept.py")
st.files = [st.change]
kept = st.kept()
# the view switched to all-in-one after the snapshot; Esc still restores file-by-file
st.all_mode = True
window = sublime.Window()
commands.show_files_panel(window, sublime.View(), st, [change("a.py")], kept)
window.panel[1](-1)
assert calls[-1] == ("file", "kept.py") and not st.all_mode
def test_on_cancel_goes_back_to_commit_list(monkeypatch):
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
st = state.ViewState("/repo")
@@ -103,6 +164,58 @@ def test_on_cancel_goes_back_to_commit_list(monkeypatch):
assert back == [1] and st.sha is None
def test_esc_from_the_reopened_commit_list_returns_to_the_branches_view(monkeypatch):
loads = []
monkeypatch.setattr(commands.branches, "load", lambda view, st, keep_caret=False: loads.append(st.mode))
window = sublime.Window()
view = sublime.View()
st = state.ViewState("/repo")
st.mode = state.MODE_BRANCHES
commit = git.Commit("a" * 40, "a" * 7, "me", "today", "subject", 1, 1, 0)
# Esc on the list opened straight over the branches view: nothing to put back
st.drawn, st.rendered_token = st.describe(), st.token
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
window.panel[1](-1)
assert loads == []
# reopened after a commit's files were cancelled: the state says branches, the screen still shows the commit
st.drawn = (state.MODE_DIFF, "a" * 40, False, "x.py")
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
window.panel[1](-1)
assert loads == [state.MODE_BRANCHES]
# the same holds for a diff the list was reopened over: the kept working-tree file is shown again
shown = []
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(c.path))
st.mode, st.change = state.MODE_DIFF, change("kept.py")
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
window.panel[1](-1)
assert shown == ["kept.py"]
def test_files_on_a_clean_tree_leave_the_branches_view_in_place(monkeypatch):
view, st = sublime.View(), state.ViewState("/repo")
messages, notices = [], []
monkeypatch.setattr(commands, "open_review", lambda window: (view, st))
monkeypatch.setattr(commands, "status", messages.append)
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: notices.append(text))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
window = sublime.Window()
st.mode = state.MODE_BRANCHES
commands.VdiffFilesCommand(window).run()
assert messages == ["no uncommitted changes"] and notices == [] and st.in_branches and window.panel is None
# with changes the panel comes up as usual; in a diff view the notice is shown
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py")])
commands.VdiffFilesCommand(window).run()
assert window.panel is not None
# nor does a commit under review give way to it
st.mode, st.sha = state.MODE_DIFF, "a" * 40
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
commands.VdiffFilesCommand(window).run()
assert notices == [] and st.sha == "a" * 40
st.sha = None
commands.VdiffFilesCommand(window).run()
assert notices == ["No uncommitted changes in /repo"]
def test_step_change_uses_blocks(rings):
view, st = at_change([(2, 4), (7, 8)], 0)
commands.ACTIONS["next_change"](view, None, st)
@@ -416,14 +529,6 @@ def test_esc_rewinds_the_file_navigation_counts_from(loading):
# --- cycling past the ends --------------------------------------------------
@pytest.fixture
def rings(monkeypatch):
"""Record visual bells instead of drawing them"""
bells = []
monkeypatch.setattr(commands.render, "flash", lambda view: bells.append(view))
return bells
def at_file(files, path):
"""A loaded working-tree view sitting on `path`"""
view, window = sublime.View(), sublime.Window()
@@ -540,6 +645,9 @@ def test_next_unstaged_scans_forward_and_wraps():
assert commands._next_unstaged(files, 2) == 2 # the slot we count from counts itself
assert commands._next_unstaged(files, 3) == 0 # wraps past the end
assert commands._next_unstaged(flagged(True, True), 0) == -1
# a partially staged file still has hunks to stage
partial = git.FileChange("p.py", "M", True, 1, 1, None, unstaged=True)
assert commands._next_unstaged(flagged(True, True) + [partial], 0) == 2
def test_staging_moves_to_the_next_unstaged_file(monkeypatch):
@@ -578,6 +686,70 @@ def test_unstage_and_discard_do_not_move(monkeypatch):
assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
def test_stage_all_and_unstage_all_stay_on_the_file(monkeypatch):
messages = []
monkeypatch.setattr(commands, "status", messages.append)
assert press(monkeypatch, "stage_all", flagged(True, False, False), "f1.py", flagged(True, True, True)) == ["f1.py"]
assert messages[0] == "staged 2 files"
assert press(monkeypatch, "unstage_all", flagged(True, False, True), "f1.py", flagged(False, False, False)) == ["f1.py"]
assert messages[-1] == "unstaged 2 files"
def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch):
messages = []
monkeypatch.setattr(commands, "status", messages.append)
assert press(monkeypatch, "stage_all", flagged(True, True), "f0.py", []) == []
assert press(monkeypatch, "unstage_all", flagged(False, False), "f0.py", []) == []
assert messages == ["nothing to stage", "nothing to unstage"]
# a partially staged file is something to stage
partial = git.FileChange("p.py", "M", True, 1, 1, None, unstaged=True)
assert press(monkeypatch, "stage_all", flagged(True) + [partial], "f0.py", flagged(True, True)) == ["f0.py"]
assert messages[-1] == "staged 1 file"
view, window, st = at_file(flagged(False), "f0.py")
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", ""))
commands.ACTIONS["stage_all"](view, window, st)
assert messages[-1] == "stage all works on uncommitted changes only"
def watched(monkeypatch, tmp_path, all_mode):
"""A working-tree view of a.py and b.py on disk with its fingerprint taken; returns (st, refreshes)"""
for name in ("a.py", "b.py", "index"):
(tmp_path / name).write_text(name)
monkeypatch.setattr(commands.git, "index_path", lambda repo: str(tmp_path / "index"))
refreshes = []
monkeypatch.setattr(commands, "refresh_listing", lambda view, st: refreshes.append(st))
st = state.ViewState(str(tmp_path))
st.files = [change("a.py"), change("b.py")]
st.change, st.all_mode = st.files[0], all_mode
st.fingerprint = commands.render.fingerprint(st)
return st, refreshes
def touch(path):
"""Push the file's mtime forward, as an edit would"""
stat = path.stat()
os.utime(path, (stat.st_atime, stat.st_mtime + 5))
def test_refresh_if_changed_watches_every_file_of_the_all_files_view(monkeypatch, tmp_path):
st, refreshes = watched(monkeypatch, tmp_path, all_mode=True)
commands.refresh_if_changed(sublime.View(), st)
assert refreshes == []
touch(tmp_path / "b.py") # not the file under the caret
commands.refresh_if_changed(sublime.View(), st)
assert refreshes == [st]
def test_refresh_if_changed_watches_the_shown_file_and_the_index(monkeypatch, tmp_path):
st, refreshes = watched(monkeypatch, tmp_path, all_mode=False)
touch(tmp_path / "b.py") # another file: the single-file view does not show it
commands.refresh_if_changed(sublime.View(), st)
assert refreshes == []
touch(tmp_path / "index")
commands.refresh_if_changed(sublime.View(), st)
assert refreshes == [st]
def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
messages = []
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
@@ -602,13 +774,53 @@ def all_files_view():
return view, st
def test_all_files_headings_show_the_status_letter_before_the_counts():
from vdiff import parse
view = sublime.View()
st = state.ViewState("/repo")
st.all_mode = True
st.files = [git.FileChange("a - b.py", "M", True, 1, 0, None), change("m.py"), change("gone.py", "D")]
lines = [parse.Line("+", None, 1, "x")]
st.all_diffs = {c.path: (lines, parse.change_blocks(lines), False) for c in st.files}
commands.render.render_all(view, st)
headings = [row for row in view.text.split("\n") if row.startswith("▼")]
# staged wins over the git letter; a path containing " -" must not confuse the letter's position
assert headings == ["▼ a - b.py s -0 +1", "▼ m.py m -0 +1", "▼ gone.py d -0 +1"]
regions, scope, _, _ = view.regions[commands.render.KEY_FILE_STATUS]
assert scope == commands.render.palette.SCOPE_FILE_STATUS
assert [view.text[r.a:r.b] for r in regions] == ["s", "m", "d"]
def heading_row(st, path):
return next(i for i, (c, _, section) in enumerate(st.all_rows) if section and c.path == path)
def put_caret(view, row):
view.sel().clear()
view.sel().add(sublime.Region(commands.render.index_point(view, row)))
def test_the_header_follows_the_caret_from_file_to_file():
from vdiff import listeners
view, st = all_files_view()
st.files[1] = git.FileChange("b.py", "M", False, 0, 4, None)
commands.render.render_all(view, st)
st.rendered_token = st.token
commands.render.init_view(view)
state.put(view, st)
title = lambda: view.phantoms[commands.render.PHANTOM_KEY][0].content.split("</div>", 1)[1] # noqa: E731
listener = listeners.VdiffEventListener()
put_caret(view, heading_row(st, "b.py") + 1)
listener.on_selection_modified(view)
assert st.change.path == "b.py" and ">b.py<" in title() and '<span class="del">-4</span> <span class="ins">+0</span>' in title()
# within the same file nothing is redrawn; back in a.py the header is a.py's again
shown = view.phantoms[commands.render.PHANTOM_KEY]
listener.on_selection_modified(view)
assert view.phantoms[commands.render.PHANTOM_KEY] is shown
put_caret(view, heading_row(st, "a.py"))
listener.on_selection_modified(view)
assert ">a.py<" in title() and '<span class="del">-1</span> <span class="ins">+1</span>' in title()
# not while a render is pending: the rows describe the old text
st.token += 1
put_caret(view, heading_row(st, "b.py"))
listener.on_selection_modified(view)
assert st.change.path == "a.py"
def test_all_files_step_change_from_a_heading_lands_on_its_first_hunk():

View File

@@ -1,4 +1,5 @@
import os
import re
import subprocess
import pytest
@@ -43,12 +44,25 @@ def test_changed_files_all_kinds(repo):
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
write(repo, "c.txt", "c\n")
sh(repo, "add", "c.txt")
write(repo, "c.txt", "c\nd\n") # partially staged: staged A, unstaged M
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 set(changes) == {"a.py", "b.txt", "c.txt", "gone.py", "sub/new.py"}
assert changes["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True)
assert changes["b.txt"] == git.FileChange("b.txt", "M", True, 1, 0, None)
assert changes["c.txt"] == git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True)
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)
assert changes["sub/new.py"] == git.FileChange("sub/new.py", "?", False, 2, 0, None, unstaged=True)
def test_worktree_stats_counts_a_partially_staged_file_in_both_and_a_binary_in_neither_total():
changes = [git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True),
git.FileChange("b.txt", "M", True, 1, 0, None),
git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True),
git.FileChange("img.png", "M", True, None, None, None)]
assert git.worktree_stats(changes) == git.WorkTree(staged=3, unstaged=2, files=4, insertions=4, deletions=1)
assert git.worktree_stats([]) == git.WorkTree(0, 0, 0, 0, 0)
def test_full_diff_working_tree(repo):
@@ -84,6 +98,29 @@ def test_stage_unstage_discard(repo):
assert git.changed_files(repo) == []
def test_stage_all_unstage_all(repo):
write(repo, "a.py", "changed\n")
os.remove(os.path.join(repo, "gone.py"))
write(repo, "sub/n.py", "new\n")
git.stage_all(repo)
changes = {c.path: c for c in git.changed_files(repo)}
assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and all(c.staged for c in changes.values())
git.unstage_all(repo)
changes = {c.path: c for c in git.changed_files(repo)}
assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and not any(c.staged for c in changes.values())
assert changes["sub/n.py"].status == "?"
def test_index_path_finds_a_worktrees_index(repo, tmp_path_factory):
assert git.index_path(repo) == os.path.join(repo, ".git", "index")
# a worktree's .git is a file pointing into the main repository
tree = str(tmp_path_factory.mktemp("tree") / "wt")
sh(repo, "worktree", "add", "-q", tree)
index = git.index_path(tree)
assert os.path.isfile(os.path.join(tree, ".git")) and os.path.isfile(index)
assert os.path.realpath(index).startswith(os.path.realpath(os.path.join(repo, ".git", "worktrees")))
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")
@@ -154,6 +191,10 @@ def test_no_head_repo(tmp_path):
assert [l.kind for l in diff.lines] == ["+"]
git.unstage(repo, changes[0])
assert git.changed_files(repo)[0].status == "?"
git.stage_all(repo)
assert git.changed_files(repo)[0].staged
git.unstage_all(repo)
assert git.changed_files(repo)[0].status == "?"
def test_rename_in_commit(repo):
@@ -269,3 +310,250 @@ def test_head_message_without_a_commit_raises(tmp_path):
sh(fresh, "init", "-q", "-b", "main")
with pytest.raises(git.GitError):
git.head_message(fresh)
# --- branches ---------------------------------------------------------------
@pytest.fixture
def remote(repo, tmp_path_factory):
"""A bare `origin` holding main, with the repo's main tracking it"""
bare = str(tmp_path_factory.mktemp("origin"))
sh(bare, "init", "-q", "--bare", "-b", "main")
sh(repo, "remote", "add", "origin", bare)
sh(repo, "push", "-q", "-u", "origin", "main")
return bare
def clone(bare, tmp_path_factory):
"""A second working copy of `bare`, to push foreign commits from"""
other = str(tmp_path_factory.mktemp("other"))
sh(other, "clone", "-q", bare, ".")
sh(other, "config", "user.email", "o@example.com")
sh(other, "config", "user.name", "Other")
return other
def commit_file(repo, name, text, message):
write(repo, name, text)
sh(repo, "add", "-A")
sh(repo, "commit", "-q", "-m", message)
def by_name(repo):
return {r.name: r for r in git.branches(repo)}
def test_branches_lists_locals_remotes_and_tracking(repo, remote, tmp_path_factory):
sh(repo, "branch", "feature/x")
sh(repo, "push", "-q", "-u", "origin", "feature/x")
sh(repo, "branch", "local-only")
commit_file(repo, "ahead.txt", "a\n", "ahead")
other = clone(remote, tmp_path_factory)
commit_file(other, "behind.txt", "b\n", "behind")
sh(other, "push", "-q", "origin", "main")
sh(other, "push", "-q", "origin", "--delete", "feature/x")
sh(repo, "fetch", "-q", "--prune")
# the remote's symbolic HEAD is a row of for-each-ref too
sh(repo, "remote", "set-head", "origin", "main")
refs = by_name(repo)
assert set(refs) == {"main", "feature/x", "local-only", "origin/main"}
main = refs["main"]
assert main.current and main.remote is None and main.upstream == "origin/main"
assert (main.ahead, main.behind, main.gone) == (1, 1, False)
assert refs["feature/x"].gone and refs["feature/x"].upstream == "origin/feature/x"
assert refs["local-only"].upstream is None and not refs["local-only"].current
assert (main.upstream_remote, main.upstream_branch) == ("origin", "main")
assert refs["local-only"].upstream_remote is None and refs["local-only"].upstream_branch is None
origin_main = refs["origin/main"]
assert (origin_main.remote, origin_main.branch, origin_main.current) == ("origin", "main", False)
assert len(origin_main.sha) >= 7
# locals come before remotes, in git's own order
assert [r.name for r in git.branches(repo)] == ["feature/x", "local-only", "main", "origin/main"]
def test_remotes_lists_a_remote_without_refs(repo, remote):
sh(repo, "remote", "add", "github", "/nowhere")
assert git.remotes(repo) == ["github", "origin"]
def test_head_sha_on_a_branch_detached_and_without_commits(repo, tmp_path):
sha = git.head_sha(repo)
assert len(sha) >= 7 and by_name(repo)["main"].current
sh(repo, "checkout", "-q", "--detach")
assert git.head_sha(repo) == sha and not any(r.current for r in git.branches(repo))
empty = str(tmp_path / "empty")
os.makedirs(empty)
sh(empty, "init", "-q", "-b", "main")
assert git.head_sha(empty) == "" and git.branches(empty) == []
def test_ref_log_carries_tags_dates_and_stats(repo, remote):
commit_file(repo, "new.py", "one\ntwo\n", "added a file")
sh(repo, "tag", "v1.0.0")
sh(repo, "tag", "-a", "release", "-m", "annotated")
sh(repo, "commit", "-q", "--allow-empty", "-m", "empty")
empty, added, initial = git.ref_log(repo, "main", 10)
assert (empty.subject, empty.tags, empty.files) == ("empty", (), None)
assert added.tags == ("release", "v1.0.0") or added.tags == ("v1.0.0", "release")
assert (added.files, added.insertions, added.deletions) == (1, 2, 0)
assert re.fullmatch(r"\d\d\.\d\d\.\d{4}", added.date) and added.author == "Test"
assert initial.tags == () and initial.files == 3
# a remote ref logs too, and the count caps the list
assert [c.subject for c in git.ref_log(repo, "origin/main", 1)] == ["initial"]
# the recent-commits panel keeps its relative dates and carries the same tags
recent = git.recent_commits(repo, 2)
assert recent[0].tags == () and recent[1].tags == added.tags and recent[0].date.endswith("ago")
def test_checkout_tracks_a_remote_branch_once_and_reuses_the_local(repo, remote, tmp_path_factory):
other = clone(remote, tmp_path_factory)
sh(other, "checkout", "-q", "-b", "topic")
commit_file(other, "t.txt", "t\n", "topic work")
sh(other, "push", "-q", "-u", "origin", "topic")
sh(repo, "fetch", "-q")
refs = by_name(repo)
assert "topic" not in refs
git.checkout(repo, refs["origin/topic"], local_names=[])
refs = by_name(repo)
assert refs["topic"].current and refs["topic"].upstream == "origin/topic"
assert os.path.exists(os.path.join(repo, "t.txt"))
# back on main, the remote row now checks out the existing local branch rather than failing
git.checkout(repo, refs["main"])
assert by_name(repo)["main"].current
git.checkout(repo, refs["origin/topic"], local_names=["topic"])
assert by_name(repo)["topic"].current
def test_checkout_detached(repo, remote):
git.checkout_detached(repo, "origin/main")
refs = by_name(repo)
assert not any(r.current for r in refs.values()) and git.head_sha(repo) == refs["origin/main"].sha
def test_delete_branch_refuses_unmerged_then_forces(repo):
sh(repo, "checkout", "-q", "-b", "side")
commit_file(repo, "s.txt", "s\n", "side work")
sh(repo, "checkout", "-q", "main")
with pytest.raises(git.GitError) as caught:
git.delete_branch(repo, "side")
assert "not fully merged" in caught.value.stderr
assert "side" in by_name(repo)
git.delete_branch(repo, "side", force=True)
assert "side" not in by_name(repo)
# a merged branch goes without force
sh(repo, "branch", "merged")
git.delete_branch(repo, "merged")
assert "merged" not in by_name(repo)
def test_fetch_into_fast_forwards_and_refuses_non_ff(repo, remote, tmp_path_factory):
sh(repo, "checkout", "-q", "-b", "work")
other = clone(remote, tmp_path_factory)
commit_file(other, "n.txt", "n\n", "upstream work")
sh(other, "push", "-q", "origin", "main")
git.fetch_into(repo, "origin", "main", "main")
refs = by_name(repo)
assert refs["main"].sha == refs["origin/main"].sha and refs["main"].ahead == 0
# diverge main, then the fast-forward is refused
sh(repo, "checkout", "-q", "main")
commit_file(repo, "m.txt", "m\n", "local work")
sh(repo, "checkout", "-q", "work")
commit_file(other, "o.txt", "o\n", "more upstream work")
sh(other, "push", "-q", "origin", "main")
with pytest.raises(git.GitError):
git.fetch_into(repo, "origin", "main", "main")
# nor does git update the checked-out branch this way
with pytest.raises(git.GitError):
git.fetch_into(repo, "origin", "main", "work")
def test_pull_fast_forwards_the_current_branch(repo, remote, tmp_path_factory):
other = clone(remote, tmp_path_factory)
commit_file(other, "n.txt", "n\n", "upstream work")
sh(other, "push", "-q", "origin", "main")
git.pull(repo)
assert os.path.exists(os.path.join(repo, "n.txt"))
assert by_name(repo)["main"].behind == 0
def test_push_sets_the_upstream_once(repo, remote):
sh(repo, "checkout", "-q", "-b", "topic")
commit_file(repo, "t.txt", "t\n", "topic work")
assert by_name(repo)["topic"].upstream is None
git.push(repo, "origin", "topic", set_upstream=True)
topic = by_name(repo)["topic"]
assert topic.upstream == "origin/topic" and (topic.ahead, topic.behind) == (0, 0)
commit_file(repo, "t.txt", "tt\n", "more topic work")
assert by_name(repo)["topic"].ahead == 1
git.push(repo, "origin", "topic")
assert by_name(repo)["topic"].ahead == 0
def test_push_goes_to_a_differently_named_upstream(repo, remote):
sh(repo, "checkout", "-q", "-b", "foo")
commit_file(repo, "f.txt", "f\n", "foo work")
sh(repo, "push", "-q", "-u", "origin", "foo:bar")
foo = by_name(repo)["foo"]
assert (foo.upstream, foo.upstream_remote, foo.upstream_branch) == ("origin/bar", "origin", "bar")
commit_file(repo, "f.txt", "ff\n", "more foo work")
git.push(repo, "origin", "foo", remote_branch="bar")
refs = by_name(repo)
assert refs["foo"].ahead == 0 and "origin/foo" not in refs
# an upstream that is a local branch has no remote
sh(repo, "branch", "--set-upstream-to=main", "foo")
foo = by_name(repo)["foo"]
assert (foo.upstream, foo.upstream_remote, foo.upstream_branch) == ("main", None, None)
def test_fetch_all_prunes_a_deleted_remote_branch(repo, remote, tmp_path_factory):
other = clone(remote, tmp_path_factory)
sh(other, "checkout", "-q", "-b", "gone")
commit_file(other, "g.txt", "g\n", "to be deleted")
sh(other, "push", "-q", "origin", "gone")
git.fetch_all(repo)
assert "origin/gone" in by_name(repo)
sh(other, "push", "-q", "origin", "--delete", "gone")
git.fetch_all(repo)
assert "origin/gone" not in by_name(repo)
def test_run_passes_env_and_closes_stdin(repo):
assert git.run(repo, ["var", "GIT_EDITOR"], env={"GIT_EDITOR": "xyz-editor"}).strip() == "xyz-editor"
# the inherited environment stays: git still finds its config and the user's PATH
assert git.run(repo, ["config", "user.name"]).strip() == "Test"
def test_ref_paths_point_into_the_git_dir(repo, tmp_path_factory):
paths = git.ref_paths(repo)
assert [os.path.relpath(p, repo) for p in paths] == [".git/" + name for name in git.REF_PATH_NAMES]
assert os.path.exists(paths[0]) and os.path.isdir(paths[4])
assert git.ref_paths(str(tmp_path_factory.mktemp("not-a-repo"))) == ()
# the directories a slashed branch and a remote put their refs in: those are the ones their updates touch
sh(repo, "branch", "feature/x")
sh(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
nested = [os.path.relpath(p, repo) for p in git.ref_paths(repo)[len(git.REF_PATH_NAMES):]]
assert nested == [".git/refs/heads/feature", ".git/refs/remotes/origin"]
def test_ref_paths_walk_again_only_when_a_ref_directory_moved(repo, monkeypatch):
sh(repo, "branch", "feature/x")
git.ref_paths(repo)
walks = []
real_walk = os.walk
monkeypatch.setattr(git.os, "walk", lambda root, *args: walks.append(root) or real_walk(root, *args))
git.ref_paths(repo)
assert walks == [] # nothing moved: the directories found last time stand
# a branch two levels down adds a directory inside a nested one, whose mtime moves
sh(repo, "branch", "feature/deep/y")
nested = [os.path.relpath(p, repo) for p in git.ref_paths(repo)[len(git.REF_PATH_NAMES):]]
assert walks and nested == [".git/refs/heads/feature", ".git/refs/heads/feature/deep"]
def test_run_keeps_gits_messages_in_english(repo):
# a localized git would translate the refusal delete_branch tells apart by its text
assert "LC_ALL=C" in git.run(repo, ["-c", "alias.showenv=!env", "showenv"]).split("\n")

View File

@@ -84,3 +84,145 @@ def test_a_hand_edited_override_is_never_clobbered(tmp_path):
assert "// mine" in path.read_text()
def colour_option(monkeypatch, name, value):
"""Set one of the colour settings for this test"""
from vdiff import utils
monkeypatch.setitem(sublime.load_settings(utils.SETTINGS_FILE), name, value)
def test_user_line_colours_get_rules_and_tint_the_token_layer(tmp_path, monkeypatch):
colour_option(monkeypatch, "deleted_line_background", "#ff000040")
colour_option(monkeypatch, "line_numbers_foreground", "#888888")
view = sublime.View()
palette.ensure(view)
rules = by_scope(tmp_path)
assert rules[palette.SCOPE_LINE_DEL] == {"scope": palette.SCOPE_LINE_DEL, "background": "#ff000040"}
assert palette.SCOPE_LINE_INS not in rules
# the token layer repaints deleted text, so it carries the same background
colour = view.styles["comment"]["foreground"]
assert rules[palette.scope_for("del", colour)]["background"] == "#ff000040"
# a foreground alone still gets a background, or sublime would fill the region with the foreground
numbers = rules[palette.SCOPE_LINE_NUMBERS]
assert numbers["foreground"] == "#888888" and numbers["background"]
def test_changing_a_colour_setting_rewrites_the_override(tmp_path, monkeypatch):
view = sublime.View()
palette.ensure(view)
assert palette.SCOPE_LINE_INS not in by_scope(tmp_path)
colour_option(monkeypatch, "inserted_line_background", "#00ff0040")
palette.ensure(view)
assert by_scope(tmp_path)[palette.SCOPE_LINE_INS]["background"] == "#00ff0040"
def test_render_uses_the_user_scopes_only_once_set_and_live(monkeypatch):
view = sublime.View()
# unset: the scheme's diff tint and the plain-text line numbers, as before
assert palette.line_scope(view, "del") == palette.SCOPE_DEL
assert palette.line_numbers_scope(view) == "text.plain"
colour_option(monkeypatch, "deleted_line_background", "#ff000040")
colour_option(monkeypatch, "line_numbers_background", "#222222")
# set, but the override is not loaded yet: an unknown scope would paint nothing
assert palette.line_scope(view, "del") == palette.SCOPE_DEL
view.override_loaded = True
assert palette.line_scope(view, "del") == palette.SCOPE_LINE_DEL
assert palette.line_scope(view, "ins") == palette.SCOPE_INS
assert palette.line_numbers_scope(view) == palette.SCOPE_LINE_NUMBERS
def test_file_header_colours_come_from_settings(tmp_path, monkeypatch):
palette.ensure(sublime.View())
rules = by_scope(tmp_path)
# unset: the built-in defaults
assert rules[palette.SCOPE_FILE_HEADER]["background"] == palette.FILE_HEADER_BACKGROUND
colour_option(monkeypatch, "file_header_background", "#202040")
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
palette.ensure(sublime.View())
rules = by_scope(tmp_path)
assert rules[palette.SCOPE_FILE_HEADER]["background"] == "#202040"
# the counts sit on the heading, so they share its background
assert rules[palette.SCOPE_FILE_INS_COUNT]["background"] == "#202040"
assert rules[palette.SCOPE_FILE_INS_COUNT]["foreground"] == "#00ff00"
assert rules[palette.SCOPE_FILE_DEL_COUNT]["foreground"] == palette.FILE_DEL_FOREGROUND
def test_the_status_letter_is_white_on_the_heading_unless_set(tmp_path, monkeypatch):
palette.ensure(sublime.View())
rule = by_scope(tmp_path)[palette.SCOPE_FILE_STATUS]
assert rule["foreground"] == palette.FILE_STATUS_FOREGROUND == "#ffffff"
assert rule["background"] == by_scope(tmp_path)[palette.SCOPE_FILE_HEADER]["background"]
colour_option(monkeypatch, "file_header_status_foreground", "#ffff00")
palette.ensure(sublime.View())
assert by_scope(tmp_path)[palette.SCOPE_FILE_STATUS]["foreground"] == "#ffff00"
def test_red_and_green_text_rules_share_the_heading_colours_on_the_editor_background(tmp_path, monkeypatch):
view = sublime.View()
palette.ensure(view)
rules = by_scope(tmp_path)
plain = palette._nudge(palette._global_background(view))
assert rules[palette.SCOPE_TEXT_DEL] == {"scope": palette.SCOPE_TEXT_DEL, "background": plain,
"foreground": palette.FILE_DEL_FOREGROUND, "font_style": "bold"}
assert rules[palette.SCOPE_TEXT_INS]["foreground"] == palette.FILE_INS_FOREGROUND
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
palette.ensure(sublime.View())
assert by_scope(tmp_path)[palette.SCOPE_TEXT_INS]["foreground"] == "#00ff00"
# until the override is live the scheme's diff tints stand in
assert palette.text_scope(view, "del") == palette.SCOPE_DEL
assert palette.text_scope(view, "ins") == palette.SCOPE_INS
assert palette.count_scope(view, "del") == palette.SCOPE_DEL
view.override_loaded = True
assert palette.text_scope(view, "del") == palette.SCOPE_TEXT_DEL
assert palette.text_scope(view, "ins") == palette.SCOPE_TEXT_INS
assert palette.count_scope(view, "del") == palette.SCOPE_FILE_DEL_COUNT
assert palette.count_scope(view, "ins") == palette.SCOPE_FILE_INS_COUNT
def test_the_cursor_count_rules_share_the_cursor_background_and_the_heading_colours(tmp_path, monkeypatch):
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
view = sublime.View()
palette.ensure(view)
rules = by_scope(tmp_path)
assert rules[palette.SCOPE_CURSOR_INS] == {"scope": palette.SCOPE_CURSOR_INS, "background": rules[palette.SCOPE_CURSOR]["background"],
"foreground": "#00ff00", "font_style": "bold"}
assert rules[palette.SCOPE_CURSOR_DEL]["foreground"] == palette.FILE_DEL_FOREGROUND
# the text scope stands in until the rule is live
assert palette.cursor_text_scope(view, "ins") == palette.SCOPE_INS
view.override_loaded = True
assert palette.cursor_text_scope(view, "ins") == palette.SCOPE_CURSOR_INS
def test_the_restart_notice_is_claimed_per_view_kind(tmp_path):
view = sublime.View()
palette.ensure(view)
# nothing live yet: each kind of view asks once
assert palette.claim_restart_notice(view, palette.DIFF_SCOPES)
assert not palette.claim_restart_notice(view, palette.DIFF_SCOPES)
assert palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
assert not palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
# an upgrade that adds only the branches rules: a diff view, whose rules are live, says nothing
palette.reset()
palette.ensure(view)
view.override_loaded = True
for scope in palette.BRANCH_SCOPES[3:]:
view.styles[scope] = {"foreground": sublime.DEFAULT_FG} # known to the scheme, but without a background: not live
assert not palette.claim_restart_notice(view, palette.DIFF_SCOPES)
assert palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
def test_the_commit_cursor_rule_uses_the_line_highlight_colour(tmp_path, monkeypatch):
view = sublime.View()
palette.ensure(view)
rule = by_scope(tmp_path)[palette.SCOPE_CURSOR]
assert rule["background"] == "#3b4350" and rule["foreground"] == sublime.DEFAULT_FG
assert palette.cursor_scope(view) is None
view.override_loaded = True
assert palette.cursor_scope(view) == palette.SCOPE_CURSOR
# a scheme without a line highlight: the background nudged towards the foreground
assert palette._blend("#000000", "#ffffff", 0.12) == "#1f1f1f"
assert palette._blend("#303841", "#d8dee9", 0.12) == "#444c55"
monkeypatch.setattr(sublime.View, "style", lambda self: {})
assert palette._line_highlight(view) == palette._blend("#000000", sublime.DEFAULT_FG, palette.CURSOR_BLEND)

View File

@@ -154,14 +154,14 @@ def test_help_popup_lists_every_key():
assert view.is_popup_visible()
for group in ("Navigate", "Actions", "View"):
assert f">{group}<" in html
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "z", "esc"):
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s / S", "u / U", "d", "C", "m", "r", "z", "esc"):
assert f">{keys}<" in html
assert "next / previous change" in html and "open the real file at this line" in html
# working-tree mode: nothing dimmed, every single-action row is a link
assert 'class="dim"' not in html and "uncommitted only" not in html
assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html
assert 'href="discard"' in html and 'href="files"' in html and 'href="close"' in html
# rows covering two actions stay plain text
assert 'href="next_change"' not in html
assert 'href="next_change"' not in html and 'href="stage"' not in html
# the popup does not list the key that opens it
assert ">?<" not in html
@@ -173,6 +173,8 @@ def test_help_popup_dims_file_actions_in_commit_mode():
assert "uncommitted only" in html
assert html.count('class="dim"') == 5
# dimmed rows keep their keys orange
assert 'class="kdim"' not in html and '<span class="k">C</span>' in html
assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html
assert 'href="commit"' not in html and 'href="amend"' not in html
# everything that still applies stays clickable
@@ -194,7 +196,7 @@ def test_help_popup_toggles_and_navigates():
# keymap spellings that the popup shows as a symbol
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc"}
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc", "shift+enter": "shift+⏎"}
def keymap_bindings():
@@ -203,19 +205,37 @@ def keymap_bindings():
return json.load(f)
def binding_mode(binding):
"""The vdiff_mode a binding is limited to, None when it applies in every mode"""
modes = [c["operand"] for c in binding["context"] if c["key"] == "vdiff_mode"]
assert len(modes) <= 1, binding["keys"]
# a setting context does not compare strings; the plugin answers vdiff_mode itself
assert not any(c["key"] == "setting.vdiff_mode" for c in binding["context"]), binding["keys"]
return modes[0] if modes else None
def test_keymap_matches_help_table():
"""The keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
"""Per mode, the keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
bindings = keymap_bindings()
assert all(b["command"] == "vdiff_action" for b in bindings)
bound = {b["args"]["action"] for b in bindings}
listed = {action for k in render.KEYS for action in k.actions}
# "?" opens the popup, so the popup leaves it out
assert bound == set(commands.ACTIONS) == listed | {"help"}
# rebinding a key without updating the table would leave the popup and status bar lying
bound_keys = {DISPLAY_KEYS.get(key, key) for b in bindings for key in b["keys"]}
assert bound_keys == {token for k in render.KEYS for token in k.keys.split()} | {"?"}
assert all({c["key"] for c in b["context"]} >= {"setting.vdiff_view"} for b in bindings)
assert {binding_mode(b) for b in bindings} == {None, "diff", "branches"}
bound_all = set()
for mode, table in render.MODE_KEY_GROUPS.items():
applicable = [b for b in bindings if binding_mode(b) in (None, mode)]
# one meaning per key within a mode
keys = [key for b in applicable for key in b["keys"]]
assert len(keys) == len(set(keys)), mode
bound = {b["args"]["action"] for b in applicable}
listed = {action for k in render.keys(mode) for action in k.actions}
# "?" opens the popup, so the popup leaves it out
assert bound == listed | {"help"}, mode
# rebinding a key without updating the table would leave the popup and status bar lying
bound_keys = {DISPLAY_KEYS.get(key, key) for key in keys}
assert bound_keys == {token for k in render.keys(mode) for token in k.keys.split() if token != "/"} | {"?"}, mode
bound_all |= bound
assert bound_all == set(commands.ACTIONS)
def test_no_binding_yields_to_vintage_command_mode():
@@ -256,13 +276,14 @@ def test_summary_counts_staged_and_unstaged_files():
git.FileChange("a.py", "M", True, 1, 1, None),
git.FileChange("b.py", "M", False, 1, 1, None),
git.FileChange("c.py", "?", False, 1, 0, None),
git.FileChange("d.py", "M", True, 2, 0, None, unstaged=True), # partially staged: on both sides, as in the branches view
]
render.render(view, st)
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
# the untracked file's whole content counts as insertions, the way git.changed_files records it
assert summary.startswith("1 staged, 2 unstaged · ")
assert '<span class="del">-2</span>' in summary and '<span class="ins">+3</span>' in summary
assert summary.startswith("2 staged, 3 unstaged · ")
assert '<span class="del">-2</span>' in summary and '<span class="ins">+5</span>' in summary
def test_summary_totals_skip_binary_files_but_still_count_them():
@@ -303,6 +324,23 @@ def test_summary_totals_a_commit_and_owns_the_date():
assert 'class="commit"' not in html
def test_the_all_files_title_shows_the_current_files_counts_not_the_totals():
st = state.ViewState("/repo", all_mode=True)
st.files = [git.FileChange("x.py", "M", False, 3, 2, None), git.FileChange("y.py", "M", True, 7, 0, None),
git.FileChange("logo.png", "M", False, None, None, None)]
st.change = st.files[1]
# every file's totals belong on the summary line only
html = render._all_header_html(st)
title = html.split("</div>", 1)[1]
assert '<span class="del">-0</span> <span class="ins">+7</span>' in title and "+10" not in title
assert '<span class="del">-2</span> <span class="ins">+10</span>' in html.split("</div>", 1)[0]
st.change = st.files[2]
assert '<span class="state">binary</span>' in render._all_header_html(st).split("</div>", 1)[1]
# file by file, the counts handed in are the shown file's own
st.all_mode, st.change = False, st.files[0]
assert '<span class="del">-4</span> <span class="ins">+5</span>' in render.header_html(st, 5, 4)
def test_summary_is_omitted_without_a_file_list():
view = sublime.View()
render.init_view(view)
@@ -430,3 +468,34 @@ def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
# said once per scheme, not once per render
render.render(view, make_state())
assert said == ["vDiff: " + render.NEEDS_RESTART]
# --- the branches view's keys ----------------------------------------------
def test_help_popup_follows_the_mode():
st = make_state()
st.mode = state.MODE_BRANCHES
_, html = help_popup(st)
for keys in ("J K", "j k", "o v", "⏎", "shift+⏎", "L", "P", "F", "d", "f", "c", "b", "r", "esc"):
assert f">{keys}<" in html, keys
assert "next / previous branch" in html and "fetch all remotes" in html
assert ">s / S<" not in html and ">n p<" not in html and ">v<" not in html
# a commit shown before switching dims nothing here: the branch keys do not depend on it
st.sha = "abc"
_, html = help_popup(st)
assert 'class="dim"' not in html and 'href="checkout"' in html and 'href="pull"' in html
# the diff popup lists the way in
st.mode = state.MODE_DIFF
_, html = help_popup(st)
assert ">b<" in html and 'href="branches"' in html and ">J K<" not in html
def test_status_hints_follow_the_mode():
view = sublime.View()
render.init_view(view)
assert view.status[render.STATUS_KEY] == render.key_hints() and "b branches" in render.key_hints()
hints = render.key_hints(state.MODE_BRANCHES)
assert "J K branch" in hints and "L pull" in hints and "s / S" not in hints
render.init_view(view, state.MODE_BRANCHES)
assert view.status[render.STATUS_KEY] == hints and view.settings().get(state.MODE_SETTING) == "branches"

View File

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

View File

@@ -1,4 +1,7 @@
{
// how a review view first shows its files: "file_by_file" or "all_in_one"; v switches between them
"default_view_mode": "file_by_file",
// number of commits listed by "vDiff: Recent Commits"
"commits_count": 30,
@@ -14,6 +17,20 @@
// number of unchanged lines shown around each change in the all-files view
"all_files_context": 5,
// background of deleted and inserted lines, e.g. "#d6464633"; empty uses the colour scheme's diff colours
"deleted_line_background": "",
"inserted_line_background": "",
// line-number column of the all-files view, e.g. "#888888" on "#1e1e1e"; empty keeps the plain-text look
"line_numbers_foreground": "",
"line_numbers_background": "",
// file heading of the all-files view: its background and the colours of its status letter and -/+ counts
"file_header_background": "#0000EE",
"file_header_status_foreground": "#ffffff",
"file_header_deleted_foreground": "#d64646",
"file_header_inserted_foreground": "#46d646",
// git executable; sublime's PATH may not include a user-installed git
"git_binary": "git",

710
vdiff/branches.py Normal file
View File

@@ -0,0 +1,710 @@
"""The branches view: local branches and remotes in a left column, the selected branch's log on the right
Drawn into the same review view as the diffs, as plain text rows of two padded columns. The selected
branch is a tinted cell moved by J / K; the caret, and with it the tinted commit, is the commit cursor
moved by j / k
"""
import html
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypeVar
import sublime
from . import git, palette, render
from .git import Commit, FileChange, GitError, Ref
from .state import MODE_BRANCHES, BranchRow, ViewState
from .utils import DEBOUNCE_MS, commits_count, file_count, run_async, status
# an ACTIONS entry: act(view, window, state) on the review view it fired in
Action = Callable[[sublime.View, sublime.Window, ViewState], None]
# the left column: its padding after the longest cell, and the width it never exceeds
LEFT_PAD = 2
LEFT_MAX = 32
# the right column: "Author: " and friends, and the bounds of its width when the viewport is odd
LABEL_WIDTH = 13
RIGHT_MIN = 60
RIGHT_MAX = 200
# a commit is its subject, author, date and stats rows, then a rule
ROWS_PER_COMMIT = 5
COLUMN_GAP = "| "
RULE = "-"
HEADING_BRANCHES = "BRANCHES"
HEADING_REMOTES = "REMOTES"
NOT_HERE = "works in the branches view, press b"
# the result of a load's work, handed to its adopt step
T = TypeVar("T")
# a coloured part of a cell: (start, end, "del" | "ins")
Span = Tuple[int, int, str]
# a right-column cell: its text, the index of its item (0 the working tree, i + 1 commit i, -1 none) and its spans
Cell = Tuple[str, int, List[Span]]
# the right column's first item: the working tree
WORKTREE = 0
NO_CHANGES = "No Changes"
# a left-column cell: its text, its branch (None for headings and remote names) and the branch's
# standing against its upstream ("↑1 ↓2", "gone" or ""), shown right-aligned in the cell
LeftCell = Tuple[str, Optional[Ref], str]
# --- loading ----------------------------------------------------------------
def _pick(refs: List[Ref], wanted: Optional[str]) -> Optional[Ref]:
"""The branch to select: `wanted` while it exists, else the checked-out one, else the first local, else the first"""
for ref in refs:
if ref.name == wanted:
return ref
return next((r for r in refs if r.current), next((r for r in refs if r.remote is None), refs[0] if refs else None))
def load(view: sublime.View, st: ViewState, message: Optional[str] = None, keep_caret: bool = False) -> None:
"""List the branches and log the selected one off the UI thread, then draw the view
`message` goes to the status bar first; `keep_caret` keeps the commit cursor where it is. The diff
state is vacated: what was shown before is not what persist records or the refresh checks any more
"""
if message:
status(message)
st.mode = MODE_BRANCHES
st.sha = st.commit = st.change = st.requested = None
st.lines, st.blocks, st.binary = [], [], False
repo, wanted, count = st.repo, st.branch_ref, commits_count()
def work() -> Tuple[str, List[Ref], List[str], Optional[Ref], List[Commit], List[FileChange]]:
"""HEAD's hash, the branches, the remotes, the branch to select and its log, and the working tree's changes
The calls that do not wait on the branch list run alongside it; only the log needs to know the branch
"""
with ThreadPoolExecutor(max_workers=4) as pool:
# git status first, the slowest; the caches are warmed too, so the fingerprint after the render costs
# no git call on the UI thread (leaving the block waits for them)
changes, sha, remotes = pool.submit(git.changed_files, repo), pool.submit(git.head_sha, repo), pool.submit(git.remotes, repo)
pool.submit(git.ref_paths, repo)
git.index_path(repo)
refs = git.branches(repo)
ref = _pick(refs, wanted)
commits = git.ref_log(repo, ref.name, count) if ref else []
return sha.result(), refs, remotes.result(), ref, commits, changes.result()
def adopt(result: Tuple[str, List[Ref], List[str], Optional[Ref], List[Commit], List[FileChange]]) -> None:
"""Take the listing over"""
st.branch_head_sha, st.branch_refs, st.branch_remotes, ref, st.branch_commits, st.branch_changes = result
st.branch_ref = ref.name if ref else None
_reload(view, st, work, adopt, keep_caret)
def _reload(view: sublime.View, st: ViewState, work: Callable[[], T], adopt: Callable[[T], None], keep_caret: bool) -> None:
"""Run `work` off the UI thread, then `adopt` its result and redraw, unless a later load superseded it or the view closed"""
st.token += 1
token = st.token
def done(result: T) -> None:
"""Adopt and draw the result of the newest load only"""
if token != st.token or not view.is_valid():
return
adopt(result)
render_branches(view, st, keep_caret=keep_caret)
st.rendered_token = token
run_async(work, done)
def _load_log(view: sublime.View, st: ViewState) -> None:
"""Log the selected branch off the UI thread and redraw with the cursor back on the working-tree entry"""
repo, name, count = st.repo, st.branch_ref, commits_count()
def adopt(commits: List[Commit]) -> None:
"""Take the log over"""
st.branch_commits = commits
_reload(view, st, lambda: git.ref_log(repo, name, count) if name is not None else [], adopt, keep_caret=False)
def load_worktree(view: sublime.View, st: ViewState) -> None:
"""Relist the working tree's changes off the UI thread and redraw its entry; the branches and the log stand"""
repo = st.repo
def adopt(changes: List[FileChange]) -> None:
"""Take the working tree's changes over"""
st.branch_changes = changes
_reload(view, st, lambda: git.changed_files(repo), adopt, keep_caret=True)
def _step(view: sublime.View, position: int, count: int, delta: int) -> Optional[int]:
"""Where a step of `delta` from `position` among `count` items lands: off them (-1) the first for a step forward
and the last for one back; None, after a flash, past either end"""
if position < 0:
return 0 if delta > 0 else count - 1
index = position + delta
if not 0 <= index < count:
render.flash(view)
return None
return index
def select(view: sublime.View, st: ViewState, delta: int) -> None:
"""Move the branch selection `delta` branches on, ringing at the ends, and log the new one
The cell and the header move at once; the log is fetched once the key rests, so holding J does
not start one git process per branch passed
"""
# git lists the local branches before the remote ones, the order J / K step in
names = [r.name for r in st.branch_refs]
if not names:
status("no branches")
return
position = names.index(st.branch_ref) if st.branch_ref in names else -1
index = _step(view, position, len(names), delta)
if index is None or index == position:
return
st.branch_ref = names[index]
_paint_selection(view, st)
render._header(view, st, header_html(st))
st.highlight += 1
ticket = st.highlight
def fetch() -> None:
"""Load the log unless another selection came since"""
if ticket == st.highlight and view.is_valid():
_load_log(view, st)
sublime.set_timeout(fetch, DEBOUNCE_MS)
# --- drawing ----------------------------------------------------------------
def _standing_parts(ref: Ref) -> List[Tuple[str, str]]:
"""How a local branch stands against its upstream, as (text, "ins" | "del") parts: ahead green, behind or gone red;
zero counts are left out, and a level or untracked branch has none"""
if ref.gone:
return [("gone", "del")]
parts = [(f"\u2191{ref.ahead}", "ins")] if ref.ahead else []
if ref.behind:
parts.append((f"\u2193{ref.behind}", "del"))
return parts
def _standing(ref: Ref) -> str:
"""The standing as the left column shows it: "↑1 ↓2", "gone", or """""
return " ".join(text for text, _ in _standing_parts(ref))
def _standing_html(ref: Ref) -> str:
"""The standing for the header, or "up to date" when there is nothing to say"""
parts = _standing_parts(ref)
if not parts:
return '<span class="state">up to date</span>'
return " ".join(f'<span class="{kind}">{text}</span>' for text, kind in parts)
def _left_cells(st: ViewState) -> List[LeftCell]:
"""(text, branch, standing) per row of the left column; headings and remote names carry no branch"""
cells: List[LeftCell] = [(HEADING_BRANCHES, None, "")]
for ref in st.branch_refs:
if ref.remote is None:
cells.append((("* " if ref.current else " ") + ref.name, ref, _standing(ref)))
cells.append((HEADING_REMOTES, None, ""))
# a remote keeps its row even before anything was fetched from it
remotes = list(dict.fromkeys(list(st.branch_remotes) + [r.remote for r in st.branch_refs if r.remote]))
for remote in remotes:
cells.append((remote, None, ""))
cells.extend((" " + ref.branch, ref, "") for ref in st.branch_refs if ref.remote == remote)
return cells
def _left_width(cells: List[LeftCell]) -> int:
"""Width of the left column for its cells: the longest (with its standing and a space) plus padding, capped"""
return min(LEFT_MAX, max(len(text) + (len(standing) + 1 if standing else 0) for text, _, standing in cells) + LEFT_PAD)
def _clip(text: str, width: int) -> str:
"""`text` cut to `width` characters with an ellipsis"""
return text if len(text) <= width else text[: width - 1] + "\u2026"
def _standing_spans(ref: Ref, width: int) -> List[Span]:
"""The spans colouring a local branch's standing on the right edge of its cell `width` wide"""
spans: List[Span] = []
at = _standing_room(width, _standing(ref))
for text, kind in _standing_parts(ref):
spans.append((at, at + len(text), kind))
at += len(text) + 1
return spans
def _standing_room(width: int, standing: str) -> int:
"""Where a standing starts in a cell `width` wide: on the right edge, before the trailing space"""
return width - 1 - len(standing)
def _left_text(cell: LeftCell, width: int) -> str:
"""The cell padded to `width` with its standing on the right edge"""
text, _, standing = cell
if not standing:
return f"{_clip(text, width - 1):<{width}}"
# the name gives way first: the counts are what the column is for
room = _standing_room(width, standing)
return f"{_clip(text, room - 1):<{room}}{standing} "
def _right_width(view: sublime.View, left_width: int) -> int:
"""Columns left for the right column after the left one and the gap, within sane bounds"""
try:
columns = int(view.viewport_extent()[0] / (view.em_width() or 8))
except Exception:
columns = 0
return max(RIGHT_MIN, min(RIGHT_MAX, columns - left_width - len(COLUMN_GAP)))
def _stats_cell(files: Optional[int], insertions: Optional[int], deletions: Optional[int]) -> Tuple[str, List[Span]]:
"""A Stats row ("Stats: 3 files changed -5 +12") with the spans colouring its counts; no diffstat when `files` is None"""
stats = f"{'Stats:':<{LABEL_WIDTH}}"
spans: List[Span] = []
if files is None:
return stats + "(no diffstat)", spans
stats += f"{file_count(files)} changed "
dele, ins = f"-{deletions}", f"+{insertions}"
spans.append((len(stats), len(stats) + len(dele), "del"))
stats += dele + " "
spans.append((len(stats), len(stats) + len(ins), "ins"))
return stats + ins, spans
def _worktree_cells(changes: List[FileChange], width: int) -> List[Cell]:
"""The working-tree entry: the staged / unstaged file counts and their diffstat, or "No Changes", padded to five rows"""
if not changes:
head, stats, spans = NO_CHANGES, "", []
else:
stat = git.worktree_stats(changes)
head = f"{file_count(stat.staged, 'staged')}, {file_count(stat.unstaged, 'unstaged')}"
stats, spans = _stats_cell(stat.files, stat.insertions, stat.deletions)
return [(head, WORKTREE, []), (stats, WORKTREE, spans), ("", WORKTREE, []), ("", WORKTREE, []), (RULE * width, WORKTREE, [])]
def _right_cells(changes: List[FileChange], commits: List[Commit], width: int) -> List[Cell]:
"""The right column: the working-tree entry, then five rows per commit, with the spans of the tags and counts to colour"""
cells = _worktree_cells(changes, width)
for i, c in enumerate(commits, 1):
author = f"{'Author:':<{LABEL_WIDTH}}{c.author}"
author_spans: List[Span] = []
if c.tags:
# the tags on the right edge, one space at least from the author
tags = " ".join(c.tags)
author = f"{author:<{max(len(author) + 1, width - len(tags))}}{tags}"
author_spans.append((len(author) - len(tags), len(author), "ins"))
stats, stats_spans = _stats_cell(c.files, c.insertions, c.deletions)
cells.append((c.subject or "(no subject)", i, []))
cells.append((author, i, author_spans))
cells.append((f"{'Date:':<{LABEL_WIDTH}}{c.date}", i, []))
cells.append((stats, i, stats_spans))
cells.append((RULE * width, i, []))
if not commits:
cells.append(("(no commits)", -1, []))
return cells
def _item_count(st: ViewState) -> int:
"""How many items the right column holds: the working tree and the commits"""
return len(st.branch_commits) + 1
def _caret_commit(view: sublime.View, st: ViewState) -> int:
"""Index of the item whose rows hold the caret (WORKTREE or a commit's), -1 off them"""
row = render.caret_index(view)
if 0 <= row < len(st.branch_rows):
return st.branch_rows[row].commit
return -1
class Paint(NamedTuple):
"""What the painters need from the last render, worked out once so a j / k or J / K repaint asks Sublime nothing
`starts` / `ends` are each row's text points, `spans` the right column's coloured (start, end, kind) points
per row, `standings` the left column's, and `scopes` the scope names resolved for this render
"""
starts: List[int]
ends: List[int]
spans: List[List[Span]]
standings: List[List[Span]]
scopes: Dict[str, Optional[str]]
def _paint(view: sublime.View, st: ViewState, rows: List[str]) -> Paint:
"""The geometry of `rows` as drawn, and the scopes the scheme answers for now"""
starts = render._row_starts_for_text(rows)
spans = [[(start + a, start + b, kind) for a, b, kind in row.spans] for start, row in zip(starts, st.branch_rows)]
standings = [[(start + a, start + b, kind) for a, b, kind in _standing_spans(row.ref, st.branch_width)]
if row.ref is not None and row.ref.remote is None else []
for start, row in zip(starts, st.branch_rows)]
scopes: Dict[str, Optional[str]] = {
"sel": render._file_header_scope(view), "cursor": palette.cursor_scope(view),
"del": palette.text_scope(view, "del"), "ins": palette.text_scope(view, "ins"),
"sel.del": palette.count_scope(view, "del"), "sel.ins": palette.count_scope(view, "ins"),
"cursor.del": palette.cursor_text_scope(view, "del"), "cursor.ins": palette.cursor_text_scope(view, "ins"),
}
return Paint(starts, [start + len(row) for start, row in zip(starts, rows)], spans, standings, scopes)
def _add(view: sublime.View, key: str, regions: List[sublime.Region], scope: Optional[str]) -> None:
"""Fill `regions` under `key` with `scope`'s colours"""
view.add_regions(key, regions, scope or "", flags=sublime.DRAW_NO_OUTLINE)
def _paint_cursor(view: sublime.View, st: ViewState, index: int) -> None:
"""Tint the right-column cells of item `index` (subject to stats) as the commit cursor, once the scope is live,
and colour the counts and tags of every row: on the cursor's rows on its background, elsewhere on the editor's
No two regions overlap, as a region painted later repaints the text under it in its own colour. Until the
cursor scope is live Sublime's own line highlight marks the caret row across both columns, so it stays on
"""
paint = st.branch_paint
assert paint is not None
scope = paint.scopes["cursor"]
view.settings().set("highlight_line", scope is None)
first, last = index * ROWS_PER_COMMIT, index * ROWS_PER_COMMIT + ROWS_PER_COMMIT - 1 # the rule row stays untinted
cursor = scope is not None and 0 <= index < _item_count(st)
gap = st.branch_width + len(COLUMN_GAP)
tint: List[sublime.Region] = []
coloured: Dict[str, List[sublime.Region]] = {"del": [], "ins": [], "cursor.del": [], "cursor.ins": []}
for i, spans in enumerate(paint.spans):
on_cursor = cursor and first <= i < last
at = paint.starts[i] + gap
for a, b, kind in spans:
if on_cursor:
tint.append(sublime.Region(at, a))
coloured["cursor." + kind if on_cursor else kind].append(sublime.Region(a, b))
at = b
if on_cursor:
tint.append(sublime.Region(at, paint.ends[i]))
_add(view, render.KEY_BRANCH_DEL, coloured["del"], paint.scopes["del"])
_add(view, render.KEY_BRANCH_INS, coloured["ins"], paint.scopes["ins"])
if not cursor:
for key in (render.KEY_BRANCH_CURSOR, render.KEY_BRANCH_CURSOR_DEL, render.KEY_BRANCH_CURSOR_INS):
view.erase_regions(key)
return
_add(view, render.KEY_BRANCH_CURSOR, tint, scope)
_add(view, render.KEY_BRANCH_CURSOR_DEL, coloured["cursor.del"], paint.scopes["cursor.del"])
_add(view, render.KEY_BRANCH_CURSOR_INS, coloured["cursor.ins"], paint.scopes["cursor.ins"])
def _paint_selection(view: sublime.View, st: ViewState) -> None:
"""Tint the selected branch's cell, or nothing when none is listed, and colour the branches' standings
The standings are filled regions, as only those take a scope's text colour, so the selected row's
carry its background rather than the editor's; a change of selection repaints them all
"""
paint = st.branch_paint
assert paint is not None
selected: List[sublime.Region] = []
standings: Dict[str, List[sublime.Region]] = {"del": [], "ins": [], "sel.del": [], "sel.ins": []}
for i, row in enumerate(st.branch_rows):
chosen = row.ref is not None and row.ref.name == st.branch_ref
if chosen:
selected.append(sublime.Region(paint.starts[i], paint.starts[i] + st.branch_width))
for a, b, kind in paint.standings[i]:
standings["sel." + kind if chosen else kind].append(sublime.Region(a, b))
_add(view, render.KEY_BRANCH_SEL, selected, paint.scopes["sel"])
_add(view, render.KEY_BRANCH_STANDING_DEL, standings["del"], paint.scopes["del"])
_add(view, render.KEY_BRANCH_STANDING_INS, standings["ins"], paint.scopes["ins"])
_add(view, render.KEY_BRANCH_SEL_DEL, standings["sel.del"], paint.scopes["sel.del"])
_add(view, render.KEY_BRANCH_SEL_INS, standings["sel.ins"], paint.scopes["sel.ins"])
def _put_caret(view: sublime.View, st: ViewState, index: int, show: bool) -> None:
"""Put the caret on item `index`'s first row and tint the item; `show` scrolls it into view"""
point = render.index_point(view, index * ROWS_PER_COMMIT)
view.sel().clear()
view.sel().add(sublime.Region(point))
_paint_cursor(view, st, index)
if show:
view.show(point)
def render_branches(view: sublime.View, st: ViewState, keep_caret: bool = False) -> None:
"""Draw the two columns, tint the selection, and put the caret on an item's first row
`keep_caret` leaves the cursor on the item it was on and keeps the scroll; otherwise the
working-tree entry is the cursor and the view scrolls to the top
"""
commit = _caret_commit(view, st) if keep_caret else -1
viewport = view.viewport_position() if keep_caret else (0, 0)
# the cells of both columns, then rows of the longer column's length
left = _left_cells(st)
width = st.branch_width = _left_width(left)
right = _right_cells(st.branch_changes, st.branch_commits, _right_width(view, width))
rows: List[str] = []
metadata: List[BranchRow] = []
gap = width + len(COLUMN_GAP)
for i in range(max(len(left), len(right))):
left_text = _left_text(left[i], width) if i < len(left) else " " * width
cell, index, spans = right[i] if i < len(right) else ("", -1, [])
rows.append(f"{left_text}{COLUMN_GAP}{cell}")
# the spans relative to the row's start, for the cursor painter to colour
metadata.append(BranchRow(left[i][1] if i < len(left) else None, index, tuple((gap + a, gap + b, kind) for a, b, kind in spans)))
st.branch_rows = metadata
render._replace(view, "\n".join(rows))
view.assign_syntax("scope:text.plain")
palette.ensure(view)
if palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False):
status(render.NEEDS_RESTART_BRANCHES)
st.branch_paint = _paint(view, st, rows)
# the selection and, with it, the standings; the tags and counts are painted with the cursor, by _put_caret
_paint_selection(view, st)
render._finish(view, st, MODE_BRANCHES, "⟐ branches", header_html(st), fingerprint)
# the caret: on the kept item's first row, else on the working-tree entry
if not 0 <= commit < _item_count(st):
commit = WORKTREE
_put_caret(view, st, commit, show=False)
view.set_viewport_position(viewport, False)
if keep_caret:
view.show(render.index_point(view, commit * ROWS_PER_COMMIT))
def fingerprint(st: ViewState) -> Tuple[Any, ...]:
"""Cheap "did anything change" key: the refs' mtimes (what a checkout, commit, fetch, push or branch change
touches), then the working-tree entry's, so a refresh can tell which half moved"""
refs = tuple(git.mtime(path) for path in git.ref_paths(st.repo))
return refs, render.worktree_fingerprint(st.repo, st.branch_changes)
def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
"""Reload when the refs changed since the view was drawn, or relist only the working tree when only it did"""
# not while a load is in flight: its result would only be thrown away
if st.token != st.rendered_token:
return
now = fingerprint(st)
if now == st.fingerprint:
return
if st.fingerprint and now[0] == st.fingerprint[0]:
# a save, something staged: the branches and the log stand
load_worktree(view, st)
else:
load(view, st, keep_caret=True)
def header_html(st: ViewState) -> str:
"""The minihtml header: the repository and HEAD, then the selected branch, its upstream and the action links"""
current = st.current_branch()
if current is not None:
where = f'on <span class="hash">{html.escape(current.name)}</span>'
elif st.branch_head_sha:
where = f'detached at <span class="hash">{html.escape(st.branch_head_sha)}</span>'
else:
where = "no commits"
summary = f'<div class="summary">Branches · {html.escape(os.path.basename(st.repo))} · {where}</div>'
ref = st.selected_ref()
if ref is None:
title = '<span class="path">no branch selected</span>'
elif ref.remote is not None:
title = f'<span class="path">{html.escape(ref.name)}</span>&nbsp;&nbsp;<span class="when">remote branch of {html.escape(ref.remote)}</span>'
else:
title = f'<span class="path">{html.escape(ref.name)}</span>'
if ref.upstream:
title += f'&nbsp;&nbsp;<span class="when">→ {html.escape(ref.upstream)}</span>&nbsp;&nbsp;{_standing_html(ref)}'
actions: List[str] = []
if ref is not None:
actions.append('<a href="checkout">Checkout</a>')
if ref.remote is not None:
actions.append('<a href="checkout_detached">Detached</a>')
else:
actions.append('<a href="pull">Pull</a>')
actions.append('<a href="push">Push</a>')
actions.append('<a href="fetch">Fetch</a>')
if ref is not None and ref.remote is None:
actions.append('<a href="delete_branch" class="danger">Delete</a>')
return render.header_frame(summary, title, actions)
# --- actions ----------------------------------------------------------------
def _selected(st: ViewState, local_verb: Optional[str] = None) -> Optional[Ref]:
"""The selected branch, with a status message when there is none; with `local_verb`, a remote one does not count either"""
ref = st.selected_ref()
if ref is None:
status("no branch selected")
elif local_verb and ref.remote is not None:
status(f"{local_verb} works on a local branch, select one")
return None
return ref
def _act_checkout(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Check out the selected branch; a remote one as the same-named local branch, created tracking it"""
ref = _selected(st)
if ref is None:
return
if ref.current:
status(f"already on {ref.name}")
return
locals_ = [r.branch for r in st.branch_refs if r.remote is None]
def done(_: None) -> None:
"""Select the branch now checked out and relist"""
st.branch_ref = ref.branch
load(view, st, f"checked out {ref.branch}")
run_async(lambda: git.checkout(st.repo, ref, locals_), done)
def _act_checkout_detached(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Check out the selected remote branch with HEAD detached"""
ref = _selected(st)
if ref is None:
return
if ref.remote is None:
status("detached checkout is for remote branches, press Enter for a local one")
return
run_async(lambda: git.checkout_detached(st.repo, ref.name), lambda _: load(view, st, f"checked out {ref.name} detached"))
def _act_pull(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Pull the checked-out branch, or fast-forward another local branch from its upstream"""
ref = _selected(st, "pull")
if ref is None:
return
if ref.current:
status(f"pulling {ref.name}…")
run_async(lambda: git.pull(st.repo), lambda _: load(view, st, f"pulled {ref.name}"))
return
if not ref.upstream or ref.gone:
status(f"{ref.name} has no upstream to pull from")
return
remote, remote_branch = ref.upstream_remote, ref.upstream_branch
if remote is None or remote_branch is None:
status(f"{ref.name} tracks the local branch {ref.upstream}, pull works with a remote upstream")
return
status(f"fast-forwarding {ref.name} from {ref.upstream}…")
run_async(lambda: git.fetch_into(st.repo, remote, remote_branch, ref.name),
lambda _: load(view, st, f"fast-forwarded {ref.name} to {ref.upstream}"))
def _act_push(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Push the selected local branch to its upstream, or to its remote (origin by default), recording that as the upstream"""
ref = _selected(st, "push")
if ref is None:
return
if ref.upstream and not ref.gone:
# to the upstream itself, whatever it is called there
remote, remote_branch = ref.upstream_remote, ref.upstream_branch
if remote is None:
status(f"{ref.name} tracks the local branch {ref.upstream}, there is no remote to push to")
return
work = lambda: git.push(st.repo, remote, ref.name, remote_branch=remote_branch) # noqa: E731
else:
remotes = st.branch_remotes
if not remotes:
status("no remote to push to")
return
remote = "origin" if "origin" in remotes else remotes[0]
work = lambda: git.push(st.repo, remote, ref.name, set_upstream=True) # noqa: E731
status(f"pushing {ref.name} to {remote}…")
run_async(work, lambda _: load(view, st, f"pushed {ref.name} to {remote}"))
def _act_fetch(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Fetch every remote, pruning, and relist"""
status("fetching all remotes…")
run_async(lambda: git.fetch_all(st.repo), lambda _: load(view, st, "fetched all remotes", keep_caret=True))
def _act_delete(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Delete the selected local branch after a confirmation; a second one forces an unmerged branch"""
ref = _selected(st, "delete")
if ref is None:
return
if ref.current:
status(f"{ref.name} is checked out, switch away before deleting it")
return
if not sublime.ok_cancel_dialog(f"Delete the branch {ref.name}?", "Delete"):
return
def work() -> Optional[GitError]:
"""Delete, handing back the refusal of an unmerged branch for the second question"""
try:
git.delete_branch(st.repo, ref.name)
except GitError as error:
if "not fully merged" not in error.stderr:
raise
return error
return None
def done(refusal: Optional[GitError]) -> None:
"""Relist, or offer to force the deletion"""
if refusal is None:
load(view, st, f"deleted {ref.name}")
return
if sublime.ok_cancel_dialog(f"{ref.name} is not fully merged.\n\nDelete it anyway? This cannot be undone.", "Delete anyway"):
run_async(lambda: git.delete_branch(st.repo, ref.name, force=True), lambda _: load(view, st, f"deleted {ref.name}"))
run_async(work, done)
def _step_branch(delta: int) -> Action:
"""Action moving the branch selection `delta` branches on"""
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Select the neighbouring branch"""
select(view, st, delta)
return act
def _step_commit(delta: int) -> Action:
"""Action moving the caret `delta` items on, ringing at the ends"""
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Put the caret on the neighbouring item's first row"""
# off the items (a left-column or blank row) j goes to the first, k to the last
index = _step(view, _caret_commit(view, st), _item_count(st), delta)
if index is not None:
_put_caret(view, st, index, show=True)
return act
def _act_open_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Review the commit under the caret in the commit view, or the changed files from the working-tree entry"""
index = _caret_commit(view, st)
if not 0 <= index < _item_count(st):
status("no commit under the caret")
return
if index == WORKTREE:
window.run_command("vdiff_files")
return
window.run_command("vdiff_commits", {"sha": st.branch_commits[index - 1].sha})
def _in_branches(act: Action) -> Action:
"""`act` guarded to the branches view: header links and the popup can fire it over a diff too"""
def guarded(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Run the action, or say where it works"""
if st.in_branches:
act(view, window, st)
else:
status(NOT_HERE)
return guarded
ACTIONS: Dict[str, Action] = {
name: _in_branches(act)
for name, act in {
"checkout": _act_checkout,
"checkout_detached": _act_checkout_detached,
"pull": _act_pull,
"push": _act_push,
"fetch": _act_fetch,
"delete_branch": _act_delete,
"next_branch": _step_branch(1),
"prev_branch": _step_branch(-1),
"next_commit": _step_commit(1),
"prev_commit": _step_commit(-1),
"open_commit": _act_open_commit,
}.items()
}

View File

@@ -6,10 +6,10 @@ from typing import Callable, Dict, List, Optional, Sequence, Tuple
import sublime
import sublime_plugin
from . import git, parse, render, state
from . import branches, git, parse, render, state
from .git import Commit, FileChange
from .state import Kept, ViewState
from .utils import file_count, run_async, settings, status
from .state import MODE_BRANCHES, Kept, ViewState
from .utils import DEBOUNCE_MS, commits_count, file_count, run_async, settings, status
# an ACTIONS entry: act(view, window, state) on the review view it fired in
Action = Callable[[sublime.View, sublime.Window, ViewState], None]
@@ -31,9 +31,6 @@ COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
COMMIT_ROW_WIDTH = 120
MIN_SUBJECT_WIDTH = 24
# highlight-to-preview delay: holding an arrow key must not spawn a git process per row
PREVIEW_DELAY_MS = 60
# cache directory -> repo root: resolve_repo runs git on the UI thread
_roots: Dict[str, str] = {}
@@ -86,13 +83,17 @@ def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewStat
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))
all_mode = settings().get("default_view_mode") == "all_in_one"
st = state.put(view, state.ViewState(repo, all_mode=all_mode))
window.focus_view(view)
return view, st
def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) -> None:
"""Load all file diffs off the UI thread and switch to the aggregate view, on `focus`'s heading if given"""
"""Load all file diffs off the UI thread and switch to the aggregate view, on `focus`'s heading
Without `focus` the caret goes to the current file's heading, as it is when the diffs land
"""
st.all_mode = True
# bump the token so earlier loads drop their results
st.token += 1
@@ -110,7 +111,8 @@ def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) ->
st.set_all_diffs(result)
if st.change is None and files:
st.change = files[0]
render.render_all(view, st, focus=focus)
render.render_all(view, st, focus=focus or (st.change.path if st.change else None))
st.rendered_token = token
run_async(work, done)
@@ -180,15 +182,45 @@ def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
return items
def redisplay(view: sublime.View, st: ViewState, keep_caret: bool = False) -> None:
"""Redraw whatever the state describes: the branches view, all diffs, or the current file"""
if st.in_branches:
branches.load(view, st, keep_caret=keep_caret)
elif st.all_mode:
show_all(view, st)
elif st.change is not None:
show_file(view, st, st.change)
def redisplay_if_stale(view: sublime.View, st: ViewState) -> None:
"""Redraw when the screen shows something else than the state describes, or a render is still on its way"""
if st.drawn != st.describe() or st.rendered_token != st.token:
redisplay(view, st)
def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
placeholder: str = "Changed files") -> None:
"""Quick panel over `files`: highlight previews, Enter shows, Esc restores `kept` (then runs on_cancel)"""
"""Quick panel over `files`: highlight previews, Enter shows, Esc restores `kept` (then runs on_cancel)
In the aggregate view every diff is loaded once as the panel opens, and a highlight only scrolls to its file
"""
# the panel opens on the shown file, the view focused behind it
st.files = files
selected = max(0, st.index())
window.focus_view(view)
def show(change: FileChange) -> None:
"""Show `change`: its own diff, or its heading in the aggregate view"""
if not st.all_mode:
show_file(view, st, change)
return
st.change = change
st.collapsed.discard(change.path)
# still loading: show_all lands on st.change when the diffs arrive
if st.rendered_token == st.token:
render.render_all(view, st, focus=change.path)
def on_select(index: int) -> None:
"""Enter shows the chosen file; Esc restores `kept`, then runs on_cancel if given"""
st.highlight += 1 # cancel a pending preview
@@ -196,16 +228,16 @@ def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState,
if on_cancel is not None:
st.restore(kept)
on_cancel()
elif kept.change is not None:
# reshow the kept file
elif kept.mode == MODE_BRANCHES or kept.change is not None:
# back to the branches view or the kept file in the kept mode, unless the screen still shows it
st.restore(kept)
show_file(view, st, kept.change)
redisplay_if_stale(view, st)
# 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)
show(change)
def on_highlight(index: int) -> None:
"""Schedule a debounced preview of the highlighted file"""
@@ -217,10 +249,13 @@ def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState,
def preview() -> None:
"""Show the file unless another highlight came since"""
if ticket == st.highlight:
show_file(view, st, files[index])
show(files[index])
sublime.set_timeout(preview, PREVIEW_DELAY_MS)
sublime.set_timeout(preview, DEBOUNCE_MS)
if st.all_mode and files:
st.change = files[selected]
show_all(view, st)
window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)
@@ -261,7 +296,10 @@ def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] =
def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
"""Re-render a working-tree view when the file or the index changed since it was drawn"""
"""Re-render a working-tree view when the file or the index changed since it was drawn; the branches view keeps its own check"""
if st.in_branches:
branches.refresh_if_changed(view, st)
return
if st.in_commit or st.change is None:
return
if render.fingerprint(st) != st.fingerprint:
@@ -283,6 +321,10 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
"""Show the files panel, or a notice when there is nothing uncommitted"""
if not view.is_valid():
return
if not files and (st.in_branches or st.in_commit):
# the notice would replace a view that still holds: the branches, or a commit under review
status("no uncommitted changes")
return
kept = st.enter()
if not files:
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
@@ -306,7 +348,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
if sha:
self.open_commit(view, st, sha, None)
return
count = int(settings().get("commits_count", 30) or 30)
count = commits_count()
def done(commits: List[Commit]) -> None:
"""Show the commits panel unless the repository has none"""
@@ -327,9 +369,12 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
selected = i
def on_select(index: int) -> None:
"""Open the chosen commit's files, remembering its row for Esc"""
"""Open the chosen commit's files, remembering its row for Esc; Esc itself redraws what the state went
back to when the list was reopened from a commit's files, whose diff is still on screen"""
if index != -1:
self.open_commit(view, st, commits[index].sha, index)
else:
redisplay_if_stale(view, st)
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
@@ -358,6 +403,16 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
run_async(lambda: git.commit_with_files(st.repo, sha), done)
class VdiffBranchesCommand(sublime_plugin.WindowCommand):
"""Show the branches view: local branches and remotes, and the log of the selected one"""
def run(self) -> None:
"""Open the review view and list the branches off the UI thread"""
opened = open_review(self.window)
if opened:
branches.load(*opened)
# --- review view actions ----------------------------------------------------
@@ -371,6 +426,11 @@ def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> N
window.run_command("vdiff_commits")
def _act_branches(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Show (or reload) the branches view"""
window.run_command("vdiff_branches")
def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Close the review view"""
view.close()
@@ -402,6 +462,27 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange]
return act
def _bulk_action(verb: str, done_verb: str, operation: Callable[[str], None],
wanted: Callable[[FileChange], bool]) -> Action:
"""Action running `operation(repo)` ("<verb> all") when some listed file is `wanted`, then re-listing on the same file"""
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Run the operation off the UI thread, then refresh the listing"""
if st.in_commit:
status(f"{verb} all works on uncommitted changes only")
return
count = sum(1 for c in st.files if wanted(c))
if not count:
status(f"nothing to {verb}")
return
run_async(
lambda: operation(st.repo),
lambda _: refresh_listing(view, st, f"{done_verb} {file_count(count)}"),
)
return act
def _confirm_discard(change: FileChange) -> bool:
"""Ask before discarding or deleting, unless the confirm_discard setting is off"""
if not settings().get("confirm_discard", True):
@@ -418,10 +499,10 @@ def _confirm_discard(change: FileChange) -> bool:
def _next_unstaged(files: Sequence[FileChange], start: int) -> int:
"""Index of the first unstaged file from `start` on, wrapping round; -1 when every file is staged"""
"""Index of the first file with something to stage from `start` on, wrapping round; -1 when everything is staged"""
for offset in range(len(files)):
index = (start + offset) % len(files)
if not files[index].staged:
if files[index].has_unstaged:
return index
return -1
@@ -436,15 +517,10 @@ def _stage_next(files: Sequence[FileChange], index: int) -> Optional[FileChange]
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Reload what is shown: all diffs, the commit's file, or the working-tree listing"""
if st.all_mode:
show_all(view, st)
return
if st.change is None:
return
if st.in_commit:
show_file(view, st, st.change)
else:
"""Reload what is shown: the branches, all diffs, the commit's file, or the working-tree listing"""
if st.in_branches or st.all_mode or st.in_commit:
redisplay(view, st, keep_caret=True)
elif st.change is not None:
refresh_listing(view, st)
@@ -674,10 +750,13 @@ def _move_line(forward: bool) -> Action:
ACTIONS: Dict[str, Action] = {
"files": _act_files,
"commits": _act_commits,
"branches": _act_branches,
"close": _act_close,
"help": _act_help,
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
"unstage": _git_action("unstage", "unstaged", git.unstage),
"stage_all": _bulk_action("stage", "staged", git.stage_all, lambda c: c.has_unstaged),
"unstage_all": _bulk_action("unstage", "unstaged", git.unstage_all, lambda c: c.staged),
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
"commit": _act_commit,
"amend": _act_amend,
@@ -694,6 +773,8 @@ ACTIONS: Dict[str, Action] = {
"line_down": _move_line(True),
"line_up": _move_line(False),
}
# the branches view's actions: the keymap reaches them through the vdiff_mode context
ACTIONS.update(branches.ACTIONS)
class VdiffActionCommand(sublime_plugin.TextCommand):
@@ -718,18 +799,24 @@ def restore_view(view: sublime.View) -> ViewState:
# the repo, commit and file saved with the view
data = view.settings().get(state.VIEW_DATA) or {}
repo = data.get("repo")
render.init_view(view)
mode = data.get("mode") or state.MODE_DIFF
render.init_view(view, mode)
# repository gone: show a notice
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.")
render.render_message(view, st, "Repository not found. Press f for changed files, c for commits, b for branches.")
return st
st = state.put(view, state.ViewState(repo))
st.sha = data.get("sha")
st.all_mode = bool(data.get("all_mode", False))
# the branches view comes back on the same branch
if mode == MODE_BRANCHES:
st.mode, st.branch_ref = mode, data.get("ref")
redisplay(view, st)
return st
path = data.get("path")
if not path:
render.render_message(view, st, "Press f for changed files, c for commits.")
render.render_message(view, st, "Press f for changed files, c for commits, b for branches.")
return st
# stand-in FileChange: no line counts or rename source
change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None)
@@ -740,12 +827,8 @@ def restore_view(view: sublime.View) -> ViewState:
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
"""Adopt the commit's files and redraw the saved file or the aggregate view"""
st.commit, st.files = result
if st.all_mode:
st.change = change
show_all(view, st)
else:
show_file(view, st, change)
st.commit, st.files, st.change = result[0], result[1], change
redisplay(view, st)
run_async(lambda: git.commit_with_files(repo, sha), show_commit)
else:
@@ -755,11 +838,8 @@ def restore_view(view: sublime.View) -> ViewState:
st.files = files
index = state.find_change(files, path)
if index >= 0:
if st.all_mode:
st.change = files[index]
show_all(view, st)
else:
show_file(view, st, files[index])
st.change = files[index]
redisplay(view, st)
else:
render.render_message(view, st, f"{path} has no uncommitted changes any more")

View File

@@ -1,4 +1,5 @@
"""Git access via subprocess; results are plain tuples, no sublime imports (testable)"""
import functools
import os
import re
import subprocess
@@ -6,7 +7,11 @@ from typing import Dict, List, Optional, NamedTuple, Sequence, Tuple
class FileChange(NamedTuple):
"""Path, one-letter status (see STATUS_NAMES), staged flag, numstat counts (None for binary), rename source"""
"""Path, one-letter status (see STATUS_NAMES), staged flag, numstat counts (None for binary), rename source
`staged` means something of the file is in the index, `unstaged` that the working tree differs from the
index (an untracked file is unstaged); a partially staged file carries both
"""
path: str
status: str
@@ -14,10 +19,35 @@ class FileChange(NamedTuple):
insertions: Optional[int]
deletions: Optional[int]
old_path: Optional[str]
unstaged: bool = False
@property
def has_unstaged(self) -> bool:
"""Whether something of the file is left to stage: a file with nothing staged is, whatever its flag says"""
return self.unstaged or not self.staged
class WorkTree(NamedTuple):
"""The working tree's changes summed up: how many files are staged / unstaged, and the diffstat of them all"""
staged: int
unstaged: int
files: int
insertions: int
deletions: int
def worktree_stats(changes: Sequence[FileChange]) -> WorkTree:
"""Sum the working tree listing: a partially staged file counts on both sides; binary files carry None counts and add nothing"""
return WorkTree(sum(1 for c in changes if c.staged), sum(1 for c in changes if c.has_unstaged), len(changes),
sum(c.insertions or 0 for c in changes), sum(c.deletions or 0 for c in changes))
class Commit(NamedTuple):
"""One log entry, date relative; files/insertions/deletions are None without a diffstat (merges, empty commits)"""
"""One log entry; files/insertions/deletions are None without a diffstat (merges, empty commits)
`date` is relative from recent_commits and dd.mm.yyyy from ref_log, which is also what fills `tags`
"""
sha: str
short: str
@@ -27,6 +57,34 @@ class Commit(NamedTuple):
files: Optional[int]
insertions: Optional[int]
deletions: Optional[int]
tags: Tuple[str, ...] = ()
class Ref(NamedTuple):
"""One local or remote branch
`name` is what git calls it ("main", "origin/main"), `branch` the part after the remote; ahead/behind
count against the upstream, `gone` says the upstream was deleted. `upstream_remote` is the remote the
upstream lives on, None without an upstream or when it is a local branch
"""
name: str
branch: str
remote: Optional[str]
sha: str
upstream: Optional[str]
ahead: int
behind: int
gone: bool
current: bool
upstream_remote: Optional[str] = None
@property
def upstream_branch(self) -> Optional[str]:
"""The upstream's name on its remote ("bar" for origin/bar), None without a remote upstream"""
if self.upstream and self.upstream_remote:
return self.upstream[len(self.upstream_remote) + 1:]
return None
class CommitInfo(NamedTuple):
@@ -52,6 +110,23 @@ FULL_CONTEXT = "-U999999"
# overridable from settings (sublime's PATH may not have a user-installed git)
GIT_BINARY: str = "git"
# environment of the network commands: no terminal to answer a credential prompt or edit a merge
# message in, so git fails fast instead of hanging the worker thread
NO_PROMPT: Dict[str, str] = {"GIT_TERMINAL_PROMPT": "0", "GIT_EDITOR": "true"}
# git's messages in English whatever the user's locale: delete_branch's refusal is told apart by its text
PLAIN_MESSAGES: Dict[str, str] = {"LC_ALL": "C", "LANGUAGE": "C"}
# the ref namespaces listed by branches(); remotes carry a symbolic HEAD that is skipped
_HEADS = "refs/heads/"
_REMOTES = "refs/remotes/"
# for-each-ref knows no %x1f escape, so the separator goes in literally
_REF_FORMAT = "\x1f".join(("%(refname)", "%(objectname:short)", "%(upstream:short)", "%(upstream:track,nobracket)", "%(HEAD)",
"%(symref)", "%(upstream:remotename)"))
# sha, short, author, date (as --date says), subject and the decorations the tags are read from
_LOG_FORMAT = "--format=%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%D"
_RELATIVE_DATE = "--date=relative"
_DAY_DATE = "--date=format:%d.%m.%Y"
STATUS_NAMES: Dict[str, str] = {
"M": "modified",
"A": "added",
@@ -81,10 +156,15 @@ class GitError(Exception):
super().__init__(first[0] if first else f"git exited with {code}")
def run(repo: str, args: Sequence[str], ok_codes: Tuple[int, ...] = (0,)) -> str:
"""Run git in `repo`, return stdout as text"""
def run(repo: str, args: Sequence[str], ok_codes: Tuple[int, ...] = (0,), env: Optional[Dict[str, str]] = None) -> str:
"""Run git in `repo`, return stdout as text; `env` adds to or overrides the inherited environment
stdin is closed: nothing git or its helpers could ask would get an answer from the worker thread
"""
cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args)
proc = subprocess.run(cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace")
environment = dict(os.environ, **PLAIN_MESSAGES, **(env or {}))
proc = subprocess.run(cmd, cwd=repo, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
encoding="utf-8", errors="replace", env=environment)
if proc.returncode not in ok_codes:
raise GitError(proc.stderr, proc.returncode)
return proc.stdout
@@ -115,6 +195,71 @@ def has_head(repo: str) -> bool:
return False
@functools.lru_cache(maxsize=None)
def _git_paths(repo: str, names: Tuple[str, ...]) -> Tuple[str, ...]:
"""Absolute paths of files of the repository's git directory, () outside a repository
A worktree or submodule keeps them outside <repo>/.git, which there is a file, not a directory
"""
try:
# one --git-path per name, answered one per line
text = run(repo, ["rev-parse"] + [arg for name in names for arg in ("--git-path", name)])
except (GitError, OSError):
return ()
return tuple(os.path.join(repo, line) for line in text.split("\n") if line)
def index_path(repo: str) -> Optional[str]:
"""Absolute path of the repository's index file, None outside a repository"""
paths = _git_paths(repo, ("index",))
return paths[0] if paths else None
# what any checkout, commit, fetch, push or branch change touches: refs are written through a lock-file
# rename (bumping the directory's mtime) and every HEAD move appends to logs/HEAD
REF_PATH_NAMES: Tuple[str, ...] = ("HEAD", "logs/HEAD", "packed-refs", "FETCH_HEAD", "refs/heads", "refs/remotes")
REF_DIRS: Tuple[str, ...] = ("refs/heads", "refs/remotes")
# {repo: (the ref directories walked, roots first, and their mtimes then)}: a directory's mtime moves when an
# entry is added to or removed from it, so the walk is only redone when one of them moved
_nested_refs: Dict[str, Tuple[Tuple[str, ...], Tuple[Optional[float], ...]]] = {}
def mtime(path: str) -> Optional[float]:
"""A file's or directory's mtime, None when it is gone"""
try:
return os.stat(path).st_mtime
except OSError:
return None
def ref_paths(repo: str) -> Tuple[str, ...]:
"""Absolute paths whose mtimes change when the branches do; () outside a repository
REF_PATH_NAMES, then every directory below the ref directories: a ref's rename bumps only the directory
it is in, and remote-tracking refs and slashed branches live one or more levels down
"""
paths = _git_paths(repo, REF_PATH_NAMES)
if not paths:
return paths
roots = tuple(paths[REF_PATH_NAMES.index(name)] for name in REF_DIRS)
cached = _nested_refs.get(repo)
if cached is not None:
walked, mtimes = cached
if tuple(mtime(d) for d in walked) == mtimes:
return paths + walked[len(roots):]
found: List[str] = []
for root in roots:
for directory, dirs, _ in os.walk(root):
dirs.sort()
if directory != root:
found.append(directory)
walked = roots + tuple(found)
_nested_refs[repo] = (walked, tuple(mtime(d) for d in walked))
return paths + tuple(found)
def base_ref(repo: str) -> str:
"""HEAD, or the empty tree when there is no commit yet"""
return "HEAD" if has_head(repo) else EMPTY_TREE
@@ -206,9 +351,9 @@ def changed_files(repo: str) -> List[FileChange]:
xy, path = ".?", entry[2:]
else:
continue
# any index change means staged; the worktree letter wins over the index letter
# any index change means staged, any worktree one unstaged; the worktree letter wins over the index letter
x, y = xy[0], xy[1]
staged = x not in (".", "?")
staged, unstaged = x not in (".", "?"), y != "."
letter = y if y != "." else x
# untracked files have no numstat: their lines count as insertions
if letter == "?":
@@ -216,24 +361,24 @@ def changed_files(repo: str) -> List[FileChange]:
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.append(FileChange(path, letter, staged, ins, dele, old, unstaged))
changes.sort(key=lambda c: c.path)
return changes
def recent_commits(repo: str, count: int) -> List[Commit]:
"""Last `count` commits, newest first; empty list without commits"""
if not has_head(repo):
return []
# --shortstat adds per-commit totals to the same call
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
# a five-field line starts a commit; the shortstat line after it fills in its totals
def _log(repo: str, args: Sequence[str]) -> List[Commit]:
"""`log <args>` in _LOG_FORMAT with --shortstat -> commits, newest first
A six-field line starts a commit (its tags come from the %D decoration list); the shortstat line
after it fills in its totals
"""
commits: List[Commit] = []
for line in text.split("\n"):
for line in run(repo, ["log", _LOG_FORMAT, "--shortstat"] + list(args)).split("\n"):
parts = line.split("\x1f")
if len(parts) == 5:
sha, short, author, date, subject = parts
commits.append(Commit(sha, short, author, date, subject, files=None, insertions=None, deletions=None))
if len(parts) == 6:
sha, short, author, date, subject, decorations = parts
tags = tuple(d[5:] for d in decorations.split(", ") if d.startswith("tag: "))
commits.append(Commit(sha, short, author, date, subject, None, None, None, tags))
continue
if commits and commits[-1].files is None:
stat = _SHORTSTAT.match(line)
@@ -243,6 +388,109 @@ def recent_commits(repo: str, count: int) -> List[Commit]:
return commits
def recent_commits(repo: str, count: int) -> List[Commit]:
"""Last `count` commits, newest first, dates relative; empty list without commits"""
if not has_head(repo):
return []
return _log(repo, ["-n", str(count), _RELATIVE_DATE])
def ref_log(repo: str, ref: str, count: int) -> List[Commit]:
"""Last `count` commits reachable from `ref`, newest first, with dd.mm.yyyy dates"""
return _log(repo, [ref, "-n", str(count), _DAY_DATE, "--"])
# --- branches ---------------------------------------------------------------
def remotes(repo: str) -> List[str]:
"""Configured remote names, fetched or not"""
return [line for line in run(repo, ["remote"]).split("\n") if line]
def head_sha(repo: str) -> str:
"""HEAD's short hash, "" before the first commit; which branch it is on, if any, branches() marks"""
# -q --verify: exit 1 and no output without a commit
return run(repo, ["rev-parse", "-q", "--verify", "--short", "HEAD"], ok_codes=(0, 1)).strip()
def _track(text: str) -> Tuple[int, int, bool]:
"""`%(upstream:track,nobracket)` -> (ahead, behind, gone)"""
ahead = re.search(r"ahead (\d+)", text)
behind = re.search(r"behind (\d+)", text)
return int(ahead.group(1)) if ahead else 0, int(behind.group(1)) if behind else 0, text.strip() == "gone"
def branches(repo: str) -> List[Ref]:
"""Local branches, then remote ones, in git's ref order; a remote's symbolic HEAD is left out"""
refs: List[Ref] = []
for line in run(repo, ["for-each-ref", f"--format={_REF_FORMAT}", _HEADS, _REMOTES]).split("\n"):
parts = line.split("\x1f")
if len(parts) != 7:
continue
refname, sha, upstream, track, marker, symref, remotename = parts
if symref:
continue
ahead, behind, gone = _track(track)
if refname.startswith(_HEADS):
name = refname[len(_HEADS):]
# "." is git's name for the local repository: an upstream that is a local branch
upstream_remote = remotename if upstream and remotename != "." else None
refs.append(Ref(name, name, None, sha, upstream or None, ahead, behind, gone, marker == "*", upstream_remote))
elif refname.startswith(_REMOTES):
name = refname[len(_REMOTES):]
# the remote is the first path segment; a remote named with a slash is not supported
remote, _, branch = name.partition("/")
refs.append(Ref(name, branch, remote, sha, None, 0, 0, False, False))
return refs
def checkout(repo: str, ref: Ref, local_names: Sequence[str] = ()) -> None:
"""Check out a local branch, or a remote one as a same-named local branch tracking it
An existing local branch of that name is checked out as it is. The remote is named explicitly:
git's own guess fails when two remotes carry the branch
"""
if ref.remote is None or ref.branch in local_names:
run(repo, ["checkout", "-q", ref.branch, "--"])
else:
run(repo, ["checkout", "-q", "--track", f"{ref.remote}/{ref.branch}", "--"])
def checkout_detached(repo: str, name: str) -> None:
"""Check out `name` (a remote branch, say) with HEAD detached"""
run(repo, ["checkout", "-q", "--detach", name, "--"])
def pull(repo: str) -> None:
"""Pull the current branch from its upstream, as the user's pull.* configuration says"""
run(repo, ["pull", "-q"], env=NO_PROMPT)
def fetch_into(repo: str, remote: str, remote_branch: str, branch: str) -> None:
"""Fast-forward the local `branch` to `remote`'s `remote_branch` without checking it out
git refuses a non-fast-forward update and a branch that is checked out, which the error says
"""
run(repo, ["fetch", "-q", remote, f"{remote_branch}:{branch}"], env=NO_PROMPT)
def push(repo: str, remote: str, branch: str, set_upstream: bool = False, remote_branch: Optional[str] = None) -> None:
"""Push `branch` to `remote`, as `remote_branch` there when that differs, recording it as the upstream when asked"""
refspec = f"{branch}:{remote_branch}" if remote_branch else branch
run(repo, ["push", "-q"] + (["-u"] if set_upstream else []) + [remote, refspec], env=NO_PROMPT)
def fetch_all(repo: str) -> None:
"""Fetch every remote, dropping remote branches that no longer exist"""
run(repo, ["fetch", "-q", "--all", "--prune"], env=NO_PROMPT)
def delete_branch(repo: str, branch: str, force: bool = False) -> None:
"""Delete a local branch; without `force` git refuses one that is not fully merged ("not fully merged" in stderr)"""
run(repo, ["branch", "-D" if force else "-d", branch])
def commit_with_files(repo: str, sha: str) -> Tuple[CommitInfo, List[FileChange]]:
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
show = ["show", "--first-parent", "-m", "-z"]
@@ -295,6 +543,11 @@ def stage(repo: str, change: FileChange) -> None:
run(repo, ["add", "-A", "--", change.path])
def stage_all(repo: str) -> None:
"""Stage every change in the working tree, including deletions and untracked files"""
run(repo, ["add", "-A"])
def commit(repo: str, message: str) -> None:
"""Commit what is staged; `message` goes as one argv element, unquoted"""
run(repo, ["commit", "-m", message])
@@ -313,12 +566,22 @@ def amend(repo: str, message: str, allow_empty: bool = False) -> None:
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
def _unstage_path(repo: str, path: str) -> None:
"""Move the staged changes under `path` back to the working tree; before the first commit, drop them from the index"""
if has_head(repo):
run(repo, ["reset", "-q", "--", path])
else:
run(repo, ["rm", "-r", "-q", "--cached", "--", path])
def unstage(repo: str, change: FileChange) -> None:
"""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])
_unstage_path(repo, change.path)
def unstage_all(repo: str) -> None:
"""Move every staged change back to the working tree"""
_unstage_path(repo, ".")
def discard(repo: str, change: FileChange) -> None:

View File

@@ -1,10 +1,15 @@
"""Event listener: state cleanup, refresh when the tab comes back or a file is saved"""
"""Event listener: state cleanup, refresh when the tab comes back or a file is saved, the keymap's mode context"""
from typing import Optional
import sublime
import sublime_plugin
from . import state
from . import render, state
from .commands import refresh_if_changed, restore_view
# the keymap context naming the review view's mode; a setting context would not compare strings reliably
MODE_CONTEXT = "vdiff_mode"
class VdiffEventListener(sublime_plugin.EventListener):
def on_pre_close(self, view: sublime.View) -> None:
@@ -12,13 +17,34 @@ class VdiffEventListener(sublime_plugin.EventListener):
state.drop(view)
def on_activated(self, view: sublime.View) -> None:
"""Re-render when the tab comes back and the file or index changed meanwhile (agent edits)"""
"""Re-render when the tab comes back and the file, the index or the branches changed meanwhile"""
if state.is_review_view(view):
refresh_if_changed(view, state.get(view) or restore_view(view))
def on_selection_modified(self, view: sublime.View) -> None:
"""In the all-files view the header follows the caret from file to file"""
if state.is_review_view(view):
st = state.get(view)
if st is not None:
render.follow_caret(view, st)
def on_post_save(self, view: sublime.View) -> None:
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
for view_id, st in state.tracked():
review = sublime.View(view_id)
if review.is_valid():
refresh_if_changed(review, st)
def on_query_context(self, view: sublime.View, key: str, operator: int, operand: object, match_all: bool) -> Optional[bool]:
"""Answer the keymap's `vdiff_mode` context: whether the review view shows "diff" or "branches"
Anything else is left to other packages (None)
"""
if key != MODE_CONTEXT or not state.is_review_view(view):
return None
mode = view.settings().get(state.MODE_SETTING) or state.MODE_DIFF
if operator == sublime.OP_EQUAL:
return mode == operand
if operator == sublime.OP_NOT_EQUAL:
return mode != operand
return None

View File

@@ -14,6 +14,8 @@ from typing import Any, Dict, List, Optional, Set, Tuple
import sublime
from .utils import settings
LOG = logging.getLogger("vdiff")
# tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text
@@ -30,14 +32,41 @@ KINDS: Tuple[str, ...] = TINT_KINDS + ("ctx",)
KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR,
"ctx": SCOPE_CTX}
# all-files file heading and its -/+ counts; the colours are user-requested
# all-files file heading, its status letter and its -/+ counts; their colours come from settings, these when one is empty
SCOPE_FILE_HEADER = "vdiff.file.header"
SCOPE_FILE_DEL_COUNT = "vdiff.file.del.count"
SCOPE_FILE_INS_COUNT = "vdiff.file.ins.count"
SCOPE_FILE_STATUS = "vdiff.file.status"
FILE_HEADER_BACKGROUND = "#0000FF"
FILE_STATUS_FOREGROUND = "#ffffff"
# sublime's former markup.deleted / markup.inserted colours
FILE_DEL_FOREGROUND = "#d64646"
FILE_INS_FOREGROUND = "#46d646"
# red / green text on the plain editor background (the branches view's -/+ counts and tags): a region
# only recolours its text when it is filled, so these carry the nudged editor background like SCOPE_CTX
SCOPE_TEXT_DEL = "vdiff.text.del"
SCOPE_TEXT_INS = "vdiff.text.ins"
# the commit under the caret in the branches view: the scheme's own line-highlight colour, on its cells only,
# and the red / green text of its counts and tags on that colour
SCOPE_CURSOR = "vdiff.commit.cursor"
SCOPE_CURSOR_DEL = "vdiff.commit.cursor.del"
SCOPE_CURSOR_INS = "vdiff.commit.cursor.ins"
# blend towards the foreground when the scheme names no line highlight
CURSOR_BLEND = 0.12
# the rules written whatever the settings say, by the view that needs them, so the restart hint names what is pending
DIFF_SCOPES: Tuple[str, ...] = (SCOPE_FILE_HEADER, SCOPE_FILE_STATUS, SCOPE_FILE_DEL_COUNT, SCOPE_FILE_INS_COUNT)
BRANCH_SCOPES: Tuple[str, ...] = (SCOPE_FILE_HEADER, SCOPE_FILE_DEL_COUNT, SCOPE_FILE_INS_COUNT, SCOPE_TEXT_DEL, SCOPE_TEXT_INS,
SCOPE_CURSOR, SCOPE_CURSOR_DEL, SCOPE_CURSOR_INS)
# colours the user may set; an empty line colour keeps the colour scheme's tint or the plain-text look
COLOUR_SETTINGS: Tuple[str, ...] = ("deleted_line_background", "inserted_line_background",
"line_numbers_foreground", "line_numbers_background",
"file_header_background", "file_header_deleted_foreground",
"file_header_inserted_foreground", "file_header_status_foreground")
# the scopes those colours go into: the changed-line tints and the all-files line-number column
SCOPE_LINE_DEL = "vdiff.line.del"
SCOPE_LINE_INS = "vdiff.line.ins"
SCOPE_LINE_NUMBERS = "vdiff.line.numbers"
# tints to provision when the active scheme defines none of its own (some third-party schemes)
FALLBACK_TINTS: Dict[str, str] = {
@@ -82,8 +111,10 @@ PALETTE_PROBES: Tuple[str, ...] = (
_palettes: Dict[str, Dict[str, int]] = {}
# the override name the last ensure() resolved, so scope_for knows which palette to read
_active: Optional[str] = None
# schemes the user has already been told need a restart
_notified: Set[Optional[str]] = set()
# (scheme, scopes) settled for this session: the user was told they need a restart, or they were found live
_notified: Set[Tuple[Optional[str], Tuple[str, ...]]] = set()
# {override file name: the user colours it was provisioned with}, so changing one re-provisions
_provisioned_with: Dict[str, Dict[str, str]] = {}
def reset() -> None:
@@ -91,18 +122,23 @@ def reset() -> None:
global _active
_palettes.clear()
_notified.clear()
_provisioned_with.clear()
_active = None
def claim_restart_notice(view: sublime.View) -> bool:
"""Whether to tell the user to restart; True at most once per scheme
def claim_restart_notice(view: sublime.View, scopes: Tuple[str, ...], tokens: bool = True) -> bool:
"""Whether to tell the user to restart for `scopes` (and the token layer, with `tokens`); True at most once per scheme and set
Only when the override is written but not yet loaded
Only when the override is written but the rules a view needs are not loaded yet; a view that needs
only rules already live after an upgrade is not sent to restart for the others. Either answer is
settled until the override is written again, so a render does not probe the scheme each time
"""
if not (_active and _palettes.get(_active)) or tokens_resolved(view, KINDS) or _active in _notified:
key = (_active, scopes)
if not (_active and _palettes.get(_active)) or key in _notified:
return False
_notified.add(_active)
return True
_notified.add(key)
live = (not tokens or tokens_resolved(view, KINDS)) and all(scope_background(view, scope) for scope in scopes)
return not live
def scope_background(view: sublime.View, scope: str) -> Optional[str]:
@@ -161,6 +197,55 @@ def token_keys() -> List[str]:
return [token_key(_scope_name(kind, i)) for kind in KINDS for i in sorted(colours.values())]
def user_colours() -> Dict[str, str]:
"""{setting: colour} for COLOUR_SETTINGS, "" where unset"""
return {name: settings().get(name) or "" for name in COLOUR_SETTINGS}
def line_scope(view: sublime.View, kind: str) -> str:
"""Tint scope for a changed line, "del" or "ins": the user's colour once its rule is live, else the scheme's"""
own, setting, scheme = ((SCOPE_LINE_DEL, "deleted_line_background", SCOPE_DEL) if kind == "del" else
(SCOPE_LINE_INS, "inserted_line_background", SCOPE_INS))
return own if settings().get(setting) and scope_background(view, own) else scheme
def text_scope(view: sublime.View, kind: str) -> str:
"""Scope painting text red ("del") or green ("ins") on the editor background, once its rule is live
Until the restart that loads it, the scheme's diff tint stands in: a coloured background rather than plain text
"""
own, scheme = (SCOPE_TEXT_DEL, SCOPE_DEL) if kind == "del" else (SCOPE_TEXT_INS, SCOPE_INS)
return own if scope_background(view, own) else scheme
def count_scope(view: sublime.View, kind: str) -> str:
"""Scope painting text red ("del") or green ("ins") on the file-heading background, once both rules are live
Before that, text_scope's stand-in: the heading tint is then a scheme fallback with no matching text rule anyway
"""
own = SCOPE_FILE_DEL_COUNT if kind == "del" else SCOPE_FILE_INS_COUNT
return own if scope_background(view, SCOPE_FILE_HEADER) and scope_background(view, own) else text_scope(view, kind)
def cursor_scope(view: sublime.View) -> Optional[str]:
"""Scope tinting the commit under the caret like a highlighted line, or None until its rule is live"""
return SCOPE_CURSOR if scope_background(view, SCOPE_CURSOR) else None
def cursor_text_scope(view: sublime.View, kind: str) -> str:
"""Scope painting text red ("del") or green ("ins") on the cursor's background, once live, else text_scope's stand-in"""
own = SCOPE_CURSOR_DEL if kind == "del" else SCOPE_CURSOR_INS
return own if scope_background(view, own) else text_scope(view, kind)
def line_numbers_scope(view: sublime.View) -> str:
"""Scope for the all-files line-number column: the user's colours once their rule is live, else plain text"""
if (settings().get("line_numbers_foreground") or settings().get("line_numbers_background")) \
and scope_background(view, SCOPE_LINE_NUMBERS):
return SCOPE_LINE_NUMBERS
return "text.plain"
def _scope_name(kind: str, index: int) -> str:
"""Generated scope name for a background kind and palette index"""
return f"vdiff.{kind}.c{index}"
@@ -174,8 +259,10 @@ def ensure(view: sublime.View) -> None:
global _active
name = override_name(view)
_active = name
if not name or name in _palettes:
user = user_colours()
if not name or (name in _palettes and _provisioned_with.get(name) == user):
return
_provisioned_with[name] = user
# merge into the existing override, if any
path = os.path.join(sublime.packages_path(), "User", name)
try:
@@ -186,14 +273,16 @@ def ensure(view: sublime.View) -> None:
scheme = sublime.decode_value(f.read()) or {}
rules = scheme.setdefault("rules", [])
# probe the scheme, record the palette, and write only when a rule changed
tints = _tints(view)
tints = _tints(view, user)
colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
_palettes[name] = colours
if not _merge_rules(rules, tints, colours, view):
if not _merge_rules(rules, tints, colours, view, user):
return
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(scheme, f, indent=4)
# new rules: whether they are live is an open question again
_notified.difference_update({key for key in _notified if key[0] == name})
LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path)
except Exception:
LOG.warning("could not write colour scheme override %s", path, exc_info=True)
@@ -211,10 +300,11 @@ def _probe_palette(view: sublime.View) -> List[str]:
return seen
def _tints(view: sublime.View) -> Dict[str, str]:
def _tints(view: sublime.View, user: Dict[str, str]) -> Dict[str, str]:
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own.
A line colour the user set wins over both
"""
tints: Dict[str, str] = {}
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)):
@@ -222,20 +312,40 @@ def _tints(view: sublime.View) -> Dict[str, str]:
char = scope_background(view, char_scope)
tints[line_scope] = line if line and char else FALLBACK_TINTS[line_scope]
tints[char_scope] = char if line and char else FALLBACK_TINTS[char_scope]
for scope, setting in ((SCOPE_DEL, "deleted_line_background"), (SCOPE_INS, "inserted_line_background")):
if user[setting]:
tints[scope] = user[setting]
# a region whose background is missing, transparent or equal to the editor's is filled with its
# foreground, so context colours carry the editor background nudged by an invisible step
tints[SCOPE_CTX] = _nudge(_global_background(view))
return tints
def _global_background(view: sublime.View) -> str:
"""The scheme's editor background; style_for_scope reports none for scopes that do not set one"""
def _global(view: sublime.View, key: str) -> Optional[str]:
"""One of the scheme's global colours (background, line_highlight, ...), None when unset or unreadable"""
try:
background = view.style().get("background")
return view.style().get(key)
except Exception:
LOG.debug("view.style failed", exc_info=True)
background = None
return background or "#000000"
return None
def _global_background(view: sublime.View) -> str:
"""The scheme's editor background; style_for_scope reports none for scopes that do not set one"""
return _global(view, "background") or "#000000"
def _line_highlight(view: sublime.View) -> str:
"""The scheme's line-highlight colour, else the editor background blended a little towards the foreground"""
return _global(view, "line_highlight") or _blend(_global_background(view), foreground(view, "text.plain") or "#ffffff", CURSOR_BLEND)
def _blend(colour: str, towards: str, fraction: float) -> str:
"""#rrggbb moved `fraction` of the way towards `towards`; a colour in another form is returned as it is"""
if not (colour.startswith("#") and len(colour) >= 7 and towards.startswith("#") and len(towards) >= 7):
return colour
mixed = (round(int(colour[i:i + 2], 16) * (1 - fraction) + int(towards[i:i + 2], 16) * fraction) for i in (1, 3, 5))
return "#" + "".join(f"{min(255, max(0, c)):02x}" for c in mixed)
def _nudge(colour: str) -> str:
@@ -246,57 +356,64 @@ def _nudge(colour: str) -> str:
return f"{colour[:5]}{blue + 1 if blue < 128 else blue - 1:02x}{colour[7:]}"
def _put_rule(rules: List[Dict[str, str]], present: Dict[Optional[str], Dict[str, str]], wanted: Dict[str, str]) -> bool:
"""Add `wanted`, or make the rule with its scope equal to it; True when anything changed"""
rule = present.get(wanted["scope"])
if rule is None:
rules.append(wanted)
elif rule != wanted:
rule.clear()
rule.update(wanted)
else:
return False
return True
def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
view: sublime.View) -> bool:
view: sublime.View, user: Dict[str, str]) -> bool:
"""Bring the scheme's rules up to date, in place; True when anything changed
Each (kind, colour) rule carries a background and a foreground, as a background alone would flatten
the line's colours; plain tints are added only when missing and never overwritten, so a hand-tuned one survives
"""
present = {rule.get("scope"): rule for rule in rules}
# the all-files file heading, the commit cursor of the branches view in the line-highlight colour (with the
# foreground, so the text keeps its colour), then bold coloured text on the heading, on the editor background
# (the branches view's counts and tags) and on the cursor, all in the heading counts' red and green
header = user["file_header_background"] or FILE_HEADER_BACKGROUND
plain = _nudge(_global_background(view))
highlight = _line_highlight(view)
red = user["file_header_deleted_foreground"] or FILE_DEL_FOREGROUND
green = user["file_header_inserted_foreground"] or FILE_INS_FOREGROUND
wanted = [{"scope": SCOPE_FILE_HEADER, "background": header},
{"scope": SCOPE_CURSOR, "background": highlight, "foreground": foreground(view, "text.plain") or "#ffffff"}]
for scope, background, colour in ((SCOPE_FILE_STATUS, header, user["file_header_status_foreground"] or FILE_STATUS_FOREGROUND),
(SCOPE_FILE_DEL_COUNT, header, red), (SCOPE_FILE_INS_COUNT, header, green),
(SCOPE_TEXT_DEL, plain, red), (SCOPE_TEXT_INS, plain, green),
(SCOPE_CURSOR_DEL, highlight, red), (SCOPE_CURSOR_INS, highlight, green)):
wanted.append({"scope": scope, "background": background, "foreground": colour, "font_style": "bold"})
# the user's line colours; an unset one keeps any old rule, which render then ignores
for scope, setting in ((SCOPE_LINE_DEL, "deleted_line_background"), (SCOPE_LINE_INS, "inserted_line_background")):
if user[setting]:
wanted.append({"scope": scope, "background": user[setting]})
if user["line_numbers_foreground"] or user["line_numbers_background"]:
# both halves, as a region without a background is filled with its foreground
wanted.append({"scope": SCOPE_LINE_NUMBERS,
"foreground": user["line_numbers_foreground"] or foreground(view, "text.plain") or "#ffffff",
"background": user["line_numbers_background"] or _nudge(_global_background(view))})
changed = False
# the all-files file heading
header = present.get(SCOPE_FILE_HEADER)
wanted_header = {"scope": SCOPE_FILE_HEADER, "background": FILE_HEADER_BACKGROUND}
if header is None:
rules.append(wanted_header)
changed = True
elif header != wanted_header:
header.clear()
header.update(wanted_header)
changed = True
# its deletion and insertion counts
for scope, foreground in ((SCOPE_FILE_DEL_COUNT, FILE_DEL_FOREGROUND),
(SCOPE_FILE_INS_COUNT, FILE_INS_FOREGROUND)):
count_rule = present.get(scope)
wanted_count = {"scope": scope, "background": FILE_HEADER_BACKGROUND,
"foreground": foreground, "font_style": "bold"}
if count_rule is None:
rules.append(wanted_count)
changed = True
elif count_rule != wanted_count:
count_rule.clear()
count_rule.update(wanted_count)
changed = True
for rule in wanted:
changed = _put_rule(rules, present, rule) or changed
# plain tints the scheme lacks; the context tint gets no rule of its own
for scope, background in tints.items():
if scope != SCOPE_CTX and scope not in present and not scope_background(view, scope):
rules.append({"scope": scope, "background": background})
changed = True
# one rule per (kind, colour) pair
# one rule per (kind, colour) pair; a palette shifted under an earlier session's rule is ours to correct
for colour, index in colours.items():
for kind in KINDS:
wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]],
"foreground": colour}
rule = present.get(wanted["scope"])
if rule is None:
rules.append(wanted)
changed = True
elif rule != wanted:
# the palette shifted under a rule an earlier session wrote; ours to keep correct
rule.clear()
rule.update(wanted)
changed = True
pair = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], "foreground": colour}
changed = _put_rule(rules, present, pair) or changed
return changed

View File

@@ -3,15 +3,15 @@ import bisect
import html
import logging
import os
from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
import sublime
import sublime_plugin
from . import palette, parse
from . import git, palette, parse
from .git import FileChange, STATUS_NAMES
from .parse import Block, Line
from .state import VIEW_FLAG, ViewState
from .state import MODE_BRANCHES, MODE_DIFF, MODE_SETTING, VIEW_FLAG, ViewState
from .utils import file_count, settings, status
LOG = logging.getLogger("vdiff")
@@ -25,7 +25,18 @@ KEY_INS_CHAR = "vdiff.ins.char"
KEY_FILE_HEADER = "vdiff.file.header"
KEY_FILE_DEL_COUNT = palette.SCOPE_FILE_DEL_COUNT
KEY_FILE_INS_COUNT = palette.SCOPE_FILE_INS_COUNT
KEY_FILE_STATUS = palette.SCOPE_FILE_STATUS
KEY_FILE_PREFIX = "vdiff.file.prefix"
KEY_BRANCH_SEL = "vdiff.branch.sel"
KEY_BRANCH_DEL = "vdiff.branch.del"
KEY_BRANCH_INS = "vdiff.branch.ins"
KEY_BRANCH_STANDING_DEL = "vdiff.branch.standing.del"
KEY_BRANCH_STANDING_INS = "vdiff.branch.standing.ins"
KEY_BRANCH_SEL_DEL = "vdiff.branch.sel.del"
KEY_BRANCH_SEL_INS = "vdiff.branch.sel.ins"
KEY_BRANCH_CURSOR = "vdiff.branch.cursor"
KEY_BRANCH_CURSOR_DEL = "vdiff.branch.cursor.del"
KEY_BRANCH_CURSOR_INS = "vdiff.branch.cursor.ins"
REGION_KEYS = (
KEY_DEL,
KEY_INS,
@@ -36,7 +47,18 @@ REGION_KEYS = (
KEY_FILE_HEADER,
KEY_FILE_DEL_COUNT,
KEY_FILE_INS_COUNT,
KEY_FILE_STATUS,
KEY_FILE_PREFIX,
KEY_BRANCH_SEL,
KEY_BRANCH_DEL,
KEY_BRANCH_INS,
KEY_BRANCH_STANDING_DEL,
KEY_BRANCH_STANDING_INS,
KEY_BRANCH_SEL_DEL,
KEY_BRANCH_SEL_INS,
KEY_BRANCH_CURSOR,
KEY_BRANCH_CURSOR_DEL,
KEY_BRANCH_CURSOR_INS,
)
PHANTOM_KEY = "vdiff.header"
@@ -63,6 +85,7 @@ SCOPE_FILE_HEADER = palette.SCOPE_FILE_HEADER
# sublime loads a newly created scheme override only after a restart
NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines"
NEEDS_RESTART_BRANCHES = "restart Sublime Text to finish enabling the colours of the branches view"
# actions: the ACTIONS names the row runs, a link when there is one; short: status-bar label, "" omits it
@@ -91,8 +114,8 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
"Actions",
False,
[
Key("s", "stage", "stage", ("stage",)),
Key("u", "unstage", "unstage", ("unstage",)),
Key("s / S", "stage / stage all", "stage", ("stage", "stage_all")),
Key("u / U", "unstage / unstage all", "unstage", ("unstage", "unstage_all")),
Key("d", "discard", "discard", ("discard",)),
Key("C", "commit the staged files", "commit", ("commit",)),
Key("m", "amend the last commit", "amend", ("amend",)),
@@ -102,10 +125,11 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
"View",
True,
[
Key("r", "refresh", "refresh", ("refresh",)),
Key("f", "changed files", "files", ("files",)),
Key("c", "recent commits", "commits", ("commits",)),
Key("b", "branches", "branches", ("branches",)),
Key("v", "toggle all-files view", "view", ("view",)),
Key("r", "refresh", "refresh", ("refresh",)),
Key("left right", "collapse / expand file", "fold", ("collapse", "expand")),
Key("z", "collapse / expand all files", "", ("fold_all",)),
Key("esc", "close the view", "", ("close",)),
@@ -113,12 +137,59 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
),
]
KEYS: List[Key] = [key for _, _, group in KEY_GROUPS for key in group]
# the keys of the branches view; the "applies in a commit" flag has no meaning there
BRANCH_KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
(
"Navigate",
True,
[
Key("J K", "next / previous branch", "branch", ("next_branch", "prev_branch")),
Key("j k", "next / previous commit", "commit", ("next_commit", "prev_commit")),
Key("o v", "review the commit, or the changed files", "review", ("open_commit",)),
],
),
(
"Actions",
True,
[
Key("⏎", "checkout remote branch", "checkout", ("checkout",)),
Key("shift+⏎", "checkout a remote branch detached", "", ("checkout_detached",)),
Key("L", "pull", "pull", ("pull",)),
Key("P", "push, setting the upstream when missing", "push", ("push",)),
Key("F", "fetch all remotes and prune", "fetch", ("fetch",)),
Key("d", "delete the local branch", "delete", ("delete_branch",)),
],
),
(
"View",
True,
[
Key("f", "changed files", "files", ("files",)),
Key("c", "recent commits", "commits", ("commits",)),
Key("b", "reload the branches", "", ("branches",)),
Key("r", "refresh", "refresh", ("refresh",)),
Key("esc", "close the view", "", ("close",)),
],
),
]
KEY_HINTS = " ".join(f"{k.keys} {k.short}" for k in KEYS if k.short)
MODE_KEY_GROUPS: Dict[str, List[Tuple[str, bool, List[Key]]]] = {MODE_DIFF: KEY_GROUPS, MODE_BRANCHES: BRANCH_KEY_GROUPS}
def keys(mode: str = MODE_DIFF) -> List[Key]:
"""Every key row of a mode's table"""
return [key for _, _, group in MODE_KEY_GROUPS[mode] for key in group]
def key_hints(mode: str = MODE_DIFF) -> str:
"""The status-bar line of a mode: its keys with a short label"""
return " ".join(f"{k.keys} {k.short}" for k in keys(mode) if k.short)
def _key_width(mode: str) -> int:
"""The popup font is the editor's, so the key column is padded to a fixed width in characters"""
return max(len(k.keys) for k in keys(mode))
# the popup font is the editor's, so the key column is padded to a fixed width in characters
_KEY_WIDTH = max(len(k.keys) for k in KEYS)
HEADER_CSS = """
body { margin: 0; padding: 0.5rem 0.8rem; background-color: color(var(--background) blend(var(--foreground) 93%)); }
@@ -138,7 +209,7 @@ a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-
"""
# minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class
# .group's top margin replaces body's top padding; .kdim inherits its colour from .dim
# .group's top margin replaces body's top padding
HELP_CSS = """
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
@@ -146,7 +217,6 @@ body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
.row { color: color(var(--foreground) alpha(0.85)); }
.dim { color: color(var(--foreground) alpha(0.35)); }
.k { color: var(--orangish); font-weight: bold; }
.kdim { font-weight: bold; }
a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
"""
@@ -169,11 +239,12 @@ def _clear_external_diagnostics(view: sublime.View) -> None:
view.erase_regions(key + suffix)
def init_view(view: sublime.View) -> None:
def init_view(view: sublime.View, mode: str = MODE_DIFF) -> None:
"""Settings a review view needs, applied once when it is created or restored"""
s = view.settings()
# mark the view as vdiff's and turn off editor chrome that does not fit a diff
# mark the view as vdiff's, say which keys apply, and turn off editor chrome that does not fit a diff
s.set(VIEW_FLAG, True)
s.set(MODE_SETTING, mode)
# native line numbers would be wrong: deleted lines have none
s.set("line_numbers", False)
s.set("word_wrap", False)
@@ -201,7 +272,7 @@ def init_view(view: sublime.View) -> None:
view.set_read_only(True)
if not view.name():
view.set_name("vDiff")
view.set_status(STATUS_KEY, KEY_HINTS)
view.set_status(STATUS_KEY, key_hints(mode))
def index_point(view: sublime.View, index: int, col: int = 0) -> int:
@@ -216,19 +287,18 @@ def caret_index(view: sublime.View) -> int:
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float]]]:
"""Cheap "did anything change" key for working-tree mode: mtimes of the file and of the git index"""
def fingerprint(st: ViewState) -> Optional[Tuple[Any, ...]]:
"""Cheap "did anything change" key of a working-tree diff: mtimes of the shown files and of the git index;
None for a commit or nothing shown (the branches view keeps its own, see branches.fingerprint)"""
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")))
return worktree_fingerprint(st.repo, st.files if st.all_mode else [st.change])
def _mtime(path: str) -> Optional[float]:
"""Modification time of `path`, or None when it cannot be stat'ed"""
try:
return os.stat(path).st_mtime
except OSError:
return None
def worktree_fingerprint(repo: str, files: Sequence[FileChange]) -> Tuple[Optional[float], ...]:
"""mtimes of `files` and of the index they are staged in"""
index = git.index_path(repo)
return tuple(git.mtime(os.path.join(repo, c.path)) for c in files) + (git.mtime(index) if index else None,)
def flash(view: sublime.View) -> None:
@@ -265,7 +335,7 @@ def render_message(view: sublime.View, st: ViewState, message: str) -> None:
st.binary = False
_replace(view, message)
view.assign_syntax("scope:text.plain")
_finish(view, st, 0, 0, "vDiff")
_finish(view, st, MODE_DIFF, "vDiff", header_html(st, 0, 0))
def _trim_context(lines: Sequence[Line], context: int) -> List[Line]:
@@ -317,12 +387,13 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
st.all_change_targets.append((change.path, hunk_index))
hunk_index += 1
in_change = changed
# the heading: fold marker, path and the file's -/+ counts
# the heading: fold marker, path, lowercase status letter (s when anything is staged) and the file's -/+ counts
ins, dele = parse.counts(lines)
marker = "▶" if change.path in st.collapsed else "▼"
if change.path == focus:
heading = len(rows)
rows.append(f"{marker} {change.path} -{dele} +{ins}")
letter = "s" if change.staged else change.status.lower()
rows.append(f"{marker} {change.path} {letter} -{dele} +{ins}")
metadata.append((change, None, True))
# the diff rows, recording the row where each visible change starts
if change.path not in st.collapsed:
@@ -344,13 +415,13 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
# paint its colours as regions
view.assign_syntax("scope:text.plain")
palette.ensure(view)
if palette.claim_restart_notice(view):
if palette.claim_restart_notice(view, palette.DIFF_SCOPES):
status(NEEDS_RESTART)
_all_colours(view, st, [c.path for c in st.files if c.path not in st.collapsed])
# per-row regions: heading counts, number prefix, tint, token colours
starts = _row_starts_for_text(rows)
del_regions, ins_regions, file_regions, prefix_regions = [], [], [], []
file_del_counts, file_ins_counts = [], []
file_del_counts, file_ins_counts, file_statuses = [], [], []
token_groups: Dict[str, List[sublime.Region]] = {}
default_colour = palette.foreground(view, "text.plain")
resolved = palette.tokens_resolved(view)
@@ -359,9 +430,12 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
for i, (change, line, section) in enumerate(metadata):
if section:
file_regions.append(sublime.Region(starts[i], starts[i] + len(rows[i])))
# colour just the counts over the heading tint; rfind, as a path may contain " -"
# colour just the status letter and counts over the heading tint; rfind, as a path may contain " -"
minus = rows[i].rfind(" -") + 1
plus = rows[i].rfind(" +") + 1
if minus >= 2:
# the letter sits just before the space before the deletion count
file_statuses.append(sublime.Region(starts[i] + minus - 2, starts[i] + minus - 1))
if minus and plus > minus:
file_del_counts.append(sublime.Region(starts[i] + minus, starts[i] + plus - 1))
if plus:
@@ -387,16 +461,16 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
elif line.kind == "+":
ins_regions.append(region)
# paint everything
view.add_regions(KEY_DEL, del_regions, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, ins_regions, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_DEL, del_regions, palette.line_scope(view, "del"), flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, ins_regions, palette.line_scope(view, "ins"), flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_FILE_HEADER, file_regions, _file_header_scope(view), flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_FILE_DEL_COUNT, file_del_counts, KEY_FILE_DEL_COUNT, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_FILE_INS_COUNT, file_ins_counts, KEY_FILE_INS_COUNT, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_FILE_PREFIX, prefix_regions, "text.plain", flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_FILE_STATUS, file_statuses, KEY_FILE_STATUS, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_FILE_PREFIX, prefix_regions, palette.line_numbers_scope(view), flags=sublime.DRAW_NO_OUTLINE)
for scope, regions in token_groups.items():
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
insertions, deletions = _totals(st.files)
_finish(view, st, insertions, deletions, "⟐ all files")
_finish(view, st, MODE_DIFF, "⟐ all files", _all_header_html(st))
# place the caret: on the focused change's first row, else on `heading` (a file's or the top)
if isinstance(focus, tuple):
heading = next((row for row, shown in zip(st.all_change_rows, st.all_change_row_targets) if shown == focus), 0)
@@ -474,6 +548,27 @@ def all_change_at(view: sublime.View, st: ViewState) -> Optional[FileChange]:
return None
def follow_caret(view: sublime.View, st: ViewState) -> None:
"""In the all-files view, make the file under the caret current and redraw the header for it
Only when the caret crossed into another file, and not while a render is pending: the rows then
describe the old text
"""
if st.in_branches or not st.all_mode or st.rendered_token != st.token:
return
change = all_change_at(view, st)
if change is None or (st.change is not None and change.path == st.change.path):
return
st.change = change
_header(view, st, _all_header_html(st))
def _all_header_html(st: ViewState) -> str:
"""The all-files header: the totals on the summary line, the current file's own counts in the title"""
change = st.change
return header_html(st, change.insertions if change else 0, change.deletions if change else 0)
def render(view: sublime.View, st: ViewState) -> None:
"""Draw st.lines for st.change into the view"""
change = st.change
@@ -497,12 +592,12 @@ def render(view: sublime.View, st: ViewState) -> None:
# here rather than init_view: during a session restore the scheme colours still read as absent
palette.ensure(view)
_paint_regions(view, st)
if palette.claim_restart_notice(view):
if palette.claim_restart_notice(view, palette.DIFF_SCOPES):
status(NEEDS_RESTART)
# header and bookkeeping, then the caret and scroll back to the top
ins, dele = parse.counts(lines)
_finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}")
_finish(view, st, MODE_DIFF, f"⟐ {os.path.basename(change.path)}", header_html(st, ins, dele))
view.sel().clear()
view.sel().add(sublime.Region(index_point(view, 0)))
view.set_viewport_position((0, 0), False)
@@ -514,8 +609,8 @@ def _paint_regions(view: sublime.View, st: ViewState) -> None:
# full-line tint
starts = _row_starts(lines)
deleted, inserted = _line_regions(lines, starts)
view.add_regions(KEY_DEL, deleted, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, inserted, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_DEL, deleted, palette.line_scope(view, "del"), flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, inserted, palette.line_scope(view, "ins"), flags=sublime.DRAW_NO_OUTLINE)
# gutter dots
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 == "-"]
@@ -620,13 +715,22 @@ def _colour_runs(
return runs
def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -> None:
"""Header, tab name, status bar and bookkeeping shared by every render"""
_header(view, st, ins, dele)
def _finish(view: sublime.View, st: ViewState, mode: str, name: str, header: str,
key: Callable[[ViewState], Any] = fingerprint) -> None:
"""Mode, header, tab name, status bar and bookkeeping shared by every render: what was drawn is settled here
`key` is the mode's fingerprint, the branches view's own one there
"""
st.mode = mode
_header(view, st, header)
view.set_name(name)
view.set_status(STATUS_KEY, KEY_HINTS)
view.set_status(STATUS_KEY, key_hints(st.mode))
view.settings().set(MODE_SETTING, st.mode)
# the caret row's highlight; the branches view turns it off itself once it tints the caret's commit
view.settings().set("highlight_line", True)
st.persist(view)
st.fingerprint = fingerprint(st)
st.fingerprint = key(st)
st.drawn = st.describe()
def _replace(view: sublime.View, text: str) -> None:
@@ -688,11 +792,6 @@ def _char_regions(
return deleted, inserted
def _totals(files: Sequence[FileChange]) -> Tuple[int, int]:
"""Summed numstat counts; binary files carry None counts and add nothing"""
return sum(c.insertions or 0 for c in files), sum(c.deletions or 0 for c in files)
def _summary_html(st: ViewState) -> str:
"""Summary line above the title: the commit on one line, or the staged/unstaged split and totals"""
if not st.files and not st.commit:
@@ -700,12 +799,11 @@ def _summary_html(st: ViewState) -> str:
if st.in_commit:
text = " · ".join(_commit_parts(st))
else:
# staged means "has something staged": a partially staged file counts as staged only
staged = sum(1 for c in st.files if c.staged)
insertions, deletions = _totals(st.files)
# the same counts as the branches view's working-tree entry: a partially staged file is on both sides
stat = git.worktree_stats(st.files)
text = (
f"{staged} staged, {len(st.files) - staged} unstaged"
f' · <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>'
f"{stat.staged} staged, {stat.unstaged} unstaged"
f' · <span class="del">-{stat.deletions}</span> <span class="ins">+{stat.insertions}</span>'
)
return f'<div class="summary">{text}</div>'
@@ -720,8 +818,8 @@ def _commit_parts(st: ViewState) -> List[str]:
parts.append(html.escape(info.author))
parts.append(f'<span class="when">{html.escape(info.date)}</span>')
# binary files add nothing to the line totals but still count toward the file total
insertions, deletions = _totals(st.files)
parts.append(f"{file_count(len(st.files))} changed" f' <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>')
stat = git.worktree_stats(st.files)
parts.append(f"{file_count(stat.files)} changed" f' <span class="del">-{stat.deletions}</span> <span class="ins">+{stat.insertions}</span>')
# the subject, plus the body's first line
if info:
message = html.escape(info.subject)
@@ -732,8 +830,8 @@ def _commit_parts(st: ViewState) -> List[str]:
return parts
def header_html(st: ViewState, ins: int, dele: int) -> str:
"""The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
def header_html(st: ViewState, ins: Optional[int], dele: Optional[int]) -> str:
"""The minihtml header: the summary line, then the shown file's path, its counts (None for a binary file), state and action links"""
change = st.change
# the title: path, rename source, counts and working-tree state
if change is None:
@@ -742,7 +840,10 @@ def header_html(st: ViewState, ins: int, dele: int) -> str:
title = f'<span class="path">{html.escape(change.path)}</span>'
if change.old_path:
title += f' <span class="old">← {html.escape(change.old_path)}</span>'
title += f'&nbsp;&nbsp;<span class="del">-{dele}</span> <span class="ins">+{ins}</span>'
if ins is None or dele is None:
title += '&nbsp;&nbsp;<span class="state">binary</span>'
else:
title += f'&nbsp;&nbsp;<span class="del">-{dele}</span> <span class="ins">+{ins}</span>'
if not st.in_commit:
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
if state:
@@ -756,13 +857,15 @@ def header_html(st: ViewState, ins: int, dele: int) -> str:
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>')
actions.append('<a href="help" title="keys">?</a>')
return header_frame(_summary_html(st), title, actions)
def header_frame(summary: str, title: str, actions: Sequence[str]) -> str:
"""The header's body: the summary line, then the title with `actions` and the links every view has"""
links = list(actions) + ['<a href="files">Files</a>', '<a href="commits">Commits</a>', '<a href="help" title="keys">?</a>']
return (
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{_summary_html(st)}'
f'<div>{title}<span class="actions">{"".join(actions)}</span></div></body>'
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{summary}'
f'<div>{title}<span class="actions">{"".join(links)}</span></div></body>'
)
@@ -771,38 +874,42 @@ def _run_action(view: sublime.View, href: str) -> None:
view.run_command("vdiff_action", {"action": href})
def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None:
"""One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep"""
def _header(view: sublime.View, st: ViewState, html_text: str) -> None:
"""One block phantom above the text, redrawn from scratch unless the same one is still in place"""
# keyed on the buffer too: replacing the text can drop the phantom while query_phantom still answers for it
drawn = html_text, view.change_count()
if st.header == drawn:
return
def navigate(href: str) -> None:
"""Run the clicked header link's action"""
_run_action(view, href)
view.erase_phantoms(PHANTOM_KEY)
view.add_phantom(PHANTOM_KEY, sublime.Region(0), header_html(st, ins, dele), sublime.LAYOUT_BLOCK, navigate)
view.add_phantom(PHANTOM_KEY, sublime.Region(0), html_text, sublime.LAYOUT_BLOCK, navigate)
st.header = drawn
# --- key help popup ---------------------------------------------------------
def help_html(st: ViewState) -> str:
"""The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown"""
"""The minihtml key list of the shown mode; rows that need a working-tree file are dimmed while a commit is shown"""
parts: List[str] = []
width = _key_width(st.mode)
# per group a heading, dimmed with a note when it does not apply in a commit
for title, applies, group in KEY_GROUPS:
for title, applies, group in MODE_KEY_GROUPS[st.mode]:
dim = st.in_commit and not applies
heading = html.escape(title)
if dim:
heading += ' <span class="note">uncommitted only</span>'
parts.append(f'<div class="group">{heading}</div>')
# one class per element: a compound selector would silently drop the rest of HELP_CSS
row_class, key_class = ("dim", "kdim") if dim else ("row", "k")
# keys stay orange in a dimmed group; only the text dims
row_class = "dim" if dim else "row"
# a row per key with a padded key column; a single-action row becomes a link
for key in group:
row = (
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
f'{"&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
)
pad = "&nbsp;" * (width - len(key.keys) + 2)
row = f'<span class="k">{html.escape(key.keys)}</span>{pad}{html.escape(key.label)}'
if len(key.actions) == 1 and not dim:
row = f'<a href="{key.actions[0]}">{row}</a>'
parts.append(f'<div class="{row_class}">{row}</div>')

View File

@@ -6,11 +6,16 @@ if TYPE_CHECKING:
# annotation-only, so this module keeps importing neither sublime nor the rest of the package
import sublime
from .git import CommitInfo, FileChange
from .branches import Paint
from .git import Commit, CommitInfo, FileChange, Ref
from .parse import Block, Line
# view setting marking a review view (keymap context "setting.vdiff_view")
VIEW_FLAG = "vdiff_view"
# view setting naming what the view shows, "diff" or "branches", so the keymap can bind a key per mode
MODE_SETTING = "vdiff_mode"
MODE_DIFF = "diff"
MODE_BRANCHES = "branches"
# view setting holding what the view shows, so a restored tab can re-render
VIEW_DATA = "vdiff"
@@ -22,6 +27,18 @@ class Kept(NamedTuple):
commit: Optional["CommitInfo"]
change: Optional["FileChange"]
files: List["FileChange"]
all_mode: bool
mode: str = MODE_DIFF
class BranchRow(NamedTuple):
"""What one row of the branches view holds: the branch of its left cell, the index of its right-column
item (0 the working tree, i + 1 the i-th commit, -1 none) and the coloured spans of its right cell
as (start, end, "del" | "ins") offsets from the row's start"""
ref: Optional["Ref"]
commit: int
spans: Tuple[Tuple[int, int, str], ...] = ()
def find_change(files: Sequence["FileChange"], path: Optional[str]) -> int:
@@ -48,24 +65,53 @@ class ViewState:
token: int = 0 # bumped per show request; a stale async result is dropped
rendered_token: int = -1 # token of the request that produced the current render
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
# (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable
fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None
# all-files view: diffs cached so collapse/expand is instant
fingerprint: Optional[Tuple[Any, ...]] = None # what the last render saw; see render.fingerprint
drawn: Optional[Tuple[Any, ...]] = None # what the last render drew, as describe() put it
all_mode: bool = False
all_diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]] = field(default_factory=dict)
# path -> {(old_no, new_no): [(start, end, colour)]}: syntax colours per row, offsets within the line
all_colours: Dict[str, Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]] = field(default_factory=dict)
collapsed: set = field(default_factory=set)
all_rows: List[Tuple[Optional["FileChange"], Optional["Line"], bool]] = field(default_factory=list)
all_change_rows: List[int] = field(default_factory=list)
all_change_targets: List[Tuple[str, int]] = field(default_factory=list)
all_change_row_targets: List[Tuple[str, int]] = field(default_factory=list)
# the branches view: what it lists, which branch is selected and what each row holds
mode: str = MODE_DIFF
branch_ref: Optional[str] = None # name of the selected branch, e.g. "main" or "origin/main"
branch_head_sha: str = "" # HEAD's short hash, "" before the first commit
branch_refs: List["Ref"] = field(default_factory=list)
branch_remotes: List[str] = field(default_factory=list)
branch_commits: List["Commit"] = field(default_factory=list)
branch_changes: List["FileChange"] = field(default_factory=list) # the working tree, listed with the branches
branch_rows: List[BranchRow] = field(default_factory=list)
branch_width: int = 0 # the left column's width in the last render
branch_paint: Optional["Paint"] = None # the last render's row geometry and scopes, see branches.Paint
header: Optional[Tuple[str, int]] = None # the header phantom last drawn: its html and the buffer's change_count then
@property
def in_commit(self) -> bool:
"""Whether a commit is being reviewed rather than the working tree"""
return self.sha is not None
def describe(self) -> Tuple[Any, ...]:
"""What the state says is shown: the branches view, or a diff's commit, mode and file"""
if self.in_branches:
return (self.mode,)
return self.mode, self.sha, self.all_mode, self.change.path if self.change else None
@property
def in_branches(self) -> bool:
"""Whether the branches view is shown rather than a diff"""
return self.mode == MODE_BRANCHES
def selected_ref(self) -> Optional["Ref"]:
"""The selected branch, or None when it is not listed (any more)"""
return next((r for r in self.branch_refs if r.name == self.branch_ref), None)
def current_branch(self) -> Optional["Ref"]:
"""The checked-out branch, None when detached or before the first commit"""
return next((r for r in self.branch_refs if r.current), None)
def index(self) -> int:
"""Position in `files` of the file being shown or loaded, or -1
@@ -77,7 +123,7 @@ class ViewState:
def kept(self) -> Kept:
"""Snapshot of what to go back to on Esc"""
return Kept(self.sha, self.commit, self.change, list(self.files))
return Kept(self.sha, self.commit, self.change, list(self.files), self.all_mode, self.mode)
def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
@@ -89,18 +135,24 @@ class ViewState:
def restore(self, kept: Kept) -> None:
"""Go back to a snapshot; its file becomes the requested one"""
self.sha, self.commit, self.change, self.files = kept
self.sha, self.commit, self.change, self.files, self.all_mode, self.mode = kept
self.requested = kept.change
def set_all_diffs(self, diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]]) -> None:
"""Replace the all-files diffs; a file's cached colours survive only if its diff is unchanged"""
self.all_colours = {path: colours for path, colours in self.all_colours.items()
if path in diffs and path in self.all_diffs and diffs[path][0] == self.all_diffs[path][0]}
self.all_colours = {
path: colours
for path, colours in self.all_colours.items()
if path in diffs and path in self.all_diffs and diffs[path][0] == self.all_diffs[path][0]
}
self.all_diffs = diffs
def persist(self, view: "sublime.View") -> None:
"""Mirror the essentials into view settings (survives a restart)"""
data: Dict[str, Any] = {"repo": self.repo, "sha": self.sha}
if self.in_branches:
data["mode"] = MODE_BRANCHES
data["ref"] = self.branch_ref
if self.all_mode:
data["all_mode"] = True
if self.change:

View File

@@ -8,6 +8,8 @@ from . import git
SETTINGS_FILE = "vDiff.sublime-settings"
DEBOUNCE_MS = 60
LOG = logging.getLogger("vdiff")
_settings: Optional[sublime.Settings] = None
@@ -28,9 +30,15 @@ def apply_git_binary() -> None:
git.GIT_BINARY = settings().get("git_binary") or "git"
def file_count(count: int) -> str:
"""'1 file' / '3 files' for a message"""
return f'{count} {"file" if count == 1 else "files"}'
def commits_count() -> int:
"""How many commits the commits panel and the branches view list"""
return int(settings().get("commits_count", 30) or 30)
def file_count(count: int, qualifier: str = "") -> str:
"""'1 file' / '3 files' for a message; a qualifier goes before the noun ('1 staged file')"""
noun = "file" if count == 1 else "files"
return f"{count} {qualifier} {noun}" if qualifier else f"{count} {noun}"
def status(message: str) -> None: