diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 1629f22..85f01aa 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -27,6 +27,10 @@ "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"}, {"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"}, "context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] }, { "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"}, diff --git a/README.md b/README.md index 3aaabbe..2cae327 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Keys inside the review view: |---|---| | `f` / `c` | changed files / recent commits panel | | `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 | | `]` / `[` | next / previous change | | `n` / `p` | next / previous file of the current list | diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py index 6ef8251..ef92589 100644 --- a/tests/stubs/sublime.py +++ b/tests/stubs/sublime.py @@ -84,10 +84,11 @@ class Phantom: 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): self.panel = None + self.input = None self.commands = [] 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): 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: """Text buffer with just the calls render.py makes""" diff --git a/tests/test_commands.py b/tests/test_commands.py index bd9c3ab..d85180d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -145,3 +145,54 @@ def test_j_and_k_move_the_caret_one_line(): ("move", {"by": "lines", "forward": True}), ("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"] diff --git a/tests/test_git.py b/tests/test_git.py index 0e1310c..0b10f66 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -146,3 +146,26 @@ def test_count_lines(tmp_path): assert git._count_lines(p) is None open(p, "wb").write(b"") 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 diff --git a/tests/test_render.py b/tests/test_render.py index 49b4e40..b9b4055 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -132,9 +132,9 @@ def test_help_popup_lists_every_key(): view, html = help_popup(st) 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 - 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 "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 @@ -152,8 +152,9 @@ def test_help_popup_dims_file_actions_in_commit_mode(): _, html = help_popup(st) 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="commit"' not in html # everything that still applies stays clickable 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() for selector in filter(None, (s.strip() for s in head.split(","))): 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 '
1 staged, 2 unstaged
' 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('
')[1].split("
")[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) diff --git a/vdiff/commands.py b/vdiff/commands.py index ff452c5..6a15c66 100644 --- a/vdiff/commands.py +++ b/vdiff/commands.py @@ -361,6 +361,29 @@ def _step_change(delta): 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 act(view, window, st): view.run_command("move", {"by": "lines", "forward": forward}) @@ -376,6 +399,7 @@ ACTIONS = { "stage": _git_action("stage", "staged", git.stage), "unstage": _git_action("unstage", "unstaged", git.unstage), "discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard), + "commit": _act_commit, "refresh": _act_refresh, "open": _act_open, "next_file": _step_file(1), diff --git a/vdiff/git.py b/vdiff/git.py index 4b9ba67..888fd75 100644 --- a/vdiff/git.py +++ b/vdiff/git.py @@ -236,6 +236,11 @@ def stage(repo, change): 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): """Move the file's staged changes back to the working tree""" if has_head(repo): diff --git a/vdiff/render.py b/vdiff/render.py index 0c38e21..491fb82 100644 --- a/vdiff/render.py +++ b/vdiff/render.py @@ -63,10 +63,11 @@ KEY_GROUPS = [ Key("f", "changed files", "files", ("files",)), Key("c", "recent commits", "commits", ("commits",)), ]), - ("This file", False, [ + ("Actions", False, [ Key("s", "stage", "stage", ("stage",)), Key("u", "unstage", "unstage", ("unstage",)), Key("d", "discard", "discard", ("discard",)), + Key("C m", "commit the staged files", "commit", ("commit",)), ]), ("View", True, [ 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; } .state { color: var(--orangish); } .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; background-color: color(var(--foreground) alpha(0.12)); color: var(--foreground); } .danger { color: var(--redish); } -.commit { margin-top: 0.4rem; color: color(var(--foreground) alpha(0.75)); } .hash { color: var(--bluish); font-weight: bold; } .body { color: color(var(--foreground) alpha(0.6)); } """ @@ -280,8 +282,46 @@ def _char_regions(lines, blocks, starts): 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 '
{}
'.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('{}'.format(html.escape(info.short))) + parts.append(html.escape(info.author)) + parts.append('{}'.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 -{} +{}'.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 += ' — {}'.format(html.escape(body[0])) + parts.append(message) + return parts + + 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 if change is None: title = 'vDiff' @@ -306,19 +346,8 @@ def header_html(st, ins, dele): actions.append('Commits') actions.append('?') - commit = "" - if st.in_commit and st.commit: - info = st.commit - commit = '
{} · {} · {} · {}'.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 += ' — {}'.format(html.escape(body[0])) - commit += "
" - - return '
{}{}
{}'.format( - HEADER_CSS, title, "".join(actions), commit + return '{}
{}{}
'.format( + HEADER_CSS, _summary_html(st), title, "".join(actions) )