1 Commits

Author SHA1 Message Date
Vaclav Tvrdik
c894d925a1 branches view initial version 2026-10-14 11:22:54 +02:00
6 changed files with 244 additions and 64 deletions

View File

@@ -111,28 +111,28 @@ def test_two_columns_are_laid_out_as_in_the_mockup():
lines = rows(view) lines = rows(view)
# the left column is as wide as its longest cell plus the padding; the gap follows it # the left column is as wide as its longest cell plus the padding; the gap follows it
width = len(" feature") + branches.LEFT_PAD width = len(" feature") + branches.LEFT_PAD
assert all(line[width:width + 2] == "| " for line in lines) assert all(line[width:width + 2] == ("├─" if line.endswith("─") else "│ ") for line in lines)
right = 100 - width - 2 right = 100 - width - 2
# the working tree comes first, five rows like a commit; nothing changed here # the working tree comes first, five rows like a commit; nothing changed here
assert lines[0] == f"{'BRANCHES':<{width}}| No Changes" assert lines[0] == f"{'BRANCHES':<{width}}│ No Changes"
assert lines[1] == f"{' feature':<{width}}| " assert lines[1] == f"{' feature':<{width}}│ "
# main is one commit ahead of its upstream: the count sits on the right edge of its cell # main is one commit ahead of its upstream: the count sits on the right edge of its cell
assert lines[2] == f"{'* main':<{width - 3}}↑1 | " assert lines[2] == f"{'* main':<{width - 3}}↑1 │ "
assert lines[3] == f"{'REMOTES':<{width}}| " assert lines[3] == f"{'REMOTES':<{width}}│ "
assert lines[4] == f"{'origin':<{width}}| " + "-" * right assert lines[4] == f"{'origin':<{width}}├─" + "─" * right
assert lines[5] == f"{' main':<{width}}| default file view mode and some colors now configurable in settings" assert lines[5] == f"{' main':<{width}}│ default file view mode and some colors now configurable in settings"
assert lines[6].startswith(f"{'github':<{width}}| Author: vtvrdik") assert lines[6].startswith(f"{'github':<{width}}│ Author: vtvrdik")
# the tag sits on the right edge of the author row # the tag sits on the right edge of the author row
assert lines[6].endswith("v1.1.0") and len(lines[6]) == width + 2 + right assert lines[6].endswith("v1.1.0") and len(lines[6]) == width + 2 + right
assert lines[7] == f"{' main':<{width}}| Date: 20.12.2025" assert lines[7] == f"{' main':<{width}}│ Date: 20.12.2025"
# the left column ran out, so the cells are blank # the left column ran out, so the cells are blank
assert lines[8] == f"{'':<{width}}| Stats: 8 files changed -58 +260" assert lines[8] == f"{'':<{width}}│ Stats: 8 files changed -58 +260"
assert lines[9] == f"{'':<{width}}| " + "-" * right assert lines[9] == f"{'':<{width}}├─" + "─" * right
assert lines[10] == f"{'':<{width}}| next commit message" assert lines[10] == f"{'':<{width}}│ next commit message"
assert lines[11].rstrip() == f"{'':<{width}}| Author: vtvrdik" assert lines[11].rstrip() == f"{'':<{width}}│ Author: vtvrdik"
# no diffstat for a merge or an empty commit # no diffstat for a merge or an empty commit
assert lines[13] == f"{'':<{width}}| Stats: (no diffstat)" assert lines[13] == f"{'':<{width}}│ Stats: (no diffstat)"
assert lines[14] == f"{'':<{width}}| " + "-" * right assert lines[14] == f"{'':<{width}}├─" + "─" * right
assert len(lines) == 15 assert len(lines) == 15
assert view.syntax == "scope:text.plain" assert view.syntax == "scope:text.plain"
assert view.name_ == "⟐ branches" assert view.name_ == "⟐ branches"
@@ -152,20 +152,20 @@ def test_the_working_tree_entry_counts_the_staged_and_unstaged_files_and_their_d
view, st = rendered(st) view, st = rendered(st)
lines = rows(view) lines = rows(view)
width = len(" feature") + branches.LEFT_PAD width = len(" feature") + branches.LEFT_PAD
assert lines[0] == f"{'BRANCHES':<{width}}| 3 staged files, 2 unstaged files" assert lines[0] == f"{'BRANCHES':<{width}}│ 3 staged files, 2 unstaged files"
assert lines[1] == f"{' feature':<{width}}| Stats: 4 files changed -1 +4" assert lines[1] == f"{' feature':<{width}}│ Stats: 4 files changed -1 +4"
assert lines[2] == f"{'* main':<{width - 3}}↑1 | " and lines[3] == f"{'REMOTES':<{width}}| " assert lines[2] == f"{'* main':<{width - 3}}↑1 │ " and lines[3] == f"{'REMOTES':<{width}}│ "
assert lines[4].endswith("-" * 10) and lines[5].endswith("| default file view mode and some colors now configurable in settings") assert lines[4].endswith("─" * 10) and lines[5].endswith("│ default file view mode and some colors now configurable in settings")
# its counts are coloured like the commits', in row order # its counts are coloured like the commits', in row order
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_DEL][0]] == ["-1", "-58"] assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_DEL][0]] == ["-1", "-58"]
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["+4", "v1.1.0", "+260"] assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["+4", "v1.1.0", "+260"]
# the singular # the singular
st.branch_changes = [CHANGES[0]] st.branch_changes = [CHANGES[0]]
branches.render_branches(view, st) branches.render_branches(view, st)
assert rows(view)[0] == f"{'BRANCHES':<{width}}| 0 staged files, 1 unstaged file" assert rows(view)[0] == f"{'BRANCHES':<{width}}│ 0 staged files, 1 unstaged file"
st.branch_changes = [CHANGES[1]] st.branch_changes = [CHANGES[1]]
branches.render_branches(view, st) branches.render_branches(view, st)
assert rows(view)[0] == f"{'BRANCHES':<{width}}| 1 staged file, 0 unstaged files" assert rows(view)[0] == f"{'BRANCHES':<{width}}│ 1 staged file, 0 unstaged files"
def test_local_branches_show_their_standing_against_the_upstream(): def test_local_branches_show_their_standing_against_the_upstream():
@@ -173,8 +173,8 @@ def test_local_branches_show_their_standing_against_the_upstream():
ref("both", upstream="origin/both", ahead=12, behind=4), ref("lost", upstream="origin/lost", gone=True), ref("both", upstream="origin/both", ahead=12, behind=4), ref("lost", upstream="origin/lost", gone=True),
ref("untracked"), ref("origin/level", remote="origin")] ref("untracked"), ref("origin/level", remote="origin")]
view, st = rendered(make_state(refs=refs, selected="ahead", remotes=("origin",))) view, st = rendered(make_state(refs=refs, selected="ahead", remotes=("origin",)))
cells = [line.split("| ")[0] for line in rows(view)]
width = len(" both ↑12 ↓4") + branches.LEFT_PAD width = len(" both ↑12 ↓4") + branches.LEFT_PAD
cells = [line[:width] for line in rows(view)]
# in the listing's order; level and untracked branches carry nothing, nor do remote ones # in the listing's order; level and untracked branches carry nothing, nor do remote ones
assert cells[1:6] == [f"{' level':<{width}}", f"{'* ahead':<{width - 3}}↑3 ", f"{' both':<{width - 7}}↑12 ↓4 ", assert cells[1:6] == [f"{' level':<{width}}", f"{'* ahead':<{width - 3}}↑3 ", f"{' both':<{width - 7}}↑12 ↓4 ",
f"{' lost':<{width - 5}}gone ", f"{' untracked':<{width}}"] f"{' lost':<{width - 5}}gone ", f"{' untracked':<{width}}"]
@@ -210,7 +210,7 @@ def test_rows_know_their_branch_and_commit():
def test_a_remote_without_fetched_branches_keeps_its_row(): def test_a_remote_without_fetched_branches_keeps_its_row():
view, st = rendered(make_state(refs=[ref("main", current=True)], remotes=("origin", "gitea"))) view, st = rendered(make_state(refs=[ref("main", current=True)], remotes=("origin", "gitea")))
names = [line.split("|")[0].rstrip() for line in rows(view)] names = [line[:st.branch_width].rstrip() for line in rows(view)]
assert names[:5] == ["BRANCHES", "* main", "REMOTES", "origin", "gitea"] assert names[:5] == ["BRANCHES", "* main", "REMOTES", "origin", "gitea"]
@@ -218,7 +218,7 @@ def test_long_branch_names_are_clipped_at_the_cap():
long = "feature/" + "x" * 60 long = "feature/" + "x" * 60
view, st = rendered(make_state(refs=[ref("main", current=True), ref(long)], selected="main")) view, st = rendered(make_state(refs=[ref("main", current=True), ref(long)], selected="main"))
width = branches.LEFT_MAX width = branches.LEFT_MAX
assert rows(view)[2].startswith(f"{' ' + long[:width - 4]}…" + " | ") or rows(view)[2][width:width + 2] == "| " assert rows(view)[2].startswith(f"{' ' + long[:width - 4]}…" + " │ ") or rows(view)[2][width:width + 2] == "│ "
assert rows(view)[2][:width].rstrip().endswith("…") assert rows(view)[2][:width].rstrip().endswith("…")
@@ -263,9 +263,9 @@ def test_caret_starts_on_the_working_tree_entry_and_stays_on_its_item_across_a_r
def test_no_commits_and_no_branches_still_draw(): def test_no_commits_and_no_branches_still_draw():
view, st = rendered(make_state(refs=[], commits=[], selected=None, remotes=())) view, st = rendered(make_state(refs=[], commits=[], selected=None, remotes=()))
rule = "-" * (100 - len("BRANCHES | ")) rule = "─" * (100 - len("BRANCHES │ "))
assert rows(view) == ["BRANCHES | No Changes", "REMOTES | ", " | ", " | ", " | " + rule, assert rows(view) == ["BRANCHES │ No Changes", "REMOTES │ ", " │ ", " │ ", " ├─" + rule,
" | (no commits)"] " │ (no commits)"]
# the one item to step through is the working tree # the one item to step through is the working tree
assert view.sel()[0].a == render.index_point(view, 0) assert view.sel()[0].a == render.index_point(view, 0)
branches.ACTIONS["next_commit"](view, None, st) branches.ACTIONS["next_commit"](view, None, st)
@@ -314,7 +314,7 @@ def test_header_shows_head_upstream_and_the_links_of_the_selected_kind():
assert "up to date" in header(view) assert "up to date" in header(view)
st.branch_refs[1] = REFS[1] st.branch_refs[1] = REFS[1]
branches.render_branches(view, st) branches.render_branches(view, st)
for link in ("checkout", "pull", "push", "fetch", "delete_branch", "files", "commits", "help"): for link in ("checkout", "pull", "push", "fetch", "delete_branch", "files", "commits", "branches", "help"):
assert f'href="{link}"' in text assert f'href="{link}"' in text
assert 'href="checkout_detached"' not in text assert 'href="checkout_detached"' not in text
@@ -380,7 +380,7 @@ def test_J_and_K_select_branches_skipping_headings_and_reload_the_log(monkeypatc
branches.ACTIONS["next_branch"](view, None, st) branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == "origin/main" and len(rings) == 2 assert st.branch_ref == "origin/main" and len(rings) == 2
# the stub runs the worker at once, so the log was redrawn with the new selection # the stub runs the worker at once, so the log was redrawn with the new selection
assert rows(view)[5].endswith("| next commit message") assert rows(view)[5].endswith("│ next commit message")
selected = view.regions[render.KEY_BRANCH_SEL][0] selected = view.regions[render.KEY_BRANCH_SEL][0]
assert view.text[selected[0].a:selected[0].b].strip() == "main" assert view.text[selected[0].a:selected[0].b].strip() == "main"
# the caret went back to the working-tree entry # the caret went back to the working-tree entry
@@ -398,10 +398,10 @@ def test_a_stale_log_is_dropped(monkeypatch):
# the older answer lands first, but the token moved on # the older answer lands first, but the token moved on
work, done = pending[0] work, done = pending[0]
done(work()) done(work())
assert "| feature" not in view.text assert "│ feature" not in view.text
work, done = pending[1] work, done = pending[1]
done(work()) done(work())
assert rows(view)[5].endswith("| main") assert rows(view)[5].endswith("│ main")
def test_load_lists_everything_and_picks_the_branch(monkeypatch): def test_load_lists_everything_and_picks_the_branch(monkeypatch):
@@ -417,8 +417,8 @@ def test_load_lists_everything_and_picks_the_branch(monkeypatch):
st = state.ViewState("/repo/vdiff") st = state.ViewState("/repo/vdiff")
branches.load(view, st) branches.load(view, st)
# nothing wanted: the checked-out branch; the working tree above its log # nothing wanted: the checked-out branch; the working tree above its log
assert st.branch_ref == "main" and rows(view)[5].endswith("| log of main") assert st.branch_ref == "main" and rows(view)[5].endswith("│ log of main")
assert rows(view)[0].endswith("| 1 staged file, 1 unstaged file") and rows(view)[1].endswith("| Stats: 1 file changed -0 +2") assert rows(view)[0].endswith("│ 1 staged file, 1 unstaged file") and rows(view)[1].endswith("│ Stats: 1 file changed -0 +2")
assert st.branch_changes == [CHANGES[2]] assert st.branch_changes == [CHANGES[2]]
assert st.branch_remotes == ["origin", "github"] and st.branch_head_sha == "abc1234" assert st.branch_remotes == ["origin", "github"] and st.branch_head_sha == "abc1234"
assert st.token == st.rendered_token assert st.token == st.rendered_token
@@ -428,7 +428,7 @@ def test_load_lists_everything_and_picks_the_branch(monkeypatch):
assert st.branch_ref == "main" assert st.branch_ref == "main"
st.branch_ref = "origin/main" st.branch_ref = "origin/main"
branches.load(view, st) branches.load(view, st)
assert st.branch_ref == "origin/main" and rows(view)[5].endswith("| log of origin/main") assert st.branch_ref == "origin/main" and rows(view)[5].endswith("│ log of origin/main")
def test_pick_prefers_the_wanted_then_the_current_then_the_first_local_then_the_first(): def test_pick_prefers_the_wanted_then_the_current_then_the_first_local_then_the_first():
@@ -681,7 +681,7 @@ def test_load_worktree_relists_only_the_changes_and_keeps_the_caret(monkeypatch)
branches.ACTIONS["next_commit"](view, None, st) branches.ACTIONS["next_commit"](view, None, st)
branches.load_worktree(view, st) branches.load_worktree(view, st)
assert listed == ["/repo/vdiff"] and st.branch_changes == [CHANGES[1]] assert listed == ["/repo/vdiff"] and st.branch_changes == [CHANGES[1]]
assert rows(view)[0].endswith("| 1 staged file, 0 unstaged files") and rows(view)[5].endswith("| default file view mode and some colors now configurable in settings") assert rows(view)[0].endswith("│ 1 staged file, 0 unstaged files") and rows(view)[5].endswith("│ default file view mode and some colors now configurable in settings")
assert render.caret_index(view) == 5 and st.token == st.rendered_token assert render.caret_index(view) == 5 and st.token == st.rendered_token
# a later load wins over a slower relisting # a later load wins over a slower relisting
pending = [] pending = []

View File

@@ -722,6 +722,9 @@ def watched(monkeypatch, tmp_path, all_mode):
st.files = [change("a.py"), change("b.py")] st.files = [change("a.py"), change("b.py")]
st.change, st.all_mode = st.files[0], all_mode st.change, st.all_mode = st.files[0], all_mode
st.fingerprint = commands.render.fingerprint(st) st.fingerprint = commands.render.fingerprint(st)
# git status lists what the view lists, unless a test says otherwise
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: list(st.files))
return st, refreshes return st, refreshes
@@ -750,6 +753,27 @@ def test_refresh_if_changed_watches_the_shown_file_and_the_index(monkeypatch, tm
assert refreshes == [st] assert refreshes == [st]
@pytest.mark.parametrize("all_mode", [False, True])
def test_refresh_if_changed_sees_a_file_changed_outside_the_list(monkeypatch, tmp_path, all_mode):
st, refreshes = watched(monkeypatch, tmp_path, all_mode=all_mode)
# no listed file and not the index moved, but git status now lists c.py too
monkeypatch.setattr(commands.git, "changed_files", lambda repo: st.files + [change("c.py")])
commands.refresh_if_changed(sublime.View(), st)
assert refreshes == [st]
def test_a_stale_git_status_does_not_refresh(monkeypatch, tmp_path):
st, refreshes = watched(monkeypatch, tmp_path, all_mode=False)
pending = []
monkeypatch.setattr(commands, "run_async", lambda work, done: pending.append(done))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
commands.refresh_if_changed(sublime.View(), st)
# the user moved on to another file before git status answered
st.token += 1
pending[0]([change("c.py")])
assert refreshes == []
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))
@@ -882,3 +906,101 @@ def test_all_files_context_zero_shows_only_changed_lines(monkeypatch):
view, st = all_files_view() view, st = all_files_view()
shown = [line for _, line, _ in st.all_rows if line is not None] shown = [line for _, line, _ in st.all_rows if line is not None]
assert shown and all(line.kind != " " for line in shown) assert shown and all(line.kind != " " for line in shown)
def test_refresh_in_the_all_files_view_relists_the_working_tree(monkeypatch):
view, st = all_files_view()
st.change = st.files[0]
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
# b.py was reverted and c.py is new since the view was drawn
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py"), change("c.py")])
shown = []
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(focus))
commands.ACTIONS["refresh"](view, None, st)
assert shown == ["a.py"] and [c.path for c in st.files] == ["a.py", "c.py"]
@pytest.mark.parametrize("all_mode, expected", [(False, ("file", "b.py")), (True, ("all", "b.py"))])
def test_restore_falls_back_to_the_first_changed_file(monkeypatch, tmp_path, all_mode, expected):
view = sublime.View()
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "path": "gone.py", "all_mode": all_mode})
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("b.py"), change("c.py")])
shown = []
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(("all", st.change.path)))
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(("file", c.path)))
commands.restore_view(view)
assert shown == [expected]
def test_restore_with_no_changes_left_says_so(monkeypatch, tmp_path):
view = sublime.View()
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "path": "gone.py"})
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
messages = []
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
commands.restore_view(view)
assert messages == [f"No uncommitted changes in {tmp_path}"]
def test_open_in_the_all_files_view_maps_a_deleted_row_like_the_single_file_view(tmp_path):
from vdiff import parse
view, st = all_files_view()
st.repo = str(tmp_path)
(tmp_path / "a.py").write_text("")
(tmp_path / "b.py").write_text("")
lines_b = [parse.Line(" ", i, i, f"b{i}") for i in range(1, 5)] + [parse.Line("-", 5, None, "old b5"),
parse.Line(" ", 6, 5, "b6")]
st.all_diffs["b.py"] = (lines_b, parse.change_blocks(lines_b), False)
commands.render.render_all(view, st)
opened = []
class Window:
def open_file(self, name, flags=0):
opened.append(name)
def open_at(match):
put_caret(view, next(i for i, (_, line, _) in enumerate(st.all_rows) if line is not None and match(line)))
commands.ACTIONS["open"](view, Window(), st)
# the deleted row opens at the line that took its place, as in the single-file view, not at line 1
open_at(lambda line: line.kind == "-")
# a surviving row keeps its own number
open_at(lambda line: line.new_no == 26)
assert opened == [f"{tmp_path}/b.py:5", f"{tmp_path}/a.py:26"]
def test_a_blank_row_in_the_all_files_view_is_no_file(monkeypatch):
view, st = all_files_view()
messages, ran = [], []
monkeypatch.setattr(commands, "status", messages.append)
monkeypatch.setattr(commands, "run_async", lambda work, done: ran.append(work))
# the caret was on a.py, then moved to the blank row above b.py: git actions and open act on nothing
put_caret(view, heading_row(st, "a.py") + 1)
commands.render.follow_caret(view, st)
put_caret(view, heading_row(st, "b.py") - 1)
commands.ACTIONS["discard"](view, None, st)
commands.ACTIONS["stage"](view, None, st)
commands.ACTIONS["open"](view, None, st)
assert ran == [] and messages == ["no file under the caret"] * 3
@pytest.mark.parametrize("action, path", [("next_file", "b.py"), ("prev_file", "a.py")])
def test_a_blank_row_steps_files_as_the_gap_above_the_file_below(action, path):
view, st = all_files_view()
st.change = st.files[0]
put_caret(view, heading_row(st, "b.py") - 1)
commands.ACTIONS[action](view, None, st)
assert st.change.path == path
def test_a_blank_row_steps_changes_as_the_gap_above_the_file_below():
view, st = all_files_view()
put_caret(view, heading_row(st, "b.py") - 1)
commands.ACTIONS["next_change"](view, None, st)
assert st.change.path == "b.py"
put_caret(view, heading_row(st, "b.py") - 1)
commands.ACTIONS["prev_change"](view, None, st)
# a.py's last hunk, not the first or last of the whole list from nowhere
assert st.change.path == "a.py" and commands.render.caret_index(view) == st.all_change_rows[1]

