From f368e41636f8474759dfaf83f738109e6ae06bba Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 28 Dec 2018 22:08:32 +0100 Subject: [PATCH 01/65] Implement Undo for diff-view Fixes #1036 --- Default (Linux).sublime-keymap | 9 ++++++++ Default (OSX).sublime-keymap | 9 ++++++++ Default (Windows).sublime-keymap | 9 ++++++++ core/commands/diff.py | 36 +++++++++++++++++++++++++++++++- 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/Default (Linux).sublime-keymap b/Default (Linux).sublime-keymap index 7a6717f62..d1c7aa663 100644 --- a/Default (Linux).sublime-keymap +++ b/Default (Linux).sublime-keymap @@ -54,6 +54,15 @@ { "key": "setting.git_savvy.diff_view.disable_stage", "operator": "equal", "operand": false } ] }, + { + "keys": ["ctrl+z"], + "command": "gs_diff_undo", + "context": [ + { "key": "setting.command_mode", "operator": "equal", "operand": false }, + { "key": "setting.git_savvy.diff_view", "operator": "equal", "operand": true }, + { "key": "setting.git_savvy.diff_view.disable_stage", "operator": "equal", "operand": false } + ] + }, ////////////////// // STAGE_DIFF VIEW // diff --git a/Default (OSX).sublime-keymap b/Default (OSX).sublime-keymap index 2b58686f9..97886177a 100644 --- a/Default (OSX).sublime-keymap +++ b/Default (OSX).sublime-keymap @@ -54,6 +54,15 @@ { "key": "setting.git_savvy.diff_view.disable_stage", "operator": "equal", "operand": false } ] }, + { + "keys": ["super+z"], + "command": "gs_diff_undo", + "context": [ + { "key": "setting.command_mode", "operator": "equal", "operand": false }, + { "key": "setting.git_savvy.diff_view", "operator": "equal", "operand": true }, + { "key": "setting.git_savvy.diff_view.disable_stage", "operator": "equal", "operand": false } + ] + }, ////////////////// // STAGE_DIFF VIEW // diff --git a/Default (Windows).sublime-keymap b/Default (Windows).sublime-keymap index 7a6717f62..d1c7aa663 100644 --- a/Default (Windows).sublime-keymap +++ b/Default (Windows).sublime-keymap @@ -54,6 +54,15 @@ { "key": "setting.git_savvy.diff_view.disable_stage", "operator": "equal", "operand": false } ] }, + { + "keys": ["ctrl+z"], + "command": "gs_diff_undo", + "context": [ + { "key": "setting.command_mode", "operator": "equal", "operand": false }, + { "key": "setting.git_savvy.diff_view", "operator": "equal", "operand": true }, + { "key": "setting.git_savvy.diff_view.disable_stage", "operator": "equal", "operand": false } + ] + }, ////////////////// // STAGE_DIFF VIEW // diff --git a/core/commands/diff.py b/core/commands/diff.py index e56bd0dbf..ee0105a2e 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -291,14 +291,21 @@ def apply_diffs_for_pts(self, cursor_pts, reset): # NOTE: When in cached mode, no action will be taken when the user # presses SUPER-BACKSPACE. - self.git( + args = ( "apply", "-R" if (reset or in_cached_mode) else None, "--cached" if (in_cached_mode or not reset) else None, "-", + ) + self.git( + *args, stdin=hunk_diff ) + history = self.view.settings().get("git_savvy.diff_view.history") or [] + history.append((args, hunk_diff)) + self.view.settings().set("git_savvy.diff_view.history", history) + sublime.set_timeout_async( lambda: self.view.run_command("gs_diff_refresh", {'navigate_to_next_hunk': True}) ) @@ -402,3 +409,30 @@ def run(self, edit, **kwargs): def get_available_regions(self): return [self.view.line(region) for region in self.view.find_by_selector("meta.diff.range.unified")] + + +class GsDiffUndo(TextCommand, GitCommand): + + """ + Undo the last action taken in the inline-diff view, if possible. + """ + + def run(self, edit): + sublime.set_timeout_async(self.run_async) + + def run_async(self): + history = self.view.settings().get("git_savvy.diff_view.history") or [] + if not history: + window = self.view.window() + if window: + window.status_message("Undo stack is empty") + return + + args, stdin = 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.run_command("gs_diff_refresh") From d7c8f837c2b5663ff3fe8f0da5158ed367613eeb Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 28 Dec 2018 22:26:45 +0100 Subject: [PATCH 02/65] Stable cursor after hunking --- core/commands/diff.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index ee0105a2e..e0fff2693 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -306,9 +306,7 @@ def apply_diffs_for_pts(self, cursor_pts, reset): history.append((args, hunk_diff)) self.view.settings().set("git_savvy.diff_view.history", history) - sublime.set_timeout_async( - lambda: self.view.run_command("gs_diff_refresh", {'navigate_to_next_hunk': True}) - ) + sublime.set_timeout_async(lambda: self.view.run_command("gs_diff_refresh")) def get_hunk_diff(self, pt): """ From ddc5da3d53804bbb0ddf8d6051206e21ea5bfac6 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 28 Dec 2018 22:45:14 +0100 Subject: [PATCH 03/65] Restore cursor after undo --- core/commands/diff.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index e0fff2693..407bae389 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -303,7 +303,7 @@ def apply_diffs_for_pts(self, cursor_pts, reset): ) history = self.view.settings().get("git_savvy.diff_view.history") or [] - history.append((args, hunk_diff)) + history.append((args, hunk_diff, cursor_pts[0])) self.view.settings().set("git_savvy.diff_view.history", history) sublime.set_timeout_async(lambda: self.view.run_command("gs_diff_refresh")) @@ -426,7 +426,7 @@ def run_async(self): window.status_message("Undo stack is empty") return - args, stdin = history.pop() + args, stdin, cursor = history.pop() # Toggle the `--reverse` flag. args[1] = "-R" if not args[1] else None @@ -434,3 +434,6 @@ def run_async(self): self.view.settings().set("git_savvy.diff_view.history", history) self.view.run_command("gs_diff_refresh") + self.view.sel().clear() + self.view.sel().add(cursor) + self.view.show_at_center(cursor) From 6def3cae254ba3658b290ed7bed3686e71b63b73 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 28 Dec 2018 23:03:04 +0100 Subject: [PATCH 04/65] Try to move cursor to last added hunk when switching staging area --- core/commands/diff.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 407bae389..83266d4e9 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -213,12 +213,29 @@ def run(self, edit, setting): self.view.window().status_message("{} is now {}".format(setting, settings.get(setting_str))) self.view.run_command("gs_diff_refresh") - if last_cursors: - sel = self.view.sel() - sel.clear() - for (a, b) in last_cursors: - sel.add(sublime.Region(a, b)) - self.view.show(sel) + if setting == 'in_cached_mode': + if self.view.settings().get("git_savvy.diff_view.just_hunked"): + self.view.settings().set("git_savvy.diff_view.just_hunked", False) + history = self.view.settings().get("git_savvy.diff_view.history") + _, stdin, _ = history[-1] + match = re.search(r'\n(@@ .+)\n', stdin) + if match is not None: + expected_content = match.group(1) + region = self.view.find(expected_content, 0, sublime.LITERAL) + if region is not None: + self.view.sel().clear() + self.view.sel().add(region.a) + self.view.show(region.a) + return + + if last_cursors: + sel = self.view.sel() + sel.clear() + for (a, b) in last_cursors: + sel.add(sublime.Region(a, b)) + self.view.show(sel) + else: + self.view.run_command("gs_diff_navigate") class GsDiffFocusEventListener(EventListener): @@ -305,6 +322,7 @@ def apply_diffs_for_pts(self, cursor_pts, reset): history = self.view.settings().get("git_savvy.diff_view.history") or [] history.append((args, hunk_diff, cursor_pts[0])) self.view.settings().set("git_savvy.diff_view.history", history) + self.view.settings().set("git_savvy.diff_view.just_hunked", True) sublime.set_timeout_async(lambda: self.view.run_command("gs_diff_refresh")) From 552a3c1e6338c5e7cf3e9b66d9a3d8ffc726ac4f Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 28 Dec 2018 23:09:38 +0100 Subject: [PATCH 05/65] Fixup doc string --- core/commands/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 83266d4e9..9528aa6c7 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -430,7 +430,7 @@ def get_available_regions(self): class GsDiffUndo(TextCommand, GitCommand): """ - Undo the last action taken in the inline-diff view, if possible. + Undo the last action taken in the diff view, if possible. """ def run(self, edit): From a6c67faa56bbb57acefb1a70957594523f32e428 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 29 Dec 2018 17:38:50 +0100 Subject: [PATCH 06/65] Extract toggling `in_cached_mode` to separate command --- Default.sublime-keymap | 3 +- core/commands/diff.py | 88 ++++++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 36 deletions(-) diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 2ff6d47b9..77e1e2c25 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -607,8 +607,7 @@ }, { "keys": ["tab"], - "command": "gs_diff_toggle_setting", - "args": { "setting": "in_cached_mode" }, + "command": "gs_diff_toggle_cached_mode", "context": [ { "key": "setting.command_mode", "operator": "equal", "operand": false }, { "key": "setting.git_savvy.diff_view", "operator": "equal", "operand": true } diff --git a/core/commands/diff.py b/core/commands/diff.py index 9528aa6c7..ec1756d0b 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -186,14 +186,32 @@ def run(self, edit, cursors=None, navigate_to_next_hunk=False): class GsDiffToggleSetting(TextCommand): """ - Toggle view settings: `ignore_whitespace` , `show_word_diff` or - `in_cached_mode`. + Toggle view settings: `ignore_whitespace` , or `show_word_diff`. """ 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 GsDiffToggleCachedMode(TextCommand): + + """ + Toggle `in_cached_mode`. + """ + + def run(self, edit): + setting = 'in_cached_mode' + if ( - setting == 'in_cached_mode' - and self.view.settings().get("git_savvy.diff_view.base_commit") + self.view.settings().get("git_savvy.diff_view.base_commit") and self.view.settings().get("git_savvy.diff_view.target_commit") ): # There is no cached mode if you diff between two commits, so @@ -201,41 +219,43 @@ def run(self, edit, setting): return settings = self.view.settings() - last_cursors = [] - if setting == 'in_cached_mode': - last_cursors = settings.get('git_savvy.diff_view.last_cursors') or [] - cursors = [(s.a, s.b) for s in self.view.sel()] - settings.set('git_savvy.diff_view.last_cursors', cursors) + last_cursors = settings.get('git_savvy.diff_view.last_cursors') or [] + cursors = [(s.a, s.b) for s in self.view.sel()] + settings.set('git_savvy.diff_view.last_cursors', cursors) setting_str = "git_savvy.diff_view.{}".format(setting) - settings.set(setting_str, not settings.get(setting_str)) - self.view.window().status_message("{} is now {}".format(setting, settings.get(setting_str))) + 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") - if setting == 'in_cached_mode': - if self.view.settings().get("git_savvy.diff_view.just_hunked"): - self.view.settings().set("git_savvy.diff_view.just_hunked", False) - history = self.view.settings().get("git_savvy.diff_view.history") - _, stdin, _ = history[-1] - match = re.search(r'\n(@@ .+)\n', stdin) - if match is not None: - expected_content = match.group(1) - region = self.view.find(expected_content, 0, sublime.LITERAL) - if region is not None: - self.view.sel().clear() - self.view.sel().add(region.a) - self.view.show(region.a) - return - - if last_cursors: - sel = self.view.sel() - sel.clear() - for (a, b) in last_cursors: - sel.add(sublime.Region(a, b)) - self.view.show(sel) - else: - self.view.run_command("gs_diff_navigate") + + if self.view.settings().get("git_savvy.diff_view.just_hunked"): + self.view.settings().set("git_savvy.diff_view.just_hunked", False) + history = self.view.settings().get("git_savvy.diff_view.history") + _, stdin, _ = history[-1] + match = re.search(r'\n(@@ .+)\n', stdin) + if match is not None: + expected_content = match.group(1) + region = self.view.find(expected_content, 0, sublime.LITERAL) + if region: + self.view.sel().clear() + self.view.sel().add(region.a) + self.view.show(region.a) + return + + if last_cursors: + sel = self.view.sel() + sel.clear() + for (a, b) in last_cursors: + sel.add(sublime.Region(a, b)) + self.view.show(sel) + else: + self.view.run_command("gs_diff_navigate") class GsDiffFocusEventListener(EventListener): From e34b58d7d205f3d234dc8bef261e0af415b6afa6 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 29 Dec 2018 21:33:53 +0100 Subject: [PATCH 07/65] Fix undo for multiple cursors --- core/commands/diff.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index ec1756d0b..bef02f6a4 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -339,10 +339,10 @@ def apply_diffs_for_pts(self, cursor_pts, reset): stdin=hunk_diff ) - history = self.view.settings().get("git_savvy.diff_view.history") or [] - history.append((args, hunk_diff, cursor_pts[0])) - self.view.settings().set("git_savvy.diff_view.history", history) - self.view.settings().set("git_savvy.diff_view.just_hunked", True) + history = self.view.settings().get("git_savvy.diff_view.history") or [] + history.append((args, hunk_diff, pt)) + self.view.settings().set("git_savvy.diff_view.history", history) + self.view.settings().set("git_savvy.diff_view.just_hunked", True) sublime.set_timeout_async(lambda: self.view.run_command("gs_diff_refresh")) From 9701c9f98ae44a6ddc1af5ff0053564294f5374f Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 29 Dec 2018 21:46:38 +0100 Subject: [PATCH 08/65] Undo must participate in 'just_hunked' --- core/commands/diff.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index bef02f6a4..c73aa058b 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -234,11 +234,10 @@ def run(self, edit): self.view.run_command("gs_diff_refresh") - if self.view.settings().get("git_savvy.diff_view.just_hunked"): - self.view.settings().set("git_savvy.diff_view.just_hunked", False) - history = self.view.settings().get("git_savvy.diff_view.history") - _, stdin, _ = history[-1] - match = re.search(r'\n(@@ .+)\n', stdin) + just_hunked = self.view.settings().get("git_savvy.diff_view.just_hunked") + if just_hunked: + self.view.settings().set("git_savvy.diff_view.just_hunked", "") + match = re.search(r'\n(@@ .+)\n', just_hunked) if match is not None: expected_content = match.group(1) region = self.view.find(expected_content, 0, sublime.LITERAL) @@ -342,7 +341,7 @@ def apply_diffs_for_pts(self, cursor_pts, reset): history = self.view.settings().get("git_savvy.diff_view.history") or [] history.append((args, hunk_diff, pt)) self.view.settings().set("git_savvy.diff_view.history", history) - self.view.settings().set("git_savvy.diff_view.just_hunked", True) + self.view.settings().set("git_savvy.diff_view.just_hunked", hunk_diff) sublime.set_timeout_async(lambda: self.view.run_command("gs_diff_refresh")) @@ -470,6 +469,7 @@ def run_async(self): 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") self.view.sel().clear() From 32f533f535ff61a6a687366573ea816633c50c1d Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 29 Dec 2018 21:53:31 +0100 Subject: [PATCH 09/65] Cleanup navigate command `super.run()` already calls 'show_at_center' --- core/commands/diff.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index c73aa058b..3cdb16ea2 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -437,10 +437,6 @@ class GsDiffNavigateCommand(GsNavigate): offset = 0 - def run(self, edit, **kwargs): - super().run(edit, **kwargs) - self.view.run_command("show_at_center") - def get_available_regions(self): return [self.view.line(region) for region in self.view.find_by_selector("meta.diff.range.unified")] From 9aeaf87ec21aba7ee5c8c22cc9d279ca8e168e34 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 29 Dec 2018 22:01:54 +0100 Subject: [PATCH 10/65] After undo just ensure that we 'show' the cursor 'show_at_center' adds unnecessary visual clutter. --- core/commands/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 3cdb16ea2..3e10096c0 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -470,4 +470,4 @@ def run_async(self): self.view.run_command("gs_diff_refresh") self.view.sel().clear() self.view.sel().add(cursor) - self.view.show_at_center(cursor) + self.view.show(cursor) From 9b148ba7abb4821cee6a7fa1fddfc452bde43eb0 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 29 Dec 2018 22:11:11 +0100 Subject: [PATCH 11/65] Reduce visual clutter when switching between the two states --- core/commands/diff.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 3e10096c0..a496802ae 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -3,6 +3,7 @@ current diff. """ +from contextlib import contextmanager import os import re import bisect @@ -252,11 +253,26 @@ def run(self, edit): sel.clear() for (a, b) in last_cursors: sel.add(sublime.Region(a, b)) - self.view.show(sel) + + # The 'flipping' between the two states should be as fast as possible and + # without visual clutter. + with no_animations(): + self.view.show(sel) else: self.view.run_command("gs_diff_navigate") +@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) + + class GsDiffFocusEventListener(EventListener): """ From acddffbc4d97f69e92fd434e51ee1d230ccf904b Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 30 Dec 2018 22:48:48 +0100 Subject: [PATCH 12/65] Make Undo a sync command bc it calls `view.show` --- core/commands/diff.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index a496802ae..c60385c36 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -208,6 +208,7 @@ class GsDiffToggleCachedMode(TextCommand): Toggle `in_cached_mode`. """ + # NOTE: MUST NOT be async, otherwise `view.show` will not update the view 100%! def run(self, edit): setting = 'in_cached_mode' @@ -464,10 +465,8 @@ class GsDiffUndo(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): - sublime.set_timeout_async(self.run_async) - - def run_async(self): history = self.view.settings().get("git_savvy.diff_view.history") or [] if not history: window = self.view.window() From 2c41d2465d3f035dca98cd77522259fb53598bd5 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 30 Dec 2018 23:18:33 +0100 Subject: [PATCH 13/65] Make hunking blocking for consistent subsequent actions We must ensure that hitting 'h' very fast does run completely ordered, and always waits for 'gs_diff_refresh' to complete. ('gs_diff_refresh' updates our model.) --- core/commands/diff.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index c60385c36..7f9c583c3 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -294,6 +294,18 @@ class GsDiffStageOrResetHunkCommand(TextCommand, GitCommand): hunk under the user's cursor(s). """ + # NOTE: The whole command must be blocking otherwise hitting 'h' very fast will + # result in errors. We _could_ lift the whole thing to the worker but this only + # leads to more, fast redraws which is basically visual clutter. If we instead + # just stay on the main thread we really block these intermediate redraws but + # of course any other user interaction as well. + # + # Please NOTE that running a command from the worker will still run that command + # on the main thread. T.i. `set_timeout_async(lambda: view.run_command("refresh"))` + # is misleading and a footgun because the "refresh" command here will still start on + # the main thread. We basically just await the worker and then put a task/command on + # the main worker queue. + 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") @@ -317,7 +329,7 @@ def run(self, edit, reset=False): set((self.view.size(), )) )) - sublime.set_timeout_async(lambda: self.apply_diffs_for_pts(cursor_pts, reset), 0) + self.apply_diffs_for_pts(cursor_pts, reset) def apply_diffs_for_pts(self, cursor_pts, reset): in_cached_mode = self.view.settings().get("git_savvy.diff_view.in_cached_mode") @@ -360,7 +372,7 @@ def apply_diffs_for_pts(self, cursor_pts, reset): self.view.settings().set("git_savvy.diff_view.history", history) self.view.settings().set("git_savvy.diff_view.just_hunked", hunk_diff) - sublime.set_timeout_async(lambda: self.view.run_command("gs_diff_refresh")) + self.view.run_command("gs_diff_refresh") def get_hunk_diff(self, pt): """ From 9a0834a7af72216b1cdcba664a0f2d7cd3013cb7 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 30 Dec 2018 23:32:27 +0100 Subject: [PATCH 14/65] Apply cursor after undo only if in same staging mode --- core/commands/diff.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 7f9c583c3..88b75f0d5 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -368,7 +368,7 @@ def apply_diffs_for_pts(self, cursor_pts, reset): ) history = self.view.settings().get("git_savvy.diff_view.history") or [] - history.append((args, hunk_diff, pt)) + history.append((args, hunk_diff, pt, in_cached_mode)) self.view.settings().set("git_savvy.diff_view.history", history) self.view.settings().set("git_savvy.diff_view.just_hunked", hunk_diff) @@ -486,7 +486,7 @@ def run(self, edit): window.status_message("Undo stack is empty") return - args, stdin, cursor = history.pop() + args, stdin, cursor, in_cached_mode = history.pop() # Toggle the `--reverse` flag. args[1] = "-R" if not args[1] else None @@ -495,6 +495,9 @@ def run(self, edit): self.view.settings().set("git_savvy.diff_view.just_hunked", stdin) self.view.run_command("gs_diff_refresh") - self.view.sel().clear() - self.view.sel().add(cursor) - self.view.show(cursor) + + # 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: + self.view.sel().clear() + self.view.sel().add(cursor) + self.view.show(cursor) From 74965b5f37b21722d7f649984dbe269bc63e7766 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 30 Dec 2018 23:52:48 +0100 Subject: [PATCH 15/65] Show `file_path` in title if given --- core/commands/diff.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 88b75f0d5..bf2aba2f3 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -98,7 +98,9 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba 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(repo_path)) + 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_views[view_key] = diff_view From 5769de41b03148141f2a9991b3c80860bc880ec8 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 30 Dec 2018 23:59:06 +0100 Subject: [PATCH 16/65] Proper initialize view state when creating the view --- core/commands/diff.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index bf2aba2f3..ee78b943c 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -64,6 +64,8 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba settings.set("git_savvy.diff_view.target_commit", target_commit) settings.set("git_savvy.diff_view.show_diffstat", self.savvy_settings.get("show_diffstat", True)) 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", "") # Clickable lines: # (A) common/commands/view_manipulation.py | 1 + @@ -369,7 +371,7 @@ def apply_diffs_for_pts(self, cursor_pts, reset): stdin=hunk_diff ) - history = self.view.settings().get("git_savvy.diff_view.history") or [] + history = self.view.settings().get("git_savvy.diff_view.history") history.append((args, hunk_diff, pt, in_cached_mode)) self.view.settings().set("git_savvy.diff_view.history", history) self.view.settings().set("git_savvy.diff_view.just_hunked", hunk_diff) @@ -481,7 +483,7 @@ class GsDiffUndo(TextCommand, GitCommand): # 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") or [] + history = self.view.settings().get("git_savvy.diff_view.history") if not history: window = self.view.window() if window: From bab6dfc6352e9fb0ff190b6c107ebd67b4ac91a6 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 2 Jan 2019 10:47:14 +0100 Subject: [PATCH 17/65] Add standard sublime settings for diff-view --- syntax/diff_view.sublime-settings | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 syntax/diff_view.sublime-settings diff --git a/syntax/diff_view.sublime-settings b/syntax/diff_view.sublime-settings new file mode 100644 index 000000000..2401baa72 --- /dev/null +++ b/syntax/diff_view.sublime-settings @@ -0,0 +1,16 @@ +{ + // same on all syntaxes + "auto_indent": false, + "detect_indentation": false, + "draw_indent_guides": false, + "draw_white_space": "None", + "gutter": false, + "line_numbers": false, + "margin": 20, + "rulers": [], + "translate_tabs_to_spaces": false, + "word_wrap": false, + // syntax specific + "match_brackets": false, + "match_tags": false +} From be6f6fc84ef2289db8bbc943e7ed3ff807546d6b Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 2 Jan 2019 10:48:14 +0100 Subject: [PATCH 18/65] Add shortcut for editing diff-view syntax --- Default (Linux).sublime-keymap | 11 +++++++++++ Default (OSX).sublime-keymap | 11 +++++++++++ Default (Windows).sublime-keymap | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/Default (Linux).sublime-keymap b/Default (Linux).sublime-keymap index d1c7aa663..d886c1dfd 100644 --- a/Default (Linux).sublime-keymap +++ b/Default (Linux).sublime-keymap @@ -183,6 +183,17 @@ { "key": "selector", "operator": "equal", "operand": "git-savvy.diff" } ] }, + { + "keys": ["ctrl+,"], + "command": "edit_settings", + "args": { + "base_file": "${packages}/GitSavvy/syntax/diff_view.sublime-settings", + "default": "// Override settings for diff syntax-specific.\n{\n\t$0\n}\n" + }, + "context": [ + { "key": "selector", "operator": "equal", "operand": "git-savvy.diff_view" } + ] + }, { "keys": ["ctrl+,"], "command": "edit_settings", diff --git a/Default (OSX).sublime-keymap b/Default (OSX).sublime-keymap index 97886177a..478c63c1b 100644 --- a/Default (OSX).sublime-keymap +++ b/Default (OSX).sublime-keymap @@ -183,6 +183,17 @@ { "key": "selector", "operator": "equal", "operand": "git-savvy.diff" } ] }, + { + "keys": ["super+,"], + "command": "edit_settings", + "args": { + "base_file": "${packages}/GitSavvy/syntax/diff_view.sublime-settings", + "default": "// Override settings for diff syntax-specific.\n{\n\t$0\n}\n" + }, + "context": [ + { "key": "selector", "operator": "equal", "operand": "git-savvy.diff_view" } + ] + }, { "keys": ["super+,"], "command": "edit_settings", diff --git a/Default (Windows).sublime-keymap b/Default (Windows).sublime-keymap index d1c7aa663..d886c1dfd 100644 --- a/Default (Windows).sublime-keymap +++ b/Default (Windows).sublime-keymap @@ -183,6 +183,17 @@ { "key": "selector", "operator": "equal", "operand": "git-savvy.diff" } ] }, + { + "keys": ["ctrl+,"], + "command": "edit_settings", + "args": { + "base_file": "${packages}/GitSavvy/syntax/diff_view.sublime-settings", + "default": "// Override settings for diff syntax-specific.\n{\n\t$0\n}\n" + }, + "context": [ + { "key": "selector", "operator": "equal", "operand": "git-savvy.diff_view" } + ] + }, { "keys": ["ctrl+,"], "command": "edit_settings", From 2946a3095a522cd570273e44448da9453d31a684 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 2 Jan 2019 10:55:33 +0100 Subject: [PATCH 19/65] Fine tune behavior when reusing existing diff-view When re-using an existing diff view it is enough to just focus the view, it will refresh `on_activated`. --- core/commands/diff.py | 112 +++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index ee78b943c..f2fbefc76 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -51,64 +51,64 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba if view_key in diff_views and diff_views[view_key] in sublime.active_window().views(): diff_view = diff_views[view_key] - else: - diff_view = util.view.get_scratch_view(self, "diff", read_only=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", in_cached_mode) - settings.set("git_savvy.diff_view.ignore_whitespace", False) - settings.set("git_savvy.diff_view.show_word_diff", False) - 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", self.savvy_settings.get("show_diffstat", True)) - 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", "") - - # 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 - # - # Now the actual problem is that Sublime only accepts a subset of modern reg expressions, - # B, C, and D are relatively straight forward because they match a whole line, and - # basically all other lines in a diff start with one of `[+- ]`. - 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. + self.window.focus_view(diff_view) + return + + diff_view = util.view.get_scratch_view(self, "diff", read_only=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", in_cached_mode) + settings.set("git_savvy.diff_view.ignore_whitespace", False) + settings.set("git_savvy.diff_view.show_word_diff", False) + 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", self.savvy_settings.get("show_diffstat", True)) + 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", "") + + # 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 + # + # Now the actual problem is that Sublime only accepts a subset of modern reg expressions, + # B, C, and D are relatively straight forward because they match a whole line, and + # basically all other lines in a diff start with one of `[+- ]`. + 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. + ) + + settings.set("result_file_regex", FILE_RE) + # Clickable line: + # @@ -69,6 +69,7 @@ class GsHandleVintageousCommand(TextCommand): + # ^^ we want the second (current) line offset of the diff + settings.set("result_line_regex", r"^@@ [^+]*\+(\d+),") + 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_views[view_key] = diff_view - settings.set("result_file_regex", FILE_RE) - # Clickable line: - # @@ -69,6 +69,7 @@ class GsHandleVintageousCommand(TextCommand): - # ^^ we want the second (current) line offset of the diff - settings.set("result_line_regex", r"^@@ [^+]*\+(\d+),") - 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_views[view_key] = diff_view - - self.window.focus_view(diff_view) - diff_view.sel().clear() diff_view.run_command("gs_diff_refresh", {'navigate_to_next_hunk': True}) diff_view.run_command("gs_handle_vintageous") From 0ebd87b06b9200044b432951a8c67311074f2916 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 2 Jan 2019 11:30:56 +0100 Subject: [PATCH 20/65] Add undo shortcut to help tooltip --- popups/diff_view.html | 1 + 1 file changed, 1 insertion(+) diff --git a/popups/diff_view.html b/popups/diff_view.html index def8e7f5f..dd5c286fa 100644 --- a/popups/diff_view.html +++ b/popups/diff_view.html @@ -26,6 +26,7 @@

