filediff.py 81.6 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 user_critical, 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 72 73 74 75 76
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


77
MASK_SHIFT, MASK_CTRL = 1, 2
Kai Willadsen's avatar
Kai Willadsen committed
78
PANE_LEFT, PANE_RIGHT = -1, +1
79

80

81
class CursorDetails(object):
82 83 84 85
    __slots__ = (
        "pane", "pos", "line", "offset", "chunk", "prev", "next",
        "prev_conflict", "next_conflict",
    )
86 87 88 89 90 91

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


92
class FileDiff(MeldDoc, Component):
93 94 95 96
    """Two or three way comparison of text files"""

    __gtype_name__ = "FileDiff"

97 98 99 100
    __gsettings_bindings__ = (
        ('ignore-blank-lines', 'ignore-blank-lines'),
    )

101
    ignore_blank_lines = GObject.Property(
102 103 104 105 106
        type=bool,
        nick="Ignore blank lines",
        blurb="Whether to ignore blank lines when comparing file contents",
        default=False,
    )
107

108
    differ = Differ
109

110 111 112 113 114 115
    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
116

117
    # Identifiers for MsgArea messages
118
    (MSG_SAME, MSG_SLOW_HIGHLIGHT, MSG_SYNCPOINTS) = list(range(3))
119 120 121
    # Transient messages that should be removed if any file in the
    # comparison gets reloaded.
    TRANSIENT_MESSAGES = {MSG_SAME, MSG_SLOW_HIGHLIGHT}
122

123
    __gsignals__ = {
Kai Willadsen's avatar
Kai Willadsen committed
124 125 126 127
        'next-conflict-changed': (
            GObject.SignalFlags.RUN_FIRST, None, (bool, bool)),
        'action-mode-changed': (
            GObject.SignalFlags.RUN_FIRST, None, (int,)),
128 129
    }

130
    def __init__(self, num_panes):
steve9000's avatar
steve9000 committed
131 132
        """Start up an filediff with num_panes empty contents.
        """
133 134
        MeldDoc.__init__(self)
        Component.__init__(
135
            self, "filediff.ui", "filediff", ["FilediffActions"])
136 137
        bind_settings(self)

138 139
        widget_lists = [
            "diffmap", "file_save_button", "file_toolbar", "fileentry",
140
            "linkmap", "msgarea_mgr", "readonlytoggle",
141
            "scrolledwindow", "selector_hbox", "textview", "vbox",
142
            "dummy_toolbar_linkmap", "filelabel_toolitem", "filelabel",
143
            "fileentry_toolitem", "dummy_toolbar_diffmap", "statusbar",
144 145
        ]
        self.map_widgets_into_lists(widget_lists)
146 147 148

        # This SizeGroup isn't actually necessary for FileDiff; it's for
        # handling non-homogenous selectors in FileComp. It's also fragile.
149
        column_sizes = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL)
150 151 152 153
        column_sizes.set_ignore_hidden(True)
        for widget in self.selector_hbox:
            column_sizes.add_widget(widget)

154
        self.warned_bad_comparison = False
155
        self._keymask = 0
156
        self.meta = {}
157
        self.lines_removed = 0
158
        self.focus_pane = None
159
        self.textbuffer = [v.get_buffer() for v in self.textview]
160
        self.buffer_texts = [BufferLines(b) for b in self.textbuffer]
161
        self.undosequence = UndoSequence(self.textbuffer)
162 163
        self.text_filters = []
        self.create_text_filters()
164
        self.settings_handlers = [
165 166
            meldsettings.connect(
                "text-filters-changed", self.on_text_filters_changed)
167
        ]
168 169 170
        self.buffer_filtered = [
            BufferLines(b, self._filter_text) for b in self.textbuffer
        ]
171 172 173
        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)
174
        self._connect_buffer_handlers()
175 176
        self._sync_vscroll_lock = False
        self._sync_hscroll_lock = False
177
        self._scroll_lock = False
178
        self.linediffer = self.differ()
179
        self.force_highlight = False
180
        self.syncpoints = []
181
        self.in_nested_textview_gutter_expose = False
182
        self._cached_match = CachedSequenceMatcher(self.scheduler)
183

184
        for buf in self.textbuffer:
185
            buf.undo_sequence = self.undosequence
186 187
            buf.connect("notify::has-selection",
                        self.update_text_actions_sensitivity)
188
            buf.data.connect('file-changed', self.notify_file_changed)
189

190
        self.ui_file = ui_file("filediff-ui.xml")
191
        self.actiongroup = self.FilediffActions
192
        self.actiongroup.set_translation_domain("meld")
193

194
        self.findbar = FindBar(self.grid)
195
        self.grid.attach(self.findbar.widget, 1, 2, 5, 1)
196

197
        self.set_num_panes(num_panes)
198
        self.cursor = CursorDetails()
199 200 201 202
        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)
203 204 205 206 207 208 209 210

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

211
        self.linediffer.connect("diffs-changed", self.on_diffs_changed)
212
        self.undosequence.connect("checkpointed", self.on_undo_checkpointed)
213
        self.connect("next-conflict-changed", self.on_next_conflict_changed)
Stephen Kennedy's avatar
Stephen Kennedy committed
214

215 216 217
        for diffmap in self.diffmap:
            self.linediffer.connect('diffs-changed', diffmap.on_diffs_changed)

218
        for statusbar, buf in zip(self.statusbar, self.textbuffer):
219 220 221 222
            buf.bind_property(
                'language', statusbar, 'source-language',
                GObject.BindingFlags.BIDIRECTIONAL)

223 224 225 226
            buf.data.bind_property(
                'encoding', statusbar, 'source-encoding',
                GObject.BindingFlags.DEFAULT)

227
            def reload_with_encoding(widget, encoding, pane):
228 229
                buffer = self.textbuffer[pane]
                if not self.check_unsaved_changes([buffer]):
230
                    return
231
                self.set_file(pane, buffer.data.gfile, encoding)
232

233
            def go_to_line(widget, line, pane):
234
                self.move_cursor(pane, line, focus=False)
235

236 237
            pane = self.statusbar.index(statusbar)
            statusbar.connect('encoding-changed', reload_with_encoding, pane)
238
            statusbar.connect('go-to-line', go_to_line, pane)
239

240 241
        # Prototype implementation

Kai Willadsen's avatar
Kai Willadsen committed
242 243
        from meld.gutterrendererchunk import (
            GutterRendererChunkAction, GutterRendererChunkLines)
244 245 246

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

249 250
            if pane == 0 or (pane == 1 and self.num_panes == 3):
                window = Gtk.TextWindowType.RIGHT
251 252
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.LEFT
253
                views = [self.textview[pane], self.textview[pane + 1]]
Kai Willadsen's avatar
Kai Willadsen committed
254 255
                renderer = GutterRendererChunkAction(
                    pane, pane + 1, views, self, self.linediffer)
256 257 258 259
                gutter = t.get_gutter(window)
                gutter.insert(renderer, 10)
            if pane in (1, 2):
                window = Gtk.TextWindowType.LEFT
260 261
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.RIGHT
262
                views = [self.textview[pane], self.textview[pane - 1]]
Kai Willadsen's avatar
Kai Willadsen committed
263 264
                renderer = GutterRendererChunkAction(
                    pane, pane - 1, views, self, self.linediffer)
265
                gutter = t.get_gutter(window)
266
                gutter.insert(renderer, -40)
267

268 269 270
            # TODO: This renderer handling should all be part of
            # MeldSourceView, but our current diff-chunk-handling makes
            # this difficult.
271 272 273
            window = Gtk.TextWindowType.LEFT
            if direction == Gtk.TextDirection.RTL:
                window = Gtk.TextWindowType.RIGHT
Kai Willadsen's avatar
Kai Willadsen committed
274 275
            renderer = GutterRendererChunkLines(
                pane, pane - 1, self.linediffer)
276 277
            gutter = t.get_gutter(window)
            gutter.insert(renderer, -30)
278
            t.line_renderer = renderer
279

280
        self.connect("notify::ignore-blank-lines", self.refresh_comparison)
281

282 283
    def get_keymask(self):
        return self._keymask
Kai Willadsen's avatar
Kai Willadsen committed
284

285 286 287 288 289 290 291 292 293 294 295
    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)

296 297 298 299 300 301 302 303 304 305
    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
306 307
            if event.keyval == Gdk.KEY_Return and self.keymask & MASK_SHIFT:
                self.findbar.start_find_previous(self.focus_pane)
308 309
            self.keymask &= ~mod_key

310 311 312
    def on_focus_change(self):
        self.keymask = 0

313 314 315
    def on_text_filters_changed(self, app):
        relevant_change = self.create_text_filters()
        if relevant_change:
316
            self.refresh_comparison()
317 318 319 320

    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]
321 322 323
        new_active = [
            f.filter_string for f in meldsettings.text_filters if f.active
        ]
324 325
        active_filters_changed = old_active != new_active

326
        self.text_filters = [copy.copy(f) for f in meldsettings.text_filters]
327 328 329

        return active_filters_changed

330 331
    def _disconnect_buffer_handlers(self):
        for textview in self.textview:
332
            textview.set_sensitive(False)
333
        for buf in self.textbuffer:
334 335
            for h in buf.handlers:
                buf.disconnect(h)
336
            buf.handlers = []
337 338

    def _connect_buffer_handlers(self):
339 340
        for textview in self.textview:
            textview.set_sensitive(True)
341
        for buf in self.textbuffer:
342 343 344
            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)
345 346 347 348
            id3 = buf.connect_after(
                "delete-range", self.after_text_delete_range)
            id4 = buf.connect(
                "notify::cursor-position", self.on_cursor_position_changed)
349
            buf.handlers = id0, id1, id2, id3, id4
350

351 352 353 354 355 356 357 358
    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)
359
        offset = self.textview[pane].get_visual_column(cursor_it)
360 361
        line = cursor_it.get_line()

362
        self.statusbar[pane].props.cursor_position = (line, offset)
363 364

        if line != self.cursor.line or force:
Kai Willadsen's avatar
Kai Willadsen committed
365
            chunk, prev, next_ = self.linediffer.locate_chunk(pane, line)
366
            if chunk != self.cursor.chunk or force:
367 368
                self.cursor.chunk = chunk
                self.emit("current-diff-changed")
Kai Willadsen's avatar
Kai Willadsen committed
369
            if prev != self.cursor.prev or next_ != self.cursor.next or force:
370 371
                self.emit(
                    "next-diff-changed", prev is not None, next_ is not None)
372 373 374 375 376

            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
377
                if next_ is not None and conflict >= next_:
378 379 380
                    next_conflict = conflict
                    break
            if prev_conflict != self.cursor.prev_conflict or \
381
               next_conflict != self.cursor.next_conflict or force:
382 383 384
                self.emit("next-conflict-changed", prev_conflict is not None,
                          next_conflict is not None)

Kai Willadsen's avatar
Kai Willadsen committed
385
            self.cursor.prev, self.cursor.next = prev, next_
386 387
            self.cursor.prev_conflict = prev_conflict
            self.cursor.next_conflict = next_conflict
388
        self.cursor.line, self.cursor.offset = line, offset
389

390
    def on_current_diff_changed(self, widget, *args):
391 392 393 394 395 396 397
        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

398
        if pane == -1 or chunk_id is None:
399 400
            push_left, push_right, pull_left, pull_right, delete, \
                copy_left, copy_right = (False,) * 7
401
        else:
402 403 404
            push_left, push_right, pull_left, pull_right, delete, \
                copy_left, copy_right = (True,) * 7

405 406 407 408 409 410
            # 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.
411 412
            editable = self.textview[pane].get_editable()
            editable_left = pane > 0 and self.textview[pane - 1].get_editable()
Kai Willadsen's avatar
Kai Willadsen committed
413 414 415 416
            editable_right = (
                pane < self.num_panes - 1 and
                self.textview[pane + 1].get_editable()
            )
417 418
            if pane == 0 or pane == 2:
                chunk = self.linediffer.get_chunk(chunk_id, pane)
419 420
                is_insert = chunk[1] == chunk[2]
                is_delete = chunk[3] == chunk[4]
421 422
                push_left = editable_left
                push_right = editable_right
423 424 425 426 427
                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
428
            elif pane == 1:
429
                chunk0 = self.linediffer.get_chunk(chunk_id, 1, 0)
430 431
                chunk2 = None
                if self.num_panes == 3:
432
                    chunk2 = self.linediffer.get_chunk(chunk_id, 1, 2)
433 434 435 436
                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])
437 438
                push_left = editable_left
                push_right = editable_right
