dirdiff.py 61.2 KB
Newer Older
1 2
# coding=UTF-8

3 4 5
# Copyright (C) 2002-2006 Stephen Kennedy <stevek@gnome.org>
# Copyright (C) 2009-2013 Kai Willadsen <kai.willadsen@gmail.com>
#
6 7 8 9 10 11 12 13 14 15 16 17
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or (at
# your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18

19
import collections
20
import copy
21
import errno
22
import functools
23
import os
24
import shutil
steve9000's avatar
steve9000 committed
25
import stat
26
import sys
27 28
from collections import namedtuple
from decimal import Decimal
29

30
from gi.repository import Gdk
31
from gi.repository import Gio
32
from gi.repository import GLib
33 34
from gi.repository import GObject
from gi.repository import Gtk
35

36 37 38
# TODO: Don't from-import whole modules
from meld import misc
from meld import tree
39
from meld.conf import _
40
from meld.iohelpers import trash_or_confirm
41
from meld.melddoc import MeldDoc
42
from meld.misc import all_same, with_focused_pane
43
from meld.recent import RecentType
44
from meld.settings import bind_settings, meldsettings, settings
45
from meld.treehelpers import refocus_deleted_path, tree_path_as_tuple
46 47
from meld.ui.cellrenderers import (
    CellRendererByteSize, CellRendererDate, CellRendererFileMode)
48 49
from meld.ui.emblemcellrenderer import EmblemCellRenderer
from meld.ui.gnomeglade import Component, ui_file
50

51

52 53 54 55 56 57 58 59
class StatItem(namedtuple('StatItem', 'mode size time')):
    __slots__ = ()

    @classmethod
    def _make(cls, stat_result):
        return StatItem(stat.S_IFMT(stat_result.st_mode),
                        stat_result.st_size, stat_result.st_mtime)

60
    def shallow_equal(self, other, time_resolution_ns):
61 62 63
        if self.size != other.size:
            return False

64 65 66 67 68
        # Shortcut to avoid expensive Decimal calculations. 2 seconds is our
        # current accuracy threshold (for VFAT), so should be safe for now.
        if abs(self.time - other.time) > 2:
            return False

69 70
        dectime1 = Decimal(self.time).scaleb(Decimal(9)).quantize(1)
        dectime2 = Decimal(other.time).scaleb(Decimal(9)).quantize(1)
71 72
        mtime1 = dectime1 // time_resolution_ns
        mtime2 = dectime2 // time_resolution_ns
73 74 75

        return mtime1 == mtime2

76 77 78 79

CacheResult = namedtuple('CacheResult', 'stats result')


80
_cache = {}
81 82
Same, SameFiltered, DodgySame, DodgyDifferent, Different, FileError = \
    list(range(6))
83 84 85
# TODO: Get the block size from os.stat
CHUNK_SIZE = 4096

86

87 88 89 90 91
def remove_blank_lines(text):
    splits = text.splitlines()
    lines = text.splitlines(True)
    blanks = set([i for i, l in enumerate(splits) if not l])
    lines = [l for i, l in enumerate(lines) if i not in blanks]
92
    return b''.join(lines)
93 94


95
def _files_same(files, regexes, comparison_args):
96 97 98 99 100 101 102 103
    """Determine whether a list of files are the same.

    Possible results are:
      Same: The files are the same
      SameFiltered: The files are identical only after filtering with 'regexes'
      DodgySame: The files are superficially the same (i.e., type, size, mtime)
      DodgyDifferent: The files are superficially different
      FileError: There was a problem reading one or more of the files
Stephen Kennedy's avatar
Stephen Kennedy committed
104
    """
105

106
    if all_same(files):
107 108 109
        return Same

    files = tuple(files)
110
    regexes = tuple(regexes)
111 112
    stats = tuple([StatItem._make(os.stat(f)) for f in files])

113 114 115 116
    shallow_comparison = comparison_args['shallow-comparison']
    time_resolution_ns = comparison_args['time-resolution']
    ignore_blank_lines = comparison_args['ignore_blank_lines']

117
    need_contents = comparison_args['apply-text-filters']
118

119 120 121 122 123 124 125 126
    # If all entries are directories, they are considered to be the same
    if all([stat.S_ISDIR(s.mode) for s in stats]):
        return Same

    # If any entries are not regular files, consider them different
    if not all([stat.S_ISREG(s.mode) for s in stats]):
        return Different

127
    # Compare files superficially if the options tells us to
128
    if shallow_comparison:
129 130 131 132
        all_same_timestamp = all(
            s.shallow_equal(stats[0], time_resolution_ns) for s in stats[1:]
        )
        return DodgySame if all_same_timestamp else Different
133

134
    # If there are no text filters, unequal sizes imply a difference
135
    if not need_contents and not all_same([s.size for s in stats]):
136 137
        return Different

138
    # Check the cache before doing the expensive comparison
139
    cache_key = (files, need_contents, regexes, ignore_blank_lines)
140
    cache = _cache.get(cache_key)
141 142 143 144 145 146 147
    if cache and cache.stats == stats:
        return cache.result

    # Open files and compare bit-by-bit
    contents = [[] for f in files]
    result = None

148
    try:
149 150 151 152 153
        handles = [open(f, "rb") for f in files]
        try:
            data = [h.read(CHUNK_SIZE) for h in handles]

            # Rough test to see whether files are binary. If files are guessed
154
            # to be binary, we don't examine contents for speed and space.
Kai Willadsen's avatar
Kai Willadsen committed
155
            if any(b"\0" in d for d in data):
156
                need_contents = False
157

158 159 160 161 162 163
            while True:
                if all_same(data):
                    if not data[0]:
                        break
                else:
                    result = Different
164
                    if not need_contents:
165 166
                        break

167
                if need_contents:
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185
                    for i in range(len(data)):
                        contents[i].append(data[i])

                data = [h.read(CHUNK_SIZE) for h in handles]

        # Files are too large; we can't apply filters
        except (MemoryError, OverflowError):
            result = DodgySame if all_same(stats) else DodgyDifferent
        finally:
            for h in handles:
                h.close()
    except IOError:
        # Don't cache generic errors as results
        return FileError

    if result is None:
        result = Same

186
    if result == Different and need_contents:
187
        contents = [b"".join(c) for c in contents]
188 189
        # For probable text files, discard newline differences to match
        # file comparisons.
190
        contents = [b"\n".join(c.splitlines()) for c in contents]
191 192 193

        contents = [misc.apply_text_filters(c, regexes) for c in contents]

194
        if ignore_blank_lines:
195
            contents = [remove_blank_lines(c) for c in contents]
196 197
        result = SameFiltered if all_same(contents) else Different

198
    _cache[cache_key] = CacheResult(stats, result)
199 200
    return result

201

202
EMBLEM_NEW = "emblem-meld-newer-file"
203
EMBLEM_SYMLINK = "emblem-symbolic-link"
204

205 206
COL_EMBLEM, COL_EMBLEM_SECONDARY, COL_SIZE, COL_TIME, COL_PERMS, COL_END = \
        range(tree.COL_END, tree.COL_END + 6)
207

208

209 210
class DirDiffTreeStore(tree.DiffTreeStore):
    def __init__(self, ntree):
211 212
        tree.DiffTreeStore.__init__(
            self, ntree, [str, str, object, object, object])
Stephen Kennedy's avatar
Stephen Kennedy committed
213

214 215 216 217 218 219 220

class CanonicalListing(object):
    """Multi-pane lists with canonicalised matching and error detection"""

    def __init__(self, n, canonicalize=None):
        self.items = collections.defaultdict(lambda: [None] * n)
        self.errors = []
221 222
        self.canonicalize = canonicalize
        self.add = self.add_simple if canonicalize is None else self.add_canon
223

224
    def add_simple(self, pane, item):
225 226 227 228 229 230 231 232 233 234
        self.items[item][pane] = item

    def add_canon(self, pane, item):
        ci = self.canonicalize(item)
        if self.items[ci][pane] is None:
            self.items[ci][pane] = item
        else:
            self.errors.append((pane, item, self.items[ci][pane]))

    def get(self):
235 236 237 238
        def filled(seq):
            fill_value = next(s for s in seq if s)
            return tuple(s or fill_value for s in seq)

239
        return sorted(filled(v) for v in self.items.values())
240

241 242 243 244
    @staticmethod
    def canonicalize_lower(element):
        return element.lower()

245

246
class DirDiff(MeldDoc, Component):
247 248 249 250
    """Two or three way folder comparison"""

    __gtype_name__ = "DirDiff"

251 252 253 254 255
    __gsettings_bindings__ = (
        ('folder-ignore-symlinks', 'ignore-symlinks'),
        ('folder-shallow-comparison', 'shallow-comparison'),
        ('folder-time-resolution', 'time-resolution'),
        ('folder-status-filters', 'status-filters'),
256
        ('folder-filter-text', 'apply-text-filters'),
257 258 259
        ('ignore-blank-lines', 'ignore-blank-lines'),
    )

260
    apply_text_filters = GObject.Property(
261 262 263 264 265 266 267
        type=bool,
        nick="Apply text filters",
        blurb=(
            "Whether text filters and other text sanitisation preferences "
            "should be applied when comparing file contents"),
        default=False,
    )
268
    ignore_blank_lines = GObject.Property(
269 270 271 272 273
        type=bool,
        nick="Ignore blank lines",
        blurb="Whether to ignore blank lines when comparing file contents",
        default=False,
    )
274
    ignore_symlinks = GObject.Property(
275 276 277 278 279
        type=bool,
        nick="Ignore symbolic links",
        blurb="Whether to follow symbolic links when comparing folders",
        default=False,
    )
280
    shallow_comparison = GObject.Property(
281 282 283 284 285
        type=bool,
        nick="Use shallow comparison",
        blurb="Whether to compare files based solely on size and mtime",
        default=False,
    )
286
    status_filters = GObject.Property(
287 288 289 290
        type=GObject.TYPE_STRV,
        nick="File status filters",
        blurb="Files with these statuses will be shown by the comparison.",
    )
291
    time_resolution = GObject.Property(
292 293 294 295 296 297 298
        type=int,
        nick="Time resolution",
        blurb="When comparing based on mtime, the minimum difference in "
              "nanoseconds between two files before they're considered to "
              "have different mtimes.",
        default=100,
    )
299

300 301 302 303 304 305 306 307 308
    """Dictionary mapping tree states to corresponding difflib-like terms"""
    chunk_type_map = {
        tree.STATE_NORMAL: None,
        tree.STATE_NOCHANGE: None,
        tree.STATE_NEW: "insert",
        tree.STATE_ERROR: "error",
        tree.STATE_EMPTY: None,
        tree.STATE_MODIFIED: "replace",
        tree.STATE_MISSING: "delete",
309
        tree.STATE_NONEXIST: "delete",
310 311
    }

312 313 314 315 316 317
    state_actions = {
        tree.STATE_NORMAL: ("normal", "ShowSame"),
        tree.STATE_NEW: ("new", "ShowNew"),
        tree.STATE_MODIFIED: ("modified", "ShowModified"),
    }

318
    def __init__(self, num_panes):
319 320
        MeldDoc.__init__(self)
        Component.__init__(self, "dirdiff.ui", "dirdiff", ["DirdiffActions"])
321
        bind_settings(self)
322

323
        self.ui_file = ui_file("dirdiff-ui.xml")
324
        self.actiongroup = self.DirdiffActions
325
        self.actiongroup.set_translation_domain("meld")
326

327
        self.name_filters = []
328
        self.text_filters = []
329
        self.create_name_filters()
330
        self.create_text_filters()
331 332 333 334 335 336
        self.settings_handlers = [
            meldsettings.connect("file-filters-changed",
                                 self.on_file_filters_changed),
            meldsettings.connect("text-filters-changed",
                                 self.on_text_filters_changed)
        ]
337

338 339
        self.map_widgets_into_lists(["treeview", "fileentry", "scrolledwindow",
                                     "diffmap", "linkmap", "msgarea_mgr",
340
                                     "vbox", "dummy_toolbar_linkmap",
341
                                     "file_toolbar"])
342 343 344

        self.widget.ensure_style()

345
        self.custom_labels = []
346
        self.set_num_panes(num_panes)
347

348 349
        self.widget.connect("style-updated", self.model.on_style_updated)
        self.model.on_style_updated(self.widget)
350

351
        self.do_to_others_lock = False
352 353 354
        self.focus_in_events = []
        self.focus_out_events = []
        for treeview in self.treeview:
355 356
            handler_id = treeview.connect(
                "focus-in-event", self.on_treeview_focus_in_event)
357
            self.focus_in_events.append(handler_id)
358 359
            handler_id = treeview.connect(
                "focus-out-event", self.on_treeview_focus_out_event)
360
            self.focus_out_events.append(handler_id)
361
            treeview.set_search_equal_func(tree.treeview_search_cb, None)
362
        self.force_cursor_recalculate = False
363
        self.current_path, self.prev_path, self.next_path = None, None, None
364
        self.on_treeview_focus_out_event(None, None)
365
        self.focus_pane = None
366
        self.row_expansions = set()
367

368 369 370 371 372
        # One column-dict for each treeview, for changing visibility and order
        self.columns_dict = [{}, {}, {}]
        for i in range(3):
            col_index = self.model.column_index
            # Create icon and filename CellRenderer
373
            column = Gtk.TreeViewColumn(_("Name"))
374
            column.set_resizable(True)
375
            rentext = Gtk.CellRendererText()
376
            renicon = EmblemCellRenderer()
377 378
            column.pack_start(renicon, False)
            column.pack_start(rentext, True)
379
            column.set_attributes(rentext, markup=col_index(tree.COL_TEXT, i),
380
                                  foreground_rgba=col_index(tree.COL_FG, i),
381 382 383
                                  style=col_index(tree.COL_STYLE, i),
                                  weight=col_index(tree.COL_WEIGHT, i),
                                  strikethrough=col_index(tree.COL_STRIKE, i))
384 385 386 387 388 389 390
            column.set_attributes(
                renicon,
                icon_name=col_index(tree.COL_ICON, i),
                emblem_name=col_index(COL_EMBLEM, i),
                secondary_emblem_name=col_index(COL_EMBLEM_SECONDARY, i),
                icon_tint=col_index(tree.COL_TINT, i)
            )
391 392 393
            self.treeview[i].append_column(column)
            self.columns_dict[i]["name"] = column
            # Create file size CellRenderer
394
            column = Gtk.TreeViewColumn(_("Size"))
395
            column.set_resizable(True)
396
            rentext = CellRendererByteSize()
397
            column.pack_start(rentext, True)
398
            column.set_attributes(rentext, bytesize=col_index(COL_SIZE, i))
399 400 401
            self.treeview[i].append_column(column)
            self.columns_dict[i]["size"] = column
            # Create date-time CellRenderer
402
            column = Gtk.TreeViewColumn(_("Modification time"))
403
            column.set_resizable(True)
404
            rentext = CellRendererDate()
405
            column.pack_start(rentext, True)
406
            column.set_attributes(rentext, timestamp=col_index(COL_TIME, i))
407 408
            self.treeview[i].append_column(column)
            self.columns_dict[i]["modification time"] = column
409
            # Create permissions CellRenderer
410
            column = Gtk.TreeViewColumn(_("Permissions"))
411
            column.set_resizable(True)
412
            rentext = CellRendererFileMode()
413
            column.pack_start(rentext, False)
414
            column.set_attributes(rentext, file_mode=col_index(COL_PERMS, i))
415 416
            self.treeview[i].append_column(column)
            self.columns_dict[i]["permissions"] = column
417

418
        for i in range(3):
419
            selection = self.treeview[i].get_selection()
420
            selection.set_mode(Gtk.SelectionMode.MULTIPLE)
421 422 423 424 425
            selection.connect('changed', self.on_treeview_selection_changed, i)
            self.scrolledwindow[i].get_vadjustment().connect(
                "value-changed", self._sync_vscroll)
            self.scrolledwindow[i].get_hadjustment().connect(
                "value-changed", self._sync_hscroll)
426
        self.linediffs = [[], []]
427

428 429 430 431
        self.update_treeview_columns(settings, 'folder-columns')
        settings.connect('changed::folder-columns',
                         self.update_treeview_columns)

432 433 434
        self.update_comparator()
        self.connect("notify::shallow-comparison", self.update_comparator)
        self.connect("notify::time-resolution", self.update_comparator)
435
        self.connect("notify::ignore-blank-lines", self.update_comparator)
436
        self.connect("notify::apply-text-filters", self.update_comparator)
437

438 439 440 441 442 443 444
        self.state_filters = []
        for s in self.state_actions:
            if self.state_actions[s][0] in self.props.status_filters:
                self.state_filters.append(s)
                action_name = self.state_actions[s][1]
                self.actiongroup.get_action(action_name).set_active(True)

445 446
        self._scan_in_progress = 0

447 448 449 450 451 452
    def queue_draw(self):
        for treeview in self.treeview:
            treeview.queue_draw()
        for diffmap in self.diffmap:
            diffmap.queue_draw()

453 454 455 456
    def update_comparator(self, *args):
        comparison_args = {
            'shallow-comparison': self.props.shallow_comparison,
            'time-resolution': self.props.time_resolution,
457
            'apply-text-filters': self.props.apply_text_filters,
458
            'ignore_blank_lines': self.props.ignore_blank_lines,
459 460 461 462
        }
        self.file_compare = functools.partial(
            _files_same, comparison_args=comparison_args)
        self.refresh()
463

464
    def update_treeview_columns(self, settings, key):
465
        """Update the visibility and order of columns"""
466 467
        columns = settings.get_value(key)
        for i, treeview in enumerate(self.treeview):
468
            extra_cols = False
469 470
            last_column = treeview.get_column(0)
            for column_name, visible in columns:
471 472 473
                extra_cols = extra_cols or visible
                current_column = self.columns_dict[i][column_name]
                current_column.set_visible(visible)
474
                treeview.move_column_after(current_column, last_column)
475
                last_column = current_column
476
            treeview.set_headers_visible(extra_cols)
477

478 479
    def on_custom_filter_menu_toggled(self, item):
        if item.get_active():
Kai Willadsen's avatar
Kai Willadsen committed
480 481
            self.custom_popup.connect("deactivate",
                                      lambda popup: item.set_active(False))
482 483 484 485
            self.custom_popup.popup(None, None,
                                    misc.position_menu_under_widget,
                                    self.filter_menu_button, 1,
                                    Gtk.get_current_event_time())
486

487 488 489 490 491 492 493 494 495
    def _cleanup_filter_menu_button(self, ui):
        if self.popup_deactivate_id:
            self.popup_menu.disconnect(self.popup_deactivate_id)
        if self.custom_merge_id:
            ui.remove_ui(self.custom_merge_id)
        if self.filter_actiongroup in ui.get_action_groups():
            ui.remove_action_group(self.filter_actiongroup)

    def _create_filter_menu_button(self, ui):
496
        ui.insert_action_group(self.filter_actiongroup, -1)
497 498 499
        self.custom_merge_id = ui.new_merge_id()
        for x in self.filter_ui:
            ui.add_ui(self.custom_merge_id, *x)
500 501
        self.popup_deactivate_id = self.popup_menu.connect(
            "deactivate", self.on_popup_deactivate_event)
502
        self.custom_popup = ui.get_widget("/CustomPopup")
503 504 505 506
        self.filter_menu_button = ui.get_widget(
            "/Toolbar/FilterActions/CustomFilterMenu")
        label = misc.make_tool_button_widget(
            self.filter_menu_button.props.label)
507 508
        self.filter_menu_button.set_label_widget(label)

509
    def on_container_switch_in_event(self, ui):
510
        MeldDoc.on_container_switch_in_event(self, ui)
511 512 513
        self._create_filter_menu_button(ui)
        self.ui_manager = ui

514
    def on_container_switch_out_event(self, ui):
515
        self._cleanup_filter_menu_button(ui)
516
        MeldDoc.on_container_switch_out_event(self, ui)
517

518 519
    def on_file_filters_changed(self, app):
        self._cleanup_filter_menu_button(self.ui_manager)
520
        relevant_change = self.create_name_filters()
521
        self._create_filter_menu_button(self.ui_manager)
522 523
        if relevant_change:
            self.refresh()
524

525
    def create_name_filters(self):
526
        # Ordering of name filters is irrelevant
527 528 529 530
        old_active = set([f.filter_string for f in self.name_filters
                          if f.active])
        new_active = set([f.filter_string for f in meldsettings.file_filters
                          if f.active])
531 532
        active_filters_changed = old_active != new_active

533
        self.name_filters = [copy.copy(f) for f in meldsettings.file_filters]
534
        actions = []
535
        disabled_actions = []
536
        self.filter_ui = []
537
        for i, f in enumerate(self.name_filters):
538
            name = "Hide%d" % i
539 540 541 542 543 544 545 546 547 548 549 550 551
            callback = functools.partial(self._update_name_filter, idx=i)
            actions.append((
                name, None, f.label, None, _("Hide %s") % f.label,
                callback, f.active
            ))
            self.filter_ui.append([
                "/CustomPopup", name, name,
                Gtk.UIManagerItemType.MENUITEM, False
            ])
            self.filter_ui.append([
                "/Menubar/ViewMenu/FileFilters", name, name,
                Gtk.UIManagerItemType.MENUITEM, False
            ])
552 553
            if f.filter is None:
                disabled_actions.append(name)
554

555
        self.filter_actiongroup = Gtk.ActionGroup(name="DirdiffFilterActions")
556
        self.filter_actiongroup.add_toggle_actions(actions)
557 558
        for name in disabled_actions:
            self.filter_actiongroup.get_action(name).set_sensitive(False)
559

560 561
        return active_filters_changed

562 563 564 565 566 567 568 569
    def on_text_filters_changed(self, app):
        relevant_change = self.create_text_filters()
        if relevant_change:
            self.refresh()

    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]
