initial version of all-in-one diff view

This commit is contained in:
Vaclav Tvrdik
2026-10-02 16:54:19 +02:00
parent a2900682a0
commit fe915178c5
16 changed files with 850 additions and 137 deletions

View File

@@ -224,9 +224,19 @@ class View:
def set_status(self, key, value):
self.status[key] = value
def window(self):
# detached: the all-files view then skips tokenising in a hidden panel
return None
def viewport_position(self):
return (0, 0)
def set_viewport_position(self, xy, animate=True):
pass
def show(self, point):
pass
def show_at_center(self, point):
pass

View File

@@ -583,3 +583,90 @@ def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
assert messages == ["No uncommitted changes in /repo"]
def all_files_view():
"""An all-files view of a.py (two hunks) and b.py (one), rendered with the caret at the top"""
from vdiff import parse
lines_a = [parse.Line(" ", i, i, f"a{i}") for i in range(1, 30)]
lines_a[2] = parse.Line("+", None, 3, "new a3")
lines_a[25] = parse.Line("+", None, 26, "new a26")
lines_b = [parse.Line(" ", 1, 1, "b1"), parse.Line("-", 2, None, "old b2")]
view = sublime.View()
st = state.ViewState("/repo")
st.all_mode = True
st.files = [change("a.py"), change("b.py")]
st.all_diffs = {path: (lines, parse.change_blocks(lines), False)
for path, lines in (("a.py", lines_a), ("b.py", lines_b))}
commands.render.render_all(view, st)
return view, st
def heading_row(st, path):
return next(i for i, (c, _, section) in enumerate(st.all_rows) if section and c.path == path)
def put_caret(view, row):
view.sel().clear()
view.sel().add(sublime.Region(commands.render.index_point(view, row)))
def test_all_files_step_change_from_a_heading_lands_on_its_first_hunk():
view, st = all_files_view()
rows = dict(zip(st.all_change_row_targets, st.all_change_rows))
# from the top, outside any file: the very first hunk, not the second
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == rows[("a.py", 0)]
# from a.py's heading: its first hunk
put_caret(view, heading_row(st, "a.py"))
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == rows[("a.py", 0)]
# from b.py's heading back: the hunk before b.py's first, a.py's last
put_caret(view, heading_row(st, "b.py"))
commands.ACTIONS["prev_change"](view, None, st)
assert commands.render.caret_index(view) == rows[("a.py", 1)]
# from a hunk: the next one, across files
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == rows[("b.py", 0)]
def test_arrows_do_nothing_in_the_single_file_view():
view, st = at_change([(2, 4)], 2)
st.change = change("a.py")
commands.ACTIONS["collapse"](view, None, st)
assert st.collapsed == set() and not st.all_mode and st.all_rows == []
def test_git_action_refresh_stays_in_the_all_files_view(monkeypatch):
view, st = all_files_view()
st.change = st.files[1]
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py"), change("b.py")])
shown = []
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(focus))
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(("file", c.path)))
commands.refresh_listing(view, st, pick=None)
assert shown == ["b.py"] and st.change.path == "b.py"
def test_all_files_step_file_from_nothing_and_with_no_files():
view, st = all_files_view()
commands.ACTIONS["next_file"](view, None, st)
assert st.change.path == "a.py"
st.change = None
put_caret(view, 0)
commands.ACTIONS["prev_file"](view, None, st)
assert st.change.path == "b.py"
st.files = []
commands.ACTIONS["next_file"](view, None, st) # no ZeroDivisionError
def test_all_files_context_zero_shows_only_changed_lines(monkeypatch):
option(monkeypatch, "all_files_context", 0)
view, st = all_files_view()
shown = [line for _, line, _ in st.all_rows if line is not None]
assert shown and all(line.kind != " " for line in shown)

View File

@@ -49,7 +49,18 @@ def test_render_text_regions_and_header():
st = make_state()
render.render(view, st)
assert view.text.split("\n") == ["", "def f():", " return 1", "", " return 10", " # added", " # more", "", "def g():", " pass"]
assert view.text.split("\n") == [
"",
"def f():",
" return 1",
"",
" return 10",
" # added",
" # more",
"",
"def g():",
" pass",
]
assert view.is_read_only()
assert view.syntax.endswith("Python.sublime-syntax")
@@ -141,9 +152,9 @@ def test_help_popup_lists_every_key():
view, html = help_popup(st)
assert view.is_popup_visible()
for group in ("Navigate", "Panels", "Actions", "View"):
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", "?", "esc"):
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "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
@@ -151,8 +162,8 @@ def test_help_popup_lists_every_key():
assert 'href="stage"' 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
# the status bar advertises it too
assert "? keys" in view.status[render.STATUS_KEY]
# the popup does not list the key that opens it
assert ">?<" not in html
def test_help_popup_dims_file_actions_in_commit_mode():
@@ -199,11 +210,12 @@ def test_keymap_matches_help_table():
assert all(b["command"] == "vdiff_action" for b in bindings)
bound = {b["args"]["action"] for b in bindings}
listed = {action for k in render.KEYS for action in k.actions}
assert bound == set(commands.ACTIONS) == listed
# "?" opens the popup, so the popup leaves it out
assert bound == set(commands.ACTIONS) == listed | {"help"}
# 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()} | {"?"}
def test_no_binding_yields_to_vintage_command_mode():
@@ -418,5 +430,3 @@ def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
# said once per scheme, not once per render
render.render(view, make_state())
assert said == ["vDiff: " + render.NEEDS_RESTART]