439 440 441
                pull_left = editable and left_exists
                pull_right = editable and right_exists
                delete = editable and (left_mid_exists or right_mid_exists)
442
                copy_left = editable_left and left_mid_exists and left_exists
Kai Willadsen's avatar
Kai Willadsen committed
443 444
                copy_right = (
                    editable_right and right_mid_exists and right_exists)
445 446 447 448 449
        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)
450 451 452 453
        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)
454 455 456 457 458

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

462 463 464 465
    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)

466 467 468 469 470 471 472 473 474 475
    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)

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

480 481 482
        if pane is None:
            pane = self._get_focused_pane()
            if pane == -1:
483
                pane = 1 if self.num_panes > 1 else 0
484 485 486 487 488 489 490 491 492 493

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

494 495
        # Scroll all panes to the given chunk, and then ensure that the newly
        # placed cursor is definitely on-screen.
496
        tolerance = 0.0 if centered else 0.2
497
        self.scroll_to_chunk_index(target, tolerance)
498 499 500
        self.textview[pane].scroll_to_mark(
            buf.get_insert(), tolerance, True, 0.5, 0.5)

501 502 503 504 505 506 507 508 509 510
        # 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)

511 512 513
    def on_linkmap_scroll_event(self, linkmap, event):
        self.next_diff(event.direction)

514 515 516
    def next_diff(self, direction, centered=False):
        target = (self.cursor.next if direction == Gdk.ScrollDirection.DOWN
                  else self.cursor.prev)
517
        self.go_to_chunk(target, centered=centered)
518 519 520 521 522 523 524 525 526 527 528 529

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

531 532 533 534 535
    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")
536 537

        chunk = self.linediffer.get_chunk(self.cursor.chunk, src, dst)
538 539 540
        if chunk is None:
            raise ValueError("Action was taken on a missing chunk")
        return chunk
541

542
    def get_action_panes(self, direction, reverse=False):
543 544
        src = self._get_focused_pane()
        dst = src + direction
545 546 547
        return (dst, src) if reverse else (src, dst)

    def action_push_change_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
548
        src, dst = self.get_action_panes(PANE_LEFT)
549 550 551
        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
552
        src, dst = self.get_action_panes(PANE_RIGHT)
553 554 555
        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
556
        src, dst = self.get_action_panes(PANE_LEFT, reverse=True)
557 558 559
        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
560
        src, dst = self.get_action_panes(PANE_RIGHT, reverse=True)
561 562 563
        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
564
        src, dst = self.get_action_panes(PANE_LEFT)
565 566 567 568
        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
569
        src, dst = self.get_action_panes(PANE_RIGHT)
570 571 572 573
        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
574
        src, dst = self.get_action_panes(PANE_LEFT)
575 576 577 578
        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
579
        src, dst = self.get_action_panes(PANE_RIGHT)
580 581
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=False)
582

583
    def pull_all_non_conflicting_changes(self, src, dst):
584
        merger = Merger()
585
        merger.differ = self.linediffer
586
        merger.texts = self.buffer_texts
587 588
        for mergedfile in merger.merge_2_files(src, dst):
            pass
589
        self._sync_vscroll_lock = True
590
        self.textbuffer[dst].begin_user_action()
591
        self.textbuffer[dst].set_text(mergedfile)
592
        self.textbuffer[dst].end_user_action()
593

594 595 596 597
        def resync():
            self._sync_vscroll_lock = False
            self._sync_vscroll(self.scrolledwindow[src].get_vadjustment(), src)
        self.scheduler.add_task(resync)
598

599
    def action_pull_all_changes_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
600
        src, dst = self.get_action_panes(PANE_LEFT, reverse=True)
601 602 603
        self.pull_all_non_conflicting_changes(src, dst)

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

607
    def merge_all_non_conflicting_changes(self, *args):
608
        dst = 1
609
        merger = Merger()
610
        merger.differ = self.linediffer
611
        merger.texts = self.buffer_texts
612 613
        for mergedfile in merger.merge_3_files(False):
            pass
614
        self._sync_vscroll_lock = True
615
        self.textbuffer[dst].begin_user_action()
616
        self.textbuffer[dst].set_text(mergedfile)
617
        self.textbuffer[dst].end_user_action()
