filediff.py 79 KB
Newer Older
1 2
# coding=UTF-8

3
# Copyright (C) 2002-2006 Stephen Kennedy <stevek@gnome.org>
4
# Copyright (C) 2009-2015 Kai Willadsen <kai.willadsen@gmail.com>
5
#
6 7 8 9 10 11 12 13 14 15 16 17
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or (at
# your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
steve9000's avatar
steve9000 committed
18

19
import copy
20
import functools
steve9000's avatar
steve9000 committed
21
import os
22
import time
steve9000's avatar
steve9000 committed
23

24 25 26 27
from multiprocessing import Pool
from multiprocessing.pool import ThreadPool


28 29
from gi.repository import GLib
from gi.repository import GObject
30
from gi.repository import Gio
31 32
from gi.repository import Gdk
from gi.repository import Gtk
33
from gi.repository import GtkSource
34

35
from meld.conf import _
36 37 38 39 40 41 42
from . import diffutil
from . import matchers
from . import meldbuffer
from . import melddoc
from . import merge
from . import misc
from . import patchdialog
43
from . import recent
44 45 46 47
from . import undo
from .ui import findbar
from .ui import gnomeglade

48
from meld.const import MODE_REPLACE, MODE_DELETE, MODE_INSERT, NEWLINES
49
from meld.settings import bind_settings, meldsettings
50
from .util.compat import text_type
51
from meld.sourceview import LanguageManager, get_custom_encoding_candidates
52

53

54 55 56 57 58 59 60 61 62 63
def with_focused_pane(function):
    @functools.wraps(function)
    def wrap_function(*args, **kwargs):
        pane = args[0]._get_focused_pane()
        if pane == -1:
            return
        return function(args[0], pane, *args[1:], **kwargs)
    return wrap_function


64 65 66 67 68 69 70 71
class CachedSequenceMatcher(object):
    """Simple class for caching diff results, with LRU-based eviction

    Results from the SequenceMatcher are cached and timestamped, and
    subsequently evicted based on least-recent generation/usage. The LRU-based
    eviction is overly simplistic, but is okay for our usage pattern.
    """

72 73
    process_pool = None

74
    def __init__(self):
75 76 77 78
        if self.process_pool is None:
            if os.name == "nt":
                CachedSequenceMatcher.process_pool = ThreadPool(None)
            else:
79 80
                CachedSequenceMatcher.process_pool = Pool(
                    None, matchers.init_worker, maxtasksperchild=1)
81 82
        self.cache = {}

83
    def match(self, text1, textn, cb):
84 85
        try:
            self.cache[(text1, textn)][1] = time.time()
86 87
            opcodes = self.cache[(text1, textn)][0]
            GLib.idle_add(lambda: cb(opcodes))
88
        except KeyError:
89 90
            def inline_cb(opcodes):
                self.cache[(text1, textn)] = [opcodes, time.time()]
91
                GLib.idle_add(lambda: cb(opcodes))
92 93 94
            self.process_pool.apply_async(matchers.matcher_worker,
                                          (text1, textn),
                                          callback=inline_cb)
95 96 97 98 99 100 101 102 103 104 105 106 107

    def clean(self, size_hint):
        """Clean the cache if necessary

        @param size_hint: the recommended minimum number of cache entries
        """
        if len(self.cache) < size_hint * 3:
            return
        items = self.cache.items()
        items.sort(key=lambda it: it[1][1])
        for item in items[:-size_hint * 2]:
            del self.cache[item[0]]

108

109
MASK_SHIFT, MASK_CTRL = 1, 2
Kai Willadsen's avatar
Kai Willadsen committed
110
PANE_LEFT, PANE_RIGHT = -1, +1
111

112

113
class CursorDetails(object):
114 115
    __slots__ = ("pane", "pos", "line", "offset", "chunk", "prev", "next",
                 "prev_conflict", "next_conflict")
116 117 118 119 120 121

    def __init__(self):
        for var in self.__slots__:
            setattr(self, var, None)


122
class FileDiff(melddoc.MeldDoc, gnomeglade.Component):
123 124 125 126
    """Two or three way comparison of text files"""

    __gtype_name__ = "FileDiff"

127 128 129 130
    __gsettings_bindings__ = (
        ('ignore-blank-lines', 'ignore-blank-lines'),
    )

131 132 133 134 135 136
    ignore_blank_lines = GObject.property(
        type=bool,
        nick="Ignore blank lines",
        blurb="Whether to ignore blank lines when comparing file contents",
        default=False,
    )
137

138 139
    differ = diffutil.Differ

140 141 142 143 144 145
    keylookup = {
        Gdk.KEY_Shift_L: MASK_SHIFT,
        Gdk.KEY_Shift_R: MASK_SHIFT,
        Gdk.KEY_Control_L: MASK_CTRL,
        Gdk.KEY_Control_R: MASK_CTRL,
    }
Stephen Kennedy's avatar
Stephen Kennedy committed
146

147
    # Identifiers for MsgArea messages
148
    (MSG_SAME, MSG_SLOW_HIGHLIGHT, MSG_SYNCPOINTS) = list(range(3))
149

150
    __gsignals__ = {
151 152
        'next-conflict-changed': (GObject.SignalFlags.RUN_FIRST, None, (bool, bool)),
        'action-mode-changed': (GObject.SignalFlags.RUN_FIRST, None, (int,)),
153 154
    }

155
    def __init__(self, num_panes):
steve9000's avatar
steve9000 committed
156 157
        """Start up an filediff with num_panes empty contents.
        """
158
        melddoc.MeldDoc.__init__(self)
159 160
        gnomeglade.Component.__init__(
            self, "filediff.ui", "filediff", ["FilediffActions"])
161 162
        bind_settings(self)

163 164
        widget_lists = [
            "diffmap", "file_save_button", "file_toolbar", "fileentry",
165
            "linkmap", "msgarea_mgr", "readonlytoggle",
166
            "scrolledwindow", "selector_hbox", "textview", "vbox",
167
            "dummy_toolbar_linkmap", "filelabel_toolitem", "filelabel",
168
            "fileentry_toolitem", "dummy_toolbar_diffmap"
169 170
        ]
        self.map_widgets_into_lists(widget_lists)
171 172 173

        # This SizeGroup isn't actually necessary for FileDiff; it's for
        # handling non-homogenous selectors in FileComp. It's also fragile.
174
        column_sizes = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL)
175 176 177 178
        column_sizes.set_ignore_hidden(True)
        for widget in self.selector_hbox:
            column_sizes.add_widget(widget)

179
        self.warned_bad_comparison = False
180
        self._keymask = 0
181
        self.meta = {}
182
        self.deleted_lines_pending = -1
183
        self.textview_overwrite = 0
184
        self.focus_pane = None
185
        self.textview_overwrite_handlers = [ t.connect("toggle-overwrite", self.on_textview_toggle_overwrite) for t in self.textview ]
186
        self.textbuffer = [v.get_buffer() for v in self.textview]
187
        self.buffer_texts = [meldbuffer.BufferLines(b) for b in self.textbuffer]
188
        self.undosequence = undo.UndoSequence()
189 190
        self.text_filters = []
        self.create_text_filters()
191 192 193 194
        self.settings_handlers = [
            meldsettings.connect("text-filters-changed",
                                 self.on_text_filters_changed)
        ]
195 196
        self.buffer_filtered = [meldbuffer.BufferLines(b, self._filter_text)
                                for b in self.textbuffer]
197 198 199
        for (i, w) in enumerate(self.scrolledwindow):
            w.get_vadjustment().connect("value-changed", self._sync_vscroll, i)
            w.get_hadjustment().connect("value-changed", self._sync_hscroll)
200 201 202
            # Revert overlay scrolling that messes with widget interactivity
            if hasattr(w, 'set_overlay_scrolling'):
                w.set_overlay_scrolling(False)
203
        self._connect_buffer_handlers()
204 205
        self._sync_vscroll_lock = False
        self._sync_hscroll_lock = False
206
        self._scroll_lock = False
207
        self.linediffer = self.differ()
208
        self.force_highlight = False
209
        self.syncpoints = []
210
        self.in_nested_textview_gutter_expose = False
211
        self._cached_match = CachedSequenceMatcher()
212

213
        for buf in self.textbuffer:
214 215
            buf.connect("notify::has-selection",
                        self.update_text_actions_sensitivity)
216 217 218 219
            buf.connect('begin_user_action',
                        self.on_textbuffer_begin_user_action)
            buf.connect('end_user_action', self.on_textbuffer_end_user_action)
            buf.data.connect('file-changed', self.notify_file_changed)
220

221
        self.ui_file = gnomeglade.ui_file("filediff-ui.xml")
222
        self.actiongroup = self.FilediffActions
223
        self.actiongroup.set_translation_domain("meld")
224

225
        self.findbar = findbar.FindBar(self.grid)
226
        self.grid.attach(self.findbar.widget, 1, 2, 5, 1)
227

228
        self.set_num_panes(num_panes)
229
        self.cursor = CursorDetails()
230 231 232 233
        self.connect("current-diff-changed", self.on_current_diff_changed)
        for t in self.textview:
            t.connect("focus-in-event", self.on_current_diff_changed)
            t.connect("focus-out-event", self.on_current_diff_changed)
234
        self.linediffer.connect("diffs-changed", self.on_diffs_changed)
235
        self.undosequence.connect("checkpointed", self.on_undo_checkpointed)
236
        self.connect("next-conflict-changed", self.on_next_conflict_changed)
Stephen Kennedy's avatar
Stephen Kennedy committed
237

238 239 240
        for diffmap in self.diffmap:
            self.linediffer.connect('diffs-changed', diffmap.on_diffs_changed)

241
        overwrite_label = Gtk.Label()
242
        overwrite_label.show()
243
        cursor_label = Gtk.Label()
244 245
        cursor_label.show()
        self.status_info_labels = [overwrite_label, cursor_label]
246
        self.statusbar.set_info_box(self.status_info_labels)
247

248 249
        # Prototype implementation

250
        from meld.gutterrendererchunk import GutterRendererChunkAction, GutterRendererChunkLines
251 252 253

        for pane, t in enumerate(self.textview):
            # FIXME: set_num_panes will break this good
254 255
            direction = t.get_direction()

256 257
            if pane == 0 or (pane == 1 and self.num_panes == 3):
                window = Gtk.TextWindowType.RIGHT
258 259
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.LEFT
260 261 262 263 264 265
                views = [self.textview[pane], self.textview[pane + 1]]
                renderer = GutterRendererChunkAction(pane, pane + 1, views, self, self.linediffer)
                gutter = t.get_gutter(window)
                gutter.insert(renderer, 10)
            if pane in (1, 2):
                window = Gtk.TextWindowType.LEFT
266 267
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.RIGHT
268 269 270
                views = [self.textview[pane], self.textview[pane - 1]]
                renderer = GutterRendererChunkAction(pane, pane - 1, views, self, self.linediffer)
                gutter = t.get_gutter(window)
271
                gutter.insert(renderer, -40)
272

273 274 275
            # TODO: This renderer handling should all be part of
            # MeldSourceView, but our current diff-chunk-handling makes
            # this difficult.
276 277 278 279
            window = Gtk.TextWindowType.LEFT
            if direction == Gtk.TextDirection.RTL:
                window = Gtk.TextWindowType.RIGHT
            renderer = GutterRendererChunkLines(pane, pane - 1, self.linediffer)
280 281 282 283 284 285
            renderer.set_properties(
                "alignment-mode", GtkSource.GutterRendererAlignmentMode.FIRST,
                "yalign", 0.5,
                "xalign", 1.0,
                "xpad", 3,
            )
286 287
            gutter = t.get_gutter(window)
            gutter.insert(renderer, -30)
288
            t.line_renderer = renderer
289

290
        self.connect("notify::ignore-blank-lines", self.refresh_comparison)
291

292 293 294 295 296 297 298 299 300 301 302 303 304
    def get_keymask(self):
        return self._keymask
    def set_keymask(self, value):
        if value & MASK_SHIFT:
            mode = MODE_DELETE
        elif value & MASK_CTRL:
            mode = MODE_INSERT
        else:
            mode = MODE_REPLACE
        self._keymask = value
        self.emit("action-mode-changed", mode)
    keymask = property(get_keymask, set_keymask)

