filediff.py 82.5 KB
Newer Older
1
# Copyright (C) 2002-2006 Stephen Kennedy <stevek@gnome.org>
2
# Copyright (C) 2009-2015 Kai Willadsen <kai.willadsen@gmail.com>
3
#
4 5 6 7 8 9 10 11 12 13 14 15
# 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
16

17
import copy
18
import functools
19
import math
steve9000's avatar
steve9000 committed
20

21 22
from gi.repository import Gdk
from gi.repository import Gio
23 24 25
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk
26
from gi.repository import GtkSource
27

28 29
# TODO: Don't from-import whole modules
from meld import misc
30 31
from meld.conf import _
from meld.const import MODE_DELETE, MODE_INSERT, MODE_REPLACE, NEWLINES
32
from meld.iohelpers import prompt_save_filename
33
from meld.matchers.diffutil import Differ, merged_chunk_order
34
from meld.matchers.helpers import CachedSequenceMatcher
35
from meld.matchers.merge import Merger
36 37
from meld.meldbuffer import (
    BufferDeletionAction, BufferInsertionAction, BufferLines)
38
from meld.melddoc import ComparisonState, MeldDoc
39
from meld.misc import with_focused_pane
40
from meld.patchdialog import PatchDialog
41
from meld.recent import RecentType
42
from meld.settings import bind_settings, meldsettings
43
from meld.sourceview import (
44
    get_custom_encoding_candidates, LanguageManager, TextviewLineAnimationType)
45
from meld.ui.findbar import FindBar
46 47
from meld.ui.gnomeglade import Component, ui_file
from meld.undo import UndoSequence
48

49

50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
def user_critical(message):
    def wrap(function):
        @functools.wraps(function)
        def wrap_function(locked, *args, **kwargs):
            try:
                return function(locked, *args, **kwargs)
            except Exception:
                misc.error_dialog(
                    primary=message,
                    secondary=_(
                        "Meld encountered a critical error while running: "
                        "<tt>{}</tt>\n\n"
                        "We're sorry this isn't a more useful error, but we "
                        "thought you needed to know about this."
                        "".format(GLib.markup_escape_text(str(function)))
                    ),
                )
                raise
        return wrap_function
    return wrap


72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
def with_scroll_lock(lock_attr):
    """Decorator for locking a callback based on an instance attribute

    This is used when scrolling panes. Since a scroll event in one pane
    causes us to set the scroll position in other panes, we need to
    stop these other panes re-scrolling the initial one.

    Unlike a threading-style lock, this decorator discards any calls
    that occur while the lock is held, rather than queuing them.

    :param lock_attr: The instance attribute used to lock access
    """
    def wrap(function):
        @functools.wraps(function)
        def wrap_function(locked, *args, **kwargs):
            if getattr(locked, lock_attr, False) or locked._scroll_lock:
                return

            try:
                setattr(locked, lock_attr, True)
                return function(locked, *args, **kwargs)
            finally:
                setattr(locked, lock_attr, False)
        return wrap_function
    return wrap


99
MASK_SHIFT, MASK_CTRL = 1, 2
Kai Willadsen's avatar
Kai Willadsen committed
100
PANE_LEFT, PANE_RIGHT = -1, +1
101

102

103
class CursorDetails(object):
104 105 106 107
    __slots__ = (
        "pane", "pos", "line", "offset", "chunk", "prev", "next",
        "prev_conflict", "next_conflict",
    )
108 109 110 111 112 113

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


114
class FileDiff(MeldDoc, Component):
115 116 117 118
    """Two or three way comparison of text files"""

    __gtype_name__ = "FileDiff"

119 120 121 122
    __gsettings_bindings__ = (
        ('ignore-blank-lines', 'ignore-blank-lines'),
    )

123 124 125 126 127 128
    ignore_blank_lines = GObject.property(
        type=bool,
        nick="Ignore blank lines",
        blurb="Whether to ignore blank lines when comparing file contents",
        default=False,
    )
