Esempio n. 1
0
class EncodingSelector(FilteredListSelector, Gtk.Grid):
    # The subclassing here is weird; the Selector must directly
    # subclass Gtk.Grid, or the template building explodes.

    __gtype_name__ = 'EncodingSelector'

    __gsignals__ = {
        'encoding-selected':
        (GObject.SignalFlags.RUN_FIRST | GObject.SignalFlags.ACTION, None,
         (GtkSource.Encoding, )),
    }

    # These exist solely to make subclassing easier.
    value_accessor = 'get_charset'
    change_signal_name = 'encoding-selected'

    entry = Template.Child('entry')
    treeview = Template.Child('treeview')

    def populate_model(self):
        for enc in GtkSource.Encoding.get_all():
            self.liststore.append((self.get_value_label(enc), enc))

    def get_value_label(self, enc):
        return _('{name} ({charset})').format(name=enc.get_name(),
                                              charset=enc.get_charset())
Esempio n. 2
0
class SourceLangSelector(FilteredListSelector, Gtk.Grid):

    __gtype_name__ = "SourceLangSelector"

    __gsignals__ = {
        'language-selected':
        (GObject.SignalFlags.RUN_FIRST | GObject.SignalFlags.ACTION, None,
         (GtkSource.Language, )),
    }

    # These exist solely to make subclassing easier.
    value_accessor = 'get_id'
    change_signal_name = 'language-selected'

    entry = Template.Child('entry')
    treeview = Template.Child('treeview')

    def populate_model(self):
        self.liststore.append((_("Plain Text"), None))
        manager = GtkSource.LanguageManager.get_default()
        for lang_id in manager.get_language_ids():
            lang = manager.get_language(lang_id)
            self.liststore.append((lang.get_name(), lang))

    def get_value_label(self, lang):
        if not lang:
            return _("Plain Text")
        return lang.get_name()
Esempio n. 3
0
class NotebookLabel(Gtk.EventBox):

    __gtype_name__ = 'NotebookLabel'

    icon = Template.Child()
    label = Template.Child()

    icon_name = GObject.Property(
        type=str,
        nick='Name of the icon to display',
        default=None,
    )

    label_text = GObject.Property(
        type=str,
        nick='Text of this notebook label',
        default='',
    )

    page = GObject.Property(
        type=object,
        nick='Notebook page for which this is the label',
        default=None,
    )

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.init_template()

        self.bind_property(
            'icon-name',
            self.icon,
            'icon-name',
            GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE,
        )
        self.bind_property(
            'label-text',
            self.label,
            'label',
            GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE,
        )
        self.bind_property(
            'label-text',
            self,
            'tooltip-text',
            GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE,
        )

    @Template.Callback()
    def on_label_button_press_event(self, widget, event):
        # Middle-click on the tab closes the tab.
        if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 2:
            self.page.on_delete_event()

    @Template.Callback()
    def on_close_button_clicked(self, widget):
        self.page.on_delete_event()
Esempio n. 4
0
class FindBar(Gtk.Grid):

    __gtype_name__ = 'FindBar'

    arrow_left = Template.Child()
    arrow_right = Template.Child()
    find_entry = Template.Child()
    find_next_button = Template.Child()
    find_previous_button = Template.Child()
    hbuttonbox2 = Template.Child()
    match_case = Template.Child()
    regex = Template.Child()
    replace_entry = Template.Child()
    replace_label = Template.Child()
    whole_word = Template.Child()
    wrap_box = Template.Child()

    def __init__(self, parent):
        super().__init__()
        self.init_template()

        self.set_text_view(None)
        self.arrow_left.show()
        self.arrow_right.show()
        parent.connect('set-focus-child', self.on_focus_child)

        settings = GtkSource.SearchSettings()
        self.match_case.bind_property('active', settings, 'case-sensitive')
        self.whole_word.bind_property('active', settings, 'at-word-boundaries')
        self.regex.bind_property('active', settings, 'regex-enabled')
        self.find_entry.bind_property('text', settings, 'search-text')
        settings.set_wrap_around(True)
        self.search_settings = settings

    def on_focus_child(self, container, widget):
        if widget is not None:
            visible = self.props.visible
            if widget is not self and visible:
                self.hide()
        return False

    def hide(self):
        self.set_text_view(None)
        self.wrap_box.set_visible(False)
        Gtk.Widget.hide(self)

    def set_text_view(self, textview):
        self.textview = textview
        if textview is not None:
            self.search_context = GtkSource.SearchContext.new(
                textview.get_buffer(), self.search_settings)
            self.search_context.set_highlight(True)
        else:
            self.search_context = None

    def start_find(self, textview, text=None):
        self.set_text_view(textview)
        self.replace_label.hide()
        self.replace_entry.hide()
        self.hbuttonbox2.hide()
        self.find_entry.get_style_context().remove_class("not-found")
        if text:
            self.find_entry.set_text(text)
        self.set_row_spacing(0)
        self.show()
        self.find_entry.grab_focus()

    def start_find_next(self, textview):
        self.set_text_view(textview)
        if self.find_entry.get_text():
            self.on_find_next_button_clicked(self.find_next_button)
        else:
            self.start_find(self.textview)

    def start_find_previous(self, textview, text=None):
        self.set_text_view(textview)
        if self.find_entry.get_text():
            self.on_find_previous_button_clicked(self.find_previous_button)
        else:
            self.start_find(self.textview)

    def start_replace(self, textview, text=None):
        self.set_text_view(textview)
        self.find_entry.get_style_context().remove_class("not-found")
        if text:
            self.find_entry.set_text(text)
        self.set_row_spacing(6)
        self.show_all()
        self.find_entry.grab_focus()
        self.wrap_box.set_visible(False)

    @Template.Callback()
    def on_find_next_button_clicked(self, button):
        self._find_text()

    @Template.Callback()
    def on_find_previous_button_clicked(self, button):
        self._find_text(backwards=True)

    @Template.Callback()
    def on_replace_button_clicked(self, entry):
        buf = self.textview.get_buffer()
        oldsel = buf.get_selection_bounds()
        match = self._find_text(0)
        newsel = buf.get_selection_bounds()
        # Only replace if there is an already-selected match at the cursor
        if (match and oldsel and oldsel[0].equal(newsel[0])
                and oldsel[1].equal(newsel[1])):
            self.search_context.replace(newsel[0], newsel[1],
                                        self.replace_entry.get_text(), -1)
            self._find_text(0)

    @Template.Callback()
    def on_replace_all_button_clicked(self, entry):
        buf = self.textview.get_buffer()
        saved_insert = buf.create_mark(None,
                                       buf.get_iter_at_mark(buf.get_insert()),
                                       True)
        self.search_context.replace_all(self.replace_entry.get_text(), -1)
        if not saved_insert.get_deleted():
            buf.place_cursor(buf.get_iter_at_mark(saved_insert))
            self.textview.scroll_to_mark(buf.get_insert(), 0.25, True, 0.5,
                                         0.5)

    @Template.Callback()
    def on_find_entry_changed(self, entry):
        self.find_entry.get_style_context().remove_class("not-found")
        self._find_text(0)

    @Template.Callback()
    def on_stop_search(self, search_entry):
        self.hide()

    def _find_text(self, start_offset=1, backwards=False):
        assert self.textview
        assert self.search_context
        buf = self.textview.get_buffer()
        insert = buf.get_iter_at_mark(buf.get_insert())

        start, end = buf.get_bounds()
        self.wrap_box.set_visible(False)
        if not backwards:
            insert.forward_chars(start_offset)
            match, start_iter, end_iter = self.search_context.forward(insert)
            if match and (start_iter.get_offset() < insert.get_offset()):
                self.wrap_box.set_visible(True)
        else:
            match, start_iter, end_iter = self.search_context.backward(insert)
            if match and (start_iter.get_offset() > insert.get_offset()):
                self.wrap_box.set_visible(True)
        if match:
            buf.place_cursor(start_iter)
            buf.move_mark(buf.get_selection_bound(), end_iter)
            self.textview.scroll_to_mark(buf.get_insert(), 0.25, True, 0.5,
                                         0.5)
            self.find_entry.get_style_context().remove_class("not-found")
            return True
        else:
            buf.place_cursor(buf.get_iter_at_mark(buf.get_insert()))
            self.find_entry.get_style_context().add_class("not-found")
            self.wrap_box.set_visible(False)
Esempio n. 5
0
class FilterList(Gtk.VBox, EditableListWidget):

    __gtype_name__ = "FilterList"

    treeview = Template.Child("treeview")
    remove = Template.Child("remove")
    move_up = Template.Child("move_up")
    move_down = Template.Child("move_down")
    pattern_column = Template.Child("pattern_column")
    validity_renderer = Template.Child("validity_renderer")

    default_entry = [_("label"), False, _("pattern"), True]

    filter_type = GObject.Property(
        type=int,
        flags=(GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE
               | GObject.ParamFlags.CONSTRUCT_ONLY),
    )

    settings_key = GObject.Property(
        type=str,
        flags=(GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE
               | GObject.ParamFlags.CONSTRUCT_ONLY),
    )

    def __init__(self, **kwargs):
        super().__init__(self, **kwargs)
        FilterList.init_template(self)
        self.model = self.treeview.get_model()

        self.pattern_column.set_cell_data_func(self.validity_renderer,
                                               self.valid_icon_celldata)

        for filter_params in settings.get_value(self.settings_key):
            filt = FilterEntry.new_from_gsetting(filter_params,
                                                 self.filter_type)
            if filt is None:
                continue
            valid = filt.filter is not None
            self.model.append(
                [filt.label, filt.active, filt.filter_string, valid])

        for signal in ('row-changed', 'row-deleted', 'row-inserted',
                       'rows-reordered'):
            self.model.connect(signal, self._update_filter_string)

        self.setup_sensitivity_handling()

    def valid_icon_celldata(self, col, cell, model, it, user_data=None):
        is_valid = model.get_value(it, 3)
        icon_name = "gtk-dialog-warning" if not is_valid else None
        cell.set_property("stock-id", icon_name)

    @Template.Callback()
    def on_add_clicked(self, button):
        self.add_entry()

    @Template.Callback()
    def on_remove_clicked(self, button):
        self.remove_selected_entry()

    @Template.Callback()
    def on_move_up_clicked(self, button):
        self.move_up_selected_entry()

    @Template.Callback()
    def on_move_down_clicked(self, button):
        self.move_down_selected_entry()

    @Template.Callback()
    def on_name_edited(self, ren, path, text):
        self.model[path][0] = text

    @Template.Callback()
    def on_cellrenderertoggle_toggled(self, ren, path):
        self.model[path][1] = not ren.get_active()

    @Template.Callback()
    def on_pattern_edited(self, ren, path, text):
        valid = FilterEntry.check_filter(text, self.filter_type)
        self.model[path][2] = text
        self.model[path][3] = valid

    def _update_filter_string(self, *args):
        value = [(row[0], row[1], row[2]) for row in self.model]
        settings.set_value(self.settings_key, GLib.Variant('a(sbs)', value))