305 306 307 308 309 310 311 312 313 314
    def on_key_event(self, object, event):
        keymap = Gdk.Keymap.get_default()
        ok, keyval, group, lvl, consumed = keymap.translate_keyboard_state(
            event.hardware_keycode, 0, event.group)
        mod_key = self.keylookup.get(keyval, 0)
        if event.type == Gdk.EventType.KEY_PRESS:
            self.keymask |= mod_key
            if event.keyval == Gdk.KEY_Escape:
                self.findbar.hide()
        elif event.type == Gdk.EventType.KEY_RELEASE:
315 316
            if event.keyval == Gdk.KEY_Return and self.keymask & MASK_SHIFT:
                self.findbar.start_find_previous(self.focus_pane)
317 318
            self.keymask &= ~mod_key

319 320 321
    def on_focus_change(self):
        self.keymask = 0

322 323 324
    def on_text_filters_changed(self, app):
        relevant_change = self.create_text_filters()
        if relevant_change:
325
            self.refresh_comparison()
326 327 328 329

    def create_text_filters(self):
        # In contrast to file filters, ordering of text filters can matter
        old_active = [f.filter_string for f in self.text_filters if f.active]
330 331
        new_active = [f.filter_string for f in meldsettings.text_filters
                      if f.active]
332 333
        active_filters_changed = old_active != new_active

334
        self.text_filters = [copy.copy(f) for f in meldsettings.text_filters]
335 336 337

        return active_filters_changed

338 339
    def _disconnect_buffer_handlers(self):
        for textview in self.textview:
340
            textview.set_sensitive(False)
341
        for buf in self.textbuffer:
342 343
            for h in buf.handlers:
                buf.disconnect(h)
344
            buf.handlers = []
345 346

    def _connect_buffer_handlers(self):
347 348
        for textview in self.textview:
            textview.set_sensitive(True)
349
        for buf in self.textbuffer:
350 351 352 353
            id0 = buf.connect("insert-text", self.on_text_insert_text)
            id1 = buf.connect("delete-range", self.on_text_delete_range)
            id2 = buf.connect_after("insert-text", self.after_text_insert_text)
            id3 = buf.connect_after("delete-range", self.after_text_delete_range)
354 355
            id4 = buf.connect("notify::cursor-position",
                              self.on_cursor_position_changed)
356
            buf.handlers = id0, id1, id2, id3, id4
357

358 359 360 361 362
    # Abbreviations for insert and overwrite that fit in the status bar
    _insert_overwrite_text = (_("INS"), _("OVR"))
    # Abbreviation for line, column so that it will fit in the status bar
    _line_column_text = _("Ln %i, Col %i")

363 364 365 366 367 368 369 370 371 372 373
    def on_cursor_position_changed(self, buf, pspec, force=False):
        pane = self.textbuffer.index(buf)
        pos = buf.props.cursor_position
        if pane == self.cursor.pane and pos == self.cursor.pos and not force:
            return
        self.cursor.pane, self.cursor.pos = pane, pos

        cursor_it = buf.get_iter_at_offset(pos)
        offset = cursor_it.get_line_offset()
        line = cursor_it.get_line()

374 375
        insert_overwrite = self._insert_overwrite_text[self.textview_overwrite]
        line_column = self._line_column_text % (line + 1, offset + 1)
376 377
        self.status_info_labels[0].set_text(insert_overwrite)
        self.status_info_labels[1].set_text(line_column)
378 379

        if line != self.cursor.line or force:
Kai Willadsen's avatar
Kai Willadsen committed
380
            chunk, prev, next_ = self.linediffer.locate_chunk(pane, line)
381
            if chunk != self.cursor.chunk or force:
382 383
                self.cursor.chunk = chunk
                self.emit("current-diff-changed")
Kai Willadsen's avatar
Kai Willadsen committed
384
            if prev != self.cursor.prev or next_ != self.cursor.next or force:
385
                self.emit("next-diff-changed", prev is not None,
Kai Willadsen's avatar
Kai Willadsen committed
386
                          next_ is not None)
387 388 389 390 391

            prev_conflict, next_conflict = None, None
            for conflict in self.linediffer.conflicts:
                if prev is not None and conflict <= prev:
                    prev_conflict = conflict
Kai Willadsen's avatar
Kai Willadsen committed
392
                if next_ is not None and conflict >= next_:
393 394 395
                    next_conflict = conflict
                    break
            if prev_conflict != self.cursor.prev_conflict or \
396
               next_conflict != self.cursor.next_conflict or force:
397 398 399
                self.emit("next-conflict-changed", prev_conflict is not None,
                          next_conflict is not None)

Kai Willadsen's avatar
Kai Willadsen committed
400
            self.cursor.prev, self.cursor.next = prev, next_
401 402
            self.cursor.prev_conflict = prev_conflict
            self.cursor.next_conflict = next_conflict
403
        self.cursor.line, self.cursor.offset = line, offset
404

405
    def on_current_diff_changed(self, widget, *args):
406 407 408 409 410 411 412
        pane = self._get_focused_pane()
        if pane != -1:
            # While this *should* be redundant, it's possible for focus pane
            # and cursor pane to be different in several situations.
            pane = self.cursor.pane
            chunk_id = self.cursor.chunk

413
        if pane == -1 or chunk_id is None:
414 415
            push_left, push_right, pull_left, pull_right, delete, \
                copy_left, copy_right = (False,) * 7
416
        else:
417 418 419
            push_left, push_right, pull_left, pull_right, delete, \
                copy_left, copy_right = (True,) * 7

420 421 422 423 424 425
            # Push and Delete are active if the current pane has something to
            # act on, and the target pane exists and is editable. Pull is
            # sensitive if the source pane has something to get, and the
            # current pane is editable. Copy actions are sensitive if the
            # conditions for push are met, *and* there is some content in the
            # target pane.
426 427
            editable = self.textview[pane].get_editable()
            editable_left = pane > 0 and self.textview[pane - 1].get_editable()
428 429
            editable_right = pane < self.num_panes - 1 and \
                             self.textview[pane + 1].get_editable()
430 431
            if pane == 0 or pane == 2:
                chunk = self.linediffer.get_chunk(chunk_id, pane)
432 433
                insert_chunk = chunk[1] == chunk[2]
                delete_chunk = chunk[3] == chunk[4]
434 435
                push_left = editable_left
                push_right = editable_right
