""" Implements a special view to visualize and stage pieces of a project's current diff. """ from collections import namedtuple from contextlib import contextmanager import os import re import sublime from sublime_plugin import WindowCommand, TextCommand, EventListener from . import intra_line_colorizer from .navigate import GsNavigate from ..fns import filter_, flatten from ..parse_diff import SplittedDiff from ..git_command import GitCommand, GitSavvyError from ..runtime import enqueue_on_ui, enqueue_on_worker from ..utils import line_indentation from ..view import replace_view_content from ...common import util __all__ = ( "gs_diff", "gs_diff_refresh", "gs_diff_toggle_setting", "gs_diff_cycle_word_diff", "gs_diff_toggle_cached_mode", "gs_diff_zoom", "gs_diff_stage_or_reset_hunk", "gs_diff_open_file_at_hunk", "gs_diff_navigate", "gs_diff_undo", "GsDiffFocusEventListener", ) MYPY = False if MYPY: from typing import ( Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypeVar ) from ..parse_diff import Hunk, HunkLine T = TypeVar('T') Point = int RowCol = Tuple[int, int] HunkLineWithB = NamedTuple('HunkLineWithB', [('line', 'HunkLine'), ('b', int)]) else: HunkLineWithB = namedtuple('HunkLineWithB', 'line b') DIFF_TITLE = "DIFF: {}" DIFF_CACHED_TITLE = "DIFF: {} (staged)" # Clickable lines: # (A) common/commands/view_manipulation.py | 1 + # (B) --- a/common/commands/view_manipulation.py # (C) +++ b/common/commands/view_manipulation.py # (D) diff --git a/common/commands/view_manipulation.py b/common/commands/view_manipulation.py FILE_RE = ( r"^(?:\s(?=.*\s+\|\s+\d+\s)|--- a\/|\+{3} b\/|diff .+b\/)" # ^^^^^^^^^^^^^^^^^^^^^ (A) # ^ one space, and then somewhere later on the line the pattern ` | 23 ` # ^^^^^^^ (B) # ^^^^^^^^ (C) # ^^^^^^^^^^^ (D) r"(\S[^|]*?)" # ^ ! lazy to not match the trailing spaces, see below r"(?:\s+\||$)" # ^ (B), (C), (D) # ^^^^^ (A) We must match the spaces here bc Sublime will not rstrip() the # filename for us. ) # Clickable line: # @@ -69,6 +69,7 @@ class GsHandleVintageousCommand(TextCommand): # ^^ we want the second (current) line offset of the diff LINE_RE = r"^@@ [^+]*\+(\d+)" def compute_identifier_for_view(view): # type: (sublime.View) -> Optional[Tuple] settings = view.settings() return ( settings.get('git_savvy.repo_path'), settings.get('git_savvy.file_path'), settings.get('git_savvy.diff_view.base_commit'), settings.get('git_savvy.diff_view.target_commit') ) if settings.get('git_savvy.diff_view') else None def focus_view(view): window = view.window() if not window: return group, _ = window.get_view_index(view) window.focus_group(group) window.focus_view(view) class gs_diff(WindowCommand, GitCommand): """ Create a new view to display the difference of `target_commit` against `base_commit`. If `target_commit` is None, compare working directory with `base_commit`. If `in_cached_mode` is set, display a diff of the Git index. Set `disable_stage` to True to disable Ctrl-Enter in the diff view. """ def run( self, repo_path=None, file_path=None, in_cached_mode=None, # type: Optional[bool] current_file=False, base_commit=None, target_commit=None, disable_stage=False, title=None, ignore_whitespace=False, show_word_diff=False, context_lines=3 ): # type: (...) -> None if repo_path is None: repo_path = self.repo_path assert repo_path if current_file: file_path = self.file_path or file_path this_id = ( repo_path, file_path, base_commit, target_commit ) for view in self.window.views(): if compute_identifier_for_view(view) == this_id: if in_cached_mode is not None: settings = view.settings() settings.set("git_savvy.diff_view.in_cached_mode", in_cached_mode) focus_view(view) break else: diff_view = util.view.get_scratch_view(self, "diff", read_only=True) show_diffstat = self.savvy_settings.get("show_diffstat", True) settings = diff_view.settings() settings.set("git_savvy.repo_path", repo_path) settings.set("git_savvy.file_path", file_path) settings.set("git_savvy.diff_view.in_cached_mode", bool(in_cached_mode)) settings.set("git_savvy.diff_view.ignore_whitespace", ignore_whitespace) settings.set("git_savvy.diff_view.show_word_diff", show_word_diff) settings.set("git_savvy.diff_view.context_lines", context_lines) settings.set("git_savvy.diff_view.base_commit", base_commit) settings.set("git_savvy.diff_view.target_commit", target_commit) settings.set("git_savvy.diff_view.show_diffstat", show_diffstat) settings.set("git_savvy.diff_view.disable_stage", disable_stage) settings.set("git_savvy.diff_view.history", []) settings.set("git_savvy.diff_view.just_hunked", "") settings.set("result_file_regex", FILE_RE) settings.set("result_line_regex", LINE_RE) settings.set("result_base_dir", repo_path) if not title: title = (DIFF_CACHED_TITLE if in_cached_mode else DIFF_TITLE).format( os.path.basename(file_path) if file_path else os.path.basename(repo_path) ) diff_view.set_name(title) diff_view.set_syntax_file("Packages/GitSavvy/syntax/diff_view.sublime-syntax") diff_view.run_command("gs_handle_vintageous") WORD_DIFF_PATTERNS = [ None, r"[a-zA-Z_\-\x80-\xff]+|[^[:space:]]|[\xc0-\xff][\x80-\xbf]+", ".", ] WORD_DIFF_MARKERS_RE = re.compile(r"{\+(.*?)\+}|\[-(.*?)-\]") class gs_diff_refresh(TextCommand, GitCommand): """Refresh the diff view with the latest repo state.""" def run(self, edit, sync=True): if sync: self.run_impl(sync) else: enqueue_on_worker(self.run_impl, sync) def run_impl(self, runs_on_ui_thread): if self.view.settings().get("git_savvy.disable_diff"): return repo_path = self.view.settings().get("git_savvy.repo_path") file_path = self.view.settings().get("git_savvy.file_path") in_cached_mode = self.view.settings().get("git_savvy.diff_view.in_cached_mode") ignore_whitespace = self.view.settings().get("git_savvy.diff_view.ignore_whitespace") show_word_diff = self.view.settings().get("git_savvy.diff_view.show_word_diff") base_commit = self.view.settings().get("git_savvy.diff_view.base_commit") target_commit = self.view.settings().get("git_savvy.diff_view.target_commit") show_diffstat = self.view.settings().get("git_savvy.diff_view.show_diffstat") disable_stage = self.view.settings().get("git_savvy.diff_view.disable_stage") context_lines = self.view.settings().get('git_savvy.diff_view.context_lines') word_diff_regex = WORD_DIFF_PATTERNS[show_word_diff] prelude = "\n" title = ["DIFF:"] if file_path: rel_file_path = os.path.relpath(file_path, repo_path) prelude += " FILE: {}\n".format(rel_file_path) title += [os.path.basename(file_path)] elif not disable_stage: title += [os.path.basename(repo_path)] if disable_stage: if in_cached_mode: prelude += " {}..INDEX\n".format(base_commit or target_commit) title += ["{}..INDEX".format(base_commit or target_commit)] else: if base_commit and target_commit: prelude += " {}..{}\n".format(base_commit, target_commit) title += ["{}..{}".format(base_commit, target_commit)] elif base_commit and "..." in base_commit: prelude += " {}\n".format(base_commit) title += [base_commit] else: prelude += " {}..WORKING DIR\n".format(base_commit or target_commit) title += ["{}..WORKING DIR".format(base_commit or target_commit)] else: if in_cached_mode: prelude += " STAGED CHANGES (Will commit)\n" title += ["(staged)"] else: prelude += " UNSTAGED CHANGES\n" if show_word_diff: prelude += " WORD REGEX: {}\n".format(word_diff_regex) if ignore_whitespace: prelude += " IGNORING WHITESPACE\n" try: diff = self.git( "diff", "--ignore-all-space" if ignore_whitespace else None, "--word-diff-regex={}".format(word_diff_regex) if word_diff_regex else None, "--unified={}".format(context_lines) if context_lines is not None else None, "--stat" if show_diffstat else None, "--patch", "--no-color", "--cached" if in_cached_mode else None, base_commit, target_commit, "--", file_path) except GitSavvyError as err: # When the output of the above Git command fails to correctly parse, # the expected notification will be displayed to the user. However, # once the userpresses OK, a new refresh event will be triggered on # the view. # # This causes an infinite loop of increasingly frustrating error # messages, ultimately resulting in psychosis and serious medical # bills. This is a better, though somewhat cludgy, alternative. # if err.args and type(err.args[0]) == UnicodeDecodeError: self.view.settings().set("git_savvy.disable_diff", True) return raise err old_diff = self.view.settings().get("git_savvy.diff_view.raw_diff") self.view.settings().set("git_savvy.diff_view.raw_diff", diff) prelude += "\n--\n" if word_diff_regex: diff, added_regions, removed_regions = postprocess_word_diff(diff, len(prelude)) else: diff, added_regions, removed_regions = diff, [], [] draw = lambda: _draw( self.view, ' '.join(title), prelude, diff, bool(word_diff_regex), added_regions, removed_regions, navigate=not old_diff ) if runs_on_ui_thread: draw() else: enqueue_on_ui(draw) def _draw(view, title, prelude, diff_text, is_word_diff, added_regions, removed_regions, navigate): # type: (sublime.View, str, str, str, bool, List[sublime.Region], List[sublime.Region], bool) -> None view.set_name(title) text = prelude + diff_text replace_view_content(view, text) if navigate: view.run_command("gs_diff_navigate") if is_word_diff: view.add_regions( "git-savvy-added-bold", added_regions, scope="diff.inserted.char.git-savvy.diff" ) view.add_regions( "git-savvy-removed-bold", removed_regions, scope="diff.deleted.char.git-savvy.diff" ) else: intra_line_colorizer.annotate_intra_line_differences(view, diff_text, len(prelude)) def postprocess_word_diff(text, global_offset=0): # type: (str, int) -> Tuple[str, List[sublime.Region], List[sublime.Region]] added_regions = [] # type: List[sublime.Region] removed_regions = [] # type: List[sublime.Region] def extractor(match): # We generally transform `{+text+}` (and likewise `[-text-]`) into just # `text`. text = match.group()[2:-2] # The `start/end` offsets are based on the original input, so we need # to adjust them for the regions we want to draw. total_matches_so_far = len(added_regions) + len(removed_regions) start, _end = match.span() # On each match the original diff is shortened by 4 chars. offset = global_offset + start - (total_matches_so_far * 4) regions = added_regions if match.group()[1] == '+' else removed_regions regions.append(sublime.Region(offset, offset + len(text))) return text return WORD_DIFF_MARKERS_RE.sub(extractor, text), added_regions, removed_regions class gs_diff_toggle_setting(TextCommand): """ Toggle view settings: `ignore_whitespace`. """ def run(self, edit, setting): settings = self.view.settings() setting_str = "git_savvy.diff_view.{}".format(setting) current_mode = settings.get(setting_str) next_mode = not current_mode settings.set(setting_str, next_mode) self.view.window().status_message("{} is now {}".format(setting, next_mode)) self.view.run_command("gs_diff_refresh") class gs_diff_cycle_word_diff(TextCommand): """ Cycle through different word diff patterns. """ def run(self, edit): settings = self.view.settings() setting_str = "git_savvy.diff_view.{}".format('show_word_diff') current_mode = settings.get(setting_str) next_mode = (current_mode + 1) % len(WORD_DIFF_PATTERNS) settings.set(setting_str, next_mode) self.view.run_command("gs_diff_refresh") class gs_diff_toggle_cached_mode(TextCommand): """ Toggle `in_cached_mode` or flip `base` with `target`. """ # NOTE: MUST NOT be async, otherwise `view.show` will not update the view 100%! def run(self, edit): settings = self.view.settings() base_commit = settings.get("git_savvy.diff_view.base_commit") target_commit = settings.get("git_savvy.diff_view.target_commit") if base_commit and target_commit: settings.set("git_savvy.diff_view.base_commit", target_commit) settings.set("git_savvy.diff_view.target_commit", base_commit) self.view.run_command("gs_diff_refresh") return if base_commit and "..." in base_commit: a, b = base_commit.split("...") settings.set("git_savvy.diff_view.base_commit", "{}...{}".format(b, a)) self.view.run_command("gs_diff_refresh") return last_cursors = settings.get('git_savvy.diff_view.last_cursors') or [] settings.set('git_savvy.diff_view.last_cursors', pickle_sel(self.view.sel())) setting_str = "git_savvy.diff_view.{}".format('in_cached_mode') current_mode = settings.get(setting_str) next_mode = not current_mode settings.set(setting_str, next_mode) self.view.window().status_message( "Showing {} changes".format("staged" if next_mode else "unstaged") ) self.view.run_command("gs_diff_refresh") just_hunked = self.view.settings().get("git_savvy.diff_view.just_hunked") # Check for `last_cursors` as well bc it is only falsy on the *first* # switch. T.i. if the user hunked and then switches to see what will be # actually comitted, the view starts at the top. Later, the view will # show the last added hunk. if just_hunked and last_cursors: self.view.settings().set("git_savvy.diff_view.just_hunked", "") region = find_hunk_in_view(self.view, just_hunked) if region: set_and_show_cursor(self.view, region.a) return if last_cursors: # The 'flipping' between the two states should be as fast as possible and # without visual clutter. with no_animations(): set_and_show_cursor(self.view, unpickle_sel(last_cursors)) class gs_diff_zoom(TextCommand): """ Update the number of context lines the diff shows by given `amount` and refresh the view. """ def run(self, edit, amount): # type: (sublime.Edit, int) -> None settings = self.view.settings() current = settings.get('git_savvy.diff_view.context_lines') next = max(current + amount, 0) settings.set('git_savvy.diff_view.context_lines', next) # Getting a meaningful cursor after 'zooming' is the tricky part # here. We first extract all hunks under the cursors *verbatim*. diff = SplittedDiff.from_view(self.view) cur_hunks = [ header.text + hunk.text for header, hunk in filter_(diff.head_and_hunk_for_pt(s.a) for s in self.view.sel()) ] self.view.run_command("gs_diff_refresh") # Now, we fuzzy search the new view content for the old hunks. cursors = { region.a for region in ( filter_(find_hunk_in_view(self.view, hunk) for hunk in cur_hunks) ) } if cursors: set_and_show_cursor(self.view, cursors) class GsDiffFocusEventListener(EventListener): """ If the current view is a diff view, refresh the view with latest tree status when the view regains focus. """ def on_activated_async(self, view): if view.settings().get("git_savvy.diff_view") is True: view.run_command("gs_diff_refresh", {"sync": False}) class gs_diff_stage_or_reset_hunk(TextCommand, GitCommand): """ Depending on whether the user is in cached mode and what action the user took, either 1) stage, 2) unstage, or 3) reset the hunk under the user's cursor(s). """ # NOTE: The whole command (including the view refresh) must be blocking otherwise # the view and the repo state get out of sync and e.g. hitting 'h' very fast will # result in errors. def run(self, edit, reset=False): ignore_whitespace = self.view.settings().get("git_savvy.diff_view.ignore_whitespace") show_word_diff = self.view.settings().get("git_savvy.diff_view.show_word_diff") if ignore_whitespace or show_word_diff: sublime.error_message("You have to be in a clean diff to stage.") return None # Filter out any cursors that are larger than a single point. cursor_pts = tuple(cursor.a for cursor in self.view.sel() if cursor.a == cursor.b) diff = SplittedDiff.from_view(self.view) patches = unique(flatten(filter_(diff.head_and_hunk_for_pt(pt) for pt in cursor_pts))) patch = ''.join(part.text for part in patches) if patch: self.apply_patch(patch, cursor_pts, reset) else: window = self.view.window() if window: window.status_message('Not within a hunk') def apply_patch(self, patch, pts, reset): in_cached_mode = self.view.settings().get("git_savvy.diff_view.in_cached_mode") context_lines = self.view.settings().get('git_savvy.diff_view.context_lines') # The three argument combinations below result from the following # three scenarios: # # 1) The user is in non-cached mode and wants to stage a hunk, so # do NOT apply the patch in reverse, but do apply it only against # the cached/indexed file (not the working tree). # 2) The user is in non-cached mode and wants to undo a line/hunk, so # DO apply the patch in reverse, and do apply it both against the # index and the working tree. # 3) The user is in cached mode and wants to undo a line hunk, so DO # apply the patch in reverse, but only apply it against the cached/ # indexed file. # # NOTE: When in cached mode, no action will be taken when the user # presses SUPER-BACKSPACE. args = ( "apply", "-R" if (reset or in_cached_mode) else None, "--cached" if (in_cached_mode or not reset) else None, "--unidiff-zero" if context_lines == 0 else None, "-", ) self.git( *args, stdin=patch ) history = self.view.settings().get("git_savvy.diff_view.history") history.append((args, patch, pts, in_cached_mode)) self.view.settings().set("git_savvy.diff_view.history", history) self.view.settings().set("git_savvy.diff_view.just_hunked", patch) self.view.run_command("gs_diff_refresh") MYPY = False if MYPY: from typing import NamedTuple JumpTo = NamedTuple('JumpTo', [ ('commit_hash', Optional[str]), ('filename', str), ('row', int), ('col', int) ]) else: from collections import namedtuple JumpTo = namedtuple('JumpTo', 'commit_hash filename row col') class gs_diff_open_file_at_hunk(TextCommand, GitCommand): """ For each cursor in the view, identify the hunk in which the cursor lies, and open the file at that hunk in a separate view. """ def run(self, edit): # type: (sublime.Edit) -> None def first_per_file(items): # type: (Iterator[JumpTo]) -> Iterator[JumpTo] seen = set() # type: Set[str] for item in items: if item.filename not in seen: seen.add(item.filename) yield item word_diff_mode = bool(self.view.settings().get('git_savvy.diff_view.show_word_diff')) algo = ( self.jump_position_to_file_for_word_diff_mode if word_diff_mode else self.jump_position_to_file ) diff = SplittedDiff.from_view(self.view) jump_positions = list(first_per_file(filter_( algo(diff, s.begin()) for s in self.view.sel() ))) if not jump_positions: util.view.flash(self.view, "Not within a hunk") else: for jp in jump_positions: self.load_file_at_line(*jp) def load_file_at_line(self, commit_hash, filename, row, col): # type: (Optional[str], str, int, int) -> None """ Show file at target commit if `git_savvy.diff_view.target_commit` is non-empty. Otherwise, open the file directly. """ target_commit = commit_hash or self.view.settings().get("git_savvy.diff_view.target_commit") full_path = os.path.join(self.repo_path, filename) window = self.view.window() if not window: return if target_commit: window.run_command("gs_show_file_at_commit", { "commit_hash": target_commit, "filepath": full_path, "lineno": row, }) else: window.open_file( "{file}:{row}:{col}".format(file=full_path, row=row, col=col), sublime.ENCODED_POSITION ) def jump_position_to_file(self, diff, pt): # type: (SplittedDiff, int) -> Optional[JumpTo] head_and_hunk = diff.head_and_hunk_for_pt(pt) if not head_and_hunk: return None view = self.view header, hunk = head_and_hunk rowcol = real_rowcol_in_hunk(hunk, relative_rowcol_in_hunk(view, hunk, pt)) if not rowcol: return None row, col = rowcol filename = header.from_filename() if not filename: return None commit_header = diff.commit_for_hunk(hunk) commit_hash = commit_header.commit_hash() if commit_header else None return JumpTo(commit_hash, filename, row, col) def jump_position_to_file_for_word_diff_mode(self, diff, pt): # type: (SplittedDiff, int) -> Optional[JumpTo] head_and_hunk = diff.head_and_hunk_for_pt(pt) if not head_and_hunk: return None view = self.view header, hunk = head_and_hunk content_start = hunk.content().a # Select all "deletion" regions in the hunk up to the cursor (pt) removed_regions_before_pt = [ # In case the cursor is *in* a region, shorten it up to # the cursor. sublime.Region(region.begin(), min(region.end(), pt)) for region in view.get_regions('git-savvy-removed-bold') if content_start <= region.begin() < pt ] # Count all completely removed lines, but exclude lines # if the cursor is exactly at the end-of-line char. removed_lines_before_pt = sum( region == view.line(region.begin()) and region.end() != pt for region in removed_regions_before_pt ) line_start = view.line(pt).begin() removed_chars_before_pt = sum( region.size() for region in removed_regions_before_pt if line_start <= region.begin() < pt ) # Compute the *relative* row in that hunk head_row, _ = view.rowcol(content_start) pt_row, col = view.rowcol(pt) rel_row = pt_row - head_row # If the cursor is in the hunk header, assume instead it is # at `(0, 0)` position in the hunk content. if rel_row < 0: rel_row, col = 0, 0 # Extract the starting line at "b" encoded in the hunk header t.i. for # "@@ -685,8 +686,14 @@ ..." extract the "686". from_start = hunk.header().from_line_start() if from_start is None: return None row = from_start + rel_row filename = header.from_filename() if not filename: return None row = row - removed_lines_before_pt col = col + 1 - removed_chars_before_pt commit_header = diff.commit_for_hunk(hunk) commit_hash = commit_header.commit_hash() if commit_header else None return JumpTo(commit_hash, filename, row, col) def relative_rowcol_in_hunk(view, hunk, pt): # type: (sublime.View, Hunk, Point) -> RowCol """Return rowcol of given pt relative to hunk start""" head_row, _ = view.rowcol(hunk.a) pt_row, col = view.rowcol(pt) # If `col=0` the user is on the meta char (e.g. '+- ') which is not # present in the source. We pin `col` to 1 because the target API # `open_file` expects 1-based row, col offsets. return pt_row - head_row, max(col, 1) def real_rowcol_in_hunk(hunk, relative_rowcol): # type: (Hunk, RowCol) -> Optional[RowCol] """Translate relative to absolute row, col pair""" hunk_lines = counted_lines(hunk) if not hunk_lines: return None row_in_hunk, col = relative_rowcol # If the user is on the header line ('@@ ..') pretend to be on the # first visible line with some content instead. if row_in_hunk == 0: row_in_hunk = next( ( index for index, (line, _) in enumerate(hunk_lines, 1) if not line.is_from_line() and line.content.strip() ), 1 ) col = 1 line, b = hunk_lines[row_in_hunk - 1] # Happy path since the user is on a present line if not line.is_from_line(): return b, col # The user is on a deleted line ('-') we cannot jump to. If possible, # select the next guaranteed to be available line for next_line, next_b in hunk_lines[row_in_hunk:]: if next_line.is_to_line(): return next_b, min(col, len(next_line.content) + 1) elif next_line.is_context(): # If we only have a contextual line, choose this or the # previous line, pretty arbitrary, depending on the # indentation. next_lines_indentation = line_indentation(next_line.content) if next_lines_indentation == line_indentation(line.content): return next_b, next_lines_indentation + 1 else: return max(1, b - 1), 1 else: return b, 1 def counted_lines(hunk): # type: (Hunk) -> Optional[List[HunkLineWithB]] """Split a hunk into (first char, line content, row) tuples Note that rows point to available rows on the b-side. """ b = hunk.header().from_line_start() if b is None: return None return list(_recount_lines(hunk.content().lines(), b)) def _recount_lines(lines, b): # type: (List[HunkLine], int) -> Iterator[HunkLineWithB] # Be aware that we only consider the b-line numbers, and that we # always yield a b value, even for deleted lines. for line in lines: yield HunkLineWithB(line, b) if not line.is_from_line(): b += 1 class gs_diff_navigate(GsNavigate): """ Travel between hunks. It is also used by show_commit_view. """ offset = 0 def get_available_regions(self): return self.view.find_by_selector("meta.diff.range.unified, meta.commit-info.header") class gs_diff_undo(TextCommand, GitCommand): """ Undo the last action taken in the diff view, if possible. """ # NOTE: MUST NOT be async, otherwise `view.show` will not update the view 100%! def run(self, edit): history = self.view.settings().get("git_savvy.diff_view.history") if not history: window = self.view.window() if window: window.status_message("Undo stack is empty") return args, stdin, cursors, in_cached_mode = history.pop() # Toggle the `--reverse` flag. args[1] = "-R" if not args[1] else None self.git(*args, stdin=stdin) self.view.settings().set("git_savvy.diff_view.history", history) self.view.settings().set("git_savvy.diff_view.just_hunked", stdin) self.view.run_command("gs_diff_refresh") # The cursor is only applicable if we're still in the same cache/stage mode if self.view.settings().get("git_savvy.diff_view.in_cached_mode") == in_cached_mode: set_and_show_cursor(self.view, cursors) def find_hunk_in_view(view, patch): # type: (sublime.View, str) -> Optional[sublime.Region] """Given a patch, search for its first hunk in the view Returns the region of the first line of the hunk (the one starting with '@@ ...'), if any. """ diff = SplittedDiff.from_string(patch) try: hunk = diff.hunks[0] except IndexError: return None return ( view.find(hunk.header().text, 0, sublime.LITERAL) or fuzzy_search_hunk_content_in_view(view, hunk.content().text.splitlines()) ) def fuzzy_search_hunk_content_in_view(view, lines): # type: (sublime.View, List[str]) -> Optional[sublime.Region] """Fuzzy search the hunk content in the view Note that hunk content does not include the starting line, the one starting with '@@ ...', anymore. The fuzzy strategy here is to search for the hunk or parts of it by reducing the contextual lines symmetrically. Returns the region of the starting line of the found hunk, if any. """ for hunk_content in shrink_list_sym(lines): region = view.find('\n'.join(hunk_content), 0, sublime.LITERAL) if region: diff = SplittedDiff.from_view(view) head_and_hunk = diff.head_and_hunk_for_pt(region.a) if head_and_hunk: _, hunk = head_and_hunk hunk_header = hunk.header() return sublime.Region(hunk_header.a, hunk_header.b) break return None def shrink_list_sym(list): # type: (List[T]) -> Iterator[List[T]] while list: yield list list = list[1:-1] def pickle_sel(sel): return [(s.a, s.b) for s in sel] def unpickle_sel(pickled_sel): return [sublime.Region(a, b) for a, b in pickled_sel] def unique(items): # type: (Iterable[T]) -> List[T] """Remove duplicate entries but remain sorted/ordered.""" rv = [] # type: List[T] for item in items: if item not in rv: rv.append(item) return rv def set_and_show_cursor(view, cursors): sel = view.sel() sel.clear() try: it = iter(cursors) except TypeError: sel.add(cursors) else: for c in it: sel.add(c) view.show(sel) @contextmanager def no_animations(): pref = sublime.load_settings("Preferences.sublime-settings") current = pref.get("animation_enabled") pref.set("animation_enabled", False) try: yield finally: pref.set("animation_enabled", current)