summary line initial version added
This commit is contained in:
@@ -27,6 +27,10 @@
|
|||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "line_up"},
|
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "line_up"},
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
|
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
|
||||||
|
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
||||||
|
{ "keys": ["m"], "command": "vdiff_action", "args": {"action": "commit"},
|
||||||
|
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
||||||
{ "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"}, {"key": "setting.command_mode", "operand": false}] },
|
||||||
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
|
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Keys inside the review view:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `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) |
|
||||||
|
| `C` or `m` | commit the staged files, typing the message in an input panel |
|
||||||
| `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 |
|
||||||
| `n` / `p` | next / previous file of the current list |
|
| `n` / `p` | next / previous file of the current list |
|
||||||
|
|||||||
@@ -84,10 +84,11 @@ 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.commands = []
|
self.commands = []
|
||||||
|
|
||||||
def focus_view(self, view):
|
def focus_view(self, view):
|
||||||
@@ -99,6 +100,10 @@ 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)
|
||||||
|
return View()
|
||||||
|
|
||||||
|
|
||||||
class View:
|
class View:
|
||||||
"""Text buffer with just the calls render.py makes"""
|
"""Text buffer with just the calls render.py makes"""
|
||||||
|
|||||||
@@ -145,3 +145,54 @@ def test_j_and_k_move_the_caret_one_line():
|
|||||||
("move", {"by": "lines", "forward": True}),
|
("move", {"by": "lines", "forward": True}),
|
||||||
("move", {"by": "lines", "forward": False}),
|
("move", {"by": "lines", "forward": False}),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def staged_state(*flags):
|
||||||
|
st = state.ViewState("/repo")
|
||||||
|
st.files = [git.FileChange("f{}.py".format(i), "M", flag, 1, 1, None) for i, flag in enumerate(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 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"]
|
||||||
|
|||||||
@@ -146,3 +146,26 @@ 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
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ 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 ">{}<".format(group) in html
|
||||||
for keys in ("] [", "n p", "o ⏎", "j k", "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 ">{}<".format(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
|
||||||
@@ -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"') == 4
|
||||||
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
|
||||||
# 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
|
||||||
|
|
||||||
@@ -222,3 +223,50 @@ def test_minihtml_stylesheets_reject_unsupported_selectors():
|
|||||||
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), "{}: {}".format(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)
|
||||||
|
assert '<div class="summary">1 staged, 2 unstaged</div>' in header(view)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -361,6 +361,29 @@ 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
|
||||||
|
noun = "file" if len(staged) == 1 else "files"
|
||||||
|
run_async(
|
||||||
|
lambda: git.commit(st.repo, message),
|
||||||
|
lambda _: refresh_listing(view, st, "committed {} {}".format(len(staged), noun)),
|
||||||
|
)
|
||||||
|
|
||||||
|
window.show_input_panel("Commit message", "", on_done, None, None)
|
||||||
|
|
||||||
|
|
||||||
def _move_line(forward):
|
def _move_line(forward):
|
||||||
def act(view, window, st):
|
def act(view, window, st):
|
||||||
view.run_command("move", {"by": "lines", "forward": forward})
|
view.run_command("move", {"by": "lines", "forward": forward})
|
||||||
@@ -376,6 +399,7 @@ ACTIONS = {
|
|||||||
"stage": _git_action("stage", "staged", git.stage),
|
"stage": _git_action("stage", "staged", git.stage),
|
||||||
"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,
|
||||||
"refresh": _act_refresh,
|
"refresh": _act_refresh,
|
||||||
"open": _act_open,
|
"open": _act_open,
|
||||||
"next_file": _step_file(1),
|
"next_file": _step_file(1),
|
||||||
|
|||||||
@@ -236,6 +236,11 @@ 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 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):
|
||||||
|
|||||||
@@ -63,10 +63,11 @@ KEY_GROUPS = [
|
|||||||
Key("f", "changed files", "files", ("files",)),
|
Key("f", "changed files", "files", ("files",)),
|
||||||
Key("c", "recent commits", "commits", ("commits",)),
|
Key("c", "recent commits", "commits", ("commits",)),
|
||||||
]),
|
]),
|
||||||
("This file", False, [
|
("Actions", False, [
|
||||||
Key("s", "stage", "stage", ("stage",)),
|
Key("s", "stage", "stage", ("stage",)),
|
||||||
Key("u", "unstage", "unstage", ("unstage",)),
|
Key("u", "unstage", "unstage", ("unstage",)),
|
||||||
Key("d", "discard", "discard", ("discard",)),
|
Key("d", "discard", "discard", ("discard",)),
|
||||||
|
Key("C m", "commit the staged files", "commit", ("commit",)),
|
||||||
]),
|
]),
|
||||||
("View", True, [
|
("View", True, [
|
||||||
Key("r", "refresh", "refresh", ("refresh",)),
|
Key("r", "refresh", "refresh", ("refresh",)),
|
||||||
@@ -90,10 +91,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)); }
|
||||||
"""
|
"""
|
||||||
@@ -280,8 +282,46 @@ def _char_regions(lines, blocks, starts):
|
|||||||
return deleted, inserted
|
return deleted, inserted
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
text = "{} staged, {} unstaged".format(staged, len(st.files) - staged)
|
||||||
|
return '<div class="summary">{}</div>'.format(text)
|
||||||
|
|
||||||
|
|
||||||
|
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('<span class="hash">{}</span>'.format(html.escape(info.short)))
|
||||||
|
parts.append(html.escape(info.author))
|
||||||
|
parts.append('<span class="when">{}</span>'.format(html.escape(info.date)))
|
||||||
|
# binary files carry None counts but still changed, so they count toward the file total
|
||||||
|
insertions = sum(c.insertions or 0 for c in st.files)
|
||||||
|
deletions = sum(c.deletions or 0 for c in st.files)
|
||||||
|
parts.append(
|
||||||
|
'{} {} changed <span class="del">-{}</span> <span class="ins">+{}</span>'.format(
|
||||||
|
len(st.files), "file" if len(st.files) == 1 else "files", deletions, insertions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if info:
|
||||||
|
message = html.escape(info.subject)
|
||||||
|
body = info.body.strip().splitlines()
|
||||||
|
if body:
|
||||||
|
message += ' <span class="body">— {}</span>'.format(html.escape(body[0]))
|
||||||
|
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>'
|
||||||
@@ -306,19 +346,8 @@ 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 '<body id="vdiff-header"><style>{}</style>{}<div>{}<span class="actions">{}</span></div></body>'.format(
|
||||||
if st.in_commit and st.commit:
|
HEADER_CSS, _summary_html(st), title, "".join(actions)
|
||||||
info = st.commit
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user