Esempio n. 6
0
class PreferencesDialog(Gtk.Dialog):

    __gtype_name__ = "PreferencesDialog"

    checkbutton_break_commit_lines = Template.Child(
        "checkbutton_break_commit_lines")  # noqa: E501
    checkbutton_default_font = Template.Child("checkbutton_default_font")
    checkbutton_folder_filter_text = Template.Child(
        "checkbutton_folder_filter_text")  # noqa: E501
    checkbutton_highlight_current_line = Template.Child(
        "checkbutton_highlight_current_line")  # noqa: E501
    checkbutton_ignore_blank_lines = Template.Child(
        "checkbutton_ignore_blank_lines")  # noqa: E501
    checkbutton_ignore_symlinks = Template.Child("checkbutton_ignore_symlinks")
    checkbutton_shallow_compare = Template.Child("checkbutton_shallow_compare")
    checkbutton_show_commit_margin = Template.Child(
        "checkbutton_show_commit_margin")  # noqa: E501
    checkbutton_show_line_numbers = Template.Child(
        "checkbutton_show_line_numbers")  # noqa: E501
    checkbutton_show_whitespace = Template.Child("checkbutton_show_whitespace")
    checkbutton_spaces_instead_of_tabs = Template.Child(
        "checkbutton_spaces_instead_of_tabs")  # noqa: E501
    checkbutton_use_syntax_highlighting = Template.Child(
        "checkbutton_use_syntax_highlighting")  # noqa: E501
    checkbutton_wrap_text = Template.Child("checkbutton_wrap_text")
    checkbutton_wrap_word = Template.Child("checkbutton_wrap_word")
    column_list_vbox = Template.Child("column_list_vbox")
    combo_file_order = Template.Child("combo_file_order")
    combo_merge_order = Template.Child("combo_merge_order")
    combo_timestamp = Template.Child("combo_timestamp")
    combobox_style_scheme = Template.Child("combobox_style_scheme")
    custom_edit_command_entry = Template.Child("custom_edit_command_entry")
    file_filters_vbox = Template.Child("file_filters_vbox")
    fontpicker = Template.Child("fontpicker")
    spinbutton_commit_margin = Template.Child("spinbutton_commit_margin")
    spinbutton_tabsize = Template.Child("spinbutton_tabsize")
    syntaxschemestore = Template.Child("syntaxschemestore")
    system_editor_checkbutton = Template.Child("system_editor_checkbutton")
    text_filters_vbox = Template.Child("text_filters_vbox")

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.init_template()

        bindings = [
            ('use-system-font', self.checkbutton_default_font, 'active'),
            ('custom-font', self.fontpicker, 'font'),
            ('indent-width', self.spinbutton_tabsize, 'value'),
            ('insert-spaces-instead-of-tabs',
             self.checkbutton_spaces_instead_of_tabs, 'active'),  # noqa: E501
            ('highlight-current-line', self.checkbutton_highlight_current_line,
             'active'),  # noqa: E501
            ('show-line-numbers', self.checkbutton_show_line_numbers,
             'active'),  # noqa: E501
            ('highlight-syntax', self.checkbutton_use_syntax_highlighting,
             'active'),  # noqa: E501
            ('use-system-editor', self.system_editor_checkbutton, 'active'),
            ('custom-editor-command', self.custom_edit_command_entry, 'text'),
            ('folder-shallow-comparison', self.checkbutton_shallow_compare,
             'active'),  # noqa: E501
            ('folder-filter-text', self.checkbutton_folder_filter_text,
             'active'),  # noqa: E501
            ('folder-ignore-symlinks', self.checkbutton_ignore_symlinks,
             'active'),  # noqa: E501
            ('vc-show-commit-margin', self.checkbutton_show_commit_margin,
             'active'),  # noqa: E501
            ('vc-commit-margin', self.spinbutton_commit_margin, 'value'),
            ('vc-break-commit-message', self.checkbutton_break_commit_lines,
             'active'),  # noqa: E501
            ('ignore-blank-lines', self.checkbutton_ignore_blank_lines,
             'active'),  # noqa: E501
            # Sensitivity bindings must come after value bindings, or the key
            # writability in gsettings overrides manual sensitivity setting.
            ('vc-show-commit-margin', self.spinbutton_commit_margin,
             'sensitive'),  # noqa: E501
            ('vc-show-commit-margin', self.checkbutton_break_commit_lines,
             'sensitive'),  # noqa: E501
        ]
        for key, obj, attribute in bindings:
            settings.bind(key, obj, attribute, Gio.SettingsBindFlags.DEFAULT)

        invert_bindings = [
            ('use-system-editor', self.custom_edit_command_entry, 'sensitive'),
            ('use-system-font', self.fontpicker, 'sensitive'),
            ('folder-shallow-comparison', self.checkbutton_folder_filter_text,
             'sensitive'),  # noqa: E501
        ]
        for key, obj, attribute in invert_bindings:
            settings.bind(
                key, obj, attribute, Gio.SettingsBindFlags.DEFAULT
                | Gio.SettingsBindFlags.INVERT_BOOLEAN)

        self.checkbutton_wrap_text.bind_property('active',
                                                 self.checkbutton_wrap_word,
                                                 'sensitive',
                                                 GObject.BindingFlags.DEFAULT)

        # TODO: Fix once bind_with_mapping is available
        self.checkbutton_show_whitespace.set_active(
            bool(settings.get_flags('draw-spaces')))

        wrap_mode = settings.get_enum('wrap-mode')
        self.checkbutton_wrap_text.set_active(wrap_mode != Gtk.WrapMode.NONE)
        self.checkbutton_wrap_word.set_active(wrap_mode == Gtk.WrapMode.WORD)

        filefilter = FilterList(
            filter_type=FilterEntry.SHELL,
            settings_key="filename-filters",
        )
        self.file_filters_vbox.pack_start(filefilter, True, True, 0)

        textfilter = FilterList(
            filter_type=FilterEntry.REGEX,
            settings_key="text-filters",
        )
        self.text_filters_vbox.pack_start(textfilter, True, True, 0)

        columnlist = ColumnList(settings_key="folder-columns")
        self.column_list_vbox.pack_start(columnlist, True, True, 0)

        self.combo_timestamp.bind_to('folder-time-resolution')
        self.combo_file_order.bind_to('vc-left-is-local')
        self.combo_merge_order.bind_to('vc-merge-file-order')

        # Fill color schemes
        manager = GtkSource.StyleSchemeManager.get_default()
        for scheme_id in manager.get_scheme_ids():
            scheme = manager.get_scheme(scheme_id)
            self.syntaxschemestore.append([scheme_id, scheme.get_name()])
        self.combobox_style_scheme.bind_to('style-scheme')

        self.show()

    @Template.Callback()
    def on_checkbutton_wrap_text_toggled(self, button):
        if not self.checkbutton_wrap_text.get_active():
            wrap_mode = Gtk.WrapMode.NONE
        elif self.checkbutton_wrap_word.get_active():
            wrap_mode = Gtk.WrapMode.WORD
        else:
            wrap_mode = Gtk.WrapMode.CHAR
        settings.set_enum('wrap-mode', wrap_mode)

    @Template.Callback()
    def on_checkbutton_show_whitespace_toggled(self, widget):
        value = GtkSource.DrawSpacesFlags.ALL if widget.get_active() else 0
        settings.set_flags('draw-spaces', value)

    @Template.Callback()
    def on_response(self, dialog, response_id):
        self.destroy()
Esempio n. 7
0
class ColumnList(Gtk.VBox, EditableListWidget):

    __gtype_name__ = "ColumnList"

    treeview = Template.Child("treeview")
    remove = Template.Child("remove")
    move_up = Template.Child("move_up")
    move_down = Template.Child("move_down")

    default_entry = [_("label"), False, _("pattern"), True]

    available_columns = {
        "size": _("Size"),
        "modification time": _("Modification time"),
        "permissions": _("Permissions"),
    }

    settings_key = GObject.Property(
        type=str,
        flags=(GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE
               | GObject.ParamFlags.CONSTRUCT_ONLY),
    )

    def __init__(self, **kwargs):
        super().__init__(self, **kwargs)
        ColumnList.init_template(self)
        self.model = self.treeview.get_model()

        # Unwrap the variant
        prefs_columns = [(k, v)
                         for k, v in settings.get_value(self.settings_key)]
        column_vis = {}
        column_order = {}
        for sort_key, (column_name, visibility) in enumerate(prefs_columns):
            column_vis[column_name] = bool(int(visibility))
            column_order[column_name] = sort_key

        columns = [(column_vis.get(name, True), name, label)
                   for name, label in self.available_columns.items()]
        columns = sorted(columns, key=lambda c: column_order.get(c[1], 0))

        for visibility, name, label in columns:
            self.model.append([visibility, name, label])

        for signal in ('row-changed', 'row-deleted', 'row-inserted',
                       'rows-reordered'):
            self.model.connect(signal, self._update_columns)

        self.setup_sensitivity_handling()

    @Template.Callback()
    def on_move_up_clicked(self, button):
        self.move_up_selected_entry()

    @Template.Callback()
    def on_move_down_clicked(self, button):
        self.move_down_selected_entry()

    @Template.Callback()
    def on_cellrenderertoggle_toggled(self, ren, path):
        self.model[path][0] = not ren.get_active()

    def _update_columns(self, *args):
        value = [(c[1].lower(), c[0]) for c in self.model]
        settings.set_value(self.settings_key, GLib.Variant('a(sb)', value))
Esempio n. 8
0
class CommitDialog(Gtk.Dialog):

    __gtype_name__ = "CommitDialog"

    break_commit_message = GObject.Property(type=bool, default=False)

    changedfiles = Template.Child("changedfiles")
    textview = Template.Child("textview")
    scrolledwindow1 = Template.Child("scrolledwindow1")
    previousentry = Template.Child("previousentry")

    def __init__(self, parent):
        super().__init__()
        self.init_template()

        self.set_transient_for(parent.get_toplevel())
        selected = parent._get_selected_files()

        try:
            to_commit = parent.vc.get_files_to_commit(selected)
            topdir = parent.vc.root
            if to_commit:
                to_commit = ["\t" + s for s in to_commit]
            else:
                to_commit = ["\t" + _("No files will be committed")]
        except NotImplementedError:
            topdir = os.path.dirname(os.path.commonprefix(selected))
            to_commit = ["\t" + s[len(topdir) + 1:] for s in selected]
        self.changedfiles.set_text("(in %s)\n%s" %
                                   (topdir, "\n".join(to_commit)))

        self.textview.modify_font(meldsettings.font)
        commit_prefill = parent.vc.get_commit_message_prefill()
        if commit_prefill:
            buf = self.textview.get_buffer()
            buf.set_text(commit_prefill)
            buf.place_cursor(buf.get_start_iter())

        # Try and make the textview wide enough for a standard 80-character
        # commit message.
        context = self.textview.get_pango_context()
        metrics = context.get_metrics(meldsettings.font,
                                      context.get_language())
        char_width = metrics.get_approximate_char_width() / Pango.SCALE
        self.scrolledwindow1.set_size_request(80 * char_width, -1)

        settings.bind('vc-show-commit-margin', self.textview,
                      'show-right-margin', Gio.SettingsBindFlags.DEFAULT)
        settings.bind('vc-commit-margin', self.textview,
                      'right-margin-position', Gio.SettingsBindFlags.DEFAULT)
        settings.bind('vc-break-commit-message', self, 'break-commit-message',
                      Gio.SettingsBindFlags.DEFAULT)
        self.show_all()

    def run(self):
        self.previousentry.set_active(-1)
        self.textview.grab_focus()
        response = super().run()
        msg = None
        if response == Gtk.ResponseType.OK:
            show_margin = self.textview.get_show_right_margin()
            margin = self.textview.get_right_margin_position()
            buf = self.textview.get_buffer()
            msg = buf.get_text(*buf.get_bounds(), include_hidden_chars=False)
            # This is a dependent option because of the margin column
            if show_margin and self.props.break_commit_message:
                paragraphs = msg.split("\n\n")
                msg = "\n\n".join(textwrap.fill(p, margin) for p in paragraphs)
            if msg.strip():
                self.previousentry.prepend_history(msg)
        self.destroy()
        return response, msg

    def on_previousentry_activate(self, gentry):
        idx = gentry.get_active()
        if idx != -1:
            model = gentry.get_model()
            buf = self.textview.get_buffer()
            buf.set_text(model[idx][1])
Esempio n. 9
0
class MeldWindow(Gtk.ApplicationWindow):

    __gtype_name__ = 'MeldWindow'

    appvbox = Template.Child("appvbox")
    folder_filter_button = Template.Child()
    text_filter_button = Template.Child()
    gear_menu_button = Template.Child("gear_menu_button")
    notebook = Template.Child("notebook")
    spinner = Template.Child("spinner")
    vc_filter_button = Template.Child()
    view_toolbar = Template.Child()

    def __init__(self):
        super().__init__()

        self.init_template()

        # Manually handle GAction additions
        actions = (
            ("close", self.action_close),
            ("new-tab", self.action_new_tab),
            ("stop", self.action_stop),
        )
        for name, callback in actions:
            action = Gio.SimpleAction.new(name, None)
            action.connect('activate', callback)
            self.add_action(action)

        state_actions = (
            (
                "fullscreen", self.action_fullscreen_change,
                GLib.Variant.new_boolean(False)
            ),
        )
        for (name, callback, state) in state_actions:
            action = Gio.SimpleAction.new_stateful(name, None, state)
            action.connect('change-state', callback)
            self.add_action(action)

        # Initialise sensitivity for important actions
        self.lookup_action('stop').set_enabled(False)

        # Fake out the spinner on Windows. See Gitlab issue #133.
        if os.name == 'nt':
            for attr in ('stop', 'hide', 'show', 'start'):
                setattr(self.spinner, attr, lambda *args: True)

        self.drag_dest_set(
            Gtk.DestDefaults.MOTION | Gtk.DestDefaults.HIGHLIGHT |
            Gtk.DestDefaults.DROP,
            None, Gdk.DragAction.COPY)
        self.drag_dest_add_uri_targets()
        self.connect(
            "drag_data_received", self.on_widget_drag_data_received)

        self.window_state = SavedWindowState()
        self.window_state.bind(self)

        self.should_close = False
        self.idle_hooked = 0
        self.scheduler = LifoScheduler()
        self.scheduler.connect("runnable", self.on_scheduler_runnable)

    def do_realize(self):
        Gtk.ApplicationWindow.do_realize(self)

        app = self.get_application()
        menu = app.get_menu_by_id("gear-menu")
        self.gear_menu_button.set_popover(
            Gtk.Popover.new_from_model(self.gear_menu_button, menu))

        filter_model = app.get_menu_by_id("text-filter-menu")
        self.text_filter_button.set_popover(
            Gtk.Popover.new_from_model(self.text_filter_button, filter_model))

        filter_menu = app.get_menu_by_id("folder-status-filter-menu")
        self.folder_filter_button.set_popover(
            Gtk.Popover.new_from_model(self.folder_filter_button, filter_menu))

        vc_filter_model = app.get_menu_by_id('vc-status-filter-menu')
        self.vc_filter_button.set_popover(
            Gtk.Popover.new_from_model(self.vc_filter_button, vc_filter_model))

        self.update_text_filters()
        self.update_filename_filters()
        self.settings_handlers = [
            meldsettings.connect(
                "text-filters-changed", self.update_text_filters),
            meldsettings.connect(
                "file-filters-changed", self.update_filename_filters),
        ]

        meld.ui.util.extract_accels_from_menu(menu, self.get_application())

    def update_filename_filters(self, *args):
        filter_items_model = Gio.Menu()
        for i, filt in enumerate(meldsettings.file_filters):
            name = FILE_FILTER_ACTION_FORMAT.format(i)
            filter_items_model.append(
                label=filt.label, detailed_action=f'view.{name}')
        section = Gio.MenuItem.new_section(_("Filename"), filter_items_model)
        section.set_attribute([("id", "s", "custom-filter-section")])
        app = self.get_application()
        filter_model = app.get_menu_by_id("folder-status-filter-menu")
        replace_menu_section(filter_model, section)

    def update_text_filters(self, *args):
        filter_items_model = Gio.Menu()
        for i, filt in enumerate(meldsettings.text_filters):
            name = TEXT_FILTER_ACTION_FORMAT.format(i)
            filter_items_model.append(
                label=filt.label, detailed_action=f'view.{name}')
        section = Gio.MenuItem.new_section(None, filter_items_model)
        section.set_attribute([("id", "s", "custom-filter-section")])
        app = self.get_application()
        filter_model = app.get_menu_by_id("text-filter-menu")
        replace_menu_section(filter_model, section)

    def on_widget_drag_data_received(
            self, wid, context, x, y, selection_data, info, time):
        uris = selection_data.get_uris()
        if uris:
            self.open_paths([Gio.File.new_for_uri(uri) for uri in uris])
            return True

    def on_idle(self):
        ret = self.scheduler.iteration()
        if ret and isinstance(ret, str):
            self.spinner.set_tooltip_text(ret)

        pending = self.scheduler.tasks_pending()
        if not pending:
            self.spinner.stop()
            self.spinner.hide()
            self.spinner.set_tooltip_text("")
            self.idle_hooked = None

            # On window close, this idle loop races widget destruction,
            # and so actions may already be gone at this point.
            stop_action = self.lookup_action('stop')
            if stop_action:
                stop_action.set_enabled(False)
        return pending

    def on_scheduler_runnable(self, sched):
        if not self.idle_hooked:
            self.spinner.show()
            self.spinner.start()
            self.lookup_action('stop').set_enabled(True)
            self.idle_hooked = GLib.idle_add(self.on_idle)

    @Template.Callback()
    def on_delete_event(self, *extra):
        should_cancel = False
        # Delete pages from right-to-left.  This ensures that if a version
        # control page is open in the far left page, it will be closed last.
        for page in reversed(self.notebook.get_children()):
            self.notebook.set_current_page(self.notebook.page_num(page))
            response = page.on_delete_event()
            if response == Gtk.ResponseType.CANCEL:
                should_cancel = True

        should_cancel = should_cancel or self.has_pages()
        if should_cancel:
            self.should_close = True
        return should_cancel

    def has_pages(self):
        return self.notebook.get_n_pages() > 0

    def handle_current_doc_switch(self, page):
        page.on_container_switch_out_event(self)

    @Template.Callback()
    def on_switch_page(self, notebook, page, which):
        oldidx = notebook.get_current_page()
        if oldidx >= 0:
            olddoc = notebook.get_nth_page(oldidx)
            self.handle_current_doc_switch(olddoc)

        newdoc = notebook.get_nth_page(which) if which >= 0 else None

        self.lookup_action('close').set_enabled(bool(newdoc))

        if newdoc:
            nbl = self.notebook.get_tab_label(newdoc)
            self.set_title(nbl.props.label_text)
        else:
            self.set_title("Meld")

        if hasattr(newdoc, 'scheduler'):
            self.scheduler.add_task(newdoc.scheduler)

        self.view_toolbar.foreach(self.view_toolbar.remove)
        if hasattr(newdoc, 'toolbar_actions'):
            self.view_toolbar.add(newdoc.toolbar_actions)

    @Template.Callback()
    def after_switch_page(self, notebook, page, which):
        newdoc = notebook.get_nth_page(which)
        newdoc.on_container_switch_in_event(self)

    @Template.Callback()
    def on_page_label_changed(self, notebook, label_text):
        self.set_title(label_text)

    def action_new_tab(self, action, parameter):
        self.append_new_comparison()

    def action_close(self, *extra):
        i = self.notebook.get_current_page()
        if i >= 0:
            page = self.notebook.get_nth_page(i)
            page.on_delete_event()

    def action_fullscreen_change(self, action, state):
        window_state = self.get_window().get_state()
        is_full = window_state & Gdk.WindowState.FULLSCREEN
        action.set_state(state)
        if state and not is_full:
            self.fullscreen()
        elif is_full:
            self.unfullscreen()

    def action_stop(self, *args):
        # TODO: This is the only window-level action we have that still
        # works on the "current" document like this.
        self.current_doc().action_stop()

    def page_removed(self, page, status):
        if hasattr(page, 'scheduler'):
            self.scheduler.remove_scheduler(page.scheduler)

        page_num = self.notebook.page_num(page)

        if self.notebook.get_current_page() == page_num:
            self.handle_current_doc_switch(page)

        self.notebook.remove_page(page_num)
        # Normal switch-page handlers don't get run for removing the
        # last page from a notebook.
        if not self.has_pages():
            self.on_switch_page(self.notebook, page, -1)
            if self.should_close:
                cancelled = self.emit(
                    'delete-event', Gdk.Event.new(Gdk.EventType.DELETE))
                if not cancelled:
                    self.emit('destroy')

    def on_page_state_changed(self, page, old_state, new_state):
        if self.should_close and old_state == ComparisonState.Closing:
            # Cancel closing if one of our tabs does
            self.should_close = False

    def on_file_changed(self, srcpage, filename):
        for page in self.notebook.get_children():
            if page != srcpage:
                page.on_file_changed(filename)

    @Template.Callback()
    def on_open_recent(self, recent_selector, uri):
        try:
            self.append_recent(uri)
        except (IOError, ValueError):
            # FIXME: Need error handling, but no sensible display location
            log.exception(f'Error opening recent file {uri}')

    def _append_page(self, page, icon):
        nbl = NotebookLabel(icon_name=icon, page=page)
        self.notebook.append_page(page, nbl)
        self.notebook.child_set_property(page, 'tab-expand', True)

        # Change focus to the newly created page only if the user is on a
        # DirDiff or VcView page, or if it's a new tab page. This prevents
        # cycling through X pages when X diffs are initiated.
        if isinstance(self.current_doc(), DirDiff) or \
           isinstance(self.current_doc(), VcView) or \
           isinstance(page, NewDiffTab):
            self.notebook.set_current_page(self.notebook.page_num(page))

        if hasattr(page, 'scheduler'):
            self.scheduler.add_scheduler(page.scheduler)
        if isinstance(page, MeldDoc):
            page.file_changed_signal.connect(self.on_file_changed)
            page.create_diff_signal.connect(
                lambda obj, arg, kwargs: self.append_diff(arg, **kwargs))
            page.tab_state_changed.connect(self.on_page_state_changed)
        page.close_signal.connect(self.page_removed)

        self.notebook.set_tab_reorderable(page, True)

    def append_new_comparison(self):
        doc = NewDiffTab(self)
        self._append_page(doc, "document-new")
        self.notebook.on_label_changed(doc, _("New comparison"), None)

        def diff_created_cb(doc, newdoc):
            doc.on_delete_event()
            idx = self.notebook.page_num(newdoc)
            self.notebook.set_current_page(idx)

        doc.connect("diff-created", diff_created_cb)
        return doc

    def append_dirdiff(self, gfiles, auto_compare=False):
        dirs = [d.get_path() if d else None for d in gfiles]
        assert len(dirs) in (1, 2, 3)
        doc = DirDiff(len(dirs))
        self._append_page(doc, "folder")
        doc.set_locations(dirs)
        if auto_compare:
            doc.scheduler.add_task(doc.auto_compare)
        return doc

    def append_filediff(
            self, gfiles, *, encodings=None, merge_output=None, meta=None):
        assert len(gfiles) in (1, 2, 3)
        doc = FileDiff(len(gfiles))
        self._append_page(doc, "text-x-generic")
        doc.set_files(gfiles, encodings)
        if merge_output is not None:
            doc.set_merge_output_file(merge_output)
        if meta is not None:
            doc.set_meta(meta)
        return doc

    def append_filemerge(self, gfiles, merge_output=None):
        if len(gfiles) != 3:
            raise ValueError(
                _("Need three files to auto-merge, got: %r") %
                [f.get_parse_name() for f in gfiles])
        doc = FileMerge(len(gfiles))
        self._append_page(doc, "text-x-generic")
        doc.set_files(gfiles)
        if merge_output is not None:
            doc.set_merge_output_file(merge_output)
        return doc

    def append_diff(self, gfiles, auto_compare=False, auto_merge=False,
                    merge_output=None, meta=None):
        have_directories = False
        have_files = False
        for f in gfiles:
            if f.query_file_type(
               Gio.FileQueryInfoFlags.NONE, None) == Gio.FileType.DIRECTORY:
                have_directories = True
            else:
                have_files = True
        if have_directories and have_files:
            raise ValueError(
                _("Cannot compare a mixture of files and directories"))
        elif have_directories:
            return self.append_dirdiff(gfiles, auto_compare)
        elif auto_merge:
            return self.append_filemerge(gfiles, merge_output=merge_output)
        else:
            return self.append_filediff(
                gfiles, merge_output=merge_output, meta=meta)

    def append_vcview(self, location, auto_compare=False):
        doc = VcView()
        self._append_page(doc, "meld-version-control")
        if isinstance(location, (list, tuple)):
            location = location[0]
        doc.set_location(location.get_path())
        if auto_compare:
            doc.scheduler.add_task(doc.auto_compare)
        return doc

    def append_recent(self, uri):
        comparison_type, gfiles = recent_comparisons.read(uri)
        comparison_method = {
            RecentType.File: self.append_filediff,
            RecentType.Folder: self.append_dirdiff,
            RecentType.Merge: self.append_filemerge,
            RecentType.VersionControl: self.append_vcview,
        }
        tab = comparison_method[comparison_type](gfiles)
        self.notebook.set_current_page(self.notebook.page_num(tab))
        recent_comparisons.add(tab)
        return tab

    def _single_file_open(self, gfile):
        doc = VcView()

        def cleanup():
            self.scheduler.remove_scheduler(doc.scheduler)
        self.scheduler.add_task(cleanup)
        self.scheduler.add_scheduler(doc.scheduler)
        path = gfile.get_path()
        doc.set_location(path)
        doc.create_diff_signal.connect(
            lambda obj, arg, kwargs: self.append_diff(arg, **kwargs))
        doc.run_diff(path)

    def open_paths(self, gfiles, auto_compare=False, auto_merge=False,
                   focus=False):
        tab = None
        if len(gfiles) == 1:
            a = gfiles[0]
            if a.query_file_type(Gio.FileQueryInfoFlags.NONE, None) == \
                    Gio.FileType.DIRECTORY:
                tab = self.append_vcview(a, auto_compare)
            else:
                self._single_file_open(a)

        elif len(gfiles) in (2, 3):
            tab = self.append_diff(gfiles, auto_compare=auto_compare,
                                   auto_merge=auto_merge)
        if tab:
            recent_comparisons.add(tab)
            if focus:
                self.notebook.set_current_page(self.notebook.page_num(tab))

        return tab

    def current_doc(self):
        "Get the current doc or a dummy object if there is no current"
        index = self.notebook.get_current_page()
        if index >= 0:
            page = self.notebook.get_nth_page(index)
            if isinstance(page, MeldDoc):
                return page

        class DummyDoc:
            def __getattr__(self, a):
                return lambda *x: None
        return DummyDoc()
Esempio n. 10
0
class VcView(Gtk.VBox, tree.TreeviewCommon, MeldDoc):

    __gtype_name__ = "VcView"

    __gsettings_bindings__ = (
        ('vc-status-filters', 'status-filters'),
        ('vc-left-is-local', 'left-is-local'),
        ('vc-merge-file-order', 'merge-file-order'),
    )

    close_signal = MeldDoc.close_signal
    create_diff_signal = MeldDoc.create_diff_signal
    file_changed_signal = MeldDoc.file_changed_signal
    label_changed = MeldDoc.label_changed
    tab_state_changed = MeldDoc.tab_state_changed

    status_filters = GObject.Property(
        type=GObject.TYPE_STRV,
        nick="File status filters",
        blurb="Files with these statuses will be shown by the comparison.",
    )
    left_is_local = GObject.Property(type=bool, default=False)
    merge_file_order = GObject.Property(type=str, default="local-merge-remote")

    # Map for inter-tab command() calls
    command_map = {
        'resolve': 'resolve',
    }

    state_actions = {
        'flatten': ('vc-flatten', None),
        'modified': ('vc-status-modified', Entry.is_modified),
        'normal': ('vc-status-normal', Entry.is_normal),
        'unknown': ('vc-status-unknown', Entry.is_nonvc),
        'ignored': ('vc-status-ignored', Entry.is_ignored),
    }

    combobox_vcs = Template.Child()
    console_vbox = Template.Child()
    consoleview = Template.Child()
    emblem_renderer = Template.Child()
    extra_column = Template.Child()
    extra_renderer = Template.Child()
    fileentry = Template.Child()
    liststore_vcs = Template.Child()
    location_column = Template.Child()
    location_renderer = Template.Child()
    name_column = Template.Child()
    name_renderer = Template.Child()
    status_column = Template.Child()
    status_renderer = Template.Child()
    treeview = Template.Child()
    vc_console_vpaned = Template.Child()

    def __init__(self):
        super().__init__()
        # FIXME:
        # This unimaginable hack exists because GObject (or GTK+?)
        # doesn't actually correctly chain init calls, even if they're
        # not to GObjects. As a workaround, we *should* just be able to
        # put our class first, but because of Gtk.Template we can't do
        # that if it's a GObject, because GObject doesn't support
        # multiple inheritance and we need to inherit from our Widget
        # parent to make Template work.
        MeldDoc.__init__(self)
        self.init_template()
        bind_settings(self)

        # Set up per-view action group for top-level menu insertion
        self.view_action_group = Gio.SimpleActionGroup()

        property_actions = (('vc-console-visible', self.console_vbox,
                             'visible'), )
        for action_name, obj, prop_name in property_actions:
            action = Gio.PropertyAction.new(action_name, obj, prop_name)
            self.view_action_group.add_action(action)

        # Manually handle GAction additions
        actions = (
            ('compare', self.action_diff),
            ('find', self.action_find),
            ('next-change', self.action_next_change),
            ('open-external', self.action_open_external),
            ('previous-change', self.action_previous_change),
            ('refresh', self.action_refresh),
            ('vc-add', self.action_add),
            ('vc-commit', self.action_commit),
            ('vc-delete-locally', self.action_delete),
            ('vc-push', self.action_push),
            ('vc-remove', self.action_remove),
            ('vc-resolve', self.action_resolved),
            ('vc-revert', self.action_revert),
            ('vc-update', self.action_update),
        )
        for name, callback in actions:
            action = Gio.SimpleAction.new(name, None)
            action.connect('activate', callback)
            self.view_action_group.add_action(action)

        new_boolean = GLib.Variant.new_boolean
        stateful_actions = (
            ('vc-flatten', self.action_filter_state_change,
             new_boolean('flatten' in self.props.status_filters)),
            ('vc-status-modified', self.action_filter_state_change,
             new_boolean('modified' in self.props.status_filters)),
            ('vc-status-normal', self.action_filter_state_change,
             new_boolean('normal' in self.props.status_filters)),
            ('vc-status-unknown', self.action_filter_state_change,
             new_boolean('unknown' in self.props.status_filters)),
            ('vc-status-ignored', self.action_filter_state_change,
             new_boolean('ignored' in self.props.status_filters)),
        )
        for (name, callback, state) in stateful_actions:
            action = Gio.SimpleAction.new_stateful(name, None, state)
            action.connect('change-state', callback)
            self.view_action_group.add_action(action)

        builder = Gtk.Builder.new_from_resource(
            '/org/gnome/meld/ui/vcview-menus.ui')
        context_menu = builder.get_object('vcview-context-menu')
        self.popup_menu = Gtk.Menu.new_from_model(context_menu)
        self.popup_menu.attach_to_widget(self)

        self.model = VcTreeStore()
        self.connect("style-updated", self.model.on_style_updated)
        self.model.on_style_updated(self)
        self.treeview.set_model(self.model)
        self.treeview.get_selection().connect(
            "changed", self.on_treeview_selection_changed)
        self.treeview.set_search_equal_func(tree.treeview_search_cb, None)
        self.current_path, self.prev_path, self.next_path = None, None, None

        self.name_column.set_attributes(self.emblem_renderer,
                                        icon_name=tree.COL_ICON,
                                        icon_tint=tree.COL_TINT)
        self.name_column.set_attributes(self.name_renderer,
                                        text=tree.COL_TEXT,
                                        foreground_rgba=tree.COL_FG,
                                        style=tree.COL_STYLE,
                                        weight=tree.COL_WEIGHT,
                                        strikethrough=tree.COL_STRIKE)
        self.location_column.set_attributes(self.location_renderer,
                                            markup=COL_LOCATION)
        self.status_column.set_attributes(self.status_renderer,
                                          markup=COL_STATUS)
        self.extra_column.set_attributes(self.extra_renderer,
                                         markup=COL_OPTIONS)

        self.consolestream = ConsoleStream(self.consoleview)
        self.location = None
        self.vc = None

        settings.bind('vc-console-visible', self.console_vbox, 'visible',
                      Gio.SettingsBindFlags.DEFAULT)
        settings.bind('vc-console-pane-position', self.vc_console_vpaned,
                      'position', Gio.SettingsBindFlags.DEFAULT)

    def on_container_switch_in_event(self, window):
        super().on_container_switch_in_event(window)
        # FIXME: open-external should be tied to having a treeview selection
        self.set_action_enabled("open-external", True)
        self.scheduler.add_task(self.on_treeview_cursor_changed)

    def on_container_switch_out_event(self, window):
        self.set_action_enabled("open-external", False)
        super().on_container_switch_out_event(window)

    def get_default_vc(self, vcs):
        target_name = self.vc.NAME if self.vc else None

        for i, (name, vc, enabled) in enumerate(vcs):
            if not enabled:
                continue

            if target_name and name == target_name:
                return i

        depths = [len(getattr(vc, 'root', [])) for name, vc, enabled in vcs]
        target_depth = max(depths, default=0)

        for i, (name, vc, enabled) in enumerate(vcs):
            if not enabled:
                continue

            if target_depth and len(vc.root) == target_depth:
                return i

        return 0

    def populate_vcs_for_location(self, location):
        """Display VC plugin(s) that can handle the location"""
        vcs_model = self.combobox_vcs.get_model()
        vcs_model.clear()

        # VC systems can be executed at the directory level, so make sure
        # we're checking for VC support there instead of
        # on a specific file or on deleted/unexisting path inside vc
        location = os.path.abspath(location or ".")
        while not os.path.isdir(location):
            parent_location = os.path.dirname(location)
            if len(parent_location) >= len(location):
                # no existing parent: for example unexisting drive on Windows
                break
            location = parent_location
        else:
            # existing parent directory was found
            for avc, enabled in get_vcs(location):
                err_str = ''
                vc_details = {'name': avc.NAME, 'cmd': avc.CMD}

                if not enabled:
                    # Translators: This error message is shown when no
                    # repository of this type is found.
                    err_str = _("%(name)s (not found)")
                elif not avc.is_installed():
                    # Translators: This error message is shown when a version
                    # control binary isn't installed.
                    err_str = _("%(name)s (%(cmd)s not installed)")
                elif not avc.valid_repo(location):
                    # Translators: This error message is shown when a version
                    # controlled repository is invalid.
                    err_str = _("%(name)s (invalid repository)")

                if err_str:
                    vcs_model.append([err_str % vc_details, avc, False])
                    continue

                vcs_model.append([avc.NAME, avc(location), True])

        default_active = self.get_default_vc(vcs_model)

        if not any(enabled for _, _, enabled in vcs_model):
            # If we didn't get any valid vcs then fallback to null
            null_vcs = _null.Vc(location)
            vcs_model.insert(0, [null_vcs.NAME, null_vcs, True])
            tooltip = _("No valid version control system found in this folder")
        else:
            tooltip = _("Choose which version control system to use")

        self.combobox_vcs.set_tooltip_text(tooltip)
        self.combobox_vcs.set_active(default_active)

    @Template.Callback()
    def on_vc_change(self, combobox_vcs):
        active_iter = combobox_vcs.get_active_iter()
        if active_iter is None:
            return
        self.vc = combobox_vcs.get_model()[active_iter][1]
        self._set_location(self.vc.location)

    def set_location(self, location):
        self.populate_vcs_for_location(location)

    def _set_location(self, location):
        self.location = location
        self.current_path = None
        self.model.clear()
        self.fileentry.set_filename(location)
        it = self.model.add_entries(None, [location])
        self.treeview.grab_focus()
        self.treeview.get_selection().select_iter(it)
        self.model.set_path_state(it, 0, tree.STATE_NORMAL, isdir=1)
        self.recompute_label()
        self.scheduler.remove_all_tasks()

        # If the user is just diffing a file (i.e., not a directory),
        # there's no need to scan the rest of the repository.
        if not os.path.isdir(self.vc.location):
            return

        root = self.model.get_iter_first()
        root_path = self.model.get_path(root)

        try:
            self.model.set_value(root, COL_OPTIONS,
                                 self.vc.get_commits_to_push_summary())
        except NotImplementedError:
            pass

        self.scheduler.add_task(self.vc.refresh_vc_state)
        self.scheduler.add_task(self._search_recursively_iter(root_path))
        self.scheduler.add_task(self.on_treeview_selection_changed)
        self.scheduler.add_task(self.on_treeview_cursor_changed)

    def get_comparison(self):
        uris = [Gio.File.new_for_path(self.location)]
        return RecentType.VersionControl, uris

    def recompute_label(self):
        self.label_text = os.path.basename(self.location)
        # TRANSLATORS: This is the location of the directory being viewed
        self.tooltip_text = _("%s: %s") % (_("Location"), self.location)
        self.label_changed.emit(self.label_text, self.tooltip_text)

    def _search_recursively_iter(self, start_path, replace=False):

        # Initial yield so when we add this to our tasks, we don't
        # create iterators that may be invalidated.
        yield _("Scanning repository")

        if replace:
            # Replace the row at start_path with a new, empty row ready
            # to be filled.
            old_iter = self.model.get_iter(start_path)
            file_path = self.model.get_file_path(old_iter)
            new_iter = self.model.insert_after(None, old_iter)
            self.model.set_value(new_iter, tree.COL_PATH, file_path)
            self.model.set_path_state(new_iter, 0, tree.STATE_NORMAL, True)
            self.model.remove(old_iter)

        iterstart = self.model.get_iter(start_path)
        rootname = self.model.get_file_path(iterstart)
        display_prefix = len(rootname) + 1
        symlinks_followed = set()
        todo = [(self.model.get_path(iterstart), rootname)]

        flattened = 'flatten' in self.props.status_filters
        active_actions = [
            self.state_actions.get(k) for k in self.props.status_filters
        ]
        filters = [a[1] for a in active_actions if a and a[1]]

        while todo:
            # This needs to happen sorted and depth-first in order for our row
            # references to remain valid while we traverse.
            todo.sort()
            treepath, path = todo.pop(0)
            it = self.model.get_iter(treepath)
            yield _("Scanning %s") % path[display_prefix:]

            entries = self.vc.get_entries(path)
            entries = [e for e in entries if any(f(e) for f in filters)]
            entries = sorted(entries, key=lambda e: e.name)
            entries = sorted(entries, key=lambda e: not e.isdir)
            for e in entries:
                if e.isdir and e.is_present():
                    try:
                        st = os.lstat(e.path)
                    # Covers certain unreadable symlink cases; see bgo#585895
                    except OSError as err:
                        error_string = "%r: %s" % (e.path, err.strerror)
                        self.model.add_error(it, error_string, 0)
                        continue

                    if stat.S_ISLNK(st.st_mode):
                        key = (st.st_dev, st.st_ino)
                        if key in symlinks_followed:
                            continue
                        symlinks_followed.add(key)

                    if flattened:
                        if e.state != tree.STATE_IGNORED:
                            # If directory state is changed, render it in
                            # in flattened mode.
                            if e.state != tree.STATE_NORMAL:
                                child = self.model.add_entries(it, [e.path])
                                self._update_item_state(child, e)
                            todo.append((Gtk.TreePath.new_first(), e.path))
                        continue

                child = self.model.add_entries(it, [e.path])
                if e.isdir and e.state != tree.STATE_IGNORED:
                    todo.append((self.model.get_path(child), e.path))
                self._update_item_state(child, e)

            if not flattened:
                if not entries:
                    self.model.add_empty(it, _("(Empty)"))
                elif any(e.state != tree.STATE_NORMAL for e in entries):
                    self.treeview.expand_to_path(treepath)

        self.treeview.expand_row(Gtk.TreePath.new_first(), False)
        self.treeview.set_cursor(Gtk.TreePath.new_first())

    # TODO: This doesn't fire when the user selects a shortcut folder
    @Template.Callback()
    def on_fileentry_file_set(self, fileentry):
        directory = fileentry.get_file()
        path = directory.get_path()
        self.set_location(path)

    def on_delete_event(self):
        self.scheduler.remove_all_tasks()
        self.close_signal.emit(0)
        return Gtk.ResponseType.OK

    @Template.Callback()
    def on_row_activated(self, treeview, path, tvc):
        it = self.model.get_iter(path)
        if self.model.iter_has_child(it):
            if self.treeview.row_expanded(path):
                self.treeview.collapse_row(path)
            else:
                self.treeview.expand_row(path, False)
        else:
            path = self.model.get_file_path(it)
            if not self.model.is_folder(it, 0, path):
                self.run_diff(path)

    def run_diff(self, path):
        if os.path.isdir(path):
            self.create_diff_signal.emit([Gio.File.new_for_path(path)], {})
            return

        basename = os.path.basename(path)
        meta = {
            'parent': self,
            'prompt_resolve': False,
        }

        # May have removed directories in list.
        vc_entry = self.vc.get_entry(path)
        if vc_entry and vc_entry.state == tree.STATE_CONFLICT and \
                hasattr(self.vc, 'get_path_for_conflict'):
            local_label = _("%s — local") % basename
            remote_label = _("%s — remote") % basename

            # We create new temp files for other, base and this, and
            # then set the output to the current file.
            if self.props.merge_file_order == "local-merge-remote":
                conflicts = (tree.CONFLICT_THIS, tree.CONFLICT_MERGED,
                             tree.CONFLICT_OTHER)
                meta['labels'] = (local_label, None, remote_label)
                meta['tablabel'] = _("%s (local, merge, remote)") % basename
            else:
                conflicts = (tree.CONFLICT_OTHER, tree.CONFLICT_MERGED,
                             tree.CONFLICT_THIS)
                meta['labels'] = (remote_label, None, local_label)
                meta['tablabel'] = _("%s (remote, merge, local)") % basename
            diffs = [
                self.vc.get_path_for_conflict(path, conflict=c)
                for c in conflicts
            ]
            temps = [p for p, is_temp in diffs if is_temp]
            diffs = [p for p, is_temp in diffs]
            kwargs = {
                'auto_merge': False,
                'merge_output': Gio.File.new_for_path(path),
            }
            meta['prompt_resolve'] = True
        else:
            remote_label = _("%s — repository") % basename
            comp_path = self.vc.get_path_for_repo_file(path)
            temps = [comp_path]
            if self.props.left_is_local:
                diffs = [path, comp_path]
                meta['labels'] = (None, remote_label)
                meta['tablabel'] = _("%s (working, repository)") % basename
            else:
                diffs = [comp_path, path]
                meta['labels'] = (remote_label, None)
                meta['tablabel'] = _("%s (repository, working)") % basename
            kwargs = {}
        kwargs['meta'] = meta

        for temp_file in temps:
            os.chmod(temp_file, 0o444)
            _temp_files.append(temp_file)

        self.create_diff_signal.emit(
            [Gio.File.new_for_path(d) for d in diffs],
            kwargs,
        )

    def action_filter_state_change(self, action, value):
        action.set_state(value)

        active_filters = [
            k for k, (action_name, fn) in self.state_actions.items()
            if self.get_action_state(action_name)
        ]

        if set(active_filters) == set(self.props.status_filters):
            return

        self.props.status_filters = active_filters
        self.refresh()

    def on_treeview_selection_changed(self, selection=None):
        if selection is None:
            selection = self.treeview.get_selection()
        model, rows = selection.get_selected_rows()
        paths = [self.model.get_file_path(model.get_iter(r)) for r in rows]
        states = [self.model.get_state(model.get_iter(r), 0) for r in rows]
        path_states = dict(zip(paths, states))

        valid_actions = self.vc.get_valid_actions(path_states)
        action_sensitivity = {
            'compare': 'compare' in valid_actions,
            'vc-add': 'add' in valid_actions,
            'vc-commit': 'commit' in valid_actions,
            'vc-delete-locally': bool(paths) and self.vc.root not in paths,
            'vc-push': 'push' in valid_actions,
            'vc-remove': 'remove' in valid_actions,
            'vc-resolve': 'resolve' in valid_actions,
            'vc-revert': 'revert' in valid_actions,
            'vc-update': 'update' in valid_actions,
        }
        for action, sensitivity in action_sensitivity.items():
            self.set_action_enabled(action, sensitivity)

    def _get_selected_files(self):
        model, rows = self.treeview.get_selection().get_selected_rows()
        sel = [self.model.get_file_path(self.model.get_iter(r)) for r in rows]
        # Remove empty entries and trailing slashes
        return [x[-1] != "/" and x or x[:-1] for x in sel if x is not None]

    def _command_iter(self, command, files, refresh, working_dir):
        """An iterable that runs a VC command on a set of files

        This method is intended to be used as a scheduled task, with
        standard out and error output displayed in this view's
        consolestream.
        """
        def shelljoin(command):
            def quote(s):
                return '"%s"' % s if len(s.split()) > 1 else s

            return " ".join(quote(tok) for tok in command)

        files = [os.path.relpath(f, working_dir) for f in files]
        msg = shelljoin(command + files) + " (in %s)\n" % working_dir
        self.consolestream.command(msg)
        readiter = read_pipe_iter(command + files,
                                  workdir=working_dir,
                                  errorstream=self.consolestream)
        try:
            result = next(readiter)
            while not result:
                yield 1
                result = next(readiter)
        except IOError as err:
            error_dialog("Error running command",
                         "While running '%s'\nError: %s" % (msg, err))
            result = (1, "")

        returncode, output = result
        self.consolestream.output(output + "\n")

        if returncode:
            self.console_vbox.show()

        if refresh:
            refresh = functools.partial(self.refresh_partial, working_dir)
            GLib.idle_add(refresh)

    def has_command(self, command):
        vc_command = self.command_map.get(command)
        return vc_command and hasattr(self.vc, vc_command)

    def command(self, command, files, sync=False):
        """
        Run a command against this view's version control subsystem

        This is the intended way for things outside of the VCView to
        call in to version control methods, e.g., to mark a conflict as
        resolved from a file comparison.

        :param command: The version control command to run, taken from
            keys in `VCView.command_map`.
        :param files: File parameters to the command as paths
        :param sync: If True, the command will be executed immediately
            (as opposed to being run by the idle scheduler).
        """
        if not self.has_command(command):
            log.error("Couldn't understand command %s", command)
            return

        if not isinstance(files, list):
            log.error("Invalid files argument to '%s': %r", command, files)
            return

        runner = self.runner if not sync else self.sync_runner
        command = getattr(self.vc, self.command_map[command])
        command(runner, files)

    def runner(self, command, files, refresh, working_dir):
        """Schedule a version control command to run as an idle task"""
        self.scheduler.add_task(
            self._command_iter(command, files, refresh, working_dir))

    def sync_runner(self, command, files, refresh, working_dir):
        """Run a version control command immediately"""
        for it in self._command_iter(command, files, refresh, working_dir):
            pass

    def action_update(self, *args):
        self.vc.update(self.runner)

    def action_push(self, *args):
        response = PushDialog(self).run()
        if response == Gtk.ResponseType.OK:
            self.vc.push(self.runner)

    def action_commit(self, *args):
        response, commit_msg = CommitDialog(self).run()
        if response == Gtk.ResponseType.OK:
            self.vc.commit(self.runner, self._get_selected_files(), commit_msg)

    def action_add(self, *args):
        self.vc.add(self.runner, self._get_selected_files())

    def action_remove(self, *args):
        selected = self._get_selected_files()
        if any(os.path.isdir(p) for p in selected):
            # TODO: Improve and reuse this dialog for the non-VC delete action
            dialog = Gtk.MessageDialog(
                parent=self.get_toplevel(),
                flags=(Gtk.DialogFlags.MODAL
                       | Gtk.DialogFlags.DESTROY_WITH_PARENT),
                type=Gtk.MessageType.WARNING,
                message_format=_("Remove folder and all its files?"))
            dialog.format_secondary_text(
                _("This will remove all selected files and folders, and all "
                  "files within any selected folders, from version control."))

            dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
            dialog.add_button(_("_Remove"), Gtk.ResponseType.OK)
            response = dialog.run()
            dialog.destroy()
            if response != Gtk.ResponseType.OK:
                return

        self.vc.remove(self.runner, selected)

    def action_resolved(self, *args):
        self.vc.resolve(self.runner, self._get_selected_files())

    def action_revert(self, *args):
        self.vc.revert(self.runner, self._get_selected_files())

    def action_delete(self, *args):
        files = self._get_selected_files()
        for name in files:
            gfile = Gio.File.new_for_path(name)

            try:
                trash_or_confirm(gfile)
            except Exception as e:
                error_dialog(
                    _("Error deleting {}").format(
                        GLib.markup_escape_text(gfile.get_parse_name()), ),
                    str(e),
                )

        workdir = os.path.dirname(os.path.commonprefix(files))
        self.refresh_partial(workdir)

    def action_diff(self, *args):
        # TODO: Review the compare/diff action. It doesn't really add much
        # over activate, since the folder compare doesn't work and hasn't
        # for... a long time.
        files = self._get_selected_files()
        for f in files:
            self.run_diff(f)

    def action_open_external(self, *args):
        self._open_files(self._get_selected_files())

    def refresh(self):
        root = self.model.get_iter_first()
        if root is None:
            return
        self.set_location(self.model.get_file_path(root))

    def refresh_partial(self, where):
        if not self.get_action_state('vc-flatten'):
            it = self.find_iter_by_name(where)
            if not it:
                return
            path = self.model.get_path(it)

            self.treeview.grab_focus()
            self.vc.refresh_vc_state(where)
            self.scheduler.add_task(
                self._search_recursively_iter(path, replace=True))
            self.scheduler.add_task(self.on_treeview_selection_changed)
            self.scheduler.add_task(self.on_treeview_cursor_changed)
        else:
            # XXX fixme
            self.refresh()

    def _update_item_state(self, it, entry):
        self.model.set_path_state(it, 0, entry.state, entry.isdir)

        location = Gio.File.new_for_path(self.vc.location)
        parent = Gio.File.new_for_path(entry.path).get_parent()
        display_location = location.get_relative_path(parent)

        self.model.set_value(it, COL_LOCATION, display_location)
        self.model.set_value(it, COL_STATUS, entry.get_status())
        self.model.set_value(it, COL_OPTIONS, entry.options)

    def on_file_changed(self, filename):
        it = self.find_iter_by_name(filename)
        if it:
            path = self.model.get_file_path(it)
            self.vc.refresh_vc_state(path)
            entry = self.vc.get_entry(path)
            self._update_item_state(it, entry)

    def find_iter_by_name(self, name):
        it = self.model.get_iter_first()
        path = self.model.get_file_path(it)
        while it:
            if name == path:
                return it
            elif name.startswith(path):
                child = self.model.iter_children(it)
                while child:
                    path = self.model.get_file_path(child)
                    if name == path:
                        return child
                    elif name.startswith(path):
                        break
                    else:
                        child = self.model.iter_next(child)
                it = child
            else:
                break
        return None

    @Template.Callback()
    def on_consoleview_populate_popup(self, textview, menu):
        buf = textview.get_buffer()
        clear_action = Gtk.MenuItem.new_with_label(_("Clear"))
        clear_action.connect("activate",
                             lambda *args: buf.delete(*buf.get_bounds()))
        menu.insert(clear_action, 0)
        menu.insert(Gtk.SeparatorMenuItem(), 1)
        menu.show_all()

    @Template.Callback()
    def on_treeview_popup_menu(self, treeview):
        tree.TreeviewCommon.on_treeview_popup_menu(self, treeview)

    @Template.Callback()
    def on_treeview_button_press_event(self, treeview, event):
        tree.TreeviewCommon.on_treeview_button_press_event(
            self, treeview, event)

    @Template.Callback()
    def on_treeview_cursor_changed(self, *args):
        cursor_path, cursor_col = self.treeview.get_cursor()
        if not cursor_path:
            self.set_action_enabled("previous-change", False)
            self.set_action_enabled("next-change", False)
            self.current_path = cursor_path
            return

        # If invoked directly rather than through a callback, we always check
        if not args:
            skip = False
        else:
            try:
                old_cursor = self.model.get_iter(self.current_path)
            except (ValueError, TypeError):
                # An invalid path gives ValueError; None gives a TypeError
                skip = False
            else:
                # We can skip recalculation if the new cursor is between
                # the previous/next bounds, and we weren't on a changed row
                state = self.model.get_state(old_cursor, 0)
                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_
            self.set_action_enabled("previous-change", prev is not None)
            self.set_action_enabled("next-change", next_ is not None)
        self.current_path = cursor_path

    def next_diff(self, direction):
        if direction == Gdk.ScrollDirection.UP:
            path = self.prev_path
        else:
            path = self.next_path
        if path:
            self.treeview.expand_to_path(path)
            self.treeview.set_cursor(path)

    def action_previous_change(self, *args):
        self.next_diff(Gdk.ScrollDirection.UP)

    def action_next_change(self, *args):
        self.next_diff(Gdk.ScrollDirection.DOWN)

    def action_refresh(self, *args):
        self.on_fileentry_file_set(self.fileentry)

    def action_find(self, *args):
        self.treeview.emit("start-interactive-search")

    def auto_compare(self):
        modified_states = (tree.STATE_MODIFIED, tree.STATE_CONFLICT)
        for it in self.model.state_rows(modified_states):
            row_paths = self.model.value_paths(it)
            paths = [p for p in row_paths if os.path.exists(p)]
            self.run_diff(paths[0])
