diff --git a/Default.sublime-keymap b/Default.sublime-keymap
index 66f1c79..7af8b67 100644
--- a/Default.sublime-keymap
+++ b/Default.sublime-keymap
@@ -3,46 +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": ["S"], "command": "vdiff_action", "args": {"action": "stage_all"},
- "context": [{"key": "setting.vdiff_view"}] },
- { "keys": ["U"], "command": "vdiff_action", "args": {"action": "unstage_all"},
- "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"},
@@ -53,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"}] }
]
diff --git a/README.md b/README.md
index ce057c3..f33ff21 100644
--- a/README.md
+++ b/README.md
@@ -45,12 +45,15 @@ 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 |
@@ -66,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 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 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
diff --git a/TODO.txt b/TODO.txt
index fb5607f..9b1940c 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -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 ..
diff --git a/main.py b/main.py
index 66f4e52..f7fed2a 100644
--- a/main.py
+++ b/main.py
@@ -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,
diff --git a/tests/conftest.py b/tests/conftest.py
index ce025dc..cb51698 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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)))
diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py
index aa7e9fc..d11c529 100644
--- a/tests/stubs/sublime.py
+++ b/tests/stubs/sublime.py
@@ -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
diff --git a/tests/test_branches.py b/tests/test_branches.py
new file mode 100644
index 0000000..6548177
--- /dev/null
+++ b/tests/test_branches.py
@@ -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] == ("├─" if line.endswith("─") else "│ ") 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",)))
+ width = len(" both ↑12 ↓4") + branches.LEFT_PAD
+ cells = [line[:width] for line in rows(view)]
+ # 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[:st.branch_width].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 ↑1' 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 '↓2' 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", "branches", "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)]
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 2f87c6c..7153abe 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -4,6 +4,7 @@ import pytest
import sublime # the stub, via conftest
+from conftest import put_caret # noqa: E402
from vdiff import commands, git, state, utils
@@ -163,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)
@@ -476,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()
@@ -600,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):
@@ -653,6 +701,10 @@ def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch):
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)
@@ -670,6 +722,9 @@ def watched(monkeypatch, tmp_path, all_mode):
st.files = [change("a.py"), change("b.py")]
st.change, st.all_mode = st.files[0], all_mode
st.fingerprint = commands.render.fingerprint(st)
+ # git status lists what the view lists, unless a test says otherwise
+ monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
+ monkeypatch.setattr(commands.git, "changed_files", lambda repo: list(st.files))
return st, refreshes
@@ -698,6 +753,27 @@ def test_refresh_if_changed_watches_the_shown_file_and_the_index(monkeypatch, tm
assert refreshes == [st]
+@pytest.mark.parametrize("all_mode", [False, True])
+def test_refresh_if_changed_sees_a_file_changed_outside_the_list(monkeypatch, tmp_path, all_mode):
+ st, refreshes = watched(monkeypatch, tmp_path, all_mode=all_mode)
+ # no listed file and not the index moved, but git status now lists c.py too
+ monkeypatch.setattr(commands.git, "changed_files", lambda repo: st.files + [change("c.py")])
+ commands.refresh_if_changed(sublime.View(), st)
+ assert refreshes == [st]
+
+
+def test_a_stale_git_status_does_not_refresh(monkeypatch, tmp_path):
+ st, refreshes = watched(monkeypatch, tmp_path, all_mode=False)
+ pending = []
+ monkeypatch.setattr(commands, "run_async", lambda work, done: pending.append(done))
+ monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
+ commands.refresh_if_changed(sublime.View(), st)
+ # the user moved on to another file before git status answered
+ st.token += 1
+ pending[0]([change("c.py")])
+ assert refreshes == []
+
+
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))
@@ -744,9 +820,31 @@ 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("", 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 '-4 +0' 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 '-1 +1' 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():
@@ -808,3 +906,101 @@ def test_all_files_context_zero_shows_only_changed_lines(monkeypatch):
view, st = all_files_view()
shown = [line for _, line, _ in st.all_rows if line is not None]
assert shown and all(line.kind != " " for line in shown)
+
+
+def test_refresh_in_the_all_files_view_relists_the_working_tree(monkeypatch):
+ view, st = all_files_view()
+ st.change = st.files[0]
+ monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
+ # b.py was reverted and c.py is new since the view was drawn
+ monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py"), change("c.py")])
+ shown = []
+ monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(focus))
+ commands.ACTIONS["refresh"](view, None, st)
+ assert shown == ["a.py"] and [c.path for c in st.files] == ["a.py", "c.py"]
+
+
+@pytest.mark.parametrize("all_mode, expected", [(False, ("file", "b.py")), (True, ("all", "b.py"))])
+def test_restore_falls_back_to_the_first_changed_file(monkeypatch, tmp_path, all_mode, expected):
+ view = sublime.View()
+ view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "path": "gone.py", "all_mode": all_mode})
+ monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
+ monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("b.py"), change("c.py")])
+ shown = []
+ monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(("all", st.change.path)))
+ monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(("file", c.path)))
+ commands.restore_view(view)
+ assert shown == [expected]
+
+
+def test_restore_with_no_changes_left_says_so(monkeypatch, tmp_path):
+ view = sublime.View()
+ view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "path": "gone.py"})
+ monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
+ monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
+ messages = []
+ monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
+ commands.restore_view(view)
+ assert messages == [f"No uncommitted changes in {tmp_path}"]
+
+
+def test_open_in_the_all_files_view_maps_a_deleted_row_like_the_single_file_view(tmp_path):
+ from vdiff import parse
+ view, st = all_files_view()
+ st.repo = str(tmp_path)
+ (tmp_path / "a.py").write_text("")
+ (tmp_path / "b.py").write_text("")
+ lines_b = [parse.Line(" ", i, i, f"b{i}") for i in range(1, 5)] + [parse.Line("-", 5, None, "old b5"),
+ parse.Line(" ", 6, 5, "b6")]
+ st.all_diffs["b.py"] = (lines_b, parse.change_blocks(lines_b), False)
+ commands.render.render_all(view, st)
+ opened = []
+
+ class Window:
+ def open_file(self, name, flags=0):
+ opened.append(name)
+
+ def open_at(match):
+ put_caret(view, next(i for i, (_, line, _) in enumerate(st.all_rows) if line is not None and match(line)))
+ commands.ACTIONS["open"](view, Window(), st)
+
+ # the deleted row opens at the line that took its place, as in the single-file view, not at line 1
+ open_at(lambda line: line.kind == "-")
+ # a surviving row keeps its own number
+ open_at(lambda line: line.new_no == 26)
+ assert opened == [f"{tmp_path}/b.py:5", f"{tmp_path}/a.py:26"]
+
+
+def test_a_blank_row_in_the_all_files_view_is_no_file(monkeypatch):
+ view, st = all_files_view()
+ messages, ran = [], []
+ monkeypatch.setattr(commands, "status", messages.append)
+ monkeypatch.setattr(commands, "run_async", lambda work, done: ran.append(work))
+ # the caret was on a.py, then moved to the blank row above b.py: git actions and open act on nothing
+ put_caret(view, heading_row(st, "a.py") + 1)
+ commands.render.follow_caret(view, st)
+ put_caret(view, heading_row(st, "b.py") - 1)
+ commands.ACTIONS["discard"](view, None, st)
+ commands.ACTIONS["stage"](view, None, st)
+ commands.ACTIONS["open"](view, None, st)
+ assert ran == [] and messages == ["no file under the caret"] * 3
+
+
+@pytest.mark.parametrize("action, path", [("next_file", "b.py"), ("prev_file", "a.py")])
+def test_a_blank_row_steps_files_as_the_gap_above_the_file_below(action, path):
+ view, st = all_files_view()
+ st.change = st.files[0]
+ put_caret(view, heading_row(st, "b.py") - 1)
+ commands.ACTIONS[action](view, None, st)
+ assert st.change.path == path
+
+
+def test_a_blank_row_steps_changes_as_the_gap_above_the_file_below():
+ view, st = all_files_view()
+ put_caret(view, heading_row(st, "b.py") - 1)
+ commands.ACTIONS["next_change"](view, None, st)
+ assert st.change.path == "b.py"
+ put_caret(view, heading_row(st, "b.py") - 1)
+ commands.ACTIONS["prev_change"](view, None, st)
+ # a.py's last hunk, not the first or last of the whole list from nowhere
+ assert st.change.path == "a.py" and commands.render.caret_index(view) == st.all_change_rows[1]
diff --git a/tests/test_git.py b/tests/test_git.py
index a5ffd82..b0515a7 100644
--- a/tests/test_git.py
+++ b/tests/test_git.py
@@ -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):
@@ -296,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")
diff --git a/tests/test_palette.py b/tests/test_palette.py
index 86ee344..9304da8 100644
--- a/tests/test_palette.py
+++ b/tests/test_palette.py
@@ -157,3 +157,72 @@ def test_the_status_letter_is_white_on_the_heading_unless_set(tmp_path, monkeypa
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)
diff --git a/tests/test_render.py b/tests/test_render.py
index f4a41ca..37e62c7 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -159,7 +159,7 @@ def test_help_popup_lists_every_key():
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="discard"' in html and 'href="files"' in html and 'href="close"' in html
+ assert 'href="discard"' in html and 'href="files"' in html and 'href="branches"' in html and 'href="close"' in html
# rows covering two actions stay plain text
assert 'href="next_change"' not in html and 'href="stage"' not in html
# the popup does not list the key that opens it
@@ -196,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():
@@ -205,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() if token != "/"} | {"?"}
+ 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():
@@ -258,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('
')[1].split("
")[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 '-2' in summary and '+3' in summary
+ assert summary.startswith("2 staged, 3 unstaged · ")
+ assert '-2' in summary and '+5' in summary
def test_summary_totals_skip_binary_files_but_still_count_them():
@@ -305,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("", 1)[1]
+ assert '-0 +7' in title and "+10" not in title
+ assert '-2 +10' in html.split("", 1)[0]
+ st.change = st.files[2]
+ assert 'binary' in render._all_header_html(st).split("", 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 '-4 +5' in render.header_html(st, 5, 4)
+
+
def test_summary_is_omitted_without_a_file_list():
view = sublime.View()
render.init_view(view)
@@ -432,3 +468,50 @@ 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"
+
+
+def test_all_files_view_tokenises_only_once_the_colours_can_show(monkeypatch):
+ calls = []
+ monkeypatch.setattr(render, "_all_colours", lambda view, st, paths: calls.append(list(paths)))
+ st = make_state()
+ st.all_mode = True
+ st.files = [st.change]
+ st.all_diffs = {st.change.path: (st.lines, st.blocks, False)}
+ view = sublime.View()
+ render.init_view(view)
+ # before the restart that loads the scheme rules the colours could not be painted: no tokenising
+ render.render_all(view, st)
+ assert calls == []
+ render.render_all(with_loaded_override(view), st)
+ assert calls == [[st.change.path]]
diff --git a/vDiff.sublime-commands b/vDiff.sublime-commands
index 577892c..d3d3cad 100644
--- a/vDiff.sublime-commands
+++ b/vDiff.sublime-commands
@@ -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" }
]
diff --git a/vdiff/branches.py b/vdiff/branches.py
new file mode 100644
index 0000000..019359e
--- /dev/null
+++ b/vdiff/branches.py
@@ -0,0 +1,713 @@
+"""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
+# box drawing: the column line, the rule under each item, and the column line's branch into a rule on the rule rows
+COLUMN_GAP = "│ "
+RULE = "─"
+JUNCTION = "├─"
+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 'up to date'
+ return " ".join(f'{text}' 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_width = _right_width(view, width)
+ right = _right_cells(st.branch_changes, st.branch_commits, right_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(left_text + (JUNCTION if cell == RULE * right_width else 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 {html.escape(current.name)}'
+ elif st.branch_head_sha:
+ where = f'detached at {html.escape(st.branch_head_sha)}'
+ else:
+ where = "no commits"
+ summary = f'Branches · {html.escape(os.path.basename(st.repo))} · {where}
'
+ ref = st.selected_ref()
+ if ref is None:
+ title = 'no branch selected'
+ elif ref.remote is not None:
+ title = f'{html.escape(ref.name)} remote branch of {html.escape(ref.remote)}'
+ else:
+ title = f'{html.escape(ref.name)}'
+ if ref.upstream:
+ title += f' → {html.escape(ref.upstream)} {_standing_html(ref)}'
+ actions: List[str] = []
+ if ref is not None:
+ actions.append('Checkout')
+ if ref.remote is not None:
+ actions.append('Detached')
+ else:
+ actions.append('Pull')
+ actions.append('Push')
+ actions.append('Fetch')
+ if ref is not None and ref.remote is None:
+ actions.append('Delete')
+ 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()
+}
diff --git a/vdiff/commands.py b/vdiff/commands.py
index f7e2053..a16f080 100644
--- a/vdiff/commands.py
+++ b/vdiff/commands.py
@@ -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] = {}
@@ -185,6 +182,22 @@ 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:
@@ -215,13 +228,10 @@ 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 in the kept mode
+ 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)
- if st.all_mode:
- show_all(view, st)
- else:
- show_file(view, st, kept.change)
+ redisplay_if_stale(view, st)
# nothing was kept before: the last previewed file stays
return
change = files[index]
@@ -241,7 +251,7 @@ def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState,
if ticket == st.highlight:
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]
@@ -286,11 +296,25 @@ 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 a shown file, the index or the list of changed files moved 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:
refresh_listing(view, st)
+ return
+ # the mtimes watch only the listed files; git status, off the UI thread, sees a file newly changed outside the list
+ token = st.token
+
+ def done(files: List[FileChange]) -> None:
+ """Re-list when the working tree's changes are no longer the listed ones, unless a newer render took over"""
+ if view.is_valid() and token == st.token and files != st.files:
+ refresh_listing(view, st)
+
+ run_async(lambda: git.changed_files(st.repo), done)
class VdiffFilesCommand(sublime_plugin.WindowCommand):
@@ -308,6 +332,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}")
@@ -331,7 +359,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"""
@@ -352,9 +380,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")
@@ -383,6 +414,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 ----------------------------------------------------
@@ -396,6 +437,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()
@@ -414,9 +460,12 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange]
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Confirm if asked, run the operation off the UI thread, then refresh the listing"""
change = _current_change(view, st)
- if st.in_commit or change is None:
+ if st.in_commit:
status(f"{verb} works on uncommitted changes only")
return
+ if change is None:
+ status("no file under the caret")
+ return
if confirm is not None and not confirm(change):
return
run_async(
@@ -464,10 +513,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
@@ -482,24 +531,20 @@ 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, the commit's diffs, or the working tree re-listed (new and reverted files too)"""
+ if st.in_branches or st.in_commit:
+ redisplay(view, st, keep_caret=True)
+ elif st.all_mode or st.change is not None:
refresh_listing(view, st)
def _current_change(view: sublime.View, st: ViewState) -> Optional[FileChange]:
- """File under the caret in the aggregate view (made current), else the shown file"""
+ """File under the caret in the aggregate view (made current), None on a blank row or the top row; else the shown file"""
if st.all_mode:
change = render.all_change_at(view, st)
if change is not None:
st.change = change
+ return change
return st.change
@@ -507,6 +552,7 @@ def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None
"""Open the working-tree file at the line under the caret"""
change = _current_change(view, st)
if change is None:
+ status("no file under the caret")
return
path = os.path.join(st.repo, change.path)
if not os.path.exists(path):
@@ -516,7 +562,10 @@ def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None
if st.all_mode:
row = render.caret_index(view)
displayed = st.all_rows[row][1] if 0 <= row < len(st.all_rows) else None
- line = displayed.new_no if displayed and displayed.new_no is not None else 1
+ # the row's slot in the file's whole diff (a heading is its start), mapped as the single-file view maps it
+ lines = st.all_diffs.get(change.path, ([], [], False))[0]
+ index = next((i for i, line in enumerate(lines) if line is displayed), 0)
+ line = parse.line_for_open(lines, index)
else:
line = parse.line_for_open(st.lines, render.caret_index(view))
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
@@ -565,9 +614,13 @@ def _step_file(delta: int) -> Action:
status("no files to step through")
return
change = _current_change(view, st)
- index = state.find_change(st.files, change.path if change else None)
- # nothing selected yet: n starts at the first file, p at the last
- index = (index + delta) % len(st.files) if index >= 0 else (0 if delta > 0 else len(st.files) - 1)
+ if change is not None:
+ index = (state.find_change(st.files, change.path) + delta) % len(st.files)
+ else:
+ # a blank row or the top row sits above a file: n goes to that file, p to the one before it
+ below = render.all_change_below(view, st)
+ index = state.find_change(st.files, below.path) if below else 0
+ index = (index if delta > 0 else index - 1) % len(st.files)
st.change = st.files[index]
st.collapsed.discard(st.change.path)
render.render_all(view, st, focus=st.change.path, center=True)
@@ -600,7 +653,8 @@ def _step_change(delta: int) -> Action:
"""Jump to the next or previous change, across files in the aggregate view"""
if st.all_mode:
# aggregate view: the hunks of all files are the targets
- current_change = _current_change(view, st)
+ # a blank row or the top row counts as above the file below it: ] goes to its first hunk, [ before it
+ current_change = _current_change(view, st) or render.all_change_below(view, st)
rows = st.all_change_rows
targets = st.all_change_targets
if not targets:
@@ -720,11 +774,12 @@ 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: not c.staged),
+ "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,
@@ -742,6 +797,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):
@@ -766,18 +823,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)
@@ -788,28 +851,20 @@ 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:
def show_working_tree(files: List[FileChange]) -> None:
- """Redraw the saved file if it still has changes, else say it has none"""
+ """Redraw the saved file, or the first changed one when it has no changes any more"""
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])
- else:
- render.render_message(view, st, f"{path} has no uncommitted changes any more")
+ if not files:
+ render.render_message(view, st, f"No uncommitted changes in {repo}")
+ return
+ st.change = files[max(0, state.find_change(files, path))]
+ redisplay(view, st)
run_async(lambda: git.changed_files(repo), show_working_tree)
return st
diff --git a/vdiff/git.py b/vdiff/git.py
index bd52d27..ed4f691 100644
--- a/vdiff/git.py
+++ b/vdiff/git.py
@@ -7,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
@@ -15,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
@@ -28,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):
@@ -53,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",
@@ -82,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
@@ -117,17 +196,70 @@ def has_head(repo: str) -> bool:
@functools.lru_cache(maxsize=None)
-def index_path(repo: str) -> Optional[str]:
- """Absolute path of the repository's index file, None outside a repository
+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 it outside /.git, which there is a file, not a directory
+ A worktree or submodule keeps them outside /.git, which there is a file, not a directory
"""
try:
- return os.path.join(repo, run(repo, ["rev-parse", "--git-path", "index"]).strip())
+ # 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
@@ -219,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 == "?":
@@ -229,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 ` 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)
@@ -256,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"]
diff --git a/vdiff/listeners.py b/vdiff/listeners.py
index 4d382db..39e4545 100644
--- a/vdiff/listeners.py
+++ b/vdiff/listeners.py
@@ -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
diff --git a/vdiff/palette.py b/vdiff/palette.py
index 3d35793..4a5cdc1 100644
--- a/vdiff/palette.py
+++ b/vdiff/palette.py
@@ -42,6 +42,21 @@ 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",
@@ -96,8 +111,8 @@ 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]] = {}
@@ -111,15 +126,19 @@ def reset() -> None:
_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]:
@@ -190,6 +209,35 @@ def line_scope(view: sublime.View, kind: str) -> str:
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")) \
@@ -233,6 +281,8 @@ def ensure(view: sublime.View) -> None:
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)
@@ -271,14 +321,31 @@ def _tints(view: sublime.View, user: Dict[str, str]) -> Dict[str, str]:
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:
@@ -310,13 +377,21 @@ def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Di
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 and its deletion and insertion counts
+ # 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
- wanted = [{"scope": SCOPE_FILE_HEADER, "background": header}]
- for scope, colour in ((SCOPE_FILE_STATUS, user["file_header_status_foreground"] or FILE_STATUS_FOREGROUND),
- (SCOPE_FILE_DEL_COUNT, user["file_header_deleted_foreground"] or FILE_DEL_FOREGROUND),
- (SCOPE_FILE_INS_COUNT, user["file_header_inserted_foreground"] or FILE_INS_FOREGROUND)):
- wanted.append({"scope": scope, "background": header, "foreground": colour, "font_style": "bold"})
+ 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]:
diff --git a/vdiff/render.py b/vdiff/render.py
index 6d3c69b..bfea822 100644
--- a/vdiff/render.py
+++ b/vdiff/render.py
@@ -3,7 +3,7 @@ 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
@@ -11,7 +11,7 @@ import sublime_plugin
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")
@@ -27,6 +27,16 @@ 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,
@@ -39,6 +49,16 @@ REGION_KEYS = (
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"
@@ -65,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
@@ -104,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",)),
@@ -115,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%)); }
@@ -170,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)
@@ -202,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:
@@ -217,21 +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], ...]]:
- """Cheap "did anything change" key for working-tree mode: mtimes of the shown files 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
- files = st.files if st.all_mode else [st.change]
- index = git.index_path(st.repo)
- return tuple(_mtime(os.path.join(st.repo, c.path)) for c in files) + (_mtime(index) if index else None,)
+ 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:
@@ -268,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]:
@@ -348,16 +415,18 @@ 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])
+ # the token colours only once their scheme rules are live; tokenising before the restart would be thrown away
+ resolved = palette.tokens_resolved(view)
+ if resolved:
+ _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_statuses = [], [], []
token_groups: Dict[str, List[sublime.Region]] = {}
default_colour = palette.foreground(view, "text.plain")
- resolved = palette.tokens_resolved(view)
# context colours come from a later scheme rule, so they may still await a restart
ctx_resolved = palette.tokens_resolved(view, ("ctx",))
for i, (change, line, section) in enumerate(metadata):
@@ -403,8 +472,7 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
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)
@@ -482,6 +550,35 @@ def all_change_at(view: sublime.View, st: ViewState) -> Optional[FileChange]:
return None
+def all_change_below(view: sublime.View, st: ViewState) -> Optional[FileChange]:
+ """File of the first heading or diff row at or below the caret: the file a blank row or the top row precedes"""
+ for change, _, _ in st.all_rows[max(0, caret_index(view)):]:
+ if change is not None:
+ return change
+ 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
@@ -505,12 +602,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)
@@ -628,13 +725,21 @@ 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:
@@ -696,11 +801,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:
@@ -708,12 +808,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' · -{deletions} +{insertions}'
+ f"{stat.staged} staged, {stat.unstaged} unstaged"
+ f' · -{stat.deletions} +{stat.insertions}'
)
return f'{text}
'
@@ -728,8 +827,10 @@ def _commit_parts(st: ViewState) -> List[str]:
parts.append(html.escape(info.author))
parts.append(f'{html.escape(info.date)}')
# 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' -{deletions} +{insertions}')
+ stat = git.worktree_stats(st.files)
+ parts.append(
+ f"{file_count(stat.files)} changed" f' -{stat.deletions} +{stat.insertions}'
+ )
# the subject, plus the body's first line
if info:
message = html.escape(info.subject)
@@ -740,8 +841,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:
@@ -750,7 +851,10 @@ def header_html(st: ViewState, ins: int, dele: int) -> str:
title = f'{html.escape(change.path)}'
if change.old_path:
title += f' ← {html.escape(change.old_path)}'
- title += f' -{dele} +{ins}'
+ if ins is None or dele is None:
+ title += ' binary'
+ else:
+ title += f' -{dele} +{ins}'
if not st.in_commit:
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
if state:
@@ -764,13 +868,20 @@ def header_html(st: ViewState, ins: int, dele: int) -> str:
actions.append('Discard')
if change is not None:
actions.append('Open')
- actions.append('Files')
- actions.append('Commits')
- actions.append('?')
+ 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) + [
+ 'Files',
+ 'Commits',
+ 'Branches',
+ '?',
+ ]
return (
- f'{_summary_html(st)}'
- f'{title}{"".join(actions)}
'
+ f'{summary}'
+ f'{title}{"".join(links)}
'
)
@@ -779,25 +890,31 @@ 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:
@@ -807,7 +924,7 @@ def help_html(st: ViewState) -> str:
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:
- pad = " " * (_KEY_WIDTH - len(key.keys) + 2)
+ pad = " " * (width - len(key.keys) + 2)
row = f'{html.escape(key.keys)}{pad}{html.escape(key.label)}'
if len(key.actions) == 1 and not dim:
row = f'{row}'
diff --git a/vdiff/state.py b/vdiff/state.py
index f0b810e..ca1bebc 100644
--- a/vdiff/state.py
+++ b/vdiff/state.py
@@ -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"
@@ -23,6 +28,17 @@ class Kept(NamedTuple):
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:
@@ -49,7 +65,8 @@ 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
- fingerprint: Optional[Tuple[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)
all_colours: Dict[str, Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]] = field(default_factory=dict)
@@ -58,12 +75,43 @@ class ViewState:
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
@@ -75,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), self.all_mode)
+ 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"""
@@ -87,7 +135,7 @@ 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, self.all_mode = 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:
@@ -102,6 +150,9 @@ class ViewState:
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:
diff --git a/vdiff/utils.py b/vdiff/utils.py
index f7ef3fb..2f695dc 100644
--- a/vdiff/utils.py
+++ b/vdiff/utils.py
@@ -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: