filediff.py 79.5 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 342
        for buf in self.textbuffer:
            assert hasattr(buf,"handlers")
343 344 345 346
            for h in buf.handlers:
                buf.disconnect(h)

    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 757 758 759 760 761
    def _filter_text(self, txt, buf, start_iter, end_iter):
        dimmed_tag = buf.get_tag_table().lookup("dimmed")
        buf.remove_tag(dimmed_tag, start_iter, end_iter)
        start = start_iter.copy()
        end = start_iter.copy()

762 763 764 765
        def killit(m):
            assert m.group().count("\n") == 0
            if len(m.groups()):
                s = m.group()
766 767
                for i in reversed(range(1, len(m.groups())+1)):
                    g = m.group(i)
768
                    if g:
769 770 771 772 773 774 775 776
                        start.forward_chars(m.start(i))
                        end.forward_chars(m.end(i))
                        buf.apply_tag(dimmed_tag, start, end)
                        start.forward_chars(-m.start(i))
                        end.forward_chars(-m.end(i))

                        s = s[:m.start(i)-m.start()]+s[m.end(i)-m.start():]

777 778
                return s
            else:
779 780 781 782 783 784
                start.forward_chars(m.start())
                end.forward_chars(m.end())
                buf.apply_tag(dimmed_tag, start, end)
                start.forward_chars(-m.start())
                end.forward_chars(-m.end())

785
                return ""
786

787
        try:
788
            for filt in self.text_filters:
789 790
                if filt.active:
                    txt = filt.filter.sub(killit, txt)
791
        except AssertionError:
792
            if not self.warned_bad_comparison:
793 794 795 796 797 798 799
                misc.error_dialog(
                    primary=_(u"Comparison results will be inaccurate"),
                    secondary=_(
                        u"Filter “%s” changed the number of lines in the "
                        u"file, which is unsupported. The comparison will "
                        u"not be accurate.") % filt.label,
                )
800 801 802
                self.warned_bad_comparison = True
        return txt

803 804 805 806 807 808
    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)
809

810 811
    def after_text_delete_range(self, buffer, it0, it1):
        starting_at = it0.get_line()
812 813 814
        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
815

816

817
    def check_save_modified(self):
818
        response = Gtk.ResponseType.OK
819
        modified = [b.get_modified() for b in self.textbuffer[:self.num_panes]]
820
        labels = [b.data.label for b in self.textbuffer[:self.num_panes]]
821
        if True in modified:
822
            dialog = gnomeglade.Component("filediff.ui", "check_save_dialog")
823
            dialog.widget.set_transient_for(self.widget.get_toplevel())
824
            message_area = dialog.widget.get_message_area()
825
            buttons = []
826 827 828 829 830 831
            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)
832
                buttons.append(button)
833
            message_area.show_all()
834
            response = dialog.widget.run()
835
            try_save = [b.get_active() for b in buttons]
836
            dialog.widget.destroy()
837
            if response == Gtk.ResponseType.OK and any(try_save):
838 839
                for i in range(self.num_panes):
                    if try_save[i]:
840 841 842 843 844 845
                        self.save_file(i)
                return Gtk.ResponseType.CANCEL

        if response == Gtk.ResponseType.DELETE_EVENT:
            response = Gtk.ResponseType.CANCEL
        elif response == Gtk.ResponseType.CLOSE:
846 847
            response = Gtk.ResponseType.OK

848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863
        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:
864 865
                    bufdata = self.textbuffer[1].data
                    conflict_file = bufdata.savefile or bufdata.filename
866
                    parent.command('resolve', [conflict_file])
867 868
        elif response == Gtk.ResponseType.CANCEL:
            self.state = melddoc.STATE_NORMAL
869

870 871
        return response

872
    def on_delete_event(self):
873
        self.state = melddoc.STATE_CLOSING
874
        response = self.check_save_modified()
875
        if response == Gtk.ResponseType.OK:
876 877
            for h in self.settings_handlers:
                meldsettings.disconnect(h)
878 879
            # TODO: Base the return code on something meaningful for VC tools
            self.emit('close', 0)
880
        return response
steve9000's avatar
steve9000 committed
881

882 883 884 885 886 887 888 889
    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()

890
    def on_textbuffer_begin_user_action(self, *buffer):
891
        self.undosequence.begin_group()
steve9000's avatar
steve9000 committed
892

893
    def on_textbuffer_end_user_action(self, *buffer):
