cmds: simplify ToggleDiffType
[git-cola.git] / cola / app.py
blob94d578fe3ac341f06228171ed271b9627d046f83
1 """Provides the main() routine and ColaApplication"""
2 # pylint: disable=unused-import
3 from __future__ import division, absolute_import, 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-2017 David Aguilar and contributors
13 """
15 try:
16 from qtpy import QtCore
17 except ImportError:
18 sys.stderr.write(
19 """
20 You do not seem to have PyQt5, PySide, or PyQt4 installed.
21 Please install it before using git-cola, e.g. on a Debian/Ubutnu system:
23 sudo apt-get install python-pyqt5 python-pyqt5.qtwebkit
25 """
27 sys.exit(1)
29 from qtpy import QtWidgets
30 from qtpy.QtCore import Qt
32 try:
33 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
34 # imported before QApplication is constructed.
35 from qtpy import QtWebEngineWidgets # noqa
36 except ImportError:
37 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
38 pass
40 # Import cola modules
41 from .i18n import N_
42 from .interaction import Interaction
43 from .models import main
44 from .models import selection
45 from .widgets import cfgactions
46 from .widgets import standard
47 from .widgets import startup
48 from .settings import Session
49 from . import cmds
50 from . import core
51 from . import compat
52 from . import fsmonitor
53 from . import git
54 from . import gitcfg
55 from . import guicmds
56 from . import hidpi
57 from . import icons
58 from . import i18n
59 from . import qtcompat
60 from . import qtutils
61 from . import resources
62 from . import themes
63 from . import utils
64 from . import version
67 def setup_environment():
68 """Set environment variables to control git's behavior"""
69 # Allow Ctrl-C to exit
70 signal.signal(signal.SIGINT, signal.SIG_DFL)
72 # Session management wants an absolute path when restarting
73 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
75 # Spoof an X11 display for SSH
76 os.environ.setdefault('DISPLAY', ':0')
78 if not core.getenv('SHELL', ''):
79 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
80 if os.path.exists(shell):
81 compat.setenv('SHELL', shell)
82 break
84 # Setup the path so that git finds us when we run 'git cola'
85 path_entries = core.getenv('PATH', '').split(os.pathsep)
86 bindir = core.decode(os.path.dirname(sys_argv0))
87 path_entries.append(bindir)
88 path = os.pathsep.join(path_entries)
89 compat.setenv('PATH', path)
91 # We don't ever want a pager
92 compat.setenv('GIT_PAGER', '')
94 # Setup *SSH_ASKPASS
95 git_askpass = core.getenv('GIT_ASKPASS')
96 ssh_askpass = core.getenv('SSH_ASKPASS')
97 if git_askpass:
98 askpass = git_askpass
99 elif ssh_askpass:
100 askpass = ssh_askpass
101 elif sys.platform == 'darwin':
102 askpass = resources.share('bin', 'ssh-askpass-darwin')
103 else:
104 askpass = resources.share('bin', 'ssh-askpass')
106 compat.setenv('GIT_ASKPASS', askpass)
107 compat.setenv('SSH_ASKPASS', askpass)
109 # --- >8 --- >8 ---
110 # Git v1.7.10 Release Notes
111 # =========================
113 # Compatibility Notes
114 # -------------------
116 # * From this release on, the "git merge" command in an interactive
117 # session will start an editor when it automatically resolves the
118 # merge for the user to explain the resulting commit, just like the
119 # "git commit" command does when it wasn't given a commit message.
121 # If you have a script that runs "git merge" and keeps its standard
122 # input and output attached to the user's terminal, and if you do not
123 # want the user to explain the resulting merge commits, you can
124 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
125 # this:
127 # #!/bin/sh
128 # GIT_MERGE_AUTOEDIT=no
129 # export GIT_MERGE_AUTOEDIT
131 # to disable this behavior (if you want your users to explain their
132 # merge commits, you do not have to do anything). Alternatively, you
133 # can give the "--no-edit" option to individual invocations of the
134 # "git merge" command if you know everybody who uses your script has
135 # Git v1.7.8 or newer.
136 # --- >8 --- >8 ---
137 # Longer-term: Use `git merge --no-commit` so that we always
138 # have a chance to explain our merges.
139 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
141 # Gnome3 on Debian has XDG_SESSION_TYPE=wayland and
142 # XDG_CURRENT_DESKTOP=GNOME, which Qt warns about at startup:
144 # Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
145 # Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
147 # This annoying, so we silence the warning.
148 # We'll need to keep this hack here until a future version of Qt provides
149 # Qt Wayland widgets that are usable in gnome-shell.
150 # Cf. https://bugreports.qt.io/browse/QTBUG-68619
151 if (
152 core.getenv('XDG_CURRENT_DESKTOP', '') == 'GNOME'
153 and core.getenv('XDG_SESSION_TYPE', '') == 'wayland'
155 compat.unsetenv('XDG_SESSION_TYPE')
158 def get_icon_themes(context):
159 """Return the default icon theme names"""
160 result = []
162 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
163 if icon_themes_env:
164 result.extend([x for x in icon_themes_env.split(':') if x])
166 icon_themes_cfg = context.cfg.get_all('cola.icontheme')
167 if icon_themes_cfg:
168 result.extend(icon_themes_cfg)
170 if not result:
171 result.append('light')
173 return result
176 # style note: we use camelCase here since we're masquerading a Qt class
177 class ColaApplication(object):
178 """The main cola application
180 ColaApplication handles i18n of user-visible data
183 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
184 cfgactions.install()
185 i18n.install(locale)
186 qtcompat.install()
187 guicmds.install()
188 standard.install()
189 icons.install(icon_themes or get_icon_themes(context))
191 self.context = context
192 self._install_hidpi_config()
193 self._app = ColaQApplication(context, list(argv))
194 self._app.setWindowIcon(icons.cola())
195 self._install_style(gui_theme)
197 def _install_style(self, theme_str):
198 """Generate and apply a stylesheet to the app"""
199 if theme_str is None:
200 theme_str = self.context.cfg.get('cola.theme', default='default')
201 theme = themes.find_theme(theme_str)
202 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
203 if theme_str != 'default':
204 self._app.setPalette(theme.build_palette(self._app.palette()))
206 def _install_hidpi_config(self):
207 """Sets QT HIDPI scalling (requires Qt 5.6)"""
208 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
209 hidpi.apply_choice(value)
211 def activeWindow(self):
212 """QApplication::activeWindow() pass-through"""
213 return self._app.activeWindow()
215 def desktop(self):
216 """QApplication::desktop() pass-through"""
217 return self._app.desktop()
219 def start(self):
220 """Wrap exec_() and start the application"""
221 # Defer connection so that local cola.inotify is honored
222 context = self.context
223 monitor = context.fsmonitor
224 monitor.files_changed.connect(
225 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
227 monitor.config_changed.connect(
228 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
230 # Start the filesystem monitor thread
231 monitor.start()
232 return self._app.exec_()
234 def stop(self):
235 """Finalize the application"""
236 self.context.fsmonitor.stop()
237 # Workaround QTBUG-52988 by deleting the app manually to prevent a
238 # crash during app shutdown.
239 # https://bugreports.qt.io/browse/QTBUG-52988
240 try:
241 del self._app
242 except (AttributeError, RuntimeError):
243 pass
244 self._app = None
246 def exit(self, status):
247 """QApplication::exit(status) pass-through"""
248 return self._app.exit(status)
251 class ColaQApplication(QtWidgets.QApplication):
252 """QApplication implementation for handling custom events"""
254 def __init__(self, context, argv):
255 super(ColaQApplication, self).__init__(argv)
256 self.context = context
257 # Make icons sharp in HiDPI screen
258 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
259 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
261 def event(self, e):
262 """Respond to focus events for the cola.refreshonfocus feature"""
263 if e.type() == QtCore.QEvent.ApplicationActivate:
264 context = self.context
265 if context:
266 cfg = context.cfg
267 if context.git.is_valid() and cfg.get(
268 'cola.refreshonfocus', default=False
270 cmds.do(cmds.Refresh, context)
271 return super(ColaQApplication, self).event(e)
273 def commitData(self, session_mgr):
274 """Save session data"""
275 if not self.context or not self.context.view:
276 return
277 view = self.context.view
278 if not hasattr(view, 'save_state'):
279 return
280 sid = session_mgr.sessionId()
281 skey = session_mgr.sessionKey()
282 session_id = '%s_%s' % (sid, skey)
283 session = Session(session_id, repo=core.getcwd())
284 view.save_state(settings=session)
287 def process_args(args):
288 """Process and verify command-line arguments"""
289 if args.version:
290 # Accept 'git cola --version' or 'git cola version'
291 version.print_version()
292 sys.exit(core.EXIT_SUCCESS)
294 # Handle session management
295 restore_session(args)
297 # Bail out if --repo is not a directory
298 repo = core.decode(args.repo)
299 if repo.startswith('file:'):
300 repo = repo[len('file:') :]
301 repo = core.realpath(repo)
302 if not core.isdir(repo):
303 errmsg = (
305 'fatal: "%s" is not a directory. '
306 'Please specify a correct --repo <path>.'
308 % repo
310 core.print_stderr(errmsg)
311 sys.exit(core.EXIT_USAGE)
314 def restore_session(args):
315 """Load a session based on the window-manager provided arguments"""
316 # args.settings is provided when restoring from a session.
317 args.settings = None
318 if args.session is None:
319 return
320 session = Session(args.session)
321 if session.load():
322 args.settings = session
323 args.repo = session.repo
326 def application_init(args, update=False):
327 """Parses the command-line arguments and starts git-cola
329 # Ensure that we're working in a valid git repository.
330 # If not, try to find one. When found, chdir there.
331 setup_environment()
332 process_args(args)
334 context = new_context(args)
335 timer = context.timer
336 timer.start('init')
338 new_worktree(context, args.repo, args.prompt, args.settings)
340 if update:
341 context.model.update_status()
343 timer.stop('init')
344 if args.perf:
345 timer.display('init')
346 return context
349 def new_context(args):
350 """Create top-level ApplicationContext objects"""
351 context = ApplicationContext(args)
352 context.git = git.create()
353 context.cfg = gitcfg.create(context)
354 context.fsmonitor = fsmonitor.create(context)
355 context.selection = selection.create()
356 context.model = main.create(context)
357 context.app = new_application(context, args)
358 context.timer = Timer()
360 return context
363 def application_run(context, view, start=None, stop=None):
364 """Run the application main loop"""
365 initialize_view(context, view)
366 # Startup callbacks
367 if start:
368 start(context, view)
369 # Start the event loop
370 result = context.app.start()
371 # Finish
372 if stop:
373 stop(context, view)
374 context.app.stop()
376 return result
379 def initialize_view(context, view):
380 """Register the main widget and display it"""
381 context.set_view(view)
382 view.show()
383 if sys.platform == 'darwin':
384 view.raise_()
387 def application_start(context, view):
388 """Show the GUI and start the main event loop"""
389 # Store the view for session management
390 return application_run(context, view, start=default_start, stop=default_stop)
393 def default_start(context, _view):
394 """Scan for the first time"""
395 QtCore.QTimer.singleShot(0, startup_message)
396 QtCore.QTimer.singleShot(0, lambda: async_update(context))
399 def default_stop(_context, _view):
400 """All done, cleanup"""
401 QtCore.QThreadPool.globalInstance().waitForDone()
404 def add_common_arguments(parser):
405 """Add command arguments to the ArgumentParser"""
406 # We also accept 'git cola version'
407 parser.add_argument(
408 '--version', default=False, action='store_true', help='print version number'
411 # Specifies a git repository to open
412 parser.add_argument(
413 '-r',
414 '--repo',
415 metavar='<repo>',
416 default=core.getcwd(),
417 help='open the specified git repository',
420 # Specifies that we should prompt for a repository at startup
421 parser.add_argument(
422 '--prompt', action='store_true', default=False, help='prompt for a repository'
425 # Specify the icon theme
426 parser.add_argument(
427 '--icon-theme',
428 metavar='<theme>',
429 dest='icon_themes',
430 action='append',
431 default=[],
432 help='specify an icon theme (name or directory)',
435 # Resume an X Session Management session
436 parser.add_argument(
437 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
440 # Enable timing information
441 parser.add_argument(
442 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
445 # Specify the GUI theme
446 parser.add_argument(
447 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
451 def new_application(context, args):
452 """Create a new ColaApplication"""
453 return ColaApplication(
454 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
458 def new_worktree(context, repo, prompt, settings):
459 """Find a Git repository, or prompt for one when not found"""
460 model = context.model
461 cfg = context.cfg
462 parent = qtutils.active_window()
463 valid = False
465 if not prompt:
466 valid = model.set_worktree(repo)
467 if not valid:
468 # We are not currently in a git repository so we need to find one.
469 # Before prompting the user for a repository, check if they've
470 # configured a default repository and attempt to use it.
471 default_repo = cfg.get('cola.defaultrepo')
472 if default_repo:
473 valid = model.set_worktree(default_repo)
475 while not valid:
476 # If we've gotten into this loop then that means that neither the
477 # current directory nor the default repository were available.
478 # Prompt the user for a repository.
479 startup_dlg = startup.StartupDialog(context, parent, settings=settings)
480 gitdir = startup_dlg.find_git_repo()
481 if not gitdir:
482 sys.exit(core.EXIT_NOINPUT)
483 valid = model.set_worktree(gitdir)
486 def async_update(context):
487 """Update the model in the background
489 git-cola should startup as quickly as possible.
492 update_status = partial(context.model.update_status, update_index=True)
493 task = qtutils.SimpleTask(context.view, update_status)
494 context.runtask.start(task)
497 def startup_message():
498 """Print debug startup messages"""
499 trace = git.GIT_COLA_TRACE
500 if trace in ('2', 'trace'):
501 msg1 = 'info: debug level 2: trace mode enabled'
502 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
503 Interaction.log(msg1)
504 Interaction.log(msg2)
505 elif trace:
506 msg1 = 'info: debug level 1'
507 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
508 Interaction.log(msg1)
509 Interaction.log(msg2)
512 def initialize():
513 """System-level initialization"""
514 # The current directory may have been deleted while we are still
515 # in that directory. We rectify this situation by walking up the
516 # directory tree and retrying.
518 # This is needed because because Python throws exceptions in lots of
519 # stdlib functions when in this situation, e.g. os.path.abspath() and
520 # os.path.realpath(), so it's simpler to mitigate the damage by changing
521 # the current directory to one that actually exists.
522 while True:
523 try:
524 return core.getcwd()
525 except OSError:
526 os.chdir('..')
529 class Timer(object):
530 """Simple performance timer"""
532 def __init__(self):
533 self._data = {}
535 def start(self, key):
536 """Start a timer"""
537 now = time.time()
538 self._data[key] = [now, now]
540 def stop(self, key):
541 """Stop a timer and return its elapsed time"""
542 entry = self._data[key]
543 entry[1] = time.time()
544 return self.elapsed(key)
546 def elapsed(self, key):
547 """Return the elapsed time for a timer"""
548 entry = self._data[key]
549 return entry[1] - entry[0]
551 def display(self, key):
552 """Display a timer"""
553 elapsed = self.elapsed(key)
554 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
557 class ApplicationContext(object):
558 """Context for performing operations on Git and related data models"""
560 def __init__(self, args):
561 self.args = args
562 self.app = None # ColaApplication
563 self.git = None # git.Git
564 self.cfg = None # gitcfg.GitConfig
565 self.model = None # main.MainModel
566 self.timer = None # Timer
567 self.runtask = None # qtutils.RunTask
568 self.selection = None # selection.SelectionModel
569 self.fsmonitor = None # fsmonitor
570 self.view = None # QWidget
572 def set_view(self, view):
573 """Initialize view-specific members"""
574 self.view = view
575 self.runtask = qtutils.RunTask(parent=view)
578 def winmain(main_fn, *argv):
579 """Find Git and launch main(argv)"""
580 git_path = find_git()
581 if git_path:
582 prepend_path(git_path)
583 return main_fn(*argv)
586 def find_git():
587 """Return the path of git.exe, or None if we can't find it."""
588 if not utils.is_win32():
589 return None # UNIX systems have git in their $PATH
591 # If the user wants to use a Git/bin/ directory from a non-standard
592 # directory then they can write its location into
593 # ~/.config/git-cola/git-bindir
594 git_bindir = os.path.expanduser(
595 os.path.join('~', '.config', 'git-cola', 'git-bindir')
597 if core.exists(git_bindir):
598 custom_path = core.read(git_bindir).strip()
599 if custom_path and core.exists(custom_path):
600 return custom_path
602 # Try to find Git's bin/ directory in one of the typical locations
603 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
604 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
605 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
606 for p in [pf64, pf32, pf, 'C:\\']:
607 candidate = os.path.join(p, 'Git\\bin')
608 if os.path.isdir(candidate):
609 return candidate
611 return None
614 def prepend_path(path):
615 """Adds git to the PATH. This is needed on Windows."""
616 path = core.decode(path)
617 path_entries = core.getenv('PATH', '').split(os.pathsep)
618 if path not in path_entries:
619 path_entries.insert(0, path)
620 compat.setenv('PATH', os.pathsep.join(path_entries))