Esempio n. 11
0
class PatchDialog(Gtk.Dialog):

    __gtype_name__ = "PatchDialog"

    left_radiobutton = Template.Child("left_radiobutton")
    reverse_checkbutton = Template.Child("reverse_checkbutton")
    right_radiobutton = Template.Child("right_radiobutton")
    side_selection_box = Template.Child("side_selection_box")
    side_selection_label = Template.Child("side_selection_label")
    textview = Template.Child("textview")

    def __init__(self, filediff):
        super().__init__()
        self.init_template()

        self.set_transient_for(filediff.get_toplevel())
        self.filediff = filediff

        buf = GtkSource.Buffer()
        self.textview.set_buffer(buf)
        lang = LanguageManager.get_language_from_mime_type("text/x-diff")
        buf.set_language(lang)
        buf.set_highlight_syntax(True)

        self.textview.modify_font(meldsettings.font)
        self.textview.set_editable(False)

        self.index_map = {self.left_radiobutton: (0, 1),
                          self.right_radiobutton: (1, 2)}
        self.left_patch = True
        self.reverse_patch = self.reverse_checkbutton.get_active()

        if self.filediff.num_panes < 3:
            self.side_selection_label.hide()
            self.side_selection_box.hide()

        meldsettings.connect('changed', self.on_setting_changed)

    def on_setting_changed(self, setting, key):
        if key == "font":
            self.textview.modify_font(meldsettings.font)

    @Template.Callback()
    def on_buffer_selection_changed(self, radiobutton):
        if not radiobutton.get_active():
            return
        self.left_patch = radiobutton == self.left_radiobutton
        self.update_patch()

    @Template.Callback()
    def on_reverse_checkbutton_toggled(self, checkbutton):
        self.reverse_patch = checkbutton.get_active()
        self.update_patch()

    def update_patch(self):
        indices = (0, 1)
        if not self.left_patch:
            indices = (1, 2)
        if self.reverse_patch:
            indices = (indices[1], indices[0])

        texts = []
        for b in self.filediff.textbuffer:
            start, end = b.get_bounds()
            text = b.get_text(start, end, False)
            lines = text.splitlines(True)

            # Ensure that the last line ends in a newline
            barelines = text.splitlines(False)
            if barelines and lines and barelines[-1] == lines[-1]:
                # Final line lacks a line-break; add in a best guess
                if len(lines) > 1:
                    previous_linebreak = lines[-2][len(barelines[-2]):]
                else:
                    previous_linebreak = "\n"
                lines[-1] += previous_linebreak

            texts.append(lines)

        names = [self.filediff.textbuffer[i].data.label for i in range(3)]
        prefix = os.path.commonprefix(names)
        names = [n[prefix.rfind("/") + 1:] for n in names]

        buf = self.textview.get_buffer()
        text0, text1 = texts[indices[0]], texts[indices[1]]
        name0, name1 = names[indices[0]], names[indices[1]]

        diff = difflib.unified_diff(text0, text1, name0, name1)
        diff_text = "".join(d for d in diff)
        buf.set_text(diff_text)

    def save_patch(self, targetfile: Gio.File):
        buf = self.textview.get_buffer()
        sourcefile = GtkSource.File.new()
        saver = GtkSource.FileSaver.new_with_target(
            buf, sourcefile, targetfile)
        saver.save_async(
            GLib.PRIORITY_HIGH,
            callback=self.file_saved_cb,
        )

    def file_saved_cb(self, saver, result, *args):
        gfile = saver.get_location()
        try:
            saver.save_finish(result)
        except GLib.Error as err:
            filename = GLib.markup_escape_text(gfile.get_parse_name())
            error_dialog(
                primary=_("Could not save file %s.") % filename,
                secondary=_("Couldn’t save file due to:\n%s") % (
                    GLib.markup_escape_text(str(err))),
            )

    def run(self):
        self.update_patch()

        result = super().run()
        if result < 0:
            self.hide()
            return

        # Copy patch to clipboard
        if result == 1:
            buf = self.textview.get_buffer()
            start, end = buf.get_bounds()
            clip = Gtk.Clipboard.get_default(Gdk.Display.get_default())
            clip.set_text(buf.get_text(start, end, False), -1)
            clip.store()
        # Save patch as a file
        else:
            gfile = prompt_save_filename(_("Save Patch"))
            if gfile:
                self.save_patch(gfile)

        self.hide()