570 571
        new_active = [f.filter_string for f in meldsettings.text_filters
                      if f.active]
572 573
        active_filters_changed = old_active != new_active

574
        self.text_filters = [copy.copy(f) for f in meldsettings.text_filters]
575 576 577

        return active_filters_changed

578
    def _do_to_others(self, master, objects, methodname, args):
579 580 581 582 583 584 585 586 587 588 589
        if self.do_to_others_lock:
            return

        self.do_to_others_lock = True
        try:
            others = [o for o in objects[:self.num_panes] if o != master]
            for o in others:
                method = getattr(o, methodname)
                method(*args)
        finally:
            self.do_to_others_lock = False
590 591

    def _sync_vscroll(self, adjustment):
592
        adjs = [sw.get_vadjustment() for sw in self.scrolledwindow]
593 594
        self._do_to_others(
            adjustment, adjs, "set_value", (int(adjustment.get_value()),))
595 596

    def _sync_hscroll(self, adjustment):
597
        adjs = [sw.get_hadjustment() for sw in self.scrolledwindow]
598 599
        self._do_to_others(
            adjustment, adjs, "set_value", (int(adjustment.get_value()),))
600 601

    def _get_focused_pane(self):
602 603 604 605
        for i, treeview in enumerate(self.treeview):
            if treeview.is_focus():
                return i
        return None
