filediff.py 82.1 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
import os
steve9000's avatar
steve9000 committed
21

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

29
from meld.conf import _
30 31 32 33
from . import meldbuffer
from . import melddoc
from . import misc
from . import undo
34
from .ui import filechooser
35 36
from .ui import gnomeglade

37
from meld.const import MODE_REPLACE, MODE_DELETE, MODE_INSERT, NEWLINES
38
from meld.matchers.diffutil import Differ, merged_chunk_order
39
from meld.matchers.helpers import CachedSequenceMatcher
40 41
from meld.matchers.merge import Merger
from meld.patchdialog import PatchDialog
42
from meld.recent import RecentType
43
from meld.settings import bind_settings, meldsettings
44 45
from meld.sourceview import (
    LanguageManager, TextviewLineAnimationType, get_custom_encoding_candidates)
46
from meld.ui.findbar import FindBar
47

48

49 50 51 52 53 54 55 56 57 58
def with_focused_pane(function):
    @functools.wraps(function)
    def wrap_function(*args, **kwargs):
        pane = args[0]._get_focused_pane()
        if pane == -1:
            return
        return function(args[0], pane, *args[1:], **kwargs)
    return wrap_function


59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
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


86
MASK_SHIFT, MASK_CTRL = 1, 2
Kai Willadsen's avatar
Kai Willadsen committed
87
PANE_LEFT, PANE_RIGHT = -1, +1
88

89

90
class CursorDetails(object):
91 92 93 94
    __slots__ = (
        "pane", "pos", "line", "offset", "chunk", "prev", "next",
        "prev_conflict", "next_conflict",
    )
95 96 97 98 99 100

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


101
class FileDiff(melddoc.MeldDoc, gnomeglade.Component):
102 103 104 105
    """Two or three way comparison of text files"""

    __gtype_name__ = "FileDiff"

106 107 108 109
    __gsettings_bindings__ = (
        ('ignore-blank-lines', 'ignore-blank-lines'),
    )

110 111 112 113 114 115
    ignore_blank_lines = GObject.property(
        type=bool,
        nick="Ignore blank lines",
        blurb="Whether to ignore blank lines when comparing file contents",
        default=False,
    )
116

117
    differ = Differ
118

119 120 121 122 123 124
    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
125

126
    # Identifiers for MsgArea messages
127
    (MSG_SAME, MSG_SLOW_HIGHLIGHT, MSG_SYNCPOINTS) = list(range(3))
128

129
    __gsignals__ = {
130 131
        'next-conflict-changed': (GObject.SignalFlags.RUN_FIRST, None, (bool, bool)),
        'action-mode-changed': (GObject.SignalFlags.RUN_FIRST, None, (int,)),
132 133
    }

134
    def __init__(self, num_panes):
steve9000's avatar
steve9000 committed
135 136
        """Start up an filediff with num_panes empty contents.
        """
137
        melddoc.MeldDoc.__init__(self)
138 139
        gnomeglade.Component.__init__(
            self, "filediff.ui", "filediff", ["FilediffActions"])
140 141
        bind_settings(self)

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

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

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

188
        for buf in self.textbuffer:
189
            buf.undo_sequence = self.undosequence
190 191
            buf.connect("notify::has-selection",
                        self.update_text_actions_sensitivity)
192
            buf.data.connect('file-changed', self.notify_file_changed)
193

194
        self.ui_file = gnomeglade.ui_file("filediff-ui.xml")
195
        self.actiongroup = self.FilediffActions
196
        self.actiongroup.set_translation_domain("meld")
197

198
        self.findbar = FindBar(self.grid)
199
        self.grid.attach(self.findbar.widget, 1, 2, 5, 1)
200

201
        self.set_num_panes(num_panes)
202
        self.cursor = CursorDetails()
203 204 205 206
        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)
207 208 209 210 211 212 213 214

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

215
        self.linediffer.connect("diffs-changed", self.on_diffs_changed)
216
        self.undosequence.connect("checkpointed", self.on_undo_checkpointed)
217
        self.connect("next-conflict-changed", self.on_next_conflict_changed)
Stephen Kennedy's avatar
Stephen Kennedy committed
218

219 220 221
        for diffmap in self.diffmap:
            self.linediffer.connect('diffs-changed', diffmap.on_diffs_changed)

222
        for statusbar, buf in zip(self.statusbar, self.textbuffer):
223 224 225 226
            buf.bind_property(
                'language', statusbar, 'source-language',
                GObject.BindingFlags.BIDIRECTIONAL)

227 228 229 230
            buf.data.bind_property(
                'encoding', statusbar, 'source-encoding',
                GObject.BindingFlags.DEFAULT)

231 232 233
            def reload_with_encoding(widget, encoding, pane):
                self.set_file(pane, self.textbuffer[pane].data.gfile, encoding)

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

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