Esempio n. 12
0
class NewDiffTab(Gtk.Alignment, LabeledObjectMixin):

    __gtype_name__ = "NewDiffTab"

    __gsignals__ = {
        'diff-created': (GObject.SignalFlags.RUN_FIRST, None, (object,)),
    }

    close_signal = MeldDoc.close_signal
    label_changed_signal = LabeledObjectMixin.label_changed

    label_text = _("New comparison")

    button_compare = Template.Child()
    button_new_blank = Template.Child()
    button_type_dir = Template.Child()
    button_type_file = Template.Child()
    button_type_vc = Template.Child()
    choosers_notebook = Template.Child()
    dir_chooser0 = Template.Child()
    dir_chooser1 = Template.Child()
    dir_chooser2 = Template.Child()
    dir_three_way_checkbutton = Template.Child()
    file_chooser0 = Template.Child()
    file_chooser1 = Template.Child()
    file_chooser2 = Template.Child()
    file_three_way_checkbutton = Template.Child()
    filechooserdialog0 = Template.Child()
    filechooserdialog1 = Template.Child()
    filechooserdialog2 = Template.Child()
    vc_chooser0 = Template.Child()

    def __init__(self, parentapp):
        super().__init__()
        self.init_template()
        map_widgets_into_lists(
            self,
            ["file_chooser", "dir_chooser", "vc_chooser", "filechooserdialog"]
        )
        self.button_types = [
            self.button_type_file,
            self.button_type_dir,
            self.button_type_vc,
        ]
        self.diff_methods = {
            DiffType.File: parentapp.append_filediff,
            DiffType.Folder: parentapp.append_dirdiff,
            DiffType.Version: parentapp.append_vcview,
        }
        self.diff_type = DiffType.Unselected

        default_path = GLib.get_home_dir()
        for chooser in self.file_chooser:
            chooser.set_current_folder(default_path)

        self.show()

    @Template.Callback()
    def on_button_type_toggled(self, button, *args):
        if not button.get_active():
            if not any([b.get_active() for b in self.button_types]):
                button.set_active(True)
            return

        for b in self.button_types:
            if b is not button:
                b.set_active(False)

        self.diff_type = DiffType(self.button_types.index(button))
        self.choosers_notebook.set_current_page(self.diff_type + 1)
        # FIXME: Add support for new blank for VcView
        self.button_new_blank.set_sensitive(
            self.diff_type.supports_blank())
        self.button_compare.set_sensitive(True)

    @Template.Callback()
    def on_three_way_checkbutton_toggled(self, button, *args):
        if button is self.file_three_way_checkbutton:
            self.file_chooser2.set_sensitive(button.get_active())
        else:  # button is self.dir_three_way_checkbutton
            self.dir_chooser2.set_sensitive(button.get_active())

    @Template.Callback()
    def on_file_set(self, filechooser, *args):
        gfile = filechooser.get_file()
        if not gfile:
            return

        parent = gfile.get_parent()
        if not parent:
            return

        if parent.query_file_type(
                Gio.FileQueryInfoFlags.NONE, None) == Gio.FileType.DIRECTORY:
            for chooser in self.file_chooser:
                if not chooser.get_file():
                    chooser.set_current_folder_file(parent)

        # TODO: We could do checks here to prevent errors: check to see if
        # we've got binary files; check for null file selections; sniff text
        # encodings; check file permissions.

    def _get_num_paths(self):
        if self.diff_type in (DiffType.File, DiffType.Folder):
            three_way_buttons = (
                self.file_three_way_checkbutton,
                self.dir_three_way_checkbutton,
            )
            three_way = three_way_buttons[self.diff_type].get_active()
            num_paths = 3 if three_way else 2
        else:  # DiffType.Version
            num_paths = 1
        return num_paths

    @Template.Callback()
    def on_button_compare_clicked(self, *args):
        type_choosers = (self.file_chooser, self.dir_chooser, self.vc_chooser)
        choosers = type_choosers[self.diff_type][:self._get_num_paths()]
        compare_gfiles = [chooser.get_file() for chooser in choosers]

        compare_kwargs = {}
        if self.diff_type == DiffType.File:
            chooserdialogs = self.filechooserdialog[:self._get_num_paths()]
            encodings = [chooser.get_encoding() for chooser in chooserdialogs]
            compare_kwargs = {'encodings': encodings}

        tab = self.diff_methods[self.diff_type](
            compare_gfiles, **compare_kwargs)
        recent_comparisons.add(tab)
        self.emit('diff-created', tab)

    @Template.Callback()
    def on_button_new_blank_clicked(self, *args):
        # TODO: This doesn't work the way I'd like for DirDiff and VCView.
        # It should do something similar to FileDiff; give a tab with empty
        # file entries and no comparison done.

        # File comparison wants None for its paths here. Folder mode
        # needs an actual directory.
        if self.diff_type == DiffType.File:
            gfiles = [None] * self._get_num_paths()
        else:
            gfiles = [Gio.File.new_for_path("")] * self._get_num_paths()
        tab = self.diff_methods[self.diff_type](gfiles)
        self.emit('diff-created', tab)

    def on_container_switch_in_event(self, *args):
        self.label_changed.emit(self.label_text, self.tooltip_text)

    def on_container_switch_out_event(self, *args):
        pass

    def on_delete_event(self, *args):
        self.close_signal.emit(0)
        return Gtk.ResponseType.OK
Esempio n. 13
0
class FindBar(Gtk.Grid):

    __gtype_name__ = 'FindBar'

    find_entry = Template.Child()
    find_next_button = Template.Child()
    find_previous_button = Template.Child()
    match_case = Template.Child()
    regex = Template.Child()
    replace_all_button = Template.Child()
    replace_button = Template.Child()
    replace_entry = Template.Child()
    whole_word = Template.Child()
    wrap_box = Template.Child()

    replace_mode = GObject.Property(type=bool, default=False)

    @GObject.Signal(
        name='activate-secondary',
        flags=(GObject.SignalFlags.RUN_FIRST | GObject.SignalFlags.ACTION),
    )
    def activate_secondary(self) -> None:
        self._find_text(backwards=True)

    def __init__(self, parent):
        super().__init__()
        self.init_template()

        self.search_context = None
        self.notify_id = None
        self.set_text_view(None)

        # Setup a signal for when the find bar loses focus
        parent.connect('set-focus-child', self.on_focus_child)

        # Create and bind our GtkSourceSearchSettings
        settings = GtkSource.SearchSettings()
        self.match_case.bind_property('active', settings, 'case-sensitive')
        self.whole_word.bind_property('active', settings, 'at-word-boundaries')
        self.regex.bind_property('active', settings, 'regex-enabled')
        self.find_entry.bind_property('text', settings, 'search-text')
        settings.set_wrap_around(True)
        self.search_settings = settings

        # Bind visibility and layout for find-and-replace mode
        self.bind_property('replace_mode', self.replace_entry, 'visible')
        self.bind_property('replace_mode', self.replace_all_button, 'visible')
        self.bind_property('replace_mode', self.replace_button, 'visible')
        self.bind_property(
            'replace_mode', self, 'row-spacing', GObject.BindingFlags.DEFAULT,
            lambda binding, replace_mode: 6 if replace_mode else 0)

    def on_focus_child(self, container, widget):
        if widget is not None:
            visible = self.props.visible
            if widget is not self and visible:
                self.hide()
        return False

    def hide(self):
        self.set_text_view(None)
        self.wrap_box.set_visible(False)
        Gtk.Widget.hide(self)

    def update_match_state(self, *args):
        # Note that -1 here implies that the search is still running
        no_matches = (self.search_context.props.occurrences_count == 0
                      and self.search_settings.props.search_text)
        style_context = self.find_entry.get_style_context()
        if no_matches:
            style_context.add_class(Gtk.STYLE_CLASS_ERROR)
        else:
            style_context.remove_class(Gtk.STYLE_CLASS_ERROR)

    def set_text_view(self, textview):
        self.textview = textview
        if textview is not None:
            self.search_context = GtkSource.SearchContext.new(
                textview.get_buffer(), self.search_settings)
            self.search_context.set_highlight(True)
            self.notify_id = self.search_context.connect(
                'notify::occurrences-count', self.update_match_state)
        else:
            if self.notify_id:
                self.search_context.disconnect(self.notify_id)
                self.notify_id = None
            self.search_context = None

    def start_find(self, *, textview: Gtk.TextView, replace: bool, text: str):
        self.replace_mode = replace
        self.set_text_view(textview)
        if text:
            self.find_entry.set_text(text)
        self.show()
        self.find_entry.grab_focus()

    def start_find_next(self, textview):
        self.set_text_view(textview)
        self._find_text()

    def start_find_previous(self, textview):
        self.set_text_view(textview)
        self._find_text(backwards=True)

    @Template.Callback()
    def on_find_next_button_clicked(self, button):
        self._find_text()

    @Template.Callback()
    def on_find_previous_button_clicked(self, button):
        self._find_text(backwards=True)

    @Template.Callback()
    def on_replace_button_clicked(self, entry):
        buf = self.textview.get_buffer()
        oldsel = buf.get_selection_bounds()
        match = self._find_text(0)
        newsel = buf.get_selection_bounds()
        # Only replace if there is an already-selected match at the cursor
        if (match and oldsel and oldsel[0].equal(newsel[0])
                and oldsel[1].equal(newsel[1])):
            self.search_context.replace(newsel[0], newsel[1],
                                        self.replace_entry.get_text(), -1)
            self._find_text(0)

    @Template.Callback()
    def on_replace_all_button_clicked(self, entry):
        buf = self.textview.get_buffer()
        saved_insert = buf.create_mark(None,
                                       buf.get_iter_at_mark(buf.get_insert()),
                                       True)
        self.search_context.replace_all(self.replace_entry.get_text(), -1)
        if not saved_insert.get_deleted():
            buf.place_cursor(buf.get_iter_at_mark(saved_insert))
            self.textview.scroll_to_mark(buf.get_insert(), 0.25, True, 0.5,
                                         0.5)

    @Template.Callback()
    def on_toggle_replace_button_clicked(self, button):
        self.replace_mode = not self.replace_mode

    @Template.Callback()
    def on_find_entry_changed(self, entry):
        self._find_text(0)

    @Template.Callback()
    def on_stop_search(self, search_entry):
        self.hide()

    def _find_text(self, start_offset=1, backwards=False):
        if not self.textview or not self.search_context:
            return

        buf = self.textview.get_buffer()
        insert = buf.get_iter_at_mark(buf.get_insert())

        start, end = buf.get_bounds()
        self.wrap_box.set_visible(False)
        if not backwards:
            insert.forward_chars(start_offset)
            match, start_iter, end_iter = self.search_context.forward(insert)
            if match and (start_iter.get_offset() < insert.get_offset()):
                self.wrap_box.set_visible(True)
        else:
            match, start_iter, end_iter = self.search_context.backward(insert)
            if match and (start_iter.get_offset() > insert.get_offset()):
                self.wrap_box.set_visible(True)
        if match:
            buf.place_cursor(start_iter)
            buf.move_mark(buf.get_selection_bound(), end_iter)
            self.textview.scroll_to_mark(buf.get_insert(), 0.25, True, 0.5,
                                         0.5)
            return True
        else:
            buf.place_cursor(buf.get_iter_at_mark(buf.get_insert()))
            self.wrap_box.set_visible(False)