129

130
    differ = Differ
131

132 133 134 135 136 137
    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
138

139
    # Identifiers for MsgArea messages
140
    (MSG_SAME, MSG_SLOW_HIGHLIGHT, MSG_SYNCPOINTS) = list(range(3))
141 142 143
    # Transient messages that should be removed if any file in the
    # comparison gets reloaded.
    TRANSIENT_MESSAGES = {MSG_SAME, MSG_SLOW_HIGHLIGHT}
144

145
    __gsignals__ = {
Kai Willadsen's avatar
Kai Willadsen committed
146 147 148 149
        'next-conflict-changed': (
            GObject.SignalFlags.RUN_FIRST, None, (bool, bool)),
        'action-mode-changed': (
            GObject.SignalFlags.RUN_FIRST, None, (int,)),
150 151
    }

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

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

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

176
        self.warned_bad_comparison = False
177
        self._keymask = 0
178
        self.meta = {}
179
        self.lines_removed = 0
180
        self.focus_pane = None
181
        self.textbuffer = [v.get_buffer() for v in self.textview]
182
        self.buffer_texts = [BufferLines(b) for b in self.textbuffer]
183
        self.undosequence = UndoSequence(self.textbuffer)
184 185
        self.text_filters = []
        self.create_text_filters()
186
        self.settings_handlers = [
187 188
            meldsettings.connect(
                "text-filters-changed", self.on_text_filters_changed)
189
        ]
190 191 192
        self.buffer_filtered = [
            BufferLines(b, self._filter_text) for b in self.textbuffer
        ]
193 194 195
        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)
196
        self._connect_buffer_handlers()
197 198
        self._sync_vscroll_lock = False
        self._sync_hscroll_lock = False
199
        self._scroll_lock = False
200
        self.linediffer = self.differ()
201
        self.force_highlight = False
202
        self.syncpoints = []
203
        self.in_nested_textview_gutter_expose = False
204
        self._cached_match = CachedSequenceMatcher(self.scheduler)
205

206
        for buf in self.textbuffer:
207
            buf.undo_sequence = self.undosequence
208 209
            buf.connect("notify::has-selection",
                        self.update_text_actions_sensitivity)
210
            buf.data.connect('file-changed', self.notify_file_changed)
211

212
        self.ui_file = ui_file("filediff-ui.xml")
213
        self.actiongroup = self.FilediffActions
214
        self.actiongroup.set_translation_domain("meld")
215

216
        self.findbar = FindBar(self.grid)
217
        self.grid.attach(self.findbar.widget, 1, 2, 5, 1)
218

219
        self.set_num_panes(num_panes)
220
        self.cursor = CursorDetails()
221 222 223 224
        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)
225 226 227 228 229 230 231 232

        # Bind all overwrite properties together, so that toggling
        # overwrite mode is per-FileDiff.
        for t in self.textview[1:]:
            t.bind_property(
                'overwrite', self.textview[0], 'overwrite',
                GObject.BindingFlags.BIDIRECTIONAL)

233
        self.linediffer.connect("diffs-changed", self.on_diffs_changed)
234
        self.undosequence.connect("checkpointed", self.on_undo_checkpointed)
235
        self.connect("next-conflict-changed", self.on_next_conflict_changed)
Stephen Kennedy's avatar
Stephen Kennedy committed
236

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

240
        for statusbar, buf in zip(self.statusbar, self.textbuffer):
241 242 243 244
            buf.bind_property(
                'language', statusbar, 'source-language',
                GObject.BindingFlags.BIDIRECTIONAL)

245 246 247 248
            buf.data.bind_property(
                'encoding', statusbar, 'source-encoding',
                GObject.BindingFlags.DEFAULT)

249
            def reload_with_encoding(widget, encoding, pane):
250 251
                buffer = self.textbuffer[pane]
                if not self.check_unsaved_changes([buffer]):
252
                    return
253
                self.set_file(pane, buffer.data.gfile, encoding)
254

255
            def go_to_line(widget, line, pane):
256
                self.move_cursor(pane, line, focus=False)
257

258 259
            pane = self.statusbar.index(statusbar)
            statusbar.connect('encoding-changed', reload_with_encoding, pane)
260
            statusbar.connect('go-to-line', go_to_line, pane)
261

262 263
        # Prototype implementation

Kai Willadsen's avatar
Kai Willadsen committed
264 265
        from meld.gutterrendererchunk import (
            GutterRendererChunkAction, GutterRendererChunkLines)
266 267 268

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

271 272
            if pane == 0 or (pane == 1 and self.num_panes == 3):
                window = Gtk.TextWindowType.RIGHT
273 274
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.LEFT
275
                views = [self.textview[pane], self.textview[pane + 1]]
Kai Willadsen's avatar
Kai Willadsen committed
276 277
                renderer = GutterRendererChunkAction(
                    pane, pane + 1, views, self, self.linediffer)
278 279 280 281
                gutter = t.get_gutter(window)
                gutter.insert(renderer, 10)
            if pane in (1, 2):
                window = Gtk.TextWindowType.LEFT
282 283
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.RIGHT
284
                views = [self.textview[pane], self.textview[pane - 1]]
Kai Willadsen's avatar
Kai Willadsen committed
285 286
                renderer = GutterRendererChunkAction(
                    pane, pane - 1, views, self, self.linediffer)
287
                gutter = t.get_gutter(window)
288
                gutter.insert(renderer, -40)
289

290 291 292
            # TODO: This renderer handling should all be part of
            # MeldSourceView, but our current diff-chunk-handling makes
            # this difficult.
293 294 295
            window = Gtk.TextWindowType.LEFT
            if direction == Gtk.TextDirection.RTL:
                window = Gtk.TextWindowType.RIGHT
Kai Willadsen's avatar
Kai Willadsen committed
296 297
            renderer = GutterRendererChunkLines(
                pane, pane - 1, self.linediffer)
298 299
            gutter = t.get_gutter(window)
            gutter.insert(renderer, -30)
300
            t.line_renderer = renderer
301

302
        self.connect("notify::ignore-blank-lines", self.refresh_comparison)
303

304 305
    def get_keymask(self):
        return self._keymask
Kai Willadsen's avatar
Kai Willadsen committed
306

307 308 309 310 311 312 313 314 315 316 317
    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)

318 319 320 321 322 323 324 325 326 327
    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:
Robert Roth's avatar
Robert Roth committed
328 329
            if event.keyval == Gdk.KEY_Return and self.keymask & MASK_SHIFT:
                self.findbar.start_find_previous(self.focus_pane)
330 331
            self.keymask &= ~mod_key

332 333 334
    def on_focus_change(self):
        self.keymask = 0

335 336 337
    def on_text_filters_changed(self, app):
        relevant_change = self.create_text_filters()
        if relevant_change:
338
            self.refresh_comparison()
339 340 341 342

    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]
343 344 345
        new_active = [
            f.filter_string for f in meldsettings.text_filters if f.active
        ]
346 347
        active_filters_changed = old_active != new_active

348
        self.text_filters = [copy.copy(f) for f in meldsettings.text_filters]
349 350 351

        return active_filters_changed

352 353
    def _disconnect_buffer_handlers(self):
        for textview in self.textview:
354
            textview.set_sensitive(False)
355
        for buf in self.textbuffer:
356 357
            for h in buf.handlers:
                buf.disconnect(h)
358
            buf.handlers = []
359 360

    def _connect_buffer_handlers(self):
361 362
        for textview in self.textview:
            textview.set_sensitive(True)
363
        for buf in self.textbuffer:
364 365 366
            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)
367 368 369 370
            id3 = buf.connect_after(
                "delete-range", self.after_text_delete_range)
            id4 = buf.connect(
                "notify::cursor-position", self.on_cursor_position_changed)
