From fe915178c5e32d03b6837d0669f8f2bb458568a1 Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Fri, 2 Oct 2026 16:54:19 +0200 Subject: [PATCH] initial version of all-in-one diff view --- Default.sublime-keymap | 8 + README.md | 5 +- TODO.txt | 7 +- main.py | 10 +- tests/stubs/sublime.py | 10 ++ tests/test_commands.py | 87 ++++++++++ tests/test_render.py | 28 +++- vDiff.sublime-settings | 3 + vdiff/commands.py | 244 ++++++++++++++++++++++++--- vdiff/git.py | 41 +++-- vdiff/listeners.py | 3 +- vdiff/palette.py | 124 ++++++++++---- vdiff/parse.py | 19 ++- vdiff/render.py | 366 ++++++++++++++++++++++++++++++++++++----- vdiff/state.py | 29 +++- vdiff/utils.py | 3 +- 16 files changed, 850 insertions(+), 137 deletions(-) diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 9c5f6e2..cba57c2 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -13,6 +13,14 @@ "context": [{"key": "setting.vdiff_view"}] }, { "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"}, "context": [{"key": "setting.vdiff_view"}] }, + { "keys": ["v"], "command": "vdiff_action", "args": {"action": "view"}, + "context": [{"key": "setting.vdiff_view"}] }, + { "keys": ["right"], "command": "vdiff_action", "args": {"action": "expand"}, + "context": [{"key": "setting.vdiff_view"}] }, + { "keys": ["left"], "command": "vdiff_action", "args": {"action": "collapse"}, + "context": [{"key": "setting.vdiff_view"}] }, + { "keys": ["z"], "command": "vdiff_action", "args": {"action": "fold_all"}, + "context": [{"key": "setting.vdiff_view"}] }, { "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"}, "context": [{"key": "setting.vdiff_view"}] }, { "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"}, diff --git a/README.md b/README.md index 2df9400..f1ed586 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,15 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov | `o` or Enter | open the real file at this line | | `]` / `[` | next / previous change (wraps at the ends) | | `n` / `p` | next / previous file of the current list (wraps at the ends) | +| `v` | switch between the file-by-file and all-files views | +| `left` / `right` | collapse / expand the file under the caret in the all-files view | +| `z` | collapse all files in the all-files view, or expand them all when every one is collapsed | | `j` / `k` | move down / up one line (vim keys) | | `r` | refresh | | `?` | show this key list in a popup | | Esc | close the view | -The header links do the same with the mouse. Settings: `vDiff.sublime-settings`. +The header links do the same with the mouse. The all-files view shows five context lines around each change by default; configure this with `all_files_context` in `vDiff.sublime-settings`. ## The channel diff --git a/TODO.txt b/TODO.txt index fb4a6d0..fb5607f 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,6 +1,9 @@ +## Bugs +- In Commits list commit message can be too long to move changes stats too far to the right so its not visible or is partially visible only + + ## New functionality - - Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?) - Add pull and push action @@ -9,10 +12,8 @@ ## Improvements - - commit messages are oneline. What are options for multiline editing? - visual bell blinking time setting ## Code - diff --git a/main.py b/main.py index 204cdc8..66f4e52 100644 --- a/main.py +++ b/main.py @@ -1,18 +1,17 @@ -""" vDiff plugin entry point: review uncommitted changes and recent commits inside Sublime Text +"""vDiff plugin entry point: review uncommitted changes and recent commits inside Sublime Text -Sublime reloads a plugin only when this file changes, so save it after editing a submodule. +Sublime reloads a plugin only when this file changes, so save it after editing a submodule """ import logging import sys import sublime -# sublime reloads only this file: drop the submodules so an edited one is re-imported below. -# Open views lose in-memory state and are rebuilt from their settings by _restore_all. +# drop the submodules so an edited one is re-imported; open views are rebuilt from settings by _restore_all for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]: del sys.modules[_name] -# imported for sublime's command discovery, not called here +# command and listener classes are imported for sublime's discovery from .vdiff.commands import ( VdiffActionCommand, VdiffCommitsCommand, @@ -35,6 +34,7 @@ def plugin_loaded(): def _restore_all(): + """Rebuild every review view, in every window, that has no in-memory state yet""" for window in sublime.windows(): for view in window.views(): if state.is_review_view(view) and state.get(view) is None: diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py index cd5898b..aa7e9fc 100644 --- a/tests/stubs/sublime.py +++ b/tests/stubs/sublime.py @@ -224,9 +224,19 @@ class View: def set_status(self, key, value): self.status[key] = value + def window(self): + # detached: the all-files view then skips tokenising in a hidden panel + return None + + def viewport_position(self): + return (0, 0) + def set_viewport_position(self, xy, animate=True): pass + def show(self, point): + pass + def show_at_center(self, point): pass diff --git a/tests/test_commands.py b/tests/test_commands.py index c7c1008..2794558 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -583,3 +583,90 @@ def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch): monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text)) assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == [] assert messages == ["No uncommitted changes in /repo"] + + +def all_files_view(): + """An all-files view of a.py (two hunks) and b.py (one), rendered with the caret at the top""" + from vdiff import parse + lines_a = [parse.Line(" ", i, i, f"a{i}") for i in range(1, 30)] + lines_a[2] = parse.Line("+", None, 3, "new a3") + lines_a[25] = parse.Line("+", None, 26, "new a26") + lines_b = [parse.Line(" ", 1, 1, "b1"), parse.Line("-", 2, None, "old b2")] + view = sublime.View() + st = state.ViewState("/repo") + st.all_mode = True + st.files = [change("a.py"), change("b.py")] + st.all_diffs = {path: (lines, parse.change_blocks(lines), False) + for path, lines in (("a.py", lines_a), ("b.py", lines_b))} + commands.render.render_all(view, st) + return view, st + + +def heading_row(st, path): + return next(i for i, (c, _, section) in enumerate(st.all_rows) if section and c.path == path) + + +def put_caret(view, row): + view.sel().clear() + view.sel().add(sublime.Region(commands.render.index_point(view, row))) + + +def test_all_files_step_change_from_a_heading_lands_on_its_first_hunk(): + view, st = all_files_view() + rows = dict(zip(st.all_change_row_targets, st.all_change_rows)) + + # from the top, outside any file: the very first hunk, not the second + commands.ACTIONS["next_change"](view, None, st) + assert commands.render.caret_index(view) == rows[("a.py", 0)] + + # from a.py's heading: its first hunk + put_caret(view, heading_row(st, "a.py")) + commands.ACTIONS["next_change"](view, None, st) + assert commands.render.caret_index(view) == rows[("a.py", 0)] + + # from b.py's heading back: the hunk before b.py's first, a.py's last + put_caret(view, heading_row(st, "b.py")) + commands.ACTIONS["prev_change"](view, None, st) + assert commands.render.caret_index(view) == rows[("a.py", 1)] + + # from a hunk: the next one, across files + commands.ACTIONS["next_change"](view, None, st) + assert commands.render.caret_index(view) == rows[("b.py", 0)] + + +def test_arrows_do_nothing_in_the_single_file_view(): + view, st = at_change([(2, 4)], 2) + st.change = change("a.py") + commands.ACTIONS["collapse"](view, None, st) + assert st.collapsed == set() and not st.all_mode and st.all_rows == [] + + +def test_git_action_refresh_stays_in_the_all_files_view(monkeypatch): + view, st = all_files_view() + st.change = st.files[1] + monkeypatch.setattr(commands, "run_async", lambda work, done: done(work())) + monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py"), change("b.py")]) + shown = [] + monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(focus)) + monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(("file", c.path))) + commands.refresh_listing(view, st, pick=None) + assert shown == ["b.py"] and st.change.path == "b.py" + + +def test_all_files_step_file_from_nothing_and_with_no_files(): + view, st = all_files_view() + commands.ACTIONS["next_file"](view, None, st) + assert st.change.path == "a.py" + st.change = None + put_caret(view, 0) + commands.ACTIONS["prev_file"](view, None, st) + assert st.change.path == "b.py" + st.files = [] + commands.ACTIONS["next_file"](view, None, st) # no ZeroDivisionError + + +def test_all_files_context_zero_shows_only_changed_lines(monkeypatch): + option(monkeypatch, "all_files_context", 0) + view, st = all_files_view() + shown = [line for _, line, _ in st.all_rows if line is not None] + assert shown and all(line.kind != " " for line in shown) diff --git a/tests/test_render.py b/tests/test_render.py index 813f18d..0859d60 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -49,7 +49,18 @@ def test_render_text_regions_and_header(): st = make_state() render.render(view, st) - assert view.text.split("\n") == ["", "def f():", " return 1", "", " return 10", " # added", " # more", "", "def g():", " pass"] + assert view.text.split("\n") == [ + "", + "def f():", + " return 1", + "", + " return 10", + " # added", + " # more", + "", + "def g():", + " pass", + ] assert view.is_read_only() assert view.syntax.endswith("Python.sublime-syntax") @@ -141,9 +152,9 @@ def test_help_popup_lists_every_key(): view, html = help_popup(st) assert view.is_popup_visible() - for group in ("Navigate", "Panels", "Actions", "View"): + for group in ("Navigate", "Actions", "View"): assert f">{group}<" in html - for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "?", "esc"): + for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "z", "esc"): assert f">{keys}<" in html assert "next / previous change" in html and "open the real file at this line" in html # working-tree mode: nothing dimmed, every single-action row is a link @@ -151,8 +162,8 @@ def test_help_popup_lists_every_key(): assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html # rows covering two actions stay plain text assert 'href="next_change"' not in html - # the status bar advertises it too - assert "? keys" in view.status[render.STATUS_KEY] + # the popup does not list the key that opens it + assert ">?<" not in html def test_help_popup_dims_file_actions_in_commit_mode(): @@ -199,11 +210,12 @@ def test_keymap_matches_help_table(): assert all(b["command"] == "vdiff_action" for b in bindings) bound = {b["args"]["action"] for b in bindings} listed = {action for k in render.KEYS for action in k.actions} - assert bound == set(commands.ACTIONS) == listed + # "?" opens the popup, so the popup leaves it out + assert bound == set(commands.ACTIONS) == listed | {"help"} # rebinding a key without updating the table would leave the popup and status bar lying bound_keys = {DISPLAY_KEYS.get(key, key) for b in bindings for key in b["keys"]} - assert bound_keys == {token for k in render.KEYS for token in k.keys.split()} + assert bound_keys == {token for k in render.KEYS for token in k.keys.split()} | {"?"} def test_no_binding_yields_to_vintage_command_mode(): @@ -418,5 +430,3 @@ def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch): # said once per scheme, not once per render render.render(view, make_state()) assert said == ["vDiff: " + render.NEEDS_RESTART] - - diff --git a/vDiff.sublime-settings b/vDiff.sublime-settings index a53c8f2..d8f58b8 100644 --- a/vDiff.sublime-settings +++ b/vDiff.sublime-settings @@ -11,6 +11,9 @@ // mark the changed characters inside paired deleted/inserted lines "word_diff": true, + // number of unchanged lines shown around each change in the all-files view + "all_files_context": 5, + // git executable; sublime's PATH may not include a user-installed git "git_binary": "git", diff --git a/vdiff/commands.py b/vdiff/commands.py index 08c7bc5..630b37c 100644 --- a/vdiff/commands.py +++ b/vdiff/commands.py @@ -1,4 +1,4 @@ -""" Commands: the two quick panels (changed files, recent commits) and the actions of a review view """ +"""Commands: the two quick panels (changed files, recent commits) and the actions of a review view""" import bisect import os from typing import Callable, Dict, List, Optional, Sequence, Tuple @@ -11,7 +11,7 @@ from .git import Commit, FileChange from .state import Kept, ViewState from .utils import file_count, run_async, settings, status -# what an ACTIONS entry does: act(view, window, state) for the review view it was fired in +# an ACTIONS entry: act(view, window, state) on the review view it fired in Action = Callable[[sublime.View, sublime.Window, ViewState], None] # quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES @@ -27,15 +27,14 @@ STATUS_COLOURS: Dict[str, int] = { } COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit") -# the annotation is drawn hard right of the subject, so past this budget the subject is clipped. -# The floor keeps a readable stub on a narrow panel. +# row width shared by subject and annotation; the subject is clipped to fit, never below MIN_SUBJECT_WIDTH COMMIT_ROW_WIDTH = 120 MIN_SUBJECT_WIDTH = 24 # highlight-to-preview delay: holding an arrow key must not spawn a git process per row PREVIEW_DELAY_MS = 60 -# directory -> repository root; roots do not move, and resolving runs on the UI thread +# cache directory -> repo root: resolve_repo runs git on the UI thread _roots: Dict[str, str] = {} @@ -43,6 +42,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]: """Repository of the review view or active file, else of the first project folder that is one""" view = window.active_view() candidates: List[str] = [] + # a review view already knows its repo; a file's directory is tried before the project folders if view is not None: if state.is_review_view(view): st = state.get(view) or restore_view(view) @@ -53,6 +53,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]: if name: candidates.append(os.path.dirname(name)) candidates.extend(window.folders()) + # first candidate in a repo wins; only hits are cached for directory in candidates: root = _roots.get(directory) if root is None: @@ -65,6 +66,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]: def find_review_view(window: sublime.Window) -> Optional[sublime.View]: + """The window's first review view, if it has one""" for view in window.views(): if state.is_review_view(view): return view @@ -72,7 +74,7 @@ def find_review_view(window: sublime.Window) -> Optional[sublime.View]: def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]: - """(view, state) of the window's single review view for the resolved repo, focused; None without a repo""" + """Focus the window's review view (created if missing) on the resolved repo; (view, state), or None without a repo""" repo = resolve_repo(window) if not repo: status("no git repository for the active file or project folders") @@ -89,8 +91,32 @@ def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewStat return view, st +def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) -> None: + """Load all file diffs off the UI thread and switch to the aggregate view, on `focus`'s heading if given""" + st.all_mode = True + # bump the token so earlier loads drop their results + st.token += 1 + token, files = st.token, list(st.files) + def work() -> Dict[str, Tuple[List[parse.Line], List[parse.Block], bool]]: + """Parse every listed file's diff and its change blocks, keyed by path""" + result = {} + for change in files: + diff = parse.parse_unified(git.full_diff(st.repo, change.path, sha=st.sha, untracked=(change.status == "?"))) + result[change.path] = (diff.lines, parse.change_blocks(diff.lines), diff.binary) + return result + def done(result: Dict[str, Tuple[List[parse.Line], List[parse.Block], bool]]) -> None: + """Store the diffs and draw the aggregate view unless superseded or closed""" + if token == st.token and view.is_valid(): + st.set_all_diffs(result) + if st.change is None and files: + st.change = files[0] + render.render_all(view, st, focus=focus) + run_async(work, done) + + def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None: """Fetch and parse the file's diff on the worker thread, then render it""" + st.all_mode = False st.token += 1 # set before the worker starts: n / p count from here while the diff is still loading st.requested = change @@ -98,6 +124,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None: repo, sha = st.repo, st.sha def work() -> Optional[Tuple[parse.Diff, List[parse.Block]]]: + """Run git diff and parse it, unless a newer request already superseded this one""" if token != st.token: return None # superseded before it started: skip the subprocess text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?")) @@ -105,6 +132,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None: return diff, parse.change_blocks(diff.lines) def done(result: Optional[Tuple[parse.Diff, List[parse.Block]]]) -> None: + """Make the file current and render it if this is still the latest request""" if result is None or token != st.token or not view.is_valid(): return diff, blocks = result @@ -117,6 +145,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None: def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]: + """Quick-panel rows: path; status, staged flag, rename source; line totals or binary""" items: List[sublime.QuickPanelItem] = [] for change in files: description = git.STATUS_NAMES.get(change.status, "") @@ -125,6 +154,7 @@ def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]: details = ", ".join(filter(None, (description, "staged" if change.staged else ""))) if change.old_path: details += " ← " + change.old_path + # no line counts means git saw a binary file if None in (change.insertions, change.deletions): annotation = "binary" else: @@ -134,13 +164,14 @@ def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]: def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]: - """Quick-panel rows: subject on the left, its totals and the hash right-aligned""" + """Quick-panel rows: subject, author · date, and diffstat plus short hash right-aligned""" items: List[sublime.QuickPanelItem] = [] for c in commits: + # merges and empty commits have no diffstat, so they show the hash alone annotation = c.short if c.files is not None: - # merges and empty commits have no diffstat, so they show the hash alone annotation = f"{file_count(c.files)} changed -{c.deletions} +{c.insertions} {c.short}" + # clip the subject so the annotation keeps its room subject = c.subject or "(no subject)" budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH) if len(subject) > budget: @@ -152,19 +183,21 @@ def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]: def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange], kept: Kept, on_cancel: Optional[Callable[[], None]] = None, placeholder: str = "Changed files") -> None: - """Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)""" + """Quick panel over `files`: highlight previews, Enter shows, Esc restores `kept` (then runs on_cancel)""" + # the panel opens on the shown file, the view focused behind it st.files = files selected = max(0, st.index()) window.focus_view(view) def on_select(index: int) -> None: + """Enter shows the chosen file; Esc restores `kept`, then runs on_cancel if given""" st.highlight += 1 # cancel a pending preview if index == -1: if on_cancel is not None: st.restore(kept) on_cancel() elif kept.change is not None: - # go back to the file that was kept before the panel opened + # reshow the kept file st.restore(kept) show_file(view, st, kept.change) # nothing was kept before: the last previewed file stays @@ -175,12 +208,14 @@ def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, show_file(view, st, change) def on_highlight(index: int) -> None: + """Schedule a debounced preview of the highlighted file""" if not 0 <= index < len(files): return st.highlight += 1 ticket = st.highlight def preview() -> None: + """Show the file unless another highlight came since""" if ticket == st.highlight: show_file(view, st, files[index]) @@ -193,14 +228,16 @@ def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] = pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> None: """Re-list the working tree changes and show the same file, or the next one when it is gone - `pick(files, index)` may name another file instead; `index` is the shown file's slot, new or vacated. + `pick(files, index)` may choose another file; `index` is the shown file's new slot, or its old one if it left the list """ if message: status(message) + # remember the shown file and its slot before the list changes previous_index = max(0, st.index()) path = st.change.path if st.change else None def done(files: List[FileChange]) -> None: + """Show the same file again, its slot's successor, or what `pick` chooses""" if not view.is_valid(): return st.files = files @@ -212,7 +249,13 @@ def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] = # the file left the list: whatever moved up into its slot takes over index = min(previous_index, len(files) - 1) chosen = pick(files, index) if pick else None - show_file(view, st, chosen if chosen is not None else files[index]) + change = chosen if chosen is not None else files[index] + if st.all_mode: + # stay in the aggregate view, re-diffed over the new list, on the same file + st.change = change + show_all(view, st, focus=change.path) + else: + show_file(view, st, change) run_async(lambda: git.changed_files(st.repo), done) @@ -229,6 +272,7 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand): """List uncommitted changes (staged, unstaged, untracked) in a quick panel""" def run(self) -> None: + """Open the review view, then list the changed files off the UI thread""" window = self.window opened = open_review(window) if not opened: @@ -236,6 +280,7 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand): view, st = opened def done(files: List[FileChange]) -> None: + """Show the files panel, or a notice when there is nothing uncommitted""" if not view.is_valid(): return kept = st.enter() @@ -253,6 +298,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand): """List recent commits, then the files of the chosen commit; `sha` skips straight to one commit""" def run(self, selected: int = 0, sha: Optional[str] = None) -> None: + """Open the review view, then the given commit or the recent-commits panel""" opened = open_review(self.window) if not opened: return @@ -263,6 +309,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand): count = int(settings().get("commits_count", 30) or 30) def done(commits: List[Commit]) -> None: + """Show the commits panel unless the repository has none""" if not view.is_valid(): return if not commits: @@ -273,22 +320,25 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand): run_async(lambda: git.recent_commits(st.repo, count), done) def show_commits(self, view: sublime.View, st: ViewState, commits: Sequence[Commit], selected: int) -> None: + """Recent-commits panel, preselecting the commit already shown""" items = commit_items(commits) for i, c in enumerate(commits): if c.sha == st.sha: selected = i def on_select(index: int) -> None: + """Open the chosen commit's files, remembering its row for Esc""" if index != -1: self.open_commit(view, st, commits[index].sha, index) self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits") def open_commit(self, view: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None: - """Files panel of one commit; Esc goes back to the commit list when it came from there (index given)""" + """Files panel of one commit; with `index`, Esc reopens the commit list on that row""" window = self.window def done(result: Tuple[git.CommitInfo, List[FileChange]]) -> None: + """Enter the commit and show its files panel, or a notice when it touches none""" if not view.is_valid(): return info, files = result @@ -298,6 +348,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand): return def back() -> None: + """Reopen the commit list on the row this commit came from""" window.run_command("vdiff_commits", {"selected": index}) placeholder = f"{info.short} · {info.subject}" @@ -311,31 +362,36 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand): def _act_files(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Open the changed-files panel""" window.run_command("vdiff_files") def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Open the recent-commits panel""" window.run_command("vdiff_commits") def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Close the review view""" view.close() def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Show the help popup""" render.show_help(view, st) def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None], confirm: Optional[Callable[[FileChange], bool]] = None, pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> Action: - """Run `operation(repo, change)` on the shown working-tree file, then re-list; `pick` goes to refresh_listing""" + """Action running `operation(repo, change)` on the current working-tree file, then re-listing; `pick` is passed to refresh_listing""" def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None: - if st.in_commit or st.change is 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: status(f"{verb} works on uncommitted changes only") return - change = st.change if confirm is not None and not confirm(change): return run_async( @@ -347,8 +403,10 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange] def _confirm_discard(change: FileChange) -> bool: + """Ask before discarding or deleting, unless the confirm_discard setting is off""" if not settings().get("confirm_discard", True): return True + # untracked files are deleted outright, tracked ones reset if change.status == "?": message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone." else: @@ -372,12 +430,16 @@ def _stage_next(files: Sequence[FileChange], index: int) -> Optional[FileChange] """After staging, the next unstaged file to show; None keeps the file the refresh would show""" if not settings().get("stage_and_move_next_file", True): return None - # the file just staged is staged now, so a scan starting on it moves off by itself + # scanning from the staged file's slot is safe: it is skipped as staged following = _next_unstaged(files, index) return files[following] if following >= 0 else None def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Reload what is shown: all diffs, the commit's file, or the working-tree listing""" + if st.all_mode: + show_all(view, st) + return if st.change is None: return if st.in_commit: @@ -386,26 +448,92 @@ def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> N 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""" + if st.all_mode: + change = render.all_change_at(view, st) + if change is not None: + st.change = change + return st.change + + def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None: - if st.change is None: + """Open the working-tree file at the line under the caret""" + change = _current_change(view, st) + if change is None: return - path = os.path.join(st.repo, st.change.path) + path = os.path.join(st.repo, change.path) if not os.path.exists(path): - status(f"{st.change.path} does not exist in the working tree") + status(f"{change.path} does not exist in the working tree") return - line = parse.line_for_open(st.lines, render.caret_index(view)) + # map the caret row to a line number of the new side + 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 + else: + line = parse.line_for_open(st.lines, render.caret_index(view)) window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION) -def _step_file(delta: int) -> Action: +def _act_view(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Switch between the single-file and the aggregate view""" + if st.all_mode: + st.all_mode = False + if st.change is not None: + show_file(view, st, st.change) + else: + show_all(view, st) + + +def _toggle_file(delta: int) -> Action: + """Action expanding (delta > 0) or collapsing the caret's file in the aggregate view""" def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Expand or collapse the file under the caret and redraw focused on it""" + if not st.all_mode: + return + change = _current_change(view, st) + if change is not None: + (st.collapsed.discard if delta > 0 else st.collapsed.add)(change.path) + render.render_all(view, st, focus=change.path) + return act + + +def _act_fold_all(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Collapse every file while any is expanded, otherwise expand them all""" + if not st.all_mode: + return + change = _current_change(view, st) + paths = {c.path for c in st.files} + st.collapsed = set() if st.collapsed >= paths else paths + render.render_all(view, st, focus=change.path if change else None) + + +def _step_file(delta: int) -> Action: + """Action moving `delta` files through the list, wrapping round""" + def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Move to the neighbouring file: scroll to it in the aggregate view, else show its diff""" + # aggregate view: unfold the neighbouring file and scroll to it + if st.all_mode: + if not st.files: + 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) + st.change = st.files[index] + st.collapsed.discard(st.change.path) + render.render_all(view, st, focus=st.change.path, center=True) + return + # single-file view: show the neighbouring file, ringing the bell on a wrap if not st.files: status("no file list, press f or c") return count = len(st.files) position = st.index() if position < 0: - # nothing shown yet: n starts at the first file, p at the last. Not a wrap. + # nothing shown yet: n starts at the first file, p at the last, no wrap index, wrapped = (0 if delta > 0 else count - 1), False else: index = position + delta @@ -421,7 +549,42 @@ def _step_file(delta: int) -> Action: def _step_change(delta: int) -> Action: + """Action moving the caret `delta` changes on, wrapping round""" def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """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) + rows = st.all_change_rows + targets = st.all_change_targets + if not targets: + return + current = render.caret_index(view) + current_path = current_change.path if current_change else None + # anchor: the caret file's last shown hunk at or above the caret + current_target = None + for row, target in zip(rows, st.all_change_row_targets): + if target[0] == current_path and row <= current: + current_target = target + if current_target in targets: + # step from the anchor, wrapping round + index = targets.index(current_target) + delta + else: + # above the file's first hunk (heading, folded) or outside any file: its first hunk + # is next and the one before it previous; outside a file, the list's first or last + first = next((i for i, t in enumerate(targets) if t[0] == current_path), None) + if first is None: + index = 0 if delta > 0 else -1 + else: + index = first if delta > 0 else first - 1 + target = targets[index % len(targets)] + # make the target's file current, unfold it and centre on the hunk + target_change = next(c for c in st.files if c.path == target[0]) + st.change = target_change + st.collapsed.discard(target_change.path) + render.render_all(view, st, focus=target, center=True) + return + # single-file view: step between change starts from the caret row starts = [start for start, _ in st.blocks] if not starts: status("no changes to step through") @@ -431,6 +594,7 @@ def _step_change(delta: int) -> Action: if not 0 <= position < len(starts): render.flash(view) position %= len(starts) # -1 is the last change, len(starts) the first + # move the caret onto the change and centre it point = render.index_point(view, starts[position]) view.sel().clear() view.sel().add(sublime.Region(point)) @@ -450,6 +614,7 @@ def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> No return def on_done(message: str) -> None: + """Commit with the typed message off the UI thread, or cancel on an empty one""" if not message.strip(): status("commit cancelled: empty message") return @@ -464,7 +629,7 @@ def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> No def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> None: """Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels - The one-line panel offers only the subject; the body rides along verbatim, and nothing staged is a reword. + The one-line panel offers only the subject; the body rides along verbatim, and nothing staged is a reword """ if st.in_commit: status("amend works on uncommitted changes only") @@ -472,9 +637,11 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non staged = [c for c in st.files if c.staged] def prompt(message: str) -> None: + """Show the input panel prefilled with HEAD's subject""" subject, newline, body = message.partition("\n") def on_done(text: str) -> None: + """Amend with the typed subject and the original body, or cancel on an empty one""" if not text.strip(): status("amend cancelled: empty message") return @@ -482,7 +649,7 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non if staged: done += f" with {file_count(len(staged))}" run_async( - # nothing staged makes this a pure reword, which git refuses on an empty commit + # a reword needs --allow-empty when HEAD itself is an empty commit lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged), lambda _: refresh_listing(view, st, done), ) @@ -496,7 +663,9 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non def _move_line(forward: bool) -> Action: + """Action moving the caret one line down (forward) or up""" def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None: + """Move the caret one line""" view.run_command("move", {"by": "lines", "forward": forward}) return act @@ -513,6 +682,10 @@ ACTIONS: Dict[str, Action] = { "commit": _act_commit, "amend": _act_amend, "refresh": _act_refresh, + "view": _act_view, + "expand": _toggle_file(1), + "collapse": _toggle_file(-1), + "fold_all": _act_fold_all, "open": _act_open, "next_file": _step_file(1), "prev_file": _step_file(-1), @@ -527,9 +700,11 @@ class VdiffActionCommand(sublime_plugin.TextCommand): """Every key/link action of a review view, dispatched by name through ACTIONS""" def is_enabled(self, action: Optional[str] = None) -> bool: + """Only in review views""" return state.is_review_view(self.view) def run(self, edit: sublime.Edit, action: str) -> None: + """Run the named action with the view's state, rebuilt if lost (e.g. after a restart)""" act = ACTIONS.get(action) if act is None: status(f"unknown action {action}") @@ -540,36 +715,51 @@ class VdiffActionCommand(sublime_plugin.TextCommand): def restore_view(view: sublime.View) -> ViewState: """Rebuild the state of a review view restored from a previous session; re-renders asynchronously""" + # the repo, commit and file saved with the view data = view.settings().get(state.VIEW_DATA) or {} repo = data.get("repo") render.init_view(view) + # repository gone: show a notice if not repo or not os.path.isdir(repo): st = state.put(view, state.ViewState("")) render.render_message(view, st, "Repository not found. Press f for changed files, c for commits.") return st st = state.put(view, state.ViewState(repo)) st.sha = data.get("sha") + st.all_mode = bool(data.get("all_mode", False)) path = data.get("path") if not path: render.render_message(view, st, "Press f for changed files, c for commits.") return st + # stand-in FileChange: no line counts or rename source change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None) + # a commit view re-lists the commit's files, a working-tree view the uncommitted changes sha = st.sha if sha is not None: def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None: + """Adopt the commit's files and redraw the saved file or the aggregate view""" st.commit, st.files = result - show_file(view, st, change) + if st.all_mode: + st.change = change + show_all(view, st) + else: + show_file(view, st, change) run_async(lambda: git.commit_with_files(repo, sha), show_commit) else: def show_working_tree(files: List[FileChange]) -> None: + """Redraw the saved file if it still has changes, else say it has none""" st.files = files index = state.find_change(files, path) if index >= 0: - show_file(view, st, files[index]) + if st.all_mode: + st.change = files[index] + show_all(view, st) + else: + show_file(view, st, files[index]) else: render.render_message(view, st, f"{path} has no uncommitted changes any more") diff --git a/vdiff/git.py b/vdiff/git.py index 80d649c..4d0e0ae 100644 --- a/vdiff/git.py +++ b/vdiff/git.py @@ -1,4 +1,4 @@ -""" Git access: every call is one subprocess; results are plain tuples, no sublime imports (testable) """ +"""Git access via subprocess; results are plain tuples, no sublime imports (testable)""" import os import re import subprocess @@ -6,7 +6,7 @@ from typing import Dict, List, Optional, NamedTuple, Sequence, Tuple class FileChange(NamedTuple): - """path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source""" + """Path, one-letter status (see STATUS_NAMES), staged flag, numstat counts (None for binary), rename source""" path: str status: str @@ -17,7 +17,7 @@ class FileChange(NamedTuple): class Commit(NamedTuple): - """shortstat totals; all three are None when git prints no diffstat (merges, empty commits)""" + """One log entry, date relative; files/insertions/deletions are None without a diffstat (merges, empty commits)""" sha: str short: str @@ -30,6 +30,8 @@ class Commit(NamedTuple): class CommitInfo(NamedTuple): + """One commit: its hashes, author, date, subject and body""" + sha: str short: str author: str @@ -69,9 +71,10 @@ _SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?( class GitError(Exception): - """git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it""" + """Git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it""" def __init__(self, stderr: str, code: int) -> None: + """Keep stderr and the exit code""" self.stderr = stderr self.code = code first = stderr.strip().splitlines() @@ -91,9 +94,11 @@ def repo_root(path: Optional[str]) -> Optional[str]: """Top-level directory of the repository containing `path`, or None""" if not path: return None + # start from the directory itself, or the file's parent directory = path if os.path.isdir(path) else os.path.dirname(path) if not os.path.isdir(directory): return None + # outside a repository git fails, which reads as None try: root = run(directory, ["rev-parse", "--show-toplevel"]).strip() except (GitError, OSError): @@ -116,13 +121,14 @@ def base_ref(repo: str) -> str: def _int_or_none(value: str) -> Optional[int]: - """A numstat count: "-" marks a binary file""" + """A numstat count as int, None for "-" (binary)""" return None if value == "-" else int(value) def _parse_numstat(text: str) -> Dict[str, Numstat]: """`--numstat -z` output -> {path: (insertions, deletions, old_path)}""" result: Dict[str, Numstat] = {} + # each NUL field is one "insertionsdeletionspath" entry fields = iter(text.split("\0")) for entry in fields: parts = entry.split("\t") @@ -141,6 +147,7 @@ def _parse_numstat(text: str) -> Dict[str, Numstat]: def _parse_name_status(text: str) -> List[Tuple[str, str, Optional[str]]]: """`--name-status -z` output -> [(status letter, path, old_path)]""" result: List[Tuple[str, str, Optional[str]]] = [] + # a status field, then its path; a rename or copy adds the new path after the old one fields = iter(text.split("\0")) for status in fields: if not status: @@ -157,11 +164,13 @@ def _count_lines(path: str) -> Optional[int]: """Line count of a working-tree file, None when binary or unreadable""" try: with open(path, "rb") as f: + # a NUL in the first 8000 bytes marks a binary file head = f.read(8000) if b"\0" in head: return None count = head.count(b"\n") last = head[-1:] + # the rest in 1 MiB chunks, remembering the final byte while True: chunk = f.read(1 << 20) if not chunk: @@ -170,6 +179,7 @@ def _count_lines(path: str) -> Optional[int]: last = chunk[-1:] except OSError: return None + # an empty file has no lines; a last line without a newline still counts if not last: return 0 return count + (0 if last == b"\n" else 1) @@ -183,6 +193,7 @@ def changed_files(repo: str) -> List[FileChange]: base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD" counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base])) + # "1" ordinary, "u" unmerged, "?" untracked entries; anything else (branch headers) is skipped changes: List[FileChange] = [] for entry in entries: kind = entry[0] @@ -195,9 +206,11 @@ def changed_files(repo: str) -> List[FileChange]: xy, path = ".?", entry[2:] else: continue + # any index change means staged; the worktree letter wins over the index letter x, y = xy[0], xy[1] staged = x not in (".", "?") letter = y if y != "." else x + # untracked files have no numstat: their lines count as insertions if letter == "?": lines = _count_lines(os.path.join(repo, path)) ins, dele, old = lines, (None if lines is None else 0), None @@ -212,8 +225,9 @@ def recent_commits(repo: str, count: int) -> List[Commit]: """Last `count` commits, newest first; empty list without commits""" if not has_head(repo): return [] - # --shortstat rides along on the same call: one subprocess, totals for free + # --shortstat adds per-commit totals to the same call text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"]) + # a five-field line starts a commit; the shortstat line after it fills in its totals commits: List[Commit] = [] for line in text.split("\n"): parts = line.split("\x1f") @@ -233,14 +247,16 @@ def commit_with_files(repo: str, sha: str) -> Tuple[CommitInfo, List[FileChange] """(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls""" show = ["show", "--first-parent", "-m", "-z"] text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha]) - # git terminates the header with its own NUL and a newline before the file list + # our %x00 ends the header; strip the separator git adds before the file list info_text, _, files_text = text.partition("\0") files_text = files_text.lstrip("\0\n") + # pad missing trailing fields so CommitInfo always gets six parts = info_text.split("\x1f") while len(parts) < 6: parts.append("") info = CommitInfo(*parts[:6]) + # second call: per-file counts, matched to the name-status list by path counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha])) changes: List[FileChange] = [] for status, path, old_path in _parse_name_status(files_text): @@ -263,9 +279,10 @@ def full_diff(repo: str, path: str, sha: Optional[str] = None, untracked: bool = """Unified diff of one file with the whole file as context Working tree vs HEAD by default (staged and unstaged merged), a commit vs its first parent with - `sha`, the whole file as insertions when untracked. + `sha`, the whole file as insertions when untracked """ common = ["--no-color", "--no-ext-diff", FULL_CONTEXT] + # untracked: against /dev/null, where exit code 1 only means the files differ if untracked: return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1)) if sha: @@ -279,19 +296,19 @@ def stage(repo: str, change: FileChange) -> None: def commit(repo: str, message: str) -> None: - """Commit what is staged; the message is one argv element, so it needs no quoting of its own""" + """Commit what is staged; `message` goes as one argv element, unquoted""" run(repo, ["commit", "-m", message]) def head_message(repo: str) -> str: - """HEAD's whole message, exactly as git stores it""" + """HEAD's full message, surrounding newlines stripped""" return run(repo, ["log", "-1", "--format=%B"]).strip("\n") def amend(repo: str, message: str, allow_empty: bool = False) -> None: """Rewrite HEAD with `message`, folding in whatever is staged - `allow_empty` for a pure reword: git refuses an amend carrying no change, failing on an empty commit. + `allow_empty` lets a reword through when HEAD itself is empty (no diff vs its parent) """ run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message]) @@ -305,7 +322,7 @@ def unstage(repo: str, change: FileChange) -> None: def discard(repo: str, change: FileChange) -> None: - """Throw away the file's changes, staged and unstaged, restoring the HEAD version""" + """Throw away the file's staged and unstaged changes: restore HEAD's version, or delete a new file""" if change.status == "?": run(repo, ["clean", "-f", "--", change.path]) return diff --git a/vdiff/listeners.py b/vdiff/listeners.py index bc895b7..4d382db 100644 --- a/vdiff/listeners.py +++ b/vdiff/listeners.py @@ -1,4 +1,4 @@ -""" Event listener: state cleanup, refresh when the tab comes back or a file is saved """ +"""Event listener: state cleanup, refresh when the tab comes back or a file is saved""" import sublime import sublime_plugin @@ -8,6 +8,7 @@ from .commands import refresh_if_changed, restore_view class VdiffEventListener(sublime_plugin.EventListener): def on_pre_close(self, view: sublime.View) -> None: + """Forget the closing view's state""" state.drop(view) def on_activated(self, view: sublime.View) -> None: diff --git a/vdiff/palette.py b/vdiff/palette.py index e6dff33..eb5e1c8 100644 --- a/vdiff/palette.py +++ b/vdiff/palette.py @@ -1,11 +1,11 @@ -""" The colour-scheme scopes vDiff paints changed lines with, provisioned into a scheme override +"""The colour-scheme scopes vDiff paints changed lines with, provisioned into a scheme override view.add_regions repaints text with the region scope's foreground, so one scope cannot give both a red/green background and the file's syntax colours. This module generates a scope per (background kind, colour) pair carrying both, and render paints each run of same-coloured tokens with it. Sublime picks up a new override only on a restart, so the palette is probed and written in one pass -rather than a colour at a time. +rather than a colour at a time """ import json import logging @@ -16,16 +16,28 @@ import sublime LOG = logging.getLogger("vdiff") -# the full-line tint under the token layer, the scheme's own: the token layer repaints the text on -# top so the tint's foreground never shows, and a scope the scheme defines needs no restart. +# tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text SCOPE_DEL = "diff.deleted" SCOPE_INS = "diff.inserted" SCOPE_DEL_CHAR = "diff.deleted.char" SCOPE_INS_CHAR = "diff.inserted.char" +# context rows of the all-files view: the editor background, nudged so the colour still paints +SCOPE_CTX = "vdiff.context" -# background kinds, and the tint each one draws over -KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char") -KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR} +# background kinds (tinted ones paint changed lines, "ctx" the all-files context rows) and their tint scopes +TINT_KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char") +KINDS: Tuple[str, ...] = TINT_KINDS + ("ctx",) +KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR, + "ctx": SCOPE_CTX} + +# all-files file heading and its -/+ counts; the colours are user-requested +SCOPE_FILE_HEADER = "vdiff.file.header" +SCOPE_FILE_DEL_COUNT = "vdiff.file.del.count" +SCOPE_FILE_INS_COUNT = "vdiff.file.ins.count" +FILE_HEADER_BACKGROUND = "#0000FF" +# sublime's former markup.deleted / markup.inserted colours +FILE_DEL_FOREGROUND = "#d64646" +FILE_INS_FOREGROUND = "#46d646" # tints to provision when the active scheme defines none of its own (some third-party schemes) FALLBACK_TINTS: Dict[str, str] = { @@ -35,9 +47,8 @@ FALLBACK_TINTS: Dict[str, str] = { SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)", } -# scope roots to probe for the scheme's palette: schemes colour by these standard names, so one pass -# yields the working set. No API returns a scheme's palette — style_for_scope takes one scope at a -# time, and reading the file would mean re-implementing sublime's override and .tmTheme merging. +# standard scope roots probed for the palette; no API returns it whole, and parsing the scheme +# file would mean re-doing sublime's merging PALETTE_PROBES: Tuple[str, ...] = ( "source", "comment", @@ -71,7 +82,7 @@ PALETTE_PROBES: Tuple[str, ...] = ( _palettes: Dict[str, Dict[str, int]] = {} # the override name the last ensure() resolved, so scope_for knows which palette to read _active: Optional[str] = None -# schemes the reader has already been told need a restart +# schemes the user has already been told need a restart _notified: Set[Optional[str]] = set() @@ -84,22 +95,23 @@ def reset() -> None: def claim_restart_notice(view: sublime.View) -> bool: - """Whether the reader still has to be told to restart, asked once per scheme + """Whether to tell the user to restart; True at most once per scheme - True only when a restart helps — provisioned but not yet loaded; it cannot fix one we never wrote. + Only when the override is written but not yet loaded """ - if not (_active and _palettes.get(_active)) or tokens_resolved(view) or _active in _notified: + if not (_active and _palettes.get(_active)) or tokens_resolved(view, KINDS) or _active in _notified: return False _notified.add(_active) return True def scope_background(view: sublime.View, scope: str) -> Optional[str]: - """The scheme's background for a scope, or None; style_for_scope can fail on a closing view""" + """The scheme's background for a scope, or None""" return _style(view, scope).get("background") def _style(view: sublime.View, scope: str) -> Dict[str, Any]: + """The scheme's style for a scope, or {} when style_for_scope fails""" try: return view.style_for_scope(scope) except Exception: @@ -112,17 +124,24 @@ def foreground(view: sublime.View, scope: str) -> Optional[str]: return _style(view, scope).get("foreground") -def tokens_resolved(view: sublime.View) -> bool: +def tokens_resolved(view: sublime.View, kinds: Tuple[str, ...] = TINT_KINDS) -> bool: """Whether the generated (tint + colour) scopes are live The token layer must never paint with a scope sublime does not know: it would draw that text in - sublime's unknown-scope green. A new override loads only on a restart, so the writing session sees false. + sublime's unknown-scope green. A new override loads only on a restart, so the writing session sees false """ colours = (_active and _palettes.get(_active)) or {} if not colours: return False - # they are all written by the same pass into the same file, so any one of them answers for all - return bool(scope_background(view, _scope_name(KINDS[0], next(iter(colours.values()))))) + # a kind added since the override was written loads only after a restart, so check each kind + # probe a colour unlike the default foreground (an unknown scope falls back to it); + # ctx's background may go unreported + default = foreground(view, "text.plain") + colour, index = next(((c, i) for c, i in colours.items() if c != default), next(iter(colours.items()))) + # live when the rule's background shows, or its distinct foreground does + return all(scope_background(view, _scope_name(kind, index)) or + (colour != default and foreground(view, _scope_name(kind, index)) == colour) + for kind in kinds) def scope_for(kind: str, colour: str) -> Optional[str]: @@ -132,7 +151,7 @@ def scope_for(kind: str, colour: str) -> Optional[str]: def token_key(scope: str) -> str: - """The add_regions key for a generated scope; one naming rule, spelled here only""" + """The add_regions key for a generated scope""" return scope.replace("vdiff.", "vdiff.tok.", 1) @@ -143,28 +162,30 @@ def token_keys() -> List[str]: def _scope_name(kind: str, index: int) -> str: + """Generated scope name for a background kind and palette index""" return f"vdiff.{kind}.c{index}" def ensure(view: sublime.View) -> None: """Provision the scopes for the active scheme, once per scheme per session - Whether they have taken effect is tokens_resolved()'s question: a new override loads only on a restart. + A new override loads only on a restart; tokens_resolved() says when it is live """ global _active name = override_name(view) _active = name if not name or name in _palettes: return + # merge into the existing override, if any path = os.path.join(sublime.packages_path(), "User", name) try: scheme: Dict[str, Any] = {} if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: - # a hand-edited scheme may hold comments decode_value rejects: the raise leaves the - # file untouched, and render still shows the changed lines + # invalid JSON raises here, leaving the file untouched; render still shows the changed lines scheme = sublime.decode_value(f.read()) or {} rules = scheme.setdefault("rules", []) + # probe the scheme, record the palette, and write only when a rule changed tints = _tints(view) colours = {colour: i for i, colour in enumerate(_probe_palette(view))} _palettes[name] = colours @@ -176,6 +197,7 @@ def ensure(view: sublime.View) -> None: LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path) except Exception: LOG.warning("could not write colour scheme override %s", path, exc_info=True) + # mark the scheme as handled so this session does not retry it _palettes.setdefault(name, {}) @@ -192,7 +214,7 @@ def _probe_palette(view: sublime.View) -> List[str]: def _tints(view: sublime.View) -> Dict[str, str]: """{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair - All or nothing per pair: taking only the half a scheme defines would put our hue next to its own. + All or nothing per pair: taking only the half a scheme defines would put our hue next to its own """ tints: Dict[str, str] = {} for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)): @@ -200,23 +222,68 @@ def _tints(view: sublime.View) -> Dict[str, str]: char = scope_background(view, char_scope) tints[line_scope] = line if line and char else FALLBACK_TINTS[line_scope] tints[char_scope] = char if line and char else FALLBACK_TINTS[char_scope] + # a region whose background is missing, transparent or equal to the editor's is filled with its + # foreground, so context colours carry the editor background nudged by an invisible step + tints[SCOPE_CTX] = _nudge(_global_background(view)) return tints +def _global_background(view: sublime.View) -> str: + """The scheme's editor background; style_for_scope reports none for scopes that do not set one""" + try: + background = view.style().get("background") + except Exception: + LOG.debug("view.style failed", exc_info=True) + background = None + return background or "#000000" + + +def _nudge(colour: str) -> str: + """#rrggbb[aa] with blue moved one step, towards the middle so it never overflows""" + if not (colour.startswith("#") and len(colour) in (7, 9)): + return colour + blue = int(colour[5:7], 16) + return f"{colour[:5]}{blue + 1 if blue < 128 else blue - 1:02x}{colour[7:]}" + + def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int], view: sublime.View) -> bool: """Bring the scheme's rules up to date, in place; True when anything changed - add_regions draws text in the region scope's own foreground, so each (kind, colour) pair needs a - rule carrying both; a background alone would flatten the line. Plain tints are written only when - the scheme has none, and never overwritten, so a hand-tuned one survives. + Each (kind, colour) rule carries a background and a foreground, as a background alone would flatten + the line's colours; plain tints are added only when missing and never overwritten, so a hand-tuned one survives """ present = {rule.get("scope"): rule for rule in rules} changed = False + # the all-files file heading + header = present.get(SCOPE_FILE_HEADER) + wanted_header = {"scope": SCOPE_FILE_HEADER, "background": FILE_HEADER_BACKGROUND} + if header is None: + rules.append(wanted_header) + changed = True + elif header != wanted_header: + header.clear() + header.update(wanted_header) + changed = True + # its deletion and insertion counts + for scope, foreground in ((SCOPE_FILE_DEL_COUNT, FILE_DEL_FOREGROUND), + (SCOPE_FILE_INS_COUNT, FILE_INS_FOREGROUND)): + count_rule = present.get(scope) + wanted_count = {"scope": scope, "background": FILE_HEADER_BACKGROUND, + "foreground": foreground, "font_style": "bold"} + if count_rule is None: + rules.append(wanted_count) + changed = True + elif count_rule != wanted_count: + count_rule.clear() + count_rule.update(wanted_count) + changed = True + # plain tints the scheme lacks; the context tint gets no rule of its own for scope, background in tints.items(): - if scope not in present and not scope_background(view, scope): + if scope != SCOPE_CTX and scope not in present and not scope_background(view, scope): rules.append({"scope": scope, "background": background}) changed = True + # one rule per (kind, colour) pair for colour, index in colours.items(): for kind in KINDS: wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], @@ -240,6 +307,7 @@ def override_name(view: sublime.View) -> Optional[str]: name = sublime.ui_info()["color_scheme"]["resolved_value"] except Exception: name = view.settings().get("color_scheme") or "" + # no usable scheme name: nothing to override if not name or name == "auto": return None root, _ = os.path.splitext(os.path.basename(name)) diff --git a/vdiff/parse.py b/vdiff/parse.py index e686459..3729cec 100644 --- a/vdiff/parse.py +++ b/vdiff/parse.py @@ -1,4 +1,4 @@ -""" Unified diff -> lines with old/new numbers, change blocks, word-level ranges; no sublime imports """ +"""Unified diff -> lines with old/new numbers, change blocks, character-level ranges; no sublime imports""" import difflib import re from collections import Counter @@ -7,7 +7,7 @@ from typing import Callable, Iterator, List, NamedTuple, Optional, Sequence, Tup class Line(NamedTuple): - """kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None""" + """One diff row: kind " " (context), "-" (deleted) or "+" (inserted); the missing line number is None""" kind: str old_no: Optional[int] @@ -16,6 +16,8 @@ class Line(NamedTuple): class Diff(NamedTuple): + """A parsed unified diff: its rows, and whether git reported the file as binary""" + lines: List[Line] binary: bool @@ -23,12 +25,12 @@ class Diff(NamedTuple): # the value `runs` groups by, so a caller gets its own key type back rather than `object` Key = TypeVar("Key") -# an (start, end) index range of consecutive changed lines +# a half-open (start, end) index range of consecutive changed lines Block = Tuple[int, int] # a (start, end) character range inside one line's text CharRange = Tuple[int, int] -# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic) +# character-level marks only when the pair is mostly the same line, and never on huge lines (quadratic) CHAR_DIFF_MIN_RATIO = 0.5 CHAR_DIFF_MAX_CHARS = 2000 @@ -42,11 +44,13 @@ def parse_unified(text: str) -> Diff: old_no = new_no = 0 in_hunk = False for raw in text.split("\n"): + # a hunk header resets both line counters if raw.startswith("@@"): match = _HUNK.match(raw) old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0) in_hunk = True continue + # before the first hunk only headers: watch for a binary marker if not in_hunk: if raw.startswith("Binary files") or raw.startswith("GIT binary patch"): binary = True @@ -54,6 +58,7 @@ def parse_unified(text: str) -> Diff: if raw == "": # only the trailing split remainder; an empty context line is " " continue + # hunk body: the first character is the line kind marker, body = raw[0], raw[1:] if marker == " ": lines.append(Line(" ", old_no, new_no, body)) @@ -80,7 +85,7 @@ def runs(items: Sequence[Line], key: Callable[[Line], Key]) -> Iterator[Tuple[Ke def change_blocks(lines: Sequence[Line]) -> List[Block]: - """[(start, end)) index ranges of consecutive changed lines""" + """Half-open (start, end) index ranges of consecutive changed lines""" return [(start, end) for changed, start, end in runs(lines, lambda line: line.kind != " ") if changed] @@ -91,7 +96,7 @@ def counts(lines: Sequence[Line]) -> Tuple[int, int]: def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]: - """(deleted_index, inserted_index) pairs of a block with equal - and + counts""" + """(deleted, inserted) index pairs of a block; [] unless its - and + counts match""" start, end = block deleted = [i for i in range(start, end) if lines[i].kind == "-"] inserted = [i for i in range(start, end) if lines[i].kind == "+"] @@ -107,6 +112,7 @@ def char_ranges(old: str, new: str) -> Tuple[List[CharRange], List[CharRange]]: matcher = difflib.SequenceMatcher(None, old, new, autojunk=False) if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO: return [], [] + # each opcode span becomes a range on whichever side it touches old_ranges: List[CharRange] = [] new_ranges: List[CharRange] = [] for tag, i1, i2, j1, j2 in matcher.get_opcodes(): @@ -121,6 +127,7 @@ def line_for_open(lines: Sequence[Line], index: int) -> int: """New-file line number to open for a row: a deleted row maps to the next surviving line""" if not lines: return 1 + # the first numbered line at or after the row, else the nearest one before it index = max(0, min(index, len(lines) - 1)) for line in lines[index:]: if line.new_no is not None: diff --git a/vdiff/render.py b/vdiff/render.py index 306c365..768ad4e 100644 --- a/vdiff/render.py +++ b/vdiff/render.py @@ -1,9 +1,9 @@ -""" Draw a parsed diff into a review view: the file text, coloured line regions, gutter marks, header phantom """ +"""Draw a parsed diff into a review view: file text, line tints, gutter marks, header phantom""" import bisect import html import logging import os -from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple +from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union import sublime import sublime_plugin @@ -22,19 +22,36 @@ KEY_DEL_GUTTER = "vdiff.del.gutter" KEY_INS_GUTTER = "vdiff.ins.gutter" KEY_DEL_CHAR = "vdiff.del.char" KEY_INS_CHAR = "vdiff.ins.char" -REGION_KEYS: Tuple[str, ...] = (KEY_DEL, KEY_INS, KEY_DEL_GUTTER, KEY_INS_GUTTER, KEY_DEL_CHAR, KEY_INS_CHAR) +KEY_FILE_HEADER = "vdiff.file.header" +KEY_FILE_DEL_COUNT = palette.SCOPE_FILE_DEL_COUNT +KEY_FILE_INS_COUNT = palette.SCOPE_FILE_INS_COUNT +KEY_FILE_PREFIX = "vdiff.file.prefix" +REGION_KEYS = ( + KEY_DEL, + KEY_INS, + KEY_DEL_GUTTER, + KEY_INS_GUTTER, + KEY_DEL_CHAR, + KEY_INS_CHAR, + KEY_FILE_HEADER, + KEY_FILE_DEL_COUNT, + KEY_FILE_INS_COUNT, + KEY_FILE_PREFIX, +) PHANTOM_KEY = "vdiff.header" STATUS_KEY = "vdiff" -# visual bell: a whole-buffer tint on a timer. Not in REGION_KEYS, so a wrap's re-render cannot wipe it early. +# visual bell: a timed whole-buffer tint, kept out of REGION_KEYS so a re-render cannot erase it early BELL_KEY = "vdiff.bell" BELL_MS = 90 # above this much changed text the colours cost more than they are worth: such a diff is scrolled, not read TOKEN_COLOUR_MAX_CHARS = 200000 +# the hidden output panel the all-files view tokenises each file in +TOKEN_PANEL = "vdiff_tokens" -# a block phantom at point 0 draws below row 0, so the file starts at row 1; only index_point / caret_index know +# a block phantom at point 0 draws below row 0, so file text starts at row 1 _HEADER_ROWS = 1 # gutter dots use the scope's foreground @@ -42,12 +59,13 @@ SCOPE_DEL_MARK = "markup.deleted" SCOPE_INS_MARK = "markup.inserted" # ST4 defines region.ish in every bundled scheme for exactly this kind of transient marking SCOPE_BELL = "region.yellowish" +SCOPE_FILE_HEADER = palette.SCOPE_FILE_HEADER -# sublime only loads an override it has not seen before on a restart +# sublime loads a newly created scheme override only after a restart NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines" -# actions: the ACTIONS names the row covers, a single one making it a link. short: status-bar -# wording, "" keeps the row out. The group flag is False for groups needing a working-tree file. + +# actions: the ACTIONS names the row runs, a link when there is one; short: status-bar label, "" omits it class Key(NamedTuple): """One row of the status-bar hints and the ? popup""" @@ -57,7 +75,7 @@ class Key(NamedTuple): actions: Tuple[str, ...] -# (group title, applies in a commit, rows) +# (group title, applies in a commit (False: needs a working-tree file), rows) KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [ ( "Navigate", @@ -69,14 +87,6 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [ Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")), ], ), - ( - "Panels", - True, - [ - Key("f", "changed files", "files", ("files",)), - Key("c", "recent commits", "commits", ("commits",)), - ], - ), ( "Actions", False, @@ -92,8 +102,12 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [ "View", True, [ + Key("f", "changed files", "files", ("files",)), + Key("c", "recent commits", "commits", ("commits",)), + Key("v", "toggle all-files view", "view", ("view",)), Key("r", "refresh", "refresh", ("refresh",)), - Key("?", "this list", "keys", ("help",)), + Key("left right", "collapse / expand file", "fold", ("collapse", "expand")), + Key("z", "collapse / expand all files", "", ("fold_all",)), Key("esc", "close the view", "", ("close",)), ], ), @@ -123,9 +137,8 @@ a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border- .body { color: color(var(--foreground) alpha(0.6)); } """ -# minihtml drops the rest of a stylesheet at a selector it cannot parse, and documents properties -# only, not selectors: every rule sticks to one bare element or class, the forms HEADER_CSS proves. -# The first group's margin stands in for body's top padding; .kdim takes its colour from .dim. +# minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class +# .group's top margin replaces body's top padding; .kdim inherits its colour from .dim HELP_CSS = """ body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; } .group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; } @@ -142,12 +155,24 @@ class VdiffReplaceCommand(sublime_plugin.TextCommand): """Replace the whole buffer; the only writer of a review view""" def run(self, edit: sublime.Edit, text: str) -> None: + """Replace the buffer""" self.view.replace(edit, sublime.Region(0, self.view.size()), text) +def _clear_external_diagnostics(view: sublime.View) -> None: + """Erase LSP diagnostic regions painted before the view became a widget""" + for prefix in ("lspLSP-pyright", "lspLSP-pylsp", "lspLSP"): + for severity in range(1, 5): + for mode in ("s", "m"): + key = f"{prefix}d{mode}{severity}" + for suffix in ("", "_underline", "_icon"): + view.erase_regions(key + suffix) + + def init_view(view: sublime.View) -> None: """Settings a review view needs, applied once when it is created or restored""" s = view.settings() + # mark the view as vdiff's and turn off editor chrome that does not fit a diff s.set(VIEW_FLAG, True) # native line numbers would be wrong: deleted lines have none s.set("line_numbers", False) @@ -158,12 +183,20 @@ def init_view(view: sublime.View) -> None: s.set("detect_indentation", False) s.set("translate_tabs_to_spaces", False) s.set("show_definitions", False) + # LSP ignores widget views; is_scratch alone does not stop every LSP version + s.set("is_widget", True) s.set("mini_diff", False) s.set("spell_check", False) + # keep linters and LSP off this synthetic buffer, whichever of these settings they read + s.set("syntax_checking", False) + s.set("sublime_linter_disable", True) + s.set("lsp_diagnostics", False) + _clear_external_diagnostics(view) # a read-only viewer, so vim's insert mode is meaningless: command mode, block caret. vdiff's - # own keys carry no command_mode context, so they fire in either mode regardless. + # own keys carry no command_mode context, so they fire in either mode s.set("command_mode", True) s.set("inverse_caret_state", True) + # a scratch, read-only buffer with the key hints in the status bar view.set_scratch(True) view.set_read_only(True) if not view.name(): @@ -191,6 +224,7 @@ def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float def _mtime(path: str) -> Optional[float]: + """Modification time of `path`, or None when it cannot be stat'ed""" try: return os.stat(path).st_mtime except OSError: @@ -198,14 +232,25 @@ def _mtime(path: str) -> Optional[float]: def flash(view: sublime.View) -> None: - """Visual bell: tint the buffer for a moment, for feedback that has nothing to say in words""" + """Visual bell: briefly tint the whole buffer""" view.add_regions(BELL_KEY, [sublime.Region(0, view.size())], _bell_scope(view), flags=sublime.DRAW_NO_OUTLINE) # unconditional erase: overlapping flashes may cut one short, but none can leave the tint stuck sublime.set_timeout(lambda: view.is_valid() and view.erase_regions(BELL_KEY), BELL_MS) +def _file_header_scope(view: sublime.View) -> str: + """Heading tint scope: vdiff's own, else the first fallback the scheme gives a background""" + # vdiff's own scope, once the generated scheme override has loaded + if palette.scope_background(view, SCOPE_FILE_HEADER): + return SCOPE_FILE_HEADER + for scope in ("region.blueish", "region.cyanish", SCOPE_BELL, "diff.inserted"): + if palette.scope_background(view, scope): + return scope + return SCOPE_FILE_HEADER + + def _bell_scope(view: sublime.View) -> str: - """region.yellowish, or a scope with a background we know of when a scheme lacks it""" + """region.yellowish, or the insert tint when the scheme lacks it""" if palette.scope_background(view, SCOPE_BELL): return SCOPE_BELL # an invisible bell is a silent failure, so borrow the insert tint @@ -223,12 +268,219 @@ def render_message(view: sublime.View, st: ViewState, message: str) -> None: _finish(view, st, 0, 0, "vDiff") +def _trim_context(lines: Sequence[Line], context: int) -> List[Line]: + """Changed lines plus up to `context` lines either side of each change""" + if not lines: + return [] + keep = set() + for start, end in parse.change_blocks(lines): + keep.update(range(max(0, start - context), min(len(lines), end + context))) + return [line for i, line in enumerate(lines) if i in keep] + + +def _all_line_text(line: Line) -> str: + """One all-files diff row: old and new line numbers, then the line's text""" + old = f"{line.old_no:>4}" if line.old_no is not None else " " + new = f"{line.new_no:>4}" if line.new_no is not None else " " + return f"{old} {new} {line.text}" + + +def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[str, int]] = None, center: bool = False) -> None: + """Draw every file, with a compact collapsible file heading before its diff. + + Without `focus` the caret goes to the top. A path puts it on that file's heading, a (path, hunk) + target on that change's first row; the scroll is kept unless the caret has left the screen, or + the caret is centred with `center` + """ + # keep the scroll only for a focused redraw; reset the rows and change-navigation tables + viewport = view.viewport_position() if focus is not None else (0, 0) + heading = 0 + rows: List[str] = [] + metadata: List[Tuple[Optional[FileChange], Optional[Line], bool]] = [] + st.all_rows = metadata + st.all_change_rows = [] + st.all_change_targets = [] + st.all_change_row_targets = [] + # build the rows: a blank line and heading per file, then its trimmed diff unless folded + for change in st.files: + rows.append("") + metadata.append((None, None, False)) + lines, _, binary = st.all_diffs.get(change.path, ([], [], False)) + context = settings().get("all_files_context", 5) + trimmed = _trim_context(lines, max(0, int(context)) if context is not None else 5) + # number hunks even when folded, so navigation can unfold to one + hunk_index = 0 + in_change = False + for line in trimmed: + changed = line.kind != " " + if changed and not in_change: + st.all_change_targets.append((change.path, hunk_index)) + hunk_index += 1 + in_change = changed + # the heading: fold marker, path and the file's -/+ counts + ins, dele = parse.counts(lines) + marker = "▶" if change.path in st.collapsed else "▼" + if change.path == focus: + heading = len(rows) + rows.append(f"{marker} {change.path} -{dele} +{ins}") + metadata.append((change, None, True)) + # the diff rows, recording the row where each visible change starts + if change.path not in st.collapsed: + previous_changed = False + visible_hunk = -1 + for line in trimmed: + rows.append(_all_line_text(line)) + metadata.append((change, line, False)) + changed = line.kind != " " + if changed and not previous_changed: + visible_hunk += 1 + st.all_change_rows.append(len(rows) - 1) + st.all_change_row_targets.append((change.path, visible_hunk)) + previous_changed = changed + # write the text and syntax + text = "\n".join(rows) if rows else "(no changes)" + _replace(view, text) + # one buffer syntax would parse prefixes and headings as code: tokenise each file alone, + # paint its colours as regions + view.assign_syntax("scope:text.plain") + palette.ensure(view) + if palette.claim_restart_notice(view): + status(NEEDS_RESTART) + _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 = [], [] + 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): + if section: + file_regions.append(sublime.Region(starts[i], starts[i] + len(rows[i]))) + # colour just the counts over the heading tint; rfind, as a path may contain " -" + minus = rows[i].rfind(" -") + 1 + plus = rows[i].rfind(" +") + 1 + if minus and plus > minus: + file_del_counts.append(sublime.Region(starts[i] + minus, starts[i] + plus - 1)) + if plus: + file_ins_counts.append(sublime.Region(starts[i] + plus, starts[i] + len(rows[i]))) + if section or line is None: + continue + text_start = starts[i] + len(rows[i]) - len(line.text) + # tint only the file text, leaving the line-number columns plain + region = sublime.Region(text_start, starts[i] + len(rows[i])) + # the last separator column of _all_line_text keeps the view background: a gap before the text + prefix_regions.append(sublime.Region(starts[i], text_start - 1)) + if resolved and change is not None: + kind = {"-": "del", "+": "ins"}.get(line.kind, "ctx") + for a, b, colour in st.all_colours.get(change.path, {}).get((line.old_no, line.new_no), []): + # context text in the default colour needs no region: plain text draws it already + if kind == "ctx" and (colour == default_colour or not ctx_resolved): + continue + scope = palette.scope_for(kind, colour) + if scope: + token_groups.setdefault(scope, []).append(sublime.Region(text_start + a, text_start + b)) + if line.kind == "-": + del_regions.append(region) + elif line.kind == "+": + ins_regions.append(region) + # paint everything + view.add_regions(KEY_DEL, del_regions, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE) + view.add_regions(KEY_INS, ins_regions, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE) + view.add_regions(KEY_FILE_HEADER, file_regions, _file_header_scope(view), flags=sublime.DRAW_NO_OUTLINE) + view.add_regions(KEY_FILE_DEL_COUNT, file_del_counts, KEY_FILE_DEL_COUNT, flags=sublime.DRAW_NO_OUTLINE) + view.add_regions(KEY_FILE_INS_COUNT, file_ins_counts, KEY_FILE_INS_COUNT, flags=sublime.DRAW_NO_OUTLINE) + view.add_regions(KEY_FILE_PREFIX, prefix_regions, "text.plain", flags=sublime.DRAW_NO_OUTLINE) + for scope, regions in token_groups.items(): + view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE) + insertions, deletions = _totals(st.files) + _finish(view, st, insertions, deletions, "⟐ all files") + # place the caret: on the focused change's first row, else on `heading` (a file's or the top) + if isinstance(focus, tuple): + heading = next((row for row, shown in zip(st.all_change_rows, st.all_change_row_targets) if shown == focus), 0) + point = index_point(view, heading) + view.sel().clear() + view.sel().add(sublime.Region(point)) + if center: + view.show_at_center(point) + else: + view.set_viewport_position(viewport, False) + if focus is not None: + view.show(point) + + +def _all_colours(view: sublime.View, st: ViewState, paths: Sequence[str]) -> None: + """Fill st.all_colours for `paths`, tokenising each file with its own syntax in a hidden panel""" + missing = [path for path in paths if path not in st.all_colours and path in st.all_diffs] + window = view.window() + if not missing or window is None: + return + # one hidden panel tokenises every missing file, then is destroyed + panel = window.create_output_panel(TOKEN_PANEL, unlisted=True) + colours: Dict[str, Optional[str]] = {} # scope name -> colour, shared by every file + try: + for path in missing: + lines = st.all_diffs[path][0] + st.all_colours[path] = _file_colours(view, panel, os.path.join(st.repo, path), lines, colours) + finally: + window.destroy_output_panel(TOKEN_PANEL) + + +def _file_colours( + view: sublime.View, panel: sublime.View, path: str, lines: Sequence[Line], colours: Dict[str, Optional[str]] +) -> Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]: + """{(old_no, new_no): [(start, end, colour)]} for every line of one file's full-context diff + + The new side (context and insertions) and the old side (context and deletions) are each a whole, + valid version of the file, so each is parsed on its own; interleaved they would confuse the parser + """ + result: Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]] = {} + if sum(len(line.text) + 1 for line in lines) > TOKEN_COLOUR_MAX_CHARS: + # too big to colour; the flat tint still shows it + return result + _apply_syntax(panel, path) + for side in ("+", "-"): + if side == "-" and not any(line.kind == "-" for line in lines): + continue # nothing deleted: the old side would only repeat the context rows + stream = [line for line in lines if line.kind in (" ", side)] + panel.run_command("vdiff_replace", {"text": "\n".join(line.text for line in stream)}) + point = 0 + for line in stream: + # context rows come from the new side + if line.kind == side or side == "+": + runs = _colour_runs(panel, point, line.text, colours, view) + result[(line.old_no, line.new_no)] = [(r.a - point, r.b - point, c) for r, c in runs] + point += len(line.text) + 1 + return result + + +def _row_starts_for_text(rows: Sequence[str]) -> List[int]: + """Text point where each all-files row starts, below the header rows""" + starts: List[int] = [] + point = _HEADER_ROWS + for row in rows: + starts.append(point) + point += len(row) + 1 + return starts + + +def all_change_at(view: sublime.View, st: ViewState) -> Optional[FileChange]: + """File whose heading or diff row holds the caret, else None""" + index = caret_index(view) + if 0 <= index < len(st.all_rows): + return st.all_rows[index][0] + return None + + def render(view: sublime.View, st: ViewState) -> None: """Draw st.lines for st.change into the view""" change = st.change # every caller with nothing to show goes to render_message instead, so there is a file here assert change is not None lines = st.lines + # the text: a placeholder for a binary or empty diff, else the lines if st.binary: text = "(binary file)" elif not lines: @@ -236,6 +488,7 @@ def render(view: sublime.View, st: ViewState) -> None: else: text = "\n".join(line.text for line in lines) + # write the text and syntax _replace(view, text) path = os.path.join(st.repo, change.path) _apply_syntax(view, path if lines and not st.binary else None) @@ -247,6 +500,7 @@ def render(view: sublime.View, st: ViewState) -> None: if palette.claim_restart_notice(view): status(NEEDS_RESTART) + # header and bookkeeping, then the caret and scroll back to the top ins, dele = parse.counts(lines) _finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}") view.sel().clear() @@ -257,47 +511,52 @@ def render(view: sublime.View, st: ViewState) -> None: def _paint_regions(view: sublime.View, st: ViewState) -> None: """The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them""" lines = st.lines + # full-line tint starts = _row_starts(lines) deleted, inserted = _line_regions(lines, starts) view.add_regions(KEY_DEL, deleted, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE) view.add_regions(KEY_INS, inserted, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE) + # gutter dots mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"] ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"] view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags) view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags) + # word-diff marks, when enabled del_chars: List[sublime.Region] = [] ins_chars: List[sublime.Region] = [] if settings().get("word_diff", True): del_chars, ins_chars = _char_regions(lines, st.blocks, starts) view.add_regions(KEY_DEL_CHAR, del_chars, palette.SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE) view.add_regions(KEY_INS_CHAR, ins_chars, palette.SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE) + # syntax colours on top of the tints _paint_tokens(view, lines, starts, del_chars + ins_chars) -def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int], - char_regions: Sequence[sublime.Region]) -> None: +def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int], char_regions: Sequence[sublime.Region]) -> None: """Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour add_regions draws text in the region scope's own foreground, so the tints above have just - flattened every changed line; only handing each run its own colour gives the highlighting back. + flattened every changed line; only handing each run its own colour gives the highlighting back """ if not palette.tokens_resolved(view): return changed = [i for i, line in enumerate(lines) if line.kind != " "] if sum(len(lines[i].text) for i in changed) > TOKEN_COLOUR_MAX_CHARS: - # a huge diff would spend longer being tokenised than read; the flat tint still shows it + # too big to colour; the flat tint still shows it return + # word-diff ranges sorted by start, so each line can bisect for its own stronger = sorted((r.a, r.b) for r in char_regions) starts_of = [a for a, _ in stronger] + # group every changed line's colour runs by scope groups: Dict[str, List[sublime.Region]] = {} colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render for i in changed: kind = "del" if lines[i].kind == "-" else "ins" end = starts[i] + len(lines[i].text) # only this line's word-diff ranges, so a diff with thousands of them stays linear - on_line = stronger[bisect.bisect_left(starts_of, starts[i]):bisect.bisect_left(starts_of, end)] + on_line = stronger[bisect.bisect_left(starts_of, starts[i]) : bisect.bisect_left(starts_of, end)] for region, colour in _colour_runs(view, starts[i], lines[i].text, colours): # a word-diff range cuts across token boundaries, so the run splits on it for part, inside in _split(region, on_line): @@ -305,6 +564,7 @@ def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[in scope = palette.scope_for(kind + ".char" if inside else kind, colour) if scope: groups.setdefault(scope, []).append(part) + # one region set per scope for scope, regions in groups.items(): view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE) @@ -313,6 +573,7 @@ def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List """(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends""" if not intervals: return [(region, False)] + # walk the intervals, emitting the gap before each and its overlap with the region parts: List[Tuple[sublime.Region, bool]] = [] pos = region.a for a, b in intervals: @@ -325,21 +586,33 @@ def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List parts.append((sublime.Region(pos, a), False)) parts.append((sublime.Region(a, b), True)) pos = b + # the tail after the last interval, or the whole region when none overlapped if pos < region.b or not parts: parts.append((sublime.Region(pos, region.b), False)) return parts -def _colour_runs(view: sublime.View, start: int, text: str, - colours: Dict[str, Optional[str]]) -> List[Tuple[sublime.Region, str]]: - """(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged""" +def _colour_runs( + view: sublime.View, start: int, text: str, colours: Dict[str, Optional[str]], scheme_view: Optional[sublime.View] = None +) -> List[Tuple[sublime.Region, str]]: + """(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged + + `scheme_view` resolves the colours when `view` is only a tokenising buffer + """ runs: List[Tuple[sublime.Region, str]] = [] - for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, start + len(text))): + end = start + len(text) + for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, end)): + # a token can run past the line's end, e.g. into the newline + region = sublime.Region(max(region.begin(), start), min(region.end(), end)) + if region.b <= region.a: + continue + # resolve each scope's colour once, skipping tokens without one if scope not in colours: - colours[scope] = palette.foreground(view, scope) + colours[scope] = palette.foreground(scheme_view or view, scope) colour = colours[scope] if not colour: continue + # extend the previous run when the same colour continues it if runs and runs[-1][1] == colour and runs[-1][0].b == region.a: runs[-1] = (sublime.Region(runs[-1][0].a, region.b), colour) else: @@ -348,7 +621,7 @@ def _colour_runs(view: sublime.View, start: int, text: str, def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -> None: - """Header, tab name, status bar and bookkeeping shared by render and render_message""" + """Header, tab name, status bar and bookkeeping shared by every render""" _header(view, st, ins, dele) view.set_name(name) view.set_status(STATUS_KEY, KEY_HINTS) @@ -357,6 +630,7 @@ def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) - def _replace(view: sublime.View, text: str) -> None: + """Write `text` below the header rows, then erase every vdiff region and token layer""" view.set_read_only(False) try: view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text}) @@ -389,8 +663,7 @@ def _row_starts(lines: Sequence[Line]) -> List[int]: return starts -def _line_regions(lines: Sequence[Line], - starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]: +def _line_regions(lines: Sequence[Line], starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]: """Full-line regions of deleted and inserted rows, consecutive rows merged""" regions: Dict[str, List[sublime.Region]] = {"-": [], "+": []} for kind, start, end in parse.runs(lines, lambda line: line.kind): @@ -400,12 +673,14 @@ def _line_regions(lines: Sequence[Line], return regions["-"], regions["+"] -def _char_regions(lines: Sequence[Line], blocks: Sequence[Block], - starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]: +def _char_regions( + lines: Sequence[Line], blocks: Sequence[Block], starts: Sequence[int] +) -> Tuple[List[sublime.Region], List[sublime.Region]]: """Changed characters inside paired deleted/inserted lines""" deleted: List[sublime.Region] = [] inserted: List[sublime.Region] = [] for block in blocks: + # word-diff each deleted/inserted pair and offset its ranges to buffer points for del_row, ins_row in parse.paired_lines(lines, block): old_ranges, new_ranges = parse.char_ranges(lines[del_row].text, lines[ins_row].text) deleted.extend(sublime.Region(starts[del_row] + a, starts[del_row] + b) for a, b in old_ranges) @@ -419,7 +694,7 @@ def _totals(files: Sequence[FileChange]) -> Tuple[int, int]: def _summary_html(st: ViewState) -> str: - """Line above the header: the whole commit on one line, or how much of the tree is staged""" + """Summary line above the title: the commit on one line, or the staged/unstaged split and totals""" if not st.files and not st.commit: return "" if st.in_commit: @@ -436,9 +711,10 @@ def _summary_html(st: ViewState) -> str: def _commit_parts(st: ViewState) -> List[str]: - """hash, author, date, totals and message — everything about the reviewed commit, in that order""" + """Hash, author, date, totals and message of the reviewed commit, in order""" info = st.commit parts: List[str] = [] + # the commit's identity: hash, author, date if info: parts.append(f'{html.escape(info.short)}') parts.append(html.escape(info.author)) @@ -446,6 +722,7 @@ def _commit_parts(st: ViewState) -> List[str]: # binary files add nothing to the line totals but still count toward the file total insertions, deletions = _totals(st.files) parts.append(f"{file_count(len(st.files))} changed" f' -{deletions} +{insertions}') + # the subject, plus the body's first line if info: message = html.escape(info.subject) body = info.body.strip().splitlines() @@ -458,6 +735,7 @@ def _commit_parts(st: ViewState) -> List[str]: def header_html(st: ViewState, ins: int, dele: int) -> str: """The minihtml header: the summary line, then the shown file's path, counts, state and action links""" change = st.change + # the title: path, rename source, counts and working-tree state if change is None: title = 'vDiff' else: @@ -470,6 +748,7 @@ def header_html(st: ViewState, ins: int, dele: int) -> str: if state: title += f'  {state}' + # action links: stage, unstage and discard need a working-tree file, open needs any file actions: List[str] = [] if change is not None and not st.in_commit: actions.append('Stage') @@ -496,6 +775,7 @@ def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None: """One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep""" def navigate(href: str) -> None: + """Run the clicked header link's action""" _run_action(view, href) view.erase_phantoms(PHANTOM_KEY) @@ -508,6 +788,7 @@ def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None: def help_html(st: ViewState) -> str: """The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown""" parts: List[str] = [] + # per group a heading, dimmed with a note when it does not apply in a commit for title, applies, group in KEY_GROUPS: dim = st.in_commit and not applies heading = html.escape(title) @@ -516,6 +797,7 @@ def help_html(st: ViewState) -> str: parts.append(f'
{heading}
') # one class per element: a compound selector would silently drop the rest of HELP_CSS row_class, key_class = ("dim", "kdim") if dim else ("row", "k") + # a row per key with a padded key column; a single-action row becomes a link for key in group: row = ( f'{html.escape(key.keys)}' @@ -534,10 +816,10 @@ def show_help(view: sublime.View, st: ViewState) -> None: return def navigate(href: str) -> None: + """Close the popup, then run the clicked row's action""" view.hide_popup() _run_action(view, href) # minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size em = view.em_width() or 8 view.show_popup(help_html(st), location=-1, max_width=int(em * 56), max_height=int(em * 44), on_navigate=navigate) - diff --git a/vdiff/state.py b/vdiff/state.py index c6f3a39..43b2b89 100644 --- a/vdiff/state.py +++ b/vdiff/state.py @@ -1,4 +1,4 @@ -""" Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore """ +"""Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore""" from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Sequence, Tuple @@ -50,21 +50,33 @@ class ViewState: highlight: int = 0 # bumped per quick-panel highlight, to debounce previews # (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None + # all-files view: diffs cached so collapse/expand is instant + all_mode: bool = False + all_diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]] = field(default_factory=dict) + # path -> {(old_no, new_no): [(start, end, colour)]}: syntax colours per row, offsets within the line + all_colours: Dict[str, Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]] = field(default_factory=dict) + collapsed: set = field(default_factory=set) + all_rows: List[Tuple[Optional["FileChange"], Optional["Line"], bool]] = field(default_factory=list) + all_change_rows: List[int] = field(default_factory=list) + all_change_targets: List[Tuple[str, int]] = field(default_factory=list) + all_change_row_targets: List[Tuple[str, int]] = field(default_factory=list) @property def in_commit(self) -> bool: + """Whether a commit is being reviewed rather than the working tree""" return self.sha is not None def index(self) -> int: """Position in `files` of the file being shown or loaded, or -1 `requested` leads: a diff arrives asynchronously, so counting from `change` would make n / p - step from what was on screen before the pending file landed. + step from what was on screen before the pending file landed """ change = self.requested or self.change return find_change(self.files, change.path) if change else -1 def kept(self) -> Kept: + """Snapshot of what to go back to on Esc""" return Kept(self.sha, self.commit, self.change, list(self.files)) def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept: @@ -76,12 +88,21 @@ class ViewState: return kept def restore(self, kept: Kept) -> None: + """Go back to a snapshot; its file becomes the requested one""" self.sha, self.commit, self.change, self.files = kept self.requested = kept.change + def set_all_diffs(self, diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]]) -> None: + """Replace the all-files diffs; a file's cached colours survive only if its diff is unchanged""" + self.all_colours = {path: colours for path, colours in self.all_colours.items() + if path in diffs and path in self.all_diffs and diffs[path][0] == self.all_diffs[path][0]} + self.all_diffs = diffs + def persist(self, view: "sublime.View") -> None: """Mirror the essentials into view settings (survives a restart)""" data: Dict[str, Any] = {"repo": self.repo, "sha": self.sha} + if self.all_mode: + data["all_mode"] = True if self.change: data["path"] = self.change.path data["status"] = self.change.status @@ -93,6 +114,7 @@ STATES: Dict[int, "ViewState"] = {} def get(view: "sublime.View") -> Optional[ViewState]: + """State of a review view, or None when not tracked""" return STATES.get(view.id()) @@ -102,13 +124,16 @@ def tracked() -> List[Tuple[int, ViewState]]: def put(view: "sublime.View", st: ViewState) -> ViewState: + """Track `st` as the view's state and return it""" STATES[view.id()] = st return st def drop(view: "sublime.View") -> None: + """Forget the view's state, if any""" STATES.pop(view.id(), None) def is_review_view(view: Optional["sublime.View"]) -> bool: + """Whether the view carries the review-view flag""" return bool(view and view.settings().get(VIEW_FLAG)) diff --git a/vdiff/utils.py b/vdiff/utils.py index 4f248ae..f7ef3fb 100644 --- a/vdiff/utils.py +++ b/vdiff/utils.py @@ -1,4 +1,4 @@ -""" Small helpers shared across vdiff modules """ +"""Small helpers shared across vdiff modules""" import logging from typing import Any, Callable, Optional @@ -42,6 +42,7 @@ def run_async(work: Callable[[], Any], done: Callable[[Any], None]) -> None: """Run `work()` on sublime's worker thread, hand the result to `done` on the main thread""" def job() -> None: + """Worker-thread body: run `work`, pass on its result or report its error""" try: result = work() except Exception as error: