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