View File

@@ -159,7 +159,7 @@ def test_help_popup_lists_every_key():
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="discard"' in html and 'href="files"' in html and 'href="close"' in html assert 'href="discard"' in html and 'href="files"' in html and 'href="branches"' 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 and 'href="stage"' 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
@@ -499,3 +499,19 @@ def test_status_hints_follow_the_mode():
assert "J K branch" in hints and "L pull" in hints and "s / S" not in hints assert "J K branch" in hints and "L pull" in hints and "s / S" not in hints
render.init_view(view, state.MODE_BRANCHES) render.init_view(view, state.MODE_BRANCHES)
assert view.status[render.STATUS_KEY] == hints and view.settings().get(state.MODE_SETTING) == "branches" assert view.status[render.STATUS_KEY] == hints and view.settings().get(state.MODE_SETTING) == "branches"
def test_all_files_view_tokenises_only_once_the_colours_can_show(monkeypatch):
calls = []
monkeypatch.setattr(render, "_all_colours", lambda view, st, paths: calls.append(list(paths)))
st = make_state()
st.all_mode = True
st.files = [st.change]
st.all_diffs = {st.change.path: (st.lines, st.blocks, False)}
view = sublime.View()
render.init_view(view)
# before the restart that loads the scheme rules the colours could not be painted: no tokenising
render.render_all(view, st)
assert calls == []
render.render_all(with_loaded_override(view), st)
assert calls == [[st.change.path]]