894
        self.undosequence.end_group()
895

896
    def on_text_insert_text(self, buf, it, text, textlen):
897
        text = text_type(text, 'utf8')
898
        self.undosequence.add_action(
899
            meldbuffer.BufferInsertionAction(buf, it.get_offset(), text))
900
        buf.create_mark("insertion-start", it, True)
steve9000's avatar
steve9000 committed
901

902
    def on_text_delete_range(self, buf, it0, it1):
903
        text = text_type(buf.get_text(it0, it1, False), 'utf8')
steve9000's avatar
steve9000 committed
904
        assert self.deleted_lines_pending == -1
905
        self.deleted_lines_pending = it1.get_line() - it0.get_line()
906
        self.undosequence.add_action(
907
            meldbuffer.BufferDeletionAction(buf, it0.get_offset(), text))
908 909

    def on_undo_checkpointed(self, undosequence, buf, checkpointed):
910
        buf.set_modified(not checkpointed)
911
        self.recompute_label()
912

913 914 915 916 917 918 919 920
    @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)
921

922 923 924 925 926 927 928 929 930
    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(
931
            #    Gdk.SELECTION_CLIPBOARD).wait_is_text_available()
932
            paste = widget.get_editable()
933 934 935 936
        if self.main_actiongroup:
            for action, sens in zip(
                    ("Cut", "Copy", "Paste"), (cut, copy, paste)):
                self.main_actiongroup.get_action(action).set_sensitive(sens)
937

938 939
    @with_focused_pane
    def get_selected_text(self, pane):
940
        """Returns selected text of active pane"""
941 942 943 944
        buf = self.textbuffer[pane]
        sel = buf.get_selection_bounds()
        if sel:
            return text_type(buf.get_text(sel[0], sel[1], False), 'utf8')
945

946
    def on_find_activate(self, *args):
947
        selected_text = self.get_selected_text()
948
        self.findbar.start_find(self.focus_pane, selected_text)
949 950 951
        self.keymask = 0

    def on_replace_activate(self, *args):
952
        selected_text = self.get_selected_text()
953
        self.findbar.start_replace(self.focus_pane, selected_text)
954 955 956
        self.keymask = 0

    def on_find_next_activate(self, *args):
957
        self.findbar.start_find_next(self.focus_pane)
958

959
    def on_find_previous_activate(self, *args):
960
        self.findbar.start_find_previous(self.focus_pane)
961

962
    def on_scrolledwindow_size_allocate(self, scrolledwindow, allocation):
963 964 965 966 967 968
        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()

969
    def on_textview_popup_menu(self, textview):
970 971
        self.popup_menu.popup(None, None, None, None, 0,
                              Gtk.get_current_event_time())
972 973
        return True

974
    def on_textview_button_press_event(self, textview, event):
975 976
        if event.button == 3:
            textview.grab_focus()
977
            self.popup_menu.popup(None, None, None, None, event.button, event.time)
978 979
            return True
        return False
980 981 982 983 984 985 986 987

    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 ]
988
        self.on_cursor_position_changed(view.get_buffer(), None, True)
989

Kai Willadsen's avatar
Kai Willadsen committed
990
    def set_labels(self, labels):
991
        labels = labels[:self.num_panes]
Kai Willadsen's avatar
Kai Willadsen committed
992 993 994
        for label, buf in zip(labels, self.textbuffer):
            if label:
                buf.data.label = label
995

996
    def set_merge_output_file(self, filename):
997
        if self.num_panes < 2:
998
            return
999 1000
        buf = self.textbuffer[1]
        buf.data.savefile = os.path.abspath(filename)
1001
        buf.data.label = filename
1002
        self.update_buffer_writable(buf)
1003 1004 1005 1006 1007

        # 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)
1008
        self.recompute_label()
1009

1010 1011
    def _set_save_action_sensitivity(self):
        pane = self._get_focused_pane()
1012 1013
        modified = (
            False if pane == -1 else self.textbuffer[pane].get_modified())
1014 1015
        if self.main_actiongroup:
            self.main_actiongroup.get_action("Save").set_sensitive(modified)
1016
        any_modified = any(b.get_modified() for b in self.textbuffer)
1017
        self.actiongroup.get_action("SaveAll").set_sensitive(any_modified)
1018

steve9000's avatar
steve9000 committed
1019
    def recompute_label(self):
1020
        self._set_save_action_sensitivity()
