From 904b941be78085a59ca34ec5d912d7aff49a8dc2 Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Sat, 10 Oct 2026 10:19:06 +0200 Subject: [PATCH] stage/unstage all actions added --- Default.sublime-keymap | 4 ++++ README.md | 1 + tests/test_commands.py | 21 +++++++++++++++++++++ tests/test_git.py | 17 +++++++++++++++++ tests/test_render.py | 10 ++++++---- vdiff/commands.py | 23 +++++++++++++++++++++++ vdiff/git.py | 23 +++++++++++++++++++---- vdiff/render.py | 17 +++++++---------- 8 files changed, 98 insertions(+), 18 deletions(-) diff --git a/Default.sublime-keymap b/Default.sublime-keymap index cba57c2..66f1c79 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -7,6 +7,10 @@ "context": [{"key": "setting.vdiff_view"}] }, { "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"}, "context": [{"key": "setting.vdiff_view"}] }, + { "keys": ["S"], "command": "vdiff_action", "args": {"action": "stage_all"}, + "context": [{"key": "setting.vdiff_view"}] }, + { "keys": ["U"], "command": "vdiff_action", "args": {"action": "unstage_all"}, + "context": [{"key": "setting.vdiff_view"}] }, { "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"}, "context": [{"key": "setting.vdiff_view"}] }, { "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"}, diff --git a/README.md b/README.md index f1ed586..e4d6c13 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov |---|---| | `f` / `c` | changed files / recent commits panel | | `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file | +| `S` / `U` | stage all unstaged files / unstage all staged files | | `C` | commit the staged files, typing the message in an input panel | | `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 | diff --git a/tests/test_commands.py b/tests/test_commands.py index 2794558..caf9f02 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -578,6 +578,27 @@ def test_unstage_and_discard_do_not_move(monkeypatch): assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"] +def test_stage_all_and_unstage_all_stay_on_the_file(monkeypatch): + messages = [] + monkeypatch.setattr(commands, "status", messages.append) + assert press(monkeypatch, "stage_all", flagged(True, False, False), "f1.py", flagged(True, True, True)) == ["f1.py"] + assert messages[0] == "staged 2 files" + assert press(monkeypatch, "unstage_all", flagged(True, False, True), "f1.py", flagged(False, False, False)) == ["f1.py"] + assert messages[-1] == "unstaged 2 files" + + +def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch): + messages = [] + monkeypatch.setattr(commands, "status", messages.append) + assert press(monkeypatch, "stage_all", flagged(True, True), "f0.py", []) == [] + assert press(monkeypatch, "unstage_all", flagged(False, False), "f0.py", []) == [] + assert messages == ["nothing to stage", "nothing to unstage"] + view, window, st = at_file(flagged(False), "f0.py") + st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", "")) + commands.ACTIONS["stage_all"](view, window, st) + assert messages[-1] == "stage all works on uncommitted changes only" + + 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)) diff --git a/tests/test_git.py b/tests/test_git.py index 6fb02d5..8ce8aea 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -84,6 +84,19 @@ def test_stage_unstage_discard(repo): assert git.changed_files(repo) == [] +def test_stage_all_unstage_all(repo): + write(repo, "a.py", "changed\n") + os.remove(os.path.join(repo, "gone.py")) + write(repo, "sub/n.py", "new\n") + git.stage_all(repo) + changes = {c.path: c for c in git.changed_files(repo)} + assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and all(c.staged for c in changes.values()) + git.unstage_all(repo) + changes = {c.path: c for c in git.changed_files(repo)} + assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and not any(c.staged for c in changes.values()) + assert changes["sub/n.py"].status == "?" + + def test_commits(repo): write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n") write(repo, "c.py", "c\n") @@ -154,6 +167,10 @@ def test_no_head_repo(tmp_path): assert [l.kind for l in diff.lines] == ["+"] git.unstage(repo, changes[0]) assert git.changed_files(repo)[0].status == "?" + git.stage_all(repo) + assert git.changed_files(repo)[0].staged + git.unstage_all(repo) + assert git.changed_files(repo)[0].status == "?" def test_rename_in_commit(repo): diff --git a/tests/test_render.py b/tests/test_render.py index 0859d60..f4a41ca 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -154,14 +154,14 @@ def test_help_popup_lists_every_key(): assert view.is_popup_visible() for group in ("Navigate", "Actions", "View"): assert f">{group}<" in html - for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "z", "esc"): + for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s / S", "u / U", "d", "C", "m", "r", "z", "esc"): assert f">{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 assert 'class="dim"' not in html and "uncommitted only" not in html - assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html + assert 'href="discard"' in html and 'href="files"' in html and 'href="close"' in html # rows covering two actions stay plain text - assert 'href="next_change"' not in html + assert 'href="next_change"' not in html and 'href="stage"' not in html # the popup does not list the key that opens it assert ">?<" not in html @@ -173,6 +173,8 @@ def test_help_popup_dims_file_actions_in_commit_mode(): assert "uncommitted only" in html assert html.count('class="dim"') == 5 + # dimmed rows keep their keys orange + assert 'class="kdim"' not in html and 'C' 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 @@ -215,7 +217,7 @@ def test_keymap_matches_help_table(): # rebinding a key without updating the table would leave the popup and status bar lying bound_keys = {DISPLAY_KEYS.get(key, key) for b in bindings for key in b["keys"]} - assert bound_keys == {token for k in render.KEYS for token in k.keys.split()} | {"?"} + assert bound_keys == {token for k in render.KEYS for token in k.keys.split() if token != "/"} | {"?"} def test_no_binding_yields_to_vintage_command_mode(): diff --git a/vdiff/commands.py b/vdiff/commands.py index 630b37c..f437e80 100644 --- a/vdiff/commands.py +++ b/vdiff/commands.py @@ -402,6 +402,27 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange] return act +def _bulk_action(verb: str, done_verb: str, operation: Callable[[str], None], + wanted: Callable[[FileChange], bool]) -> Action: + """Action running `operation(repo)` (" all") when some listed file is `wanted`, then re-listing on the same file""" + + def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Run the operation off the UI thread, then refresh the listing""" + if st.in_commit: + status(f"{verb} all works on uncommitted changes only") + return + count = sum(1 for c in st.files if wanted(c)) + if not count: + status(f"nothing to {verb}") + return + run_async( + lambda: operation(st.repo), + lambda _: refresh_listing(view, st, f"{done_verb} {file_count(count)}"), + ) + + return act + + def _confirm_discard(change: FileChange) -> bool: """Ask before discarding or deleting, unless the confirm_discard setting is off""" if not settings().get("confirm_discard", True): @@ -678,6 +699,8 @@ ACTIONS: Dict[str, Action] = { "help": _act_help, "stage": _git_action("stage", "staged", git.stage, pick=_stage_next), "unstage": _git_action("unstage", "unstaged", git.unstage), + "stage_all": _bulk_action("stage", "staged", git.stage_all, lambda c: not c.staged), + "unstage_all": _bulk_action("unstage", "unstaged", git.unstage_all, lambda c: c.staged), "discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard), "commit": _act_commit, "amend": _act_amend, diff --git a/vdiff/git.py b/vdiff/git.py index 4d0e0ae..fe50659 100644 --- a/vdiff/git.py +++ b/vdiff/git.py @@ -295,6 +295,11 @@ def stage(repo: str, change: FileChange) -> None: run(repo, ["add", "-A", "--", change.path]) +def stage_all(repo: str) -> None: + """Stage every change in the working tree, including deletions and untracked files""" + run(repo, ["add", "-A"]) + + def commit(repo: str, message: str) -> None: """Commit what is staged; `message` goes as one argv element, unquoted""" run(repo, ["commit", "-m", message]) @@ -313,12 +318,22 @@ def amend(repo: str, message: str, allow_empty: bool = False) -> None: run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message]) +def _unstage_path(repo: str, path: str) -> None: + """Move the staged changes under `path` back to the working tree; before the first commit, drop them from the index""" + if has_head(repo): + run(repo, ["reset", "-q", "--", path]) + else: + run(repo, ["rm", "-r", "-q", "--cached", "--", path]) + + def unstage(repo: str, change: FileChange) -> None: """Move the file's staged changes back to the working tree""" - if has_head(repo): - run(repo, ["reset", "-q", "--", change.path]) - else: - run(repo, ["rm", "-r", "-q", "--cached", "--", change.path]) + _unstage_path(repo, change.path) + + +def unstage_all(repo: str) -> None: + """Move every staged change back to the working tree""" + _unstage_path(repo, ".") def discard(repo: str, change: FileChange) -> None: diff --git a/vdiff/render.py b/vdiff/render.py index 768ad4e..7944f49 100644 --- a/vdiff/render.py +++ b/vdiff/render.py @@ -91,8 +91,8 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [ "Actions", False, [ - Key("s", "stage", "stage", ("stage",)), - Key("u", "unstage", "unstage", ("unstage",)), + Key("s / S", "stage / stage all", "stage", ("stage", "stage_all")), + Key("u / U", "unstage / unstage all", "unstage", ("unstage", "unstage_all")), Key("d", "discard", "discard", ("discard",)), Key("C", "commit the staged files", "commit", ("commit",)), Key("m", "amend the last commit", "amend", ("amend",)), @@ -138,7 +138,7 @@ a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border- """ # minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class -# .group's top margin replaces body's top padding; .kdim inherits its colour from .dim +# .group's top margin replaces body's top padding HELP_CSS = """ body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; } .group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; } @@ -146,7 +146,6 @@ body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; } .row { color: color(var(--foreground) alpha(0.85)); } .dim { color: color(var(--foreground) alpha(0.35)); } .k { color: var(--orangish); font-weight: bold; } -.kdim { font-weight: bold; } a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); } """ @@ -795,14 +794,12 @@ def help_html(st: ViewState) -> str: if dim: heading += ' uncommitted only' parts.append(f'
{heading}
') - # 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") + # keys stay orange in a dimmed group; only the text dims + row_class = "dim" if dim else "row" # a row per key with a padded key column; a single-action row becomes a link for key in group: - row = ( - f'{html.escape(key.keys)}' - f'{" " * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}' - ) + pad = " " * (_KEY_WIDTH - len(key.keys) + 2) + row = f'{html.escape(key.keys)}{pad}{html.escape(key.label)}' if len(key.actions) == 1 and not dim: row = f'{row}' parts.append(f'
{row}
')