View File

@@ -28,8 +28,10 @@ RIGHT_MIN = 60
RIGHT_MAX = 200 RIGHT_MAX = 200
# a commit is its subject, author, date and stats rows, then a rule # a commit is its subject, author, date and stats rows, then a rule
ROWS_PER_COMMIT = 5 ROWS_PER_COMMIT = 5
COLUMN_GAP = "| " # box drawing: the column line, the rule under each item, and the column line's branch into a rule on the rule rows
RULE = "-" COLUMN_GAP = "│ "
RULE = "─"
JUNCTION = "├─"
HEADING_BRANCHES = "BRANCHES" HEADING_BRANCHES = "BRANCHES"
HEADING_REMOTES = "REMOTES" HEADING_REMOTES = "REMOTES"
@@ -438,14 +440,15 @@ def render_branches(view: sublime.View, st: ViewState, keep_caret: bool = False)
# the cells of both columns, then rows of the longer column's length # the cells of both columns, then rows of the longer column's length
left = _left_cells(st) left = _left_cells(st)
width = st.branch_width = _left_width(left) width = st.branch_width = _left_width(left)
right = _right_cells(st.branch_changes, st.branch_commits, _right_width(view, width)) right_width = _right_width(view, width)
right = _right_cells(st.branch_changes, st.branch_commits, right_width)
rows: List[str] = [] rows: List[str] = []
metadata: List[BranchRow] = [] metadata: List[BranchRow] = []
gap = width + len(COLUMN_GAP) gap = width + len(COLUMN_GAP)
for i in range(max(len(left), len(right))): for i in range(max(len(left), len(right))):
left_text = _left_text(left[i], width) if i < len(left) else " " * width left_text = _left_text(left[i], width) if i < len(left) else " " * width
cell, index, spans = right[i] if i < len(right) else ("", -1, []) cell, index, spans = right[i] if i < len(right) else ("", -1, [])
rows.append(f"{left_text}{COLUMN_GAP}{cell}") rows.append(left_text + (JUNCTION if cell == RULE * right_width else COLUMN_GAP) + cell)
# the spans relative to the row's start, for the cursor painter to colour # the spans relative to the row's start, for the cursor painter to colour
metadata.append(BranchRow(left[i][1] if i < len(left) else None, index, tuple((gap + a, gap + b, kind) for a, b, kind in spans))) metadata.append(BranchRow(left[i][1] if i < len(left) else None, index, tuple((gap + a, gap + b, kind) for a, b, kind in spans)))
st.branch_rows = metadata st.branch_rows = metadata