Kai Willadsen's avatar
Kai Willadsen committed
618

619 620 621 622
        def resync():
            self._sync_vscroll_lock = False
            self._sync_vscroll(self.scrolledwindow[0].get_vadjustment(), 0)
        self.scheduler.add_task(resync)
623

624
    @with_focused_pane
625
    def delete_change(self, pane, *args):
626
        chunk = self.linediffer.get_chunk(self.cursor.chunk, pane)
627
        assert(self.cursor.chunk is not None)
628 629 630
        assert(chunk is not None)
        self.delete_chunk(pane, chunk)

631 632 633 634 635 636 637 638 639 640 641 642 643 644 645
    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
646
        _, prev, next_ = self.linediffer.locate_chunk(pane0, line)
647 648 649 650 651 652 653 654 655 656
        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
657 658 659 660
        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)
661 662 663 664
                if None not in (next_chunk0, next_chunk1):
                    end0 = next_chunk0[1]
                    end1 = next_chunk1[1]
                    break
Kai Willadsen's avatar
Kai Willadsen committed
665
                next_ += 1
666

Kai Willadsen's avatar
Kai Willadsen committed
667 668
        # TODO: Move myers.DiffChunk to a more general place, update
        # this to use it, and update callers to use nice attributes.
669 670 671 672 673
        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
674
        new_buf = self.textbuffer[new_pane]
675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696

        # 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
697 698
        cursor_chunk, _, _ = self.linediffer.locate_chunk(
            new_pane, cursor_line)
699 700 701 702
        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
703 704
            already_in_chunk = (
                cursor_chunk[3] == new_start and cursor_chunk[4] == new_end)
705 706 707 708 709 710 711 712 713 714 715 716 717 718 719

        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

720
    def move_cursor(self, pane, line, focus=True):
721
        buf, view = self.textbuffer[pane], self.textview[pane]
722 723
        if focus:
            view.grab_focus()
724 725 726
        buf.place_cursor(buf.get_iter_at_line(line))
        view.scroll_to_mark(buf.get_insert(), 0.1, True, 0.5, 0.5)

727
    def move_cursor_pane(self, pane, new_pane):
728 729
        chunk, line = self.cursor.chunk, self.cursor.line
        new_line = self._corresponding_chunk_line(chunk, line, pane, new_pane)
730
        self.move_cursor(pane, new_line)
731

732 733 734 735 736 737 738 739 740 741
    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)

742 743 744 745 746 747 748 749
    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

750
    def on_textview_focus_in_event(self, view, event):
751
        self.focus_pane = view
752
        self.findbar.textview = view
753
        self.on_cursor_position_changed(view.get_buffer(), None, True)
754
        self._set_save_action_sensitivity()
755
        self._set_merge_action_sensitivity()
756
        self._set_external_action_sensitivity()
757
        self.update_text_actions_sensitivity()
Stephen Kennedy's avatar
Stephen Kennedy committed
758

759 760
    def on_textview_focus_out_event(self, view, event):
        self._set_merge_action_sensitivity()
761
        self._set_external_action_sensitivity()
762

763
    def _after_text_modified(self, buf, startline, sizechange):
764
        if self.num_panes > 1:
765
            pane = self.textbuffer.index(buf)
766
            if not self.linediffer.syncpoints:
767 768
                self.linediffer.change_sequence(pane, startline, sizechange,
                                                self.buffer_filtered)
769 770 771
            # TODO: We should have a diff-changed signal for the
            # current buffer instead of passing everything through
            # cursor change logic.
772 773 774 775
            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
776
            self.queue_draw()
steve9000's avatar
steve9000 committed
777

778
    def _filter_text(self, txt, buf, txt_start_iter, txt_end_iter):
David Rabel's avatar
David Rabel committed
779
        dimmed_tag = buf.get_tag_table().lookup("dimmed")
780
        buf.remove_tag(dimmed_tag, txt_start_iter, txt_end_iter)
David Rabel's avatar
David Rabel committed
781

782
        def highlighter(start, end):
783 784 785 786 787
            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
788

789 790
        try:
            regexes = [f.filter for f in self.text_filters if f.active]
791
            txt = misc.apply_text_filters(txt, regexes, apply_fn=highlighter)
792
        except AssertionError:
Stephen Kennedy's avatar
Stephen Kennedy committed
793
            if not self.warned_bad_comparison:
