Compare commits
1 Commits
dc11d4e688
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c894d925a1 |
@@ -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 = []
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>'
|
||||||
|
|||||||
Reference in New Issue
Block a user