filediff.py 81.8 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 34 35
from . import meldbuffer
from . import melddoc
from . import misc
from . import undo
from .ui import gnomeglade

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

47

48 49 50 51 52 53 54 55 56 57
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


58 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
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


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

88

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

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


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

    __gtype_name__ = "FileDiff"

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

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

116
    differ = Differ
117

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

242
        from meld.gutterrendererchunk import GutterRendererChunkAction, GutterRendererChunkLines
243 244 245

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

248 249
            if pane == 0 or (pane == 1 and self.num_panes == 3):
                window = Gtk.TextWindowType.RIGHT
250 251
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.LEFT
252 253 254 255 256 257
                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
258 259
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.RIGHT
260 261 262
                views = [self.textview[pane], self.textview[pane - 1]]
                renderer = GutterRendererChunkAction(pane, pane - 1, views, self, self.linediffer)
                gutter = t.get_gutter(window)
263
                gutter.insert(renderer, -40)
264

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

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

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

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

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

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

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

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

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

        return active_filters_changed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        # 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
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 772 773
            # FIXME: diff-changed signal for the current buffer would be cleaner
            focused_pane = self._get_focused_pane()
            if focused_pane != -1:
                self.on_cursor_position_changed(self.textbuffer[focused_pane],
                                                None, True)
Stephen Kennedy's avatar
Stephen Kennedy committed
774
            self.queue_draw()
steve9000's avatar
steve9000 committed
775

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

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

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

801 802
        return txt

803 804 805 806 807 808
    def after_text_insert_text(self, buf, it, newtext, textlen):
        start_mark = buf.get_mark("insertion-start")
        starting_at = buf.get_iter_at_mark(start_mark).get_line()
        buf.delete_mark(start_mark)
        lines_added = it.get_line() - starting_at
        self._after_text_modified(buf, starting_at, lines_added)
809

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

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

841 842 843
        if response == Gtk.ResponseType.DELETE_EVENT:
            response = Gtk.ResponseType.CANCEL
        elif response == Gtk.ResponseType.CLOSE:
844 845
            response = Gtk.ResponseType.OK

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

871 872
        return response

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

886 887 888 889 890 891 892 893 894
    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())

895 896
    def on_undo_activate(self):
        if self.undosequence.can_undo():
897 898
            actions = self.undosequence.undo()
        self._scroll_to_actions(actions)
899 900 901

    def on_redo_activate(self):
        if self.undosequence.can_redo():
902 903
            actions = self.undosequence.redo()
        self._scroll_to_actions(actions)
904

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

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

    def on_undo_checkpointed(self, undosequence, buf, checkpointed):
917
        buf.set_modified(not checkpointed)
918
        self.recompute_label()
919

920 921 922 923 924 925 926 927
    @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)
928

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

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

953
    def on_find_activate(self, *args):
954
        selected_text = self.get_selected_text()
955
        self.findbar.start_find(self.focus_pane, selected_text)
956 957 958
        self.keymask = 0

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

    def on_find_next_activate(self, *args):
964
        self.findbar.start_find_next(self.focus_pane)
965

966
    def on_find_previous_activate(self, *args):
967
        self.findbar.start_find_previous(self.focus_pane)
968

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

973
    def on_scrolledwindow_size_allocate(self, scrolledwindow, allocation):
974 975 976 977 978 979
        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()

980
    def on_textview_popup_menu(self, textview):
981 982
        self.popup_menu.popup(None, None, None, None, 0,
                              Gtk.get_current_event_time())
983 984
        return True

985
    def on_textview_button_press_event(self, textview, event):