stage/unstage all actions added

This commit is contained in:
Vaclav Tvrdik
2026-10-10 10:19:06 +02:00
parent fe915178c5
commit 904b941be7
8 changed files with 98 additions and 18 deletions

View File

@@ -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"},

View File

@@ -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 |

View File

@@ -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))

View File

@@ -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):

View File

@@ -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 '<span class="k">C</span>' 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():

View File

@@ -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)` ("<verb> 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,

View File

@@ -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:

View File

@@ -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 += ' <span class="note">uncommitted only</span>'
parts.append(f'<div class="group">{heading}</div>')
# 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'<span class="{key_class}">{html.escape(key.keys)}</span>'
f'{"&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
)
pad = "&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2)
row = f'<span class="k">{html.escape(key.keys)}</span>{pad}{html.escape(key.label)}'
if len(key.actions) == 1 and not dim:
row = f'<a href="{key.actions[0]}">{row}</a>'
parts.append(f'<div class="{row_class}">{row}</div>')