2 Commits

Author SHA1 Message Date
Vaclav Tvrdik
904b941be7 stage/unstage all actions added 2026-10-10 10:24:15 +02:00
Vaclav Tvrdik
fe915178c5 initial version of all-in-one diff view 2026-10-04 21:27:16 +02:00
17 changed files with 945 additions and 152 deletions

View File

@@ -7,12 +7,24 @@
"context": [{"key": "setting.vdiff_view"}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"}, { "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
"context": [{"key": "setting.vdiff_view"}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["S"], "command": "vdiff_action", "args": {"action": "stage_all"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["U"], "command": "vdiff_action", "args": {"action": "unstage_all"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"}, { "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
"context": [{"key": "setting.vdiff_view"}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"}, { "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"}, { "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}] }, "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"}, { "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
"context": [{"key": "setting.vdiff_view"}] }, "context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"}, { "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},

View File

@@ -52,17 +52,21 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov
|---|---| |---|---|
| `f` / `c` | changed files / recent commits panel | | `f` / `c` | changed files / recent commits panel |
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file | | `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file |
| `S` / `U` | stage all unstaged files / unstage all staged files |
| `C` | commit the staged files, typing the message in an input panel | | `C` | commit the staged files, typing the message in an input panel |
| `m` | amend the last commit: edit its subject, keep its body, fold in anything staged | | `m` | amend the last commit: edit its subject, keep its body, fold in anything staged |
| `o` or Enter | open the real file at this line | | `o` or Enter | open the real file at this line |
| `]` / `[` | next / previous change (wraps at the ends) | | `]` / `[` | next / previous change (wraps at the ends) |
| `n` / `p` | next / previous file of the current list (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) | | `j` / `k` | move down / up one line (vim keys) |
| `r` | refresh | | `r` | refresh |
| `?` | show this key list in a popup | | `?` | show this key list in a popup |
| Esc | close the view | | 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 ## The channel

View File

@@ -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 ## New functionality
- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?) - Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?)
- Add pull and push action - Add pull and push action
@@ -9,10 +12,8 @@
## Improvements ## Improvements
- commit messages are oneline. What are options for multiline editing? - commit messages are oneline. What are options for multiline editing?
- visual bell blinking time setting - visual bell blinking time setting
## Code ## Code

View File

@@ -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 logging
import sys import sys
import sublime import sublime
# sublime reloads only this file: drop the submodules so an edited one is re-imported below. # drop the submodules so an edited one is re-imported; open views are rebuilt from settings by _restore_all
# Open views lose in-memory state and are rebuilt from their settings by _restore_all.
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]: for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
del sys.modules[_name] 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 ( from .vdiff.commands import (
VdiffActionCommand, VdiffActionCommand,
VdiffCommitsCommand, VdiffCommitsCommand,
@@ -35,6 +34,7 @@ def plugin_loaded():
def _restore_all(): def _restore_all():
"""Rebuild every review view, in every window, that has no in-memory state yet"""
for window in sublime.windows(): for window in sublime.windows():
for view in window.views(): for view in window.views():
if state.is_review_view(view) and state.get(view) is None: if state.is_review_view(view) and state.get(view) is None:

View File

@@ -224,9 +224,19 @@ class View:
def set_status(self, key, value): def set_status(self, key, value):
self.status[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): def set_viewport_position(self, xy, animate=True):
pass pass
def show(self, point):
pass
def show_at_center(self, point): def show_at_center(self, point):
pass pass

View File

@@ -578,8 +578,116 @@ def test_unstage_and_discard_do_not_move(monkeypatch):
assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"] assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
def test_stage_all_and_unstage_all_stay_on_the_file(monkeypatch):
messages = []
monkeypatch.setattr(commands, "status", messages.append)
assert press(monkeypatch, "stage_all", flagged(True, False, False), "f1.py", flagged(True, True, True)) == ["f1.py"]
assert messages[0] == "staged 2 files"
assert press(monkeypatch, "unstage_all", flagged(True, False, True), "f1.py", flagged(False, False, False)) == ["f1.py"]
assert messages[-1] == "unstaged 2 files"
def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch):
messages = []
monkeypatch.setattr(commands, "status", messages.append)
assert press(monkeypatch, "stage_all", flagged(True, True), "f0.py", []) == []
assert press(monkeypatch, "unstage_all", flagged(False, False), "f0.py", []) == []
assert messages == ["nothing to stage", "nothing to unstage"]
view, window, st = at_file(flagged(False), "f0.py")
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", ""))
commands.ACTIONS["stage_all"](view, window, st)
assert messages[-1] == "stage all works on uncommitted changes only"
def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch): def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
messages = [] messages = []
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text)) monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == [] assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
assert messages == ["No uncommitted changes in /repo"] 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)

View File

@@ -84,6 +84,19 @@ def test_stage_unstage_discard(repo):
assert git.changed_files(repo) == [] assert git.changed_files(repo) == []
def test_stage_all_unstage_all(repo):
write(repo, "a.py", "changed\n")
os.remove(os.path.join(repo, "gone.py"))
write(repo, "sub/n.py", "new\n")
git.stage_all(repo)
changes = {c.path: c for c in git.changed_files(repo)}
assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and all(c.staged for c in changes.values())
git.unstage_all(repo)
changes = {c.path: c for c in git.changed_files(repo)}
assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and not any(c.staged for c in changes.values())
assert changes["sub/n.py"].status == "?"
def test_commits(repo): def test_commits(repo):
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n") write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
write(repo, "c.py", "c\n") write(repo, "c.py", "c\n")
@@ -154,6 +167,10 @@ def test_no_head_repo(tmp_path):
assert [l.kind for l in diff.lines] == ["+"] assert [l.kind for l in diff.lines] == ["+"]
git.unstage(repo, changes[0]) git.unstage(repo, changes[0])
assert git.changed_files(repo)[0].status == "?" assert git.changed_files(repo)[0].status == "?"
git.stage_all(repo)
assert git.changed_files(repo)[0].staged
git.unstage_all(repo)
assert git.changed_files(repo)[0].status == "?"
def test_rename_in_commit(repo): def test_rename_in_commit(repo):

View File

@@ -49,7 +49,18 @@ def test_render_text_regions_and_header():
st = make_state() st = make_state()
render.render(view, st) 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.is_read_only()
assert view.syntax.endswith("Python.sublime-syntax") assert view.syntax.endswith("Python.sublime-syntax")
@@ -141,18 +152,18 @@ def test_help_popup_lists_every_key():
view, html = help_popup(st) view, html = help_popup(st)
assert view.is_popup_visible() assert view.is_popup_visible()
for group in ("Navigate", "Panels", "Actions", "View"): for group in ("Navigate", "Actions", "View"):
assert f">{group}<" in html 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 / S", "u / U", "d", "C", "m", "r", "z", "esc"):
assert f">{keys}<" in html assert f">{keys}<" in html
assert "next / previous change" in html and "open the real file at this line" in html assert "next / previous change" in html and "open the real file at this line" in html
# working-tree mode: nothing dimmed, every single-action row is a link # working-tree mode: nothing dimmed, every single-action row is a link
assert 'class="dim"' not in html and "uncommitted only" not in html assert 'class="dim"' not in html and "uncommitted only" not in html
assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html assert 'href="discard"' in html and 'href="files"' in html and 'href="close"' in html
# rows covering two actions stay plain text # rows covering two actions stay plain text
assert 'href="next_change"' not in html assert 'href="next_change"' not in html and 'href="stage"' not in html
# the status bar advertises it too # the popup does not list the key that opens it
assert "? keys" in view.status[render.STATUS_KEY] assert ">?<" not in html
def test_help_popup_dims_file_actions_in_commit_mode(): def test_help_popup_dims_file_actions_in_commit_mode():
@@ -162,6 +173,8 @@ def test_help_popup_dims_file_actions_in_commit_mode():
assert "uncommitted only" in html assert "uncommitted only" in html
assert html.count('class="dim"') == 5 assert html.count('class="dim"') == 5
# dimmed rows keep their keys orange
assert 'class="kdim"' not in html and '<span class="k">C</span>' in html
assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html
assert 'href="commit"' not in html and 'href="amend"' not in html assert 'href="commit"' not in html and 'href="amend"' not in html
# everything that still applies stays clickable # everything that still applies stays clickable
@@ -199,11 +212,12 @@ def test_keymap_matches_help_table():
assert all(b["command"] == "vdiff_action" for b in bindings) assert all(b["command"] == "vdiff_action" for b in bindings)
bound = {b["args"]["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} 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 # 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"]} 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() if token != "/"} | {"?"}
def test_no_binding_yields_to_vintage_command_mode(): def test_no_binding_yields_to_vintage_command_mode():
@@ -418,5 +432,3 @@ def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
# said once per scheme, not once per render # said once per scheme, not once per render
render.render(view, make_state()) render.render(view, make_state())
assert said == ["vDiff: " + render.NEEDS_RESTART] assert said == ["vDiff: " + render.NEEDS_RESTART]

View File

@@ -11,6 +11,9 @@
// mark the changed characters inside paired deleted/inserted lines // mark the changed characters inside paired deleted/inserted lines
"word_diff": true, "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 executable; sublime's PATH may not include a user-installed git
"git_binary": "git", "git_binary": "git",

View File

@@ -11,7 +11,7 @@ from .git import Commit, FileChange
from .state import Kept, ViewState from .state import Kept, ViewState
from .utils import file_count, run_async, settings, status 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] Action = Callable[[sublime.View, sublime.Window, ViewState], None]
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES # 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") 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. # row width shared by subject and annotation; the subject is clipped to fit, never below MIN_SUBJECT_WIDTH
# The floor keeps a readable stub on a narrow panel.
COMMIT_ROW_WIDTH = 120 COMMIT_ROW_WIDTH = 120
MIN_SUBJECT_WIDTH = 24 MIN_SUBJECT_WIDTH = 24
# highlight-to-preview delay: holding an arrow key must not spawn a git process per row # highlight-to-preview delay: holding an arrow key must not spawn a git process per row
PREVIEW_DELAY_MS = 60 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] = {} _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""" """Repository of the review view or active file, else of the first project folder that is one"""
view = window.active_view() view = window.active_view()
candidates: List[str] = [] 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 view is not None:
if state.is_review_view(view): if state.is_review_view(view):
st = state.get(view) or restore_view(view) st = state.get(view) or restore_view(view)
@@ -53,6 +53,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]:
if name: if name:
candidates.append(os.path.dirname(name)) candidates.append(os.path.dirname(name))
candidates.extend(window.folders()) candidates.extend(window.folders())
# first candidate in a repo wins; only hits are cached
for directory in candidates: for directory in candidates:
root = _roots.get(directory) root = _roots.get(directory)
if root is None: 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]: 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(): for view in window.views():
if state.is_review_view(view): if state.is_review_view(view):
return 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]]: 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) repo = resolve_repo(window)
if not repo: if not repo:
status("no git repository for the active file or project folders") 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 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: 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""" """Fetch and parse the file's diff on the worker thread, then render it"""
st.all_mode = False
st.token += 1 st.token += 1
# set before the worker starts: n / p count from here while the diff is still loading # set before the worker starts: n / p count from here while the diff is still loading
st.requested = change st.requested = change
@@ -98,6 +124,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
repo, sha = st.repo, st.sha repo, sha = st.repo, st.sha
def work() -> Optional[Tuple[parse.Diff, List[parse.Block]]]: 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: if token != st.token:
return None # superseded before it started: skip the subprocess return None # superseded before it started: skip the subprocess
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?")) 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) return diff, parse.change_blocks(diff.lines)
def done(result: Optional[Tuple[parse.Diff, List[parse.Block]]]) -> None: 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(): if result is None or token != st.token or not view.is_valid():
return return
diff, blocks = result 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]: 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] = [] items: List[sublime.QuickPanelItem] = []
for change in files: for change in files:
description = git.STATUS_NAMES.get(change.status, "") 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 ""))) details = ", ".join(filter(None, (description, "staged" if change.staged else "")))
if change.old_path: if change.old_path:
details += " ← " + change.old_path details += " ← " + change.old_path
# no line counts means git saw a binary file
if None in (change.insertions, change.deletions): if None in (change.insertions, change.deletions):
annotation = "binary" annotation = "binary"
else: else:
@@ -134,13 +164,14 @@ def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
def commit_items(commits: Sequence[Commit]) -> 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] = [] items: List[sublime.QuickPanelItem] = []
for c in commits: for c in commits:
# merges and empty commits have no diffstat, so they show the hash alone
annotation = c.short annotation = c.short
if c.files is not None: 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}" 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)" subject = c.subject or "(no subject)"
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH) budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
if len(subject) > budget: 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], def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
kept: Kept, on_cancel: Optional[Callable[[], None]] = None, kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
placeholder: str = "Changed files") -> 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 st.files = files
selected = max(0, st.index()) selected = max(0, st.index())
window.focus_view(view) window.focus_view(view)
def on_select(index: int) -> None: 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 st.highlight += 1 # cancel a pending preview
if index == -1: if index == -1:
if on_cancel is not None: if on_cancel is not None:
st.restore(kept) st.restore(kept)
on_cancel() on_cancel()
elif kept.change is not None: 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) st.restore(kept)
show_file(view, st, kept.change) show_file(view, st, kept.change)
# nothing was kept before: the last previewed file stays # 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) show_file(view, st, change)
def on_highlight(index: int) -> None: def on_highlight(index: int) -> None:
"""Schedule a debounced preview of the highlighted file"""
if not 0 <= index < len(files): if not 0 <= index < len(files):
return return
st.highlight += 1 st.highlight += 1
ticket = st.highlight ticket = st.highlight
def preview() -> None: def preview() -> None:
"""Show the file unless another highlight came since"""
if ticket == st.highlight: if ticket == st.highlight:
show_file(view, st, files[index]) 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: 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 """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: if message:
status(message) status(message)
# remember the shown file and its slot before the list changes
previous_index = max(0, st.index()) previous_index = max(0, st.index())
path = st.change.path if st.change else None path = st.change.path if st.change else None
def done(files: List[FileChange]) -> 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(): if not view.is_valid():
return return
st.files = files 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 # the file left the list: whatever moved up into its slot takes over
index = min(previous_index, len(files) - 1) index = min(previous_index, len(files) - 1)
chosen = pick(files, index) if pick else None 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) 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""" """List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
def run(self) -> None: def run(self) -> None:
"""Open the review view, then list the changed files off the UI thread"""
window = self.window window = self.window
opened = open_review(window) opened = open_review(window)
if not opened: if not opened:
@@ -236,6 +280,7 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
view, st = opened view, st = opened
def done(files: List[FileChange]) -> None: def done(files: List[FileChange]) -> None:
"""Show the files panel, or a notice when there is nothing uncommitted"""
if not view.is_valid(): if not view.is_valid():
return return
kept = st.enter() 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""" """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: 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) opened = open_review(self.window)
if not opened: if not opened:
return return
@@ -263,6 +309,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
count = int(settings().get("commits_count", 30) or 30) count = int(settings().get("commits_count", 30) or 30)
def done(commits: List[Commit]) -> None: def done(commits: List[Commit]) -> None:
"""Show the commits panel unless the repository has none"""
if not view.is_valid(): if not view.is_valid():
return return
if not commits: if not commits:
@@ -273,22 +320,25 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
run_async(lambda: git.recent_commits(st.repo, count), done) 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: 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) items = commit_items(commits)
for i, c in enumerate(commits): for i, c in enumerate(commits):
if c.sha == st.sha: if c.sha == st.sha:
selected = i selected = i
def on_select(index: int) -> None: def on_select(index: int) -> None:
"""Open the chosen commit's files, remembering its row for Esc"""
if index != -1: if index != -1:
self.open_commit(view, st, commits[index].sha, index) self.open_commit(view, st, commits[index].sha, index)
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits") 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: 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 window = self.window
def done(result: Tuple[git.CommitInfo, List[FileChange]]) -> None: 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(): if not view.is_valid():
return return
info, files = result info, files = result
@@ -298,6 +348,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
return return
def back() -> None: def back() -> None:
"""Reopen the commit list on the row this commit came from"""
window.run_command("vdiff_commits", {"selected": index}) window.run_command("vdiff_commits", {"selected": index})
placeholder = f"{info.short} · {info.subject}" 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: def _act_files(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Open the changed-files panel"""
window.run_command("vdiff_files") window.run_command("vdiff_files")
def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None: def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Open the recent-commits panel"""
window.run_command("vdiff_commits") window.run_command("vdiff_commits")
def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None: def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Close the review view"""
view.close() view.close()
def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None: def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Show the help popup"""
render.show_help(view, st) render.show_help(view, st)
def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None], def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None],
confirm: Optional[Callable[[FileChange], bool]] = None, confirm: Optional[Callable[[FileChange], bool]] = None,
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> Action: 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: 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") status(f"{verb} works on uncommitted changes only")
return return
change = st.change
if confirm is not None and not confirm(change): if confirm is not None and not confirm(change):
return return
run_async( run_async(
@@ -346,9 +402,32 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange]
return act return act
def _bulk_action(verb: str, done_verb: str, operation: Callable[[str], None],
wanted: Callable[[FileChange], bool]) -> Action:
"""Action running `operation(repo)` ("<verb> all") when some listed file is `wanted`, then re-listing on the same file"""
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Run the operation off the UI thread, then refresh the listing"""
if st.in_commit:
status(f"{verb} all works on uncommitted changes only")
return
count = sum(1 for c in st.files if wanted(c))
if not count:
status(f"nothing to {verb}")
return
run_async(
lambda: operation(st.repo),
lambda _: refresh_listing(view, st, f"{done_verb} {file_count(count)}"),
)
return act
def _confirm_discard(change: FileChange) -> bool: 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): if not settings().get("confirm_discard", True):
return True return True
# untracked files are deleted outright, tracked ones reset
if change.status == "?": if change.status == "?":
message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone." message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
else: else:
@@ -372,12 +451,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""" """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): if not settings().get("stage_and_move_next_file", True):
return None 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) following = _next_unstaged(files, index)
return files[following] if following >= 0 else None return files[following] if following >= 0 else None
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None: def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Reload what is shown: 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: if st.change is None:
return return
if st.in_commit: if st.in_commit:
@@ -386,26 +469,92 @@ def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> N
refresh_listing(view, st) 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: 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 return
path = os.path.join(st.repo, st.change.path) path = os.path.join(st.repo, change.path)
if not os.path.exists(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 return
# 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)) line = parse.line_for_open(st.lines, render.caret_index(view))
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION) window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
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: 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: if not st.files:
status("no file list, press f or c") status("no file list, press f or c")
return return
count = len(st.files) count = len(st.files)
position = st.index() position = st.index()
if position < 0: 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 index, wrapped = (0 if delta > 0 else count - 1), False
else: else:
index = position + delta index = position + delta
@@ -421,7 +570,42 @@ def _step_file(delta: int) -> Action:
def _step_change(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: 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] starts = [start for start, _ in st.blocks]
if not starts: if not starts:
status("no changes to step through") status("no changes to step through")
@@ -431,6 +615,7 @@ def _step_change(delta: int) -> Action:
if not 0 <= position < len(starts): if not 0 <= position < len(starts):
render.flash(view) render.flash(view)
position %= len(starts) # -1 is the last change, len(starts) the first 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]) point = render.index_point(view, starts[position])
view.sel().clear() view.sel().clear()
view.sel().add(sublime.Region(point)) view.sel().add(sublime.Region(point))
@@ -450,6 +635,7 @@ def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> No
return return
def on_done(message: str) -> None: 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(): if not message.strip():
status("commit cancelled: empty message") status("commit cancelled: empty message")
return return
@@ -464,7 +650,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: 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 """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: if st.in_commit:
status("amend works on uncommitted changes only") status("amend works on uncommitted changes only")
@@ -472,9 +658,11 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
staged = [c for c in st.files if c.staged] staged = [c for c in st.files if c.staged]
def prompt(message: str) -> None: def prompt(message: str) -> None:
"""Show the input panel prefilled with HEAD's subject"""
subject, newline, body = message.partition("\n") subject, newline, body = message.partition("\n")
def on_done(text: str) -> None: 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(): if not text.strip():
status("amend cancelled: empty message") status("amend cancelled: empty message")
return return
@@ -482,7 +670,7 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
if staged: if staged:
done += f" with {file_count(len(staged))}" done += f" with {file_count(len(staged))}"
run_async( 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: git.amend(st.repo, text + newline + body, allow_empty=not staged),
lambda _: refresh_listing(view, st, done), lambda _: refresh_listing(view, st, done),
) )
@@ -496,7 +684,9 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
def _move_line(forward: bool) -> Action: 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: def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Move the caret one line"""
view.run_command("move", {"by": "lines", "forward": forward}) view.run_command("move", {"by": "lines", "forward": forward})
return act return act
@@ -509,10 +699,16 @@ ACTIONS: Dict[str, Action] = {
"help": _act_help, "help": _act_help,
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next), "stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
"unstage": _git_action("unstage", "unstaged", git.unstage), "unstage": _git_action("unstage", "unstaged", git.unstage),
"stage_all": _bulk_action("stage", "staged", git.stage_all, lambda c: not c.staged),
"unstage_all": _bulk_action("unstage", "unstaged", git.unstage_all, lambda c: c.staged),
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard), "discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
"commit": _act_commit, "commit": _act_commit,
"amend": _act_amend, "amend": _act_amend,
"refresh": _act_refresh, "refresh": _act_refresh,
"view": _act_view,
"expand": _toggle_file(1),
"collapse": _toggle_file(-1),
"fold_all": _act_fold_all,
"open": _act_open, "open": _act_open,
"next_file": _step_file(1), "next_file": _step_file(1),
"prev_file": _step_file(-1), "prev_file": _step_file(-1),
@@ -527,9 +723,11 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
"""Every key/link action of a review view, dispatched by name through ACTIONS""" """Every key/link action of a review view, dispatched by name through ACTIONS"""
def is_enabled(self, action: Optional[str] = None) -> bool: def is_enabled(self, action: Optional[str] = None) -> bool:
"""Only in review views"""
return state.is_review_view(self.view) return state.is_review_view(self.view)
def run(self, edit: sublime.Edit, action: str) -> None: 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) act = ACTIONS.get(action)
if act is None: if act is None:
status(f"unknown action {action}") status(f"unknown action {action}")
@@ -540,35 +738,50 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
def restore_view(view: sublime.View) -> ViewState: def restore_view(view: sublime.View) -> ViewState:
"""Rebuild the state of a review view restored from a previous session; re-renders asynchronously""" """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 {} data = view.settings().get(state.VIEW_DATA) or {}
repo = data.get("repo") repo = data.get("repo")
render.init_view(view) render.init_view(view)
# repository gone: show a notice
if not repo or not os.path.isdir(repo): if not repo or not os.path.isdir(repo):
st = state.put(view, state.ViewState("")) st = state.put(view, state.ViewState(""))
render.render_message(view, st, "Repository not found. Press f for changed files, c for commits.") render.render_message(view, st, "Repository not found. Press f for changed files, c for commits.")
return st return st
st = state.put(view, state.ViewState(repo)) st = state.put(view, state.ViewState(repo))
st.sha = data.get("sha") st.sha = data.get("sha")
st.all_mode = bool(data.get("all_mode", False))
path = data.get("path") path = data.get("path")
if not path: if not path:
render.render_message(view, st, "Press f for changed files, c for commits.") render.render_message(view, st, "Press f for changed files, c for commits.")
return st 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) 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 sha = st.sha
if sha is not None: if sha is not None:
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> 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 st.commit, st.files = result
if st.all_mode:
st.change = change
show_all(view, st)
else:
show_file(view, st, change) show_file(view, st, change)
run_async(lambda: git.commit_with_files(repo, sha), show_commit) run_async(lambda: git.commit_with_files(repo, sha), show_commit)
else: else:
def show_working_tree(files: List[FileChange]) -> None: def show_working_tree(files: List[FileChange]) -> None:
"""Redraw the saved file if it still has changes, else say it has none"""
st.files = files st.files = files
index = state.find_change(files, path) index = state.find_change(files, path)
if index >= 0: if index >= 0:
if st.all_mode:
st.change = files[index]
show_all(view, st)
else:
show_file(view, st, files[index]) show_file(view, st, files[index])
else: else:
render.render_message(view, st, f"{path} has no uncommitted changes any more") render.render_message(view, st, f"{path} has no uncommitted changes any more")

View File

@@ -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 os
import re import re
import subprocess import subprocess
@@ -6,7 +6,7 @@ from typing import Dict, List, Optional, NamedTuple, Sequence, Tuple
class FileChange(NamedTuple): 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 path: str
status: str status: str
@@ -17,7 +17,7 @@ class FileChange(NamedTuple):
class Commit(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 sha: str
short: str short: str
@@ -30,6 +30,8 @@ class Commit(NamedTuple):
class CommitInfo(NamedTuple): class CommitInfo(NamedTuple):
"""One commit: its hashes, author, date, subject and body"""
sha: str sha: str
short: str short: str
author: str author: str
@@ -69,9 +71,10 @@ _SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(
class GitError(Exception): 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: def __init__(self, stderr: str, code: int) -> None:
"""Keep stderr and the exit code"""
self.stderr = stderr self.stderr = stderr
self.code = code self.code = code
first = stderr.strip().splitlines() 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""" """Top-level directory of the repository containing `path`, or None"""
if not path: if not path:
return None return None
# start from the directory itself, or the file's parent
directory = path if os.path.isdir(path) else os.path.dirname(path) directory = path if os.path.isdir(path) else os.path.dirname(path)
if not os.path.isdir(directory): if not os.path.isdir(directory):
return None return None
# outside a repository git fails, which reads as None
try: try:
root = run(directory, ["rev-parse", "--show-toplevel"]).strip() root = run(directory, ["rev-parse", "--show-toplevel"]).strip()
except (GitError, OSError): except (GitError, OSError):
@@ -116,13 +121,14 @@ def base_ref(repo: str) -> str:
def _int_or_none(value: str) -> Optional[int]: 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) return None if value == "-" else int(value)
def _parse_numstat(text: str) -> Dict[str, Numstat]: def _parse_numstat(text: str) -> Dict[str, Numstat]:
"""`--numstat -z` output -> {path: (insertions, deletions, old_path)}""" """`--numstat -z` output -> {path: (insertions, deletions, old_path)}"""
result: Dict[str, Numstat] = {} result: Dict[str, Numstat] = {}
# each NUL field is one "insertions<TAB>deletions<TAB>path" entry
fields = iter(text.split("\0")) fields = iter(text.split("\0"))
for entry in fields: for entry in fields:
parts = entry.split("\t") 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]]]: def _parse_name_status(text: str) -> List[Tuple[str, str, Optional[str]]]:
"""`--name-status -z` output -> [(status letter, path, old_path)]""" """`--name-status -z` output -> [(status letter, path, old_path)]"""
result: List[Tuple[str, str, Optional[str]]] = [] 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")) fields = iter(text.split("\0"))
for status in fields: for status in fields:
if not status: 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""" """Line count of a working-tree file, None when binary or unreadable"""
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
# a NUL in the first 8000 bytes marks a binary file
head = f.read(8000) head = f.read(8000)
if b"\0" in head: if b"\0" in head:
return None return None
count = head.count(b"\n") count = head.count(b"\n")
last = head[-1:] last = head[-1:]
# the rest in 1 MiB chunks, remembering the final byte
while True: while True:
chunk = f.read(1 << 20) chunk = f.read(1 << 20)
if not chunk: if not chunk:
@@ -170,6 +179,7 @@ def _count_lines(path: str) -> Optional[int]:
last = chunk[-1:] last = chunk[-1:]
except OSError: except OSError:
return None return None
# an empty file has no lines; a last line without a newline still counts
if not last: if not last:
return 0 return 0
return count + (0 if last == b"\n" else 1) 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" base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base])) 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] = [] changes: List[FileChange] = []
for entry in entries: for entry in entries:
kind = entry[0] kind = entry[0]
@@ -195,9 +206,11 @@ def changed_files(repo: str) -> List[FileChange]:
xy, path = ".?", entry[2:] xy, path = ".?", entry[2:]
else: else:
continue continue
# any index change means staged; the worktree letter wins over the index letter
x, y = xy[0], xy[1] x, y = xy[0], xy[1]
staged = x not in (".", "?") staged = x not in (".", "?")
letter = y if y != "." else x letter = y if y != "." else x
# untracked files have no numstat: their lines count as insertions
if letter == "?": if letter == "?":
lines = _count_lines(os.path.join(repo, path)) lines = _count_lines(os.path.join(repo, path))
ins, dele, old = lines, (None if lines is None else 0), None 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""" """Last `count` commits, newest first; empty list without commits"""
if not has_head(repo): if not has_head(repo):
return [] 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"]) 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] = [] commits: List[Commit] = []
for line in text.split("\n"): for line in text.split("\n"):
parts = line.split("\x1f") 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""" """(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
show = ["show", "--first-parent", "-m", "-z"] show = ["show", "--first-parent", "-m", "-z"]
text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha]) 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") info_text, _, files_text = text.partition("\0")
files_text = files_text.lstrip("\0\n") files_text = files_text.lstrip("\0\n")
# pad missing trailing fields so CommitInfo always gets six
parts = info_text.split("\x1f") parts = info_text.split("\x1f")
while len(parts) < 6: while len(parts) < 6:
parts.append("") parts.append("")
info = CommitInfo(*parts[:6]) 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])) counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha]))
changes: List[FileChange] = [] changes: List[FileChange] = []
for status, path, old_path in _parse_name_status(files_text): 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 """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 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] common = ["--no-color", "--no-ext-diff", FULL_CONTEXT]
# untracked: against /dev/null, where exit code 1 only means the files differ
if untracked: if untracked:
return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1)) return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1))
if sha: if sha:
@@ -278,34 +295,49 @@ def stage(repo: str, change: FileChange) -> None:
run(repo, ["add", "-A", "--", change.path]) run(repo, ["add", "-A", "--", change.path])
def stage_all(repo: str) -> None:
"""Stage every change in the working tree, including deletions and untracked files"""
run(repo, ["add", "-A"])
def commit(repo: str, message: str) -> 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]) run(repo, ["commit", "-m", message])
def head_message(repo: str) -> str: 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") return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
def amend(repo: str, message: str, allow_empty: bool = False) -> None: def amend(repo: str, message: str, allow_empty: bool = False) -> None:
"""Rewrite HEAD with `message`, folding in whatever is staged """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]) run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
def _unstage_path(repo: str, path: str) -> None:
"""Move the staged changes under `path` back to the working tree; before the first commit, drop them from the index"""
if has_head(repo):
run(repo, ["reset", "-q", "--", path])
else:
run(repo, ["rm", "-r", "-q", "--cached", "--", path])
def unstage(repo: str, change: FileChange) -> None: def unstage(repo: str, change: FileChange) -> None:
"""Move the file's staged changes back to the working tree""" """Move the file's staged changes back to the working tree"""
if has_head(repo): _unstage_path(repo, change.path)
run(repo, ["reset", "-q", "--", change.path])
else:
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path]) def unstage_all(repo: str) -> None:
"""Move every staged change back to the working tree"""
_unstage_path(repo, ".")
def discard(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 == "?": if change.status == "?":
run(repo, ["clean", "-f", "--", change.path]) run(repo, ["clean", "-f", "--", change.path])
return return

View File

@@ -8,6 +8,7 @@ from .commands import refresh_if_changed, restore_view
class VdiffEventListener(sublime_plugin.EventListener): class VdiffEventListener(sublime_plugin.EventListener):
def on_pre_close(self, view: sublime.View) -> None: def on_pre_close(self, view: sublime.View) -> None:
"""Forget the closing view's state"""
state.drop(view) state.drop(view)
def on_activated(self, view: sublime.View) -> None: def on_activated(self, view: sublime.View) -> None:

View File

@@ -5,7 +5,7 @@ red/green background and the file's syntax colours. This module generates a scop
kind, colour) pair carrying both, and render paints each run of same-coloured tokens with it. 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 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 json
import logging import logging
@@ -16,16 +16,28 @@ import sublime
LOG = logging.getLogger("vdiff") LOG = logging.getLogger("vdiff")
# the full-line tint under the token layer, the scheme's own: the token layer repaints the text on # tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text
# top so the tint's foreground never shows, and a scope the scheme defines needs no restart.
SCOPE_DEL = "diff.deleted" SCOPE_DEL = "diff.deleted"
SCOPE_INS = "diff.inserted" SCOPE_INS = "diff.inserted"
SCOPE_DEL_CHAR = "diff.deleted.char" SCOPE_DEL_CHAR = "diff.deleted.char"
SCOPE_INS_CHAR = "diff.inserted.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 # background kinds (tinted ones paint changed lines, "ctx" the all-files context rows) and their tint scopes
KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char") TINT_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} 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) # tints to provision when the active scheme defines none of its own (some third-party schemes)
FALLBACK_TINTS: Dict[str, str] = { FALLBACK_TINTS: Dict[str, str] = {
@@ -35,9 +47,8 @@ FALLBACK_TINTS: Dict[str, str] = {
SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)", 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 # standard scope roots probed for the palette; no API returns it whole, and parsing the scheme
# yields the working set. No API returns a scheme's palette — style_for_scope takes one scope at a # file would mean re-doing sublime's merging
# time, and reading the file would mean re-implementing sublime's override and .tmTheme merging.
PALETTE_PROBES: Tuple[str, ...] = ( PALETTE_PROBES: Tuple[str, ...] = (
"source", "source",
"comment", "comment",
@@ -71,7 +82,7 @@ PALETTE_PROBES: Tuple[str, ...] = (
_palettes: Dict[str, Dict[str, int]] = {} _palettes: Dict[str, Dict[str, int]] = {}
# the override name the last ensure() resolved, so scope_for knows which palette to read # the override name the last ensure() resolved, so scope_for knows which palette to read
_active: Optional[str] = None _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() _notified: Set[Optional[str]] = set()
@@ -84,22 +95,23 @@ def reset() -> None:
def claim_restart_notice(view: sublime.View) -> bool: 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 return False
_notified.add(_active) _notified.add(_active)
return True return True
def scope_background(view: sublime.View, scope: str) -> Optional[str]: 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") return _style(view, scope).get("background")
def _style(view: sublime.View, scope: str) -> Dict[str, Any]: def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
"""The scheme's style for a scope, or {} when style_for_scope fails"""
try: try:
return view.style_for_scope(scope) return view.style_for_scope(scope)
except Exception: except Exception:
@@ -112,17 +124,24 @@ def foreground(view: sublime.View, scope: str) -> Optional[str]:
return _style(view, scope).get("foreground") 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 """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 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 {} colours = (_active and _palettes.get(_active)) or {}
if not colours: if not colours:
return False return False
# they are all written by the same pass into the same file, so any one of them answers for all # a kind added since the override was written loads only after a restart, so check each kind
return bool(scope_background(view, _scope_name(KINDS[0], next(iter(colours.values()))))) # 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]: 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: 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) return scope.replace("vdiff.", "vdiff.tok.", 1)
@@ -143,28 +162,30 @@ def token_keys() -> List[str]:
def _scope_name(kind: str, index: int) -> 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}" return f"vdiff.{kind}.c{index}"
def ensure(view: sublime.View) -> None: def ensure(view: sublime.View) -> None:
"""Provision the scopes for the active scheme, once per scheme per session """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 global _active
name = override_name(view) name = override_name(view)
_active = name _active = name
if not name or name in _palettes: if not name or name in _palettes:
return return
# merge into the existing override, if any
path = os.path.join(sublime.packages_path(), "User", name) path = os.path.join(sublime.packages_path(), "User", name)
try: try:
scheme: Dict[str, Any] = {} scheme: Dict[str, Any] = {}
if os.path.exists(path): if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
# a hand-edited scheme may hold comments decode_value rejects: the raise leaves the # invalid JSON raises here, leaving the file untouched; render still shows the changed lines
# file untouched, and render still shows the changed lines
scheme = sublime.decode_value(f.read()) or {} scheme = sublime.decode_value(f.read()) or {}
rules = scheme.setdefault("rules", []) rules = scheme.setdefault("rules", [])
# probe the scheme, record the palette, and write only when a rule changed
tints = _tints(view) tints = _tints(view)
colours = {colour: i for i, colour in enumerate(_probe_palette(view))} colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
_palettes[name] = colours _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) LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path)
except Exception: except Exception:
LOG.warning("could not write colour scheme override %s", path, exc_info=True) 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, {}) _palettes.setdefault(name, {})
@@ -192,7 +214,7 @@ def _probe_palette(view: sublime.View) -> List[str]:
def _tints(view: sublime.View) -> Dict[str, 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 """{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] = {} tints: Dict[str, str] = {}
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)): 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) char = scope_background(view, char_scope)
tints[line_scope] = line if line and char else FALLBACK_TINTS[line_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] 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 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], def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
view: sublime.View) -> bool: view: sublime.View) -> bool:
"""Bring the scheme's rules up to date, in place; True when anything changed """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 Each (kind, colour) rule carries a background and a foreground, as a background alone would flatten
rule carrying both; a background alone would flatten the line. Plain tints are written only when the line's colours; plain tints are added only when missing and never overwritten, so a hand-tuned one survives
the scheme has none, and never overwritten, so a hand-tuned one survives.
""" """
present = {rule.get("scope"): rule for rule in rules} present = {rule.get("scope"): rule for rule in rules}
changed = False 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(): 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}) rules.append({"scope": scope, "background": background})
changed = True changed = True
# one rule per (kind, colour) pair
for colour, index in colours.items(): for colour, index in colours.items():
for kind in KINDS: for kind in KINDS:
wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], 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"] name = sublime.ui_info()["color_scheme"]["resolved_value"]
except Exception: except Exception:
name = view.settings().get("color_scheme") or "" name = view.settings().get("color_scheme") or ""
# no usable scheme name: nothing to override
if not name or name == "auto": if not name or name == "auto":
return None return None
root, _ = os.path.splitext(os.path.basename(name)) root, _ = os.path.splitext(os.path.basename(name))

View File

@@ -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 difflib
import re import re
from collections import Counter from collections import Counter
@@ -7,7 +7,7 @@ from typing import Callable, Iterator, List, NamedTuple, Optional, Sequence, Tup
class Line(NamedTuple): 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 kind: str
old_no: Optional[int] old_no: Optional[int]
@@ -16,6 +16,8 @@ class Line(NamedTuple):
class Diff(NamedTuple): class Diff(NamedTuple):
"""A parsed unified diff: its rows, and whether git reported the file as binary"""
lines: List[Line] lines: List[Line]
binary: bool 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` # the value `runs` groups by, so a caller gets its own key type back rather than `object`
Key = TypeVar("Key") 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] Block = Tuple[int, int]
# a (start, end) character range inside one line's text # a (start, end) character range inside one line's text
CharRange = Tuple[int, int] 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_MIN_RATIO = 0.5
CHAR_DIFF_MAX_CHARS = 2000 CHAR_DIFF_MAX_CHARS = 2000
@@ -42,11 +44,13 @@ def parse_unified(text: str) -> Diff:
old_no = new_no = 0 old_no = new_no = 0
in_hunk = False in_hunk = False
for raw in text.split("\n"): for raw in text.split("\n"):
# a hunk header resets both line counters
if raw.startswith("@@"): if raw.startswith("@@"):
match = _HUNK.match(raw) match = _HUNK.match(raw)
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0) old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
in_hunk = True in_hunk = True
continue continue
# before the first hunk only headers: watch for a binary marker
if not in_hunk: if not in_hunk:
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"): if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
binary = True binary = True
@@ -54,6 +58,7 @@ def parse_unified(text: str) -> Diff:
if raw == "": if raw == "":
# only the trailing split remainder; an empty context line is " " # only the trailing split remainder; an empty context line is " "
continue continue
# hunk body: the first character is the line kind
marker, body = raw[0], raw[1:] marker, body = raw[0], raw[1:]
if marker == " ": if marker == " ":
lines.append(Line(" ", old_no, new_no, body)) 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]: 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] 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]]: 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 start, end = block
deleted = [i for i in range(start, end) if lines[i].kind == "-"] deleted = [i for i in range(start, end) if lines[i].kind == "-"]
inserted = [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) matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO: if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
return [], [] return [], []
# each opcode span becomes a range on whichever side it touches
old_ranges: List[CharRange] = [] old_ranges: List[CharRange] = []
new_ranges: List[CharRange] = [] new_ranges: List[CharRange] = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes(): 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""" """New-file line number to open for a row: a deleted row maps to the next surviving line"""
if not lines: if not lines:
return 1 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)) index = max(0, min(index, len(lines) - 1))
for line in lines[index:]: for line in lines[index:]:
if line.new_no is not None: if line.new_no is not None:

View File

@@ -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 bisect
import html import html
import logging import logging
import os 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
import sublime_plugin import sublime_plugin
@@ -22,19 +22,36 @@ KEY_DEL_GUTTER = "vdiff.del.gutter"
KEY_INS_GUTTER = "vdiff.ins.gutter" KEY_INS_GUTTER = "vdiff.ins.gutter"
KEY_DEL_CHAR = "vdiff.del.char" KEY_DEL_CHAR = "vdiff.del.char"
KEY_INS_CHAR = "vdiff.ins.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" PHANTOM_KEY = "vdiff.header"
STATUS_KEY = "vdiff" 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_KEY = "vdiff.bell"
BELL_MS = 90 BELL_MS = 90
# above this much changed text the colours cost more than they are worth: such a diff is scrolled, not read # 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 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 _HEADER_ROWS = 1
# gutter dots use the scope's foreground # gutter dots use the scope's foreground
@@ -42,12 +59,13 @@ SCOPE_DEL_MARK = "markup.deleted"
SCOPE_INS_MARK = "markup.inserted" SCOPE_INS_MARK = "markup.inserted"
# ST4 defines region.<colour>ish in every bundled scheme for exactly this kind of transient marking # ST4 defines region.<colour>ish in every bundled scheme for exactly this kind of transient marking
SCOPE_BELL = "region.yellowish" 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" 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): class Key(NamedTuple):
"""One row of the status-bar hints and the ? popup""" """One row of the status-bar hints and the ? popup"""
@@ -57,7 +75,7 @@ class Key(NamedTuple):
actions: Tuple[str, ...] 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]]] = [ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
( (
"Navigate", "Navigate",
@@ -69,20 +87,12 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")), 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", "Actions",
False, False,
[ [
Key("s", "stage", "stage", ("stage",)), Key("s / S", "stage / stage all", "stage", ("stage", "stage_all")),
Key("u", "unstage", "unstage", ("unstage",)), Key("u / U", "unstage / unstage all", "unstage", ("unstage", "unstage_all")),
Key("d", "discard", "discard", ("discard",)), Key("d", "discard", "discard", ("discard",)),
Key("C", "commit the staged files", "commit", ("commit",)), Key("C", "commit the staged files", "commit", ("commit",)),
Key("m", "amend the last commit", "amend", ("amend",)), Key("m", "amend the last commit", "amend", ("amend",)),
@@ -92,8 +102,12 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
"View", "View",
True, 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("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",)), 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)); } .body { color: color(var(--foreground) alpha(0.6)); }
""" """
# minihtml drops the rest of a stylesheet at a selector it cannot parse, and documents properties # minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class
# only, not selectors: every rule sticks to one bare element or class, the forms HEADER_CSS proves. # .group's top margin replaces body's top padding
# The first group's margin stands in for body's top padding; .kdim takes its colour from .dim.
HELP_CSS = """ HELP_CSS = """
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; } body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; } .group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
@@ -133,7 +146,6 @@ body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
.row { color: color(var(--foreground) alpha(0.85)); } .row { color: color(var(--foreground) alpha(0.85)); }
.dim { color: color(var(--foreground) alpha(0.35)); } .dim { color: color(var(--foreground) alpha(0.35)); }
.k { color: var(--orangish); font-weight: bold; } .k { color: var(--orangish); font-weight: bold; }
.kdim { font-weight: bold; }
a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); } a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
""" """
@@ -142,12 +154,24 @@ class VdiffReplaceCommand(sublime_plugin.TextCommand):
"""Replace the whole buffer; the only writer of a review view""" """Replace the whole buffer; the only writer of a review view"""
def run(self, edit: sublime.Edit, text: str) -> None: def run(self, edit: sublime.Edit, text: str) -> None:
"""Replace the buffer"""
self.view.replace(edit, sublime.Region(0, self.view.size()), text) 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: def init_view(view: sublime.View) -> None:
"""Settings a review view needs, applied once when it is created or restored""" """Settings a review view needs, applied once when it is created or restored"""
s = view.settings() 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) s.set(VIEW_FLAG, True)
# native line numbers would be wrong: deleted lines have none # native line numbers would be wrong: deleted lines have none
s.set("line_numbers", False) s.set("line_numbers", False)
@@ -158,12 +182,20 @@ def init_view(view: sublime.View) -> None:
s.set("detect_indentation", False) s.set("detect_indentation", False)
s.set("translate_tabs_to_spaces", False) s.set("translate_tabs_to_spaces", False)
s.set("show_definitions", 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("mini_diff", False)
s.set("spell_check", 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 # 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("command_mode", True)
s.set("inverse_caret_state", 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_scratch(True)
view.set_read_only(True) view.set_read_only(True)
if not view.name(): if not view.name():
@@ -191,6 +223,7 @@ def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float
def _mtime(path: str) -> Optional[float]: def _mtime(path: str) -> Optional[float]:
"""Modification time of `path`, or None when it cannot be stat'ed"""
try: try:
return os.stat(path).st_mtime return os.stat(path).st_mtime
except OSError: except OSError:
@@ -198,14 +231,25 @@ def _mtime(path: str) -> Optional[float]:
def flash(view: sublime.View) -> None: 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) 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 # 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) 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: 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): if palette.scope_background(view, SCOPE_BELL):
return SCOPE_BELL return SCOPE_BELL
# an invisible bell is a silent failure, so borrow the insert tint # an invisible bell is a silent failure, so borrow the insert tint
@@ -223,12 +267,219 @@ def render_message(view: sublime.View, st: ViewState, message: str) -> None:
_finish(view, st, 0, 0, "vDiff") _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: def render(view: sublime.View, st: ViewState) -> None:
"""Draw st.lines for st.change into the view""" """Draw st.lines for st.change into the view"""
change = st.change change = st.change
# every caller with nothing to show goes to render_message instead, so there is a file here # every caller with nothing to show goes to render_message instead, so there is a file here
assert change is not None assert change is not None
lines = st.lines lines = st.lines
# the text: a placeholder for a binary or empty diff, else the lines
if st.binary: if st.binary:
text = "(binary file)" text = "(binary file)"
elif not lines: elif not lines:
@@ -236,6 +487,7 @@ def render(view: sublime.View, st: ViewState) -> None:
else: else:
text = "\n".join(line.text for line in lines) text = "\n".join(line.text for line in lines)
# write the text and syntax
_replace(view, text) _replace(view, text)
path = os.path.join(st.repo, change.path) path = os.path.join(st.repo, change.path)
_apply_syntax(view, path if lines and not st.binary else None) _apply_syntax(view, path if lines and not st.binary else None)
@@ -247,6 +499,7 @@ def render(view: sublime.View, st: ViewState) -> None:
if palette.claim_restart_notice(view): if palette.claim_restart_notice(view):
status(NEEDS_RESTART) status(NEEDS_RESTART)
# header and bookkeeping, then the caret and scroll back to the top
ins, dele = parse.counts(lines) ins, dele = parse.counts(lines)
_finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}") _finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}")
view.sel().clear() view.sel().clear()
@@ -257,40 +510,45 @@ def render(view: sublime.View, st: ViewState) -> None:
def _paint_regions(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""" """The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
lines = st.lines lines = st.lines
# full-line tint
starts = _row_starts(lines) starts = _row_starts(lines)
deleted, inserted = _line_regions(lines, starts) deleted, inserted = _line_regions(lines, starts)
view.add_regions(KEY_DEL, deleted, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE) 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) 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 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 == "-"] 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 == "+"] 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_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) 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] = [] del_chars: List[sublime.Region] = []
ins_chars: List[sublime.Region] = [] ins_chars: List[sublime.Region] = []
if settings().get("word_diff", True): if settings().get("word_diff", True):
del_chars, ins_chars = _char_regions(lines, st.blocks, starts) 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_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) 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) _paint_tokens(view, lines, starts, del_chars + ins_chars)
def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int], def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int], char_regions: Sequence[sublime.Region]) -> None:
char_regions: Sequence[sublime.Region]) -> None:
"""Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour """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 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): if not palette.tokens_resolved(view):
return return
changed = [i for i, line in enumerate(lines) if line.kind != " "] 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: 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 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) stronger = sorted((r.a, r.b) for r in char_regions)
starts_of = [a for a, _ in stronger] starts_of = [a for a, _ in stronger]
# group every changed line's colour runs by scope
groups: Dict[str, List[sublime.Region]] = {} groups: Dict[str, List[sublime.Region]] = {}
colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render
for i in changed: for i in changed:
@@ -305,6 +563,7 @@ def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[in
scope = palette.scope_for(kind + ".char" if inside else kind, colour) scope = palette.scope_for(kind + ".char" if inside else kind, colour)
if scope: if scope:
groups.setdefault(scope, []).append(part) groups.setdefault(scope, []).append(part)
# one region set per scope
for scope, regions in groups.items(): for scope, regions in groups.items():
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE) view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
@@ -313,6 +572,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""" """(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
if not intervals: if not intervals:
return [(region, False)] return [(region, False)]
# walk the intervals, emitting the gap before each and its overlap with the region
parts: List[Tuple[sublime.Region, bool]] = [] parts: List[Tuple[sublime.Region, bool]] = []
pos = region.a pos = region.a
for a, b in intervals: for a, b in intervals:
@@ -325,21 +585,33 @@ def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List
parts.append((sublime.Region(pos, a), False)) parts.append((sublime.Region(pos, a), False))
parts.append((sublime.Region(a, b), True)) parts.append((sublime.Region(a, b), True))
pos = b pos = b
# the tail after the last interval, or the whole region when none overlapped
if pos < region.b or not parts: if pos < region.b or not parts:
parts.append((sublime.Region(pos, region.b), False)) parts.append((sublime.Region(pos, region.b), False))
return parts return parts
def _colour_runs(view: sublime.View, start: int, text: str, def _colour_runs(
colours: Dict[str, Optional[str]]) -> List[Tuple[sublime.Region, str]]: view: sublime.View, start: int, text: str, colours: Dict[str, Optional[str]], scheme_view: Optional[sublime.View] = None
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged""" ) -> 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]] = [] 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: if scope not in colours:
colours[scope] = palette.foreground(view, scope) colours[scope] = palette.foreground(scheme_view or view, scope)
colour = colours[scope] colour = colours[scope]
if not colour: if not colour:
continue 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: 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) runs[-1] = (sublime.Region(runs[-1][0].a, region.b), colour)
else: else:
@@ -348,7 +620,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: 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) _header(view, st, ins, dele)
view.set_name(name) view.set_name(name)
view.set_status(STATUS_KEY, KEY_HINTS) view.set_status(STATUS_KEY, KEY_HINTS)
@@ -357,6 +629,7 @@ def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -
def _replace(view: sublime.View, text: str) -> None: 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) view.set_read_only(False)
try: try:
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text}) view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
@@ -389,8 +662,7 @@ def _row_starts(lines: Sequence[Line]) -> List[int]:
return starts return starts
def _line_regions(lines: Sequence[Line], def _line_regions(lines: Sequence[Line], starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
"""Full-line regions of deleted and inserted rows, consecutive rows merged""" """Full-line regions of deleted and inserted rows, consecutive rows merged"""
regions: Dict[str, List[sublime.Region]] = {"-": [], "+": []} regions: Dict[str, List[sublime.Region]] = {"-": [], "+": []}
for kind, start, end in parse.runs(lines, lambda line: line.kind): for kind, start, end in parse.runs(lines, lambda line: line.kind):
@@ -400,12 +672,14 @@ def _line_regions(lines: Sequence[Line],
return regions["-"], regions["+"] return regions["-"], regions["+"]
def _char_regions(lines: Sequence[Line], blocks: Sequence[Block], def _char_regions(
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]: lines: Sequence[Line], blocks: Sequence[Block], starts: Sequence[int]
) -> Tuple[List[sublime.Region], List[sublime.Region]]:
"""Changed characters inside paired deleted/inserted lines""" """Changed characters inside paired deleted/inserted lines"""
deleted: List[sublime.Region] = [] deleted: List[sublime.Region] = []
inserted: List[sublime.Region] = [] inserted: List[sublime.Region] = []
for block in blocks: 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): 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) 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) deleted.extend(sublime.Region(starts[del_row] + a, starts[del_row] + b) for a, b in old_ranges)
@@ -419,7 +693,7 @@ def _totals(files: Sequence[FileChange]) -> Tuple[int, int]:
def _summary_html(st: ViewState) -> str: 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: if not st.files and not st.commit:
return "" return ""
if st.in_commit: if st.in_commit:
@@ -436,9 +710,10 @@ def _summary_html(st: ViewState) -> str:
def _commit_parts(st: ViewState) -> List[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 info = st.commit
parts: List[str] = [] parts: List[str] = []
# the commit's identity: hash, author, date
if info: if info:
parts.append(f'<span class="hash">{html.escape(info.short)}</span>') parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
parts.append(html.escape(info.author)) parts.append(html.escape(info.author))
@@ -446,6 +721,7 @@ def _commit_parts(st: ViewState) -> List[str]:
# binary files add nothing to the line totals but still count toward the file total # binary files add nothing to the line totals but still count toward the file total
insertions, deletions = _totals(st.files) insertions, deletions = _totals(st.files)
parts.append(f"{file_count(len(st.files))} changed" f' <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>') parts.append(f"{file_count(len(st.files))} changed" f' <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>')
# the subject, plus the body's first line
if info: if info:
message = html.escape(info.subject) message = html.escape(info.subject)
body = info.body.strip().splitlines() body = info.body.strip().splitlines()
@@ -458,6 +734,7 @@ def _commit_parts(st: ViewState) -> List[str]:
def header_html(st: ViewState, ins: int, dele: int) -> 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""" """The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
change = st.change change = st.change
# the title: path, rename source, counts and working-tree state
if change is None: if change is None:
title = '<span class="path">vDiff</span>' title = '<span class="path">vDiff</span>'
else: else:
@@ -470,6 +747,7 @@ def header_html(st: ViewState, ins: int, dele: int) -> str:
if state: if state:
title += f'&nbsp;&nbsp;<span class="state">{state}</span>' title += f'&nbsp;&nbsp;<span class="state">{state}</span>'
# action links: stage, unstage and discard need a working-tree file, open needs any file
actions: List[str] = [] actions: List[str] = []
if change is not None and not st.in_commit: if change is not None and not st.in_commit:
actions.append('<a href="stage">Stage</a>') actions.append('<a href="stage">Stage</a>')
@@ -496,6 +774,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""" """One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep"""
def navigate(href: str) -> None: def navigate(href: str) -> None:
"""Run the clicked header link's action"""
_run_action(view, href) _run_action(view, href)
view.erase_phantoms(PHANTOM_KEY) view.erase_phantoms(PHANTOM_KEY)
@@ -508,19 +787,19 @@ def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None:
def help_html(st: ViewState) -> str: def help_html(st: ViewState) -> str:
"""The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown""" """The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown"""
parts: List[str] = [] 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: for title, applies, group in KEY_GROUPS:
dim = st.in_commit and not applies dim = st.in_commit and not applies
heading = html.escape(title) heading = html.escape(title)
if dim: if dim:
heading += ' <span class="note">uncommitted only</span>' heading += ' <span class="note">uncommitted only</span>'
parts.append(f'<div class="group">{heading}</div>') parts.append(f'<div class="group">{heading}</div>')
# one class per element: a compound selector would silently drop the rest of HELP_CSS # keys stay orange in a dimmed group; only the text dims
row_class, key_class = ("dim", "kdim") if dim else ("row", "k") row_class = "dim" if dim else "row"
# a row per key with a padded key column; a single-action row becomes a link
for key in group: for key in group:
row = ( pad = "&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2)
f'<span class="{key_class}">{html.escape(key.keys)}</span>' row = f'<span class="k">{html.escape(key.keys)}</span>{pad}{html.escape(key.label)}'
f'{"&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
)
if len(key.actions) == 1 and not dim: if len(key.actions) == 1 and not dim:
row = f'<a href="{key.actions[0]}">{row}</a>' row = f'<a href="{key.actions[0]}">{row}</a>'
parts.append(f'<div class="{row_class}">{row}</div>') parts.append(f'<div class="{row_class}">{row}</div>')
@@ -534,10 +813,10 @@ def show_help(view: sublime.View, st: ViewState) -> None:
return return
def navigate(href: str) -> None: def navigate(href: str) -> None:
"""Close the popup, then run the clicked row's action"""
view.hide_popup() view.hide_popup()
_run_action(view, href) _run_action(view, href)
# minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size # minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size
em = view.em_width() or 8 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) view.show_popup(help_html(st), location=-1, max_width=int(em * 56), max_height=int(em * 44), on_navigate=navigate)

View File

@@ -50,21 +50,33 @@ class ViewState:
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews 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 # (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable
fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None 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 @property
def in_commit(self) -> bool: def in_commit(self) -> bool:
"""Whether a commit is being reviewed rather than the working tree"""
return self.sha is not None return self.sha is not None
def index(self) -> int: def index(self) -> int:
"""Position in `files` of the file being shown or loaded, or -1 """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 `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 change = self.requested or self.change
return find_change(self.files, change.path) if change else -1 return find_change(self.files, change.path) if change else -1
def kept(self) -> Kept: def kept(self) -> Kept:
"""Snapshot of what to go back to on Esc"""
return Kept(self.sha, self.commit, self.change, list(self.files)) return Kept(self.sha, self.commit, self.change, list(self.files))
def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept: def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
@@ -76,12 +88,21 @@ class ViewState:
return kept return kept
def restore(self, kept: Kept) -> None: 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.sha, self.commit, self.change, self.files = kept
self.requested = kept.change 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: def persist(self, view: "sublime.View") -> None:
"""Mirror the essentials into view settings (survives a restart)""" """Mirror the essentials into view settings (survives a restart)"""
data: Dict[str, Any] = {"repo": self.repo, "sha": self.sha} data: Dict[str, Any] = {"repo": self.repo, "sha": self.sha}
if self.all_mode:
data["all_mode"] = True
if self.change: if self.change:
data["path"] = self.change.path data["path"] = self.change.path
data["status"] = self.change.status data["status"] = self.change.status
@@ -93,6 +114,7 @@ STATES: Dict[int, "ViewState"] = {}
def get(view: "sublime.View") -> Optional[ViewState]: def get(view: "sublime.View") -> Optional[ViewState]:
"""State of a review view, or None when not tracked"""
return STATES.get(view.id()) return STATES.get(view.id())
@@ -102,13 +124,16 @@ def tracked() -> List[Tuple[int, ViewState]]:
def put(view: "sublime.View", st: ViewState) -> ViewState: def put(view: "sublime.View", st: ViewState) -> ViewState:
"""Track `st` as the view's state and return it"""
STATES[view.id()] = st STATES[view.id()] = st
return st return st
def drop(view: "sublime.View") -> None: def drop(view: "sublime.View") -> None:
"""Forget the view's state, if any"""
STATES.pop(view.id(), None) STATES.pop(view.id(), None)
def is_review_view(view: Optional["sublime.View"]) -> bool: 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)) return bool(view and view.settings().get(VIEW_FLAG))

View File

@@ -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""" """Run `work()` on sublime's worker thread, hand the result to `done` on the main thread"""
def job() -> None: def job() -> None:
"""Worker-thread body: run `work`, pass on its result or report its error"""
try: try:
result = work() result = work()
except Exception as error: except Exception as error: