diff --git a/git_gutter.py b/git_gutter.py index 9557b7c5..53dc82be 100644 --- a/git_gutter.py +++ b/git_gutter.py @@ -25,6 +25,8 @@ class GitGutterCommand(sublime_plugin.WindowCommand): 'deleted_dual', 'inserted', 'changed', 'untracked', 'ignored'] + qualifiers = ['staged','unstaged','staged_unstaged'] + def run(self, force_refresh=False): self.view = self.window.active_view() if not self.view: @@ -37,18 +39,94 @@ def run(self, force_refresh=False): elif ViewCollection.ignored(self.view): self.bind_files('ignored') else: - # If the file is untracked there is no need to execute the diff - # update if force_refresh: - ViewCollection.clear_git_time(self.view) - inserted, modified, deleted = ViewCollection.diff(self.view) - self.lines_removed(deleted) - self.bind_icons('inserted', inserted) - self.bind_icons('changed', modified) + ViewCollection.clear_times(self.view) + + staged = ViewCollection.has_stages(self.view) + if staged: + # Mark changes qualified with staged/unstaged/staged_unstaged + + # Get unstaged changes + u_inserted, u_modified, u_deleted = self.unstaged_changes() + + # Get staged changes + s_inserted, s_modified, s_deleted = self.staged_changes() + + # Find lines with a mix of staged/unstaged + m_modified = self.mixed_mofified(u_modified, + [s_inserted, s_modified, s_deleted]) + m_inserted = self.mixed_mofified(u_inserted, + [s_inserted, s_modified, s_deleted]) + m_deleted = self.mixed_mofified(u_deleted, + [s_inserted, s_modified, s_deleted]) + + m_all = m_inserted + m_modified + m_deleted + + # Remove mixed from unstaged + u_inserted = self.list_subtract(u_inserted, m_all) + u_modified = self.list_subtract(u_modified, m_all) + u_deleted = self.list_subtract(u_deleted, m_all) + + # Remove mixed from staged + s_inserted = self.list_subtract(s_inserted, m_all) + s_modified = self.list_subtract(s_modified, m_all) + s_deleted = self.list_subtract(s_deleted, m_all) + + # Unstaged + self.lines_removed(u_deleted, 'unstaged') + self.bind_icons('inserted', u_inserted, 'unstaged') + self.bind_icons('changed', u_modified, 'unstaged') + + # Staged + self.lines_removed(s_deleted, 'staged') + self.bind_icons('inserted', s_inserted, 'staged') + self.bind_icons('changed', s_modified, 'staged') + + # Mixed + self.lines_removed(m_deleted, 'staged_unstaged') + self.bind_icons('inserted', m_inserted, 'staged_unstaged') + self.bind_icons('changed', m_modified, 'staged_unstaged') + else: + # Mark changes without a qualifier + inserted, modified, deleted = self.all_changes() + + self.lines_removed(deleted) + self.bind_icons('inserted', inserted) + self.bind_icons('changed', modified) + + def all_changes(self): + return ViewCollection.diff(self.view) + + def unstaged_changes(self): + return ViewCollection.unstaged(self.view) + + def staged_changes(self): + return ViewCollection.staged(self.view) + + def list_subtract(self, a, b): + subtracted = [elem for elem in a if elem not in b] + return subtracted + + def list_intersection(self, a, b): + intersected = [elem for elem in a if elem in b] + return intersected + + def mixed_mofified(self, a, lists): + # a is a list of modified lines + # lists is a list of lists (inserted, modified, deleted) + # We want the values in a that are in any of the lists + c = [] + for b in lists: + mix = self.list_intersection(a,b) + [c.append(elem) for elem in mix] + return c def clear_all(self): for region_name in self.region_names: self.view.erase_regions('git_gutter_%s' % region_name) + for qualifier in self.qualifiers: + self.view.erase_regions('git_gutter_%s_%s' % ( + region_name, qualifier)) def lines_to_regions(self, lines): regions = [] @@ -58,7 +136,7 @@ def lines_to_regions(self, lines): regions.append(region) return regions - def lines_removed(self, lines): + def lines_removed(self, lines, qualifier=None): top_lines = lines bottom_lines = [line - 1 for line in lines if line > 1] dual_lines = [] @@ -69,9 +147,9 @@ def lines_removed(self, lines): bottom_lines.remove(line) top_lines.remove(line) - self.bind_icons('deleted_top', top_lines) - self.bind_icons('deleted_bottom', bottom_lines) - self.bind_icons('deleted_dual', dual_lines) + self.bind_icons('deleted_top', top_lines, qualifier) + self.bind_icons('deleted_bottom', bottom_lines, qualifier) + self.bind_icons('deleted_dual', dual_lines, qualifier) def icon_path(self, icon_name): if icon_name in ['deleted_top','deleted_bottom','deleted_dual']: @@ -87,14 +165,31 @@ def icon_path(self, icon_name): return path + '/GitGutter/icons/' + icon_name + extn - def bind_icons(self, event, lines): + def icon_scope(self, event_scope, qualifier): + scope = 'markup.%s.git_gutter' % event_scope + if qualifier: + scope += "." + qualifier + return scope + + def icon_region(self, event, qualifier): + region = 'git_gutter_%s' % event + if qualifier: + region += "_" + qualifier + return region + + def bind_icons(self, event, lines, qualifier=None): regions = self.lines_to_regions(lines) event_scope = event if event.startswith('deleted'): event_scope = 'deleted' - scope = 'markup.%s.git_gutter' % event_scope - icon = self.icon_path(event) - self.view.add_regions('git_gutter_%s' % event, regions, scope, icon) + if qualifier == "staged_unstaged": + icon_name = "staged_unstaged" + else: + icon_name = event + scope = self.icon_scope(event_scope,qualifier) + icon = self.icon_path(icon_name) + key = self.icon_region(event,qualifier) + self.view.add_regions(key, regions, scope, icon) def bind_files(self, event): lines = [] diff --git a/git_gutter_compare.py b/git_gutter_compare.py index a68f7a26..f103e7a1 100644 --- a/git_gutter_compare.py +++ b/git_gutter_compare.py @@ -10,8 +10,7 @@ class GitGutterCompareCommit(sublime_plugin.WindowCommand): def run(self): self.view = self.window.active_view() - key = ViewCollection.get_key(self.view) - self.handler = ViewCollection.views[key] + self.handler = ViewCollection.get_handler(self.view) self.results = self.commit_list() if self.results: @@ -30,7 +29,7 @@ def on_select(self, selected): item = self.results[selected] commit = self.item_to_commit(item) ViewCollection.set_compare(commit) - ViewCollection.clear_git_time(self.view) + ViewCollection.clear_times(self.view) ViewCollection.add(self.view) class GitGutterCompareBranch(GitGutterCompareCommit): @@ -66,7 +65,7 @@ class GitGutterCompareHead(sublime_plugin.WindowCommand): def run(self): self.view = self.window.active_view() ViewCollection.set_compare("HEAD") - ViewCollection.clear_git_time(self.view) + ViewCollection.clear_times(self.view) ViewCollection.add(self.view) class GitGutterShowCompare(sublime_plugin.WindowCommand): diff --git a/git_gutter_handler.py b/git_gutter_handler.py index b671efb8..30fd9741 100644 --- a/git_gutter_handler.py +++ b/git_gutter_handler.py @@ -19,6 +19,7 @@ def __init__(self, view): self.view = view self.git_temp_file = ViewCollection.git_tmp_file(self.view) self.buf_temp_file = ViewCollection.buf_tmp_file(self.view) + self.stg_temp_file = ViewCollection.stg_tmp_file(self.view) if self.on_disk(): self.git_tree = git_helper.git_tree(self.view) self.git_dir = git_helper.git_dir(self.git_tree) @@ -71,6 +72,32 @@ def update_buf_file(self): f.write(contents) f.close() + def update_stg_file(self): + # FIXME dry up duplicate in update_stg_file and update_git_file + + # the git repo won't change that often + # so we can easily wait 5 seconds + # between updates for performance + if ViewCollection.stg_time(self.view) > 5: + open(self.stg_temp_file.name, 'w').close() + args = [ + self.git_binary_path, + '--git-dir=' + self.git_dir, + '--work-tree=' + self.git_tree, + 'show', + ':' + self.git_path, + ] + try: + contents = self.run_command(args) + contents = contents.replace(b'\r\n', b'\n') + contents = contents.replace(b'\r', b'\n') + f = open(self.stg_temp_file.name, 'wb') + f.write(contents) + f.close() + ViewCollection.update_stg_time(self.view) + except Exception: + pass + def update_git_file(self): # the git repo won't change that often # so we can easily wait 5 seconds @@ -117,26 +144,32 @@ def process_diff(self, diff_str): inserted = [] modified = [] deleted = [] + adj_map = {0:0} hunk_re = '^@@ \-(\d+),?(\d*) \+(\d+),?(\d*) @@' hunks = re.finditer(hunk_re, diff_str, re.MULTILINE) for hunk in hunks: - start = int(hunk.group(3)) + old_start = int(hunk.group(1)) + new_start = int(hunk.group(3)) old_size = int(hunk.group(2) or 1) new_size = int(hunk.group(4) or 1) if not old_size: - inserted += range(start, start + new_size) + inserted += range(new_start, new_start + new_size) elif not new_size: - deleted += [start + 1] + deleted += [new_start + 1] else: - modified += range(start, start + new_size) + modified += range(new_start, new_start + new_size) + # Add values to adjustment map + k = old_start + sum(adj_map.values()) + v = new_size - old_size + adj_map[k] = v if len(inserted) == self.total_lines() and not self.show_untracked: # All lines are "inserted" # this means this file is either: # - New and not being tracked *yet* # - Or it is a *gitignored* file - return ([], [], []) + return ([], [], [], {0:0}) else: - return (inserted, modified, deleted) + return (inserted, modified, deleted, adj_map) def diff(self): if self.on_disk() and self.git_path: @@ -156,10 +189,70 @@ def diff(self): decoded_results = results.decode(encoding.replace(' ', '')) except UnicodeError: decoded_results = results.decode("utf-8") - return self.process_diff(decoded_results) + return self.process_diff(decoded_results)[:3] else: return ([], [], []) + # FIXME + # Refactor staged/diff methods to dry up duplicated code + def unstaged(self): + if self.on_disk() and self.git_path: + self.update_stg_file() + self.update_buf_file() + args = [ + self.git_binary_path, 'diff', '-U0', '--no-color', + self.stg_temp_file.name, + self.buf_temp_file.name + ] + args = list(filter(None, args)) # Remove empty args + results = self.run_command(args) + encoding = self._get_view_encoding() + try: + decoded_results = results.decode(encoding.replace(' ', '')) + except UnicodeError: + decoded_results = results.decode("utf-8") + processed = self.process_diff(decoded_results) + ViewCollection.set_line_adjustment_map(self.view,processed[3]) + return processed[:3] + else: + return ([], [], []) + + def staged(self): + if self.on_disk() and self.git_path: + self.update_stg_file() + self.update_buf_file() + args = [ + self.git_binary_path, 'diff', '-U0', '--no-color', '--staged', + ViewCollection.get_compare(), + self.git_path + ] + args = list(filter(None, args)) # Remove empty args + results = self.run_command(args) + encoding = self._get_view_encoding() + try: + decoded_results = results.decode(encoding.replace(' ', '')) + except UnicodeError: + decoded_results = results.decode("utf-8") + diffs = self.process_diff(decoded_results)[:3] + return self.apply_line_adjustments(*diffs) + else: + return ([], [], []) + + def apply_line_adjustments(self, inserted, modified, deleted): + adj_map = ViewCollection.get_line_adjustment_map(self.view) + i = inserted + m = modified + d = deleted + for k in sorted(adj_map.keys()): + at_line = k + lines_added = adj_map[k] + # `lines_added` lines were added at line `at_line` + # Each line in the diffs that are above `at_line` add `lines_added` + i = [l + lines_added if l > at_line else l for l in i] + m = [l + lines_added if l > at_line else l for l in m] + d = [l + lines_added if l > at_line else l for l in d] + return (i,m,d) + def untracked(self): return self.handle_files([]) @@ -187,6 +280,17 @@ def handle_files(self, additionnal_args): else: return False + def has_stages(self): + args = [self.git_binary_path, + '--git-dir=' + self.git_dir, + '--work-tree=' + self.git_tree, + 'diff', '--staged'] + results = self.run_command(args) + if len(results): + return True + else: + return False + def git_commits(self): args = [ self.git_binary_path, diff --git a/icons/staged_unstaged.png b/icons/staged_unstaged.png new file mode 100644 index 00000000..c2a98070 Binary files /dev/null and b/icons/staged_unstaged.png differ diff --git a/view_collection.py b/view_collection.py index 8c845618..4f295a4f 100644 --- a/view_collection.py +++ b/view_collection.py @@ -3,10 +3,13 @@ class ViewCollection: - views = {} + views = {} # Todo: these aren't really views but handlers. Refactor/Rename. git_times = {} + stg_times = {} git_files = {} buf_files = {} + stg_files = {} + line_adjustment_map = {} compare_against = "HEAD" @staticmethod @@ -16,8 +19,9 @@ def add(view): from GitGutter.git_gutter_handler import GitGutterHandler except ImportError: from git_gutter_handler import GitGutterHandler - ViewCollection.views[key] = GitGutterHandler(view) - ViewCollection.views[key].reset() + handler = ViewCollection.views[key] = GitGutterHandler(view) + handler.reset() + return handler @staticmethod def git_path(view): @@ -31,11 +35,39 @@ def git_path(view): def get_key(view): return view.file_name() + @staticmethod + def has_view(view): + key = ViewCollection.get_key(view) + return key in ViewCollection.views + + @staticmethod + def get_handler(view): + if ViewCollection.has_view(view): + key = ViewCollection.get_key(view) + return ViewCollection.views[key] + else: + return ViewCollection.add(view) + @staticmethod def diff(view): key = ViewCollection.get_key(view) return ViewCollection.views[key].diff() + @staticmethod + def has_stages(view): + key = ViewCollection.get_key(view) + return ViewCollection.views[key].has_stages() + + @staticmethod + def staged(view): + key = ViewCollection.get_key(view) + return ViewCollection.views[key].staged() + + @staticmethod + def unstaged(view): + key = ViewCollection.get_key(view) + return ViewCollection.views[key].unstaged() + @staticmethod def untracked(view): key = ViewCollection.get_key(view) @@ -59,15 +91,28 @@ def git_time(view): return time.time() - ViewCollection.git_times[key] @staticmethod - def clear_git_time(view): + def clear_times(view): key = ViewCollection.get_key(view) ViewCollection.git_times[key] = 0 + ViewCollection.stg_times[key] = 0 @staticmethod def update_git_time(view): key = ViewCollection.get_key(view) ViewCollection.git_times[key] = time.time() + @staticmethod + def stg_time(view): + key = ViewCollection.get_key(view) + if not key in ViewCollection.stg_times: + ViewCollection.stg_times[key] = 0 + return time.time() - ViewCollection.stg_times[key] + + @staticmethod + def update_stg_time(view): + key = ViewCollection.get_key(view) + ViewCollection.stg_times[key] = time.time() + @staticmethod def git_tmp_file(view): key = ViewCollection.get_key(view) @@ -84,6 +129,14 @@ def buf_tmp_file(view): ViewCollection.buf_files[key].close() return ViewCollection.buf_files[key] + @staticmethod + def stg_tmp_file(view): + key = ViewCollection.get_key(view) + if not key in ViewCollection.stg_files: + ViewCollection.stg_files[key] = tempfile.NamedTemporaryFile() + ViewCollection.stg_files[key].close() + return ViewCollection.stg_files[key] + @staticmethod def set_compare(commit): print("GitGutter now comparing against:",commit) @@ -95,3 +148,16 @@ def get_compare(): return ViewCollection.compare_against else: return "HEAD" + + @staticmethod + def set_line_adjustment_map(view, adj_map): + key = ViewCollection.get_key(view) + ViewCollection.line_adjustment_map[key] = adj_map + + def get_line_adjustment_map(view): + key = ViewCollection.get_key(view) + if key in ViewCollection.line_adjustment_map: + return ViewCollection.line_adjustment_map[key] + else: + # Zero adjustments + return {0:0} \ No newline at end of file