436 437 438
                pull_left = pane == 2 and editable and not delete_chunk
                pull_right = pane == 0 and editable and not delete_chunk
                delete = editable and not insert_chunk
439 440
                copy_left = editable_left and not (insert_chunk or delete_chunk)
                copy_right = editable_right and not (insert_chunk or delete_chunk)
441
            elif pane == 1:
442
                chunk0 = self.linediffer.get_chunk(chunk_id, 1, 0)
443 444
                chunk2 = None
                if self.num_panes == 3:
445 446 447 448 449
                    chunk2 = self.linediffer.get_chunk(chunk_id, 1, 2)
                left_mid_exists = chunk0 is not None and chunk0[1] != chunk0[2]
                left_exists = chunk0 is not None and chunk0[3] != chunk0[4]
                right_mid_exists = chunk2 is not None and chunk2[1] != chunk2[2]
                right_exists = chunk2 is not None and chunk2[3] != chunk2[4]
450 451
                push_left = editable_left
                push_right = editable_right
452 453 454
                pull_left = editable and left_exists
                pull_right = editable and right_exists
                delete = editable and (left_mid_exists or right_mid_exists)
455 456
                copy_left = editable_left and left_mid_exists and left_exists
                copy_right = editable_right and right_mid_exists and right_exists
457 458 459 460 461
        self.actiongroup.get_action("PushLeft").set_sensitive(push_left)
        self.actiongroup.get_action("PushRight").set_sensitive(push_right)
        self.actiongroup.get_action("PullLeft").set_sensitive(pull_left)
        self.actiongroup.get_action("PullRight").set_sensitive(pull_right)
        self.actiongroup.get_action("Delete").set_sensitive(delete)
462 463 464 465
        self.actiongroup.get_action("CopyLeftUp").set_sensitive(copy_left)
        self.actiongroup.get_action("CopyLeftDown").set_sensitive(copy_left)
        self.actiongroup.get_action("CopyRightUp").set_sensitive(copy_right)
        self.actiongroup.get_action("CopyRightDown").set_sensitive(copy_right)
466 467 468 469 470

        prev_pane = pane > 0
        next_pane = pane < self.num_panes - 1
        self.actiongroup.get_action("PrevPane").set_sensitive(prev_pane)
        self.actiongroup.get_action("NextPane").set_sensitive(next_pane)
471 472
        # FIXME: don't queue_draw() on everything... just on what changed
        self.queue_draw()
473

474 475 476 477
    def on_next_conflict_changed(self, doc, have_prev, have_next):
        self.actiongroup.get_action("PrevConflict").set_sensitive(have_prev)
        self.actiongroup.get_action("NextConflict").set_sensitive(have_next)

478
    def go_to_chunk(self, target, pane=None, centered=False):
479 480 481
        if target is None:
            return

482 483 484
        if pane is None:
            pane = self._get_focused_pane()
            if pane == -1:
485
                pane = 1 if self.num_panes > 1 else 0
486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502

        chunk = self.linediffer.get_chunk(target, pane)
        if not chunk:
            return

        # Warp the cursor to the first line of the chunk
        buf = self.textbuffer[pane]
        if self.cursor.line != chunk[1]:
            buf.place_cursor(buf.get_iter_at_line(chunk[1]))

        tolerance = 0.0 if centered else 0.2
        self.textview[pane].scroll_to_mark(
            buf.get_insert(), tolerance, True, 0.5, 0.5)

    def next_diff(self, direction, centered=False):
        target = (self.cursor.next if direction == Gdk.ScrollDirection.DOWN
                  else self.cursor.prev)
503
        self.go_to_chunk(target, centered=centered)
504 505 506 507 508 509 510 511 512 513 514 515

    def action_previous_conflict(self, *args):
        self.go_to_chunk(self.cursor.prev_conflict, self.cursor.pane)

    def action_next_conflict(self, *args):
        self.go_to_chunk(self.cursor.next_conflict, self.cursor.pane)

    def action_previous_diff(self, *args):
        self.go_to_chunk(self.cursor.prev)

    def action_next_diff(self, *args):
        self.go_to_chunk(self.cursor.next)
516

517 518 519 520 521
    def get_action_chunk(self, src, dst):
        valid_panes = list(range(0, self.num_panes))
        if (src not in valid_panes or dst not in valid_panes or
                self.cursor.chunk is None):
            raise ValueError("Action was taken on invalid panes")
522 523

        chunk = self.linediffer.get_chunk(self.cursor.chunk, src, dst)
524 525 526
        if chunk is None:
            raise ValueError("Action was taken on a missing chunk")
        return chunk
527

528
    def get_action_panes(self, direction, reverse=False):
529 530
        src = self._get_focused_pane()
        dst = src + direction
531 532 533
        return (dst, src) if reverse else (src, dst)

    def action_push_change_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
534
        src, dst = self.get_action_panes(PANE_LEFT)
