Compare commits
1 Commits
v1.1.0
...
904b941be7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904b941be7 |
@@ -7,6 +7,10 @@
|
|||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
|
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"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"},
|
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
|
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
|
||||||
|
|||||||
@@ -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 |
|
| `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` / `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 |
|
| `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 |
|
| `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 |
|
| `o` or Enter | open the real file at this line |
|
||||||
|
|||||||
@@ -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"]
|
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):
|
def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
|
||||||
messages = []
|
messages = []
|
||||||
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
||||||
|
|||||||
@@ -84,6 +84,19 @@ def test_stage_unstage_discard(repo):
|
|||||||
assert git.changed_files(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):
|
def test_commits(repo):
|
||||||
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
|
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
|
||||||
write(repo, "c.py", "c\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] == ["+"]
|
assert [l.kind for l in diff.lines] == ["+"]
|
||||||
git.unstage(repo, changes[0])
|
git.unstage(repo, changes[0])
|
||||||
assert git.changed_files(repo)[0].status == "?"
|
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):
|
def test_rename_in_commit(repo):
|
||||||
|
|||||||
@@ -154,14 +154,14 @@ def test_help_popup_lists_every_key():
|
|||||||
assert view.is_popup_visible()
|
assert view.is_popup_visible()
|
||||||
for group in ("Navigate", "Actions", "View"):
|
for group in ("Navigate", "Actions", "View"):
|
||||||
assert f">{group}<" in html
|
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 f">{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
|
||||||
assert 'class="dim"' not in html and "uncommitted only" not in html
|
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
|
# 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
|
# the popup does not list the key that opens it
|
||||||
assert ">?<" not in html
|
assert ">?<" not in html
|
||||||
|
|
||||||
@@ -173,6 +173,8 @@ def test_help_popup_dims_file_actions_in_commit_mode():
|
|||||||
|
|
||||||
assert "uncommitted only" in html
|
assert "uncommitted only" in html
|
||||||
assert html.count('class="dim"') == 5
|
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="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
|
assert 'href="commit"' not in html and 'href="amend"' not in html
|
||||||
# everything that still applies stays clickable
|
# 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
|
# 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"]}
|
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():
|
def test_no_binding_yields_to_vintage_command_mode():
|
||||||
|
|||||||
@@ -402,6 +402,27 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange]
|
|||||||
return act
|
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:
|
def _confirm_discard(change: FileChange) -> bool:
|
||||||
"""Ask before discarding or deleting, unless the confirm_discard setting is off"""
|
"""Ask before discarding or deleting, unless the confirm_discard setting is off"""
|
||||||
if not settings().get("confirm_discard", True):
|
if not settings().get("confirm_discard", True):
|
||||||
@@ -678,6 +699,8 @@ ACTIONS: Dict[str, Action] = {
|
|||||||
"help": _act_help,
|
"help": _act_help,
|
||||||
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
|
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
|
||||||
"unstage": _git_action("unstage", "unstaged", git.unstage),
|
"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),
|
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
|
||||||
"commit": _act_commit,
|
"commit": _act_commit,
|
||||||
"amend": _act_amend,
|
"amend": _act_amend,
|
||||||
|
|||||||
23
vdiff/git.py
23
vdiff/git.py
@@ -295,6 +295,11 @@ def stage(repo: str, change: FileChange) -> None:
|
|||||||
run(repo, ["add", "-A", "--", change.path])
|
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:
|
def commit(repo: str, message: str) -> None:
|
||||||
"""Commit what is staged; `message` goes as one argv element, unquoted"""
|
"""Commit what is staged; `message` goes as one argv element, unquoted"""
|
||||||
run(repo, ["commit", "-m", message])
|
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])
|
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:
|
def unstage(repo: str, change: FileChange) -> None:
|
||||||
"""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):
|
_unstage_path(repo, change.path)
|
||||||
run(repo, ["reset", "-q", "--", change.path])
|
|
||||||
else:
|
|
||||||
run(repo, ["rm", "-r", "-q", "--cached", "--", 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:
|
def discard(repo: str, change: FileChange) -> None:
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
|||||||
"Actions",
|
"Actions",
|
||||||
False,
|
False,
|
||||||
[
|
[
|
||||||
Key("s", "stage", "stage", ("stage",)),
|
Key("s / S", "stage / stage all", "stage", ("stage", "stage_all")),
|
||||||
Key("u", "unstage", "unstage", ("unstage",)),
|
Key("u / U", "unstage / unstage all", "unstage", ("unstage", "unstage_all")),
|
||||||
Key("d", "discard", "discard", ("discard",)),
|
Key("d", "discard", "discard", ("discard",)),
|
||||||
Key("C", "commit the staged files", "commit", ("commit",)),
|
Key("C", "commit the staged files", "commit", ("commit",)),
|
||||||
Key("m", "amend the last commit", "amend", ("amend",)),
|
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
|
# 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 = """
|
HELP_CSS = """
|
||||||
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
||||||
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
|
.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)); }
|
.row { color: color(var(--foreground) alpha(0.85)); }
|
||||||
.dim { color: color(var(--foreground) alpha(0.35)); }
|
.dim { color: color(var(--foreground) alpha(0.35)); }
|
||||||
.k { color: var(--orangish); font-weight: bold; }
|
.k { color: var(--orangish); font-weight: bold; }
|
||||||
.kdim { font-weight: bold; }
|
|
||||||
a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
|
a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -795,14 +794,12 @@ def help_html(st: ViewState) -> str:
|
|||||||
if dim:
|
if dim:
|
||||||
heading += ' <span class="note">uncommitted only</span>'
|
heading += ' <span class="note">uncommitted only</span>'
|
||||||
parts.append(f'<div class="group">{heading}</div>')
|
parts.append(f'<div class="group">{heading}</div>')
|
||||||
# one class per element: a compound selector would silently drop the rest of HELP_CSS
|
# keys stay orange in a dimmed group; only the text dims
|
||||||
row_class, key_class = ("dim", "kdim") if dim else ("row", "k")
|
row_class = "dim" if dim else "row"
|
||||||
# a row per key with a padded key column; a single-action row becomes a link
|
# a row per key with a padded key column; a single-action row becomes a link
|
||||||
for key in group:
|
for key in group:
|
||||||
row = (
|
pad = " " * (_KEY_WIDTH - len(key.keys) + 2)
|
||||||
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
|
row = f'<span class="k">{html.escape(key.keys)}</span>{pad}{html.escape(key.label)}'
|
||||||
f'{" " * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
|
|
||||||
)
|
|
||||||
if len(key.actions) == 1 and not dim:
|
if len(key.actions) == 1 and not dim:
|
||||||
row = f'<a href="{key.actions[0]}">{row}</a>'
|
row = f'<a href="{key.actions[0]}">{row}</a>'
|
||||||
parts.append(f'<div class="{row_class}">{row}</div>')
|
parts.append(f'<div class="{row_class}">{row}</div>')
|
||||||
|
|||||||
Reference in New Issue
Block a user