606 607 608

    def file_deleted(self, path, pane):
        # is file still extant in other pane?
609 610
        it = self.model.get_iter(path)
        files = self.model.value_paths(it)
Kai Willadsen's avatar
Kai Willadsen committed
611
        is_present = [os.path.exists(f) for f in files]
612
        if 1 in is_present:
613
            self._update_item_state(it)
Kai Willadsen's avatar
Kai Willadsen committed
614
        else:  # nope its gone
615
            self.model.remove(it)
616 617 618
        self._update_diffmaps()

    def file_created(self, path, pane):
619
        it = self.model.get_iter(path)
620 621
        root = Gtk.TreePath.new_first()
        while it and self.model.get_path(it) != root:
Kai Willadsen's avatar
Kai Willadsen committed
622
            self._update_item_state(it)
623
            it = self.model.iter_parent(it)
624 625
        self._update_diffmaps()

626 627 628 629
    def on_fileentry_file_set(self, entry):
        files = [e.get_file() for e in self.fileentry[:self.num_panes]]
        paths = [f.get_path() for f in files]
        self.set_locations(paths)
steve9000's avatar
steve9000 committed
630 631 632

    def set_locations(self, locations):
        self.set_num_panes(len(locations))
633 634 635
        # This is difficult to trigger, and to test. Most of the time here we
        # will actually have had UTF-8 from GTK, which has been unicode-ed by
        # the time we get this far. This is a fallback, and may be wrong!
636
        locations = list(locations)
637
        for i, l in enumerate(locations):
638
            if not isinstance(l, str):
639
                locations[i] = l.decode(sys.getfilesystemencoding())
640
        locations = [os.path.abspath(l) if l else '' for l in locations]
641
        self.current_path = None
642
        self.model.clear()
643 644
        for m in self.msgarea_mgr:
            m.clear()
645
        for pane, loc in enumerate(locations):
646 647
            if loc:
                self.fileentry[pane].set_filename(loc)
648
        child = self.model.add_entries(None, locations)
649
        self.treeview0.grab_focus()
650
        self._update_item_state(child)
651 652
        self.recompute_label()
        self.scheduler.remove_all_tasks()
653
        self.recursively_update(Gtk.TreePath.new_first())
654
        self._update_diffmaps()
655

656
    def get_comparison(self):
657
        root = self.model.get_iter_first()
658
        if root:
659
            uris = [Gio.File.new_for_path(d)
660
                    for d in self.model.value_paths(root)]
661
        else:
662
            uris = []
663
        return RecentType.Folder, uris
664

Kai Willadsen's avatar
Kai Willadsen committed
665
    def recursively_update(self, path):
666 667
        """Recursively update from tree path 'path'.
        """
Kai Willadsen's avatar
Kai Willadsen committed
668 669
        it = self.model.get_iter(path)
        child = self.model.iter_children(it)