Esempio n. 14
0
class RecentSelector(Gtk.Grid):

    __gtype_name__ = 'RecentSelector'

    @GObject.Signal(
        flags=(GObject.SignalFlags.RUN_FIRST | GObject.SignalFlags.ACTION),
        arg_types=(str, ),
    )
    def open_recent(self, uri: str) -> None:
        ...

    recent_chooser = Template.Child()
    search_entry = Template.Child()
    open_button = Template.Child()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.init_template()

        self.filter_text = ''
        self.recent_chooser.set_filter(self.make_recent_filter())

    def custom_recent_filter_func(self,
                                  filter_info: Gtk.RecentFilterInfo) -> bool:
        """Filter function for Meld-specific files

        Normal GTK recent filter rules are all OR-ed together to check
        whether an entry should be shown. This filter instead only ever
        shows Meld-specific entries, and then filters down from there.
        """

        if filter_info.mime_type != RecentFiles.mime_type:
            return False

        if self.filter_text not in filter_info.display_name.lower():
            return False

        return True

    def make_recent_filter(self) -> Gtk.RecentFilter:
        recent_filter = Gtk.RecentFilter()
        recent_filter.add_custom(
            (Gtk.RecentFilterFlags.MIME_TYPE
             | Gtk.RecentFilterFlags.DISPLAY_NAME),
            self.custom_recent_filter_func,
        )
        return recent_filter

    @Template.Callback()
    def on_filter_text_changed(self, *args):
        self.filter_text = self.search_entry.get_text().lower()

        # This feels unnecessary, but there's no other good way to get
        # the RecentChooser to re-evaluate the filter.
        self.recent_chooser.set_filter(self.make_recent_filter())

    @Template.Callback()
    def on_selection_changed(self, *args):
        have_selection = bool(self.recent_chooser.get_current_uri())
        self.open_button.set_sensitive(have_selection)

    @Template.Callback()
    def on_activate(self, *args):
        uri = self.recent_chooser.get_current_uri()
        if uri:
            self.open_recent.emit(uri)