535 536 537
        self.replace_chunk(src, dst, self.get_action_chunk(src, dst))

    def action_push_change_right(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
538
        src, dst = self.get_action_panes(PANE_RIGHT)
539 540 541
        self.replace_chunk(src, dst, self.get_action_chunk(src, dst))

    def action_pull_change_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
542
        src, dst = self.get_action_panes(PANE_LEFT, reverse=True)
543 544 545
        self.replace_chunk(src, dst, self.get_action_chunk(src, dst))

    def action_pull_change_right(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
546
        src, dst = self.get_action_panes(PANE_RIGHT, reverse=True)
547 548 549
        self.replace_chunk(src, dst, self.get_action_chunk(src, dst))

    def action_copy_change_left_up(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
550
        src, dst = self.get_action_panes(PANE_LEFT)
551 552 553 554
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=True)

    def action_copy_change_right_up(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
555
        src, dst = self.get_action_panes(PANE_RIGHT)
556 557 558 559
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=True)

    def action_copy_change_left_down(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
560
        src, dst = self.get_action_panes(PANE_LEFT)
561 562 563 564
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=False)

    def action_copy_change_right_down(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
565
        src, dst = self.get_action_panes(PANE_RIGHT)
566 567
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=False)
568

569
    def pull_all_non_conflicting_changes(self, src, dst):
570 571
        merger = merge.Merger()
        merger.differ = self.linediffer
572
        merger.texts = self.buffer_texts
573 574
        for mergedfile in merger.merge_2_files(src, dst):
            pass
575
        self._sync_vscroll_lock = True
576
        self.on_textbuffer_begin_user_action()
577
        self.textbuffer[dst].set_text(mergedfile)
578
        self.on_textbuffer_end_user_action()
579

580 581 582 583
        def resync():
            self._sync_vscroll_lock = False
            self._sync_vscroll(self.scrolledwindow[src].get_vadjustment(), src)
        self.scheduler.add_task(resync)
584

585
    def action_pull_all_changes_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
586
        src, dst = self.get_action_panes(PANE_LEFT, reverse=True)
587 588 589
        self.pull_all_non_conflicting_changes(src, dst)

    def action_pull_all_changes_right(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
590
        src, dst = self.get_action_panes(PANE_RIGHT, reverse=True)
591 592
        self.pull_all_non_conflicting_changes(src, dst)

593
    def merge_all_non_conflicting_changes(self, *args):
594 595 596
        dst = 1
        merger = merge.Merger()
        merger.differ = self.linediffer
597
        merger.texts = self.buffer_texts
598 599
        for mergedfile in merger.merge_3_files(False):
            pass
600
        self._sync_vscroll_lock = True
601
        self.on_textbuffer_begin_user_action()
602
        self.textbuffer[dst].set_text(mergedfile)
603
        self.on_textbuffer_end_user_action()
604 605 606 607
        def resync():
            self._sync_vscroll_lock = False
            self._sync_vscroll(self.scrolledwindow[0].get_vadjustment(), 0)
        self.scheduler.add_task(resync)
608

609 610
    @with_focused_pane
    def delete_change(self, pane):
611
        chunk = self.linediffer.get_chunk(self.cursor.chunk, pane)
612
        assert(self.cursor.chunk is not None)
613 614 615
        assert(chunk is not None)
        self.delete_chunk(pane, chunk)

616 617 618 619 620 621 622 623 624 625 626 627 628 629 630
    def _synth_chunk(self, pane0, pane1, line):
        """Returns the Same chunk that would exist at
           the given location if we didn't remove Same chunks"""

        # This method is a hack around our existing diffutil data structures;
        # getting rid of the Same chunk removal is difficult, as several places
        # have baked in the assumption of only being given changed blocks.

        buf0, buf1 = self.textbuffer[pane0], self.textbuffer[pane1]
        start0, end0 = 0, buf0.get_line_count() - 1
        start1, end1 = 0, buf1.get_line_count() - 1

        # This hack is required when pane0's prev/next chunk doesn't exist
        # (i.e., is Same) between pane0 and pane1.
        prev_chunk0, prev_chunk1, next_chunk0, next_chunk1 = (None,) * 4
Kai Willadsen's avatar
Kai Willadsen committed
631
        _, prev, next_ = self.linediffer.locate_chunk(pane0, line)
632 633 634 635 636 637 638 639 640 641
        if prev is not None:
            while prev >= 0:
                prev_chunk0 = self.linediffer.get_chunk(prev, pane0, pane1)
                prev_chunk1 = self.linediffer.get_chunk(prev, pane1, pane0)
                if None not in (prev_chunk0, prev_chunk1):
                    start0 = prev_chunk0[2]
                    start1 = prev_chunk1[2]
                    break
                prev -= 1

Kai Willadsen's avatar
Kai Willadsen committed
642 643 644 645
        if next_ is not None:
            while next_ < self.linediffer.diff_count():
                next_chunk0 = self.linediffer.get_chunk(next_, pane0, pane1)
                next_chunk1 = self.linediffer.get_chunk(next_, pane1, pane0)
646 647 648 649
                if None not in (next_chunk0, next_chunk1):
                    end0 = next_chunk0[1]
                    end1 = next_chunk1[1]
                    break
Kai Willadsen's avatar
Kai Willadsen committed
650
                next_ += 1
651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701

        return "Same", start0, end0, start1, end1

    def _corresponding_chunk_line(self, chunk, line, pane, new_pane):
        """Approximates the corresponding line between panes"""

        old_buf, new_buf = self.textbuffer[pane], self.textbuffer[new_pane]

        # Special-case cross-pane jumps
        if (pane == 0 and new_pane == 2) or (pane == 2 and new_pane == 0):
            proxy = self._corresponding_chunk_line(chunk, line, pane, 1)
            return self._corresponding_chunk_line(chunk, proxy, 1, new_pane)

        # Either we are currently in a identifiable chunk, or we are in a Same
        # chunk; if we establish the start/end of that chunk in both panes, we
        # can figure out what our new offset should be.
        cur_chunk = None
        if chunk is not None:
            cur_chunk = self.linediffer.get_chunk(chunk, pane, new_pane)

        if cur_chunk is None:
            cur_chunk = self._synth_chunk(pane, new_pane, line)
        cur_start, cur_end, new_start, new_end = cur_chunk[1:5]

        # If the new buffer's current cursor is already in the correct chunk,
        # assume that we have in-progress editing, and don't move it.
        cursor_it = new_buf.get_iter_at_mark(new_buf.get_insert())
        cursor_line = cursor_it.get_line()

        cursor_chunk, _, _ = self.linediffer.locate_chunk(new_pane, cursor_line)
        if cursor_chunk is not None:
            already_in_chunk = cursor_chunk == chunk
        else:
            cursor_chunk = self._synth_chunk(pane, new_pane, cursor_line)
            already_in_chunk = cursor_chunk[3] == new_start and \
                               cursor_chunk[4] == new_end

        if already_in_chunk:
            new_line = cursor_line
        else:
            # Guess where to put the cursor: in the same chunk, at about the
            # same place within the chunk, calculated proportionally by line.
            # Insert chunks and one-line chunks are placed at the top.
            if cur_end == cur_start:
                chunk_offset = 0.0
            else:
                chunk_offset = (line - cur_start) / float(cur_end - cur_start)
            new_line = new_start + int(chunk_offset * (new_end - new_start))

        return new_line

702
    def move_cursor_pane(self, pane, new_pane):
703 704 705 706 707 708
        chunk, line = self.cursor.chunk, self.cursor.line
        new_line = self._corresponding_chunk_line(chunk, line, pane, new_pane)

        new_buf = self.textbuffer[new_pane]
        self.textview[new_pane].grab_focus()
        new_buf.place_cursor(new_buf.get_iter_at_line(new_line))
709 710
        self.textview[new_pane].scroll_to_mark(
            new_buf.get_insert(), 0.1, True, 0.5, 0.5)
711

712 713 714 715 716 717 718 719 720 721
    def action_prev_pane(self, *args):
        pane = self._get_focused_pane()
        new_pane = (pane - 1) % self.num_panes
        self.move_cursor_pane(pane, new_pane)

    def action_next_pane(self, *args):
        pane = self._get_focused_pane()
        new_pane = (pane + 1) % self.num_panes
        self.move_cursor_pane(pane, new_pane)

722 723 724 725 726 727 728 729
    def _set_external_action_sensitivity(self):
        have_file = self.focus_pane is not None
        try:
            self.main_actiongroup.get_action("OpenExternal").set_sensitive(
                have_file)
        except AttributeError:
            pass

730
    def on_textview_focus_in_event(self, view, event):
731
        self.focus_pane = view
732
        self.findbar.textview = view
733
        self.on_cursor_position_changed(view.get_buffer(), None, True)
734
        self._set_save_action_sensitivity()
735
        self._set_merge_action_sensitivity()
736
        self._set_external_action_sensitivity()
737
        self.update_text_actions_sensitivity()
738

739 740
    def on_textview_focus_out_event(self, view, event):
        self._set_merge_action_sensitivity()
741
        self._set_external_action_sensitivity()
742

steve9000's avatar
steve9000 committed
743
    def _after_text_modified(self, buffer, startline, sizechange):
744
        if self.num_panes > 1:
745
            pane = self.textbuffer.index(buffer)
746
            if not self.linediffer.syncpoints:
747 748
                self.linediffer.change_sequence(pane, startline, sizechange,
                                                self.buffer_filtered)
749 750 751 752 753
            # FIXME: diff-changed signal for the current buffer would be cleaner
            focused_pane = self._get_focused_pane()
            if focused_pane != -1:
                self.on_cursor_position_changed(self.textbuffer[focused_pane],
                                                None, True)
Stephen Kennedy's avatar
Stephen Kennedy committed
754
            self.queue_draw()
steve9000's avatar
steve9000 committed
755

756
    def _filter_text(self, txt, buf, txt_start_iter, txt_end_iter):
757
        dimmed_tag = buf.get_tag_table().lookup("dimmed")
758
        buf.remove_tag(dimmed_tag, txt_start_iter, txt_end_iter)
759

760 761 762 763 764 765 766 767 768
        def cutter(txt, start, end):
            assert txt[start:end].count("\n") == 0
            txt = txt[:start] + txt[end:]
            start_iter = txt_start_iter.copy()
            start_iter.forward_chars(start)
            end_iter = txt_start_iter.copy()
            end_iter.forward_chars(end)
            buf.apply_tag(dimmed_tag, start_iter, end_iter)
            return txt
769

770 771 772
        try:
            regexes = [f.filter for f in self.text_filters if f.active]
            txt = misc.apply_text_filters(txt, regexes, cutter)
773
        except AssertionError:
774
            if not self.warned_bad_comparison:
775 776 777
                misc.error_dialog(
                    primary=_(u"Comparison results will be inaccurate"),
                    secondary=_(
778
                        u"A filter changed the number of lines in the "
779
                        u"file, which is unsupported. The comparison will "
780
                        u"not be accurate."),
781
                )
782
                self.warned_bad_comparison = True
783

784 785
        return txt

786 787 788 789 790 791
    def after_text_insert_text(self, buf, it, newtext, textlen):
        start_mark = buf.get_mark("insertion-start")
        starting_at = buf.get_iter_at_mark(start_mark).get_line()
        buf.delete_mark(start_mark)
        lines_added = it.get_line() - starting_at
        self._after_text_modified(buf, starting_at, lines_added)
792

793 794
    def after_text_delete_range(self, buffer, it0, it1):
        starting_at = it0.get_line()
795 796 797
        assert self.deleted_lines_pending != -1
        self._after_text_modified(buffer, starting_at, -self.deleted_lines_pending)
        self.deleted_lines_pending = -1
steve9000's avatar
steve9000 committed
798

799

800
    def check_save_modified(self):
801
        response = Gtk.ResponseType.OK
802
        modified = [b.get_modified() for b in self.textbuffer[:self.num_panes]]
803
        labels = [b.data.label for b in self.textbuffer[:self.num_panes]]
804
        if True in modified:
805
            dialog = gnomeglade.Component("filediff.ui", "check_save_dialog")
806
            dialog.widget.set_transient_for(self.widget.get_toplevel())
807
            message_area = dialog.widget.get_message_area()
808
            buttons = []
809 810 811 812 813 814
            for label, should_save in zip(labels, modified):
                button = Gtk.CheckButton.new_with_label(label)
                button.set_sensitive(should_save)
                button.set_active(should_save)
                message_area.pack_start(
                    button, expand=False, fill=True, padding=0)
815
                buttons.append(button)
816
            message_area.show_all()
817
            response = dialog.widget.run()
818
            try_save = [b.get_active() for b in buttons]
819
            dialog.widget.destroy()
820
            if response == Gtk.ResponseType.OK and any(try_save):
821 822
                for i in range(self.num_panes):
                    if try_save[i]:
823 824
                        self.save_file(i)
                return Gtk.ResponseType.CANCEL
825

826 827 828
        if response == Gtk.ResponseType.DELETE_EVENT:
            response = Gtk.ResponseType.CANCEL
        elif response == Gtk.ResponseType.CLOSE:
829 830
            response = Gtk.ResponseType.OK

831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846
        if response == Gtk.ResponseType.OK and self.meta:
            parent = self.meta.get('parent', None)
            saved = self.meta.get('middle_saved', False)
            prompt_resolve = self.meta.get('prompt_resolve', False)
            if prompt_resolve and saved and parent.has_command('resolve'):
                primary = _("Mark conflict as resolved?")
                secondary = _(
                    "If the conflict was resolved successfully, you may mark "
                    "it as resolved now.")
                buttons = ((_("Cancel"), Gtk.ResponseType.CANCEL),
                           (_("Mark _Resolved"), Gtk.ResponseType.OK))
                resolve_response = misc.modal_dialog(
                    primary, secondary, buttons, parent=self.widget,
                    messagetype=Gtk.MessageType.QUESTION)

                if resolve_response == Gtk.ResponseType.OK:
847 848
                    bufdata = self.textbuffer[1].data
                    conflict_file = bufdata.savefile or bufdata.filename
849
                    parent.command('resolve', [conflict_file])
850 851
        elif response == Gtk.ResponseType.CANCEL:
            self.state = melddoc.STATE_NORMAL
852

853 854
        return response

855
    def on_delete_event(self):
856
        self.state = melddoc.STATE_CLOSING
857
        response = self.check_save_modified()
858
        if response == Gtk.ResponseType.OK:
859 860
            for h in self.settings_handlers:
                meldsettings.disconnect(h)
861 862
            # TODO: Base the return code on something meaningful for VC tools
            self.emit('close', 0)
863
        return response
steve9000's avatar
steve9000 committed
864

865 866 867 868 869 870 871 872
    def on_undo_activate(self):
        if self.undosequence.can_undo():
            self.undosequence.undo()

    def on_redo_activate(self):
        if self.undosequence.can_redo():
            self.undosequence.redo()

873
    def on_textbuffer_begin_user_action(self, *buffer):
874
        self.undosequence.begin_group()
steve9000's avatar
steve9000 committed
875

876
    def on_textbuffer_end_user_action(self, *buffer):
877
        self.undosequence.end_group()
878

879
    def on_text_insert_text(self, buf, it, text, textlen):
880
        text = text_type(text, 'utf8')
881
        self.undosequence.add_action(
882
            meldbuffer.BufferInsertionAction(buf, it.get_offset(), text))
883
        buf.create_mark("insertion-start", it, True)
steve9000's avatar
steve9000 committed
884

885
    def on_text_delete_range(self, buf, it0, it1):
886
        text = text_type(buf.get_text(it0, it1, False), 'utf8')
steve9000's avatar
steve9000 committed
887
        assert self.deleted_lines_pending == -1
888
        self.deleted_lines_pending = it1.get_line() - it0.get_line()
889
        self.undosequence.add_action(
890
            meldbuffer.BufferDeletionAction(buf, it0.get_offset(), text))
891 892

    def on_undo_checkpointed(self, undosequence, buf, checkpointed):
893
        buf.set_modified(not checkpointed)
894
        self.recompute_label()
895

896 897 898 899 900 901 902 903
    @with_focused_pane
    def open_external(self, pane):
        if not self.textbuffer[pane].data.filename:
            return
        pos = self.textbuffer[pane].props.cursor_position
        cursor_it = self.textbuffer[pane].get_iter_at_offset(pos)
        line = cursor_it.get_line() + 1
        self._open_files([self.textbuffer[pane].data.filename], line)
904

905 906 907 908 909 910 911 912 913
    def update_text_actions_sensitivity(self, *args):
        widget = self.focus_pane
        if not widget:
            cut, copy, paste = False, False, False
        else:
            cut = copy = widget.get_buffer().get_has_selection()
            # Ideally, this would check whether the clipboard included
            # something pasteable. However, there is no changed signal.
            # widget.get_clipboard(
914
            #    Gdk.SELECTION_CLIPBOARD).wait_is_text_available()
915
            paste = widget.get_editable()
916 917 918 919
        if self.main_actiongroup:
            for action, sens in zip(
                    ("Cut", "Copy", "Paste"), (cut, copy, paste)):
                self.main_actiongroup.get_action(action).set_sensitive(sens)
920

921 922
    @with_focused_pane
    def get_selected_text(self, pane):
923
        """Returns selected text of active pane"""
924 925 926 927
        buf = self.textbuffer[pane]
        sel = buf.get_selection_bounds()
        if sel:
            return text_type(buf.get_text(sel[0], sel[1], False), 'utf8')
928

929
    def on_find_activate(self, *args):
930
        selected_text = self.get_selected_text()
931
        self.findbar.start_find(self.focus_pane, selected_text)
932 933 934
        self.keymask = 0

    def on_replace_activate(self, *args):
935
        selected_text = self.get_selected_text()
936
        self.findbar.start_replace(self.focus_pane, selected_text)
937 938 939
        self.keymask = 0

    def on_find_next_activate(self, *args):
940
        self.findbar.start_find_next(self.focus_pane)
941

942
    def on_find_previous_activate(self, *args):
943
        self.findbar.start_find_previous(self.focus_pane)
944

945
    def on_scrolledwindow_size_allocate(self, scrolledwindow, allocation):
946 947 948 949 950 951
        index = self.scrolledwindow.index(scrolledwindow)
        if index == 0 or index == 1:
            self.linkmap[0].queue_draw()
        if index == 1 or index == 2:
            self.linkmap[1].queue_draw()

952
    def on_textview_popup_menu(self, textview):
953 954
        self.popup_menu.popup(None, None, None, None, 0,
                              Gtk.get_current_event_time())
955 956
        return True

957
    def on_textview_button_press_event(self, textview, event):
958 959
        if event.button == 3:
            textview.grab_focus()
960
            self.popup_menu.popup(None, None, None, None, event.button, event.time)
961 962
            return True
        return False
963 964 965 966 967 968 969 970

    def on_textview_toggle_overwrite(self, view):
        self.textview_overwrite = not self.textview_overwrite
        for v,h in zip(self.textview, self.textview_overwrite_handlers):
            v.disconnect(h)
            if v != view:
                v.emit("toggle-overwrite")
        self.textview_overwrite_handlers = [ t.connect("toggle-overwrite", self.on_textview_toggle_overwrite) for t in self.textview ]
971
        self.on_cursor_position_changed(view.get_buffer(), None, True)
972

Kai Willadsen's avatar
Kai Willadsen committed
973
    def set_labels(self, labels):
974
        labels = labels[:self.num_panes]
Kai Willadsen's avatar
Kai Willadsen committed
975 976 977
        for label, buf in zip(labels, self.textbuffer):
            if label:
                buf.data.label = label
978

979
    def set_merge_output_file(self, filename):
980
        if self.num_panes < 2:
981
            return
982 983
        buf = self.textbuffer[1]
        buf.data.savefile = os.path.abspath(filename)
984
        buf.data.label = filename
985
        self.update_buffer_writable(buf)
986 987 988 989 990

        # FIXME: Hack around bgo#737804; remove after GTK+ 3.18 is required
        def set_merge_file_entry():
            self.fileentry[1].set_filename(buf.data.savefile)
        self.scheduler.add_task(set_merge_file_entry)
991
        self.recompute_label()
992

993 994
    def _set_save_action_sensitivity(self):
        pane = self._get_focused_pane()
995 996
        modified = (
            False if pane == -1 else self.textbuffer[pane].get_modified())
997 998
        if self.main_actiongroup:
            self.main_actiongroup.get_action("Save").set_sensitive(modified)
999
        any_modified = any(b.get_modified() for b in self.textbuffer)
1000
        self.actiongroup.get_action("SaveAll").set_sensitive(any_modified)
1001

steve9000's avatar
steve9000 committed
1002
    def recompute_label(self):
1003
        self._set_save_action_sensitivity()
1004
        filenames = [b.data.label for b in self.textbuffer[:self.num_panes]]
1005
        shortnames = misc.shorten_names(*filenames)
1006 1007

        for i, buf in enumerate(self.textbuffer[:self.num_panes]):
1008
            if buf.get_modified():
1009
                shortnames[i] += "*"
1010
            self.file_save_button[i].set_sensitive(buf.get_modified())
1011 1012 1013
            self.file_save_button[i].props.icon_name = (
                'document-save-symbolic' if buf.data.writable else
                'document-save-as-symbolic')
1014

1015 1016 1017 1018 1019
        label = self.meta.get("tablabel", "")
        if label:
            self.label_text = label
        else:
            self.label_text = (" — ").decode('utf8').join(shortnames)
1020
        self.tooltip_text = self.label_text
1021
        self.label_changed()
steve9000's avatar
steve9000 committed
1022 1023

    def set_files(self, files):
1024 1025 1026
        """Load the given files

        If an element is None, the text of a pane is left as is.
steve9000's avatar
steve9000 committed
1027
        """
1028 1029 1030
        if len(files) != self.num_panes:
            return

1031
        self._disconnect_buffer_handlers()
1032 1033
        self.undosequence.clear()
        self.linediffer.clear()
1034

1035 1036
        custom_candidates = get_custom_encoding_candidates()

1037 1038 1039
        files = [(pane, Gio.File.new_for_path(filename))
                 for pane, filename in enumerate(files) if filename]

1040 1041 1042
        if not files:
            self.scheduler.add_task(self._compare_files_internal())

1043 1044 1045
        for pane, gfile in files:
            self.fileentry[pane].set_file(gfile)
            self.msgarea_mgr[pane].clear()
1046

1047
            self.textbuffer[pane].data.reset(gfile)
1048

1049
            loader = GtkSource.FileLoader.new(
1050
                self.textbuffer[pane], self.textbuffer[pane].data.sourcefile)
1051 1052
            if custom_candidates:
                loader.set_candidate_encodings(custom_candidates)
1053 1054 1055 1056 1057
            loader.load_async(
                GLib.PRIORITY_HIGH,
                callback=self.file_loaded,
                user_data=(pane,)
            )
steve9000's avatar
steve9000 committed
1058

1059 1060 1061 1062
    def get_comparison(self):
        files = [b.data.filename for b in self.textbuffer[:self.num_panes]]
        return recent.TYPE_FILE, files

1063
    def file_loaded(self, loader, result, user_data):
1064

1065 1066 1067 1068
        gfile = loader.get_location()
        pane = user_data[0]

        try:
1069
            loader.load_finish(result)
1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081
        except GLib.Error as err:
            # TODO: Find sane error domain constants
            if err.domain == 'gtk-source-file-loader-error':
                # TODO: Add custom reload-with-encoding handling for
                # GtkSource.FileLoaderError.CONVERSION_FALLBACK and
                # GtkSource.FileLoaderError.ENCODING_AUTO_DETECTION_FAILED
                pass

            filename = GLib.markup_escape_text(
                gfile.get_parse_name()).decode('utf-8')
            primary = _(
                u"There was a problem opening the file “%s”." % filename)
1082
            self.msgarea_mgr[pane].add_dismissable_msg(
1083
                'dialog-error-symbolic', primary, err.message)
1084 1085

        buf = loader.get_buffer()
1086 1087
        start, end = buf.get_bounds()
        buffer_text = buf.get_text(start, end, False)
1088
        if not loader.get_encoding() and '\\00' in buffer_text:
1089 1090 1091
            primary = _("File %s appears to be a binary file.") % filename
            secondary = _(
                "Do you want to open the file using the default application?")
1092
            self.msgarea_mgr[pane].add_action_msg(
1093
                'dialog-warning-symbolic', primary, secondary, _("Open"),
1094
                functools.partial(self._open_files, [gfile.get_path()]))
1095

1096
        self.update_buffer_writable(buf)
1097 1098 1099

        self.undosequence.checkpoint(buf)
        buf.data.update_mtime()
1100
        buf.data.loaded = True
1101

1102
        if all(b.data.loaded for b in self.textbuffer[:self.num_panes]):
1103
            self.scheduler.add_task(self._compare_files_internal())
1104

1105 1106
    def _merge_files(self):
        yield 1
1107

1108
    def _diff_files(self, refresh=False):
steve9000's avatar
steve9000 committed
1109
        yield _("[%s] Computing differences") % self.label_text
1110
        texts = self.buffer_filtered[:self.num_panes]
1111
        self.linediffer.ignore_blanks = self.props.ignore_blank_lines
1112
        step = self.linediffer.set_sequences_iter(texts)
Kai Willadsen's avatar
Kai Willadsen committed
1113
        while next(step) is None:
steve9000's avatar
steve9000 committed
1114
            yield 1
1115

1116 1117 1118 1119 1120 1121 1122 1123 1124 1125
        if not refresh:
            chunk, prev, next_ = self.linediffer.locate_chunk(1, 0)
            self.cursor.next = chunk
            if self.cursor.next is None:
                self.cursor.next = next_
            for buf in self.textbuffer:
                buf.place_cursor(buf.get_start_iter())

            if self.cursor.next is not None:
                self