When Diffing Working Tree

Other

  • ?                show this help popup
  • +
  • {super_key}-z           undo last action

Vintageous friendly mode

From 49a82d51cc730a5737304128c584edfe6def8f06 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 2 Jan 2019 11:31:21 +0100 Subject: [PATCH 21/65] Align undo shortcut help for inline diff view --- popups/inline_diff_view.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/popups/inline_diff_view.html b/popups/inline_diff_view.html index a3cc70652..32100c857 100644 --- a/popups/inline_diff_view.html +++ b/popups/inline_diff_view.html @@ -21,7 +21,7 @@

Other

  • ?                show this help popup
  • r                refresh the page
  • -
  • {super_key}-z          undo last action
  • +
  • {super_key}-z           undo last action

Vintageous friendly mode

From 7f41e7563b63935139eefb34d110dcd2e1f15fb4 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 15:22:23 +0100 Subject: [PATCH 22/65] Move clickable regexes at the top of the file --- core/commands/diff.py | 62 ++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index f2fbefc76..f30005dc2 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -20,6 +20,36 @@ DIFF_TITLE = "DIFF: {}" DIFF_CACHED_TITLE = "DIFF (cached): {}" +# 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 +# +# Now the actual problem is that Sublime only accepts a subset of modern reg expressions, +# B, C, and D are relatively straight forward because they match a whole line, and +# basically all other lines in a diff start with one of `[+- ]`. +CLICKABLE_FILES_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 +CLICKABLE_LINES_RE = r"^@@ [^+]*\+(\d+)," + diff_views = {} @@ -69,36 +99,8 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba settings.set("git_savvy.diff_view.history", []) settings.set("git_savvy.diff_view.just_hunked", "") - # 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 - # - # Now the actual problem is that Sublime only accepts a subset of modern reg expressions, - # B, C, and D are relatively straight forward because they match a whole line, and - # basically all other lines in a diff start with one of `[+- ]`. - 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. - ) - - settings.set("result_file_regex", FILE_RE) - # Clickable line: - # @@ -69,6 +69,7 @@ class GsHandleVintageousCommand(TextCommand): - # ^^ we want the second (current) line offset of the diff - settings.set("result_line_regex", r"^@@ [^+]*\+(\d+),") + settings.set("result_file_regex", CLICKABLE_FILES_RE) + settings.set("result_line_regex", CLICKABLE_LINES_RE) settings.set("result_base_dir", repo_path) if not title: From 7c2c68b4c32eacaeedc615ccfa919eb25ed72892 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 15:36:14 +0100 Subject: [PATCH 23/65] Extract view state handling --- core/commands/diff.py | 144 +++++++++++++++++++++++++++--------------- 1 file changed, 93 insertions(+), 51 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index f30005dc2..b8b72b461 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -4,6 +4,7 @@ """ from contextlib import contextmanager +from functools import partial import os import re import bisect @@ -54,6 +55,44 @@ diff_views = {} +# ++ COMMON VIEW STATE HANDLING +COMMON_TOP_KEYS = {'repo_path', 'file_path'} +NOT_SET = object() + + +def make_key(typ, top_keys, key): + if key in top_keys: + return 'git_savvy.{}'.format(key) + + return 'git_savvy.{}.{}'.format(typ, key) + + +def get_view_setting(key_fn, view, key_or_set): + if isinstance(key_or_set, str): + return view.settings().get(key_fn(key_or_set)) + else: + return {k: view.settings().get(key_fn(k)) for k in key_or_set} + + +def set_view_setting(key_fn, view, key_or_mapping, value=NOT_SET): + if value is NOT_SET: + for k, v in key_or_mapping.items(): + view.settings().set(key_fn(k), v) + else: + view.settings().set(key_fn(key_or_mapping), value) + + +# ++ SPECIFIC VIEW STATE HANDLING +TOP_KEYS = COMMON_TOP_KEYS | {'disable_diff'} +make_diff_view_key = partial(make_key, 'diff_view', TOP_KEYS) +get_diff_view_state = partial(get_view_setting, make_diff_view_key) +set_diff_view_state = partial(set_view_setting, make_diff_view_key) + + +def state_fns(view): + return partial(get_diff_view_state, view), partial(set_diff_view_state, view) + + class GsDiffCommand(WindowCommand, GitCommand): """ @@ -86,19 +125,22 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba diff_view = util.view.get_scratch_view(self, "diff", read_only=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", in_cached_mode) - settings.set("git_savvy.diff_view.ignore_whitespace", False) - settings.set("git_savvy.diff_view.show_word_diff", False) - 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", self.savvy_settings.get("show_diffstat", True)) - 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", "") + state, set_state = state_fns(diff_view) + set_state({ + "repo_path": repo_path, + "file_path": file_path, + "in_cached_mode": in_cached_mode, + "ignore_whitespace": False, + "show_word_diff": False, + "base_commit": base_commit, + "target_commit": target_commit, + "show_diffstat": self.savvy_settings.get("show_diffstat", True), + "disable_stage": disable_stage, + "history": [], + "just_hunked": "", + }) + settings = diff_view.settings() settings.set("result_file_regex", CLICKABLE_FILES_RE) settings.set("result_line_regex", CLICKABLE_LINES_RE) settings.set("result_base_dir", repo_path) @@ -122,15 +164,18 @@ class GsDiffRefreshCommand(TextCommand, GitCommand): """ def run(self, edit, cursors=None, navigate_to_next_hunk=False): - if self.view.settings().get("git_savvy.disable_diff"): + state, set_state = state_fns(self.view) + + if state("git_savvy.disable_diff"): return - 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") + + in_cached_mode = state("in_cached_mode") + ignore_whitespace = state("ignore_whitespace") + show_word_diff = state("show_word_diff") + base_commit = state("base_commit") + target_commit = state("target_commit") + show_diffstat = state("show_diffstat") + disable_stage = state("disable_stage") prelude = "\n" if self.file_path: @@ -177,7 +222,7 @@ def run(self, edit, cursors=None, navigate_to_next_hunk=False): # 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) + set_state("disable_diff", True) return raise err @@ -197,12 +242,11 @@ class GsDiffToggleSetting(TextCommand): """ def run(self, edit, setting): - settings = self.view.settings() + state, set_state = state_fns(self.view) - setting_str = "git_savvy.diff_view.{}".format(setting) - current_mode = settings.get(setting_str) + current_mode = state(setting) next_mode = not current_mode - settings.set(setting_str, next_mode) + set_state(setting, next_mode) self.view.window().status_message("{} is now {}".format(setting, next_mode)) self.view.run_command("gs_diff_refresh") @@ -216,35 +260,29 @@ class GsDiffToggleCachedMode(TextCommand): # NOTE: MUST NOT be async, otherwise `view.show` will not update the view 100%! def run(self, edit): - setting = 'in_cached_mode' + state, set_state = state_fns(self.view) - if ( - self.view.settings().get("git_savvy.diff_view.base_commit") - and self.view.settings().get("git_savvy.diff_view.target_commit") - ): + if state("base_commit") and state("target_commit"): # There is no cached mode if you diff between two commits, so # we need to abort here return - settings = self.view.settings() - - last_cursors = settings.get('git_savvy.diff_view.last_cursors') or [] + last_cursors = state('last_cursors') or [] cursors = [(s.a, s.b) for s in self.view.sel()] - settings.set('git_savvy.diff_view.last_cursors', cursors) + set_state('last_cursors', cursors) - setting_str = "git_savvy.diff_view.{}".format(setting) - current_mode = settings.get(setting_str) + current_mode = state('in_cached_mode') next_mode = not current_mode - settings.set(setting_str, next_mode) + set_state('in_cached_mode', 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") + just_hunked = state("just_hunked") if just_hunked: - self.view.settings().set("git_savvy.diff_view.just_hunked", "") + set_state("just_hunked", "") match = re.search(r'\n(@@ .+)\n', just_hunked) if match is not None: expected_content = match.group(1) @@ -313,9 +351,9 @@ class GsDiffStageOrResetHunkCommand(TextCommand, GitCommand): # the main worker queue. 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: + state, set_state = state_fns(self.view) + + if state("ignore_whitespace") or state("show_word_diff"): sublime.error_message("You have to be in a clean diff to stage.") return None @@ -338,7 +376,8 @@ def run(self, edit, reset=False): self.apply_diffs_for_pts(cursor_pts, reset) def apply_diffs_for_pts(self, cursor_pts, reset): - in_cached_mode = self.view.settings().get("git_savvy.diff_view.in_cached_mode") + state, set_state = state_fns(self.view) + in_cached_mode = state("in_cached_mode") # Apply the diffs in reverse order - otherwise, line number will be off. for pt in reversed(cursor_pts): @@ -373,10 +412,10 @@ def apply_diffs_for_pts(self, cursor_pts, reset): stdin=hunk_diff ) - history = self.view.settings().get("git_savvy.diff_view.history") + history = state("history") history.append((args, hunk_diff, pt, in_cached_mode)) - self.view.settings().set("git_savvy.diff_view.history", history) - self.view.settings().set("git_savvy.diff_view.just_hunked", hunk_diff) + set_state("history", history) + set_state("just_hunked", hunk_diff) self.view.run_command("gs_diff_refresh") @@ -449,7 +488,8 @@ def load_file_at_line(self, filename, lineno): Show file at target commit if `git_savvy.diff_view.target_commit` is non-empty. Otherwise, open the file directly. """ - target_commit = self.view.settings().get("git_savvy.diff_view.target_commit") + state, set_state = state_fns(self.view) + target_commit = state("target_commit") full_path = os.path.join(self.repo_path, filename) if target_commit: self.view.window().run_command("gs_show_file_at_commit", { @@ -485,7 +525,9 @@ class GsDiffUndo(TextCommand, GitCommand): # 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") + state, set_state = state_fns(self.view) + + history = state("history") if not history: window = self.view.window() if window: @@ -497,13 +539,13 @@ def run(self, edit): 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) + set_state("history", history) + set_state("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: + if state("in_cached_mode") == in_cached_mode: self.view.sel().clear() self.view.sel().add(cursor) self.view.show(cursor) From 9cc183b9868d02c164ff7ef21cbada4ea1cef61f Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 15:58:23 +0100 Subject: [PATCH 24/65] Extract `toggle_setting` --- core/commands/diff.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index b8b72b461..ea681e232 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -243,15 +243,19 @@ class GsDiffToggleSetting(TextCommand): def run(self, edit, setting): state, set_state = state_fns(self.view) - - current_mode = state(setting) - next_mode = not current_mode - set_state(setting, next_mode) + next_mode = toggle_setting(state, set_state, setting) self.view.window().status_message("{} is now {}".format(setting, next_mode)) self.view.run_command("gs_diff_refresh") +def toggle_setting(get, set, key): + current = get(key) + next = not current + set(key, next) + return next + + class GsDiffToggleCachedMode(TextCommand): """ @@ -271,9 +275,7 @@ def run(self, edit): cursors = [(s.a, s.b) for s in self.view.sel()] set_state('last_cursors', cursors) - current_mode = state('in_cached_mode') - next_mode = not current_mode - set_state('in_cached_mode', next_mode) + next_mode = toggle_setting(state, set_state, 'in_cached_mode') self.view.window().status_message( "Showing {} changes".format("staged" if next_mode else "unstaged") ) From ddc7cc8bfa2f0fd6d4ceeb1becb3da3d5a1f2885 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 16:07:17 +0100 Subject: [PATCH 25/65] Extract `set_and_show_cursor` fn --- core/commands/diff.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index ea681e232..610d806d5 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -290,25 +290,33 @@ def run(self, edit): expected_content = match.group(1) region = self.view.find(expected_content, 0, sublime.LITERAL) if region: - self.view.sel().clear() - self.view.sel().add(region.a) - self.view.show(region.a) + set_and_show_cursor(self.view, region.a) return if last_cursors: - sel = self.view.sel() - sel.clear() - for (a, b) in last_cursors: - sel.add(sublime.Region(a, b)) - # The 'flipping' between the two states should be as fast as possible and # without visual clutter. with no_animations(): - self.view.show(sel) + set_and_show_cursor(self.view, [sublime.Region(a, b) for a, b in last_cursors]) + else: self.view.run_command("gs_diff_navigate") +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") @@ -548,6 +556,4 @@ def run(self, edit): # The cursor is only applicable if we're still in the same cache/stage mode if state("in_cached_mode") == in_cached_mode: - self.view.sel().clear() - self.view.sel().add(cursor) - self.view.show(cursor) + set_and_show_cursor(self.view, cursor) From fd11a769589af5aebf218c4a348e4abaea386a73 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 16:11:26 +0100 Subject: [PATCH 26/65] Extract pickle/unpickle selection --- core/commands/diff.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 610d806d5..b866ca159 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -272,8 +272,7 @@ def run(self, edit): return last_cursors = state('last_cursors') or [] - cursors = [(s.a, s.b) for s in self.view.sel()] - set_state('last_cursors', cursors) + set_state('last_cursors', pickle_sel(self.view.sel())) next_mode = toggle_setting(state, set_state, 'in_cached_mode') self.view.window().status_message( @@ -297,12 +296,20 @@ def run(self, edit): # 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, [sublime.Region(a, b) for a, b in last_cursors]) + set_and_show_cursor(self.view, unpickle_sel(last_cursors)) else: self.view.run_command("gs_diff_navigate") +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 set_and_show_cursor(view, cursors): sel = view.sel() sel.clear() From ac4ceb09ee0e474b77508e5dd264cc7dea151a91 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 16:20:22 +0100 Subject: [PATCH 27/65] Extract `find_hunk_in_view` --- core/commands/diff.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index b866ca159..156719289 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -284,13 +284,10 @@ def run(self, edit): just_hunked = state("just_hunked") if just_hunked: set_state("just_hunked", "") - match = re.search(r'\n(@@ .+)\n', just_hunked) - if match is not None: - expected_content = match.group(1) - region = self.view.find(expected_content, 0, sublime.LITERAL) - if region: - set_and_show_cursor(self.view, region.a) - return + 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 @@ -302,6 +299,15 @@ def run(self, edit): self.view.run_command("gs_diff_navigate") +def find_hunk_in_view(view, hunk): + match = re.search(r'\n(@@ .+)\n', hunk) + if match is not None: + expected_content = match.group(1) + region = view.find(expected_content, 0, sublime.LITERAL) + if region: + return region + + def pickle_sel(sel): return [(s.a, s.b) for s in sel] From fda5458c8690fc23224bd9b92777796ad65d2ffe Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 17:02:28 +0100 Subject: [PATCH 28/65] Remove unused `cursors` arg from GsDiffRefreshCommand --- core/commands/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 156719289..ea214b4c1 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -163,7 +163,7 @@ class GsDiffRefreshCommand(TextCommand, GitCommand): Refresh the diff view with the latest repo state. """ - def run(self, edit, cursors=None, navigate_to_next_hunk=False): + def run(self, edit, navigate_to_next_hunk=False): state, set_state = state_fns(self.view) if state("git_savvy.disable_diff"): From 53b620e58cee8662122b391db41ea67e02f3726a Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 17:17:15 +0100 Subject: [PATCH 29/65] fixup: Extract view state handling --- core/commands/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index ea214b4c1..8fb6dbda3 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -166,7 +166,7 @@ class GsDiffRefreshCommand(TextCommand, GitCommand): def run(self, edit, navigate_to_next_hunk=False): state, set_state = state_fns(self.view) - if state("git_savvy.disable_diff"): + if state("disable_diff"): return in_cached_mode = state("in_cached_mode") From 0188e99cf99542411809ba21f814646fe74bc70a Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 17:31:45 +0100 Subject: [PATCH 30/65] Split refresh into two functions --- core/commands/diff.py | 132 +++++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 48 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 8fb6dbda3..50c570778 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -157,6 +157,87 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba diff_view.run_command("gs_handle_vintageous") +def prelude_text(view): + state, set_state = state_fns(view) + prelude = "\n" + + file_path = state('file_path') + if file_path: + repo_path = state('repo_path') + rel_file_path = os.path.relpath(file_path, repo_path) + prelude += " FILE: {}\n".format(rel_file_path) + + in_cached_mode = state('in_cached_mode') + if state('disable_stage'): + base_commit = state('base_commit') + target_commit = state('target_commit') + if in_cached_mode: + prelude += " INDEX..{}\n".format(base_commit or target_commit) + else: + if base_commit and target_commit: + prelude += " {}..{}\n".format(target_commit, base_commit) + else: + prelude += " WORKING DIR..{}\n".format(base_commit or target_commit) + else: + if in_cached_mode: + prelude += " STAGED CHANGES (Will commit)\n" + else: + prelude += " UNSTAGED CHANGES\n" + + if state('ignore_whitespace'): + prelude += " IGNORING WHITESPACE\n" + + prelude += "\n--\n" + return prelude + + +def do_git_diff(view): + state, set_state = state_fns(view) + git = make_git(view) + + file_path = state('file_path') + in_cached_mode = state("in_cached_mode") + ignore_whitespace = state("ignore_whitespace") + show_word_diff = state("show_word_diff") + base_commit = state("base_commit") + target_commit = state("target_commit") + show_diffstat = state("show_diffstat") + + try: + return git( + "diff", + "--ignore-all-space" if ignore_whitespace else None, + "--word-diff" if show_word_diff 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: + set_state("disable_diff", True) + return + raise err + + +def make_git(view): + cmd = GitCommand() + cmd.view = view + return cmd.git + + class GsDiffRefreshCommand(TextCommand, GitCommand): """ @@ -169,64 +250,19 @@ def run(self, edit, navigate_to_next_hunk=False): if state("disable_diff"): return - in_cached_mode = state("in_cached_mode") - ignore_whitespace = state("ignore_whitespace") - show_word_diff = state("show_word_diff") - base_commit = state("base_commit") - target_commit = state("target_commit") - show_diffstat = state("show_diffstat") - disable_stage = state("disable_stage") - - prelude = "\n" - if self.file_path: - rel_file_path = os.path.relpath(self.file_path, self.repo_path) - prelude += " FILE: {}\n".format(rel_file_path) - - if disable_stage: - if in_cached_mode: - prelude += " INDEX..{}\n".format(base_commit or target_commit) - else: - if base_commit and target_commit: - prelude += " {}..{}\n".format(target_commit, base_commit) - else: - prelude += " WORKING DIR..{}\n".format(base_commit or target_commit) - else: - if in_cached_mode: - prelude += " STAGED CHANGES (Will commit)\n" - else: - prelude += " UNSTAGED CHANGES\n" - - if ignore_whitespace: - prelude += " IGNORING WHITESPACE\n" - try: - stdout = self.git( - "diff", - "--ignore-all-space" if ignore_whitespace else None, - "--word-diff" if show_word_diff else None, - "--stat" if show_diffstat else None, - "--patch", - "--no-color", - "--cached" if in_cached_mode else None, - base_commit, - target_commit, - "--", self.file_path) + diff = do_git_diff(self.view) 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. - # + # the view. Thus, we set a disable flag if err.args and type(err.args[0]) == UnicodeDecodeError: set_state("disable_diff", True) return raise err - text = prelude + '\n--\n' + stdout + text = prelude_text(self.view) + diff self.view.run_command( "gs_replace_view_text", {"text": text, "restore_cursors": True} From c8354b84e9bae940f67cbc1dfd02ef47e2898f70 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 17:33:52 +0100 Subject: [PATCH 31/65] Navigate to first hunk if last diff was empty --- core/commands/diff.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 50c570778..af183295b 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -262,12 +262,14 @@ def run(self, edit, navigate_to_next_hunk=False): return raise err + old_diff = state("raw_diff") + set_state("raw_diff", diff) text = prelude_text(self.view) + diff self.view.run_command( "gs_replace_view_text", {"text": text, "restore_cursors": True} ) - if navigate_to_next_hunk: + if not old_diff: self.view.run_command("gs_diff_navigate") From 049be9d209e4a4ee40742a1d3fa15daeb7511a55 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 17:36:43 +0100 Subject: [PATCH 32/65] Remove obsolete `navigate_to_next_hunk` arg --- core/commands/diff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index af183295b..e454bcaf3 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -153,7 +153,7 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba diff_view.set_syntax_file("Packages/GitSavvy/syntax/diff_view.sublime-syntax") diff_views[view_key] = diff_view - diff_view.run_command("gs_diff_refresh", {'navigate_to_next_hunk': True}) + diff_view.run_command("gs_diff_refresh") diff_view.run_command("gs_handle_vintageous") @@ -244,7 +244,7 @@ class GsDiffRefreshCommand(TextCommand, GitCommand): Refresh the diff view with the latest repo state. """ - def run(self, edit, navigate_to_next_hunk=False): + def run(self, edit): state, set_state = state_fns(self.view) if state("disable_diff"): From 262cbbe8daec6a400e509f5aa718c7ec1048a626 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 17:37:56 +0100 Subject: [PATCH 33/65] Do not fallback to `gs_diff_navigate` when switching areas --- core/commands/diff.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index e454bcaf3..22d2aebd3 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -333,9 +333,6 @@ def run(self, edit): with no_animations(): set_and_show_cursor(self.view, unpickle_sel(last_cursors)) - else: - self.view.run_command("gs_diff_navigate") - def find_hunk_in_view(view, hunk): match = re.search(r'\n(@@ .+)\n', hunk) From 410fa62ee2bcfe67746143eecc5dad6e8c959302 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 17:47:52 +0100 Subject: [PATCH 34/65] Simplify focus listener --- core/commands/diff.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 22d2aebd3..9cec065cb 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -383,9 +383,11 @@ class GsDiffFocusEventListener(EventListener): when the view regains focus. """ - def on_activated(self, view): - if view.settings().get("git_savvy.diff_view") is True: - sublime.set_timeout_async(lambda: view.run_command("gs_diff_refresh")) + def on_activated_async(self, view): + if not view.settings().get("git_savvy.diff_view"): + return + + view.run_command("gs_diff_refresh") class GsDiffStageOrResetHunkCommand(TextCommand, GitCommand): From 00dfbb0b6e02970d03f6e9f0add0c01c9e6a0ee6 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 17:56:45 +0100 Subject: [PATCH 35/65] Fix tests --- tests/test_diff_view.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index b819995cc..b75481240 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -6,7 +6,8 @@ from unittesting import DeferrableTestCase from GitSavvy.tests.mockito import expect, unstub, when, spy2 -from GitSavvy.core.commands.diff import GsDiffCommand, GsDiffRefreshCommand +import GitSavvy.core.commands.diff as module +from GitSavvy.core.commands.diff import GsDiffCommand THIS_DIRNAME = os.path.dirname(os.path.realpath(__file__)) @@ -45,7 +46,7 @@ def test_extract_clickable_lines(self): FILE_PATH = '/not/there/README.md' DIFF = fixture('diff_1.txt') - when(GsDiffRefreshCommand).git('diff', ...).thenReturn(DIFF) + when(module).do_git_diff(...).thenReturn(DIFF) cmd = GsDiffCommand(self.window) when(cmd).get_repo_path().thenReturn(REPO_PATH) cmd.run_async() @@ -67,7 +68,7 @@ def test_result_file_regex(self): FILE_PATH = '/not/there/README.md' DIFF = fixture('diff_1.txt') - when(GsDiffRefreshCommand).git('diff', ...).thenReturn(DIFF) + when(module).do_git_diff(...).thenReturn(DIFF) cmd = GsDiffCommand(self.window) when(cmd).get_repo_path().thenReturn(REPO_PATH) cmd.run_async() From af9b26c70e470c33a4b6622ad3f4a2da9ef3a99d Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 3 Jan 2019 18:15:54 +0100 Subject: [PATCH 36/65] Make a simple `refresh` function --- core/commands/diff.py | 62 +++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 9cec065cb..defaecdf3 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -153,7 +153,7 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba diff_view.set_syntax_file("Packages/GitSavvy/syntax/diff_view.sublime-syntax") diff_views[view_key] = diff_view - diff_view.run_command("gs_diff_refresh") + refresh(diff_view) diff_view.run_command("gs_handle_vintageous") @@ -238,39 +238,33 @@ def make_git(view): return cmd.git -class GsDiffRefreshCommand(TextCommand, GitCommand): +def refresh(view): + state, set_state = state_fns(view) - """ - Refresh the diff view with the latest repo state. - """ + if state("disable_diff"): + return - def run(self, edit): - state, set_state = state_fns(self.view) - - if state("disable_diff"): + try: + diff = do_git_diff(view) + 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. Thus, we set a disable flag + if err.args and type(err.args[0]) == UnicodeDecodeError: + set_state("disable_diff", True) return + raise err - try: - diff = do_git_diff(self.view) - 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. Thus, we set a disable flag - if err.args and type(err.args[0]) == UnicodeDecodeError: - set_state("disable_diff", True) - return - raise err - - old_diff = state("raw_diff") - set_state("raw_diff", diff) - text = prelude_text(self.view) + diff + old_diff = state("raw_diff") + set_state("raw_diff", diff) + text = prelude_text(view) + diff - self.view.run_command( - "gs_replace_view_text", {"text": text, "restore_cursors": True} - ) - if not old_diff: - self.view.run_command("gs_diff_navigate") + view.run_command( + "gs_replace_view_text", {"text": text, "restore_cursors": True} + ) + if not old_diff: + view.run_command("gs_diff_navigate") class GsDiffToggleSetting(TextCommand): @@ -284,7 +278,7 @@ def run(self, edit, setting): next_mode = toggle_setting(state, set_state, setting) self.view.window().status_message("{} is now {}".format(setting, next_mode)) - self.view.run_command("gs_diff_refresh") + refresh(self.view) def toggle_setting(get, set, key): @@ -317,7 +311,7 @@ def run(self, edit): "Showing {} changes".format("staged" if next_mode else "unstaged") ) - self.view.run_command("gs_diff_refresh") + refresh(self.view) just_hunked = state("just_hunked") if just_hunked: @@ -387,7 +381,7 @@ def on_activated_async(self, view): if not view.settings().get("git_savvy.diff_view"): return - view.run_command("gs_diff_refresh") + refresh(view) class GsDiffStageOrResetHunkCommand(TextCommand, GitCommand): @@ -477,7 +471,7 @@ def apply_diffs_for_pts(self, cursor_pts, reset): set_state("history", history) set_state("just_hunked", hunk_diff) - self.view.run_command("gs_diff_refresh") + refresh(self.view) def get_hunk_diff(self, pt): """ @@ -602,7 +596,7 @@ def run(self, edit): set_state("history", history) set_state("just_hunked", stdin) - self.view.run_command("gs_diff_refresh") + refresh(self.view) # The cursor is only applicable if we're still in the same cache/stage mode if state("in_cached_mode") == in_cached_mode: From 2e9b0a9c11e3ca32a751326d12fea1807eb8e9d2 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 4 Jan 2019 14:23:04 +0100 Subject: [PATCH 37/65] Find hunk using the actual hunk content The hunk header (`@@ ...`) is relatively brittle. We now search for the hunk contents as a fallback. --- core/commands/diff.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index defaecdf3..d08c7a5be 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -329,14 +329,42 @@ def run(self, edit): def find_hunk_in_view(view, hunk): - match = re.search(r'\n(@@ .+)\n', hunk) - if match is not None: - expected_content = match.group(1) - region = view.find(expected_content, 0, sublime.LITERAL) + hunk_lines = hunk.split('\n') + for i, line in enumerate(hunk_lines): + if line.startswith('@@ '): + region = view.find(line, 0, sublime.LITERAL) + if region: + return region + else: + break + else: + return + + for hunk_content in shrink_list_sym(hunk_lines[i + 1:]): + region = view.find('\n'.join(hunk_content), 0, sublime.LITERAL) if region: + return first_hunk_start_before_pt(view, region.a) + + +def first_hunk_start_before_pt(view, pt): + for region in line_regions_before_pt(view, pt): + if view.substr(region).startswith('@@ '): return region +def shrink_list_sym(list): + while list: + yield list + list = list[1:-1] + + +def line_regions_before_pt(view, pt): + row, _ = view.rowcol(pt) + for row in reversed(range(row)): + pt = view.text_point(row, 0) + yield view.line(pt) + + def pickle_sel(sel): return [(s.a, s.b) for s in sel] From 0a35caf52f7c63058a044632bb126a53975e48d9 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 4 Jan 2019 16:06:18 +0100 Subject: [PATCH 38/65] Fix clickable hunk header when it doesn't contain a line count --- core/commands/diff.py | 2 +- tests/fixtures/diff_1.txt | 2 +- tests/test_diff_view.py | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index d08c7a5be..84a2c5f5b 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -49,7 +49,7 @@ # Clickable line: # @@ -69,6 +69,7 @@ class GsHandleVintageousCommand(TextCommand): # ^^ we want the second (current) line offset of the diff -CLICKABLE_LINES_RE = r"^@@ [^+]*\+(\d+)," +CLICKABLE_LINES_RE = r"^@@ [^+]*\+(\d+)" diff_views = {} diff --git a/tests/fixtures/diff_1.txt b/tests/fixtures/diff_1.txt index b3bd04ec8..0dcbd3e11 100644 --- a/tests/fixtures/diff_1.txt +++ b/tests/fixtures/diff_1.txt @@ -6,7 +6,7 @@ diff --git a/core/commands/custom.py b/core/commands/custom.py index 1facb191..8b079d4d 100644 --- a/core/commands/custom.py +++ b/core/commands/custom.py -@@ -16,7 +16,7 @@ class CustomCommandThread(threading.Thread): +@@ -16 +16 @@ class CustomCommandThread(threading.Thread): self.daemon = True def run(self): diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index b75481240..e5d18643b 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -43,7 +43,6 @@ def tearDown(self): def test_extract_clickable_lines(self): REPO_PATH = '/not/there' - FILE_PATH = '/not/there/README.md' DIFF = fixture('diff_1.txt') when(module).do_git_diff(...).thenReturn(DIFF) @@ -65,7 +64,6 @@ def test_extract_clickable_lines(self): def test_result_file_regex(self): REPO_PATH = '/not/there' - FILE_PATH = '/not/there/README.md' DIFF = fixture('diff_1.txt') when(module).do_git_diff(...).thenReturn(DIFF) From caa43654e5ddf3abebf054c6f12758484687ab7c Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 4 Jan 2019 18:06:14 +0100 Subject: [PATCH 39/65] Add tests for hunk finding --- core/commands/diff.py | 15 ++- tests/test_diff_view.py | 204 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 199 insertions(+), 20 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 84a2c5f5b..a2840f06f 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -332,15 +332,14 @@ def find_hunk_in_view(view, hunk): hunk_lines = hunk.split('\n') for i, line in enumerate(hunk_lines): if line.startswith('@@ '): - region = view.find(line, 0, sublime.LITERAL) - if region: - return region - else: - break - else: - return + return ( + view.find(line, 0, sublime.LITERAL) + or search_for_hunk_content_in_view(view, hunk_lines[i + 1:]) + ) + - for hunk_content in shrink_list_sym(hunk_lines[i + 1:]): +def search_for_hunk_content_in_view(view, lines): + for hunk_content in shrink_list_sym(lines): region = view.find('\n'.join(hunk_content), 0, sublime.LITERAL) if region: return first_hunk_start_before_pt(view, region.a) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index e5d18643b..9a903284b 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -4,7 +4,8 @@ import sublime from unittesting import DeferrableTestCase -from GitSavvy.tests.mockito import expect, unstub, when, spy2 +from GitSavvy.tests.mockito import when, unstub +from GitSavvy.tests.parameterized import parameterized as p import GitSavvy.core.commands.diff as module from GitSavvy.core.commands.diff import GsDiffCommand @@ -18,27 +19,206 @@ def fixture(name): return f.read() -class TestDiffView(DeferrableTestCase): - def setUp(self): - original_window_id = sublime.active_window().id() +class TestDiffViewInternalFunctions(DeferrableTestCase): + @classmethod + def setUpClass(cls): sublime.run_command("new_window") + cls.window = sublime.active_window() + s = sublime.load_settings("Preferences.sublime-settings") + s.set("close_windows_when_empty", False) + + @classmethod + def tearDownClass(self): + self.window.run_command('close_window') + + @p.expand([ + ([1, 2, 3, 4, 5], [[1, 2, 3, 4, 5], [2, 3, 4], [3]]), + ([1, 2, 3, 4], [[1, 2, 3, 4], [2, 3]]), + ([1, 2, 3], [[1, 2, 3], [2]]), + ([1, 2], [[1, 2]]), + ([1], [[1]]), + ([], []) + ]) + def test_shrink_list(self, IN, expected): + actual = module.shrink_list_sym(IN) + actual = list(actual) + self.assertEqual(actual, expected) + + @p.expand([ + (26, [(20, 24), (15, 19), (10, 14), (5, 9), (0, 4)]), + (25, [(20, 24), (15, 19), (10, 14), (5, 9), (0, 4)]), + + (24, [(15, 19), (10, 14), (5, 9), (0, 4)]), + (23, [(15, 19), (10, 14), (5, 9), (0, 4)]), + (22, [(15, 19), (10, 14), (5, 9), (0, 4)]), + (21, [(15, 19), (10, 14), (5, 9), (0, 4)]), + (20, [(15, 19), (10, 14), (5, 9), (0, 4)]), + + (19, [(10, 14), (5, 9), (0, 4)]), + (18, [(10, 14), (5, 9), (0, 4)]), + (17, [(10, 14), (5, 9), (0, 4)]), + (16, [(10, 14), (5, 9), (0, 4)]), + (15, [(10, 14), (5, 9), (0, 4)]), + + (14, [(5, 9), (0, 4)]), + (13, [(5, 9), (0, 4)]), + (12, [(5, 9), (0, 4)]), + (11, [(5, 9), (0, 4)]), + (10, [(5, 9), (0, 4)]), + + (9, [(0, 4)]), + (8, [(0, 4)]), + (7, [(0, 4)]), + (6, [(0, 4)]), + (5, [(0, 4)]), + + (4, []), + (3, []), + (2, []), + (1, []), + (0, []), + + (-1, []), + ]) + def test_line_ranges_before_point(self, IN, expected): + # ATT: All '0123' actually are '0123\n' (length: 5) + VIEW_CONTENT = """\ +0123 +0123 +0123 +0123 +0123 +""" + view = self.window.new_file() + self.addCleanup(view.close) + view.run_command('append', {'characters': VIEW_CONTENT}) + view.set_scratch(True) + + actual = module.line_regions_before_pt(view, IN) + # unpack `sublime.Region`s + actual = module.pickle_sel(actual) + + self.assertEqual(actual, expected) + + @p.expand([ + (0, None), + (1, None), + (2, None), + (3, None), + (4, None), + + (10, (5, 9)), + (11, (5, 9)), + (12, (5, 9)), + (13, (5, 9)), + (14, (5, 9)), + (15, (5, 9)), + (16, (5, 9)), + (17, (5, 9)), + (18, (5, 9)), + (19, (5, 9)), - yield lambda: sublime.active_window().id() != original_window_id + (25, (20, 24)), + (26, (20, 24)), + (27, (20, 24)), + (28, (20, 24)), + (29, (20, 24)), + (30, (20, 24)), + (31, (20, 24)), + (32, (20, 24)), + (33, (20, 24)), + (34, (20, 24)), + (35, (20, 24)), - self.window = sublime.active_window() - self.view = sublime.active_window().new_file() - self.window.focus_view(self.view) - yield lambda: sublime.active_window().active_view().id() == self.view.id() - # make sure we have a window to work with + ]) + def test_first_hunk_start_before_pt(self, IN, expected): + VIEW_CONTENT = """\ +0123 +@@ 1 +1123 +1123 +@@ 2 +2123 +2123 +""" + + view = self.window.new_file() + self.addCleanup(view.close) + view.run_command('append', {'characters': VIEW_CONTENT}) + view.set_scratch(True) + + actual = module.first_hunk_start_before_pt(view, IN) + actual = (actual.a, actual.b) if actual else actual + self.assertEqual(actual, expected) + + @p.expand([ + ("@@ 1\n1234\n1567\n1890", (30, 34)), + ("@@ 1\n1234\n1567", (30, 34)), + ("@@ 1\n1567\n1890", (30, 34)), + ("@@ 1\n1234", (30, 34)), + ("@@ 1\n1567", (30, 34)), + ("@@ 1\n1890", (30, 34)), + + ("@@ 1\n1XXX\n1XXX", (30, 34)), + + ("@@ X\n1234\n1567\n1890", (30, 34)), + ("@@ X\n1567\n1890", (30, 34)), + ("@@ X\n1234\n1567", (30, 34)), + ("@@ X\n1234", (30, 34)), + ("@@ X\n1567", (30, 34)), + ("@@ X\n1890", (30, 34)), + ("@@ X\n1XXX\n1567\n1890", (30, 34)), + ("@@ X\n1234\n1567\n1XXX", (30, 34)), + ("@@ X\n1XXX\n1567\n1XXX", (30, 34)), + + ("@@ X\n1234\n1XXX\n1XXX", None), + ("@@ X\n1XXX\n1XXX\n1890", None), + ("@@ X\n1XXX\n1XXX\n1XXX", None), + ("@@ X\n0123", None), + ]) + def test_find_hunk_in_view(self, IN, expected): + VIEW_CONTENT = """\ +0123 +diff --git a/barz b/fooz +@@ 1 +1234 +1567 +1890 +@@ 2 +2345 +2678 +""" + + view = self.window.new_file() + self.addCleanup(view.close) + view.run_command('append', {'characters': VIEW_CONTENT}) + view.set_scratch(True) + + actual = module.find_hunk_in_view(view, IN) + actual = (actual.a, actual.b) if actual else actual + self.assertEqual(actual, expected) + + +class TestDiffView(DeferrableTestCase): + @classmethod + def setUpClass(cls): + sublime.run_command("new_window") + cls.window = sublime.active_window() s = sublime.load_settings("Preferences.sublime-settings") s.set("close_windows_when_empty", False) + @classmethod + def tearDownClass(self): + self.window.run_command('close_window') + + def setUp(self): + self.view = self.window.new_file() + def tearDown(self): if self.view: self.view.set_scratch(True) - self.view.window().focus_view(self.view) self.view.close() - self.window.run_command('close_window') + unstub() def test_extract_clickable_lines(self): From fefa525fcffadd3feab3b84946d76f911080ed08 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Fri, 4 Jan 2019 18:49:32 +0100 Subject: [PATCH 40/65] Defensive hunk finding - Guard against invalid input - Only consider first hunk if multiple are given --- core/commands/diff.py | 24 ++++++++++++++++++------ tests/test_diff_view.py | 10 ++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index a2840f06f..f6ec2531e 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -5,6 +5,7 @@ from contextlib import contextmanager from functools import partial +from itertools import dropwhile, takewhile import os import re import bisect @@ -329,13 +330,24 @@ def run(self, edit): def find_hunk_in_view(view, hunk): + hunk_content = extract_first_hunk(hunk) + if hunk_content: + return ( + view.find(hunk_content[0], 0, sublime.LITERAL) + or search_for_hunk_content_in_view(view, hunk_content[1:]) + ) + + +def extract_first_hunk(hunk): hunk_lines = hunk.split('\n') - for i, line in enumerate(hunk_lines): - if line.startswith('@@ '): - return ( - view.find(line, 0, sublime.LITERAL) - or search_for_hunk_content_in_view(view, hunk_lines[i + 1:]) - ) + not_hunk_start = lambda l: not l.startswith('@@ ') # noqa: E731 + + try: + start, *rest = dropwhile(not_hunk_start, hunk_lines) + except (StopIteration, ValueError): + return None + + return [start] + list(takewhile(not_hunk_start, rest)) def search_for_hunk_content_in_view(view, lines): diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 9a903284b..2c0832fc1 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -175,6 +175,16 @@ def test_first_hunk_start_before_pt(self, IN, expected): ("@@ X\n1XXX\n1XXX\n1890", None), ("@@ X\n1XXX\n1XXX\n1XXX", None), ("@@ X\n0123", None), + + # Only consider first hunk in input + ("@@ X\n1234\n1567\n1890\n@@ 2\n2345\n2678", (30, 34)), + ("@@ X\n1234\n@@ 2\n2345\n2678", (30, 34)), + ("@@ X\n1234\n1567\n1890\n@@ X\n2XXX\n2678", (30, 34)), + + # Ensure invalid input doesn't throw + ("@@ X", None), + ("@@ X\n", None), + ("1234\n1567\n1890", None), ]) def test_find_hunk_in_view(self, IN, expected): VIEW_CONTENT = """\ From 37632f9d769f50bd932473440a264363c4df5309 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 5 Jan 2019 13:03:51 +0100 Subject: [PATCH 41/65] Allow hunking last hunk if at end of file --- core/commands/diff.py | 3 +- tests/test_diff_view.py | 83 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index f6ec2531e..594d313f3 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -463,7 +463,8 @@ def run(self, edit, reset=False): # immediately following diff headers. (set(self.hunk_starts) - set(self.header_ends)) | # The last hunk ends at the end of the file. - set((self.view.size(), )) + # It should include the last line (`+ 1`). + set((self.view.size() + 1, )) )) self.apply_diffs_for_pts(cursor_pts, reset) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 2c0832fc1..3a4068360 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -1,3 +1,4 @@ +from itertools import chain, repeat import os import re @@ -209,6 +210,88 @@ def test_find_hunk_in_view(self, IN, expected): self.assertEqual(actual, expected) +class TestDiffViewHunking(DeferrableTestCase): + @classmethod + def setUpClass(cls): + sublime.run_command("new_window") + cls.window = sublime.active_window() + s = sublime.load_settings("Preferences.sublime-settings") + s.set("close_windows_when_empty", False) + + @classmethod + def tearDownClass(self): + self.window.run_command('close_window') + + def tearDown(self): + unstub() + + HUNK1 = """\ +diff --git a/fooz b/barz +--- a/fooz ++++ b/barz +@@ -16,1 +16,1 @@ Hi + one + two +""" + HUNK2 = """\ +diff --git a/foxx b/boxx +--- a/foox ++++ b/boox +@@ -16,1 +16,1 @@ Hello + one + two +""" + + @p.expand(chain( + zip(range(58, 89), repeat(HUNK1)), + zip(range(136, 170), repeat(HUNK2)), + [ + (170, HUNK2), # at EOF should work + ] + )) + def test_hunking_one_hunk(self, CURSOR, HUNK, IN_CACHED_MODE=False): + # Docstring here to get verbose parameterized printing + """""" + VIEW_CONTENT = """\ +prelude +-- +diff --git a/fooz b/barz +--- a/fooz ++++ b/barz +@@ -16,1 +16,1 @@ Hi + one + two +diff --git a/foxx b/boxx +--- a/foox ++++ b/boox +@@ -16,1 +16,1 @@ Hello + one + two +""" + view = self.window.new_file() + self.addCleanup(view.close) + view.run_command('append', {'characters': VIEW_CONTENT}) + view.set_scratch(True) + + state, set_state = module.state_fns(view) + set_state({'in_cached_mode': IN_CACHED_MODE, 'history': []}) + cmd = module.GsDiffStageOrResetHunkCommand(view) + when(cmd).git(...) + when(module).refresh(view) + + view.sel().clear() + view.sel().add(CURSOR) + + cmd.run({'unused_edit'}) + + history = state('history') + self.assertEqual(len(history), 1) + + actual = history.pop() + expected = [['apply', None, '--cached', '-'], HUNK, CURSOR, IN_CACHED_MODE] + self.assertEqual(actual, expected) + + class TestDiffView(DeferrableTestCase): @classmethod def setUpClass(cls): From b452c3fc2388f3369be8fc21b46335aa7831f2f5 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 13:02:11 +0100 Subject: [PATCH 42/65] Close diff view after test --- tests/test_diff_view.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 3a4068360..357eada91 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -321,9 +321,12 @@ def test_extract_clickable_lines(self): when(module).do_git_diff(...).thenReturn(DIFF) cmd = GsDiffCommand(self.window) when(cmd).get_repo_path().thenReturn(REPO_PATH) - cmd.run_async() + cmd.run_async() diff_view = self.window.active_view() + self.addCleanup(diff_view.close) + yield 'AWAIT_WORKER' + actual = diff_view.find_all_results() # `find_all_results` only returns full filename-with-line matches. # These match clicking on `@@ -52,8 +XX,7` lines @@ -342,9 +345,12 @@ def test_result_file_regex(self): when(module).do_git_diff(...).thenReturn(DIFF) cmd = GsDiffCommand(self.window) when(cmd).get_repo_path().thenReturn(REPO_PATH) - cmd.run_async() + cmd.run_async() diff_view = self.window.active_view() + self.addCleanup(diff_view.close) + yield 'AWAIT_WORKER' + BUFFER_CONTENT = diff_view.substr(sublime.Region(0, diff_view.size())) self.assertEqual( BUFFER_CONTENT, From 6e0ed4fed03ba3dbdc652082446b917e0157e51c Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 13:05:01 +0100 Subject: [PATCH 43/65] Ensure we `refresh` once for new diff views --- core/commands/diff.py | 1 - tests/test_diff_view.py | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 594d313f3..e8edb4ca1 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -154,7 +154,6 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba diff_view.set_syntax_file("Packages/GitSavvy/syntax/diff_view.sublime-syntax") diff_views[view_key] = diff_view - refresh(diff_view) diff_view.run_command("gs_handle_vintageous") diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 357eada91..e840dacec 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -5,7 +5,7 @@ import sublime from unittesting import DeferrableTestCase -from GitSavvy.tests.mockito import when, unstub +from GitSavvy.tests.mockito import unstub, verify, when from GitSavvy.tests.parameterized import parameterized as p import GitSavvy.core.commands.diff as module @@ -314,6 +314,22 @@ def tearDown(self): unstub() + def test_create_new_diff_view(self): + # We get two `on_activated_async` events for the first test we run. + yield 'AWAIT_WORKER' + + REPO_PATH = '/not/there' + cmd = GsDiffCommand(self.window) + when(cmd).get_repo_path().thenReturn(REPO_PATH) + when(module).refresh(...) + + cmd.run_async() + diff_view = self.window.active_view() + self.addCleanup(diff_view.close) + yield 'AWAIT_WORKER' + + verify(module, times=1).refresh(diff_view) + def test_extract_clickable_lines(self): REPO_PATH = '/not/there' DIFF = fixture('diff_1.txt') From 1de2cfe18415cd2206efcbb9b1bc1e5056f8106b Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 13:10:50 +0100 Subject: [PATCH 44/65] Ensure we reuse open diff views --- tests/test_diff_view.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index e840dacec..461e9e5ad 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -330,6 +330,28 @@ def test_create_new_diff_view(self): verify(module, times=1).refresh(diff_view) + def test_reuse_open_diff_view(self): + yield 'AWAIT_WORKER' + + REPO_PATH = '/not/there' + cmd = GsDiffCommand(self.window) + when(cmd).get_repo_path().thenReturn(REPO_PATH) + when(module).refresh(...) + + cmd.run_async() + diff_view = self.window.active_view() + self.addCleanup(diff_view.close) + yield 'AWAIT_WORKER' + + self.window.focus_view(self.view) + + cmd.run_async() + diff_view_2 = self.window.active_view() + self.assertEqual(diff_view, diff_view_2) + + yield 'AWAIT_WORKER' + verify(module, times=2).refresh(diff_view) + def test_extract_clickable_lines(self): REPO_PATH = '/not/there' DIFF = fixture('diff_1.txt') From 761be3e1282d6a2c82d013e84f5818a1406af00a Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 14:14:24 +0100 Subject: [PATCH 45/65] Implement dict-like `ViewState` class --- core/commands/diff.py | 154 ++++++++++++++++++++-------------------- tests/test_diff_view.py | 6 +- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index e8edb4ca1..a9983359b 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -4,7 +4,6 @@ """ from contextlib import contextmanager -from functools import partial from itertools import dropwhile, takewhile import os import re @@ -56,42 +55,43 @@ diff_views = {} -# ++ COMMON VIEW STATE HANDLING -COMMON_TOP_KEYS = {'repo_path', 'file_path'} -NOT_SET = object() +class ViewState: + """ + Abstract minimal dict-like class to handle view state. + All state is stored within `view.settings()` as key-value pairs. Due to that + only 'primitive' values are persisted. -def make_key(typ, top_keys, key): - if key in top_keys: - return 'git_savvy.{}'.format(key) + """ + _COMMON_TOP_KEYS = {'repo_path', 'file_path'} + TOP_KEYS = set() + TYPE = '' - return 'git_savvy.{}.{}'.format(typ, key) + def __init__(self, view): + assert self.TYPE + self._view = view + self._top_keys = self._COMMON_TOP_KEYS | self.TOP_KEYS + def _make_key(self, key): + if key in self._top_keys: + return 'git_savvy.{}'.format(key) -def get_view_setting(key_fn, view, key_or_set): - if isinstance(key_or_set, str): - return view.settings().get(key_fn(key_or_set)) - else: - return {k: view.settings().get(key_fn(k)) for k in key_or_set} + return 'git_savvy.{}.{}'.format(self.TYPE, key) + def get(self, key): + return self._view.settings().get(self._make_key(key)) -def set_view_setting(key_fn, view, key_or_mapping, value=NOT_SET): - if value is NOT_SET: - for k, v in key_or_mapping.items(): - view.settings().set(key_fn(k), v) - else: - view.settings().set(key_fn(key_or_mapping), value) - + def set(self, key, val): + self._view.settings().set(self._make_key(key), val) -# ++ SPECIFIC VIEW STATE HANDLING -TOP_KEYS = COMMON_TOP_KEYS | {'disable_diff'} -make_diff_view_key = partial(make_key, 'diff_view', TOP_KEYS) -get_diff_view_state = partial(get_view_setting, make_diff_view_key) -set_diff_view_state = partial(set_view_setting, make_diff_view_key) + def update(self, mapping): + for k, v in mapping.items(): + self.set(k, v) -def state_fns(view): - return partial(get_diff_view_state, view), partial(set_diff_view_state, view) +class DiffViewState(ViewState): + TYPE = 'diff_view' + TOP_KEYS = {'disable_diff'} class GsDiffCommand(WindowCommand, GitCommand): @@ -126,8 +126,8 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba diff_view = util.view.get_scratch_view(self, "diff", read_only=True) - state, set_state = state_fns(diff_view) - set_state({ + state = DiffViewState(diff_view) + state.update({ "repo_path": repo_path, "file_path": file_path, "in_cached_mode": in_cached_mode, @@ -158,19 +158,19 @@ def run_async(self, in_cached_mode=False, file_path=None, current_file=False, ba def prelude_text(view): - state, set_state = state_fns(view) + state = DiffViewState(view) prelude = "\n" - file_path = state('file_path') + file_path = state.get('file_path') if file_path: - repo_path = state('repo_path') + repo_path = state.get('repo_path') rel_file_path = os.path.relpath(file_path, repo_path) prelude += " FILE: {}\n".format(rel_file_path) - in_cached_mode = state('in_cached_mode') - if state('disable_stage'): - base_commit = state('base_commit') - target_commit = state('target_commit') + in_cached_mode = state.get('in_cached_mode') + if state.get('disable_stage'): + base_commit = state.get('base_commit') + target_commit = state.get('target_commit') if in_cached_mode: prelude += " INDEX..{}\n".format(base_commit or target_commit) else: @@ -184,7 +184,7 @@ def prelude_text(view): else: prelude += " UNSTAGED CHANGES\n" - if state('ignore_whitespace'): + if state.get('ignore_whitespace'): prelude += " IGNORING WHITESPACE\n" prelude += "\n--\n" @@ -192,16 +192,16 @@ def prelude_text(view): def do_git_diff(view): - state, set_state = state_fns(view) + state = DiffViewState(view) git = make_git(view) - file_path = state('file_path') - in_cached_mode = state("in_cached_mode") - ignore_whitespace = state("ignore_whitespace") - show_word_diff = state("show_word_diff") - base_commit = state("base_commit") - target_commit = state("target_commit") - show_diffstat = state("show_diffstat") + file_path = state.get('file_path') + in_cached_mode = state.get("in_cached_mode") + ignore_whitespace = state.get("ignore_whitespace") + show_word_diff = state.get("show_word_diff") + base_commit = state.get("base_commit") + target_commit = state.get("target_commit") + show_diffstat = state.get("show_diffstat") try: return git( @@ -227,7 +227,7 @@ def do_git_diff(view): # bills. This is a better, though somewhat cludgy, alternative. # if err.args and type(err.args[0]) == UnicodeDecodeError: - set_state("disable_diff", True) + state.set("disable_diff", True) return raise err @@ -239,9 +239,9 @@ def make_git(view): def refresh(view): - state, set_state = state_fns(view) + state = DiffViewState(view) - if state("disable_diff"): + if state.get("disable_diff"): return try: @@ -252,12 +252,12 @@ def refresh(view): # once the userpresses OK, a new refresh event will be triggered on # the view. Thus, we set a disable flag if err.args and type(err.args[0]) == UnicodeDecodeError: - set_state("disable_diff", True) + state.set("disable_diff", True) return raise err - old_diff = state("raw_diff") - set_state("raw_diff", diff) + old_diff = state.get("raw_diff") + state.set("raw_diff", diff) text = prelude_text(view) + diff view.run_command( @@ -274,17 +274,17 @@ class GsDiffToggleSetting(TextCommand): """ def run(self, edit, setting): - state, set_state = state_fns(self.view) - next_mode = toggle_setting(state, set_state, setting) + state = DiffViewState(self.view) + next_mode = toggle_setting(state, setting) self.view.window().status_message("{} is now {}".format(setting, next_mode)) refresh(self.view) -def toggle_setting(get, set, key): - current = get(key) +def toggle_setting(state, key): + current = state.get(key) next = not current - set(key, next) + state.set(key, next) return next @@ -296,26 +296,26 @@ class GsDiffToggleCachedMode(TextCommand): # NOTE: MUST NOT be async, otherwise `view.show` will not update the view 100%! def run(self, edit): - state, set_state = state_fns(self.view) + state = DiffViewState(self.view) - if state("base_commit") and state("target_commit"): + if state.get("base_commit") and state.get("target_commit"): # There is no cached mode if you diff between two commits, so # we need to abort here return - last_cursors = state('last_cursors') or [] - set_state('last_cursors', pickle_sel(self.view.sel())) + last_cursors = state.get('last_cursors') or [] + state.set('last_cursors', pickle_sel(self.view.sel())) - next_mode = toggle_setting(state, set_state, 'in_cached_mode') + next_mode = toggle_setting(state, 'in_cached_mode') self.view.window().status_message( "Showing {} changes".format("staged" if next_mode else "unstaged") ) refresh(self.view) - just_hunked = state("just_hunked") + just_hunked = state.get("just_hunked") if just_hunked: - set_state("just_hunked", "") + state.set("just_hunked", "") region = find_hunk_in_view(self.view, just_hunked) if region: set_and_show_cursor(self.view, region.a) @@ -443,9 +443,9 @@ class GsDiffStageOrResetHunkCommand(TextCommand, GitCommand): # the main worker queue. def run(self, edit, reset=False): - state, set_state = state_fns(self.view) + state = DiffViewState(self.view) - if state("ignore_whitespace") or state("show_word_diff"): + if state.get("ignore_whitespace") or state.get("show_word_diff"): sublime.error_message("You have to be in a clean diff to stage.") return None @@ -469,8 +469,8 @@ def run(self, edit, reset=False): self.apply_diffs_for_pts(cursor_pts, reset) def apply_diffs_for_pts(self, cursor_pts, reset): - state, set_state = state_fns(self.view) - in_cached_mode = state("in_cached_mode") + state = DiffViewState(self.view) + in_cached_mode = state.get("in_cached_mode") # Apply the diffs in reverse order - otherwise, line number will be off. for pt in reversed(cursor_pts): @@ -505,10 +505,10 @@ def apply_diffs_for_pts(self, cursor_pts, reset): stdin=hunk_diff ) - history = state("history") + history = state.get("history") history.append((args, hunk_diff, pt, in_cached_mode)) - set_state("history", history) - set_state("just_hunked", hunk_diff) + state.set("history", history) + state.set("just_hunked", hunk_diff) refresh(self.view) @@ -581,8 +581,8 @@ def load_file_at_line(self, filename, lineno): Show file at target commit if `git_savvy.diff_view.target_commit` is non-empty. Otherwise, open the file directly. """ - state, set_state = state_fns(self.view) - target_commit = state("target_commit") + state = DiffViewState(self.view) + target_commit = state.get("target_commit") full_path = os.path.join(self.repo_path, filename) if target_commit: self.view.window().run_command("gs_show_file_at_commit", { @@ -618,9 +618,9 @@ class GsDiffUndo(TextCommand, GitCommand): # NOTE: MUST NOT be async, otherwise `view.show` will not update the view 100%! def run(self, edit): - state, set_state = state_fns(self.view) + state = DiffViewState(self.view) - history = state("history") + history = state.get("history") if not history: window = self.view.window() if window: @@ -632,11 +632,11 @@ def run(self, edit): args[1] = "-R" if not args[1] else None self.git(*args, stdin=stdin) - set_state("history", history) - set_state("just_hunked", stdin) + state.set("history", history) + state.set("just_hunked", stdin) refresh(self.view) # The cursor is only applicable if we're still in the same cache/stage mode - if state("in_cached_mode") == in_cached_mode: + if state.get("in_cached_mode") == in_cached_mode: set_and_show_cursor(self.view, cursor) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 461e9e5ad..7fe9bc572 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -273,8 +273,8 @@ def test_hunking_one_hunk(self, CURSOR, HUNK, IN_CACHED_MODE=False): view.run_command('append', {'characters': VIEW_CONTENT}) view.set_scratch(True) - state, set_state = module.state_fns(view) - set_state({'in_cached_mode': IN_CACHED_MODE, 'history': []}) + state = module.DiffViewState(view) + state.update({'in_cached_mode': IN_CACHED_MODE, 'history': []}) cmd = module.GsDiffStageOrResetHunkCommand(view) when(cmd).git(...) when(module).refresh(view) @@ -284,7 +284,7 @@ def test_hunking_one_hunk(self, CURSOR, HUNK, IN_CACHED_MODE=False): cmd.run({'unused_edit'}) - history = state('history') + history = state.get('history') self.assertEqual(len(history), 1) actual = history.pop() From 4cfb8c29aa112e0146dccf546ed4b2a0152e36bb Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 14:15:41 +0100 Subject: [PATCH 46/65] Run unittest on new runner --- unittesting.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/unittesting.json b/unittesting.json index 9bd84a466..36acdc8c1 100644 --- a/unittesting.json +++ b/unittesting.json @@ -1,4 +1,5 @@ { "deferred": true, - "capture_console": true + "capture_console": true, + "legacy_runner": false } From 7b31fe21dcd3093e86e2427f51acf2959b2c6d0c Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 14:20:22 +0100 Subject: [PATCH 47/65] Add comment --- core/commands/diff.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/commands/diff.py b/core/commands/diff.py index a9983359b..9d5651ea3 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -62,6 +62,9 @@ class ViewState: All state is stored within `view.settings()` as key-value pairs. Due to that only 'primitive' values are persisted. + Since setting/getting is not a pure function `__set_item__` and `__get_item__` are + not implemented. Everything else is out-of-scope anyway. + """ _COMMON_TOP_KEYS = {'repo_path', 'file_path'} TOP_KEYS = set() From 8434247f3cc63574fed54c6c31b6c8dc427eb449 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 14:41:05 +0100 Subject: [PATCH 48/65] Do not catch for `disable_diff` twice --- core/commands/diff.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 9d5651ea3..6e047d0fa 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -223,12 +223,7 @@ def do_git_diff(view): # 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. - # + # the view. Thus, we set a disable flag if err.args and type(err.args[0]) == UnicodeDecodeError: state.set("disable_diff", True) return @@ -247,21 +242,12 @@ def refresh(view): if state.get("disable_diff"): return - try: - diff = do_git_diff(view) - 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. Thus, we set a disable flag - if err.args and type(err.args[0]) == UnicodeDecodeError: - state.set("disable_diff", True) - return - raise err + diff = do_git_diff(view) + prelude = prelude_text(view) old_diff = state.get("raw_diff") state.set("raw_diff", diff) - text = prelude_text(view) + diff + text = prelude + diff view.run_command( "gs_replace_view_text", {"text": text, "restore_cursors": True} From d2df9e3bd5934d5182718a94b4b0a3b667007c65 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 14:47:11 +0100 Subject: [PATCH 49/65] Move `toggle_setting` up --- core/commands/diff.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 6e047d0fa..65dfd244f 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -92,6 +92,13 @@ def update(self, mapping): self.set(k, v) +def toggle_setting(state, key): + current = state.get(key) + next = not current + state.set(key, next) + return next + + class DiffViewState(ViewState): TYPE = 'diff_view' TOP_KEYS = {'disable_diff'} @@ -270,13 +277,6 @@ def run(self, edit, setting): refresh(self.view) -def toggle_setting(state, key): - current = state.get(key) - next = not current - state.set(key, next) - return next - - class GsDiffToggleCachedMode(TextCommand): """ From 01cc77c1bf83f28d60c711a1ed2a153e4cfa02c9 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 14:49:09 +0100 Subject: [PATCH 50/65] Extract `replace_setting` function --- core/commands/diff.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 65dfd244f..4f381d80e 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -99,6 +99,12 @@ def toggle_setting(state, key): return next +def replace_setting(state, key, val): + current = state.get(key) + state.set(key, val) + return current + + class DiffViewState(ViewState): TYPE = 'diff_view' TOP_KEYS = {'disable_diff'} @@ -252,8 +258,7 @@ def refresh(view): diff = do_git_diff(view) prelude = prelude_text(view) - old_diff = state.get("raw_diff") - state.set("raw_diff", diff) + old_diff = replace_setting(state, 'raw_diff', diff) text = prelude + diff view.run_command( @@ -292,8 +297,7 @@ def run(self, edit): # we need to abort here return - last_cursors = state.get('last_cursors') or [] - state.set('last_cursors', pickle_sel(self.view.sel())) + last_cursors = replace_setting(state, 'last_cursors', pickle_sel(self.view.sel())) next_mode = toggle_setting(state, 'in_cached_mode') self.view.window().status_message( From 0dc58bf3b1af885001c1f7d18c7986f63f1251f8 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 17:05:13 +0100 Subject: [PATCH 51/65] Allow E731 --- core/commands/diff.py | 2 +- setup.cfg | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 4f381d80e..8eb06a157 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -332,7 +332,7 @@ def find_hunk_in_view(view, hunk): def extract_first_hunk(hunk): hunk_lines = hunk.split('\n') - not_hunk_start = lambda l: not l.startswith('@@ ') # noqa: E731 + not_hunk_start = lambda l: not l.startswith('@@ ') try: start, *rest = dropwhile(not_hunk_start, hunk_lines) diff --git a/setup.cfg b/setup.cfg index affe38a63..81a067464 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [pycodestyle] count = False -ignore = E401, W503, W504 +ignore = E401, E731, W503, W504 max-line-length = 120 exclude = tests/mockito, @@ -11,6 +11,7 @@ exclude = max-line-length = 120 ignore = E401, + E731, D, W503, W504 From d9df7a0eadff4505978bc5f68eeb64592433f9f2 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 6 Jan 2019 17:17:35 +0100 Subject: [PATCH 52/65] Expect failures on Linux Travis On Travis/Linux, `on_activated_async` events do not run so we currently expect failures here. --- tests/test_diff_view.py | 42 +++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 7fe9bc572..b5facb7e7 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -1,10 +1,13 @@ +from functools import wraps from itertools import chain, repeat import os import re +import sys +from unittest.case import _ExpectedFailure, _UnexpectedSuccess import sublime -from unittesting import DeferrableTestCase +from unittesting import DeferrableTestCase, AWAIT_WORKER from GitSavvy.tests.mockito import unstub, verify, when from GitSavvy.tests.parameterized import parameterized as p @@ -12,7 +15,26 @@ from GitSavvy.core.commands.diff import GsDiffCommand +def isiterable(obj): + return hasattr(obj, '__iter__') + + +def expectedFailure(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + deferred = func(*args, **kwargs) + if isiterable(deferred): + yield from deferred + except Exception: + raise _ExpectedFailure(sys.exc_info()) + raise _UnexpectedSuccess + return wrapper + + THIS_DIRNAME = os.path.dirname(os.path.realpath(__file__)) +RUNNING_ON_LINUX_TRAVIS = os.environ.get('TRAVIS_OS_NAME') == 'linux' +expectedFailureOnLinuxTravis = expectedFailure if RUNNING_ON_LINUX_TRAVIS else lambda f: f def fixture(name): @@ -314,9 +336,10 @@ def tearDown(self): unstub() + @expectedFailureOnLinuxTravis def test_create_new_diff_view(self): # We get two `on_activated_async` events for the first test we run. - yield 'AWAIT_WORKER' + yield AWAIT_WORKER REPO_PATH = '/not/there' cmd = GsDiffCommand(self.window) @@ -326,12 +349,13 @@ def test_create_new_diff_view(self): cmd.run_async() diff_view = self.window.active_view() self.addCleanup(diff_view.close) - yield 'AWAIT_WORKER' + yield AWAIT_WORKER verify(module, times=1).refresh(diff_view) + @expectedFailureOnLinuxTravis def test_reuse_open_diff_view(self): - yield 'AWAIT_WORKER' + yield AWAIT_WORKER REPO_PATH = '/not/there' cmd = GsDiffCommand(self.window) @@ -341,7 +365,7 @@ def test_reuse_open_diff_view(self): cmd.run_async() diff_view = self.window.active_view() self.addCleanup(diff_view.close) - yield 'AWAIT_WORKER' + yield AWAIT_WORKER self.window.focus_view(self.view) @@ -349,9 +373,10 @@ def test_reuse_open_diff_view(self): diff_view_2 = self.window.active_view() self.assertEqual(diff_view, diff_view_2) - yield 'AWAIT_WORKER' + yield AWAIT_WORKER verify(module, times=2).refresh(diff_view) + @expectedFailureOnLinuxTravis def test_extract_clickable_lines(self): REPO_PATH = '/not/there' DIFF = fixture('diff_1.txt') @@ -363,7 +388,7 @@ def test_extract_clickable_lines(self): cmd.run_async() diff_view = self.window.active_view() self.addCleanup(diff_view.close) - yield 'AWAIT_WORKER' + yield AWAIT_WORKER actual = diff_view.find_all_results() # `find_all_results` only returns full filename-with-line matches. @@ -376,6 +401,7 @@ def test_extract_clickable_lines(self): self.assertEqual(actual, expected) + @expectedFailureOnLinuxTravis def test_result_file_regex(self): REPO_PATH = '/not/there' DIFF = fixture('diff_1.txt') @@ -387,7 +413,7 @@ def test_result_file_regex(self): cmd.run_async() diff_view = self.window.active_view() self.addCleanup(diff_view.close) - yield 'AWAIT_WORKER' + yield AWAIT_WORKER BUFFER_CONTENT = diff_view.substr(sublime.Region(0, diff_view.size())) self.assertEqual( From 494ae9b2d9b55ac392f7e67ffd2ca810130afbc7 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Mon, 7 Jan 2019 10:39:20 +0100 Subject: [PATCH 53/65] Make main `GsDiffCommand` a sync command --- core/commands/diff.py | 7 ++----- tests/test_diff_view.py | 10 +++++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 8eb06a157..8892072f8 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -120,11 +120,8 @@ class GsDiffCommand(WindowCommand, GitCommand): disable Ctrl-Enter in the diff view. """ - def run(self, **kwargs): - sublime.set_timeout_async(lambda: self.run_async(**kwargs), 0) - - def run_async(self, in_cached_mode=False, file_path=None, current_file=False, base_commit=None, - target_commit=None, disable_stage=False, title=None): + def run(self, in_cached_mode=False, file_path=None, current_file=False, base_commit=None, + target_commit=None, disable_stage=False, title=None): repo_path = self.repo_path if current_file: file_path = self.file_path or file_path diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index b5facb7e7..cf5d0c4ac 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -346,7 +346,7 @@ def test_create_new_diff_view(self): when(cmd).get_repo_path().thenReturn(REPO_PATH) when(module).refresh(...) - cmd.run_async() + cmd.run() diff_view = self.window.active_view() self.addCleanup(diff_view.close) yield AWAIT_WORKER @@ -362,14 +362,14 @@ def test_reuse_open_diff_view(self): when(cmd).get_repo_path().thenReturn(REPO_PATH) when(module).refresh(...) - cmd.run_async() + cmd.run() diff_view = self.window.active_view() self.addCleanup(diff_view.close) yield AWAIT_WORKER self.window.focus_view(self.view) - cmd.run_async() + cmd.run() diff_view_2 = self.window.active_view() self.assertEqual(diff_view, diff_view_2) @@ -385,7 +385,7 @@ def test_extract_clickable_lines(self): cmd = GsDiffCommand(self.window) when(cmd).get_repo_path().thenReturn(REPO_PATH) - cmd.run_async() + cmd.run() diff_view = self.window.active_view() self.addCleanup(diff_view.close) yield AWAIT_WORKER @@ -410,7 +410,7 @@ def test_result_file_regex(self): cmd = GsDiffCommand(self.window) when(cmd).get_repo_path().thenReturn(REPO_PATH) - cmd.run_async() + cmd.run() diff_view = self.window.active_view() self.addCleanup(diff_view.close) yield AWAIT_WORKER From 0667dbb3cdf99af3529e4acf18313722e675c122 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Mon, 7 Jan 2019 10:58:29 +0100 Subject: [PATCH 54/65] Do not create `new_file` per test --- tests/test_diff_view.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index cf5d0c4ac..701790998 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -326,14 +326,7 @@ def setUpClass(cls): def tearDownClass(self): self.window.run_command('close_window') - def setUp(self): - self.view = self.window.new_file() - def tearDown(self): - if self.view: - self.view.set_scratch(True) - self.view.close() - unstub() @expectedFailureOnLinuxTravis @@ -355,6 +348,9 @@ def test_create_new_diff_view(self): @expectedFailureOnLinuxTravis def test_reuse_open_diff_view(self): + view = self.window.new_file() + self.addCleanup(view.close) + view.set_scratch(True) yield AWAIT_WORKER REPO_PATH = '/not/there' @@ -367,7 +363,7 @@ def test_reuse_open_diff_view(self): self.addCleanup(diff_view.close) yield AWAIT_WORKER - self.window.focus_view(self.view) + self.window.focus_view(view) cmd.run() diff_view_2 = self.window.active_view() From 5f96c65e11687ee25a87a3a13631c25c1ab2a35b Mon Sep 17 00:00:00 2001 From: herr kaste Date: Mon, 7 Jan 2019 11:19:34 +0100 Subject: [PATCH 55/65] Use Sublime API to run the command --- tests/test_diff_view.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 701790998..f67e28123 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -335,14 +335,13 @@ def test_create_new_diff_view(self): yield AWAIT_WORKER REPO_PATH = '/not/there' - cmd = GsDiffCommand(self.window) - when(cmd).get_repo_path().thenReturn(REPO_PATH) + when(GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) when(module).refresh(...) - cmd.run() + self.window.run_command('gs_diff') + yield AWAIT_WORKER diff_view = self.window.active_view() self.addCleanup(diff_view.close) - yield AWAIT_WORKER verify(module, times=1).refresh(diff_view) @@ -354,22 +353,21 @@ def test_reuse_open_diff_view(self): yield AWAIT_WORKER REPO_PATH = '/not/there' - cmd = GsDiffCommand(self.window) - when(cmd).get_repo_path().thenReturn(REPO_PATH) + when(GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) when(module).refresh(...) - cmd.run() + self.window.run_command('gs_diff') + yield AWAIT_WORKER diff_view = self.window.active_view() self.addCleanup(diff_view.close) - yield AWAIT_WORKER self.window.focus_view(view) - cmd.run() + self.window.run_command('gs_diff') + yield AWAIT_WORKER diff_view_2 = self.window.active_view() self.assertEqual(diff_view, diff_view_2) - yield AWAIT_WORKER verify(module, times=2).refresh(diff_view) @expectedFailureOnLinuxTravis @@ -378,13 +376,12 @@ def test_extract_clickable_lines(self): DIFF = fixture('diff_1.txt') when(module).do_git_diff(...).thenReturn(DIFF) - cmd = GsDiffCommand(self.window) - when(cmd).get_repo_path().thenReturn(REPO_PATH) + when(GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) - cmd.run() + self.window.run_command('gs_diff') + yield AWAIT_WORKER diff_view = self.window.active_view() self.addCleanup(diff_view.close) - yield AWAIT_WORKER actual = diff_view.find_all_results() # `find_all_results` only returns full filename-with-line matches. @@ -403,13 +400,12 @@ def test_result_file_regex(self): DIFF = fixture('diff_1.txt') when(module).do_git_diff(...).thenReturn(DIFF) - cmd = GsDiffCommand(self.window) - when(cmd).get_repo_path().thenReturn(REPO_PATH) + when(GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) - cmd.run() + self.window.run_command('gs_diff') + yield AWAIT_WORKER diff_view = self.window.active_view() self.addCleanup(diff_view.close) - yield AWAIT_WORKER BUFFER_CONTENT = diff_view.substr(sublime.Region(0, diff_view.size())) self.assertEqual( From 8ebc8c35782237da74fa66ab847aed636275511b Mon Sep 17 00:00:00 2001 From: herr kaste Date: Tue, 8 Jan 2019 15:26:53 +0100 Subject: [PATCH 56/65] Extract `create_diff_view` --- common/util/view.py | 16 +++++++--- core/commands/diff.py | 70 ++++++++++++++++++++++++++++------------- tests/test_diff_view.py | 33 +++++++++++-------- 3 files changed, 80 insertions(+), 39 deletions(-) diff --git a/common/util/view.py b/common/util/view.py index 30b4d6304..3fe4966da 100644 --- a/common/util/view.py +++ b/common/util/view.py @@ -36,18 +36,26 @@ def decorated_run(self, *args, **kwargs): # NEW-VIEW HELPER FUNCTIONS # ############################# -def get_scratch_view(context, name, read_only=True): +def create_scratch_view(window, name, read_only=True): """ - Create and return a read-only view. + Create and return a scratch view. """ - window = context.window if hasattr(context, "window") else context.view.window() view = window.new_file() - view.settings().set("git_savvy.{}_view".format(name), True) + view.settings().set("git_savvy.{}".format(name), True) view.set_scratch(True) view.set_read_only(read_only) return view +def get_scratch_view(context, name, read_only=True): + """ + Create and return a scratch view. + Given `name` gets expanded to `{name}_view`. + """ + window = context.window if hasattr(context, "window") else context.view.window() + return create_scratch_view(window, "{}_view".format(name), read_only=read_only) + + def get_is_view_of_type(view, typ): """ Determine if view is of specified type. diff --git a/core/commands/diff.py b/core/commands/diff.py index 8892072f8..3bd6f900f 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -3,6 +3,7 @@ current diff. """ +from collections import ChainMap from contextlib import contextmanager from itertools import dropwhile, takewhile import os @@ -110,6 +111,18 @@ class DiffViewState(ViewState): TOP_KEYS = {'disable_diff'} +DEFAULT_SETTINGS = dict( + in_cached_mode=False, + file_path=None, + base_commit=None, + target_commit=None, + disable_stage=False, + show_diffstat=None, + ignore_whitespace=False, + show_word_diff=False, +) + + class GsDiffCommand(WindowCommand, GitCommand): """ @@ -120,16 +133,20 @@ class GsDiffCommand(WindowCommand, GitCommand): disable Ctrl-Enter in the diff view. """ - def run(self, in_cached_mode=False, file_path=None, current_file=False, base_commit=None, - target_commit=None, disable_stage=False, title=None): - repo_path = self.repo_path + def run(self, current_file=False, title=None, **kwargs): + + try: + repo_path = kwargs.pop("repo_path") + except KeyError: + repo_path = self.repo_path + if current_file: - file_path = self.file_path or file_path + kwargs.setdefault("file_path", self.file_path) view_key = "{0}{1}+{2}".format( - in_cached_mode, - "-" if base_commit is None else "--" + base_commit, - file_path or repo_path + kwargs.get("in_cached_mode"), + kwargs.get("base_commit") or "-", + kwargs.get("file_path") or repo_path ) if view_key in diff_views and diff_views[view_key] in sublime.active_window().views(): @@ -137,22 +154,28 @@ def run(self, in_cached_mode=False, file_path=None, current_file=False, base_com self.window.focus_view(diff_view) return - diff_view = util.view.get_scratch_view(self, "diff", read_only=True) + if kwargs.get("show_diffstat") is None: + kwargs["show_diffstat"] = self.savvy_settings.get("show_diffstat", True) + + diff_view = create_diff_view(self.window, repo_path, title=title, **kwargs) + diff_views[view_key] = diff_view + + +def create_diff_view(window, repo_path, title=None, **kwargs): + diff_view = util.view.create_scratch_view(window, "diff_view") + + options = ChainMap( + { + "repo_path": repo_path, + "history": [], + "just_hunked": "", + }, + kwargs, + DEFAULT_SETTINGS + ) state = DiffViewState(diff_view) - state.update({ - "repo_path": repo_path, - "file_path": file_path, - "in_cached_mode": in_cached_mode, - "ignore_whitespace": False, - "show_word_diff": False, - "base_commit": base_commit, - "target_commit": target_commit, - "show_diffstat": self.savvy_settings.get("show_diffstat", True), - "disable_stage": disable_stage, - "history": [], - "just_hunked": "", - }) + state.update(options) settings = diff_view.settings() settings.set("result_file_regex", CLICKABLE_FILES_RE) @@ -160,15 +183,18 @@ def run(self, in_cached_mode=False, file_path=None, current_file=False, base_com settings.set("result_base_dir", repo_path) if not title: + in_cached_mode = options.get("in_cached_mode") + file_path = options.get("file_path") 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_views[view_key] = diff_view diff_view.run_command("gs_handle_vintageous") + return diff_view + def prelude_text(view): state = DiffViewState(view) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index f67e28123..d2689e000 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -329,16 +329,28 @@ def tearDownClass(self): def tearDown(self): unstub() + def test_finds_repo_path_if_not_given(self): + REPO_PATH = '/not/there' + when(module.GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) + when(module).refresh(...) + + self.window.run_command('gs_diff') + yield AWAIT_WORKER + diff_view = self.window.active_view() + self.addCleanup(diff_view.close) + + state = module.DiffViewState(diff_view) + self.assertEqual(state.get('repo_path'), REPO_PATH) + @expectedFailureOnLinuxTravis def test_create_new_diff_view(self): # We get two `on_activated_async` events for the first test we run. yield AWAIT_WORKER REPO_PATH = '/not/there' - when(GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) when(module).refresh(...) - self.window.run_command('gs_diff') + self.window.run_command('gs_diff', {'repo_path': REPO_PATH}) yield AWAIT_WORKER diff_view = self.window.active_view() self.addCleanup(diff_view.close) @@ -353,17 +365,16 @@ def test_reuse_open_diff_view(self): yield AWAIT_WORKER REPO_PATH = '/not/there' - when(GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) when(module).refresh(...) - self.window.run_command('gs_diff') + self.window.run_command('gs_diff', {'repo_path': REPO_PATH}) yield AWAIT_WORKER diff_view = self.window.active_view() self.addCleanup(diff_view.close) self.window.focus_view(view) - self.window.run_command('gs_diff') + self.window.run_command('gs_diff', {'repo_path': REPO_PATH}) yield AWAIT_WORKER diff_view_2 = self.window.active_view() self.assertEqual(diff_view, diff_view_2) @@ -376,12 +387,10 @@ def test_extract_clickable_lines(self): DIFF = fixture('diff_1.txt') when(module).do_git_diff(...).thenReturn(DIFF) - when(GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) - self.window.run_command('gs_diff') - yield AWAIT_WORKER - diff_view = self.window.active_view() + diff_view = module.create_diff_view(self.window, REPO_PATH) self.addCleanup(diff_view.close) + yield AWAIT_WORKER actual = diff_view.find_all_results() # `find_all_results` only returns full filename-with-line matches. @@ -400,12 +409,10 @@ def test_result_file_regex(self): DIFF = fixture('diff_1.txt') when(module).do_git_diff(...).thenReturn(DIFF) - when(GsDiffCommand).get_repo_path().thenReturn(REPO_PATH) - self.window.run_command('gs_diff') - yield AWAIT_WORKER - diff_view = self.window.active_view() + diff_view = module.create_diff_view(self.window, REPO_PATH) self.addCleanup(diff_view.close) + yield AWAIT_WORKER BUFFER_CONTENT = diff_view.substr(sublime.Region(0, diff_view.size())) self.assertEqual( From cec97f198a92624eef300da5d8d06a6c767bb2fe Mon Sep 17 00:00:00 2001 From: herr kaste Date: Tue, 8 Jan 2019 21:22:37 +0100 Subject: [PATCH 57/65] If providing `file_path`, `current_file` is a noop --- core/commands/log.py | 1 - core/interfaces/status.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/core/commands/log.py b/core/commands/log.py index dc486713f..a297229fb 100644 --- a/core/commands/log.py +++ b/core/commands/log.py @@ -200,7 +200,6 @@ def _diff_commit(self, cache=False): self.window.run_command("gs_diff", { "in_cached_mode": cache, "file_path": self._file_path, - "current_file": bool(self._file_path), "base_commit": self._commit_hash, "disable_stage": True }) diff --git a/core/interfaces/status.py b/core/interfaces/status.py index b5368c9dc..8a6515bdf 100644 --- a/core/interfaces/status.py +++ b/core/interfaces/status.py @@ -495,14 +495,12 @@ def load_diff_windows(self, non_cached_files, cached_files): for fpath in non_cached_files: self.view.window().run_command("gs_diff", { "file_path": fpath, - "current_file": True }) for fpath in cached_files: self.view.window().run_command("gs_diff", { "file_path": fpath, "in_cached_mode": True, - "current_file": True }) From e7a88e4fbca4a9ff8de59396462c9b9c85a0720e Mon Sep 17 00:00:00 2001 From: herr kaste Date: Tue, 8 Jan 2019 21:28:57 +0100 Subject: [PATCH 58/65] `disable_stage` can be inferred --- core/commands/commit_compare.py | 2 -- core/commands/diff.py | 3 +++ core/commands/log.py | 1 - core/interfaces/branch.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/core/commands/commit_compare.py b/core/commands/commit_compare.py index 1b01055b1..c74e50a0f 100644 --- a/core/commands/commit_compare.py +++ b/core/commands/commit_compare.py @@ -101,7 +101,6 @@ def run_async(self): "base_commit": target_commit, "target_commit": base_commit, "file_path": file_path, - "disable_stage": True, "title": "DIFF: {}..{}".format(target_commit, base_commit) }) else: @@ -109,7 +108,6 @@ def run_async(self): "base_commit": base_commit, "target_commit": target_commit, "file_path": file_path, - "disable_stage": True, "title": "DIFF: {}..{}".format(base_commit, target_commit) }) diff --git a/core/commands/diff.py b/core/commands/diff.py index 3bd6f900f..0c0b40377 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -154,6 +154,9 @@ def run(self, current_file=False, title=None, **kwargs): self.window.focus_view(diff_view) return + if kwargs.get("disable_stage") is None: + kwargs["disable_stage"] = bool(kwargs.get("base_commit") or kwargs.get("target_commit")) + if kwargs.get("show_diffstat") is None: kwargs["show_diffstat"] = self.savvy_settings.get("show_diffstat", True) diff --git a/core/commands/log.py b/core/commands/log.py index a297229fb..e59d86643 100644 --- a/core/commands/log.py +++ b/core/commands/log.py @@ -201,7 +201,6 @@ def _diff_commit(self, cache=False): "in_cached_mode": cache, "file_path": self._file_path, "base_commit": self._commit_hash, - "disable_stage": True }) def diff_commit(self): diff --git a/core/interfaces/branch.py b/core/interfaces/branch.py index 952101372..99fe8ff40 100644 --- a/core/interfaces/branch.py +++ b/core/interfaces/branch.py @@ -480,7 +480,6 @@ def show_diff(self, branch_name, remote=None): self.view.window().run_command("gs_diff", { "base_commit": comparison_branch_name, "target_commit": active_branch_name, - "disable_stage": True, "title": "DIFF: {}..{}".format(comparison_branch_name, active_branch_name) }) From afb02e931312cb49cb5094a46ec4209e478255e3 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 9 Jan 2019 13:46:31 +0100 Subject: [PATCH 59/65] Show `base..target` in prelude --- core/commands/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 0c0b40377..b6a14e4f9 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -217,7 +217,7 @@ def prelude_text(view): prelude += " INDEX..{}\n".format(base_commit or target_commit) else: if base_commit and target_commit: - prelude += " {}..{}\n".format(target_commit, base_commit) + prelude += " {}..{}\n".format(base_commit, target_commit) else: prelude += " WORKING DIR..{}\n".format(base_commit or target_commit) else: From 86fef7e47a83d3bcdb25614029fa3f6a42eeb187 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 9 Jan 2019 13:50:24 +0100 Subject: [PATCH 60/65] Simplify prelude generation --- core/commands/diff.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index b6a14e4f9..6a29a761f 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -210,16 +210,13 @@ def prelude_text(view): prelude += " FILE: {}\n".format(rel_file_path) in_cached_mode = state.get('in_cached_mode') - if state.get('disable_stage'): - base_commit = state.get('base_commit') - target_commit = state.get('target_commit') - if in_cached_mode: - prelude += " INDEX..{}\n".format(base_commit or target_commit) - else: - if base_commit and target_commit: - prelude += " {}..{}\n".format(base_commit, target_commit) - else: - prelude += " WORKING DIR..{}\n".format(base_commit or target_commit) + base_commit = state.get('base_commit') + target_commit = state.get('target_commit') + if base_commit and target_commit: + prelude += " {}..{}\n".format(base_commit, target_commit) + elif base_commit or target_commit: + from_commit = "INDEX" if in_cached_mode else "WORKING DIR" + prelude += " {}..{}\n".format(from_commit, base_commit or target_commit) else: if in_cached_mode: prelude += " STAGED CHANGES (Will commit)\n" From b2ddbdac8f9474b863d9b4ef5d94ea60d9d2c6b0 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 9 Jan 2019 13:53:25 +0100 Subject: [PATCH 61/65] Simplify `do_git_diff` --- core/commands/diff.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 6a29a761f..29b09da8e 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -234,26 +234,18 @@ def do_git_diff(view): state = DiffViewState(view) git = make_git(view) - file_path = state.get('file_path') - in_cached_mode = state.get("in_cached_mode") - ignore_whitespace = state.get("ignore_whitespace") - show_word_diff = state.get("show_word_diff") - base_commit = state.get("base_commit") - target_commit = state.get("target_commit") - show_diffstat = state.get("show_diffstat") - try: return git( "diff", - "--ignore-all-space" if ignore_whitespace else None, - "--word-diff" if show_word_diff else None, - "--stat" if show_diffstat else None, + "--ignore-all-space" if state.get("ignore_whitespace") else None, + "--word-diff" if state.get("show_word_diff") else None, + "--stat" if state.get("show_diffstat") else None, "--patch", "--no-color", - "--cached" if in_cached_mode else None, - base_commit, - target_commit, - "--", file_path + "--cached" if state.get("in_cached_mode") else None, + state.get("base_commit"), + state.get("target_commit"), + "--", state.get('file_path') ) except GitSavvyError as err: # When the output of the above Git command fails to correctly parse, From dd644c4e9342ace03becc9844057393b75483ae7 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 9 Jan 2019 14:48:21 +0100 Subject: [PATCH 62/65] Reduce hunking tests a bit --- tests/test_diff_view.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index d2689e000..c730bc49b 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -1,5 +1,4 @@ from functools import wraps -from itertools import chain, repeat import os import re import sys @@ -264,13 +263,19 @@ def tearDown(self): two """ - @p.expand(chain( - zip(range(58, 89), repeat(HUNK1)), - zip(range(136, 170), repeat(HUNK2)), - [ - (170, HUNK2), # at EOF should work - ] - )) + @p.expand([ + (58, HUNK1), + (68, HUNK1), + (79, HUNK1), + (84, HUNK1), + (88, HUNK1), + (136, HUNK2), + (146, HUNK2), + (156, HUNK2), + (166, HUNK2), + (169, HUNK2), + (170, HUNK2), # at EOF should work + ]) def test_hunking_one_hunk(self, CURSOR, HUNK, IN_CACHED_MODE=False): # Docstring here to get verbose parameterized printing """""" From 828bb433bc7da806c18eefd42140e7d13e742eff Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 9 Jan 2019 14:49:19 +0100 Subject: [PATCH 63/65] Implement efficient multi hunking --- core/commands/diff.py | 92 ++++++++++++++++++++++------------------- tests/test_diff_view.py | 78 +++++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 43 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index 29b09da8e..f04f498af 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -3,7 +3,7 @@ current diff. """ -from collections import ChainMap +from collections import ChainMap, defaultdict from contextlib import contextmanager from itertools import dropwhile, takewhile import os @@ -474,49 +474,57 @@ def run(self, edit, reset=False): set((self.view.size() + 1, )) )) - self.apply_diffs_for_pts(cursor_pts, reset) + patches = [p for p in (self.get_hunk_diff(pt) for pt in cursor_pts) if p] + grouped_by_head = defaultdict(list) + for head, content in patches: + if content not in grouped_by_head[head]: + grouped_by_head[head].append(content) - def apply_diffs_for_pts(self, cursor_pts, reset): + flat = [] + for head, contents in grouped_by_head.items(): + flat.append(head) + flat.extend(contents) + + patch = ''.join(flat) + + if patch: + self.apply_patch(patch, cursor_pts, reset) + + def apply_patch(self, patch, pts, reset): state = DiffViewState(self.view) in_cached_mode = state.get("in_cached_mode") - # Apply the diffs in reverse order - otherwise, line number will be off. - for pt in reversed(cursor_pts): - hunk_diff = self.get_hunk_diff(pt) - if not hunk_diff: - return - - # 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, - "-", - ) - self.git( - *args, - stdin=hunk_diff - ) + # 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, + "-", + ) + self.git( + *args, + stdin=patch + ) - history = state.get("history") - history.append((args, hunk_diff, pt, in_cached_mode)) - state.set("history", history) - state.set("just_hunked", hunk_diff) + history = state.get("history") + history.append((args, patch, pts, in_cached_mode)) + state.set("history", history) + state.set("just_hunked", patch) refresh(self.view) @@ -544,7 +552,7 @@ def get_hunk_diff(self, pt): header = self.view.substr(sublime.Region(header_start, header_end)) diff = self.view.substr(sublime.Region(hunk_start, hunk_end)) - return header + diff + return header, diff class GsDiffOpenFileAtHunkCommand(TextCommand, GitCommand): @@ -635,7 +643,7 @@ def run(self, edit): window.status_message("Undo stack is empty") return - args, stdin, cursor, in_cached_mode = history.pop() + args, stdin, cursors, in_cached_mode = history.pop() # Toggle the `--reverse` flag. args[1] = "-R" if not args[1] else None @@ -647,4 +655,4 @@ def run(self, edit): # The cursor is only applicable if we're still in the same cache/stage mode if state.get("in_cached_mode") == in_cached_mode: - set_and_show_cursor(self.view, cursor) + set_and_show_cursor(self.view, cursors) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index c730bc49b..06a6c22e4 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -315,7 +315,83 @@ def test_hunking_one_hunk(self, CURSOR, HUNK, IN_CACHED_MODE=False): self.assertEqual(len(history), 1) actual = history.pop() - expected = [['apply', None, '--cached', '-'], HUNK, CURSOR, IN_CACHED_MODE] + expected = [['apply', None, '--cached', '-'], HUNK, [CURSOR], IN_CACHED_MODE] + self.assertEqual(actual, expected) + + HUNK3 = """\ +diff --git a/fooz b/barz +--- a/fooz ++++ b/barz +@@ -16,1 +16,1 @@ Hi + one + two +@@ -20,1 +20,1 @@ Ho + three + four +""" + + HUNK4 = """\ +diff --git a/fooz b/barz +--- a/fooz ++++ b/barz +@@ -20,1 +20,1 @@ Ho + three + four +diff --git a/foxx b/boxx +--- a/foox ++++ b/boox +@@ -16,1 +16,1 @@ Hello + one + two +""" + + @p.expand([ + ([58, 79], HUNK1), + ([58, 79, 84], HUNK1), + ([58, 89], HUNK3), + ([89, 170], HUNK4), + ]) + def test_hunking_two_hunks(self, CURSORS, PATCH, IN_CACHED_MODE=False): + VIEW_CONTENT = """\ +prelude +-- +diff --git a/fooz b/barz +--- a/fooz ++++ b/barz +@@ -16,1 +16,1 @@ Hi + one + two +@@ -20,1 +20,1 @@ Ho + three + four +diff --git a/foxx b/boxx +--- a/foox ++++ b/boox +@@ -16,1 +16,1 @@ Hello + one + two +""" + view = self.window.new_file() + self.addCleanup(view.close) + view.run_command('append', {'characters': VIEW_CONTENT}) + view.set_scratch(True) + + state = module.DiffViewState(view) + state.update({'in_cached_mode': IN_CACHED_MODE, 'history': []}) + when(module.GsDiffStageOrResetHunkCommand).git(...) + when(module).refresh(view) + + view.sel().clear() + for c in CURSORS: + view.sel().add(c) + + view.run_command('gs_diff_stage_or_reset_hunk') + + history = state.get('history') + self.assertEqual(len(history), 1) + + actual = history.pop() + expected = [['apply', None, '--cached', '-'], PATCH, CURSORS, IN_CACHED_MODE] self.assertEqual(actual, expected) From f643e600ca226eba044f5f7022315068c7f6cbd2 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 9 Jan 2019 14:51:13 +0100 Subject: [PATCH 64/65] Use `run_command` in the test instead of direct invocation --- tests/test_diff_view.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 06a6c22e4..6a2581e76 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -302,14 +302,13 @@ def test_hunking_one_hunk(self, CURSOR, HUNK, IN_CACHED_MODE=False): state = module.DiffViewState(view) state.update({'in_cached_mode': IN_CACHED_MODE, 'history': []}) - cmd = module.GsDiffStageOrResetHunkCommand(view) - when(cmd).git(...) + when(module.GsDiffStageOrResetHunkCommand).git(...) when(module).refresh(view) view.sel().clear() view.sel().add(CURSOR) - cmd.run({'unused_edit'}) + view.run_command('gs_diff_stage_or_reset_hunk') history = state.get('history') self.assertEqual(len(history), 1) From ac1a55bf9a6113b7368909299fe736a3881d21b6 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Thu, 10 Jan 2019 13:42:39 +0100 Subject: [PATCH 65/65] Simplify patch generation and ensure silent None's --- core/commands/diff.py | 33 ++++++++++++++++++--------------- tests/test_diff_view.py | 7 +++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/core/commands/diff.py b/core/commands/diff.py index f04f498af..163fcb7a7 100644 --- a/core/commands/diff.py +++ b/core/commands/diff.py @@ -3,9 +3,10 @@ current diff. """ -from collections import ChainMap, defaultdict +from collections import ChainMap from contextlib import contextmanager -from itertools import dropwhile, takewhile +from functools import partial +from itertools import chain, dropwhile, takewhile import os import re import bisect @@ -391,6 +392,15 @@ def unpickle_sel(pickled_sel): return [sublime.Region(a, b) for a, b in pickled_sel] +def unique(items): + """Remove duplicate entries but remain sorted/ordered.""" + rv = [] + 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() @@ -474,18 +484,11 @@ def run(self, edit, reset=False): set((self.view.size() + 1, )) )) - patches = [p for p in (self.get_hunk_diff(pt) for pt in cursor_pts) if p] - grouped_by_head = defaultdict(list) - for head, content in patches: - if content not in grouped_by_head[head]: - grouped_by_head[head].append(content) - - flat = [] - for head, contents in grouped_by_head.items(): - flat.append(head) - flat.extend(contents) + filter_ = partial(filter, None) + flat = chain.from_iterable - patch = ''.join(flat) + patches = unique(flat(filter_(self.head_and_hunk_for_pt(pt) for pt in cursor_pts))) + patch = ''.join(patches) if patch: self.apply_patch(patch, cursor_pts, reset) @@ -528,7 +531,7 @@ def apply_patch(self, patch, pts, reset): refresh(self.view) - def get_hunk_diff(self, pt): + def head_and_hunk_for_pt(self, pt): """ Given a cursor position, find and return the diff header and the diff for the selected hunk/file. @@ -541,7 +544,7 @@ def get_hunk_diff(self, pt): window = self.view.window() if window: window.status_message('Not within a hunk') - return # Error! + return None # Error! header_start, header_end = max( (header_start, header_end) diff --git a/tests/test_diff_view.py b/tests/test_diff_view.py index 6a2581e76..54e3d404a 100644 --- a/tests/test_diff_view.py +++ b/tests/test_diff_view.py @@ -345,10 +345,17 @@ def test_hunking_one_hunk(self, CURSOR, HUNK, IN_CACHED_MODE=False): """ @p.expand([ + # De-duplicate cursors in the same hunk ([58, 79], HUNK1), ([58, 79, 84], HUNK1), + # Combine hunks ([58, 89], HUNK3), ([89, 170], HUNK4), + + # Ignore cursors not in a hunk + ([2, 11, 58, 79], HUNK1), + ([58, 89, 123], HUNK3), + ([11, 89, 123, 170], HUNK4), ]) def test_hunking_two_hunks(self, CURSORS, PATCH, IN_CACHED_MODE=False): VIEW_CONTENT = """\