Compare commits
10 Commits
07f0503cef
...
af8c1dd6df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8c1dd6df | ||
|
|
bb7ae537ea | ||
|
|
4749835e4f | ||
|
|
0655d3b74a | ||
|
|
57476e78dc | ||
|
|
18551641b5 | ||
|
|
419b1a95fb | ||
|
|
120676e952 | ||
|
|
fda13684d2 | ||
|
|
d45a250e62 |
@@ -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"},
|
||||||
|
|||||||
@@ -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
30
TODO.txt
Normal 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?
|
||||||
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +388,20 @@ 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.
|
||||||
show_file(view, st, st.files[index])
|
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])
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
46
vdiff/git.py
46
vdiff/git.py
@@ -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):
|
||||||
|
|||||||
175
vdiff/render.py
175
vdiff/render.py
@@ -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("o \u23ce", "open the real file at this line", "open", ("open",)),
|
[
|
||||||
]),
|
Key("] [", "next / previous change (wraps)", "change", ("next_change", "prev_change")),
|
||||||
("Panels", True, [
|
Key("n p", "next / previous file (wraps)", "file", ("next_file", "prev_file")),
|
||||||
Key("f", "changed files", "files", ("files",)),
|
Key("o \u23ce", "open the real file at this line", "open", ("open",)),
|
||||||
Key("c", "recent commits", "commits", ("commits",)),
|
Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
|
||||||
]),
|
],
|
||||||
("This file", False, [
|
),
|
||||||
Key("s", "stage", "stage", ("stage",)),
|
(
|
||||||
Key("u", "unstage", "unstage", ("unstage",)),
|
"Panels",
|
||||||
Key("d", "discard", "discard", ("discard",)),
|
True,
|
||||||
]),
|
[
|
||||||
("View", True, [
|
Key("f", "changed files", "files", ("files",)),
|
||||||
Key("r", "refresh", "refresh", ("refresh",)),
|
Key("c", "recent commits", "commits", ("commits",)),
|
||||||
Key("?", "this list", "keys", ("help",)),
|
],
|
||||||
Key("esc", "close the view", "", ("close",)),
|
),
|
||||||
]),
|
(
|
||||||
|
"Actions",
|
||||||
|
False,
|
||||||
|
[
|
||||||
|
Key("s", "stage", "stage", ("stage",)),
|
||||||
|
Key("u", "unstage", "unstage", ("unstage",)),
|
||||||
|
Key("d", "discard", "discard", ("discard",)),
|
||||||
|
Key("C", "commit the staged files", "commit", ("commit",)),
|
||||||
|
Key("m", "amend the last commit", "amend", ("amend",)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"View",
|
||||||
|
True,
|
||||||
|
[
|
||||||
|
Key("r", "refresh", "refresh", ("refresh",)),
|
||||||
|
Key("?", "this list", "keys", ("help",)),
|
||||||
|
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 += ' <span class="del">-{}</span> <span class="ins">+{}</span>'.format(dele, ins)
|
title += f' <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 += ' <span class="state">{}</span>'.format(state)
|
title += f' <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), " " * (_KEY_WIDTH - len(key.keys) + 2), html.escape(key.label)
|
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
|
||||||
|
f'{" " * (_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):
|
||||||
|
|||||||
@@ -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)"""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user