From 25f80983147e9fe4d24227c2087ede0234ead537 Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Wed, 14 Oct 2026 11:01:07 +0200 Subject: [PATCH] various fixes found during review --- tests/test_branches.py | 68 +++++++++++------------ tests/test_commands.py | 122 +++++++++++++++++++++++++++++++++++++++++ tests/test_render.py | 16 ++++++ vdiff/branches.py | 11 ++-- vdiff/commands.py | 59 ++++++++++++++------ vdiff/render.py | 14 ++++- 6 files changed, 232 insertions(+), 58 deletions(-) diff --git a/tests/test_branches.py b/tests/test_branches.py index 60718ce..6548177 100644 --- a/tests/test_branches.py +++ b/tests/test_branches.py @@ -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) @@ -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 = [] diff --git a/tests/test_commands.py b/tests/test_commands.py index 88ab768..7153abe 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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] diff --git a/tests/test_render.py b/tests/test_render.py index 58edd11..37e62c7 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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]] diff --git a/vdiff/branches.py b/vdiff/branches.py index f5b631c..019359e 100644 --- a/vdiff/branches.py +++ b/vdiff/branches.py @@ -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 diff --git a/vdiff/commands.py b/vdiff/commands.py index 7367569..a16f080 100644 --- a/vdiff/commands.py +++ b/vdiff/commands.py @@ -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 diff --git a/vdiff/render.py b/vdiff/render.py index 1aee0c7..bfea822 100644 --- a/vdiff/render.py +++ b/vdiff/render.py @@ -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