670 671
        while child:
            self.model.remove(child)
Kai Willadsen's avatar
Kai Willadsen committed
672
            child = self.model.iter_children(it)
673
        self._update_item_state(it)
674
        self._scan_in_progress += 1
675
        self.scheduler.add_task(self._search_recursively_iter(path))
steve9000's avatar
steve9000 committed
676

677
    def _search_recursively_iter(self, rootpath):
678 679 680 681
        for t in self.treeview:
            sel = t.get_selection()
            sel.unselect_all()

682
        yield _("[%s] Scanning %s") % (self.label_text, "")
683 684
        prefixlen = 1 + len(
            self.model.value_path(self.model.get_iter(rootpath), 0))
Kai Willadsen's avatar
Kai Willadsen committed
685
        symlinks_followed = set()
686 687 688 689
        # TODO: This is horrible.
        if isinstance(rootpath, tuple):
            rootpath = Gtk.TreePath(rootpath)
        todo = [rootpath]
690
        expanded = set()
691 692

        shadowed_entries = []
693
        invalid_filenames = []
694
        while len(todo):
Kai Willadsen's avatar
Kai Willadsen committed
695
            todo.sort()  # depth first
696
            path = todo.pop(0)
Kai Willadsen's avatar
Kai Willadsen committed
697 698
            it = self.model.get_iter(path)
            roots = self.model.value_paths(it)
699 700 701 702 703 704

            # Buggy ordering when deleting rows means that we sometimes try to
            # recursively update files; this fix seems the least invasive.
            if not any(os.path.isdir(root) for root in roots):
                continue

705 706
            yield _("[%s] Scanning %s") % (
                self.label_text, roots[0][prefixlen:])
707
            differences = False
708
            encoding_errors = []
709 710 711

            canonicalize = None
            if self.actiongroup.get_action("IgnoreCase").get_active():
712
                canonicalize = CanonicalListing.canonicalize_lower
713 714 715
            dirs = CanonicalListing(self.num_panes, canonicalize)
            files = CanonicalListing(self.num_panes, canonicalize)

716
            for pane, root in enumerate(roots):
