1# Copyright (C) 2002-2006 Stephen Kennedy <stevek@gnome.org> 2# Copyright (C) 2010-2013 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 io 18import logging 19import optparse 20import os 21 22from gi.repository import Gdk 23from gi.repository import Gio 24from gi.repository import GLib 25from gi.repository import Gtk 26 27import meld.conf 28import meld.preferences 29import meld.ui.util 30from meld.conf import _ 31from meld.filediff import FileDiff 32from meld.meldwindow import MeldWindow 33 34log = logging.getLogger(__name__) 35 36# Monkeypatching optparse like this is obviously awful, but this is to 37# handle Unicode translated strings within optparse itself that will 38# otherwise crash badly. This just makes optparse use our ugettext 39# import of _, rather than the non-unicode gettext. 40optparse._ = _ 41 42 43class MeldApp(Gtk.Application): 44 45 def __init__(self): 46 super().__init__( 47 application_id=meld.conf.APPLICATION_ID, 48 flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, 49 ) 50 GLib.set_application_name("Meld") 51 GLib.set_prgname(meld.conf.APPLICATION_ID) 52 Gtk.Window.set_default_icon_name("meld") 53 54 def do_startup(self): 55 Gtk.Application.do_startup(self) 56 57 actions = ( 58 ("preferences", self.preferences_callback), 59 ("help", self.help_callback), 60 ("about", self.about_callback), 61 ("quit", self.quit_callback), 62 ) 63 for (name, callback) in actions: 64 action = Gio.SimpleAction.new(name, None) 65 action.connect('activate', callback) 66 self.add_action(action) 67 68 # Keep clipboard contents after application exit 69 clip = Gtk.Clipboard.get_default(Gdk.Display.get_default()) 70 clip.set_can_store(None) 71 72 # TODO: Should not be necessary but Builder doesn't understand Menus 73 builder = meld.ui.util.get_builder("application.ui") 74 menu = builder.get_object("app-menu") 75 self.set_app_menu(menu) 76 # self.set_menubar() 77 self.new_window() 78 79 def do_activate(self): 80 self.get_active_window().present() 81 82 def do_command_line(self, command_line): 83 tab = self.parse_args(command_line) 84 85 if isinstance(tab, int): 86 return tab 87 elif tab: 88 89 def done(tab, status): 90 self.release() 91 tab.command_line.set_exit_status(status) 92 tab.command_line = None 93 94 self.hold() 95 tab.command_line = command_line 96 tab.connect('close', done) 97 98 window = self.get_active_window().meldwindow 99 if not window.has_pages(): 100 window.append_new_comparison() 101 self.activate() 102 return 0 103 104 def do_window_removed(self, widget): 105 widget.meldwindow = None 106 Gtk.Application.do_window_removed(self, widget) 107 if not len(self.get_windows()): 108 self.quit() 109 110 # We can't override do_local_command_line because it has no introspection 111 # annotations: https://bugzilla.gnome.org/show_bug.cgi?id=687912 112 113 # def do_local_command_line(self, command_line): 114 # return False 115 116 def preferences_callback(self, action, parameter): 117 meld.preferences.PreferencesDialog(self.get_active_window()) 118 119 def help_callback(self, action, parameter): 120 if meld.conf.DATADIR_IS_UNINSTALLED: 121 uri = "http://meldmerge.org/help/" 122 else: 123 uri = "help:meld" 124 Gtk.show_uri( 125 Gdk.Screen.get_default(), uri, Gtk.get_current_event_time()) 126 127 def about_callback(self, action, parameter): 128 about = meld.ui.util.get_widget("application.ui", "aboutdialog") 129 about.set_version(meld.conf.__version__) 130 about.set_transient_for(self.get_active_window()) 131 about.run() 132 about.destroy() 133 134 def quit_callback(self, action, parameter): 135 for window in self.get_windows(): 136 cancelled = window.emit( 137 "delete-event", Gdk.Event.new(Gdk.EventType.DELETE)) 138 if cancelled: 139 return 140 window.destroy() 141 self.quit() 142 143 def new_window(self): 144 window = MeldWindow() 145 self.add_window(window.widget) 146 window.widget.meldwindow = window 147 return window 148 149 def get_meld_window(self): 150 return self.get_active_window().meldwindow 151 152 def open_files( 153 self, gfiles, *, window=None, close_on_error=False, **kwargs): 154 """Open a comparison between files in a Meld window 155 156 :param gfiles: list of Gio.File to be compared 157 :param window: window in which to open comparison tabs; if 158 None, the current window is used 159 :param close_on_error: if true, close window if an error occurs 160 """ 161 window = window or self.get_meld_window() 162 try: 163 return window.open_paths(gfiles, **kwargs) 164 except ValueError: 165 if close_on_error: 166 self.remove_window(window.widget) 167 raise 168 169 def diff_files_callback(self, option, opt_str, value, parser): 170 """Gather --diff arguments and append to a list""" 171 assert value is None 172 diff_files_args = [] 173 while parser.rargs: 174 # Stop if we find a short- or long-form arg, or a '--' 175 # Note that this doesn't handle negative numbers. 176 arg = parser.rargs[0] 177 if arg[:2] == "--" or (arg[:1] == "-" and len(arg) > 1): 178 break 179 else: 180 diff_files_args.append(arg) 181 del parser.rargs[0] 182 183 if len(diff_files_args) not in (1, 2, 3): 184 raise optparse.OptionValueError( 185 _("wrong number of arguments supplied to --diff")) 186 parser.values.diff.append(diff_files_args) 187 188 def parse_args(self, command_line): 189 usages = [ 190 ("", _("Start with an empty window")), 191 ("<%s|%s>" % (_("file"), _("folder")), 192 _("Start a version control comparison")), 193 ("<%s> <%s> [<%s>]" % ((_("file"),) * 3), 194 _("Start a 2- or 3-way file comparison")), 195 ("<%s> <%s> [<%s>]" % ((_("folder"),) * 3), 196 _("Start a 2- or 3-way folder comparison")), 197 ] 198 pad_args_fmt = "%-" + str(max([len(s[0]) for s in usages])) + "s %s" 199 usage_lines = [" %prog " + pad_args_fmt % u for u in usages] 200 usage = "\n" + "\n".join(usage_lines) 201 202 class GLibFriendlyOptionParser(optparse.OptionParser): 203 204 def __init__(self, command_line, *args, **kwargs): 205 self.command_line = command_line 206 self.should_exit = False 207 self.output = io.StringIO() 208 self.exit_status = 0 209 super().__init__(*args, **kwargs) 210 211 def exit(self, status=0, msg=None): 212 self.should_exit = True 213 # FIXME: This is... let's say... an unsupported method. Let's 214 # be circumspect about the likelihood of this working. 215 try: 216 self.command_line.do_print_literal( 217 self.command_line, self.output.getvalue()) 218 except Exception: 219 print(self.output.getvalue()) 220 self.exit_status = status 221 222 def print_usage(self, file=None): 223 if self.usage: 224 print(self.get_usage(), file=self.output) 225 226 def print_version(self, file=None): 227 if self.version: 228 print(self.get_version(), file=self.output) 229 230 def print_help(self, file=None): 231 print(self.format_help(), file=self.output) 232 233 def error(self, msg): 234 self.local_error(msg) 235 raise ValueError() 236 237 def local_error(self, msg): 238 self.print_usage() 239 error_string = _("Error: %s\n") % msg 240 print(error_string, file=self.output) 241 self.exit(2) 242 243 parser = GLibFriendlyOptionParser( 244 command_line=command_line, 245 usage=usage, 246 description=_("Meld is a file and directory comparison tool."), 247 version="%prog " + meld.conf.__version__) 248 parser.add_option( 249 "-L", "--label", action="append", default=[], 250 help=_("Set label to use instead of file name")) 251 parser.add_option( 252 "-n", "--newtab", action="store_true", default=False, 253 help=_("Open a new tab in an already running instance")) 254 parser.add_option( 255 "-a", "--auto-compare", action="store_true", default=False, 256 help=_("Automatically compare all differing files on startup")) 257 parser.add_option( 258 "-u", "--unified", action="store_true", 259 help=_("Ignored for compatibility")) 260 parser.add_option( 261 "-o", "--output", action="store", type="string", 262 dest="outfile", default=None, 263 help=_("Set the target file for saving a merge result")) 264 parser.add_option( 265 "--auto-merge", None, action="store_true", default=False, 266 help=_("Automatically merge files")) 267 parser.add_option( 268 "", "--comparison-file", action="store", type="string", 269 dest="comparison_file", default=None, 270 help=_("Load a saved comparison from a Meld comparison file")) 271 parser.add_option( 272 "", "--diff", action="callback", callback=self.diff_files_callback, 273 dest="diff", default=[], 274 help=_("Create a diff tab for the supplied files or folders")) 275 276 def cleanup(): 277 if not command_line.get_is_remote(): 278 self.quit() 279 parser.command_line = None 280 281 rawargs = command_line.get_arguments()[1:] 282 try: 283 options, args = parser.parse_args(rawargs) 284 except ValueError: 285 # Thrown to avert further parsing when we've hit an error, because 286 # of our weird when-to-exit issues. 287 pass 288 289 if parser.should_exit: 290 cleanup() 291 return parser.exit_status 292 293 if len(args) > 3: 294 parser.local_error(_("too many arguments (wanted 0-3, got %d)") % 295 len(args)) 296 elif options.auto_merge and len(args) < 3: 297 parser.local_error(_("can’t auto-merge less than 3 files")) 298 elif options.auto_merge and any([os.path.isdir(f) for f in args]): 299 parser.local_error(_("can’t auto-merge directories")) 300 301 if parser.should_exit: 302 cleanup() 303 return parser.exit_status 304 305 if options.comparison_file or (len(args) == 1 and 306 args[0].endswith(".meldcmp")): 307 path = options.comparison_file or args[0] 308 comparison_file_path = os.path.expanduser(path) 309 gio_file = Gio.File.new_for_path(comparison_file_path) 310 try: 311 tab = self.get_meld_window().append_recent(gio_file.get_uri()) 312 except (IOError, ValueError): 313 parser.local_error(_("Error reading saved comparison file")) 314 if parser.should_exit: 315 cleanup() 316 return parser.exit_status 317 return tab 318 319 def make_file_from_command_line(arg): 320 f = command_line.create_file_for_arg(arg) 321 if not f.query_exists(cancellable=None): 322 # May be a relative path with ':', misinterpreted as a URI 323 cwd = Gio.File.new_for_path(command_line.get_cwd()) 324 relative = Gio.File.resolve_relative_path(cwd, arg) 325 if relative.query_exists(cancellable=None): 326 return relative 327 # Return the original arg for a better error message 328 329 if f.get_uri() is None: 330 raise ValueError(_("invalid path or URI “%s”") % arg) 331 332 # TODO: support for directories specified by URIs 333 file_type = f.query_file_type(Gio.FileQueryInfoFlags.NONE, None) 334 if not f.is_native() and file_type == Gio.FileType.DIRECTORY: 335 raise ValueError( 336 _("remote folder “{}” not supported").format(arg)) 337 338 return f 339 340 tab = None 341 error = None 342 comparisons = [c for c in [args] + options.diff if c] 343 344 # Every Meld invocation creates at most one window. If there is 345 # no existing application, a window is created in do_startup(). 346 # If there is an existing application, then this is a remote 347 # invocation, in which case we'll create a window if and only 348 # if the new-tab flag is not provided. 349 # 350 # In all cases, all tabs newly created here are attached to the 351 # same window, either implicitly by using the most-recently- 352 # focused window, or explicitly as below. 353 window = None 354 close_on_error = False 355 if command_line.get_is_remote() and not options.newtab: 356 window = self.new_window() 357 close_on_error = True 358 359 for i, paths in enumerate(comparisons): 360 auto_merge = options.auto_merge and i == 0 361 try: 362 files = [make_file_from_command_line(p) for p in paths] 363 tab = self.open_files( 364 files, 365 window=window, 366 close_on_error=close_on_error, 367 auto_compare=options.auto_compare, 368 auto_merge=auto_merge, 369 focus=i == 0, 370 ) 371 except ValueError as err: 372 error = err 373 log.debug("Couldn't open comparison: %s", error, exc_info=True) 374 else: 375 if i > 0: 376 continue 377 378 if options.label: 379 tab.set_labels(options.label) 380 381 if options.outfile and isinstance(tab, FileDiff): 382 outfile = make_file_from_command_line(options.outfile) 383 tab.set_merge_output_file(outfile) 384 385 if error: 386 if not tab: 387 parser.local_error(error) 388 else: 389 print(error) 390 # Delete the error here; otherwise we keep the local app 391 # alive in a reference cycle, and the command line hangs. 392 del error 393 394 if parser.should_exit: 395 cleanup() 396 return parser.exit_status 397 398 parser.command_line = None 399 return tab if len(comparisons) == 1 else None 400 401 402app = MeldApp() 403