Esempio n. 15
0
class MeldWindow(Gtk.ApplicationWindow):

    __gtype_name__ = 'MeldWindow'

    appvbox = Template.Child("appvbox")
    gear_menu_button = Template.Child("gear_menu_button")
    notebook = Template.Child("notebook")
    spinner = Template.Child("spinner")
    toolbar_holder = Template.Child("toolbar_holder")

    def __init__(self):
        super().__init__()

        self.init_template()

        actions = (
            ("FileMenu", None, _("_File")),
            ("New", Gtk.STOCK_NEW, _("_New Comparison…"), "<Primary>N",
             _("Start a new comparison"), self.on_menu_file_new_activate),
            ("Save", Gtk.STOCK_SAVE, None, None, _("Save the current file"),
             self.on_menu_save_activate),
            ("SaveAs", Gtk.STOCK_SAVE_AS, _("Save As…"), "<Primary><shift>S",
             _("Save the current file with a different name"),
             self.on_menu_save_as_activate),
            ("Close", Gtk.STOCK_CLOSE, None, None, _("Close the current file"),
             self.on_menu_close_activate),
            ("EditMenu", None, _("_Edit")),
            ("Undo", Gtk.STOCK_UNDO, None, "<Primary>Z",
             _("Undo the last action"), self.on_menu_undo_activate),
            ("Redo", Gtk.STOCK_REDO, None, "<Primary><shift>Z",
             _("Redo the last undone action"), self.on_menu_redo_activate),
            ("Cut", Gtk.STOCK_CUT, None, None, _("Cut the selection"),
             self.on_menu_cut_activate),
            ("Copy", Gtk.STOCK_COPY, None, None, _("Copy the selection"),
             self.on_menu_copy_activate),
            ("Paste", Gtk.STOCK_PASTE, None, None, _("Paste the clipboard"),
             self.on_menu_paste_activate),
            ("Find", Gtk.STOCK_FIND, _("Find…"), None, _("Search for text"),
             self.on_menu_find_activate),
            ("FindNext", None, _("Find Ne_xt"), "<Primary>G",
             _("Search forwards for the same text"),
             self.on_menu_find_next_activate),
            ("FindPrevious", None, _("Find _Previous"), "<Primary><shift>G",
             _("Search backwards for the same text"),
             self.on_menu_find_previous_activate),
            ("Replace",
             Gtk.STOCK_FIND_AND_REPLACE, _("_Replace…"), "<Primary>H",
             _("Find and replace text"), self.on_menu_replace_activate),
            ("GoToLine", None, _("Go to _Line"), "<Primary>I",
             _("Go to a specific line"), self.on_menu_go_to_line_activate),
            ("ChangesMenu", None, _("_Changes")),
            ("NextChange", Gtk.STOCK_GO_DOWN, _("Next Change"), "<Alt>Down",
             _("Go to the next change"), self.on_menu_edit_down_activate),
            ("PrevChange", Gtk.STOCK_GO_UP, _("Previous Change"), "<Alt>Up",
             _("Go to the previous change"), self.on_menu_edit_up_activate),
            ("OpenExternal", None, _("Open Externally"), None,
             _("Open selected file or directory in the default external "
               "application"), self.on_open_external),
            ("ViewMenu", None, _("_View")),
            ("FileStatus", None, _("File Status")),
            ("VcStatus", None, _("Version Status")),
            ("FileFilters", None, _("File Filters")),
            ("Stop", Gtk.STOCK_STOP, None, "Escape",
             _("Stop the current action"), self.on_toolbar_stop_clicked),
            ("Refresh", Gtk.STOCK_REFRESH, None, "<Primary>R",
             _("Refresh the view"), self.on_menu_refresh_activate),
        )
        toggleactions = (("Fullscreen", None, _("Fullscreen"), "F11",
                          _("View the comparison in fullscreen"),
                          self.on_action_fullscreen_toggled, False), )
        self.actiongroup = Gtk.ActionGroup(name='MainActions')
        self.actiongroup.set_translation_domain("meld")
        self.actiongroup.add_actions(actions)
        self.actiongroup.add_toggle_actions(toggleactions)

        recent_action = Gtk.RecentAction(name="Recent",
                                         label=_("Open Recent"),
                                         tooltip=_("Open recent files"),
                                         stock_id=None)
        recent_action.set_show_private(True)
        recent_action.set_filter(recent_comparisons.recent_filter)
        recent_action.set_sort_type(Gtk.RecentSortType.MRU)
        recent_action.connect("item-activated", self.on_action_recent)
        self.actiongroup.add_action(recent_action)

        self.ui = Gtk.UIManager()
        self.ui.insert_action_group(self.actiongroup, 0)
        self.ui.add_ui_from_file(ui_file("meldapp-ui.xml"))

        for menuitem in ("Save", "Undo"):
            self.actiongroup.get_action(menuitem).props.is_important = True
        self.add_accel_group(self.ui.get_accel_group())
        self.menubar = self.ui.get_widget('/Menubar')
        self.toolbar = self.ui.get_widget('/Toolbar')
        self.toolbar.get_style_context().add_class(
            Gtk.STYLE_CLASS_PRIMARY_TOOLBAR)

        # Alternate keybindings for a few commands.
        extra_accels = (
            ("<Primary>D", self.on_menu_edit_down_activate),
            ("<Primary>E", self.on_menu_edit_up_activate),
            ("<Alt>KP_Down", self.on_menu_edit_down_activate),
            ("<Alt>KP_Up", self.on_menu_edit_up_activate),
            ("F5", self.on_menu_refresh_activate),
        )

        accel_group = self.ui.get_accel_group()
        for accel, callback in extra_accels:
            keyval, mask = Gtk.accelerator_parse(accel)
            accel_group.connect(keyval, mask, 0, callback)

        # Initialise sensitivity for important actions
        self.actiongroup.get_action("Stop").set_sensitive(False)
        self._update_page_action_sensitivity()

        self.appvbox.pack_start(self.menubar, False, True, 0)
        self.toolbar_holder.pack_start(self.toolbar, True, True, 0)

        # This double toolbar works around integrating non-UIManager widgets
        # into the toolbar. It's no longer used, but kept as a possible
        # GAction porting helper.
        self.secondary_toolbar = Gtk.Toolbar()
        self.secondary_toolbar.get_style_context().add_class(
            Gtk.STYLE_CLASS_PRIMARY_TOOLBAR)
        self.toolbar_holder.pack_end(self.secondary_toolbar, False, True, 0)
        self.secondary_toolbar.show_all()

        # Manually handle GAction additions
        actions = (("close", self.on_menu_close_activate, None), )
        for (name, callback, accel) in actions:
            action = Gio.SimpleAction.new(name, None)
            action.connect('activate', callback)
            self.add_action(action)

        # Fake out the spinner on Windows. See Gitlab issue #133.
        if os.name == 'nt':
            for attr in ('stop', 'hide', 'show', 'start'):
                setattr(self.spinner, attr, lambda *args: True)

        self.drag_dest_set(
            Gtk.DestDefaults.MOTION | Gtk.DestDefaults.HIGHLIGHT
            | Gtk.DestDefaults.DROP, None, Gdk.DragAction.COPY)
        self.drag_dest_add_uri_targets()
        self.connect("drag_data_received", self.on_widget_drag_data_received)

        self.window_state = SavedWindowState()
        self.window_state.bind(self)

        self.should_close = False
        self.idle_hooked = 0
        self.scheduler = LifoScheduler()
        self.scheduler.connect("runnable", self.on_scheduler_runnable)

        self.ui.ensure_update()
        self.diff_handler = None
        self.undo_handlers = tuple()

        # Set tooltip on map because the recentmenu is lazily created
        rmenu = self.ui.get_widget('/Menubar/FileMenu/Recent').get_submenu()
        rmenu.connect("map", self._on_recentmenu_map)

    def do_realize(self):
        Gtk.ApplicationWindow.do_realize(self)

        app = self.get_application()
        menu = app.get_menu_by_id("gear-menu")
        self.gear_menu_button.set_popover(
            Gtk.Popover.new_from_model(self.gear_menu_button, menu))

        meld.ui.util.extract_accels_from_menu(menu, self.get_application())

    def _on_recentmenu_map(self, recentmenu):
        for imagemenuitem in recentmenu.get_children():
            imagemenuitem.set_tooltip_text(imagemenuitem.get_label())

    def on_widget_drag_data_received(self, wid, context, x, y, selection_data,
                                     info, time):
        uris = selection_data.get_uris()
        if uris:
            self.open_paths([Gio.File.new_for_uri(uri) for uri in uris])
            return True

    def on_idle(self):
        ret = self.scheduler.iteration()
        if ret and isinstance(ret, str):
            self.spinner.set_tooltip_text(ret)

        pending = self.scheduler.tasks_pending()
        if not pending:
            self.spinner.stop()
            self.spinner.hide()
            self.spinner.set_tooltip_text("")
            self.idle_hooked = None
            self.actiongroup.get_action("Stop").set_sensitive(False)
        return pending

    def on_scheduler_runnable(self, sched):
        if not self.idle_hooked:
            self.spinner.show()
            self.spinner.start()
            self.actiongroup.get_action("Stop").set_sensitive(True)
            self.idle_hooked = GLib.idle_add(self.on_idle)

    @Template.Callback()
    def on_delete_event(self, *extra):
        should_cancel = False
        # Delete pages from right-to-left.  This ensures that if a version
        # control page is open in the far left page, it will be closed last.
        for page in reversed(self.notebook.get_children()):
            self.notebook.set_current_page(self.notebook.page_num(page))
            response = page.on_delete_event()
            if response == Gtk.ResponseType.CANCEL:
                should_cancel = True

        should_cancel = should_cancel or self.has_pages()
        if should_cancel:
            self.should_close = True
        return should_cancel

    def has_pages(self):
        return self.notebook.get_n_pages() > 0

    def _update_page_action_sensitivity(self):
        current_page = self.notebook.get_current_page()

        if current_page != -1:
            page = self.notebook.get_nth_page(current_page)
        else:
            page = None

        self.actiongroup.get_action("Close").set_sensitive(bool(page))
        if not isinstance(page, MeldDoc):
            for action in ("PrevChange", "NextChange", "Cut", "Copy", "Paste",
                           "Find", "FindNext", "FindPrevious", "Replace",
                           "Refresh", "GoToLine"):
                self.actiongroup.get_action(action).set_sensitive(False)
        else:
            for action in ("Find", "Refresh"):
                self.actiongroup.get_action(action).set_sensitive(True)
            is_filediff = isinstance(page, FileDiff)
            for action in ("Cut", "Copy", "Paste", "FindNext", "FindPrevious",
                           "Replace", "GoToLine"):
                self.actiongroup.get_action(action).set_sensitive(is_filediff)

    def handle_current_doc_switch(self, page):
        if self.diff_handler is not None:
            page.disconnect(self.diff_handler)
        page.on_container_switch_out_event(self.ui)
        if self.undo_handlers:
            undoseq = page.undosequence
            for handler in self.undo_handlers:
                undoseq.disconnect(handler)
            self.undo_handlers = tuple()

    @Template.Callback()
    def on_switch_page(self, notebook, page, which):
        oldidx = notebook.get_current_page()
        if oldidx >= 0:
            olddoc = notebook.get_nth_page(oldidx)
            self.handle_current_doc_switch(olddoc)

        newdoc = notebook.get_nth_page(which) if which >= 0 else None
        try:
            undoseq = newdoc.undosequence
            can_undo = undoseq.can_undo()
            can_redo = undoseq.can_redo()
            undo_handler = undoseq.connect("can-undo", self.on_can_undo)
            redo_handler = undoseq.connect("can-redo", self.on_can_redo)
            self.undo_handlers = (undo_handler, redo_handler)
        except AttributeError:
            can_undo, can_redo = False, False
        self.actiongroup.get_action("Undo").set_sensitive(can_undo)
        self.actiongroup.get_action("Redo").set_sensitive(can_redo)

        # FileDiff handles save sensitivity; it makes no sense for other modes
        if not isinstance(newdoc, FileDiff):
            self.actiongroup.get_action("Save").set_sensitive(False)
            self.actiongroup.get_action("SaveAs").set_sensitive(False)
        else:
            self.actiongroup.get_action("SaveAs").set_sensitive(True)

        if newdoc:
            nbl = self.notebook.get_tab_label(newdoc)
            self.set_title(nbl.props.label_text)
        else:
            self.set_title("Meld")

        if isinstance(newdoc, MeldDoc):
            self.diff_handler = newdoc.next_diff_changed_signal.connect(
                self.on_next_diff_changed)
        else:
            self.diff_handler = None
        if hasattr(newdoc, 'scheduler'):
            self.scheduler.add_task(newdoc.scheduler)

    @Template.Callback()
    def after_switch_page(self, notebook, page, which):
        newdoc = notebook.get_nth_page(which)
        newdoc.on_container_switch_in_event(self.ui)
        self._update_page_action_sensitivity()

    @Template.Callback()
    def after_page_reordered(self, notebook, page, page_num):
        self._update_page_action_sensitivity()

    @Template.Callback()
    def on_page_label_changed(self, notebook, label_text):
        self.set_title(label_text)

    def on_can_undo(self, undosequence, can):
        self.actiongroup.get_action("Undo").set_sensitive(can)

    def on_can_redo(self, undosequence, can):
        self.actiongroup.get_action("Redo").set_sensitive(can)

    def on_next_diff_changed(self, doc, have_prev, have_next):
        self.actiongroup.get_action("PrevChange").set_sensitive(have_prev)
        self.actiongroup.get_action("NextChange").set_sensitive(have_next)

    def on_menu_file_new_activate(self, menuitem):
        self.append_new_comparison()

    def on_menu_save_activate(self, menuitem):
        self.current_doc().save()

    def on_menu_save_as_activate(self, menuitem):
        self.current_doc().save_as()

    def on_action_recent(self, action):
        uri = action.get_current_uri()
        if not uri:
            return
        try:
            self.append_recent(uri)
        except (IOError, ValueError):
            # FIXME: Need error handling, but no sensible display location
            pass

    def on_menu_close_activate(self, *extra):
        i = self.notebook.get_current_page()
        if i >= 0:
            page = self.notebook.get_nth_page(i)
            page.on_delete_event()

    def on_menu_undo_activate(self, *extra):
        self.current_doc().on_undo_activate()

    def on_menu_redo_activate(self, *extra):
        self.current_doc().on_redo_activate()

    def on_menu_refresh_activate(self, *extra):
        self.current_doc().on_refresh_activate()

    def on_menu_find_activate(self, *extra):
        self.current_doc().on_find_activate()

    def on_menu_find_next_activate(self, *extra):
        self.current_doc().on_find_next_activate()

    def on_menu_find_previous_activate(self, *extra):
        self.current_doc().on_find_previous_activate()

    def on_menu_replace_activate(self, *extra):
        self.current_doc().on_replace_activate()

    def on_menu_go_to_line_activate(self, *extra):
        self.current_doc().on_go_to_line_activate()

    def on_menu_copy_activate(self, *extra):
        widget = self.get_focus()
        if isinstance(widget, Gtk.Editable):
            widget.copy_clipboard()
        elif isinstance(widget, Gtk.TextView):
            widget.emit("copy-clipboard")

    def on_menu_cut_activate(self, *extra):
        widget = self.get_focus()
        if isinstance(widget, Gtk.Editable):
            widget.cut_clipboard()
        elif isinstance(widget, Gtk.TextView):
            widget.emit("cut-clipboard")

    def on_menu_paste_activate(self, *extra):
        widget = self.get_focus()
        if isinstance(widget, Gtk.Editable):
            widget.paste_clipboard()
        elif isinstance(widget, Gtk.TextView):
            widget.emit("paste-clipboard")

    def on_action_fullscreen_toggled(self, widget):
        window_state = self.get_window().get_state()
        is_full = window_state & Gdk.WindowState.FULLSCREEN
        if widget.get_active() and not is_full:
            self.fullscreen()
        elif is_full:
            self.unfullscreen()

    def on_menu_edit_down_activate(self, *args):
        self.current_doc().next_diff(Gdk.ScrollDirection.DOWN)

    def on_menu_edit_up_activate(self, *args):
        self.current_doc().next_diff(Gdk.ScrollDirection.UP)

    def on_open_external(self, *args):
        self.current_doc().open_external()

    def on_toolbar_stop_clicked(self, *args):
        doc = self.current_doc()
        if doc.scheduler.tasks_pending():
            doc.scheduler.remove_task(doc.scheduler.get_current_task())

    def page_removed(self, page, status):
        if hasattr(page, 'scheduler'):
            self.scheduler.remove_scheduler(page.scheduler)

        page_num = self.notebook.page_num(page)

        if self.notebook.get_current_page() == page_num:
            self.handle_current_doc_switch(page)

        self.notebook.remove_page(page_num)
        # Normal switch-page handlers don't get run for removing the
        # last page from a notebook.
        if not self.has_pages():
            self.on_switch_page(self.notebook, page, -1)
            self._update_page_action_sensitivity()
            # Synchronise UIManager state; this shouldn't be necessary,
            # but upstream aren't touching UIManager bugs.
            self.ui.ensure_update()
            if self.should_close:
                cancelled = self.emit('delete-event',
                                      Gdk.Event.new(Gdk.EventType.DELETE))
                if not cancelled:
                    self.emit('destroy')

    def on_page_state_changed(self, page, old_state, new_state):
        if self.should_close and old_state == ComparisonState.Closing:
            # Cancel closing if one of our tabs does
            self.should_close = False

    def on_file_changed(self, srcpage, filename):
        for page in self.notebook.get_children():
            if page != srcpage:
                page.on_file_changed(filename)

    def _append_page(self, page, icon):
        nbl = NotebookLabel(icon_name=icon, page=page)
        self.notebook.append_page(page, nbl)
        self.notebook.child_set_property(page, 'tab-expand', True)

        # Change focus to the newly created page only if the user is on a
        # DirDiff or VcView page, or if it's a new tab page. This prevents
        # cycling through X pages when X diffs are initiated.
        if isinstance(self.current_doc(), DirDiff) or \
           isinstance(self.current_doc(), VcView) or \
           isinstance(page, NewDiffTab):
            self.notebook.set_current_page(self.notebook.page_num(page))

        if hasattr(page, 'scheduler'):
            self.scheduler.add_scheduler(page.scheduler)
        if isinstance(page, MeldDoc):
            page.file_changed_signal.connect(self.on_file_changed)
            page.create_diff_signal.connect(
                lambda obj, arg, kwargs: self.append_diff(arg, **kwargs))
            page.tab_state_changed.connect(self.on_page_state_changed)
        page.close_signal.connect(self.page_removed)

        self.notebook.set_tab_reorderable(page, True)

    def append_new_comparison(self):
        doc = NewDiffTab(self)
        self._append_page(doc, "document-new")
        self.notebook.on_label_changed(doc, _("New comparison"), None)

        def diff_created_cb(doc, newdoc):
            doc.on_delete_event()
            idx = self.notebook.page_num(newdoc)
            self.notebook.set_current_page(idx)

        doc.connect("diff-created", diff_created_cb)
        return doc

    def append_dirdiff(self, gfiles, auto_compare=False):
        dirs = [d.get_path() if d else None for d in gfiles]
        assert len(dirs) in (1, 2, 3)
        doc = DirDiff(len(dirs))
        self._append_page(doc, "folder")
        doc.set_locations(dirs)
        if auto_compare:
            doc.scheduler.add_task(doc.auto_compare)
        return doc

    def append_filediff(self,
                        gfiles,
                        *,
                        encodings=None,
                        merge_output=None,
                        meta=None):
        assert len(gfiles) in (1, 2, 3)
        doc = FileDiff(len(gfiles))
        self._append_page(doc, "text-x-generic")
        doc.set_files(gfiles, encodings)
        if merge_output is not None:
            doc.set_merge_output_file(merge_output)
        if meta is not None:
            doc.set_meta(meta)
        return doc

    def append_filemerge(self, gfiles, merge_output=None):
        if len(gfiles) != 3:
            raise ValueError(
                _("Need three files to auto-merge, got: %r") %
                [f.get_parse_name() for f in gfiles])
        doc = FileMerge(len(gfiles))
        self._append_page(doc, "text-x-generic")
        doc.set_files(gfiles)
        if merge_output is not None:
            doc.set_merge_output_file(merge_output)
        return doc

    def append_diff(self,
                    gfiles,
                    auto_compare=False,
                    auto_merge=False,
                    merge_output=None,
                    meta=None):
        have_directories = False
        have_files = False
        for f in gfiles:
            if f.query_file_type(Gio.FileQueryInfoFlags.NONE,
                                 None) == Gio.FileType.DIRECTORY:
                have_directories = True
            else:
                have_files = True
        if have_directories and have_files:
            raise ValueError(
                _("Cannot compare a mixture of files and directories"))
        elif have_directories:
            return self.append_dirdiff(gfiles, auto_compare)
        elif auto_merge:
            return self.append_filemerge(gfiles, merge_output=merge_output)
        else:
            return self.append_filediff(gfiles,
                                        merge_output=merge_output,
                                        meta=meta)

    def append_vcview(self, location, auto_compare=False):
        doc = VcView()
        self._append_page(doc, "meld-version-control")
        if isinstance(location, (list, tuple)):
            location = location[0]
        doc.set_location(location.get_path())
        if auto_compare:
            doc.scheduler.add_task(doc.auto_compare)
        return doc

    def append_recent(self, uri):
        comparison_type, gfiles = recent_comparisons.read(uri)
        comparison_method = {
            RecentType.File: self.append_filediff,
            RecentType.Folder: self.append_dirdiff,
            RecentType.Merge: self.append_filemerge,
            RecentType.VersionControl: self.append_vcview,
        }
        tab = comparison_method[comparison_type](gfiles)
        self.notebook.set_current_page(self.notebook.page_num(tab))
        recent_comparisons.add(tab)
        return tab

    def _single_file_open(self, gfile):
        doc = VcView()

        def cleanup():
            self.scheduler.remove_scheduler(doc.scheduler)

        self.scheduler.add_task(cleanup)
        self.scheduler.add_scheduler(doc.scheduler)
        path = gfile.get_path()
        doc.set_location(path)
        doc.create_diff_signal.connect(
            lambda obj, arg, kwargs: self.append_diff(arg, **kwargs))
        doc.run_diff(path)

    def open_paths(self,
                   gfiles,
                   auto_compare=False,
                   auto_merge=False,
                   focus=False):
        tab = None
        if len(gfiles) == 1:
            a = gfiles[0]
            if a.query_file_type(Gio.FileQueryInfoFlags.NONE, None) == \
                    Gio.FileType.DIRECTORY:
                tab = self.append_vcview(a, auto_compare)
            else:
                self._single_file_open(a)

        elif len(gfiles) in (2, 3):
            tab = self.append_diff(gfiles,
                                   auto_compare=auto_compare,
                                   auto_merge=auto_merge)
        if tab:
            recent_comparisons.add(tab)
            if focus:
                self.notebook.set_current_page(self.notebook.page_num(tab))

        return tab

    def current_doc(self):
        "Get the current doc or a dummy object if there is no current"
        index = self.notebook.get_current_page()
        if index >= 0:
            page = self.notebook.get_nth_page(index)
            if isinstance(page, MeldDoc):
                return page

        class DummyDoc:
            def __getattr__(self, a):
                return lambda *x: None

        return DummyDoc()