Files
vdiff/vdiff/render.py

825 lines
36 KiB
Python

"""Draw a parsed diff into a review view: file text, line tints, gutter marks, header phantom"""
import bisect
import html
import logging
import os
from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
import sublime
import sublime_plugin
from . import git, palette, parse
from .git import FileChange, STATUS_NAMES
from .parse import Block, Line
from .state import VIEW_FLAG, ViewState
from .utils import file_count, settings, status
LOG = logging.getLogger("vdiff")
KEY_DEL = "vdiff.del"
KEY_INS = "vdiff.ins"
KEY_DEL_GUTTER = "vdiff.del.gutter"
KEY_INS_GUTTER = "vdiff.ins.gutter"
KEY_DEL_CHAR = "vdiff.del.char"
KEY_INS_CHAR = "vdiff.ins.char"
KEY_FILE_HEADER = "vdiff.file.header"
KEY_FILE_DEL_COUNT = palette.SCOPE_FILE_DEL_COUNT
KEY_FILE_INS_COUNT = palette.SCOPE_FILE_INS_COUNT
KEY_FILE_PREFIX = "vdiff.file.prefix"
REGION_KEYS = (
KEY_DEL,
KEY_INS,
KEY_DEL_GUTTER,
KEY_INS_GUTTER,
KEY_DEL_CHAR,
KEY_INS_CHAR,
KEY_FILE_HEADER,
KEY_FILE_DEL_COUNT,
KEY_FILE_INS_COUNT,
KEY_FILE_PREFIX,
)
PHANTOM_KEY = "vdiff.header"
STATUS_KEY = "vdiff"
# visual bell: a timed whole-buffer tint, kept out of REGION_KEYS so a re-render cannot erase it early
BELL_KEY = "vdiff.bell"
BELL_MS = 90
# above this much changed text the colours cost more than they are worth: such a diff is scrolled, not read
TOKEN_COLOUR_MAX_CHARS = 200000
# the hidden output panel the all-files view tokenises each file in
TOKEN_PANEL = "vdiff_tokens"
# a block phantom at point 0 draws below row 0, so file text starts at row 1
_HEADER_ROWS = 1
# gutter dots use the scope's foreground
SCOPE_DEL_MARK = "markup.deleted"
SCOPE_INS_MARK = "markup.inserted"
# ST4 defines region.<colour>ish in every bundled scheme for exactly this kind of transient marking
SCOPE_BELL = "region.yellowish"
SCOPE_FILE_HEADER = palette.SCOPE_FILE_HEADER
# sublime loads a newly created scheme override only after a restart
NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines"
# actions: the ACTIONS names the row runs, a link when there is one; short: status-bar label, "" omits it
class Key(NamedTuple):
"""One row of the status-bar hints and the ? popup"""
keys: str
label: str
short: str
actions: Tuple[str, ...]
# (group title, applies in a commit (False: needs a working-tree file), rows)
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
(
"Navigate",
True,
[
Key("] [", "next / previous change (wraps)", "change", ("next_change", "prev_change")),
Key("n p", "next / previous file (wraps)", "file", ("next_file", "prev_file")),
Key("o \u23ce", "open the real file at this line", "open", ("open",)),
Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
],
),
(
"Actions",
False,
[
Key("s / S", "stage / stage all", "stage", ("stage", "stage_all")),
Key("u / U", "unstage / unstage all", "unstage", ("unstage", "unstage_all")),
Key("d", "discard", "discard", ("discard",)),
Key("C", "commit the staged files", "commit", ("commit",)),
Key("m", "amend the last commit", "amend", ("amend",)),
],
),
(
"View",
True,
[
Key("f", "changed files", "files", ("files",)),
Key("c", "recent commits", "commits", ("commits",)),
Key("v", "toggle all-files view", "view", ("view",)),
Key("r", "refresh", "refresh", ("refresh",)),
Key("left right", "collapse / expand file", "fold", ("collapse", "expand")),
Key("z", "collapse / expand all files", "", ("fold_all",)),
Key("esc", "close the view", "", ("close",)),
],
),
]
KEYS: List[Key] = [key for _, _, group in KEY_GROUPS for key in group]
KEY_HINTS = " ".join(f"{k.keys} {k.short}" for k in KEYS if k.short)
# the popup font is the editor's, so the key column is padded to a fixed width in characters
_KEY_WIDTH = max(len(k.keys) for k in KEYS)
HEADER_CSS = """
body { margin: 0; padding: 0.5rem 0.8rem; background-color: color(var(--background) blend(var(--foreground) 93%)); }
.path { font-weight: bold; }
.old { color: color(var(--foreground) alpha(0.6)); }
.del { color: var(--redish); font-weight: bold; }
.ins { color: var(--greenish); font-weight: bold; }
.state { color: var(--orangish); }
.actions { margin-left: 1rem; }
.summary { margin-bottom: 0.3rem; color: color(var(--foreground) alpha(0.75)); }
.when { color: color(var(--foreground) alpha(0.6)); }
a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-radius: 3px;
background-color: color(var(--foreground) alpha(0.12)); color: var(--foreground); }
.danger { color: var(--redish); }
.hash { color: var(--bluish); font-weight: bold; }
.body { color: color(var(--foreground) alpha(0.6)); }
"""
# minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class
# .group's top margin replaces body's top padding
HELP_CSS = """
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
.note { color: color(var(--foreground) alpha(0.5)); font-weight: normal; }
.row { color: color(var(--foreground) alpha(0.85)); }
.dim { color: color(var(--foreground) alpha(0.35)); }
.k { color: var(--orangish); font-weight: bold; }
a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
"""
class VdiffReplaceCommand(sublime_plugin.TextCommand):
"""Replace the whole buffer; the only writer of a review view"""
def run(self, edit: sublime.Edit, text: str) -> None:
"""Replace the buffer"""
self.view.replace(edit, sublime.Region(0, self.view.size()), text)
def _clear_external_diagnostics(view: sublime.View) -> None:
"""Erase LSP diagnostic regions painted before the view became a widget"""
for prefix in ("lspLSP-pyright", "lspLSP-pylsp", "lspLSP"):
for severity in range(1, 5):
for mode in ("s", "m"):
key = f"{prefix}d{mode}{severity}"
for suffix in ("", "_underline", "_icon"):
view.erase_regions(key + suffix)
def init_view(view: sublime.View) -> None:
"""Settings a review view needs, applied once when it is created or restored"""
s = view.settings()
# mark the view as vdiff's and turn off editor chrome that does not fit a diff
s.set(VIEW_FLAG, True)
# native line numbers would be wrong: deleted lines have none
s.set("line_numbers", False)
s.set("word_wrap", False)
s.set("gutter", True)
s.set("highlight_line", True)
s.set("scroll_past_end", False)
s.set("detect_indentation", False)
s.set("translate_tabs_to_spaces", False)
s.set("show_definitions", False)
# LSP ignores widget views; is_scratch alone does not stop every LSP version
s.set("is_widget", True)
s.set("mini_diff", False)
s.set("spell_check", False)
# keep linters and LSP off this synthetic buffer, whichever of these settings they read
s.set("syntax_checking", False)
s.set("sublime_linter_disable", True)
s.set("lsp_diagnostics", False)
_clear_external_diagnostics(view)
# a read-only viewer, so vim's insert mode is meaningless: command mode, block caret. vdiff's
# own keys carry no command_mode context, so they fire in either mode
s.set("command_mode", True)
s.set("inverse_caret_state", True)
# a scratch, read-only buffer with the key hints in the status bar
view.set_scratch(True)
view.set_read_only(True)
if not view.name():
view.set_name("vDiff")
view.set_status(STATUS_KEY, KEY_HINTS)
def index_point(view: sublime.View, index: int, col: int = 0) -> int:
"""Text point of column `col` on the row showing st.lines[index]"""
return view.text_point(index + _HEADER_ROWS, col)
def caret_index(view: sublime.View) -> int:
"""Index into st.lines of the caret's row (the header row maps to -1)"""
if not len(view.sel()):
return 0
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], ...]]:
"""Cheap "did anything change" key for working-tree mode: mtimes of the shown files and of the git index"""
if st.in_commit or st.change is None:
return None
files = st.files if st.all_mode else [st.change]
index = git.index_path(st.repo)
return tuple(_mtime(os.path.join(st.repo, c.path)) for c in files) + (_mtime(index) if index else None,)
def _mtime(path: str) -> Optional[float]:
"""Modification time of `path`, or None when it cannot be stat'ed"""
try:
return os.stat(path).st_mtime
except OSError:
return None
def flash(view: sublime.View) -> None:
"""Visual bell: briefly tint the whole buffer"""
view.add_regions(BELL_KEY, [sublime.Region(0, view.size())], _bell_scope(view), flags=sublime.DRAW_NO_OUTLINE)
# unconditional erase: overlapping flashes may cut one short, but none can leave the tint stuck
sublime.set_timeout(lambda: view.is_valid() and view.erase_regions(BELL_KEY), BELL_MS)
def _file_header_scope(view: sublime.View) -> str:
"""Heading tint scope: vdiff's own, else the first fallback the scheme gives a background"""
# vdiff's own scope, once the generated scheme override has loaded
if palette.scope_background(view, SCOPE_FILE_HEADER):
return SCOPE_FILE_HEADER
for scope in ("region.blueish", "region.cyanish", SCOPE_BELL, "diff.inserted"):
if palette.scope_background(view, scope):
return scope
return SCOPE_FILE_HEADER
def _bell_scope(view: sublime.View) -> str:
"""region.yellowish, or the insert tint when the scheme lacks it"""
if palette.scope_background(view, SCOPE_BELL):
return SCOPE_BELL
# an invisible bell is a silent failure, so borrow the insert tint
return palette.SCOPE_INS
def render_message(view: sublime.View, st: ViewState, message: str) -> None:
"""Show a one-line notice instead of a file (nothing to review, binary, ...)"""
st.change = None
st.lines = []
st.blocks = []
st.binary = False
_replace(view, message)
view.assign_syntax("scope:text.plain")
_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.line_scope(view, "del"), flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, ins_regions, palette.line_scope(view, "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, palette.line_numbers_scope(view), flags=sublime.DRAW_NO_OUTLINE)
for scope, regions in token_groups.items():
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
insertions, deletions = _totals(st.files)
_finish(view, st, insertions, deletions, "⟐ all files")
# place the caret: on the focused change's first row, else on `heading` (a file's or the top)
if isinstance(focus, tuple):
heading = next((row for row, shown in zip(st.all_change_rows, st.all_change_row_targets) if shown == focus), 0)
point = index_point(view, heading)
view.sel().clear()
view.sel().add(sublime.Region(point))
if center:
view.show_at_center(point)
else:
view.set_viewport_position(viewport, False)
if focus is not None:
view.show(point)
def _all_colours(view: sublime.View, st: ViewState, paths: Sequence[str]) -> None:
"""Fill st.all_colours for `paths`, tokenising each file with its own syntax in a hidden panel"""
missing = [path for path in paths if path not in st.all_colours and path in st.all_diffs]
window = view.window()
if not missing or window is None:
return
# one hidden panel tokenises every missing file, then is destroyed
panel = window.create_output_panel(TOKEN_PANEL, unlisted=True)
colours: Dict[str, Optional[str]] = {} # scope name -> colour, shared by every file
try:
for path in missing:
lines = st.all_diffs[path][0]
st.all_colours[path] = _file_colours(view, panel, os.path.join(st.repo, path), lines, colours)
finally:
window.destroy_output_panel(TOKEN_PANEL)
def _file_colours(
view: sublime.View, panel: sublime.View, path: str, lines: Sequence[Line], colours: Dict[str, Optional[str]]
) -> Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]:
"""{(old_no, new_no): [(start, end, colour)]} for every line of one file's full-context diff
The new side (context and insertions) and the old side (context and deletions) are each a whole,
valid version of the file, so each is parsed on its own; interleaved they would confuse the parser
"""
result: Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]] = {}
if sum(len(line.text) + 1 for line in lines) > TOKEN_COLOUR_MAX_CHARS:
# too big to colour; the flat tint still shows it
return result
_apply_syntax(panel, path)
for side in ("+", "-"):
if side == "-" and not any(line.kind == "-" for line in lines):
continue # nothing deleted: the old side would only repeat the context rows
stream = [line for line in lines if line.kind in (" ", side)]
panel.run_command("vdiff_replace", {"text": "\n".join(line.text for line in stream)})
point = 0
for line in stream:
# context rows come from the new side
if line.kind == side or side == "+":
runs = _colour_runs(panel, point, line.text, colours, view)
result[(line.old_no, line.new_no)] = [(r.a - point, r.b - point, c) for r, c in runs]
point += len(line.text) + 1
return result
def _row_starts_for_text(rows: Sequence[str]) -> List[int]:
"""Text point where each all-files row starts, below the header rows"""
starts: List[int] = []
point = _HEADER_ROWS
for row in rows:
starts.append(point)
point += len(row) + 1
return starts
def all_change_at(view: sublime.View, st: ViewState) -> Optional[FileChange]:
"""File whose heading or diff row holds the caret, else None"""
index = caret_index(view)
if 0 <= index < len(st.all_rows):
return st.all_rows[index][0]
return None
def render(view: sublime.View, st: ViewState) -> None:
"""Draw st.lines for st.change into the view"""
change = st.change
# every caller with nothing to show goes to render_message instead, so there is a file here
assert change is not None
lines = st.lines
# the text: a placeholder for a binary or empty diff, else the lines
if st.binary:
text = "(binary file)"
elif not lines:
text = "(no textual changes)"
else:
text = "\n".join(line.text for line in lines)
# write the text and syntax
_replace(view, text)
path = os.path.join(st.repo, change.path)
_apply_syntax(view, path if lines and not st.binary else None)
if lines and not st.binary:
# here rather than init_view: during a session restore the scheme colours still read as absent
palette.ensure(view)
_paint_regions(view, st)
if palette.claim_restart_notice(view):
status(NEEDS_RESTART)
# header and bookkeeping, then the caret and scroll back to the top
ins, dele = parse.counts(lines)
_finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}")
view.sel().clear()
view.sel().add(sublime.Region(index_point(view, 0)))
view.set_viewport_position((0, 0), False)
def _paint_regions(view: sublime.View, st: ViewState) -> None:
"""The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
lines = st.lines
# full-line tint
starts = _row_starts(lines)
deleted, inserted = _line_regions(lines, starts)
view.add_regions(KEY_DEL, deleted, palette.line_scope(view, "del"), flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, inserted, palette.line_scope(view, "ins"), flags=sublime.DRAW_NO_OUTLINE)
# gutter dots
mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"]
ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"]
view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags)
view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags)
# word-diff marks, when enabled
del_chars: List[sublime.Region] = []
ins_chars: List[sublime.Region] = []
if settings().get("word_diff", True):
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
view.add_regions(KEY_DEL_CHAR, del_chars, palette.SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS_CHAR, ins_chars, palette.SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE)
# syntax colours on top of the tints
_paint_tokens(view, lines, starts, del_chars + ins_chars)
def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int], char_regions: Sequence[sublime.Region]) -> None:
"""Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour
add_regions draws text in the region scope's own foreground, so the tints above have just
flattened every changed line; only handing each run its own colour gives the highlighting back
"""
if not palette.tokens_resolved(view):
return
changed = [i for i, line in enumerate(lines) if line.kind != " "]
if sum(len(lines[i].text) for i in changed) > TOKEN_COLOUR_MAX_CHARS:
# too big to colour; the flat tint still shows it
return
# word-diff ranges sorted by start, so each line can bisect for its own
stronger = sorted((r.a, r.b) for r in char_regions)
starts_of = [a for a, _ in stronger]
# group every changed line's colour runs by scope
groups: Dict[str, List[sublime.Region]] = {}
colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render
for i in changed:
kind = "del" if lines[i].kind == "-" else "ins"
end = starts[i] + len(lines[i].text)
# only this line's word-diff ranges, so a diff with thousands of them stays linear
on_line = stronger[bisect.bisect_left(starts_of, starts[i]) : bisect.bisect_left(starts_of, end)]
for region, colour in _colour_runs(view, starts[i], lines[i].text, colours):
# a word-diff range cuts across token boundaries, so the run splits on it
for part, inside in _split(region, on_line):
# a colour outside the scheme's probed palette keeps the plain tint under it
scope = palette.scope_for(kind + ".char" if inside else kind, colour)
if scope:
groups.setdefault(scope, []).append(part)
# one region set per scope
for scope, regions in groups.items():
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List[Tuple[sublime.Region, bool]]:
"""(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
if not intervals:
return [(region, False)]
# walk the intervals, emitting the gap before each and its overlap with the region
parts: List[Tuple[sublime.Region, bool]] = []
pos = region.a
for a, b in intervals:
a, b = max(a, region.a), min(b, region.b)
if b <= pos:
continue
if a >= region.b:
break
if a > pos:
parts.append((sublime.Region(pos, a), False))
parts.append((sublime.Region(a, b), True))
pos = b
# the tail after the last interval, or the whole region when none overlapped
if pos < region.b or not parts:
parts.append((sublime.Region(pos, region.b), False))
return parts
def _colour_runs(
view: sublime.View, start: int, text: str, colours: Dict[str, Optional[str]], scheme_view: Optional[sublime.View] = None
) -> List[Tuple[sublime.Region, str]]:
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged
`scheme_view` resolves the colours when `view` is only a tokenising buffer
"""
runs: List[Tuple[sublime.Region, str]] = []
end = start + len(text)
for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, end)):
# a token can run past the line's end, e.g. into the newline
region = sublime.Region(max(region.begin(), start), min(region.end(), end))
if region.b <= region.a:
continue
# resolve each scope's colour once, skipping tokens without one
if scope not in colours:
colours[scope] = palette.foreground(scheme_view or view, scope)
colour = colours[scope]
if not colour:
continue
# extend the previous run when the same colour continues it
if runs and runs[-1][1] == colour and runs[-1][0].b == region.a:
runs[-1] = (sublime.Region(runs[-1][0].a, region.b), colour)
else:
runs.append((region, colour))
return runs
def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -> None:
"""Header, tab name, status bar and bookkeeping shared by every render"""
_header(view, st, ins, dele)
view.set_name(name)
view.set_status(STATUS_KEY, KEY_HINTS)
st.persist(view)
st.fingerprint = fingerprint(st)
def _replace(view: sublime.View, text: str) -> None:
"""Write `text` below the header rows, then erase every vdiff region and token layer"""
view.set_read_only(False)
try:
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
finally:
view.set_read_only(True)
# the token layer's keys vary with the palette, so they are listed rather than fixed
for key in list(REGION_KEYS) + palette.token_keys():
view.erase_regions(key)
def _apply_syntax(view: sublime.View, path: Optional[str]) -> None:
"""The real file's syntax, so the diff is highlighted like the file itself"""
syntax = None
if path:
try:
syntax = sublime.find_syntax_for_file(path)
except Exception:
LOG.debug("find_syntax_for_file failed", exc_info=True)
view.assign_syntax(syntax.path if syntax else "scope:text.plain")
def _row_starts(lines: Sequence[Line]) -> List[int]:
"""Text point where each line starts; computed from the text we wrote, no view round-trips"""
starts: List[int] = []
point = _HEADER_ROWS
for line in lines:
starts.append(point)
point += len(line.text) + 1
starts.append(point - 1) # end of the buffer (the last line has no newline)
return starts
def _line_regions(lines: Sequence[Line], starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
"""Full-line regions of deleted and inserted rows, consecutive rows merged"""
regions: Dict[str, List[sublime.Region]] = {"-": [], "+": []}
for kind, start, end in parse.runs(lines, lambda line: line.kind):
if kind in regions:
# starts has a sentinel at len(lines): the last line stops at the buffer end
regions[kind].append(sublime.Region(starts[start], starts[end]))
return regions["-"], regions["+"]
def _char_regions(
lines: Sequence[Line], blocks: Sequence[Block], starts: Sequence[int]
) -> Tuple[List[sublime.Region], List[sublime.Region]]:
"""Changed characters inside paired deleted/inserted lines"""
deleted: List[sublime.Region] = []
inserted: List[sublime.Region] = []
for block in blocks:
# word-diff each deleted/inserted pair and offset its ranges to buffer points
for del_row, ins_row in parse.paired_lines(lines, block):
old_ranges, new_ranges = parse.char_ranges(lines[del_row].text, lines[ins_row].text)
deleted.extend(sublime.Region(starts[del_row] + a, starts[del_row] + b) for a, b in old_ranges)
inserted.extend(sublime.Region(starts[ins_row] + a, starts[ins_row] + b) for a, b in new_ranges)
return deleted, inserted
def _totals(files: Sequence[FileChange]) -> Tuple[int, int]:
"""Summed numstat counts; binary files carry None counts and add nothing"""
return sum(c.insertions or 0 for c in files), sum(c.deletions or 0 for c in files)
def _summary_html(st: ViewState) -> str:
"""Summary line above the title: the commit on one line, or the staged/unstaged split and totals"""
if not st.files and not st.commit:
return ""
if st.in_commit:
text = " · ".join(_commit_parts(st))
else:
# staged means "has something staged": a partially staged file counts as staged only
staged = sum(1 for c in st.files if c.staged)
insertions, deletions = _totals(st.files)
text = (
f"{staged} staged, {len(st.files) - staged} unstaged"
f' · <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>'
)
return f'<div class="summary">{text}</div>'
def _commit_parts(st: ViewState) -> List[str]:
"""Hash, author, date, totals and message of the reviewed commit, in order"""
info = st.commit
parts: List[str] = []
# the commit's identity: hash, author, date
if info:
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
parts.append(html.escape(info.author))
parts.append(f'<span class="when">{html.escape(info.date)}</span>')
# binary files add nothing to the line totals but still count toward the file total
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>')
# the subject, plus the body's first line
if info:
message = html.escape(info.subject)
body = info.body.strip().splitlines()
if body:
message += f' <span class="body">— {html.escape(body[0])}</span>'
parts.append(message)
return parts
def header_html(st: ViewState, ins: int, dele: int) -> str:
"""The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
change = st.change
# the title: path, rename source, counts and working-tree state
if change is None:
title = '<span class="path">vDiff</span>'
else:
title = f'<span class="path">{html.escape(change.path)}</span>'
if change.old_path:
title += f' <span class="old">← {html.escape(change.old_path)}</span>'
title += f'&nbsp;&nbsp;<span class="del">-{dele}</span> <span class="ins">+{ins}</span>'
if not st.in_commit:
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
if state:
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] = []
if change is not None and not st.in_commit:
actions.append('<a href="stage">Stage</a>')
actions.append('<a href="unstage">Unstage</a>')
actions.append('<a href="discard" class="danger">Discard</a>')
if change is not None:
actions.append('<a href="open">Open</a>')
actions.append('<a href="files">Files</a>')
actions.append('<a href="commits">Commits</a>')
actions.append('<a href="help" title="keys">?</a>')
return (
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{_summary_html(st)}'
f'<div>{title}<span class="actions">{"".join(actions)}</span></div></body>'
)
def _run_action(view: sublime.View, href: str) -> None:
"""Dispatch a header or popup link through the same command the keymap uses"""
view.run_command("vdiff_action", {"action": href})
def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None:
"""One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep"""
def navigate(href: str) -> None:
"""Run the clicked header link's action"""
_run_action(view, href)
view.erase_phantoms(PHANTOM_KEY)
view.add_phantom(PHANTOM_KEY, sublime.Region(0), header_html(st, ins, dele), sublime.LAYOUT_BLOCK, navigate)
# --- key help popup ---------------------------------------------------------
def help_html(st: ViewState) -> str:
"""The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown"""
parts: List[str] = []
# per group a heading, dimmed with a note when it does not apply in a commit
for title, applies, group in KEY_GROUPS:
dim = st.in_commit and not applies
heading = html.escape(title)
if dim:
heading += ' <span class="note">uncommitted only</span>'
parts.append(f'<div class="group">{heading}</div>')
# keys stay orange in a dimmed group; only the text dims
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:
pad = "&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2)
row = f'<span class="k">{html.escape(key.keys)}</span>{pad}{html.escape(key.label)}'
if len(key.actions) == 1 and not dim:
row = f'<a href="{key.actions[0]}">{row}</a>'
parts.append(f'<div class="{row_class}">{row}</div>')
return f'<body id="vdiff-help"><style>{HELP_CSS}</style>{"".join(parts)}</body>'
def show_help(view: sublime.View, st: ViewState) -> None:
"""Toggle the key list popup at the caret"""
if view.is_popup_visible():
view.hide_popup()
return
def navigate(href: str) -> None:
"""Close the popup, then run the clicked row's action"""
view.hide_popup()
_run_action(view, href)
# minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size
em = view.em_width() or 8
view.show_popup(help_html(st), location=-1, max_width=int(em * 56), max_height=int(em * 44), on_navigate=navigate)