241 242
        # Prototype implementation

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 254 255 256 257 258
                views = [self.textview[pane], self.textview[pane + 1]]
                renderer = GutterRendererChunkAction(pane, pane + 1, views, self, self.linediffer)
                gutter = t.get_gutter(window)
                gutter.insert(renderer, 10)
            if pane in (1, 2):
                window = Gtk.TextWindowType.LEFT
259 260
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.RIGHT
261 262 263
                views = [self.textview[pane], self.textview[pane - 1]]
                renderer = GutterRendererChunkAction(pane, pane - 1, views, self, self.linediffer)
                gutter = t.get_gutter(window)
264
                gutter.insert(renderer, -40)
265

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

283
        self.connect("notify::ignore-blank-lines", self.refresh_comparison)
284

285 286
    def get_keymask(self):
        return self._keymask
Kai Willadsen's avatar
Kai Willadsen committed
287

288 289 290 291 292 293 294 295 296 297 298
    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)

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

313 314 315
    def on_focus_change(self):
        self.keymask = 0

316 317 318
    def on_text_filters_changed(self, app):
        relevant_change = self.create_text_filters()
        if relevant_change:
319
            self.refresh_comparison()
320 321 322 323

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

329
        self.text_filters = [copy.copy(f) for f in meldsettings.text_filters]
330 331 332

        return active_filters_changed

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

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

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

365
        self.statusbar[pane].props.cursor_position = (line, offset)
366 367

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

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

Kai Willadsen's avatar
Kai Willadsen committed
388
            self.cursor.prev, self.cursor.next = prev, next_
389 390
            self.cursor.prev_conflict = prev_conflict
            self.cursor.next_conflict = next_conflict
391
        self.cursor.line, self.cursor.offset = line, offset
392

393
    def on_current_diff_changed(self, widget, *args):
394 395 396 397 398 399 400
        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

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

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

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

464 465 466 467
    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)

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

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

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

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

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

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

513 514 515
    def on_linkmap_scroll_event(self, linkmap, event):
        self.next_diff(event.direction)

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

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

533 534 535 536 537
    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")
538 539

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        cursor_chunk, _, _ = self.linediffer.locate_chunk(new_pane, cursor_line)
        if cursor_chunk is not None:
            already_in_chunk = cursor_chunk == chunk
        else:
            cursor_chunk = self._synth_chunk(pane, new_pane, cursor_line)
Kai Willadsen's avatar
Kai Willadsen committed
704 705
            already_in_chunk = (
                cursor_chunk[3] == new_start and cursor_chunk[4] == new_end)
706 707 708 709 710 711 712 713 714 715 716 717 718 719 720

        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

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

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

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

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

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

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

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

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

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

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

804 805
        return txt

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

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

818
    def check_save_modified(self):
819
        response = Gtk.ResponseType.OK
820
        modified = [b.get_modified() for b in self.textbuffer[:self.num_panes]]
821
        labels = [b.data.label for b in self.textbuffer[:self.num_panes]]
822
        if True in modified:
823
            dialog = gnomeglade.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 866
                    bufdata = self.textbuffer[1].data
                    conflict_file = bufdata.savefile or bufdata.filename
867 868 869 870
                    # 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.
                    parent.command('resolve', [conflict_file], sync=True)
871 872
        elif response == Gtk.ResponseType.CANCEL:
            self.state = melddoc.STATE_NORMAL
873

874 875
        return response

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

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

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

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

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

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

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

923 924 925 926 927 928 929 930
    @with_focused_pane
    def open_external(self, pane):
        if not self.textbuffer[pane].data.filename:
            return
        pos = self.textbuffer[pane].props.cursor_position
        cursor_it = self.textbuffer[pane].get_iter_at_offset(pos)
        line = cursor_it.get_line() + 1
        self._open_files([self.textbuffer[pane].data.filename], line)
931

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

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

956
    def on_find_activate(self, *args):
957
        selected_text = self.get_selected_text()
958
        self.findbar.start_find(self.focus_pane, selected_text)
959 960 961
        self.keymask = 0

    def on_replace_activate(self, *args):
962
        selected_text = self.get_selected_text()
963
        self.findbar.start_replace(self.focus_pane, selected_text)
964 965 966
        self.keymask = 0

    def on_find_next_activate(self, *args):
967
        self.findbar.start_find_next(self.focus_pane)
968

969
    def on_find_previous_activate(self, *args):
970
        self.findbar.start_find_previous(self.focus_pane)
971

972 973 974 975
    @with_focused_pane
    def on_go_to_line_activate(self, pane, *args):
        self.statusbar[pane].emit('start-go-to-line')

976
    def on_scrolledwindow_size_allocate(self, scrolledwindow, allocation):
977 978 979 980 981 982
        index = self.scrolledwindow.index(scrolledwindow)
        if index == 0 or index == 1:
            self.linkmap[0].queue_draw()
        if index == 1 or index == 2:
            self.linkmap[1].queue_draw()

983
    def on_textview_popup_menu(self, textview):
984 985
        self.popup_menu.popup(None