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