717 718 719 720 721
                if not os.path.isdir(root):
                    continue

                try:
                    entries = os.listdir(root)
722
                except OSError as err:
723 724 725 726 727
                    self.model.add_error(it, err.strerror, pane)
                    differences = True
                    continue

                for f in self.name_filters:
728 729 730
                    if not f.active or f.filter is None:
                        continue
                    entries = [e for e in entries if f.filter.match(e) is None]
731 732

                for e in entries:
733
                    try:
734
                        if not isinstance(e, str):
735
                            e = e.decode('utf8')
736
                    except UnicodeDecodeError:
737 738
                        approximate_name = e.decode('utf8', 'replace')
                        encoding_errors.append((pane, approximate_name))
739 740
                        continue

741
                    try:
742
                        s = os.lstat(os.path.join(root, e))
743
                    # Covers certain unreadable symlink cases; see bgo#585895
744
                    except OSError as err:
745 746
                        error_string = e + err.strerror
                        self.model.add_error(it, error_string, pane)
747 748 749
                        continue

                    if stat.S_ISLNK(s.st_mode):
750
                        if self.props.ignore_symlinks:
751 752 753 754 755 756 757 758
                            continue
                        key = (s.st_dev, s.st_ino)
                        if key in symlinks_followed:
                            continue
                        symlinks_followed.add(key)
                        try:
                            s = os.stat(os.path.join(root, e))
                            if stat.S_ISREG(s.st_mode):
759
                                files.add(pane, e)
760
                            elif stat.S_ISDIR(s.st_mode):
761
                                dirs.add(pane, e)
762
                        except OSError as err:
763 764 765 766 767 768
                            if err.errno == errno.ENOENT:
                                error_string = e + ": Dangling symlink"
                            else:
                                error_string = e + err.strerror
                            self.model.add_error(it, error_string, pane)
                            differences = True
769
                    elif stat.S_ISREG(s.st_mode):
770
                        files.add(pane, e)
771
                    elif stat.S_ISDIR(s.st_mode):
772
                        dirs.add(pane, e)
773 774 775
                    else:
                        # FIXME: Unhandled stat type
                        pass
776

777 778
            for pane, f in encoding_errors:
                invalid_filenames.append((pane, roots[pane], f))
779

780
            for pane, f1, f2 in dirs.errors + files.errors:
781
                shadowed_entries.append((pane, roots[pane], f1, f2))
782

783
            alldirs = self._filter_on_state(roots, dirs.get())
784
            allfiles = self._filter_on_state(roots, files.get())
785

Kai Willadsen's avatar
Kai Willadsen committed
786
            if alldirs or allfiles:
787
                for names in alldirs:
788 789
                    entries = [
                        os.path.join(r, n) for r, n in zip(roots, names)]
790 791 792 793
                    child = self.model.add_entries(it, entries)
                    differences |= self._update_item_state(child)
                    todo.append(self.model.get_path(child))
                for names in allfiles:
794 795
                    entries = [
                        os.path.join(r, n) for r, n in zip(roots, names)]
796 797
                    child = self.model.add_entries(it, entries)
                    differences |= self._update_item_state(child)
798
            else:
799
                # Our subtree is empty, or has been filtered to be empty
800 801
                if (tree.STATE_NORMAL in self.state_filters or
                        not all(os.path.isdir(f) for f in roots)):
802
                    self.model.add_empty(it)
803
                    if self.model.iter_parent(it) is None:
804
                        expanded.add(tree_path_as_tuple(rootpath))
805
                else:
806 807
                    # At this point, we have an empty folder tree node; we can
                    # prune this and any ancestors that then end up empty.
808
                    while not self.model.iter_has_child(it):
809
                        parent = self.model.iter_parent(it)
810 811 812 813 814 815 816 817 818 819 820 821 822

                        # In our tree, there is always a top-level parent with
                        # no siblings. If we're here, we have an empty tree.
                        if parent is None:
                            self.model.add_empty(it)
                            break

                        # Remove the current row, and then revalidate all
                        # sibling paths on the stack by removing and
                        # readding them.
                        had_siblings = self.model.remove(it)
                        if had_siblings:
                            parent_path = self.model.get_path(parent)
823
                            for path in todo:
824
                                if parent_path.is_ancestor(path):
825
                                    path.prev()
826

827
                        it = parent
828

829
            if differences:
830
                expanded.add(tree_path_as_tuple(path))
831

832 833 834 835
        if invalid_filenames or shadowed_entries:
            self._show_tree_wide_errors(invalid_filenames, shadowed_entries)
        elif not expanded:
            self._show_identical_status()
836

837
        self.treeview[0].expand_to_path(Gtk.TreePath(("0",)))
838
        for path in sorted(expanded):
839
            self.treeview[0].expand_to_path(Gtk.TreePath(path))
840
        yield _("[%s] Done") % self.label_text
841

842
        self._scan_in_progress -= 1
843
        self.force_cursor_recalculate = True
844
        self.treeview[0].set_cursor(Gtk.TreePath.new_first())
845
        self._update_diffmaps()
846

847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871
    def _show_identical_status(self):
        primary = _("Folders have no differences")
        identical_note = _(
            "Contents of scanned files in folders are identical.")
        shallow_note = _(
            "Scanned files in folders appear identical, but contents have not "
            "been scanned.")
        file_filter_qualifier = _(
            "File filters are in use, so not all files have been scanned.")
        text_filter_qualifier = _(
            "Text filters are in use and may be masking content differences.")

        is_shallow = self.props.shallow_comparison
        have_file_filters = any(f.active for f in self.name_filters)
        have_text_filters = any(f.active for f in self.text_filters)

        secondary = [shallow_note if is_shallow else identical_note]
        if have_file_filters:
            secondary.append(file_filter_qualifier)
        if not is_shallow and have_text_filters:
            secondary.append(text_filter_qualifier)
        secondary = " ".join(secondary)

        for pane in range(self.num_panes):
            msgarea = self.msgarea_mgr[pane].new_from_text_and_icon(
872
                'dialog-information-symbolic', primary, secondary)