1021
        filenames = [b.data.label for b in self.textbuffer[:self.num_panes]]
1022
        shortnames = misc.shorten_names(*filenames)
1023 1024

        for i, buf in enumerate(self.textbuffer[:self.num_panes]):
1025
            if buf.get_modified():
1026
                shortnames[i] += "*"
1027
            self.file_save_button[i].set_sensitive(buf.get_modified())
1028 1029 1030
            self.file_save_button[i].props.icon_name = (
                'document-save-symbolic' if buf.data.writable else
                'document-save-as-symbolic')
1031

1032 1033 1034 1035 1036
        label = self.meta.get("tablabel", "")
        if label:
            self.label_text = label
        else:
            self.label_text = (" — ").decode('utf8').join(shortnames)
1037
        self.tooltip_text = self.label_text
1038
        self.label_changed()
steve9000's avatar
steve9000 committed
1039 1040

    def set_files(self, files):
1041 1042 1043
        """Load the given files

        If an element is None, the text of a pane is left as is.
steve9000's avatar
steve9000 committed
1044
        """
1045 1046 1047
        if len(files) != self.num_panes:
            return

1048
        self._disconnect_buffer_handlers()
1049 1050
        self.undosequence.clear()
        self.linediffer.clear()
1051

1052 1053
        custom_candidates = get_custom_encoding_candidates()

1054 1055 1056 1057 1058 1059
        files = [(pane, Gio.File.new_for_path(filename))
                 for pane, filename in enumerate(files) if filename]

        for pane, gfile in files:
            self.fileentry[pane].set_file(gfile)
            self.msgarea_mgr[pane].clear()
1060

1061
            self.textbuffer[pane].data.reset(gfile)
1062

1063
            loader = GtkSource.FileLoader.new(
1064
                self.textbuffer[pane], self.textbuffer[pane].data.sourcefile)
1065 1066
            if custom_candidates:
                loader.set_candidate_encodings(custom_candidates)
1067 1068 1069 1070 1071 1072
            loader.load_async(
                GLib.PRIORITY_HIGH,
                callback=self.file_loaded,
                user_data=(pane,)
            )

1073 1074 1075 1076
    def get_comparison(self):
        files = [b.data.filename for b in self.textbuffer[:self.num_panes]]
        return recent.TYPE_FILE, files

1077 1078 1079 1080 1081 1082
    def file_loaded(self, loader, result, user_data):

        gfile = loader.get_location()
        pane = user_data[0]

        try:
1083
            loader.load_finish(result)
1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095
        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)
1096
            self.msgarea_mgr[pane].add_dismissable_msg(
1097
                'dialog-error-symbolic', primary, err.message)
1098 1099

        buf = loader.get_buffer()
1100 1101
        start, end = buf.get_bounds()
        buffer_text = buf.get_text(start, end, False)
1102
        if not loader.get_encoding() and '\\00' in buffer_text:
1103 1104 1105
            primary = _("File %s appears to be a binary file.") % filename
            secondary = _(
                "Do you want to open the file using the default application?")
1106
            self.msgarea_mgr[pane].add_action_msg(
1107
                'dialog-warning-symbolic', primary, secondary, _("Open"),
1108
                functools.partial(self._open_files, [gfile.get_path()]))
1109

1110
        self.update_buffer_writable(buf)
1111 1112 1113

        self.undosequence.checkpoint(buf)
        buf.data.update_mtime()
1114
        buf.data.loaded = True
1115

1116
        if all(b.data.loaded for b in self.textbuffer[:self.num_panes]):
1117
            self.scheduler.add_task(self._compare_files_internal())
1118

1119 1120 1121
    def _merge_files(self):
        yield 1

1122
    def _diff_files(self, refresh=False):
steve9000's avatar
steve9000 committed
1123
        yield _("[%s] Computing differences") % self.label_text
1124
        texts = self.buffer_filtered[:self.num_panes]
1125
        self.linediffer.ignore_blanks = self.props.ignore_blank_lines
1126
        step = self.linediffer.set_sequences_iter(texts)
Kai Willadsen's avatar
Kai Willadsen committed
1127
        while next(step) is None:
steve9000's avatar
steve9000 committed
1128
            yield 1
1129

1130 1131 1132 1133 1134 1135 1136 1137 1138 1139
        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.scheduler.add_task(
1140 1141
                    lambda: self.go_to_chunk(self.cursor.next, centered=True),
                    True)
Kai Willadsen's avatar