1# -*- coding: utf-8 -*-
2
3#  Copyright (C) 2013 - Ignacio Casal Quinteiro
4#
5#  This program is free software; you can redistribute it and/or modify
6#  it under the terms of the GNU General Public License as published by
7#  the Free Software Foundation; either version 2 of the License, or
8#  (at your option) any later version.
9#
10#  This program is distributed in the hope that it will be useful,
11#  but WITHOUT ANY WARRANTY; without even the implied warranty of
12#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13#  GNU General Public License for more details.
14#
15#  You should have received a copy of the GNU General Public License
16#  along with this program; if not, write to the Free Software
17#  Foundation, Inc.  51 Franklin Street, Fifth Floor, Boston, MA
18#  02110-1301 USA.
19
20from gi.repository import GLib, GObject, Gtk, Gedit, Ggit
21
22from .appactivatable import GitAppActivatable
23from .diffrenderer import DiffType, DiffRenderer
24from .windowactivatable import GitWindowActivatable
25
26import sys
27import os.path
28import difflib
29
30
31class LineContext:
32    __slots__ = ('removed_lines', 'line_type')
33
34    def __init__(self):
35        self.removed_lines = []
36        self.line_type = DiffType.NONE
37
38
39class GitViewActivatable(GObject.Object, Gedit.ViewActivatable):
40    view = GObject.Property(type=Gedit.View)
41
42    status = GObject.Property(type=Ggit.StatusFlags,
43                              default=Ggit.StatusFlags.CURRENT)
44
45    def __init__(self):
46        super().__init__()
47
48        self.diff_timeout = 0
49        self.file_contents_list = None
50        self.file_context = None
51
52    def do_activate(self):
53        GitWindowActivatable.register_view_activatable(self)
54
55        self.app_activatable = GitAppActivatable.get_instance()
56
57        self.diff_renderer = DiffRenderer()
58        self.gutter = self.view.get_gutter(Gtk.TextWindowType.LEFT)
59
60        # Note: GitWindowActivatable will call
61        #       update_location() for us when needed
62        self.view_signals = [
63            self.view.connect('notify::buffer', self.on_notify_buffer)
64        ]
65
66        self.buffer = None
67        self.on_notify_buffer(self.view)
68
69    def do_deactivate(self):
70        if self.diff_timeout != 0:
71            GLib.source_remove(self.diff_timeout)
72
73        self.disconnect_buffer()
74        self.buffer = None
75
76        self.disconnect_view()
77        self.gutter.remove(self.diff_renderer)
78
79    def disconnect(self, obj, signals):
80        for sid in signals:
81            obj.disconnect(sid)
82
83        signals[:] = []
84
85    def disconnect_buffer(self):
86        self.disconnect(self.buffer, self.buffer_signals)
87
88    def disconnect_view(self):
89        self.disconnect(self.view, self.view_signals)
90
91    def on_notify_buffer(self, view, gspec=None):
92        if self.diff_timeout != 0:
93            GLib.source_remove(self.diff_timeout)
94
95        if self.buffer:
96            self.disconnect_buffer()
97
98        self.buffer = view.get_buffer()
99
100        # The changed signal is connected to in update_location().
101        # The saved signal is pointless as the window activatable
102        # will see the change and call update_location().
103        self.buffer_signals = [
104            self.buffer.connect('loaded', self.update_location)
105        ]
106
107        # We wait and let the loaded signal call
108        # update_location() as the buffer is currently empty
109
110    # TODO: This can be called many times and by idles,
111    #       should instead do the work in another thread
112    def update_location(self, *args):
113        self.location = self.buffer.get_file().get_location()
114
115        if self.location is not None:
116            repo = self.app_activatable.get_repository(self.location, False)
117
118        if self.location is None or repo is None:
119            if self.file_contents_list is not None:
120                self.file_contents_list = None
121                self.gutter.remove(self.diff_renderer)
122                self.diff_renderer.set_file_context({})
123                self.buffer.disconnect(self.buffer_signals.pop())
124
125            return
126
127        if self.file_contents_list is None:
128            self.gutter.insert(self.diff_renderer, 40)
129            self.buffer_signals.append(self.buffer.connect('changed',
130                                                           self.update))
131
132        try:
133            head = repo.get_head()
134            commit = repo.lookup(head.get_target(), Ggit.Commit)
135            tree = commit.get_tree()
136            relative_path = os.path.relpath(
137                os.path.realpath(self.location.get_path()),
138                repo.get_workdir().get_path()
139            )
140
141            entry = tree.get_by_path(relative_path)
142            file_blob = repo.lookup(entry.get_id(), Ggit.Blob)
143            try:
144                gitconfig = repo.get_config()
145                encoding = gitconfig.get_string('gui.encoding')
146            except GLib.Error:
147                encoding = 'utf8'
148            file_contents = file_blob.get_raw_content().decode(encoding)
149            self.file_contents_list = file_contents.splitlines()
150
151            # Remove the last empty line added by gedit automatically
152            if self.file_contents_list:
153                last_item = self.file_contents_list[-1]
154                if last_item[-1:] == '\n':
155                    self.file_contents_list[-1] = last_item[:-1]
156
157        except GLib.Error:
158            # New file in a git repository
159            self.file_contents_list = []
160
161        self.update()
162
163    def update(self, *unused):
164        # We don't let the delay accumulate
165        if self.diff_timeout != 0:
166            return
167
168        # Do the initial diff without a delay
169        if self.file_context is None:
170            self.on_diff_timeout()
171
172        else:
173            n_lines = self.buffer.get_line_count()
174            delay = min(10000, 200 * (n_lines // 2000 + 1))
175
176            self.diff_timeout = GLib.timeout_add(delay,
177                                                 self.on_diff_timeout)
178
179    def on_diff_timeout(self):
180        self.diff_timeout = 0
181
182        # Must be a new file
183        if not self.file_contents_list:
184            self.status = Ggit.StatusFlags.WORKING_TREE_NEW
185
186            n_lines = self.buffer.get_line_count()
187            if len(self.diff_renderer.file_context) == n_lines:
188                return False
189
190            line_context = LineContext()
191            line_context.line_type = DiffType.ADDED
192            file_context = dict(zip(range(1, n_lines + 1),
193                                    [line_context] * n_lines))
194
195            self.diff_renderer.set_file_context(file_context)
196            return False
197
198        start_iter, end_iter = self.buffer.get_bounds()
199        src_contents = start_iter.get_visible_text(end_iter)
200        src_contents_list = src_contents.splitlines()
201
202        # GtkTextBuffer does not consider a trailing "\n" to be text
203        if len(src_contents_list) != self.buffer.get_line_count():
204            src_contents_list.append('')
205
206        diff = difflib.unified_diff(self.file_contents_list,
207                                    src_contents_list, n=0)
208
209        # Skip the first 2 lines: ---, +++
210        try:
211            next(diff)
212            next(diff)
213
214        except StopIteration:
215            # Nothing has changed
216            self.status = Ggit.StatusFlags.CURRENT
217
218        else:
219            self.status = Ggit.StatusFlags.WORKING_TREE_MODIFIED
220
221        file_context = {}
222        for line_data in diff:
223            if line_data[0] == '@':
224                for token in line_data.split():
225                    if token[0] == '+':
226                        hunk_point = int(token.split(',', 1)[0])
227                        line_context = LineContext()
228                        break
229
230            elif line_data[0] == '-':
231                if line_context.line_type == DiffType.NONE:
232                    line_context.line_type = DiffType.REMOVED
233
234                line_context.removed_lines.append(line_data[1:])
235
236                # No hunk point increase
237                file_context[hunk_point] = line_context
238
239            elif line_data[0] == '+':
240                if line_context.line_type == DiffType.NONE:
241                    line_context.line_type = DiffType.ADDED
242                    file_context[hunk_point] = line_context
243
244                elif line_context.line_type == DiffType.REMOVED:
245                    # Why is this the only one that does
246                    # not add it to file_context?
247
248                    line_context.line_type = DiffType.MODIFIED
249
250                else:
251                    file_context[hunk_point] = line_context
252
253                hunk_point += 1
254
255        # Occurs when all of the original content is deleted
256        if 0 in file_context:
257            for i in reversed(list(file_context.keys())):
258                file_context[i + 1] = file_context[i]
259                del file_context[i]
260
261        self.file_context = file_context
262        self.diff_renderer.set_file_context(file_context)
263        return False
264
265# ex:ts=4:et:
266