Compare commits

10 Commits

Author SHA1 Message Date
Vaclav Tvrdik
af8c1dd6df .format replaced by f-strings 2026-09-26 20:25:50 +02:00
Vaclav Tvrdik
bb7ae537ea TODO.txt added 2026-09-25 12:57:15 +02:00
Vaclav Tvrdik
4749835e4f commit --amend initial version 2026-09-25 10:33:32 +02:00
Vaclav Tvrdik
0655d3b74a stage action moves to next unstaged file (there is setting to turn this off) 2026-09-25 10:08:36 +02:00
Vaclav Tvrdik
57476e78dc "[" and "]" cycle over changes (hunks) 2026-09-24 22:32:14 +02:00
Vaclav Tvrdik
18551641b5 "n" and "p" keys cycle over files 2026-09-24 17:13:29 +02:00
Vaclav Tvrdik
419b1a95fb fix "n" and "p" keys not working all the time 2026-09-24 16:49:52 +02:00
Vaclav Tvrdik
120676e952 more info in commit list 2026-09-24 16:23:27 +02:00
Vaclav Tvrdik
fda13684d2 summary line initial version added 2026-09-24 15:38:03 +02:00
Vaclav Tvrdik
d45a250e62 added j / k for vim-style line movement
j and k move the caret one line, delegated to sublime's own `move` command
so they behave exactly like the arrow keys. They dispatch through the
existing ACTIONS registry, so the keymap, the status-bar hints and the ?
popup all stay in sync through the single KEY_GROUPS table.

Unlike the other letter keys, j / k keep no command_mode context: the view
is read-only, so leaving them live while Vintage is in command mode can
only ever move the caret. That asymmetry was previously documented only in
a comment, which was wrong (it claimed j / k were the only unguarded
bindings, but escape has always carried its own context instead). It is now
pinned by test_command_mode_guard_is_deliberate, so normalising the keymap
fails the suite with the reason attached.

Also tidies two long lines in commands.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 15:03:32 +02:00
13 changed files with 1134 additions and 132 deletions

View File