371
            buf.handlers = id0, id1, id2, id3, id4
372

373 374 375 376 377 378 379 380
    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)
381
        offset = self.textview[pane].get_visual_column(cursor_it)
382 383
        line = cursor_it.get_line()

384
        self.statusbar[pane].props.cursor_position = (line, offset)
385 386

        if line != self.cursor.line or force:
Kai Willadsen's avatar
Kai Willadsen committed
387
            chunk, prev, next_ = self.linediffer.locate_chunk(pane, line)
388
            if chunk != self.cursor.chunk or force:
389 390
                self.cursor.chunk = chunk
                self.emit("current-diff-changed")
Kai Willadsen's avatar
Kai Willadsen committed
391
            if prev != self.cursor.prev or next_ != self.cursor.next or force:
392 393
                self.emit(
                    "next-diff-changed", prev is not None, next_ is not None)
394 395 396 397 398

            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
399
                if next_ is not None and conflict >= next_:
400 401 402
                    next_conflict = conflict
                    break
            if prev_conflict != self.cursor.prev_conflict or \
403
               next_conflict != self.cursor.next_conflict or force:
404 405 406
                self.emit("next-conflict-changed", prev_conflict is not None,
                          next_conflict is not None)

Kai Willadsen's avatar
Kai Willadsen committed
407
            self.cursor.prev, self.cursor.next = prev, next_
408 409
            self.cursor.prev_conflict = prev_conflict
            self.cursor.next_conflict = next_conflict
410
        self.cursor.line, self.cursor.offset = line, offset
411

412
    def on_current_diff_changed(self, widget, *args):
413 414 415 416 417 418 419
        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

420
        if pane == -1 or chunk_id is None:
421 422
            push_left, push_right, pull_left, pull_right, delete, \
                copy_left, copy_right = (False,) * 7
423
        else:
424 425 426
            push_left, push_right, pull_left, pull_right, delete, \
                copy_left, copy_right = (True,) * 7

427 428 429 430 431 432
            # 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.
433 434
            editable = self.textview[pane].get_editable()
            editable_left = pane > 0 and self.textview[pane - 1].get_editable()
Kai Willadsen's avatar
Kai Willadsen committed
435 436 437 438
            editable_right = (
                pane < self.num_panes - 1 and
                self.textview[pane + 1].get_editable()
            )
439 440
            if pane == 0 or pane == 2:
                chunk = self.linediffer.get_chunk(chunk_id, pane)
441 442
                is_insert = chunk[1] == chunk[2]
                is_delete = chunk[3] == chunk[4]
443 444
                push_left = editable_left
                push_right = editable_right
445 446 447 448 449
                pull_left = pane == 2 and editable and not is_delete
                pull_right = pane == 0 and editable and not is_delete
                delete = editable and not is_insert
                copy_left = editable_left and not is_insert or is_delete
                copy_right = editable_right and not is_insert or is_delete
450
            elif pane == 1:
451
                chunk0 = self.linediffer.get_chunk(chunk_id, 1, 0)
452 453
                chunk2 = None
                if self.num_panes == 3:
454
                    chunk2 = self.linediffer.get_chunk(chunk_id, 1, 2)
455 456 457 458
                left_mid_exists = bool(chunk0 and chunk0[1] != chunk0[2])
                left_exists = bool(chunk0 and chunk0[3] != chunk0[4])
                right_mid_exists = bool(chunk2 and chunk2[1] != chunk2[2])
                right_exists = bool(chunk2 and chunk2[3] != chunk2[4])
459 460
                push_left = editable_left
                push_right = editable_right
461 462 463
                pull_left = editable and left_exists
                pull_right = editable and right_exists
                delete = editable and (left_mid_exists or right_mid_exists)
464
                copy_left = editable_left and left_mid_exists and left_exists
Kai Willadsen's avatar
Kai Willadsen committed
465 466
                copy_right = (
                    editable_right and right_mid_exists and right_exists)