873 874 875 876 877 878 879 880 881 882
            button = msgarea.add_button(_("Hide"), Gtk.ResponseType.CLOSE)
            if pane == 0:
                button.props.label = _("Hi_de")

            def clear_all(*args):
                for p in range(self.num_panes):
                    self.msgarea_mgr[p].clear()
            msgarea.connect("response", clear_all)
            msgarea.show_all()

883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901
    def _show_tree_wide_errors(self, invalid_filenames, shadowed_entries):
        header = _("Multiple errors occurred while scanning this folder")
        invalid_header = _("Files with invalid encodings found")
        # TRANSLATORS: This is followed by a list of files
        invalid_secondary = _("Some files were in an incorrect encoding. "
                              "The names are something like:")
        shadowed_header = _("Files hidden by case insensitive comparison")
        # TRANSLATORS: This is followed by a list of files
        shadowed_secondary = _("You are running a case insensitive comparison "
                               "on a case sensitive filesystem. The following "
                               "files in this folder are hidden:")

        invalid_entries = [[] for i in range(self.num_panes)]
        for pane, root, f in invalid_filenames:
            invalid_entries[pane].append(os.path.join(root, f))

        formatted_entries = [[] for i in range(self.num_panes)]
        for pane, root, f1, f2 in shadowed_entries:
            paths = [os.path.join(root, f) for f in (f1, f2)]
902
            entry_str = _("“%s” hidden by “%s”") % (paths[0], paths[1])
903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920
            formatted_entries[pane].append(entry_str)

        if invalid_filenames or shadowed_entries:
            for pane in range(self.num_panes):
                invalid = "\n".join(invalid_entries[pane])
                shadowed = "\n".join(formatted_entries[pane])
                if invalid and shadowed:
                    messages = (invalid_secondary, invalid, "",
                                shadowed_secondary, shadowed)
                elif invalid:
                    header = invalid_header
                    messages = (invalid_secondary, invalid)
                elif shadowed:
                    header = shadowed_header
                    messages = (shadowed_secondary, shadowed)
                else:
                    continue
                secondary = "\n".join(messages)
921
                self.msgarea_mgr[pane].add_dismissable_msg(
922
                    'dialog-error-symbolic', header, secondary)
923

924
    def copy_selected(self, direction):
925
        assert direction in (-1, 1)
926
        src_pane = self._get_focused_pane()
927 928 929 930 931 932 933 934
        if src_pane is None:
            return

        dst_pane = src_pane + direction
        assert dst_pane >= 0 and dst_pane < self.num_panes
        paths = self._get_selected_paths(src_pane)
        paths.reverse()
        model = self.model
Kai Willadsen's avatar
Kai Willadsen committed
935
        for path in paths:  # filter(lambda x: x.name is not None, sel):
936 937 938 939 940 941 942 943
            it = model.get_iter(path)
            name = model.value_path(it, src_pane)
            if name is None:
                continue
            src = model.value_path(it, src_pane)
            dst = model.value_path(it, dst_pane)
            try:
                if os.path.isfile(src):
Kai Willadsen's avatar
Kai Willadsen committed
944 945 946 947 948
                    dstdir = os.path.dirname(dst)
                    if not os.path.exists(dstdir):
                        os.makedirs(dstdir)
                    misc.copy2(src, dstdir)
                    self.file_created(path, dst_pane)
949 950
                elif os.path.isdir(src):
                    if os.path.exists(dst):
951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967
                        parent_name = os.path.dirname(dst)
                        folder_name = os.path.basename(dst)
                        dialog_buttons = [
                            (_("_Cancel"), Gtk.ResponseType.CANCEL),
                            (_("_Replace"), Gtk.ResponseType.OK),
                        ]
                        replace = misc.modal_dialog(
                            primary=_(u"Replace folder “%s”?") % folder_name,
                            secondary=_(
                                u"Another folder with the same name already "
                                u"exists in “%s”.\n"
                                u"If you replace the existing folder, all "
                                u"files in it will be lost.") % parent_name,
                            buttons=dialog_buttons,
                            messagetype=Gtk.MessageType.WARNING,
                        )
                        if replace != Gtk.ResponseType.OK:
968 969
                            continue
                    misc.copytree(src, dst)
Kai Willadsen's avatar
Kai Willadsen committed
970
                    self.recursively_update(path)
971 972 973
            except (OSError, IOError, shutil.Error) as err:
                misc.error_dialog(
                    _("Error copying file"),
974
                    _("Couldn’t copy %s\nto %s.\n\n%s") % (
975 976 977
                        GLib.markup_escape_text(src),
                        GLib.markup_escape_text(dst),
                        GLib.markup_escape_text(str(err)),
978
                    )
979
                )
980

981 982
    @with_focused_pane
    def delete_selected(self, pane):
983 984
        """Trash or delete all selected files/folders recursively"""

985
        paths = self._get_selected_paths(pane)
986 987 988

        # Reversing paths means that we remove tree rows bottom-up, so
        # tree paths don't change during the iteration.
989 990 991 992
        paths.reverse()
        for path in paths:
            it = self.model.get_iter(path)
            name = self.model.value_path(it, pane)
993
            gfile = Gio.File.new_for_path(name)
994 995

            try:
996
                deleted = trash_or_confirm(gfile)
997
            except Exception as e:
998 999 1000 1001 1002 1003 1004 1005 1006
                misc.error_dialog(
                    _("Error deleting {}").format(
                        GLib.markup_escape_text(gfile.get_parse_name()),
                    ),
                    str(e),
                )
            else:
                if deleted:
                    self.file_deleted(path, pane)
1007

1008 1009 1010
    def on_treemodel_row_deleted(self, model, path):
        if self.current_path == path:
            self.current_path = refocus_deleted_path(model, path)
1011 1012
            if self.current_path and self.focus_pane:
                self.focus_pane.set_cursor(self.current_path)
1013

1014 1015
        self.row_expansions = set()

1016 1017 1018 1019 1020
    def on_treeview_selection_changed(self, selection, pane):
        if not self.treeview[pane].is_focus():
            return
        have_selection = bool(selection.count_selected_rows())
        get_action = self.actiongroup.get_action
1021

1022
        if have_selection:
1023 1024 1025 1026 1027 1028 1029
            is_valid = True
            for path in selection.get_selected_rows()[1]:
                state = self.model.get_state(self.model.get_iter(path), pane)
                if state in (tree.STATE_ERROR, tree.STATE_NONEXIST):
                    is_valid = False
                    break

1030 1031
            busy = self._scan_in_progress > 0

1032 1033
            get_action("DirCompare").set_sensitive(True)
            get_action("Hide").set_sensitive(True)
1034 1035 1036 1037
            get_action("DirDelete").set_sensitive(
                is_valid and not busy)
            get_action("DirCopyLeft").set_sensitive(
                is_valid and not busy and pane > 0)
1038
            get_action("DirCopyRight").set_sensitive(
1039
                is_valid and not busy and pane + 1 < self.num_panes)
1040 1041 1042
            if self.main_actiongroup:
                act = self.main_actiongroup.get_action("OpenExternal")
                act.set_sensitive(is_valid)
1043 1044 1045 1046
        else:
            for action in ("DirCompare", "DirCopyLeft", "DirCopyRight",
                           "DirDelete", "Hide"):
                get_action(action).set_sensitive(False)
1047 1048 1049
            if self.main_actiongroup:
                act = self.main_actiongroup.get_action("OpenExternal")
                act.set_sensitive(False)
1050

1051 1052
    def on_treeview_cursor_changed(self, *args):
        pane = self._get_focused_pane()
1053
        if pane is None or len(self.model) == 0:
1054
            return
1055 1056 1057 1058

        cursor_path, cursor_col = self.treeview[pane].get_cursor()
        if not cursor_path:
            self.emit("next-diff-changed", False, False)
1059 1060 1061
            self.current_path = cursor_path
            return

1062 1063 1064
        if self.force_cursor_recalculate:
            # We force cursor recalculation on initial load, and when
            # we handle model change events.
1065
            skip = False
1066
            self.force_cursor_recalculate = False
1067
        else:
1068
            try:
1069
                old_cursor = self.model.get_iter(self.current_path)
1070 1071 1072 1073
            except (ValueError, TypeError):
                # An invalid path gives ValueError; None gives a TypeError
                skip = False
            else:
1074 1075
                # We can skip recalculation if the new cursor is between
                # the previous/next bounds, and we weren't on a changed row
1076
                state = self.model.get_state(old_cursor, 0)
1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093
                if state not in (tree.STATE_NORMAL, tree.STATE_EMPTY):
                    skip = False
                else:
                    if self.prev_path is None and self.next_path is None:
                        skip = True
                    elif self.prev_path is None:
                        skip = cursor_path < self.next_path
                    elif self.next_path is None:
                        skip = self.prev_path < cursor_path
                    else:
                        skip = self.prev_path < cursor_path < self.next_path

        if not skip:
            prev, next = self.model._find_next_prev_diff(cursor_path)
            self.prev_path, self.next_path = prev, next
            have_next_diffs = (prev is not None, next is not None)
            self.emit("next-diff-changed", *have_next_diffs)
1094
        self.current_path = cursor_path
1095

1096 1097 1098
    def on_treeview_key_press_event(self, view, event):
        pane = self.treeview.index(view)
        tree = None
1099
        if Gdk.KEY_Right == event.keyval:
1100 1101
            if pane+1 < self.num_panes:
                tree = self.treeview[pane+1]
1102
        elif Gdk.KEY_Left == event.keyval:
1103 1104
            if pane-1 >= 0:
                tree = self.treeview[pane-1]
1105
        if tree is not None:
1106 1107 1108 1109 1110 1111 1112 1113 1114
            paths = self._get_selected_paths(pane)
            view.get_selection().unselect_all()
            tree.grab_focus()
            tree.get_selection().unselect_all()
            if len(paths):
                tree.set_cursor(paths[0])
                for p in paths:
                    tree.get_selection().select_path(p)
            tree.emit("cursor-changed")
Kai Willadsen's avatar
Kai Willadsen committed
1115
        return event.keyval in (Gdk.KEY_Left, Gdk.KEY_Right)  # handled
steve9000's avatar
steve9000 committed
1116

1117
    def on_treeview_row_activated(self, view, path, column):
1118
        pane = self.treeview.index(view)
1119
        rows = self.model.value_paths(self.model.get_iter(path))
1120 1121 1122 1123
        # Click a file: compare; click a directory: expand; click a missing
        # entry: check the next neighbouring entry
        pane_ordering = ((0, 1, 2), (1, 2, 0), (2, 1, 0))
        for p in pane_ordering[pane]:
1124
            if p < self.num_panes and rows[p] and os.path.exists(rows[p]):
1125 1126
                pane = p
                break
1127 1128 1129
        if not rows[pane]:
            return
        if os.path.isfile(rows[pane]):
1130 1131
            self.emit("create-diff", [Gio.File.new_for_path(r)
                      for r in rows if os.path.isfile(r)], {})
1132
        elif os.path.isdir(rows<