View File

@@ -296,7 +296,8 @@ def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] =
def refresh_if_changed(view: sublime.View, st: ViewState) -> None: def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
"""Re-render a working-tree view when the file or the index changed since it was drawn; the branches view keeps its own check""" """Re-render a working-tree view when a shown file, the index or the list of changed files moved since it was drawn;
the branches view keeps its own check"""
if st.in_branches: if st.in_branches:
branches.refresh_if_changed(view, st) branches.refresh_if_changed(view, st)
return return
@@ -304,6 +305,16 @@ def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
return return
if render.fingerprint(st) != st.fingerprint: if render.fingerprint(st) != st.fingerprint:
refresh_listing(view, st) refresh_listing(view, st)
return
# the mtimes watch only the listed files; git status, off the UI thread, sees a file newly changed outside the list
token = st.token
def done(files: List[FileChange]) -> None:
"""Re-list when the working tree's changes are no longer the listed ones, unless a newer render took over"""
if view.is_valid() and token == st.token and files != st.files:
refresh_listing(view, st)
run_async(lambda: git.changed_files(st.repo), done)
class VdiffFilesCommand(sublime_plugin.WindowCommand): class VdiffFilesCommand(sublime_plugin.WindowCommand):
@@ -449,9 +460,12 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange]
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None: def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Confirm if asked, run the operation off the UI thread, then refresh the listing""" """Confirm if asked, run the operation off the UI thread, then refresh the listing"""
change = _current_change(view, st) change = _current_change(view, st)
if st.in_commit or change is None: if st.in_commit:
status(f"{verb} works on uncommitted changes only") status(f"{verb} works on uncommitted changes only")
return return
if change is None:
status("no file under the caret")
return
if confirm is not None and not confirm(change): if confirm is not None and not confirm(change):
return return
run_async( run_async(
@@ -517,19 +531,20 @@ def _stage_next(files: Sequence[FileChange], index: int) -> Optional[FileChange]
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None: def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Reload what is shown: the branches, all diffs, the commit's file, or the working-tree listing""" """Reload what is shown: the branches, the commit's diffs, or the working tree re-listed (new and reverted files too)"""
if st.in_branches or st.all_mode or st.in_commit: if st.in_branches or st.in_commit:
redisplay(view, st, keep_caret=True) redisplay(view, st, keep_caret=True)
elif st.change is not None: elif st.all_mode or st.change is not None:
refresh_listing(view, st) refresh_listing(view, st)
def _current_change(view: sublime.View, st: ViewState) -> Optional[FileChange]: def _current_change(view: sublime.View, st: ViewState) -> Optional[FileChange]:
"""File under the caret in the aggregate view (made current), else the shown file""" """File under the caret in the aggregate view (made current), None on a blank row or the top row; else the shown file"""
if st.all_mode: if st.all_mode:
change = render.all_change_at(view, st) change = render.all_change_at(view, st)
if change is not None: if change is not None:
st.change = change st.change = change
return change
return st.change return st.change
@@ -537,6 +552,7 @@ def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None
"""Open the working-tree file at the line under the caret""" """Open the working-tree file at the line under the caret"""
change = _current_change(view, st) change = _current_change(view, st)
if change is None: if change is None:
status("no file under the caret")
return return
path = os.path.join(st.repo, change.path) path = os.path.join(st.repo, change.path)
if not os.path.exists(path): if not os.path.exists(path):
@@ -546,7 +562,10 @@ def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None
if st.all_mode: if st.all_mode:
row = render.caret_index(view) row = render.caret_index(view)
displayed = st.all_rows[row][1] if 0 <= row < len(st.all_rows) else None displayed = st.all_rows[row][1] if 0 <= row < len(st.all_rows) else None
line = displayed.new_no if displayed and displayed.new_no is not None else 1 # the row's slot in the file's whole diff (a heading is its start), mapped as the single-file view maps it
lines = st.all_diffs.get(change.path, ([], [], False))[0]
index = next((i for i, line in enumerate(lines) if line is displayed), 0)
line = parse.line_for_open(lines, index)
else: else:
line = parse.line_for_open(st.lines, render.caret_index(view)) line = parse.line_for_open(st.lines, render.caret_index(view))
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION) window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
@@ -595,9 +614,13 @@ def _step_file(delta: int) -> Action:
status("no files to step through") status("no files to step through")
return return
change = _current_change(view, st) change = _current_change(view, st)
index = state.find_change(st.files, change.path if change else None) if change is not None:
# nothing selected yet: n starts at the first file, p at the last index = (state.find_change(st.files, change.path) + delta) % len(st.files)
index = (index + delta) % len(st.files) if index >= 0 else (0 if delta > 0 else len(st.files) - 1) else:
# a blank row or the top row sits above a file: n goes to that file, p to the one before it
below = render.all_change_below(view, st)
index = state.find_change(st.files, below.path) if below else 0
index = (index if delta > 0 else index - 1) % len(st.files)
st.change = st.files[index] st.change = st.files[index]
st.collapsed.discard(st.change.path) st.collapsed.discard(st.change.path)
render.render_all(view, st, focus=st.change.path, center=True) render.render_all(view, st, focus=st.change.path, center=True)
@@ -630,7 +653,8 @@ def _step_change(delta: int) -> Action:
"""Jump to the next or previous change, across files in the aggregate view""" """Jump to the next or previous change, across files in the aggregate view"""
if st.all_mode: if st.all_mode:
# aggregate view: the hunks of all files are the targets # aggregate view: the hunks of all files are the targets
current_change = _current_change(view, st) # a blank row or the top row counts as above the file below it: ] goes to its first hunk, [ before it
current_change = _current_change(view, st) or render.all_change_below(view, st)
rows = st.all_change_rows rows = st.all_change_rows
targets = st.all_change_targets targets = st.all_change_targets
if not targets: if not targets:
@@ -834,14 +858,13 @@ def restore_view(view: sublime.View) -> ViewState:
else: else:
def show_working_tree(files: List[FileChange]) -> None: def show_working_tree(files: List[FileChange]) -> None:
"""Redraw the saved file if it still has changes, else say it has none""" """Redraw the saved file, or the first changed one when it has no changes any more"""
st.files = files st.files = files
index = state.find_change(files, path) if not files:
if index >= 0: render.render_message(view, st, f"No uncommitted changes in {repo}")
st.change = files[index] return
redisplay(view, st) st.change = files[max(0, state.find_change(files, path))]
else: redisplay(view, st)
render.render_message(view, st, f"{path} has no uncommitted changes any more")
run_async(lambda: git.changed_files(repo), show_working_tree) run_async(lambda: git.changed_files(repo), show_working_tree)
return st return st