467 468 469 470 471
        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)
472 473 474 475
        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)
476 477 478 479 480

        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)
481 482
        # FIXME: don't queue_draw() on everything... just on what changed
        self.queue_draw()
483

484 485 486 487
    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)

488 489 490 491 492 493 494 495 496 497
    def scroll_to_chunk_index(self, chunk_index, tolerance):
        """Scrolls chunks with the given index on screen in all panes"""
        starts = self.linediffer.get_chunk_starts(chunk_index)
        for pane, start in enumerate(starts):
            if start is None:
                continue
            buf = self.textbuffer[pane]
            it = buf.get_iter_at_line(start)
            self.textview[pane].scroll_to_iter(it, tolerance, True, 0.5, 0.5)

498
    def go_to_chunk(self, target, pane=None, centered=False):
499 500 501
        if target is None:
            return

502 503 504
        if pane is None:
            pane = self._get_focused_pane()
            if pane == -1:
505
                pane = 1 if self.num_panes > 1 else 0
506 507 508 509 510 511 512 513 514 515

        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]))

516 517
        # Scroll all panes to the given chunk, and then ensure that the newly
        # placed cursor is definitely on-screen.
518
        tolerance = 0.0 if centered else 0.2
519
        self.scroll_to_chunk_index(target, tolerance)
520 521 522
        self.textview[pane].scroll_to_mark(
            buf.get_insert(), tolerance, True, 0.5, 0.5)

523 524 525 526 527 528 529 530 531 532
        # If we've moved to a valid chunk (or stayed in the first/last chunk)
        # then briefly highlight the chunk for better visual orientation.
        chunk_start = buf.get_iter_at_line_or_eof(chunk[1])
        chunk_end = buf.get_iter_at_line_or_eof(chunk[2])
        mark0 = buf.create_mark(None, chunk_start, True)
        mark1 = buf.create_mark(None, chunk_end, True)
        self.textview[pane].add_fading_highlight(
            mark0, mark1, 'focus-highlight', 400000, starting_alpha=0.3,
            anim_type=TextviewLineAnimationType.stroke)

533 534 535
    def on_linkmap_scroll_event(self, linkmap, event):
        self.next_diff(event.direction)

536 537 538
    def next_diff(self, direction, centered=False):
        target = (self.cursor.next if direction == Gdk.ScrollDirection.DOWN
                  else self.cursor.prev)
539
        self.go_to_chunk(target, centered=centered)
540 541 542 543 544 545 546 547 548 549 550 551

    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)
552

553 554 555 556 557
    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")
558 559

        chunk = self.linediffer.get_chunk(self.cursor.chunk, src, dst)
560 561 562
        if chunk is None:
            raise ValueError("Action was taken on a missing chunk")
        return chunk
563

564
    def get_action_panes(self, direction, reverse=False):
565 566
        src = self._get_focused_pane()
        dst = src + direction
567 568 569
        return (dst, src) if reverse else (src, dst)

    def action_push_change_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
570
        src, dst = self.get_action_panes(PANE_LEFT)
571 572 573
        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
574
        src, dst = self.get_action_panes(PANE_RIGHT)
575 576 577
        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
578
        src, dst = self.get_action_panes(PANE_LEFT, reverse=True)
579 580 581
        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
582
        src, dst = self.get_action_panes(PANE_RIGHT, reverse=True)
583 584 585
        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
586
        src, dst = self.get_action_panes(PANE_LEFT)
587 588 589 590
        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
591
        src, dst = self.get_action_panes(PANE_RIGHT)
592 593 594 595
        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
596
        src, dst = self.get_action_panes(PANE_LEFT)
597 598 599 600
        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
601
        src, dst = self.get_action_panes(PANE_RIGHT)
602 603
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=False)
604

605
    def pull_all_non_conflicting_changes(self, src, dst):
606
        merger = Merger()
607
        merger.differ = self.linediffer
