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)
# the left column is as wide as its longest cell plus the padding; the gap follows it
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
# the working tree comes first, five rows like a commit; nothing changed here
assert lines[0] == f"{'BRANCHES':<{width}}| No Changes"
assert lines[1] == f"{' feature':<{width}}| "
assert lines[0] == f"{'BRANCHES':<{width}}│ No Changes"
assert lines[1] == f"{' feature':<{width}}│ "
# 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[3] == f"{'REMOTES':<{width}}| "
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[6].startswith(f"{'github':<{width}}| Author: vtvrdik")
assert lines[2] == f"{'* main':<{width - 3}}↑1 │ "
assert lines[3] == f"{'REMOTES':<{width}}│ "
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[6].startswith(f"{'github':<{width}}│ Author: vtvrdik")
# 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[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
assert lines[8] == f"{'':<{width}}| Stats: 8 files changed -58 +260"
assert lines[9] == f"{'':<{width}}| " + "-" * right
assert lines[10] == f"{'':<{width}}| next commit message"
assert lines[11].rstrip() == f"{'':<{width}}| Author: vtvrdik"
assert lines[8] == f"{'':<{width}}│ Stats: 8 files changed -58 +260"
assert lines[9] == f"{'':<{width}}├─" + "─" * right
assert lines[10] == f"{'':<{width}}│ next commit message"
assert lines[11].rstrip() == f"{'':<{width}}│ Author: vtvrdik"
# no diffstat for a merge or an empty commit
assert lines[13] == f"{'':<{width}}| Stats: (no diffstat)"
assert lines[14] == f"{'':<{width}}| " + "-" * right
assert lines[13] == f"{'':<{width}}│ Stats: (no diffstat)"
assert lines[14] == f"{'':<{width}}├─" + "─" * right
assert len(lines) == 15
assert view.syntax == "scope:text.plain"
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)
lines = rows(view)
width = len(" feature") + branches.LEFT_PAD
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[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[0] == f"{'BRANCHES':<{width}}│ 3 staged files, 2 unstaged files"
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[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
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"]
# the singular
st.branch_changes = [CHANGES[0]]
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]]
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():
@@ -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("untracked"), ref("origin/level", remote="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
cells = [line[:width] for line in rows(view)]
# 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 ",
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():
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"]
@@ -218,7 +218,7 @@ def test_long_branch_names_are_clipped_at_the_cap():
long = "feature/" + "x" * 60
view, st = rendered(make_state(refs=[ref("main", current=True), ref(long)], selected="main"))
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("…")
@@ -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():
view, st = rendered(make_state(refs=[], commits=[], selected=None, remotes=()))
rule = "-" * (100 - len("BRANCHES | "))
assert rows(view) == ["BRANCHES | No Changes", "REMOTES | ", " | ", " | ", " | " + rule,
" | (no commits)"]
rule = "─" * (100 - len("BRANCHES │ "))
assert rows(view) == ["BRANCHES │ No Changes", "REMOTES │ ", " │ ", " │ ", " ├─" + rule,
" │ (no commits)"]
# the one item to step through is the working tree
assert view.sel()[0].a == render.index_point(view, 0)
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)
st.branch_refs[1] = REFS[1]
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 '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)
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
assert rows(view)[5].endswith("| next commit message")
assert rows(view)[5].endswith("│ next commit message")
selected = view.regions[render.KEY_BRANCH_SEL][0]
assert view.text[selected[0].a:selected[0].b].strip() == "main"
# 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
work, done = pending[0]
done(work())
assert "| feature" not in view.text
assert "│ feature" not in view.text
work, done = pending[1]
done(work())
assert rows(view)[5].endswith("| main")
assert rows(view)[5].endswith("│ main")
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")
branches.load(view, st)
# 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 rows(view)[0].endswith("| 1 staged file, 1 unstaged file") and rows(view)[1].endswith("| Stats: 1 file changed -0 +2")
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 st.branch_changes == [CHANGES[2]]
assert st.branch_remotes == ["origin", "github"] and st.branch_head_sha == "abc1234"
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"
st.branch_ref = "origin/main"
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():
@@ -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.load_worktree(view, st)
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
# a later load wins over a slower relisting
pending = []

View File

