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}
')