View File

@@ -417,14 +417,16 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
palette.ensure(view) palette.ensure(view)
if palette.claim_restart_notice(view, palette.DIFF_SCOPES): if palette.claim_restart_notice(view, palette.DIFF_SCOPES):
status(NEEDS_RESTART) status(NEEDS_RESTART)
_all_colours(view, st, [c.path for c in st.files if c.path not in st.collapsed]) # the token colours only once their scheme rules are live; tokenising before the restart would be thrown away
resolved = palette.tokens_resolved(view)
if resolved:
_all_colours(view, st, [c.path for c in st.files if c.path not in st.collapsed])
# per-row regions: heading counts, number prefix, tint, token colours # per-row regions: heading counts, number prefix, tint, token colours
starts = _row_starts_for_text(rows) starts = _row_starts_for_text(rows)
del_regions, ins_regions, file_regions, prefix_regions = [], [], [], [] del_regions, ins_regions, file_regions, prefix_regions = [], [], [], []
file_del_counts, file_ins_counts, file_statuses = [], [], [] file_del_counts, file_ins_counts, file_statuses = [], [], []
token_groups: Dict[str, List[sublime.Region]] = {} token_groups: Dict[str, List[sublime.Region]] = {}
default_colour = palette.foreground(view, "text.plain") default_colour = palette.foreground(view, "text.plain")
resolved = palette.tokens_resolved(view)
# context colours come from a later scheme rule, so they may still await a restart # context colours come from a later scheme rule, so they may still await a restart
ctx_resolved = palette.tokens_resolved(view, ("ctx",)) ctx_resolved = palette.tokens_resolved(view, ("ctx",))
for i, (change, line, section) in enumerate(metadata): for i, (change, line, section) in enumerate(metadata):
@@ -548,6 +550,14 @@ def all_change_at(view: sublime.View, st: ViewState) -> Optional[FileChange]:
return None return None
def all_change_below(view: sublime.View, st: ViewState) -> Optional[FileChange]:
"""File of the first heading or diff row at or below the caret: the file a blank row or the top row precedes"""
for change, _, _ in st.all_rows[max(0, caret_index(view)):]:
if change is not None:
return change
return None
def follow_caret(view: sublime.View, st: ViewState) -> None: def follow_caret(view: sublime.View, st: ViewState) -> None:
"""In the all-files view, make the file under the caret current and redraw the header for it """In the all-files view, make the file under the caret current and redraw the header for it
@@ -715,8 +725,7 @@ def _colour_runs(
return runs return runs
def _finish(view: sublime.View, st: ViewState, mode: str, name: str, header: str, def _finish(view: sublime.View, st: ViewState, mode: str, name: str, header: str, key: Callable[[ViewState], Any] = fingerprint) -> None:
key: Callable[[ViewState], Any] = fingerprint) -> None:
"""Mode, header, tab name, status bar and bookkeeping shared by every render: what was drawn is settled here """Mode, header, tab name, status bar and bookkeeping shared by every render: what was drawn is settled here
`key` is the mode's fingerprint, the branches view's own one there `key` is the mode's fingerprint, the branches view's own one there
@@ -819,7 +828,9 @@ def _commit_parts(st: ViewState) -> List[str]:
parts.append(f'<span class="when">{html.escape(info.date)}</span>') parts.append(f'<span class="when">{html.escape(info.date)}</span>')
# binary files add nothing to the line totals but still count toward the file total # binary files add nothing to the line totals but still count toward the file total
stat = git.worktree_stats(st.files) stat = git.worktree_stats(st.files)
parts.append(f"{file_count(stat.files)} changed" f' <span class="del">-{stat.deletions}</span> <span class="ins">+{stat.insertions}</span>') parts.append(
f"{file_count(stat.files)} changed" f' <span class="del">-{stat.deletions}</span> <span class="ins">+{stat.insertions}</span>'
)
# the subject, plus the body's first line # the subject, plus the body's first line
if info: if info:
message = html.escape(info.subject) message = html.escape(info.subject)
@@ -862,7 +873,12 @@ def header_html(st: ViewState, ins: Optional[int], dele: Optional[int]) -> str:
def header_frame(summary: str, title: str, actions: Sequence[str]) -> str: def header_frame(summary: str, title: str, actions: Sequence[str]) -> str:
"""The header's body: the summary line, then the title with `actions` and the links every view has""" """The header's body: the summary line, then the title with `actions` and the links every view has"""
links = list(actions) + ['<a href="files">Files</a>', '<a href="commits">Commits</a>', '<a href="help" title="keys">?</a>'] links = list(actions) + [
'<a href="files">Files</a>',
'<a href="commits">Commits</a>',
'<a href="branches">Branches</a>',
'<a href="help" title="keys">?</a>',
]
return ( return (
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{summary}' f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{summary}'
f'<div>{title}<span class="actions">{"".join(links)}</span></div></body>' f'<div>{title}<span class="actions">{"".join(links)}</span></div></body>'