@@ -722,6 +722,9 @@ def watched(monkeypatch, tmp_path, all_mode):
st.files = [change("a.py"), change("b.py")]
st.change, st.all_mode = st.files[0], all_mode
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
@@ -750,6 +753,27 @@ def test_refresh_if_changed_watches_the_shown_file_and_the_index(monkeypatch, tm
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):
messages = []
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()
shown = [line for _, line, _ in st.all_rows if line is not None]
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
# working-tree mode: nothing dimmed, every single-action row is a link
assert 'class="dim"' not in html and "uncommitted only" not in html
assert 'href="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
assert 'href="next_change"' not in html and 'href="stage"' not in html
# 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
render.init_view(view, state.MODE_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
# a commit is its subject, author, date and stats rows, then a rule
ROWS_PER_COMMIT = 5
COLUMN_GAP = "| "
RULE = "-"
# box drawing: the column line, the rule under each item, and the column line's branch into a rule on the rule rows
COLUMN_GAP = "│ "
RULE = "─"
JUNCTION = "├─"
HEADING_BRANCHES = "BRANCHES"
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
left = _left_cells(st)
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] = []
metadata: List[BranchRow] = []
gap = width + len(COLUMN_GAP)
for i in range(max(len(left), len(right))):
left_text = _left_text(left[i], width) if i < len(left) else " " * width
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
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

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:
"""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:
branches.refresh_if_changed(view, st)
return
@@ -304,6 +305,16 @@ def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
return
if render.fingerprint(st) != st.fingerprint:
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):
@@ -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:
"""Confirm if asked, run the operation off the UI thread, then refresh the listing"""
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")
return
if change is None:
status("no file under the caret")
return
if confirm is not None and not confirm(change):
return
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:
"""Reload what is shown: the branches, all diffs, the commit's file, or the working-tree listing"""
if st.in_branches or st.all_mode or st.in_commit:
"""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.in_commit:
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)
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:
change = render.all_change_at(view, st)
if change is not None:
st.change = change
return 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"""
change = _current_change(view, st)
if change is None:
status("no file under the caret")
return
path = os.path.join(st.repo, change.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:
row = render.caret_index(view)
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:
line = parse.line_for_open(st.lines, render.caret_index(view))
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")
return
change = _current_change(view, st)
index = state.find_change(st.files, change.path if change else None)
# nothing selected yet: n starts at the first file, p at the last
index = (index + delta) % len(st.files) if index >= 0 else (0 if delta > 0 else len(st.files) - 1)
if change is not None:
index = (state.find_change(st.files, change.path) + delta) % len(st.files)
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.collapsed.discard(st.change.path)
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"""
if st.all_mode:
# 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
targets = st.all_change_targets
if not targets:
@@ -834,14 +858,13 @@ def restore_view(view: sublime.View) -> ViewState:
else:
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
index = state.find_change(files, path)
if index >= 0:
st.change = files[index]
redisplay(view, st)
else:
render.render_message(view, st, f"{path} has no uncommitted changes any more")
if not files:
render.render_message(view, st, f"No uncommitted changes in {repo}")
return
st.change = files[max(0, state.find_change(files, path))]
redisplay(view, st)
run_async(lambda: git.changed_files(repo), show_working_tree)
return st

View File

@@ -417,14 +417,16 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
palette.ensure(view)
if palette.claim_restart_notice(view, palette.DIFF_SCOPES):
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
starts = _row_starts_for_text(rows)
del_regions, ins_regions, file_regions, prefix_regions = [], [], [], []
file_del_counts, file_ins_counts, file_statuses = [], [], []
token_groups: Dict[str, List[sublime.Region]] = {}
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
ctx_resolved = palette.tokens_resolved(view, ("ctx",))
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
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:
"""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
def _finish(view: sublime.View, st: ViewState, mode: str, name: str, header: str,
key: Callable[[ViewState], Any] = fingerprint) -> None:
def _finish(view: sublime.View, st: ViewState, mode: str, name: str, header: str, key: Callable[[ViewState], Any] = fingerprint) -> None:
"""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
@@ -819,7 +828,9 @@ def _commit_parts(st: ViewState) -> List[str]:
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
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
if info:
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:
"""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 (
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{summary}'
f'<div>{title}<span class="actions">{"".join(links)}</span></div></body>'