app: stop setting QT_QPA_PLATFORM=wayland
[git-cola.git] / cola / app.py
blob8860b32aa901583291761fb4bae99f85fdb8f4fb
1 """Provides the main() routine and ColaApplication"""
2 # pylint: disable=unused-import
3 from __future__ import absolute_import, division, print_function, unicode_literals
4 from functools import partial
5 import argparse
6 import os
7 import signal
8 import sys
9 import time
11 __copyright__ = """
12 Copyright (C) 2007-2022 David Aguilar and contributors
13 """
15 try:
16 from qtpy import QtCore
17 except ImportError as error:
18 sys.stderr.write(
19 """
20 Your Python environment does not have qtpy and PyQt (or PySide).
21 The following error was encountered when importing "qtpy":
23 ImportError: {err}
25 Install qtpy and PyQt (or PySide) into your Python environment.
26 On a Debian/Ubuntu system you can install these modules using apt:
28 sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine python3-qtpy
30 """.format(err=error)
32 sys.exit(1)
34 from qtpy import QtWidgets
35 from qtpy.QtCore import Qt
37 try:
38 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
39 # imported before QApplication is constructed.
40 from qtpy import QtWebEngineWidgets # noqa
41 except ImportError:
42 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
43 pass
45 # Import cola modules
46 from .i18n import N_
47 from .interaction import Interaction
48 from .models import main
49 from .models import selection
50 from .widgets import cfgactions
51 from .widgets import standard
52 from .widgets import startup
53 from .settings import Session
54 from .settings import Settings
55 from . import cmds
56 from . import core
57 from . import compat
58 from . import fsmonitor
59 from . import git
60 from . import gitcfg
61 from . import guicmds
62 from . import hidpi
63 from . import icons
64 from . import i18n
65 from . import qtcompat
66 from . import qtutils
67 from . import resources
68 from . import themes
69 from . import utils
70 from . import version
73 def setup_environment():
74 """Set environment variables to control git's behavior"""
75 # Allow Ctrl-C to exit
76 signal.signal(signal.SIGINT, signal.SIG_DFL)
78 # Session management wants an absolute path when restarting
79 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
81 # Spoof an X11 display for SSH
82 os.environ.setdefault('DISPLAY', ':0')
84 if not core.getenv('SHELL', ''):
85 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
86 if os.path.exists(shell):
87 compat.setenv('SHELL', shell)
88 break
90 # Setup the path so that git finds us when we run 'git cola'
91 path_entries = core.getenv('PATH', '').split(os.pathsep)
92 bindir = core.decode(os.path.dirname(sys_argv0))
93 path_entries.append(bindir)
94 path = os.pathsep.join(path_entries)
95 compat.setenv('PATH', path)
97 # We don't ever want a pager
98 compat.setenv('GIT_PAGER', '')
100 # Setup *SSH_ASKPASS
101 git_askpass = core.getenv('GIT_ASKPASS')
102 ssh_askpass = core.getenv('SSH_ASKPASS')
103 if git_askpass:
104 askpass = git_askpass
105 elif ssh_askpass:
106 askpass = ssh_askpass
107 elif sys.platform == 'darwin':
108 askpass = resources.package_command('ssh-askpass-darwin')
109 else:
110 askpass = resources.package_command('ssh-askpass')
112 compat.setenv('GIT_ASKPASS', askpass)
113 compat.setenv('SSH_ASKPASS', askpass)
115 # --- >8 --- >8 ---
116 # Git v1.7.10 Release Notes
117 # =========================
119 # Compatibility Notes
120 # -------------------
122 # * From this release on, the "git merge" command in an interactive
123 # session will start an editor when it automatically resolves the
124 # merge for the user to explain the resulting commit, just like the
125 # "git commit" command does when it wasn't given a commit message.
127 # If you have a script that runs "git merge" and keeps its standard
128 # input and output attached to the user's terminal, and if you do not
129 # want the user to explain the resulting merge commits, you can
130 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
131 # this:
133 # #!/bin/sh
134 # GIT_MERGE_AUTOEDIT=no
135 # export GIT_MERGE_AUTOEDIT
137 # to disable this behavior (if you want your users to explain their
138 # merge commits, you do not have to do anything). Alternatively, you
139 # can give the "--no-edit" option to individual invocations of the
140 # "git merge" command if you know everybody who uses your script has
141 # Git v1.7.8 or newer.
142 # --- >8 --- >8 ---
143 # Longer-term: Use `git merge --no-commit` so that we always
144 # have a chance to explain our merges.
145 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
148 def get_icon_themes(context):
149 """Return the default icon theme names"""
150 result = []
152 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
153 if icon_themes_env:
154 result.extend([x for x in icon_themes_env.split(':') if x])
156 icon_themes_cfg = list(reversed(context.cfg.get_all('cola.icontheme')))
157 if icon_themes_cfg:
158 result.extend(icon_themes_cfg)
160 if not result:
161 result.append('light')
163 return result
166 # style note: we use camelCase here since we're masquerading a Qt class
167 class ColaApplication(object):
168 """The main cola application
170 ColaApplication handles i18n of user-visible data
173 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
174 cfgactions.install()
175 i18n.install(locale)
176 qtcompat.install()
177 guicmds.install()
178 standard.install()
179 icons.install(icon_themes or get_icon_themes(context))
181 self.context = context
182 self.theme = None
183 self._install_hidpi_config()
184 self._app = ColaQApplication(context, list(argv))
185 self._app.setWindowIcon(icons.cola())
186 self._app.setDesktopFileName('git-cola')
187 self._install_style(gui_theme)
189 def _install_style(self, theme_str):
190 """Generate and apply a stylesheet to the app"""
191 if theme_str is None:
192 theme_str = self.context.cfg.get('cola.theme', default='default')
193 theme = themes.find_theme(theme_str)
194 self.theme = theme
195 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
196 if theme_str != 'default':
197 self._app.setPalette(theme.build_palette(self._app.palette()))
199 def _install_hidpi_config(self):
200 """Sets QT HIDPI scalling (requires Qt 5.6)"""
201 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
202 hidpi.apply_choice(value)
204 def activeWindow(self):
205 """QApplication::activeWindow() pass-through"""
206 return self._app.activeWindow()
208 def desktop(self):
209 """QApplication::desktop() pass-through"""
210 return self._app.desktop()
212 def palette(self):
213 """QApplication::palette() pass-through"""
214 return self._app.palette()
216 def start(self):
217 """Wrap exec_() and start the application"""
218 # Defer connection so that local cola.inotify is honored
219 context = self.context
220 monitor = context.fsmonitor
221 monitor.files_changed.connect(
222 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
224 monitor.config_changed.connect(
225 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
227 # Start the filesystem monitor thread
228 monitor.start()
229 return self._app.exec_()
231 def stop(self):
232 """Finalize the application"""
233 self.context.fsmonitor.stop()
234 # Workaround QTBUG-52988 by deleting the app manually to prevent a
235 # crash during app shutdown.
236 # https://bugreports.qt.io/browse/QTBUG-52988
237 try:
238 del self._app
239 except (AttributeError, RuntimeError):
240 pass
241 self._app = None
243 def exit(self, status):
244 """QApplication::exit(status) pass-through"""
245 return self._app.exit(status)
248 class ColaQApplication(QtWidgets.QApplication):
249 """QApplication implementation for handling custom events"""
251 def __init__(self, context, argv):
252 super(ColaQApplication, self).__init__(argv)
253 self.context = context
254 # Make icons sharp in HiDPI screen
255 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
256 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
258 def event(self, e):
259 """Respond to focus events for the cola.refreshonfocus feature"""
260 if e.type() == QtCore.QEvent.ApplicationActivate:
261 context = self.context
262 if context:
263 cfg = context.cfg
264 if context.git.is_valid() and cfg.get(
265 'cola.refreshonfocus', default=False
267 cmds.do(cmds.Refresh, context)
268 return super(ColaQApplication, self).event(e)
270 def commitData(self, session_mgr):
271 """Save session data"""
272 if not self.context or not self.context.view:
273 return
274 view = self.context.view
275 if not hasattr(view, 'save_state'):
276 return
277 sid = session_mgr.sessionId()
278 skey = session_mgr.sessionKey()
279 session_id = '%s_%s' % (sid, skey)
280 session = Session(session_id, repo=core.getcwd())
281 session.update()
282 view.save_state(settings=session)
285 def process_args(args):
286 """Process and verify command-line arguments"""
287 if args.version:
288 # Accept 'git cola --version' or 'git cola version'
289 version.print_version()
290 sys.exit(core.EXIT_SUCCESS)
292 # Handle session management
293 restore_session(args)
295 # Bail out if --repo is not a directory
296 repo = core.decode(args.repo)
297 if repo.startswith('file:'):
298 repo = repo[len('file:') :]
299 repo = core.realpath(repo)
300 if not core.isdir(repo):
301 errmsg = (
303 'fatal: "%s" is not a directory. '
304 'Please specify a correct --repo <path>.'
306 % repo
308 core.print_stderr(errmsg)
309 sys.exit(core.EXIT_USAGE)
312 def restore_session(args):
313 """Load a session based on the window-manager provided arguments"""
314 # args.settings is provided when restoring from a session.
315 args.settings = None
316 if args.session is None:
317 return
318 session = Session(args.session)
319 if session.load():
320 args.settings = session
321 args.repo = session.repo
324 def application_init(args, update=False):
325 """Parses the command-line arguments and starts git-cola"""
326 # Ensure that we're working in a valid git repository.
327 # If not, try to find one. When found, chdir there.
328 setup_environment()
329 process_args(args)
331 context = new_context(args)
332 timer = context.timer
333 timer.start('init')
335 new_worktree(context, args.repo, args.prompt)
337 if update:
338 context.model.update_status()
340 timer.stop('init')
341 if args.perf:
342 timer.display('init')
343 return context
346 def new_context(args):
347 """Create top-level ApplicationContext objects"""
348 context = ApplicationContext(args)
349 context.settings = args.settings or Settings.read()
350 context.git = git.create()
351 context.cfg = gitcfg.create(context)
352 context.fsmonitor = fsmonitor.create(context)
353 context.selection = selection.create()
354 context.model = main.create(context)
355 context.app = new_application(context, args)
356 context.timer = Timer()
358 return context
361 def application_run(context, view, start=None, stop=None):
362 """Run the application main loop"""
363 initialize_view(context, view)
364 # Startup callbacks
365 if start:
366 start(context, view)
367 # Start the event loop
368 result = context.app.start()
369 # Finish
370 if stop:
371 stop(context, view)
372 context.app.stop()
374 return result
377 def initialize_view(context, view):
378 """Register the main widget and display it"""
379 context.set_view(view)
380 view.show()
381 if sys.platform == 'darwin':
382 view.raise_()
385 def application_start(context, view):
386 """Show the GUI and start the main event loop"""
387 # Store the view for session management
388 return application_run(context, view, start=default_start, stop=default_stop)
391 def default_start(context, _view):
392 """Scan for the first time"""
393 QtCore.QTimer.singleShot(0, startup_message)
394 QtCore.QTimer.singleShot(0, lambda: async_update(context))
397 def default_stop(_context, _view):
398 """All done, cleanup"""
399 QtCore.QThreadPool.globalInstance().waitForDone()
402 def add_common_arguments(parser):
403 """Add command arguments to the ArgumentParser"""
404 # We also accept 'git cola version'
405 parser.add_argument(
406 '--version', default=False, action='store_true', help='print version number'
409 # Specifies a git repository to open
410 parser.add_argument(
411 '-r',
412 '--repo',
413 metavar='<repo>',
414 default=core.getcwd(),
415 help='open the specified git repository',
418 # Specifies that we should prompt for a repository at startup
419 parser.add_argument(
420 '--prompt', action='store_true', default=False, help='prompt for a repository'
423 # Specify the icon theme
424 parser.add_argument(
425 '--icon-theme',
426 metavar='<theme>',
427 dest='icon_themes',
428 action='append',
429 default=[],
430 help='specify an icon theme (name or directory)',
433 # Resume an X Session Management session
434 parser.add_argument(
435 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
438 # Enable timing information
439 parser.add_argument(
440 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
443 # Specify the GUI theme
444 parser.add_argument(
445 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
449 def new_application(context, args):
450 """Create a new ColaApplication"""
451 return ColaApplication(
452 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
456 def new_worktree(context, repo, prompt):
457 """Find a Git repository, or prompt for one when not found"""
458 model = context.model
459 cfg = context.cfg
460 parent = qtutils.active_window()
461 valid = False
463 if not prompt:
464 valid = model.set_worktree(repo)
465 if not valid:
466 # We are not currently in a git repository so we need to find one.
467 # Before prompting the user for a repository, check if they've
468 # configured a default repository and attempt to use it.
469 default_repo = cfg.get('cola.defaultrepo')
470 if default_repo:
471 valid = model.set_worktree(default_repo)
473 while not valid:
474 # If we've gotten into this loop then that means that neither the
475 # current directory nor the default repository were available.
476 # Prompt the user for a repository.
477 startup_dlg = startup.StartupDialog(context, parent)
478 gitdir = startup_dlg.find_git_repo()
479 if not gitdir:
480 sys.exit(core.EXIT_NOINPUT)
482 if not core.exists(os.path.join(gitdir, '.git')):
483 offer_to_create_repo(context, gitdir)
484 valid = model.set_worktree(gitdir)
485 continue
487 valid = model.set_worktree(gitdir)
488 if not valid:
489 err = model.error
490 standard.critical(
491 N_('Error Opening Repository'),
492 message=N_('Could not open %s.' % gitdir),
493 details=err,
497 def offer_to_create_repo(context, gitdir):
498 """Offer to create a new repo"""
499 title = N_('Repository Not Found')
500 text = N_('%s is not a Git repository.') % gitdir
501 informative_text = N_('Create a new repository at that location?')
502 if standard.confirm(title, text, informative_text, N_('Create')):
503 status, out, err = context.git.init(gitdir)
504 title = N_('Error Creating Repository')
505 if status != 0:
506 Interaction.command_error(title, 'git init', status, out, err)
509 def async_update(context):
510 """Update the model in the background
512 git-cola should startup as quickly as possible.
515 update_status = partial(context.model.update_status, update_index=True)
516 task = qtutils.SimpleTask(update_status)
517 context.runtask.start(task)
520 def startup_message():
521 """Print debug startup messages"""
522 trace = git.GIT_COLA_TRACE
523 if trace in ('2', 'trace'):
524 msg1 = 'info: debug level 2: trace mode enabled'
525 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
526 Interaction.log(msg1)
527 Interaction.log(msg2)
528 elif trace:
529 msg1 = 'info: debug level 1'
530 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
531 Interaction.log(msg1)
532 Interaction.log(msg2)
535 def initialize():
536 """System-level initialization"""
537 # We support ~/.config/git-cola/git-bindir on Windows for configuring
538 # a custom location for finding the "git" executable.
539 git_path = find_git()
540 if git_path:
541 prepend_path(git_path)
543 # The current directory may have been deleted while we are still
544 # in that directory. We rectify this situation by walking up the
545 # directory tree and retrying.
547 # This is needed because because Python throws exceptions in lots of
548 # stdlib functions when in this situation, e.g. os.path.abspath() and
549 # os.path.realpath(), so it's simpler to mitigate the damage by changing
550 # the current directory to one that actually exists.
551 while True:
552 try:
553 return core.getcwd()
554 except OSError:
555 os.chdir('..')
558 class Timer(object):
559 """Simple performance timer"""
561 def __init__(self):
562 self._data = {}
564 def start(self, key):
565 """Start a timer"""
566 now = time.time()
567 self._data[key] = [now, now]
569 def stop(self, key):
570 """Stop a timer and return its elapsed time"""
571 entry = self._data[key]
572 entry[1] = time.time()
573 return self.elapsed(key)
575 def elapsed(self, key):
576 """Return the elapsed time for a timer"""
577 entry = self._data[key]
578 return entry[1] - entry[0]
580 def display(self, key):
581 """Display a timer"""
582 elapsed = self.elapsed(key)
583 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
586 class NullArgs(object):
587 """Stub arguments for interactive API use"""
589 def __init__(self):
590 self.icon_themes = []
591 self.theme = None
592 self.settings = None
595 def null_args():
596 """Create a new instance of application arguments"""
597 return NullArgs()
600 class ApplicationContext(object):
601 """Context for performing operations on Git and related data models"""
603 def __init__(self, args):
604 self.args = args
605 self.app = None # ColaApplication
606 self.git = None # git.Git
607 self.cfg = None # gitcfg.GitConfig
608 self.model = None # main.MainModel
609 self.timer = None # Timer
610 self.runtask = None # qtutils.RunTask
611 self.settings = None # settings.Settings
612 self.selection = None # selection.SelectionModel
613 self.fsmonitor = None # fsmonitor
614 self.view = None # QWidget
615 self.browser_windows = [] # list of browse.Browser
617 def set_view(self, view):
618 """Initialize view-specific members"""
619 self.view = view
620 self.runtask = qtutils.RunTask(parent=view)
623 def find_git():
624 """Return the path of git.exe, or None if we can't find it."""
625 if not utils.is_win32():
626 return None # UNIX systems have git in their $PATH
628 # If the user wants to use a Git/bin/ directory from a non-standard
629 # directory then they can write its location into
630 # ~/.config/git-cola/git-bindir
631 git_bindir = resources.config_home('git-bindir')
632 if core.exists(git_bindir):
633 custom_path = core.read(git_bindir).strip()
634 if custom_path and core.exists(custom_path):
635 return custom_path
637 # Try to find Git's bin/ directory in one of the typical locations
638 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
639 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
640 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
641 for p in [pf64, pf32, pf, 'C:\\']:
642 candidate = os.path.join(p, 'Git\\bin')
643 if os.path.isdir(candidate):
644 return candidate
646 return None
649 def prepend_path(path):
650 """Adds git to the PATH. This is needed on Windows."""
651 path = core.decode(path)
652 path_entries = core.getenv('PATH', '').split(os.pathsep)
653 if path not in path_entries:
654 path_entries.insert(0, path)
655 compat.setenv('PATH', os.pathsep.join(path_entries))