794 795 796
                misc.error_dialog(
                    primary=_(u"Comparison results will be inaccurate"),
                    secondary=_(
797
                        u"A filter changed the number of lines in the "
798
                        u"file, which is unsupported. The comparison will "
799
                        u"not be accurate."),
800
                )
801
                self.warned_bad_comparison = True
802

803 804
        return txt

805 806 807 808 809 810
    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)
811

812
    def after_text_delete_range(self, buf, it0, it1):
Stephen Kennedy's avatar
Stephen Kennedy committed
813
        starting_at = it0.get_line()
814 815
        self._after_text_modified(buf, starting_at, -self.lines_removed)
        self.lines_removed = 0
816

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

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

849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864
        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:
865
                    bufdata = self.textbuffer[1].data
866
                    conflict_gfile = bufdata.savefile or bufdata.gfile
867 868 869
                    # 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.
870 871
                    parent.command(
                        'resolve', [conflict_gfile.get_path()], sync=True)
872
        elif response == Gtk.ResponseType.CANCEL:
873
            self.state = ComparisonState.Normal
874

875 876
        return response

877
    def on_delete_event(self):
878
        self.state = ComparisonState.Closing
879
        response = self.check_save_modified()
880
        if response == Gtk.ResponseType.OK:
881 882
            for h in self.settings_handlers:
                meldsettings.disconnect(h)
883 884 885
            # 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
886 887
            # TODO: Base the return code on something meaningful for VC tools
            self.emit('close', 0)
888
        return response
steve9000's avatar
steve9000 committed
889

890 891 892 893 894 895 896 897 898
    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())

899 900
    def on_undo_activate(self):
        if self.undosequence.can_undo():
901 902
            actions = self.undosequence.undo()
        self._scroll_to_actions(actions)
903 904 905

    def on_redo_activate(self):
        if self.undosequence.can_redo():
906 907
            actions = self.undosequence.redo()
        self._scroll_to_actions(actions)
908

909
    def on_text_insert_text(self, buf, it, text, textlen):
910
        self.undosequence.add_action(
911
            BufferInsertionAction(buf, it.get_offset(), text))
912
        buf.create_mark("insertion-start", it, True)
steve9000's avatar
steve9000 committed
913

914
    def on_text_delete_range(self, buf, it0, it1):
915
        text = buf.get_text(it0, it1, False)
916
        self.lines_removed = it1.get_line() - it0.get_line()
917
        self.undosequence.add_action(
918
            BufferDeletionAction(buf, it0.get_offset(), text))
919 920

    def on_undo_checkpointed(self, undosequence, buf, checkpointed):
921
        buf.set_modified(not checkpointed)
922
        self.recompute_label()
923

924 925
    @with_focused_pane
    def open_external(self, pane):
926
        if not self.textbuffer[pane].data.gfile:
927 928 929 930
            return
        pos = self.textbuffer[pane].props.cursor_position
        cursor_it = self.textbuffer[pane].get_iter_at_offset(pos)
        line = cursor_it.get_line() + 1
931 932 933
        # TODO: Support URI-based opens
        path = self.textbuffer[pane].data.gfile.get_path()
        self._open_files([path], line)
934

935 936 937 938 939 940 941 942 943
    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(
944
            #    Gdk.SELECTION_CLIPBOARD).wait_is_text_available()
945
            paste = widget.get_editable()
946 947 948 949
        if self.main_actiongroup:
            for action, sens in zip(
                    ("Cut", "Copy", "Paste"), (cut, copy, paste)):
                self.main_actiongroup.get_action(action).set_sensitive(sens)
950

951 952
    @with_focused_pane
    def get_selected_text(self, pane):
953
        """Returns selected text of active pane"""
954 955 956
        buf = self.textbuffer[pane]
        sel = buf.get_selection_bounds()
        if sel:
957
            return buf.get_text(sel[0], sel[1], False)
958

959
    def on_find_activate(self, *args):
960
        selected_text = self.get_selected_text()
961
        self.findbar.start_find(self.focus_pane, selected_text)
962 963 964
        self.keymask = 0

    def on_replace_activate(self, *args):
965
        selected_text = self.get_selected_text()
966
        self.findbar.start_replace(self.focus_pane, selected_text)