608
        merger.texts = self.buffer_texts
609 610
        for mergedfile in merger.merge_2_files(src, dst):
            pass
611
        self._sync_vscroll_lock = True
612
        self.textbuffer[dst].begin_user_action()
613
        self.textbuffer[dst].set_text(mergedfile)
614
        self.textbuffer[dst].end_user_action()
615

616 617 618 619
        def resync():
            self._sync_vscroll_lock = False
            self._sync_vscroll(self.scrolledwindow[src].get_vadjustment(), src)
        self.scheduler.add_task(resync)
620

621
    def action_pull_all_changes_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
622
        src, dst = self.get_action_panes(PANE_LEFT, reverse=True)
623 624 625
        self.pull_all_non_conflicting_changes(src, dst)

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

629
    def merge_all_non_conflicting_changes(self, *args):
630
        dst = 1
631
        merger = Merger()
632
        merger.differ = self.linediffer
633
        merger.texts = self.buffer_texts
634 635
        for mergedfile in merger.merge_3_files(False):
            pass
636
        self._sync_vscroll_lock = True
637
        self.textbuffer[dst].begin_user_action()
638
        self.textbuffer[dst].set_text(mergedfile)
639
        self.textbuffer[dst].end_user_action()
Kai Willadsen's avatar
Kai Willadsen committed
640

641 642 643 644
        def resync():
            self._sync_vscroll_lock = False
            self._sync_vscroll(self.scrolledwindow[0].get_vadjustment(), 0)
        self.scheduler.add_task(resync)
645

646
    @with_focused_pane
647
    def delete_change(self, pane, *args):
648
        chunk = self.linediffer.get_chunk(self.cursor.chunk, pane)
649
        assert(self.cursor.chunk is not None)
650 651 652
        assert(chunk is not None)
        self.delete_chunk(pane, chunk)

653 654 655 656 657 658 659 660 661 662 663 664 665 666 667
    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
668
        _, prev, next_ = self.linediffer.locate_chunk(pane0, line)
669 670 671 672 673 674 675 676 677 678
        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
679 680 681 682
        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)
683 684 685 686
                if None not in (next_chunk0, next_chunk1):
                    end0 = next_chunk0[1]
                    end1 = next_chunk1[1]
                    break
Kai Willadsen's avatar
Kai Willadsen committed
687
                next_ += 1
688

Kai Willadsen's avatar
Kai Willadsen committed
689 690
        # TODO: Move myers.DiffChunk to a more general place, update
        # this to use it, and update callers to use nice attributes.
691 692 693 694 695
        return "Same", start0, end0, start1, end1

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

Kai Willadsen's avatar
Kai Willadsen committed
696
        new_buf = self.textbuffer[new_pane]
697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718

        # 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()

Kai Willadsen's avatar
Kai Willadsen committed
719 720
        cursor_chunk, _, _ = self.linediffer.locate_chunk(
            new_pane, cursor_line)
721 722 723 724
        if cursor_chunk is not None:
            already_in_chunk = cursor_chunk == chunk
        else:
            cursor_chunk = self._synth_chunk(pane, new_pane, cursor_line)
Kai Willadsen's avatar
Kai Willadsen committed
725 726
            already_in_chunk = (
                cursor_chunk[3] == new_start and cursor_chunk[4] == new_end)
727 728 729 730 731 732 733 734 735 736 737 738 739 740 741

        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

742
    def move_cursor(self, pane, line, focus=True):
743
        buf, view = self.textbuffer[pane], self.textview[pane]
744 745
        if focus:
            view.grab_focus()
746 747 748
        buf.place_cursor(buf.get_iter_at_line(line))
        view.scroll_to_mark(buf.get_insert(), 0.1, True, 0.5, 0.5)

749
    def move_cursor_pane(self, pane, new_pane):
750 751
        chunk, line = self.cursor.chunk, self.cursor.line
        new_line = self._corresponding_chunk_line(chunk, line, pane, new_pane)
