1# Copyright (C) 2002-2006 Stephen Kennedy <stevek@gnome.org>
2# Copyright (C) 2010-2015 Kai Willadsen <kai.willadsen@gmail.com>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 2 of the License, or (at
7# your option) any later version.
8#
9# This program is distributed in the hope that it will be useful, but
10# WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12# General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17import atexit
18import functools
19import logging
20import os
21import shutil
22import stat
23import sys
24import tempfile
25
26from gi.repository import Gdk
27from gi.repository import Gio
28from gi.repository import GLib
29from gi.repository import GObject
30from gi.repository import Gtk
31from gi.repository import Pango
32
33from meld import tree
34from meld.conf import _
35from meld.iohelpers import trash_or_confirm
36from meld.melddoc import MeldDoc
37from meld.misc import error_dialog, read_pipe_iter
38from meld.recent import RecentType
39from meld.settings import bind_settings, settings
40from meld.ui.gnomeglade import Component, ui_file
41from meld.ui.vcdialogs import CommitDialog, PushDialog
42from meld.vc import _null, get_vcs
43from meld.vc._vc import Entry
44
45log = logging.getLogger(__name__)
46
47
48def cleanup_temp():
49    temp_location = tempfile.gettempdir()
50    # The strings below will probably end up as debug log, and are deliberately
51    # not marked for translation.
52    for f in _temp_files:
53        try:
54            assert (os.path.exists(f) and os.path.isabs(f) and
55                    os.path.dirname(f) == temp_location)
56            # Windows throws permissions errors if we remove read-only files
57            if os.name == "nt":
58                os.chmod(f, stat.S_IWRITE)
59            os.remove(f)
60        except Exception:
61            except_str = "{0[0]}: \"{0[1]}\"".format(sys.exc_info())
62            print("File \"{0}\" not removed due to".format(f), except_str,
63                  file=sys.stderr)
64    for f in _temp_dirs:
65        try:
66            assert (os.path.exists(f) and os.path.isabs(f) and
67                    os.path.dirname(f) == temp_location)
68            shutil.rmtree(f, ignore_errors=1)
69        except Exception:
70            except_str = "{0[0]}: \"{0[1]}\"".format(sys.exc_info())
71            print("Directory \"{0}\" not removed due to".format(f), except_str,
72                  file=sys.stderr)
73
74
75_temp_dirs, _temp_files = [], []
76atexit.register(cleanup_temp)
77
78
79class ConsoleStream:
80
81    def __init__(self, textview):
82        self.textview = textview
83        buf = textview.get_buffer()
84        self.command_tag = buf.create_tag("command")
85        self.command_tag.props.weight = Pango.Weight.BOLD
86        self.output_tag = buf.create_tag("output")
87        self.error_tag = buf.create_tag("error")
88        # FIXME: Need to add this to the gtkrc?
89        self.error_tag.props.foreground = "#cc0000"
90        self.end_mark = buf.create_mark(None, buf.get_end_iter(),
91                                        left_gravity=False)
92
93    def command(self, message):
94        self.write(message, self.command_tag)
95
96    def output(self, message):
97        self.write(message, self.output_tag)
98
99    def error(self, message):
100        self.write(message, self.error_tag)
101
102    def write(self, message, tag):
103        if not message:
104            return
105        buf = self.textview.get_buffer()
106        buf.insert_with_tags(buf.get_end_iter(), message, tag)
107        self.textview.scroll_mark_onscreen(self.end_mark)
108
109
110COL_LOCATION, COL_STATUS, COL_OPTIONS, COL_END = \
111    list(range(tree.COL_END, tree.COL_END + 4))
112
113
114class VcTreeStore(tree.DiffTreeStore):
115    def __init__(self):
116        super().__init__(1, [str] * 5)
117
118    def get_file_path(self, it):
119        return self.get_value(it, self.column_index(tree.COL_PATH, 0))
120
121
122class VcView(MeldDoc, Component):
123
124    __gtype_name__ = "VcView"
125
126    __gsettings_bindings__ = (
127        ('vc-status-filters', 'status-filters'),
128        ('vc-left-is-local', 'left-is-local'),
129        ('vc-merge-file-order', 'merge-file-order'),
130    )
131
132    status_filters = GObject.Property(
133        type=GObject.TYPE_STRV,
134        nick="File status filters",
135        blurb="Files with these statuses will be shown by the comparison.",
136    )
137    left_is_local = GObject.Property(type=bool, default=False)
138    merge_file_order = GObject.Property(type=str, default="local-merge-remote")
139
140    # Map for inter-tab command() calls
141    command_map = {
142        'resolve': 'resolve',
143    }
144
145    state_actions = {
146        "flatten": ("VcFlatten", None),
147        "modified": ("VcShowModified", Entry.is_modified),
148        "normal": ("VcShowNormal", Entry.is_normal),
149        "unknown": ("VcShowNonVC", Entry.is_nonvc),
150        "ignored": ("VcShowIgnored", Entry.is_ignored),
151    }
152
153    def __init__(self):
154        MeldDoc.__init__(self)
155        Component.__init__(
156            self, "vcview.ui", "vcview", ["VcviewActions", 'liststore_vcs'])
157        bind_settings(self)
158
159        self.ui_file = ui_file("vcview-ui.xml")
160        self.actiongroup = self.VcviewActions
161        self.actiongroup.set_translation_domain("meld")
162        self.model = VcTreeStore()
163        self.widget.connect("style-updated", self.model.on_style_updated)
164        self.model.on_style_updated(self.widget)
165        self.treeview.set_model(self.model)
166        self.treeview.get_selection().connect(
167            "changed", self.on_treeview_selection_changed)
168        self.treeview.set_search_equal_func(tree.treeview_search_cb, None)
169        self.current_path, self.prev_path, self.next_path = None, None, None
170
171        self.name_column.set_attributes(
172            self.emblem_renderer,
173            icon_name=tree.COL_ICON,
174            icon_tint=tree.COL_TINT)
175        self.name_column.set_attributes(
176            self.name_renderer,
177            text=tree.COL_TEXT,
178            foreground_rgba=tree.COL_FG,
179            style=tree.COL_STYLE,
180            weight=tree.COL_WEIGHT,
181            strikethrough=tree.COL_STRIKE)
182        self.location_column.set_attributes(
183            self.location_renderer, markup=COL_LOCATION)
184        self.status_column.set_attributes(
185            self.status_renderer, markup=COL_STATUS)
186        self.extra_column.set_attributes(
187            self.extra_renderer, markup=COL_OPTIONS)
188        self.location_column.bind_property(
189            'visible', self.actiongroup.get_action("VcFlatten"), 'active')
190
191        self.consolestream = ConsoleStream(self.consoleview)
192        self.location = None
193        self.vc = None
194
195        settings.bind('vc-console-visible',
196                      self.actiongroup.get_action('VcConsoleVisible'),
197                      'active', Gio.SettingsBindFlags.DEFAULT)
198        settings.bind('vc-console-visible', self.console_vbox, 'visible',
199                      Gio.SettingsBindFlags.DEFAULT)
200        settings.bind('vc-console-pane-position', self.vc_console_vpaned,
201                      'position', Gio.SettingsBindFlags.DEFAULT)
202
203        for s in self.props.status_filters:
204            if s in self.state_actions:
205                self.actiongroup.get_action(
206                    self.state_actions[s][0]).set_active(True)
207
208    def _set_external_action_sensitivity(self, focused):
209        try:
210            self.main_actiongroup.get_action("OpenExternal").set_sensitive(
211                focused)
212        except AttributeError:
213            pass
214
215    def on_container_switch_in_event(self, ui):
216        super().on_container_switch_in_event(ui)
217        self._set_external_action_sensitivity(True)
218        self.scheduler.add_task(self.on_treeview_cursor_changed)
219
220    def on_container_switch_out_event(self, ui):
221        self._set_external_action_sensitivity(False)
222        super().on_container_switch_out_event(ui)
223
224    def populate_vcs_for_location(self, location):
225        """Display VC plugin(s) that can handle the location"""
226        vcs_model = self.combobox_vcs.get_model()
227        vcs_model.clear()
228
229        # VC systems can be executed at the directory level, so make sure
230        # we're checking for VC support there instead of
231        # on a specific file or on deleted/unexisting path inside vc
232        location = os.path.abspath(location or ".")
233        while not os.path.isdir(location):
234            parent_location = os.path.dirname(location)
235            if len(parent_location) >= len(location):
236                # no existing parent: for example unexisting drive on Windows
237                break
238            location = parent_location
239        else:
240            # existing parent directory was found
241            for avc in get_vcs(location):
242                err_str = ''
243                vc_details = {'name': avc.NAME, 'cmd': avc.CMD}
244
245                if not avc.is_installed():
246                    # Translators: This error message is shown when a version
247                    # control binary isn't installed.
248                    err_str = _("%(name)s (%(cmd)s not installed)")
249                elif not avc.valid_repo(location):
250                    # Translators: This error message is shown when a version
251                    # controlled repository is invalid.
252                    err_str = _("%(name)s (Invalid repository)")
253
254                if err_str:
255                    vcs_model.append([err_str % vc_details, avc, False])
256                    continue
257
258                vcs_model.append([avc.NAME, avc(location), True])
259
260        valid_vcs = [(i, r[1].NAME) for i, r in enumerate(vcs_model) if r[2]]
261        default_active = min(valid_vcs)[0] if valid_vcs else 0
262
263        # Keep the same VC plugin active on refresh, otherwise use the first
264        current_vc_name = self.vc.NAME if self.vc else None
265        same_vc = [i for i, name in valid_vcs if name == current_vc_name]
266        if same_vc:
267            default_active = same_vc[0]
268
269        if not valid_vcs:
270            # If we didn't get any valid vcs then fallback to null
271            null_vcs = _null.Vc(location)
272            vcs_model.insert(0, [null_vcs.NAME, null_vcs, True])
273            tooltip = _("No valid version control system found in this folder")
274        elif len(vcs_model) == 1:
275            tooltip = _("Only one version control system found in this folder")
276        else:
277            tooltip = _("Choose which version control system to use")
278
279        self.combobox_vcs.set_tooltip_text(tooltip)
280        self.combobox_vcs.set_sensitive(len(vcs_model) > 1)
281        self.combobox_vcs.set_active(default_active)
282
283    def on_vc_change(self, combobox_vcs):
284        active_iter = combobox_vcs.get_active_iter()
285        if active_iter is None:
286            return
287        self.vc = combobox_vcs.get_model()[active_iter][1]
288        self._set_location(self.vc.location)
289
290    def set_location(self, location):
291        self.populate_vcs_for_location(location)
292
293    def _set_location(self, location):
294        self.location = location
295        self.current_path = None
296        self.model.clear()
297        self.fileentry.set_filename(location)
298        it = self.model.add_entries(None, [location])
299        self.treeview.grab_focus()
300        self.treeview.get_selection().select_iter(it)
301        self.model.set_path_state(it, 0, tree.STATE_NORMAL, isdir=1)
302        self.recompute_label()
303        self.scheduler.remove_all_tasks()
304
305        # If the user is just diffing a file (i.e., not a directory),
306        # there's no need to scan the rest of the repository.
307        if not os.path.isdir(self.vc.location):
308            return
309
310        root = self.model.get_iter_first()
311        root_path = self.model.get_path(root)
312
313        try:
314            self.model.set_value(
315                root, COL_OPTIONS, self.vc.get_commits_to_push_summary())
316        except NotImplementedError:
317            pass
318
319        self.scheduler.add_task(self.vc.refresh_vc_state)
320        self.scheduler.add_task(self._search_recursively_iter(root_path))
321        self.scheduler.add_task(self.on_treeview_selection_changed)
322        self.scheduler.add_task(self.on_treeview_cursor_changed)
323
324    def get_comparison(self):
325        uris = [Gio.File.new_for_path(self.location)]
326        return RecentType.VersionControl, uris
327
328    def recompute_label(self):
329        self.label_text = os.path.basename(self.location)
330        # TRANSLATORS: This is the location of the directory being viewed
331        self.tooltip_text = _("%s: %s") % (_("Location"), self.location)
332        self.label_changed()
333
334    def _search_recursively_iter(self, start_path, replace=False):
335
336        # Initial yield so when we add this to our tasks, we don't
337        # create iterators that may be invalidated.
338        yield _("Scanning repository")
339
340        if replace:
341            # Replace the row at start_path with a new, empty row ready
342            # to be filled.
343            old_iter = self.model.get_iter(start_path)
344            file_path = self.model.get_file_path(old_iter)
345            new_iter = self.model.insert_after(None, old_iter)
346            self.model.set_value(new_iter, tree.COL_PATH, file_path)
347            self.model.set_path_state(new_iter, 0, tree.STATE_NORMAL, True)
348            self.model.remove(old_iter)
349
350        iterstart = self.model.get_iter(start_path)
351        rootname = self.model.get_file_path(iterstart)
352        display_prefix = len(rootname) + 1
353        symlinks_followed = set()
354        todo = [(self.model.get_path(iterstart), rootname)]
355
356        flattened = 'flatten' in self.props.status_filters
357        active_actions = [
358            self.state_actions.get(k) for k in self.props.status_filters]
359        filters = [a[1] for a in active_actions if a and a[1]]
360
361        while todo:
362            # This needs to happen sorted and depth-first in order for our row
363            # references to remain valid while we traverse.
364            todo.sort()
365            treepath, path = todo.pop(0)
366            it = self.model.get_iter(treepath)
367            yield _("Scanning %s") % path[display_prefix:]
368
369            entries = self.vc.get_entries(path)
370            entries = [e for e in entries if any(f(e) for f in filters)]
371            entries = sorted(entries, key=lambda e: e.name)
372            entries = sorted(entries, key=lambda e: not e.isdir)
373            for e in entries:
374                if e.isdir and e.is_present():
375                    try:
376                        st = os.lstat(e.path)
377                    # Covers certain unreadable symlink cases; see bgo#585895
378                    except OSError as err:
379                        error_string = "%r: %s" % (e.path, err.strerror)
380                        self.model.add_error(it, error_string, 0)
381                        continue
382
383                    if stat.S_ISLNK(st.st_mode):
384                        key = (st.st_dev, st.st_ino)
385                        if key in symlinks_followed:
386                            continue
387                        symlinks_followed.add(key)
388
389                    if flattened:
390                        if e.state != tree.STATE_IGNORED:
391                            # If directory state is changed, render it in
392                            # in flattened mode.
393                            if e.state != tree.STATE_NORMAL:
394                                child = self.model.add_entries(it, [e.path])
395                                self._update_item_state(child, e)
396                            todo.append((Gtk.TreePath.new_first(), e.path))
397                        continue
398
399                child = self.model.add_entries(it, [e.path])
400                if e.isdir and e.state != tree.STATE_IGNORED:
401                    todo.append((self.model.get_path(child), e.path))
402                self._update_item_state(child, e)
403
404            if not flattened:
405                if not entries:
406                    self.model.add_empty(it, _("(Empty)"))
407                elif any(e.state != tree.STATE_NORMAL for e in entries):
408                    self.treeview.expand_to_path(treepath)
409
410        self.treeview.expand_row(Gtk.TreePath.new_first(), False)
411
412    # TODO: This doesn't fire when the user selects a shortcut folder
413    def on_fileentry_file_set(self, fileentry):
414        directory = fileentry.get_file()
415        path = directory.get_path()
416        self.set_location(path)
417
418    def on_delete_event(self):
419        self.scheduler.remove_all_tasks()
420        self.emit('close', 0)
421        return Gtk.ResponseType.OK
422
423    def on_row_activated(self, treeview, path, tvc):
424        it = self.model.get_iter(path)
425        if self.model.iter_has_child(it):
426            if self.treeview.row_expanded(path):
427                self.treeview.collapse_row(path)
428            else:
429                self.treeview.expand_row(path, False)
430        else:
431            path = self.model.get_file_path(it)
432            if not self.model.is_folder(it, 0, path):
433                self.run_diff(path)
434
435    def run_diff(self, path):
436        if os.path.isdir(path):
437            self.emit("create-diff", [Gio.File.new_for_path(path)], {})
438            return
439
440        basename = os.path.basename(path)
441        meta = {
442            'parent': self,
443            'prompt_resolve': False,
444        }
445
446        # May have removed directories in list.
447        vc_entry = self.vc.get_entry(path)
448        if vc_entry and vc_entry.state == tree.STATE_CONFLICT and \
449                hasattr(self.vc, 'get_path_for_conflict'):
450            local_label = _("%s — local") % basename
451            remote_label = _("%s — remote") % basename
452
453            # We create new temp files for other, base and this, and
454            # then set the output to the current file.
455            if self.props.merge_file_order == "local-merge-remote":
456                conflicts = (tree.CONFLICT_THIS, tree.CONFLICT_MERGED,
457                             tree.CONFLICT_OTHER)
458                meta['labels'] = (local_label, None, remote_label)
459                meta['tablabel'] = _("%s (local, merge, remote)") % basename
460            else:
461                conflicts = (tree.CONFLICT_OTHER, tree.CONFLICT_MERGED,
462                             tree.CONFLICT_THIS)
463                meta['labels'] = (remote_label, None, local_label)
464                meta['tablabel'] = _("%s (remote, merge, local)") % basename
465            diffs = [self.vc.get_path_for_conflict(path, conflict=c)
466                     for c in conflicts]
467            temps = [p for p, is_temp in diffs if is_temp]
468            diffs = [p for p, is_temp in diffs]
469            kwargs = {
470                'auto_merge': False,
471                'merge_output': Gio.File.new_for_path(path),
472            }
473            meta['prompt_resolve'] = True
474        else:
475            remote_label = _("%s — repository") % basename
476            comp_path = self.vc.get_path_for_repo_file(path)
477            temps = [comp_path]
478            if self.props.left_is_local:
479                diffs = [path, comp_path]
480                meta['labels'] = (None, remote_label)
481                meta['tablabel'] = _("%s (working, repository)") % basename
482            else:
483                diffs = [comp_path, path]
484                meta['labels'] = (remote_label, None)
485                meta['tablabel'] = _("%s (repository, working)") % basename
486            kwargs = {}
487        kwargs['meta'] = meta
488
489        for temp_file in temps:
490            os.chmod(temp_file, 0o444)
491            _temp_files.append(temp_file)
492
493        self.emit("create-diff",
494                  [Gio.File.new_for_path(d) for d in diffs], kwargs)
495
496    def do_popup_treeview_menu(self, widget, event):
497        if event:
498            button = event.button
499            time = event.time
500        else:
501            button = 0
502            time = Gtk.get_current_event_time()
503        self.popup_menu.popup(None, None, None, None, button, time)
504
505    def on_treeview_popup_menu(self, treeview):
506        self.do_popup_treeview_menu(treeview, None)
507        return True
508
509    def on_button_press_event(self, treeview, event):
510        if (event.triggers_context_menu() and
511                event.type == Gdk.EventType.BUTTON_PRESS):
512            path = treeview.get_path_at_pos(int(event.x), int(event.y))
513            if path is None:
514                return False
515            selection = treeview.get_selection()
516            model, rows = selection.get_selected_rows()
517
518            if path[0] not in rows:
519                selection.unselect_all()
520                selection.select_path(path[0])
521                treeview.set_cursor(path[0])
522
523            self.do_popup_treeview_menu(treeview, event)
524            return True
525        return False
526
527    def on_filter_state_toggled(self, button):
528        active_filters = [
529            k for k, (action_name, fn) in self.state_actions.items()
530            if self.actiongroup.get_action(action_name).get_active()
531        ]
532
533        if set(active_filters) == set(self.props.status_filters):
534            return
535
536        self.props.status_filters = active_filters
537        self.refresh()
538
539    def on_treeview_selection_changed(self, selection=None):
540        if selection is None:
541            selection = self.treeview.get_selection()
542        model, rows = selection.get_selected_rows()
543        paths = [self.model.get_file_path(model.get_iter(r)) for r in rows]
544        states = [self.model.get_state(model.get_iter(r), 0) for r in rows]
545        path_states = dict(zip(paths, states))
546
547        valid_actions = self.vc.get_valid_actions(path_states)
548        action_sensitivity = {
549            "VcCompare": 'compare' in valid_actions,
550            "VcCommit": 'commit' in valid_actions,
551            "VcUpdate": 'update' in valid_actions,
552            "VcPush": 'push' in valid_actions,
553            "VcAdd": 'add' in valid_actions,
554            "VcResolved": 'resolve' in valid_actions,
555            "VcRemove": 'remove' in valid_actions,
556            "VcRevert": 'revert' in valid_actions,
557            "VcDeleteLocally": bool(paths) and self.vc.root not in paths,
558        }
559        for action, sensitivity in action_sensitivity.items():
560            self.actiongroup.get_action(action).set_sensitive(sensitivity)
561
562    def _get_selected_files(self):
563        model, rows = self.treeview.get_selection().get_selected_rows()
564        sel = [self.model.get_file_path(self.model.get_iter(r)) for r in rows]
565        # Remove empty entries and trailing slashes
566        return [x[-1] != "/" and x or x[:-1] for x in sel if x is not None]
567
568    def _command_iter(self, command, files, refresh, working_dir):
569        """An iterable that runs a VC command on a set of files
570
571        This method is intended to be used as a scheduled task, with
572        standard out and error output displayed in this view's
573        consolestream.
574        """
575
576        def shelljoin(command):
577            def quote(s):
578                return '"%s"' % s if len(s.split()) > 1 else s
579            return " ".join(quote(tok) for tok in command)
580
581        files = [os.path.relpath(f, working_dir) for f in files]
582        msg = shelljoin(command + files) + " (in %s)\n" % working_dir
583        self.consolestream.command(msg)
584        readiter = read_pipe_iter(
585            command + files, workdir=working_dir,
586            errorstream=self.consolestream)
587        try:
588            result = next(readiter)
589            while not result:
590                yield 1
591                result = next(readiter)
592        except IOError as err:
593            error_dialog(
594                "Error running command",
595                "While running '%s'\nError: %s" % (msg, err))
596            result = (1, "")
597
598        returncode, output = result
599        self.consolestream.output(output + "\n")
600
601        if returncode:
602            self.console_vbox.show()
603
604        if refresh:
605            refresh = functools.partial(self.refresh_partial, working_dir)
606            GLib.idle_add(refresh)
607
608    def has_command(self, command):
609        vc_command = self.command_map.get(command)
610        return vc_command and hasattr(self.vc, vc_command)
611
612    def command(self, command, files, sync=False):
613        """
614        Run a command against this view's version control subsystem
615
616        This is the intended way for things outside of the VCView to
617        call in to version control methods, e.g., to mark a conflict as
618        resolved from a file comparison.
619
620        :param command: The version control command to run, taken from
621            keys in `VCView.command_map`.
622        :param files: File parameters to the command as paths
623        :param sync: If True, the command will be executed immediately
624            (as opposed to being run by the idle scheduler).
625        """
626        if not self.has_command(command):
627            log.error("Couldn't understand command %s", command)
628            return
629
630        if not isinstance(files, list):
631            log.error("Invalid files argument to '%s': %r", command, files)
632            return
633
634        runner = self.runner if not sync else self.sync_runner
635        command = getattr(self.vc, self.command_map[command])
636        command(runner, files)
637
638    def runner(self, command, files, refresh, working_dir):
639        """Schedule a version control command to run as an idle task"""
640        self.scheduler.add_task(
641            self._command_iter(command, files, refresh, working_dir))
642
643    def sync_runner(self, command, files, refresh, working_dir):
644        """Run a version control command immediately"""
645        for it in self._command_iter(command, files, refresh, working_dir):
646            pass
647
648    def on_button_update_clicked(self, obj):
649        self.vc.update(self.runner)
650
651    def on_button_push_clicked(self, obj):
652        response = PushDialog(self).run()
653        if response == Gtk.ResponseType.OK:
654            self.vc.push(self.runner)
655
656    def on_button_commit_clicked(self, obj):
657        response, commit_msg = CommitDialog(self).run()
658        if response == Gtk.ResponseType.OK:
659            self.vc.commit(
660                self.runner, self._get_selected_files(), commit_msg)
661
662    def on_button_add_clicked(self, obj):
663        self.vc.add(self.runner, self._get_selected_files())
664
665    def on_button_remove_clicked(self, obj):
666        selected = self._get_selected_files()
667        if any(os.path.isdir(p) for p in selected):
668            # TODO: Improve and reuse this dialog for the non-VC delete action
669            dialog = Gtk.MessageDialog(
670                parent=self.widget.get_toplevel(),
671                flags=(Gtk.DialogFlags.MODAL |
672                       Gtk.DialogFlags.DESTROY_WITH_PARENT),
673                type=Gtk.MessageType.WARNING,
674                message_format=_("Remove folder and all its files?"))
675            dialog.format_secondary_text(
676                _("This will remove all selected files and folders, and all "
677                  "files within any selected folders, from version control."))
678
679            dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
680            dialog.add_button(_("_Remove"), Gtk.ResponseType.OK)
681            response = dialog.run()
682            dialog.destroy()
683            if response != Gtk.ResponseType.OK:
684                return
685
686        self.vc.remove(self.runner, selected)
687
688    def on_button_resolved_clicked(self, obj):
689        self.vc.resolve(self.runner, self._get_selected_files())
690
691    def on_button_revert_clicked(self, obj):
692        self.vc.revert(self.runner, self._get_selected_files())
693
694    def on_button_delete_clicked(self, obj):
695        files = self._get_selected_files()
696        for name in files:
697            gfile = Gio.File.new_for_path(name)
698
699            try:
700                trash_or_confirm(gfile)
701            except Exception as e:
702                error_dialog(
703                    _("Error deleting {}").format(
704                        GLib.markup_escape_text(gfile.get_parse_name()),
705                    ),
706                    str(e),
707                )
708
709        workdir = os.path.dirname(os.path.commonprefix(files))
710        self.refresh_partial(workdir)
711
712    def on_button_diff_clicked(self, obj):
713        files = self._get_selected_files()
714        for f in files:
715            self.run_diff(f)
716
717    def open_external(self):
718        self._open_files(self._get_selected_files())
719
720    def refresh(self):
721        root = self.model.get_iter_first()
722        if root is None:
723            return
724        self.set_location(self.model.get_file_path(root))
725
726    def refresh_partial(self, where):
727        if not self.actiongroup.get_action("VcFlatten").get_active():
728            it = self.find_iter_by_name(where)
729            if not it:
730                return
731            path = self.model.get_path(it)
732
733            self.treeview.grab_focus()
734            self.vc.refresh_vc_state(where)
735            self.scheduler.add_task(
736                self._search_recursively_iter(path, replace=True))
737            self.scheduler.add_task(self.on_treeview_selection_changed)
738            self.scheduler.add_task(self.on_treeview_cursor_changed)
739        else:
740            # XXX fixme
741            self.refresh()
742
743    def _update_item_state(self, it, entry):
744        self.model.set_path_state(it, 0, entry.state, entry.isdir)
745
746        location = Gio.File.new_for_path(self.vc.location)
747        parent = Gio.File.new_for_path(entry.path).get_parent()
748        display_location = location.get_relative_path(parent)
749
750        self.model.set_value(it, COL_LOCATION, display_location)
751        self.model.set_value(it, COL_STATUS, entry.get_status())
752        self.model.set_value(it, COL_OPTIONS, entry.options)
753
754    def on_file_changed(self, filename):
755        it = self.find_iter_by_name(filename)
756        if it:
757            path = self.model.get_file_path(it)
758            self.vc.refresh_vc_state(path)
759            entry = self.vc.get_entry(path)
760            self._update_item_state(it, entry)
761
762    def find_iter_by_name(self, name):
763        it = self.model.get_iter_first()
764        path = self.model.get_file_path(it)
765        while it:
766            if name == path:
767                return it
768            elif name.startswith(path):
769                child = self.model.iter_children(it)
770                while child:
771                    path = self.model.get_file_path(child)
772                    if name == path:
773                        return child
774                    elif name.startswith(path):
775                        break
776                    else:
777                        child = self.model.iter_next(child)
778                it = child
779            else:
780                break
781        return None
782
783    def on_consoleview_populate_popup(self, textview, menu):
784        buf = textview.get_buffer()
785        clear_action = Gtk.MenuItem.new_with_label(_("Clear"))
786        clear_action.connect(
787            "activate", lambda *args: buf.delete(*buf.get_bounds()))
788        menu.insert(clear_action, 0)
789        menu.insert(Gtk.SeparatorMenuItem(), 1)
790        menu.show_all()
791
792    def on_treeview_cursor_changed(self, *args):
793        cursor_path, cursor_col = self.treeview.get_cursor()
794        if not cursor_path:
795            self.emit("next-diff-changed", False, False)
796            self.current_path = cursor_path
797            return
798
799        # If invoked directly rather than through a callback, we always check
800        if not args:
801            skip = False
802        else:
803            try:
804                old_cursor = self.model.get_iter(self.current_path)
805            except (ValueError, TypeError):
806                # An invalid path gives ValueError; None gives a TypeError
807                skip = False
808            else:
809                # We can skip recalculation if the new cursor is between
810                # the previous/next bounds, and we weren't on a changed row
811                state = self.model.get_state(old_cursor, 0)
812                if state not in (tree.STATE_NORMAL, tree.STATE_EMPTY):
813                    skip = False
814                else:
815                    if self.prev_path is None and self.next_path is None:
816                        skip = True
817                    elif self.prev_path is None:
818                        skip = cursor_path < self.next_path
819                    elif self.next_path is None:
820                        skip = self.prev_path < cursor_path
821                    else:
822                        skip = self.prev_path < cursor_path < self.next_path
823
824        if not skip:
825            prev, next = self.model._find_next_prev_diff(cursor_path)
826            self.prev_path, self.next_path = prev, next
827            have_next_diffs = (prev is not None, next is not None)
828            self.emit("next-diff-changed", *have_next_diffs)
829        self.current_path = cursor_path
830
831    def next_diff(self, direction):
832        if direction == Gdk.ScrollDirection.UP:
833            path = self.prev_path
834        else:
835            path = self.next_path
836        if path:
837            self.treeview.expand_to_path(path)
838            self.treeview.set_cursor(path)
839
840    def on_refresh_activate(self, *extra):
841        self.on_fileentry_file_set(self.fileentry)
842
843    def on_find_activate(self, *extra):
844        self.treeview.emit("start-interactive-search")
845
846    def auto_compare(self):
847        modified_states = (tree.STATE_MODIFIED, tree.STATE_CONFLICT)
848        for it in self.model.state_rows(modified_states):
849            row_paths = self.model.value_paths(it)
850            paths = [p for p in row_paths if os.path.exists(p)]
851            self.run_diff(paths[0])
852