@@ -1,30 +1,38 @@
[ [
{ "keys": ["f"], "command": "vdiff_action", "args": {"action": "files"}, { "keys": ["f"], "command": "vdiff_action", "args": {"action": "files"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"}, { "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"}, { "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"}, { "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"}, { "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"}, { "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"}, { "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"}, { "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"}, { "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"}, { "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"}, { "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"}, { "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "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"}, { "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"}, { "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
"context": [ "context": [
{"key": "setting.vdiff_view"}, {"key": "setting.vdiff_view"},

View File

@@ -18,10 +18,13 @@ Keys inside the review view:
| key | action | | key | action |
|---|---| |---|---|
| `f` / `c` | changed files / recent commits panel | | `f` / `c` | changed files / recent commits panel |
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode) | | `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file |
| `C` | commit the staged files, typing the message in an input panel |
| `m` | amend the last commit: edit its subject, keep its body, fold in anything staged |
| `o` or Enter | open the real file at this line | | `o` or Enter | open the real file at this line |
| `]` / `[` | next / previous change | | `]` / `[` | next / previous change (wraps at the ends) |
| `n` / `p` | next / previous file of the current list | | `n` / `p` | next / previous file of the current list (wraps at the ends) |
| `j` / `k` | move down / up one line (vim keys) |
| `r` | refresh | | `r` | refresh |
| `?` | show this key list in a popup | | `?` | show this key list in a popup |
| Esc | close the view | | Esc | close the view |

30
TODO.txt Normal file
View File

@@ -0,0 +1,30 @@
macbook: claude --resume 275357c1-ce75-41de-a85a-4e06288bc8f0
macstudio: claude --resume ca7e7e5d-0ad3-45e5-bd67-e5dbd3af1b4c
Add amend action. Similar to commit but commit --amend. When user has chosen amend open commit message input field with last commit message and cursor on the end of message and user can change commit message or confirm it by pressing Enter. When User presses Esc the commmit amend is canceled.
Amend action is available under key "m"
- in Files in first line there is 2 staged, 2 unstaged - add there deletions and insertions
- Add stash action ..
- Add (commit) summary view (just changes across files with 5 lines before and after change?)
- visual bell blinking time setting
- When reviewing changes in python files the deleted and inserted lines have red and green background but the text on these lines has no syntax highlighting (it's white on darker theme). How difficult would be to add syntax highlighting to changed lines and keep red and green background?
(tady full syntax coloring je hard, nabizi se barevnej gutter, nebo barevne podtrzeni)
- v detailu komitu when pressing n and p the second line is nonsence
- add type hints
- add tags into commits list (and branches?)
- commit messages are oneline. What are options for multiline editing?

View File

@@ -32,7 +32,7 @@ class Region:
return (self.a, self.b) == (other.a, other.b) return (self.a, self.b) == (other.a, other.b)
def __repr__(self): def __repr__(self):
return "Region({}, {})".format(self.a, self.b) return f"Region({self.a}, {self.b})"
class Settings(dict): class Settings(dict):
@@ -84,10 +84,12 @@ class Phantom:
class Window: class Window:
"""Captures the quick panel so tests can drive its callbacks""" """Captures the quick panel and the input panel so tests can drive their callbacks"""
def __init__(self): def __init__(self):
self.panel = None self.panel = None
self.input = None
self.input_view = None
self.commands = [] self.commands = []
def focus_view(self, view): def focus_view(self, view):
@@ -99,6 +101,13 @@ class Window:
def show_quick_panel(self, items, on_select, flags=0, selected=-1, on_highlight=None, placeholder=None): def show_quick_panel(self, items, on_select, flags=0, selected=-1, on_highlight=None, placeholder=None):
self.panel = (items, on_select, selected, on_highlight, placeholder) self.panel = (items, on_select, selected, on_highlight, placeholder)
def show_input_panel(self, caption, initial_text, on_done, on_change, on_cancel):
self.input = (caption, initial_text, on_done, on_change, on_cancel)
self.input_view = View(initial_text)
# the real panel hands its prefill back selected, which is why callers clear the selection
self.input_view.sel().add(Region(0, len(initial_text)))
return self.input_view
class View: class View:
"""Text buffer with just the calls render.py makes""" """Text buffer with just the calls render.py makes"""
@@ -231,7 +240,13 @@ def find_syntax_for_file(path, first_line=""):
def ui_info(): def ui_info():
return {"system": {"style": "dark"}, "color_scheme": {"value": "Mariana.sublime-color-scheme", "resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme"}} return {
"system": {"style": "dark"},
"color_scheme": {
"value": "Mariana.sublime-color-scheme",
"resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme",
},
}
def decode_value(text): def decode_value(text):

View File

@@ -1,16 +1,38 @@
import pytest
import sublime # the stub, via conftest import sublime # the stub, via conftest
from vdiff import commands, git, state from vdiff import commands, git, state, utils
def change(path, status="M"): def change(path, status="M"):
return git.FileChange(path, status, False, 1, 1, None) return git.FileChange(path, status, False, 1, 1, None)
def flagged(*flags):
"""f0.py, f1.py ... with the given staged flags"""
return [git.FileChange(f"f{i}.py", "M", flag, 1, 1, None) for i, flag in enumerate(flags)]
def option(monkeypatch, name, value):
"""Set a plugin setting for one test; the stub hands out one Settings dict per file"""
monkeypatch.setitem(sublime.load_settings(utils.SETTINGS_FILE), name, value)
def flush_previews(): def flush_previews():
"""The stub runs set_timeout synchronously, so previews are already shown""" """The stub runs set_timeout synchronously, so previews are already shown"""
def at_change(blocks, row):
"""A rendered view with `blocks` as its changes and the caret on `row`"""
view = sublime.View()
st = state.ViewState("/repo")
st.blocks = blocks
view.text = "\n" + "\n".join(f"l{i}" for i in range(20))
view.sel().add(sublime.Region(commands.render.index_point(view, row)))
return view, st
def test_esc_restores_previous_file_and_mode(monkeypatch): def test_esc_restores_previous_file_and_mode(monkeypatch):
shown = [] shown = []
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append((st.sha, c.path))) monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append((st.sha, c.path)))
@@ -81,18 +103,15 @@ def test_on_cancel_goes_back_to_commit_list(monkeypatch):
assert back == [1] and st.sha is None assert back == [1] and st.sha is None
def test_step_change_uses_blocks(): def test_step_change_uses_blocks(rings):
view = sublime.View() view, st = at_change([(2, 4), (7, 8)], 0)
st = state.ViewState("/repo")
st.blocks = [(2, 4), (7, 8)]
view.text = "\n" + "\n".join("l{}".format(i) for i in range(10))
view.sel().add(sublime.Region(commands.render.index_point(view, 0)))
commands.ACTIONS["next_change"](view, None, st) commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 2 assert commands.render.caret_index(view) == 2
commands.ACTIONS["next_change"](view, None, st) commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 7 assert commands.render.caret_index(view) == 7
commands.ACTIONS["prev_change"](view, None, st) commands.ACTIONS["prev_change"](view, None, st)
assert commands.render.caret_index(view) == 2 # stepping inside the list stays silent
assert commands.render.caret_index(view) == 2 and rings == []
def test_file_items_annotations(): def test_file_items_annotations():
@@ -135,3 +154,432 @@ def test_run_async_reports_generic_error(monkeypatch):
commands.run_async(boom, lambda r: messages.append("done")) commands.run_async(boom, lambda r: messages.append("done"))
assert messages == ["vDiff: boom"] assert messages == ["vDiff: boom"]
def test_j_and_k_move_the_caret_one_line():
view = sublime.View()
commands.ACTIONS["line_down"](view, None, None)
commands.ACTIONS["line_up"](view, None, None)
assert view.commands == [
("move", {"by": "lines", "forward": True}),
("move", {"by": "lines", "forward": False}),
]
def staged_state(*flags):
st = state.ViewState("/repo")
st.files = flagged(*flags)
return st
def test_commit_needs_a_staged_file(monkeypatch):
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
window = sublime.Window()
commands.ACTIONS["commit"](sublime.View(), window, staged_state(False, False))
assert messages == ["vDiff: no staged files"] and window.input is None
# a commit under review has nothing to stage in the first place
st = staged_state(True)
st.enter("abc", None)
commands.ACTIONS["commit"](sublime.View(), window, st)
assert messages[-1] == "vDiff: commit works on uncommitted changes only" and window.input is None
def test_commit_prompts_then_commits(monkeypatch):
committed = []
refreshed = []
monkeypatch.setattr(commands.git, "commit", lambda repo, message: committed.append((repo, message)))
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
window = sublime.Window()
commands.ACTIONS["commit"](sublime.View(), window, staged_state(True, False, True))
caption, initial, on_done, _, _ = window.input
assert caption == "Commit message" and initial == "" and committed == []
on_done(" ") # an empty message is not a commit
assert committed == []
on_done("real message")
assert committed == [("/repo", "real message")]
assert refreshed == ["committed 2 files"]
def prompt_amend(monkeypatch, st, head="last subject"):
"""Press m; git reports `head` as HEAD's message. Returns (window, amended, refreshed)"""
amended, refreshed = [], []
monkeypatch.setattr(commands.git, "head_message", lambda repo: head)
monkeypatch.setattr(
commands.git, "amend", lambda repo, message, allow_empty=False: amended.append((repo, message, allow_empty))
)
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
window = sublime.Window()
commands.ACTIONS["amend"](sublime.View(), window, st)
return window, amended, refreshed
def test_amend_refuses_commit_mode(monkeypatch):
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
st = staged_state(True)
st.enter("abc", None)
window, amended, _ = prompt_amend(monkeypatch, st)
assert messages[-1] == "vDiff: amend works on uncommitted changes only"
assert window.input is None and amended == []
def test_amend_prefills_the_subject_with_the_caret_at_its_end(monkeypatch):
window, amended, _ = prompt_amend(monkeypatch, staged_state(True), head="fix the parser")
caption, initial, _, _, on_cancel = window.input
assert caption == "Amend message" and initial == "fix the parser" and amended == []
# the panel selects its prefill; the caret must sit after it, ready to type
assert list(window.input_view.sel()) == [sublime.Region(len("fix the parser"))]
# Esc is a no-op: there is no cancel callback to run
assert on_cancel is None
def test_amend_offers_the_subject_and_keeps_the_rest_verbatim(monkeypatch):
head = "typo hree\n\nCloses #12.\n\n- a\n- b"
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(True, False, True), head=head)
assert window.input[1] == "typo hree" # only the first line is offered for editing
window.input[2]("typo here")
# everything below the first line comes back untouched, and staged files keep git's guard
assert amended == [("/repo", "typo here" + head[len("typo hree") :], False)]
assert refreshed == ["amended the last commit with 2 files"]
def test_amend_rewords_with_nothing_staged(monkeypatch):
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(False, False))
window.input[2]("just a reword")
# a reword alone opts out of git's refusal, so an empty commit can still be renamed
assert amended == [("/repo", "just a reword", True)]
assert refreshed == ["amended the last commit"]
def test_amend_with_an_empty_message_does_nothing(monkeypatch):
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(True))
window.input[2](" ")
assert amended == [] and refreshed == []
assert messages[-1] == "vDiff: amend cancelled: empty message"
def test_commit_message_counts_one_file(monkeypatch):
refreshed = []
monkeypatch.setattr(commands.git, "commit", lambda repo, message: None)
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
window = sublime.Window()
commands.ACTIONS["commit"](sublime.View(), window, staged_state(True, False))
window.input[2]("one")
assert refreshed == ["committed 1 file"]
def commit(subject, files=1, insertions=1, deletions=0, short="abc1234"):
return git.Commit("a" * 40, short, "Ann", "2 days ago", subject, files, insertions, deletions)
def test_commit_items_show_totals_before_the_hash():
plural, single, merge = commands.commit_items(
[
commit("fix the parser", 23, 210, 0, "fda1368"),
commit("tweak", 1, 1, 2, "d45a250"),
commit("merge branch 'side'", None, None, None, "beef123"),
]
)
assert plural.annotation == "23 files changed -0 +210 fda1368"
assert single.annotation == "1 file changed -2 +1 d45a250"
# no diffstat for a merge, so the row falls back to the hash alone
assert merge.annotation == "beef123"
assert plural.trigger == "fix the parser" and plural.details == "Ann · 2 days ago"
def test_long_subject_is_clipped_to_clear_the_annotation():
long_subject = "a subject that runs on and on " * 6
item = commands.commit_items([commit(long_subject, 3, 9, 4)])[0]
assert item.trigger.endswith("..") and item.trigger[:-2] == long_subject[: len(item.trigger) - 2]
# the clipped subject plus the annotation fit the row, leaving the totals and hash readable
assert len(item.trigger) + len(item.annotation) <= commands.COMMIT_ROW_WIDTH
# a subject that already fits is left alone
assert commands.commit_items([commit("short one")])[0].trigger == "short one"
def test_commit_items_keep_a_readable_subject_when_the_annotation_is_huge():
# an annotation long enough to eat the whole row: the subject still keeps its floor
item = commands.commit_items([commit("x" * 200, 999999, 999999, 999999, "a" * 90)])[0]
assert len(item.trigger) == commands.MIN_SUBJECT_WIDTH and item.trigger.endswith("..")
def test_commit_items_fall_back_for_an_empty_subject():
assert commands.commit_items([commit("")])[0].trigger == "(no subject)"
# --- navigating while a diff is still loading -------------------------------
@pytest.fixture
def loading(monkeypatch):
"""Queue run_async instead of running it inline, so a test can act mid-load like a user can
The stub runs set_timeout synchronously, which is why the real plugin's async window - the one
n / p used to read stale state through - is invisible to every other test in the suite.
"""
queue = []
monkeypatch.setattr(commands, "run_async", lambda work, done: queue.append((work, done)))
monkeypatch.setattr(commands.render, "render", lambda view, st: None)
monkeypatch.setattr(commands.git, "full_diff", lambda *a, **k: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n")
def flush():
while queue:
work, done = queue.pop(0)
done(work())
return queue, flush
def commit_panel(files):
"""A review view showing a commit, its file panel open, nothing loaded yet"""
view, window = sublime.View(), sublime.Window()
st = state.ViewState("/repo")
st.enter("abc", git.CommitInfo("abc", "abc1234", "A", "d", "s", ""))
commands.show_files_panel(window, view, st, files, st.kept(), placeholder="c")
return view, window, st
def numbered(count=5):
return flagged(*(False,) * count)
def requested_path(queue):
"""Path of the file the newest queued load will fetch"""
return queue[-1][0].__closure__[0].cell_contents.path
def test_next_file_steps_from_the_file_being_loaded(loading):
queue, _ = loading
files = numbered()
view, window, st = commit_panel(files)
window.panel[1](2) # Enter on the third file; its diff has not arrived yet
assert st.change is None
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f3.py"
def test_prev_file_works_before_the_diff_arrives(loading, monkeypatch):
queue, _ = loading
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
files = numbered()
view, window, st = commit_panel(files)
window.panel[1](2)
commands.ACTIONS["prev_file"](view, window, st)
assert requested_path(queue) == "f1.py" and messages == []
def test_rapid_next_file_presses_each_advance(loading):
queue, flush = loading
files = numbered()
view, window, st = commit_panel(files)
window.panel[1](1)
for _ in range(3):
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f4.py"
flush()
assert st.change.path == "f4.py"
def test_esc_rewinds_the_file_navigation_counts_from(loading):
queue, flush = loading
files = numbered()
view, window, st = commit_panel(files)
window.panel[1](3)
flush()
kept = st.kept()
window.panel[1](0) # preview another file, then back out
st.restore(kept)
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f4.py"
# --- 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()
st = state.ViewState("/repo")
st.files = files
st.change = st.requested = next(c for c in files if c.path == path)
return view, window, st
def test_next_file_wraps_past_the_last(loading, rings):
queue, _ = loading
files = numbered()
view, window, st = at_file(files, "f4.py")
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f0.py" and len(rings) == 1
def test_prev_file_wraps_before_the_first(loading, rings):
queue, _ = loading
files = numbered()
view, window, st = at_file(files, "f0.py")
commands.ACTIONS["prev_file"](view, window, st)
assert requested_path(queue) == "f4.py" and len(rings) == 1
def test_stepping_inside_the_list_does_not_ring(loading, rings):
queue, _ = loading
files = numbered()
view, window, st = at_file(files, "f2.py")
commands.ACTIONS["next_file"](view, window, st)
commands.ACTIONS["prev_file"](view, window, st)
assert requested_path(queue) == "f2.py" and rings == []
def test_single_file_list_rings_without_refetching(loading, rings):
queue, _ = loading
files = numbered(1)
view, window, st = at_file(files, "f0.py")
commands.ACTIONS["next_file"](view, window, st)
commands.ACTIONS["prev_file"](view, window, st)
# it rings both times, but never re-runs git for the file already on screen
assert len(rings) == 2 and queue == []
def test_first_step_without_a_shown_file_picks_an_end(loading, rings):
queue, _ = loading
files = numbered()
view, window, st = commit_panel(files) # opening a commit leaves nothing shown
assert st.index() == -1
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f0.py"
st.requested = None
commands.ACTIONS["prev_file"](view, window, st)
assert requested_path(queue) == "f4.py"
# neither is a wrap: there was no position to run past
assert rings == []
def test_next_change_wraps_past_the_last(rings):
view, st = at_change([(2, 4), (7, 8)], 7)
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 2 and len(rings) == 1
def test_prev_change_wraps_before_the_first(rings):
view, st = at_change([(2, 4), (7, 8)], 2)
commands.ACTIONS["prev_change"](view, None, st)
assert commands.render.caret_index(view) == 7 and len(rings) == 1
def test_single_change_rings_and_stays_put(rings):
view, st = at_change([(2, 4)], 2)
commands.ACTIONS["next_change"](view, None, st)
commands.ACTIONS["prev_change"](view, None, st)
assert commands.render.caret_index(view) == 2 and len(rings) == 2
def test_a_file_without_changes_neither_moves_nor_rings(rings):
view, st = at_change([], 3)
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 3 and rings == []
# --- staging moves on to the next unstaged file -----------------------------
def press(monkeypatch, action, before, path, after):
"""Run `action` on `path` of `before`; git then reports `after`. Returns the paths shown
ACTIONS captured git.stage and friends when the table was built, so the subprocess is stubbed
one level down, at git.run; that keeps the real wiring of the action under test.
"""
shown = []
monkeypatch.setattr(commands.git, "run", lambda repo, args, ok_codes=(0,): "")
monkeypatch.setattr(commands.git, "changed_files", lambda repo: after)
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(c.path))
view, window, st = at_file(before, path)
commands.ACTIONS[action](view, window, st)
return shown
def test_next_unstaged_scans_forward_and_wraps():
files = flagged(False, True, False, True)
assert commands._next_unstaged(files, 1) == 2
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
def test_staging_moves_to_the_next_unstaged_file(monkeypatch):
shown = press(monkeypatch, "stage", flagged(True, False, True, False), "f1.py", flagged(True, True, True, False))
assert shown == ["f3.py"]
def test_staging_wraps_to_an_unstaged_file_above(monkeypatch):
shown = press(monkeypatch, "stage", flagged(False, True, False), "f2.py", flagged(False, True, True))
assert shown == ["f0.py"]
def test_staging_the_last_unstaged_file_stays_put(monkeypatch):
shown = press(monkeypatch, "stage", flagged(True, False), "f1.py", flagged(True, True))
assert shown == ["f1.py"]
def test_staging_a_file_that_leaves_the_list_counts_from_its_slot(monkeypatch):
# staging can make the entry vanish (the change was identical to HEAD); f2.py takes the slot
before = flagged(True, False, False)
after = [before[0], before[2]]
shown = press(monkeypatch, "stage", before, "f1.py", after)
assert shown == ["f2.py"]
def test_staging_does_not_move_when_the_option_is_off(monkeypatch):
option(monkeypatch, "stage_and_move_next_file", False)
shown = press(monkeypatch, "stage", flagged(True, False, False), "f1.py", flagged(True, True, False))
assert shown == ["f1.py"]
def test_unstage_and_discard_do_not_move(monkeypatch):
option(monkeypatch, "confirm_discard", False)
files = flagged(True, True, False)
assert press(monkeypatch, "unstage", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
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))
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
assert messages == ["No uncommitted changes in /repo"]

View File

@@ -109,6 +109,37 @@ def test_commits(repo):
assert info.sha == commits[0].sha assert info.sha == commits[0].sha
def test_recent_commits_carry_shortstat_totals(repo):
write(repo, "new.py", "one\ntwo\n")
sh(repo, "add", "-A")
sh(repo, "commit", "-q", "-m", "added a file")
write(repo, "a.py", "x\n") # 6 lines become 1
write(repo, "b.txt", "y\n") # 1 line becomes 1
sh(repo, "commit", "-q", "-am", "changed two files")
newest, added, initial = git.recent_commits(repo, 3)
assert (newest.files, newest.insertions, newest.deletions) == (2, 2, 7)
# a pure addition: git omits the deletions clause entirely, which reads as zero, not unknown
assert (added.files, added.insertions, added.deletions) == (1, 2, 0)
assert initial.subject == "initial" and initial.files == 3
def test_commits_without_a_diffstat_report_no_totals(repo):
sh(repo, "commit", "-q", "--allow-empty", "-m", "empty")
sh(repo, "checkout", "-q", "-b", "side")
write(repo, "s.py", "s\n")
sh(repo, "add", "-A")
sh(repo, "commit", "-q", "-m", "on the side")
sh(repo, "checkout", "-q", "main")
sh(repo, "merge", "-q", "--no-ff", "side", "-m", "merge side")
totals = {c.subject: (c.files, c.insertions, c.deletions) for c in git.recent_commits(repo, 5)}
# git log prints no diffstat for either, so the totals stay unknown rather than a false zero
assert totals["merge side"] == (None, None, None)
assert totals["empty"] == (None, None, None)
assert totals["on the side"] == (1, 1, 0)
def test_no_head_repo(tmp_path): def test_no_head_repo(tmp_path):
repo = str(tmp_path) repo = str(tmp_path)
sh(repo, "init", "-q") sh(repo, "init", "-q")
@@ -146,3 +177,95 @@ def test_count_lines(tmp_path):
assert git._count_lines(p) is None assert git._count_lines(p) is None
open(p, "wb").write(b"") open(p, "wb").write(b"")
assert git._count_lines(p) == 0 assert git._count_lines(p) == 0
def test_commit_only_records_staged_files(repo):
write(repo, "a.py", "staged\n")
write(repo, "b.txt", "left alone\n")
changes = {c.path: c for c in git.changed_files(repo)}
git.stage(repo, changes["a.py"])
git.commit(repo, "a message with 'quotes' and \"doubles\"")
left = {c.path: c for c in git.changed_files(repo)}
assert "a.py" not in left and not left["b.txt"].staged
info, files = git.commit_with_files(repo, "HEAD")
# the message reaches git verbatim: it is one argv element, never a shell string
assert info.subject == "a message with 'quotes' and \"doubles\""
assert [f.path for f in files] == ["a.py"]
def test_commit_without_staged_files_raises(repo):
write(repo, "a.py", "unstaged only\n")
with pytest.raises(git.GitError) as info:
git.commit(repo, "nothing to record")
assert info.value.code != 0
def message(repo):
"""HEAD's raw message, as git stores it"""
return git.run(repo, ["log", "-1", "--format=%B"]).strip("\n")
def test_head_message_returns_the_whole_message(repo):
assert git.head_message(repo) == "initial"
sh(repo, "commit", "-q", "--allow-empty", "-m", "second", "-m", "body text\nsecond line")
assert git.head_message(repo) == "second\n\nbody text\nsecond line"
def test_amend_keeps_everything_below_the_first_line_byte_for_byte(repo):
body = "Closes #12.\n\n- a\n- b"
sh(repo, "commit", "-q", "--allow-empty", "-m", "typo hree", "-m", body)
before = git.run(repo, ["rev-list", "--count", "HEAD"])
_, newline, rest = git.head_message(repo).partition("\n")
git.amend(repo, "typo here" + newline + rest, allow_empty=True)
# the paragraph the user never touched comes back exactly as git stored it
assert message(repo) == "typo here\n\n" + body
# an amend rewrites HEAD, it does not add a commit
assert git.run(repo, ["rev-list", "--count", "HEAD"]) == before
def test_amend_without_a_body_leaves_a_one_line_message(repo):
git.amend(repo, "reworded")
assert message(repo) == "reworded"
def test_amend_rewords_an_empty_commit(repo):
# git refuses an amend that leaves nothing behind unless --allow-empty says otherwise
sh(repo, "commit", "-q", "--allow-empty", "-m", "marker")
git.amend(repo, "marker, reworded", allow_empty=True)
assert message(repo) == "marker, reworded"
def test_amend_that_would_empty_the_commit_is_refused(repo):
# folding staged files in keeps git's guard: only a reword opts out of it
write(repo, "new.py", "temporary\n")
sh(repo, "add", "new.py")
sh(repo, "commit", "-q", "-m", "add new.py")
sh(repo, "rm", "-q", "new.py")
with pytest.raises(git.GitError):
git.amend(repo, "add new.py")
def test_amend_folds_staged_files_into_head(repo):
write(repo, "a.py", "amended\n")
changes = {c.path: c for c in git.changed_files(repo)}
git.stage(repo, changes["a.py"])
before = git.run(repo, ["rev-list", "--count", "HEAD"])
git.amend(repo, "initial, with a.py")
assert git.run(repo, ["rev-list", "--count", "HEAD"]) == before
assert not git.changed_files(repo)
assert message(repo) == "initial, with a.py"
def test_head_message_without_a_commit_raises(tmp_path):
fresh = str(tmp_path)
sh(fresh, "init", "-q", "-b", "main")
with pytest.raises(git.GitError):
git.head_message(fresh)

View File

@@ -132,10 +132,10 @@ def test_help_popup_lists_every_key():
view, html = help_popup(st) view, html = help_popup(st)
assert view.is_popup_visible() assert view.is_popup_visible()
for group in ("Navigate", "Panels", "This file", "View"): for group in ("Navigate", "Panels", "Actions", "View"):
assert ">{}<".format(group) in html assert f">{group}<" in html
for keys in ("] [", "n p", "o ⏎", "f", "c", "s", "u", "d", "r", "?", "esc"): for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "?", "esc"):
assert ">{}<".format(keys) in html assert f">{keys}<" in html
assert "next / previous change" in html and "open the real file at this line" in html assert "next / previous change" in html and "open the real file at this line" in html
# working-tree mode: nothing dimmed, every single-action row is a link # 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 'class="dim"' not in html and "uncommitted only" not in html
@@ -152,8 +152,9 @@ def test_help_popup_dims_file_actions_in_commit_mode():
_, html = help_popup(st) _, html = help_popup(st)
assert "uncommitted only" in html assert "uncommitted only" in html
assert html.count('class="dim"') == 3 assert html.count('class="dim"') == 5
assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html
assert 'href="commit"' not in html and 'href="amend"' not in html
# everything that still applies stays clickable # everything that still applies stays clickable
assert 'href="files"' in html and 'href="open"' in html and 'href="refresh"' in html assert 'href="files"' in html and 'href="open"' in html and 'href="refresh"' in html
@@ -176,11 +177,15 @@ def test_help_popup_toggles_and_navigates():
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc"} DISPLAY_KEYS = {"enter": "⏎", "escape": "esc"}
def test_keymap_matches_help_table(): def keymap_bindings():
"""The keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Default.sublime-keymap") path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Default.sublime-keymap")
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
bindings = json.load(f) return json.load(f)
def test_keymap_matches_help_table():
"""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) assert all(b["command"] == "vdiff_action" for b in bindings)
bound = {b["args"]["action"] for b in bindings} bound = {b["args"]["action"] for b in bindings}
@@ -192,6 +197,20 @@ def test_keymap_matches_help_table():
assert bound_keys == {token for k in render.KEYS for token in k.keys.split()} assert bound_keys == {token for k in render.KEYS for token in k.keys.split()}
def test_no_binding_yields_to_vintage_command_mode():
"""The review view is read-only, so its letters are vdiff commands in either vim mode
init_view opens the view in command mode; a binding that still carried the old
`command_mode: false` context would go dead there, which is what broke n / p.
"""
guard = {"key": "setting.command_mode", "operand": False}
for binding in keymap_bindings():
assert guard not in binding["context"], binding["keys"]
view = sublime.View()
render.init_view(view)
assert view.settings().get("command_mode") is True
def test_minihtml_stylesheets_reject_unsupported_selectors(): def test_minihtml_stylesheets_reject_unsupported_selectors():
"""minihtml drops the rest of a stylesheet at a selector it cannot parse; these are the forms """minihtml drops the rest of a stylesheet at a selector it cannot parse; these are the forms
that silently broke HELP_CSS, so no stylesheet constant may use them""" that silently broke HELP_CSS, so no stylesheet constant may use them"""
@@ -203,4 +222,103 @@ def test_minihtml_stylesheets_reject_unsupported_selectors():
for rule in css.split("}"): for rule in css.split("}"):
head = rule.split("{")[0].strip() head = rule.split("{")[0].strip()
for selector in filter(None, (s.strip() for s in head.split(","))): for selector in filter(None, (s.strip() for s in head.split(","))):
assert not unsupported.search(selector), "{}: {}".format(name, selector) assert not unsupported.search(selector), f"{name}: {selector}"
# --- header summary line ----------------------------------------------------
def test_summary_counts_staged_and_unstaged_files():
view = sublime.View()
render.init_view(view)
st = make_state()
st.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),
]
render.render(view, st)
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
# the untracked file's whole content counts as insertions, the way git.changed_files records it
assert summary.startswith("1 staged, 2 unstaged · ")
assert '<span class="del">-2</span>' in summary and '<span class="ins">+3</span>' in summary
def test_summary_totals_skip_binary_files_but_still_count_them():
view = sublime.View()
render.init_view(view)
st = make_state()
st.files = [
git.FileChange("a.py", "M", False, 4, 1, None),
git.FileChange("logo.png", "M", False, None, None, None), # binary: no counts, still a file
]
render.render(view, st)
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
assert summary.startswith("0 staged, 2 unstaged · ")
assert '<span class="del">-1</span>' in summary and '<span class="ins">+4</span>' in summary
def test_summary_totals_a_commit_and_owns_the_date():
view = sublime.View()
render.init_view(view)
st = make_state()
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", ""))
st.change = git.FileChange("x.py", "M", False, 3, 2, None)
st.files = [
git.FileChange("x.py", "M", False, 3, 2, None),
git.FileChange("y.py", "M", False, 7, 0, None),
git.FileChange("logo.png", "M", False, None, None, None), # binary: no counts, still a file
]
render.render(view, st)
html = header(view)
# hash, author, date, totals and message all live on the one summary line
summary = html.split('<div class="summary">')[1].split("</div>")[0]
for part in ("abc1234", "Ann", "2026-09-23 10:00", "3 files changed", "-2", "+10", "Fix things"):
assert part in summary, part
# and nowhere else: there is no second commit line any more
assert html.count("abc1234") == 1 and html.count("2026-09-23 10:00") == 1
assert 'class="commit"' not in html
def test_summary_is_omitted_without_a_file_list():
view = sublime.View()
render.init_view(view)
st = make_state()
render.render(view, st)
assert 'class="summary"' not in header(view)
def test_flash_tints_the_buffer_then_clears(monkeypatch):
view = sublime.View("some text")
timers = []
monkeypatch.setattr(render.sublime, "set_timeout", lambda fn, ms=0: timers.append((fn, ms)))
render.flash(view)
regions, scope, _, flags = view.regions[render.BELL_KEY]
assert regions == [sublime.Region(0, len(view.text))] and flags == sublime.DRAW_NO_OUTLINE
assert scope and timers[0][1] == render.BELL_MS
timers[0][0]()
assert render.BELL_KEY not in view.regions
def test_flash_survives_a_re_render(monkeypatch):
"""The wrap that rings also re-renders; _replace must not wipe the bell before it is seen"""
assert render.BELL_KEY not in render.REGION_KEYS
monkeypatch.setattr(render.sublime, "set_timeout", lambda fn, ms=0: None) # hold the bell up
view = sublime.View()
render.init_view(view)
render.flash(view)
render.render(view, make_state())
assert render.BELL_KEY in view.regions
def test_bell_scope_falls_back_when_the_scheme_lacks_it(monkeypatch):
view = sublime.View()
assert render._bell_scope(view) == render.SCOPE_BELL
monkeypatch.setattr(view, "style_for_scope", lambda scope: {"foreground": "#fff"})
# an invisible bell is a silent failure, so fall back to a scope the plugin guarantees
assert render._bell_scope(view) == render.SCOPE_INS

View File

@@ -5,6 +5,9 @@
// ask before discarding a file's changes // ask before discarding a file's changes
"confirm_discard": true, "confirm_discard": true,
// after staging a file, move on to the next unstaged file of the list
"stage_and_move_next_file": true,
// mark the changed characters inside paired deleted/inserted lines // mark the changed characters inside paired deleted/inserted lines
"word_diff": true, "word_diff": true,

View File

@@ -6,7 +6,7 @@ import sublime
import sublime_plugin import sublime_plugin
from . import git, parse, render, state from . import git, parse, render, state
from .utils import run_async, settings, status from .utils import file_count, run_async, settings, status
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES # quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES
STATUS_COLOURS = { STATUS_COLOURS = {
@@ -21,6 +21,11 @@ STATUS_COLOURS = {
} }
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit") COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
# a quick-panel row draws the annotation hard right on the subject's line; past this budget the
# subject would run into it, so it is clipped. The floor keeps a readable stub on a narrow panel.
COMMIT_ROW_WIDTH = 120
MIN_SUBJECT_WIDTH = 24
# highlight-to-preview delay: holding an arrow key must not spawn a git process per row # highlight-to-preview delay: holding an arrow key must not spawn a git process per row
PREVIEW_DELAY_MS = 60 PREVIEW_DELAY_MS = 60
@@ -79,6 +84,8 @@ def open_review(window):
def show_file(view, st, change): def show_file(view, st, change):
"""Fetch and parse the file's diff on the worker thread, then render it""" """Fetch and parse the file's diff on the worker thread, then render it"""
st.token += 1 st.token += 1
# set before the worker starts: n / p count from here while the diff is still loading
st.requested = change
token = st.token token = st.token
repo, sha = st.repo, st.sha repo, sha = st.repo, st.sha
@@ -113,11 +120,27 @@ def file_items(files):
if None in (change.insertions, change.deletions): if None in (change.insertions, change.deletions):
annotation = "binary" annotation = "binary"
else: else:
annotation = "-{} +{}".format(change.deletions, change.insertions) annotation = f"-{change.deletions} +{change.insertions}"
items.append(sublime.QuickPanelItem(change.path, details, annotation, kind)) items.append(sublime.QuickPanelItem(change.path, details, annotation, kind))
return items return items
def commit_items(commits):
"""Quick-panel rows: subject on the left, its totals and the hash right-aligned"""
items = []
for c in commits:
annotation = c.short
if c.files is not None:
# merges and empty commits have no diffstat, so they show the hash alone
annotation = f"{file_count(c.files)} changed -{c.deletions} +{c.insertions} {c.short}"
subject = c.subject or "(no subject)"
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
if len(subject) > budget:
subject = subject[: budget - 2] + ".."
items.append(sublime.QuickPanelItem(subject, f"{c.author} · {c.date}", annotation, COMMIT_KIND))
return items
def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder="Changed files"): def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder="Changed files"):
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)""" """Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
st.files = files st.files = files
@@ -151,8 +174,12 @@ def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder=
window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder) window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)
def refresh_listing(view, st, message=None): def refresh_listing(view, st, message=None, pick=None):
"""Re-list the working tree changes and show the same file, or the next one when it is gone""" """Re-list the working tree changes and show the same file, or the next one when it is gone
`pick(files, index)` may name another file to show instead; `index` is where the shown file
sits in the new list, or the slot it left behind when it is gone.
"""
if message: if message:
status(message) status(message)
previous_index = max(0, st.index()) previous_index = max(0, st.index())
@@ -162,13 +189,15 @@ def refresh_listing(view, st, message=None):
if not view.is_valid(): if not view.is_valid():
return return
st.files = files st.files = files
if not files:
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
return
index = state.find_change(files, path) index = state.find_change(files, path)
if index >= 0: if index < 0:
show_file(view, st, files[index]) # the file left the list: whatever moved up into its slot takes over
elif files: index = min(previous_index, len(files) - 1)
show_file(view, st, files[min(previous_index, len(files) - 1)]) chosen = pick(files, index) if pick else None
else: show_file(view, st, chosen if chosen is not None else files[index])
render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
run_async(lambda: git.changed_files(st.repo), done) run_async(lambda: git.changed_files(st.repo), done)
@@ -196,10 +225,10 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
return return
kept = st.enter() kept = st.enter()
if not files: if not files:
render.render_message(view, st, "No uncommitted changes in {}".format(st.repo)) render.render_message(view, st, f"No uncommitted changes in {st.repo}")
status("no uncommitted changes") status("no uncommitted changes")
return return
placeholder = "Changed files in {}".format(os.path.basename(st.repo)) placeholder = f"Changed files in {os.path.basename(st.repo)}"
show_files_panel(window, view, st, files, kept, placeholder=placeholder) show_files_panel(window, view, st, files, kept, placeholder=placeholder)
run_async(lambda: git.changed_files(st.repo), done) run_async(lambda: git.changed_files(st.repo), done)
@@ -229,10 +258,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
run_async(lambda: git.recent_commits(st.repo, count), done) run_async(lambda: git.recent_commits(st.repo, count), done)
def show_commits(self, view, st, commits, selected): def show_commits(self, view, st, commits, selected):
items = [ items = commit_items(commits)
sublime.QuickPanelItem(c.subject or "(no subject)", "{} · {}".format(c.author, c.date), c.short, COMMIT_KIND)
for c in commits
]
for i, c in enumerate(commits): for i, c in enumerate(commits):
if c.sha == st.sha: if c.sha == st.sha:
selected = i selected = i
@@ -253,13 +279,13 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
info, files = result info, files = result
kept = st.enter(info.sha, info) kept = st.enter(info.sha, info)
if not files: if not files:
render.render_message(view, st, "Commit {} touches no files".format(info.short)) render.render_message(view, st, f"Commit {info.short} touches no files")
return return
def back(): def back():
window.run_command("vdiff_commits", {"selected": index}) window.run_command("vdiff_commits", {"selected": index})
placeholder = "{} · {}".format(info.short, info.subject) placeholder = f"{info.short} · {info.subject}"
on_cancel = back if index is not None else None on_cancel = back if index is not None else None
show_files_panel(window, view, st, files, kept, on_cancel=on_cancel, placeholder=placeholder) show_files_panel(window, view, st, files, kept, on_cancel=on_cancel, placeholder=placeholder)
@@ -285,17 +311,23 @@ def _act_help(view, window, st):
render.show_help(view, st) render.show_help(view, st)
def _git_action(verb, done_verb, operation, confirm=None): def _git_action(verb, done_verb, operation, confirm=None, pick=None):
"""Action running `operation(repo, change)` on the shown working-tree file, then re-listing""" """Action running `operation(repo, change)` on the shown working-tree file, then re-listing
`pick` is handed to refresh_listing to choose which file to show afterwards.
"""
def act(view, window, st): def act(view, window, st):
if st.in_commit or st.change is None: if st.in_commit or st.change is None:
status("{} works on uncommitted changes only".format(verb)) status(f"{verb} works on uncommitted changes only")
return return
change = st.change change = st.change
if confirm is not None and not confirm(change): if confirm is not None and not confirm(change):
return return
run_async(lambda: operation(st.repo, change), lambda _: refresh_listing(view, st, "{} {}".format(done_verb, change.path))) run_async(
lambda: operation(st.repo, change),
lambda _: refresh_listing(view, st, f"{done_verb} {change.path}", pick=pick),
)
return act return act
@@ -304,12 +336,33 @@ def _confirm_discard(change):
if not settings().get("confirm_discard", True): if not settings().get("confirm_discard", True):
return True return True
if change.status == "?": if change.status == "?":
message = "Delete the untracked file {}?\n\nThis cannot be undone.".format(change.path) message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
else: else:
message = "Discard all changes to {}?\n\nThe file goes back to its last committed version. This cannot be undone.".format(change.path) message = (
f"Discard all changes to {change.path}?\n\n"
"The file goes back to its last committed version. This cannot be undone."
)
return sublime.ok_cancel_dialog(message, "Discard") return sublime.ok_cancel_dialog(message, "Discard")
def _next_unstaged(files, start):
"""Index of the first unstaged file from `start` on, wrapping round; -1 when every file is staged"""
for offset in range(len(files)):
index = (start + offset) % len(files)
if not files[index].staged:
return index
return -1
def _stage_next(files, index):
"""After staging, the next unstaged file to show; None keeps the file the refresh would show"""
if not settings().get("stage_and_move_next_file", True):
return None
# the file just staged is staged now, so a scan starting on it moves off by itself
following = _next_unstaged(files, index)
return files[following] if following >= 0 else None
def _act_refresh(view, window, st): def _act_refresh(view, window, st):
if st.change is None: if st.change is None:
return return
@@ -324,10 +377,10 @@ def _act_open(view, window, st):
return return
path = os.path.join(st.repo, st.change.path) path = os.path.join(st.repo, st.change.path)
if not os.path.exists(path): if not os.path.exists(path):
status("{} does not exist in the working tree".format(st.change.path)) status(f"{st.change.path} does not exist in the working tree")
return return
line = parse.line_for_open(st.lines, render.caret_index(view)) line = parse.line_for_open(st.lines, render.caret_index(view))
window.open_file("{}:{}".format(path, line), sublime.ENCODED_POSITION) window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
def _step_file(delta): def _step_file(delta):
@@ -335,10 +388,19 @@ def _step_file(delta):
if not st.files: if not st.files:
status("no file list, press f or c") status("no file list, press f or c")
return return
index = st.index() + delta count = len(st.files)
if not 0 <= index < len(st.files): position = st.index()
status("no more files") if position < 0:
return # nothing shown yet: n starts at the first file, p at the last. Not a wrap.
index, wrapped = (0 if delta > 0 else count - 1), False
else:
index = position + delta
wrapped = not 0 <= index < count
index %= count # -1 is the last file, count is the first
if wrapped:
render.flash(view)
if index != position:
# a one-file list wraps onto itself: ring, but do not re-run git for the same file
show_file(view, st, st.files[index]) show_file(view, st, st.files[index])
return act return act
@@ -347,11 +409,14 @@ def _step_file(delta):
def _step_change(delta): def _step_change(delta):
def act(view, window, st): def act(view, window, st):
starts = [start for start, _ in st.blocks] starts = [start for start, _ in st.blocks]
if not starts:
status("no changes to step through")
return
row = render.caret_index(view) row = render.caret_index(view)
position = bisect.bisect_right(starts, row) if delta > 0 else bisect.bisect_left(starts, row) - 1 position = bisect.bisect_right(starts, row) if delta > 0 else bisect.bisect_left(starts, row) - 1
if not 0 <= position < len(starts): if not 0 <= position < len(starts):
status("no more changes") render.flash(view)
return position %= len(starts) # -1 is the last change, len(starts) the first
point = render.index_point(view, starts[position]) point = render.index_point(view, starts[position])
view.sel().clear() view.sel().clear()
view.sel().add(sublime.Region(point)) view.sel().add(sublime.Region(point))
@@ -360,20 +425,88 @@ def _step_change(delta):
return act return act
def _act_commit(view, window, st):
"""Commit the staged files with a message typed into an input panel; Esc there cancels"""
if st.in_commit:
status("commit works on uncommitted changes only")
return
staged = [c for c in st.files if c.staged]
if not staged:
status("no staged files")
return
def on_done(message):
if not message.strip():
status("commit cancelled: empty message")
return
run_async(
lambda: git.commit(st.repo, message),
lambda _: refresh_listing(view, st, f"committed {file_count(len(staged))}"),
)
window.show_input_panel("Commit message", "", on_done, None, None)
def _act_amend(view, window, st):
"""Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels
The panel is one line, so only the subject is offered; the rest of the message rides along
verbatim. Nothing staged is fine - then the amend is a pure reword.
"""
if st.in_commit:
status("amend works on uncommitted changes only")
return
staged = [c for c in st.files if c.staged]
def prompt(message):
subject, newline, body = message.partition("\n")
def on_done(text):
if not text.strip():
status("amend cancelled: empty message")
return
done = "amended the last commit"
if staged:
done += f" with {file_count(len(staged))}"
run_async(
# nothing staged makes this a pure reword, which git refuses on an empty commit
lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged),
lambda _: refresh_listing(view, st, done),
)
panel = window.show_input_panel("Amend message", subject, on_done, None, None)
# the panel selects the whole prefill; the caret belongs after it, ready to type
panel.sel().clear()
panel.sel().add(sublime.Region(panel.size()))
run_async(lambda: git.head_message(st.repo), prompt)
def _move_line(forward):
def act(view, window, st):
view.run_command("move", {"by": "lines", "forward": forward})
return act
ACTIONS = { ACTIONS = {
"files": _act_files, "files": _act_files,
"commits": _act_commits, "commits": _act_commits,
"close": _act_close, "close": _act_close,
"help": _act_help, "help": _act_help,
"stage": _git_action("stage", "staged", git.stage), "stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
"unstage": _git_action("unstage", "unstaged", git.unstage), "unstage": _git_action("unstage", "unstaged", git.unstage),
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard), "discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
"commit": _act_commit,
"amend": _act_amend,
"refresh": _act_refresh, "refresh": _act_refresh,
"open": _act_open, "open": _act_open,
"next_file": _step_file(1), "next_file": _step_file(1),
"prev_file": _step_file(-1), "prev_file": _step_file(-1),
"next_change": _step_change(1), "next_change": _step_change(1),
"prev_change": _step_change(-1), "prev_change": _step_change(-1),
"line_down": _move_line(True),
"line_up": _move_line(False),
} }
@@ -386,7 +519,7 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
def run(self, edit, action): def run(self, edit, action):
act = ACTIONS.get(action) act = ACTIONS.get(action)
if act is None: if act is None:
status("unknown action {}".format(action)) status(f"unknown action {action}")
return return
st = state.get(self.view) or restore_view(self.view) st = state.get(self.view) or restore_view(self.view)
act(self.view, self.view.window(), st) act(self.view, self.view.window(), st)
@@ -424,7 +557,7 @@ def restore_view(view):
if index >= 0: if index >= 0:
show_file(view, st, files[index]) show_file(view, st, files[index])
else: else:
render.render_message(view, st, "{} has no uncommitted changes any more".format(path)) render.render_message(view, st, f"{path} has no uncommitted changes any more")
run_async(lambda: git.changed_files(repo), done) run_async(lambda: git.changed_files(repo), done)
return st return st

View File

@@ -1,11 +1,13 @@
""" Git access: every call is one subprocess; results are plain tuples, no sublime imports (testable) """ """ Git access: every call is one subprocess; results are plain tuples, no sublime imports (testable) """
import os import os
import re
import subprocess import subprocess
from collections import namedtuple from collections import namedtuple
# path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source # path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source
FileChange = namedtuple("FileChange", "path status staged insertions deletions old_path") FileChange = namedtuple("FileChange", "path status staged insertions deletions old_path")
Commit = namedtuple("Commit", "sha short author date subject") # shortstat totals; all three are None when git prints no diffstat (merges, empty commits)
Commit = namedtuple("Commit", "sha short author date subject files insertions deletions")
CommitInfo = namedtuple("CommitInfo", "sha short author date subject body") CommitInfo = namedtuple("CommitInfo", "sha short author date subject body")
# hash of the empty tree: the diff base for a repository without commits # hash of the empty tree: the diff base for a repository without commits
@@ -31,6 +33,9 @@ STATUS_NAMES = {
_INFO_FORMAT = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%b" _INFO_FORMAT = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%b"
_DATE_FORMAT = "--date=format:%Y-%m-%d %H:%M" _DATE_FORMAT = "--date=format:%Y-%m-%d %H:%M"
# " 3 files changed, 10 insertions(+), 2 deletions(-)" - either count clause is dropped when zero
_SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?")
class GitError(Exception): class GitError(Exception):
"""git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it""" """git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it"""
@@ -39,15 +44,13 @@ class GitError(Exception):
self.stderr = stderr self.stderr = stderr
self.code = code self.code = code
first = stderr.strip().splitlines() first = stderr.strip().splitlines()
super().__init__(first[0] if first else "git exited with {}".format(code)) super().__init__(first[0] if first else f"git exited with {code}")
def run(repo, args, ok_codes=(0,)): def run(repo, args, ok_codes=(0,)):
"""Run git in `repo`, return stdout as text""" """Run git in `repo`, return stdout as text"""
cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args) cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args)
proc = subprocess.run( proc = subprocess.run(cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace")
cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace"
)
if proc.returncode not in ok_codes: if proc.returncode not in ok_codes:
raise GitError(proc.stderr, proc.returncode) raise GitError(proc.stderr, proc.returncode)
return proc.stdout return proc.stdout
@@ -178,19 +181,25 @@ def recent_commits(repo, count):
"""Last `count` commits, newest first; empty list without commits""" """Last `count` commits, newest first; empty list without commits"""
if not has_head(repo): if not has_head(repo):
return [] return []
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s"]) # --shortstat rides along on the same call: one subprocess, totals for free
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
commits = [] commits = []
for line in text.split("\n"): for line in text.split("\n"):
parts = line.split("\x1f") parts = line.split("\x1f")
if len(parts) == 5: if len(parts) == 5:
commits.append(Commit(*parts)) commits.append(Commit(*parts, files=None, insertions=None, deletions=None))
continue
stat = commits and commits[-1].files is None and _SHORTSTAT.match(line)
if stat:
files, ins, dele = (int(g or 0) for g in stat.groups())
commits[-1] = commits[-1]._replace(files=files, insertions=ins, deletions=dele)
return commits return commits
def commit_with_files(repo, sha): def commit_with_files(repo, sha):
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls""" """(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
show = ["show", "--first-parent", "-m", "-z"] show = ["show", "--first-parent", "-m", "-z"]
text = run(repo, show + ["--format={}%x00".format(_INFO_FORMAT), _DATE_FORMAT, "--name-status", sha]) text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
# git terminates the header with its own NUL and a newline before the file list # git terminates the header with its own NUL and a newline before the file list
info_text, _, files_text = text.partition("\0") info_text, _, files_text = text.partition("\0")
files_text = files_text.lstrip("\0\n") files_text = files_text.lstrip("\0\n")
@@ -211,7 +220,7 @@ def commit_with_files(repo, sha):
def _in_tree(repo, ref, path): def _in_tree(repo, ref, path):
"""Whether `path` exists in the tree of `ref`""" """Whether `path` exists in the tree of `ref`"""
try: try:
run(repo, ["cat-file", "-e", "{}:{}".format(ref, path)]) run(repo, ["cat-file", "-e", f"{ref}:{path}"])
return True return True
except GitError: except GitError:
return False return False
@@ -236,6 +245,25 @@ def stage(repo, change):
run(repo, ["add", "-A", "--", change.path]) run(repo, ["add", "-A", "--", change.path])
def commit(repo, message):
"""Commit what is staged; the message is one argv element, so it needs no quoting of its own"""
run(repo, ["commit", "-m", message])
def head_message(repo):
"""HEAD's whole message, exactly as git stores it"""
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
def amend(repo, message, allow_empty=False):
"""Rewrite HEAD with `message`, folding in whatever is staged
`allow_empty` for a pure reword: git otherwise refuses an amend whose result carries no change,
which would fail on a commit that happens to be empty.
"""
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
def unstage(repo, change): def unstage(repo, change):
"""Move the file's staged changes back to the working tree""" """Move the file's staged changes back to the working tree"""
if has_head(repo): if has_head(repo):

View File

@@ -11,7 +11,7 @@ import sublime_plugin
from . import parse from . import parse
from .git import STATUS_NAMES from .git import STATUS_NAMES
from .state import VIEW_FLAG from .state import VIEW_FLAG
from .utils import settings from .utils import file_count, settings
LOG = logging.getLogger("vdiff") LOG = logging.getLogger("vdiff")
@@ -26,6 +26,11 @@ REGION_KEYS = (KEY_DEL, KEY_INS, KEY_DEL_GUTTER, KEY_INS_GUTTER, KEY_DEL_CHAR, K
PHANTOM_KEY = "vdiff.header" PHANTOM_KEY = "vdiff.header"
STATUS_KEY = "vdiff" STATUS_KEY = "vdiff"
# visual bell: a whole-buffer tint, erased on a timer. Deliberately not in REGION_KEYS, so the
# re-render a wrap triggers does not wipe it before it has been seen.
BELL_KEY = "vdiff.bell"
BELL_MS = 90
# a block phantom anchored at point 0 is drawn below row 0, so row 0 stays empty and the file starts at row 1. # a block phantom anchored at point 0 is drawn below row 0, so row 0 stays empty and the file starts at row 1.
# Only index_point / caret_index know about it. # Only index_point / caret_index know about it.
_HEADER_ROWS = 1 _HEADER_ROWS = 1
@@ -38,6 +43,8 @@ SCOPE_INS_CHAR = "diff.inserted.char"
# gutter dots use the scope's foreground # gutter dots use the scope's foreground
SCOPE_DEL_MARK = "markup.deleted" SCOPE_DEL_MARK = "markup.deleted"
SCOPE_INS_MARK = "markup.inserted" SCOPE_INS_MARK = "markup.inserted"
# ST4 defines region.<colour>ish in every bundled scheme for exactly this kind of transient marking
SCOPE_BELL = "region.yellowish"
# rules written into a colour-scheme override when the active scheme lacks the scopes above # rules written into a colour-scheme override when the active scheme lacks the scopes above
FALLBACK_RULES = [ FALLBACK_RULES = [
@@ -53,30 +60,49 @@ FALLBACK_RULES = [
Key = namedtuple("Key", "keys label short actions") Key = namedtuple("Key", "keys label short actions")
KEY_GROUPS = [ KEY_GROUPS = [
("Navigate", True, [ (
Key("] [", "next / previous change", "change", ("next_change", "prev_change")), "Navigate",
Key("n p", "next / previous file", "file", ("next_file", "prev_file")), True,
[
Key("] [", "next / previous change (wraps)", "change", ("next_change", "prev_change")),
Key("n p", "next / previous file (wraps)", "file", ("next_file", "prev_file")),
Key("o \u23ce", "open the real file at this line", "open", ("open",)), Key("o \u23ce", "open the real file at this line", "open", ("open",)),
]), Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
("Panels", True, [ ],
),
(
"Panels",
True,
[
Key("f", "changed files", "files", ("files",)), Key("f", "changed files", "files", ("files",)),
Key("c", "recent commits", "commits", ("commits",)), Key("c", "recent commits", "commits", ("commits",)),
]), ],
("This file", False, [ ),
(
"Actions",
False,
[
Key("s", "stage", "stage", ("stage",)), Key("s", "stage", "stage", ("stage",)),
Key("u", "unstage", "unstage", ("unstage",)), Key("u", "unstage", "unstage", ("unstage",)),
Key("d", "discard", "discard", ("discard",)), Key("d", "discard", "discard", ("discard",)),
]), Key("C", "commit the staged files", "commit", ("commit",)),
("View", True, [ Key("m", "amend the last commit", "amend", ("amend",)),
],
),
(
"View",
True,
[
Key("r", "refresh", "refresh", ("refresh",)), Key("r", "refresh", "refresh", ("refresh",)),
Key("?", "this list", "keys", ("help",)), Key("?", "this list", "keys", ("help",)),
Key("esc", "close the view", "", ("close",)), Key("esc", "close the view", "", ("close",)),
]), ],
),
] ]
KEYS = [key for _, _, group in KEY_GROUPS for key in group] KEYS = [key for _, _, group in KEY_GROUPS for key in group]
KEY_HINTS = " ".join("{} {}".format(k.keys, k.short) for k in KEYS if k.short) KEY_HINTS = " ".join(f"{k.keys} {k.short}" for k in KEYS if k.short)
# the popup font is the editor's, so the key column is padded to a fixed width in characters # 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) _KEY_WIDTH = max(len(k.keys) for k in KEYS)
@@ -89,10 +115,11 @@ body { margin: 0; padding: 0.5rem 0.8rem; background-color: color(var(--backgrou
.ins { color: var(--greenish); font-weight: bold; } .ins { color: var(--greenish); font-weight: bold; }
.state { color: var(--orangish); } .state { color: var(--orangish); }
.actions { margin-left: 1rem; } .actions { margin-left: 1rem; }
.summary { margin-bottom: 0.3rem; color: color(var(--foreground) alpha(0.75)); }
.when { color: color(var(--foreground) alpha(0.6)); }
a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-radius: 3px; a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-radius: 3px;
background-color: color(var(--foreground) alpha(0.12)); color: var(--foreground); } background-color: color(var(--foreground) alpha(0.12)); color: var(--foreground); }
.danger { color: var(--redish); } .danger { color: var(--redish); }
.commit { margin-top: 0.4rem; color: color(var(--foreground) alpha(0.75)); }
.hash { color: var(--bluish); font-weight: bold; } .hash { color: var(--bluish); font-weight: bold; }
.body { color: color(var(--foreground) alpha(0.6)); } .body { color: color(var(--foreground) alpha(0.6)); }
""" """
@@ -135,6 +162,10 @@ def init_view(view):
s.set("show_definitions", False) s.set("show_definitions", False)
s.set("mini_diff", False) s.set("mini_diff", False)
s.set("spell_check", False) s.set("spell_check", False)
# a read-only viewer, so vim's insert mode is meaningless: open in command mode with the block
# caret. vdiff's own keys carry no command_mode context, so they fire in either mode regardless.
s.set("command_mode", True)
s.set("inverse_caret_state", True)
view.set_scratch(True) view.set_scratch(True)
view.set_read_only(True) view.set_read_only(True)
if not view.name(): if not view.name():
@@ -169,6 +200,23 @@ def _mtime(path):
return None return None
def flash(view):
"""Visual bell: tint the buffer for a moment, for feedback that has nothing to say in words"""
view.add_regions(BELL_KEY, [sublime.Region(0, view.size())], _bell_scope(view), flags=sublime.DRAW_NO_OUTLINE)
# unconditional erase: overlapping flashes may cut one short, but none can leave the tint stuck
sublime.set_timeout(lambda: view.is_valid() and view.erase_regions(BELL_KEY), BELL_MS)
def _bell_scope(view):
"""region.yellowish, or a scope the plugin guarantees a background for when a scheme lacks it"""
try:
if view.style_for_scope(SCOPE_BELL).get("background"):
return SCOPE_BELL
except Exception:
LOG.debug("style_for_scope failed for %s", SCOPE_BELL, exc_info=True)
return SCOPE_INS
def render_message(view, st, message): def render_message(view, st, message):
"""Show a one-line notice instead of a file (nothing to review, binary, ...)""" """Show a one-line notice instead of a file (nothing to review, binary, ...)"""
st.change = None st.change = None
@@ -210,7 +258,7 @@ def render(view, st):
view.add_regions(KEY_INS_CHAR, ins_chars, SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE) view.add_regions(KEY_INS_CHAR, ins_chars, SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE)
ins, dele = parse.counts(lines) ins, dele = parse.counts(lines)
_finish(view, st, ins, dele, "⟐ {}".format(os.path.basename(st.change.path))) _finish(view, st, ins, dele, f"⟐ {os.path.basename(st.change.path)}")
view.sel().clear() view.sel().clear()
view.sel().add(sublime.Region(index_point(view, 0))) view.sel().add(sublime.Region(index_point(view, 0)))
view.set_viewport_position((0, 0), False) view.set_viewport_position((0, 0), False)
@@ -279,20 +327,62 @@ def _char_regions(lines, blocks, starts):
return deleted, inserted return deleted, inserted
def _totals(files):
"""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):
"""Line above the header: the whole commit on one line, or how much of the tree is staged"""
if not st.files and not st.commit:
return ""
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)
text = (
f"{staged} staged, {len(st.files) - staged} unstaged"
f' · <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>'
)
return f'<div class="summary">{text}</div>'
def _commit_parts(st):
"""hash, author, date, totals and message — everything about the reviewed commit, in that order"""
info = st.commit
parts = []
if info:
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
parts.append(html.escape(info.author))
parts.append(f'<span class="when">{html.escape(info.date)}</span>')
# binary files add nothing to the line totals but still count toward the file total
insertions, deletions = _totals(st.files)
parts.append(f"{file_count(len(st.files))} changed" f' <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>')
if info:
message = html.escape(info.subject)
body = info.body.strip().splitlines()
if body:
message += f' <span class="body">— {html.escape(body[0])}</span>'
parts.append(message)
return parts
def header_html(st, ins, dele): def header_html(st, ins, dele):
"""The minihtml header: path, counts, state, action links and the commit line""" """The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
change = st.change change = st.change
if change is None: if change is None:
title = '<span class="path">vDiff</span>' title = '<span class="path">vDiff</span>'
else: else:
title = '<span class="path">{}</span>'.format(html.escape(change.path)) title = f'<span class="path">{html.escape(change.path)}</span>'
if change.old_path: if change.old_path:
title += ' <span class="old">← {}</span>'.format(html.escape(change.old_path)) title += f' <span class="old">← {html.escape(change.old_path)}</span>'
title += '&nbsp;&nbsp;<span class="del">-{}</span> <span class="ins">+{}</span>'.format(dele, ins) title += f'&nbsp;&nbsp;<span class="del">-{dele}</span> <span class="ins">+{ins}</span>'
if not st.in_commit: if not st.in_commit:
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "") state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
if state: if state:
title += '&nbsp;&nbsp;<span class="state">{}</span>'.format(state) title += f'&nbsp;&nbsp;<span class="state">{state}</span>'
actions = [] actions = []
if change is not None and not st.in_commit: if change is not None and not st.in_commit:
@@ -305,19 +395,9 @@ def header_html(st, ins, dele):
actions.append('<a href="commits">Commits</a>') actions.append('<a href="commits">Commits</a>')
actions.append('<a href="help" title="keys">?</a>') actions.append('<a href="help" title="keys">?</a>')
commit = "" return (
if st.in_commit and st.commit: f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{_summary_html(st)}'
info = st.commit f'<div>{title}<span class="actions">{"".join(actions)}</span></div></body>'
commit = '<div class="commit"><span class="hash">{}</span> · {} · {} · {}'.format(
html.escape(info.short), html.escape(info.author), html.escape(info.date), html.escape(info.subject)
)
body = info.body.strip().splitlines()
if body:
commit += ' <span class="body">— {}</span>'.format(html.escape(body[0]))
commit += "</div>"
return '<body id="vdiff-header"><style>{}</style><div>{}<span class="actions">{}</span></div>{}</body>'.format(
HEADER_CSS, title, "".join(actions), commit
) )
@@ -347,17 +427,18 @@ def help_html(st):
heading = html.escape(title) heading = html.escape(title)
if dim: if dim:
heading += ' <span class="note">uncommitted only</span>' heading += ' <span class="note">uncommitted only</span>'
parts.append('<div class="group">{}</div>'.format(heading)) parts.append(f'<div class="group">{heading}</div>')
# one class per element: a compound selector would silently drop the rest of HELP_CSS # one class per element: a compound selector would silently drop the rest of HELP_CSS
row_class, key_class = ("dim", "kdim") if dim else ("row", "k") row_class, key_class = ("dim", "kdim") if dim else ("row", "k")
for key in group: for key in group:
row = '<span class="{}">{}</span>{}{}'.format( row = (
key_class, html.escape(key.keys), "&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2), html.escape(key.label) f'<span class="{key_class}">{html.escape(key.keys)}</span>'
f'{"&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
) )
if len(key.actions) == 1 and not dim: if len(key.actions) == 1 and not dim:
row = '<a href="{}">{}</a>'.format(key.actions[0], row) row = f'<a href="{key.actions[0]}">{row}</a>'
parts.append('<div class="{}">{}</div>'.format(row_class, row)) parts.append(f'<div class="{row_class}">{row}</div>')
return '<body id="vdiff-help"><style>{}</style>{}</body>'.format(HELP_CSS, "".join(parts)) return f'<body id="vdiff-help"><style>{HELP_CSS}</style>{"".join(parts)}</body>'
def show_help(view, st): def show_help(view, st):

View File

@@ -30,6 +30,7 @@ class ViewState:
sha: object = None # None in working-tree mode, the reviewed commit's hash otherwise sha: object = None # None in working-tree mode, the reviewed commit's hash otherwise
commit: object = None # git.CommitInfo when sha is set commit: object = None # git.CommitInfo when sha is set
change: object = None # git.FileChange currently shown change: object = None # git.FileChange currently shown
requested: object = None # file asked for, maybe still loading; navigation counts from here
files: list = field(default_factory=list) # git.FileChange list the user is browsing (next/prev file) files: list = field(default_factory=list) # git.FileChange list the user is browsing (next/prev file)
lines: list = field(default_factory=list) # parse.Line list currently rendered lines: list = field(default_factory=list) # parse.Line list currently rendered
blocks: list = field(default_factory=list) # parse.change_blocks(lines) blocks: list = field(default_factory=list) # parse.change_blocks(lines)
@@ -44,8 +45,13 @@ class ViewState:
return self.sha is not None return self.sha is not None
def index(self): def index(self):
"""Position of the shown file in `files`, or -1""" """Position in `files` of the file being shown or loaded, or -1
return find_change(self.files, self.change.path) if self.change else -1
`requested` leads: a diff arrives asynchronously, so counting from `change` alone would make
n / p step from whatever was on screen before the pending file landed.
"""
change = self.requested or self.change
return find_change(self.files, change.path) if change else -1
def kept(self): def kept(self):
return Kept(self.sha, self.commit, self.change, list(self.files)) return Kept(self.sha, self.commit, self.change, list(self.files))
@@ -54,12 +60,13 @@ class ViewState:
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc""" """Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
kept = self.kept() kept = self.kept()
if sha != self.sha: if sha != self.sha:
self.change = None self.change = self.requested = None
self.sha, self.commit = sha, commit self.sha, self.commit = sha, commit
return kept return kept
def restore(self, kept): def restore(self, kept):
self.sha, self.commit, self.change, self.files = kept self.sha, self.commit, self.change, self.files = kept
self.requested = kept.change
def persist(self, view): def persist(self, view):
"""Mirror the essentials into view settings (survives a restart)""" """Mirror the essentials into view settings (survives a restart)"""

View File

@@ -27,9 +27,14 @@ def apply_git_binary():
git.GIT_BINARY = settings().get("git_binary") or "git" git.GIT_BINARY = settings().get("git_binary") or "git"
def file_count(count):
"""'1 file' / '3 files' for a message"""
return f'{count} {"file" if count == 1 else "files"}'
def status(message): def status(message):
"""Status-bar message with the plugin prefix""" """Status-bar message with the plugin prefix"""
sublime.status_message("vDiff: {}".format(message)) sublime.status_message(f"vDiff: {message}")
def run_async(work, done): def run_async(work, done):