752
        self.move_cursor(pane, new_line)
753

754 755 756 757 758 759 760 761 762 763
    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)

764 765 766 767 768 769 770 771
    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

772
    def on_textview_focus_in_event(self, view, event):
773
        self.focus_pane = view
774
        self.findbar.textview = view
775
        self.on_cursor_position_changed(view.get_buffer(), None, True)
776
        self._set_save_action_sensitivity()
777
        self._set_merge_action_sensitivity()
778
        self._set_external_action_sensitivity()
779
        self.update_text_actions_sensitivity()
Stephen Kennedy's avatar
Stephen Kennedy committed
780

781 782
    def on_textview_focus_out_event(self, view, event):
        self._set_merge_action_sensitivity()
783
        self._set_external_action_sensitivity()
784

785
    def _after_text_modified(self, buf, startline, sizechange):
786
        if self.num_panes > 1:
787
            pane = self.textbuffer.index(buf)
788
            if not self.linediffer.syncpoints:
789 790
                self.linediffer.change_sequence(pane, startline, sizechange,
                                                self.buffer_filtered)
791 792 793
            # TODO: We should have a diff-changed signal for the
            # current buffer instead of passing everything through
            # cursor change logic.
794 795 796 797
            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
798
            self.queue_draw()
steve9000's avatar
steve9000 committed
799

800
    def _filter_text(self, txt, buf, txt_start_iter, txt_end_iter):
David Rabel's avatar
David Rabel committed
801
        dimmed_tag = buf.get_tag_table().lookup("dimmed")
802
        buf.remove_tag(dimmed_tag, txt_start_iter, txt_end_iter)
David Rabel's avatar
David Rabel committed
803

804
        def highlighter(start, end):
805 806 807 808 809
            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)
David Rabel's avatar
David Rabel committed
810

811 812
        try:
            regexes = [f.filter for f in self.text_filters if f.active]
813
            txt = misc.apply_text_filters(txt, regexes, apply_fn=highlighter)
814
        except AssertionError:
Stephen Kennedy's avatar
Stephen Kennedy committed
815
            if not self.warned_bad_comparison:
816 817 818
                misc.error_dialog(
                    primary=_(u"Comparison results will be inaccurate"),
                    secondary=_(
819
                        u"A filter changed the number of lines in the "
820
                        u"file, which is unsupported. The comparison will "
821
                        u"not be accurate."),
822
                )
823
                self.warned_bad_comparison = True
824

825 826
        return txt

827 828 829 830 831 832
    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)
833

834
    def after_text_delete_range(self, buf, it0, it1):
Stephen Kennedy's avatar
Stephen Kennedy committed
835
        starting_at = it0.get_line()
836 837
        self._after_text_modified(buf, starting_at, -self.lines_removed)
        self.lines_removed = 0
838

839
    def check_save_modified(self, buffers=None):
840
        response = Gtk.ResponseType.OK
841 842 843 844
        buffers = buffers or self.textbuffer[:self.num_panes]
        modified = [b.get_modified() for b in buffers]
        labels = [b.data.label for b in buffers]
        if any(modified):
845
            dialog = Component("filediff.ui", "check_save_dialog")
846
            dialog.widget.set_transient_for(self.widget.get_toplevel())
847
            message_area = dialog.widget.get_message_area()
848
            buttons = []
849 850 851 852 853 854
            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)
855
                buttons.append(button)
856
            message_area.show_all()
857
            response = dialog.widget.run()
858
            try_save = [b.get_active() for b in buttons]
859
            dialog.widget.destroy()
860
            if response == Gtk.ResponseType.OK and any(try_save):
861 862
                for i in range(self.num_panes):
                    if try_save[i]:
863 864
                        self.save_file(i)
                return Gtk.ResponseType.CANCEL
865

866 867 868
        if response == Gtk.ResponseType.DELETE_EVENT:
            response = Gtk.ResponseType.CANCEL
        elif response == Gtk.ResponseType.CLOSE:
869 870
            response = Gtk.ResponseType.OK

871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886
        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:
887
                    bufdata = self.textbuffer[1].data
888
                    conflict_gfile = bufdata.savefile or bufdata.gfile
889 890 891
                    # It's possible that here we're in a quit callback,
                    # so we can't schedule the resolve action to an
                    # idle loop; it might never happen.
892 893
                    parent.command(
                        'resolve', [conflict_gfile.get_path()], sync=True)
894
        elif response == Gtk.ResponseType.CANCEL:
895
            self.state = ComparisonState.Normal
896

897 898
        return response

899
    def on_delete_event(self):
900
        self.state = ComparisonState.Closing
901
        response = self.check_save_modified()
902
        if response == Gtk.ResponseType.OK:
903 904
            for h in self.settings_handlers:
                meldsettings.disconnect(h)
905 906 907
            # TODO: This should not be necessary; remove if and when we
            # figure out what's keeping MeldDocs alive for too long.
            del self._cached_match
908 909
            # TODO: Base the return code on something meaningful for VC tools
            self.emit('close', 0)
910
        return response
steve9000's avatar
steve9000 committed
911

912 913 914 915 916 917 918 919 920
    def _scroll_to_actions(self, actions):
        """Scroll all views affected by *actions* to the current cursor"""

        affected_buffers = set(a.buffer for a in actions)
        for buf in affected_buffers:
            buf_index = self.textbuffer.index(buf)
            view = self.textview[buf_index]
            view.scroll_mark_onscreen(buf.get_insert())

921 922
    def on_undo_activate(self):
        if self.undosequence.can_undo():
923 924
            actions = self.undosequence.undo()
        self._scroll_to_actions(actions)
925 926 927

    def on_redo_activate(self):
        if self.undosequence.can_redo():
928 929
            actions = self.undosequence.redo()
        self._scroll_to_actions(actions)
930

931
    def on_text_insert_text(self, buf, it, text, textlen):
932
        self.undosequence.add_action(
933
            BufferInsertionAction(buf, it.get_offset(), text))
934
        buf.create_mark("insertion-start", it, True)
steve9000's avatar
steve9000 committed
935

936
    def on_text_delete_range(self, buf, it0, it1):
937
        text = buf.get_text(it0, it1, False)
938
        self.lines_removed = it1.get_line() - it0.get_line()
939
        self.undosequence.add_action(
940
            BufferDeletionAction(buf, it0.get_offset(), text))
941 942

    def on_undo_checkpointed(self, undosequence, buf, checkpointed):
943
        buf.set_modified(not checkpointed)
944
        self.recompute_label()
945

946 947
    @with_focused_pane
    def open_external(self, pane):
948
        if not self.textbuffer[pane].data.gfile:
949 950 951 952
            return
        pos = self.textbuffer[pane].props.cursor_position
        cursor_it = self.textbuffer[pane].get_iter_at_offset(pos)
        line = cursor_it.get_line() + 1
953 954 955
        # TODO: Support URI-based opens
        path = self.textbuffer[pane].data.gfile.get_path()
        self._open_files([path], line)
956

957 958 959 960 961 962 963 964 965
    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(
966
            #    Gdk.SELECTION_CLIPBOARD).wait_is_text_available()
967
            paste = widget.get_editable()
968 969 970 971
        if self.main_actiongroup:
            for action, sens in zip(
                    ("Cut", "Copy", "Paste"), (cut, copy, paste)):
                self.main_actiongroup.get_action(action).set_sensitive(sens)
972

973 974
    @with_focused_pane
    def get_selected_text(self, pane):
975
        """Returns selected text of active pane"""
976 977 978
        buf = self.textbuffer[pane]
        sel = buf.get_selection_bounds()
        if sel:
979
            return buf.get_text(sel[0], sel[1], False)
980

981
    def on_find_activate(self, *args):