models: add notification when submodules are updated
[git-cola.git] / cola / app.py
blobe392c9702c35c4c317b48cab29e77439efa80596
1 """Provides the main() routine and ColaApplication"""
2 from __future__ import division, absolute_import, unicode_literals
3 from functools import partial
4 import argparse
5 import os
6 import signal
7 import sys
8 import time
10 __copyright__ = """
11 Copyright (C) 2007-2017 David Aguilar and contributors
12 """
14 try:
15 from qtpy import QtCore
16 except ImportError:
17 sys.stderr.write("""
18 You do not seem to have PyQt5, PySide, or PyQt4 installed.
19 Please install it before using git-cola, e.g. on a Debian/Ubutnu system:
21 sudo apt-get install python-pyqt5 python-pyqt5.qtwebkit
23 """)
24 sys.exit(1)
26 from qtpy import QtGui
27 from qtpy import QtWidgets
28 from qtpy.QtCore import Qt
30 # Import cola modules
31 from .i18n import N_
32 from .interaction import Interaction
33 from .models import main
34 from .models import selection
35 from .widgets import cfgactions
36 from .widgets import defs
37 from .widgets import standard
38 from .widgets import startup
39 from .settings import Session
40 from . import cmds
41 from . import core
42 from . import compat
43 from . import fsmonitor
44 from . import git
45 from . import gitcfg
46 from . import guicmds
47 from . import icons
48 from . import i18n
49 from . import qtcompat
50 from . import qtutils
51 from . import resources
52 from . import utils
53 from . import version
56 def setup_environment():
57 """Set environment variables to control git's behavior"""
58 # Allow Ctrl-C to exit
59 signal.signal(signal.SIGINT, signal.SIG_DFL)
61 # Session management wants an absolute path when restarting
62 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
64 # Spoof an X11 display for SSH
65 os.environ.setdefault('DISPLAY', ':0')
67 if not core.getenv('SHELL', ''):
68 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
69 if os.path.exists(shell):
70 compat.setenv('SHELL', shell)
71 break
73 # Setup the path so that git finds us when we run 'git cola'
74 path_entries = core.getenv('PATH', '').split(os.pathsep)
75 bindir = core.decode(os.path.dirname(sys_argv0))
76 path_entries.append(bindir)
77 path = os.pathsep.join(path_entries)
78 compat.setenv('PATH', path)
80 # We don't ever want a pager
81 compat.setenv('GIT_PAGER', '')
83 # Setup *SSH_ASKPASS
84 git_askpass = core.getenv('GIT_ASKPASS')
85 ssh_askpass = core.getenv('SSH_ASKPASS')
86 if git_askpass:
87 askpass = git_askpass
88 elif ssh_askpass:
89 askpass = ssh_askpass
90 elif sys.platform == 'darwin':
91 askpass = resources.share('bin', 'ssh-askpass-darwin')
92 else:
93 askpass = resources.share('bin', 'ssh-askpass')
95 compat.setenv('GIT_ASKPASS', askpass)
96 compat.setenv('SSH_ASKPASS', askpass)
98 # --- >8 --- >8 ---
99 # Git v1.7.10 Release Notes
100 # =========================
102 # Compatibility Notes
103 # -------------------
105 # * From this release on, the "git merge" command in an interactive
106 # session will start an editor when it automatically resolves the
107 # merge for the user to explain the resulting commit, just like the
108 # "git commit" command does when it wasn't given a commit message.
110 # If you have a script that runs "git merge" and keeps its standard
111 # input and output attached to the user's terminal, and if you do not
112 # want the user to explain the resulting merge commits, you can
113 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
114 # this:
116 # #!/bin/sh
117 # GIT_MERGE_AUTOEDIT=no
118 # export GIT_MERGE_AUTOEDIT
120 # to disable this behavior (if you want your users to explain their
121 # merge commits, you do not have to do anything). Alternatively, you
122 # can give the "--no-edit" option to individual invocations of the
123 # "git merge" command if you know everybody who uses your script has
124 # Git v1.7.8 or newer.
125 # --- >8 --- >8 ---
126 # Longer-term: Use `git merge --no-commit` so that we always
127 # have a chance to explain our merges.
128 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
130 # Gnome3 on Debian has XDG_SESSION_TYPE=wayland and
131 # XDG_CURRENT_DESKTOP=GNOME, which Qt warns about at startup:
133 # Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
134 # Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
136 # This annoying, so we silence the warning.
137 # We'll need to keep this hack here until a future version of Qt provides
138 # Qt Wayland widgets that are usable in gnome-shell.
139 # Cf. https://bugreports.qt.io/browse/QTBUG-68619
140 if (core.getenv('XDG_CURRENT_DESKTOP', '') == 'GNOME'
141 and core.getenv('XDG_SESSION_TYPE', '') == 'wayland'):
142 compat.unsetenv('XDG_SESSION_TYPE')
145 def get_icon_themes(context):
146 """Return the default icon theme names"""
147 themes = []
149 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
150 if icon_themes_env:
151 themes.extend([x for x in icon_themes_env.split(':') if x])
153 icon_themes_cfg = context.cfg.get_all('cola.icontheme')
154 if icon_themes_cfg:
155 themes.extend(icon_themes_cfg)
157 if not themes:
158 themes.append('light')
160 return themes
163 # style note: we use camelCase here since we're masquerading a Qt class
164 class ColaApplication(object):
165 """The main cola application
167 ColaApplication handles i18n of user-visible data
170 def __init__(self, context, argv, locale=None, icon_themes=None):
171 cfgactions.install()
172 i18n.install(locale)
173 qtcompat.install()
174 guicmds.install()
175 standard.install()
176 icons.install(icon_themes or get_icon_themes(context))
178 self.context = context
179 self._app = ColaQApplication(context, list(argv))
180 self._app.setWindowIcon(icons.cola())
181 self._install_style()
183 def _install_style(self):
184 """Generate and apply a stylesheet to the app"""
185 palette = self._app.palette()
186 window = palette.color(QtGui.QPalette.Window)
187 highlight = palette.color(QtGui.QPalette.Highlight)
188 shadow = palette.color(QtGui.QPalette.Shadow)
189 base = palette.color(QtGui.QPalette.Base)
191 window_rgb = qtutils.rgb_css(window)
192 highlight_rgb = qtutils.rgb_css(highlight)
193 shadow_rgb = qtutils.rgb_css(shadow)
194 base_rgb = qtutils.rgb_css(base)
196 self._app.setStyleSheet("""
197 QCheckBox::indicator {
198 width: %(checkbox_size)spx;
199 height: %(checkbox_size)spx;
201 QCheckBox::indicator::unchecked {
202 border: %(checkbox_border)spx solid %(shadow_rgb)s;
203 background: %(base_rgb)s;
205 QCheckBox::indicator::checked {
206 image: url(%(checkbox_icon)s);
207 border: %(checkbox_border)spx solid %(shadow_rgb)s;
208 background: %(base_rgb)s;
211 QRadioButton::indicator {
212 width: %(radio_size)spx;
213 height: %(radio_size)spx;
215 QRadioButton::indicator::unchecked {
216 border: %(radio_border)spx solid %(shadow_rgb)s;
217 border-radius: %(radio_radius)spx;
218 background: %(base_rgb)s;
220 QRadioButton::indicator::checked {
221 image: url(%(radio_icon)s);
222 border: %(radio_border)spx solid %(shadow_rgb)s;
223 border-radius: %(radio_radius)spx;
224 background: %(base_rgb)s;
227 QSplitter::handle:hover {
228 background: %(highlight_rgb)s;
231 QMainWindow::separator {
232 background: %(window_rgb)s;
233 width: %(separator)spx;
234 height: %(separator)spx;
236 QMainWindow::separator:hover {
237 background: %(highlight_rgb)s;
240 """ % dict(separator=defs.separator,
241 window_rgb=window_rgb,
242 highlight_rgb=highlight_rgb,
243 shadow_rgb=shadow_rgb,
244 base_rgb=base_rgb,
245 checkbox_border=defs.border,
246 checkbox_icon=icons.check_name(),
247 checkbox_size=defs.checkbox,
248 radio_border=defs.radio_border,
249 radio_icon=icons.dot_name(),
250 radio_radius=defs.checkbox//2,
251 radio_size=defs.checkbox))
253 def activeWindow(self):
254 """QApplication::activeWindow() pass-through"""
255 return self._app.activeWindow()
257 def desktop(self):
258 """QApplication::desktop() pass-through"""
259 return self._app.desktop()
261 def start(self):
262 """Wrap exec_() and start the application"""
263 # Defer connection so that local cola.inotify is honored
264 context = self.context
265 monitor = context.fsmonitor
266 monitor.files_changed.connect(
267 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection)
268 monitor.config_changed.connect(
269 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection)
270 # Start the filesystem monitor thread
271 monitor.start()
272 return self._app.exec_()
274 def stop(self):
275 """Finalize the application"""
276 self.context.fsmonitor.stop()
277 # Workaround QTBUG-52988 by deleting the app manually to prevent a
278 # crash during app shutdown.
279 # https://bugreports.qt.io/browse/QTBUG-52988
280 try:
281 del self._app
282 except (AttributeError, RuntimeError):
283 pass
284 self._app = None
286 def exit(self, status):
287 """QApplication::exit(status) pass-through"""
288 return self._app.exit(status)
291 class ColaQApplication(QtWidgets.QApplication):
292 """QApplication implementation for handling custom events"""
294 def __init__(self, context, argv):
295 super(ColaQApplication, self).__init__(argv)
296 self.context = context
298 def event(self, e):
299 """Respond to focus events for the cola.refreshonfocus feature"""
300 if e.type() == QtCore.QEvent.ApplicationActivate:
301 context = self.context
302 if context:
303 cfg = context.cfg
304 if (context.git.is_valid()
305 and cfg.get('cola.refreshonfocus', default=False)):
306 cmds.do(cmds.Refresh, context)
307 return super(ColaQApplication, self).event(e)
309 def commitData(self, session_mgr):
310 """Save session data"""
311 if not self.context or not self.context.view:
312 return
313 view = self.context.view
314 if not hasattr(view, 'save_state'):
315 return
316 sid = session_mgr.sessionId()
317 skey = session_mgr.sessionKey()
318 session_id = '%s_%s' % (sid, skey)
319 session = Session(session_id, repo=core.getcwd())
320 view.save_state(settings=session)
323 def process_args(args):
324 """Process and verify command-line arguments"""
325 if args.version:
326 # Accept 'git cola --version' or 'git cola version'
327 version.print_version()
328 sys.exit(core.EXIT_SUCCESS)
330 # Handle session management
331 restore_session(args)
333 # Bail out if --repo is not a directory
334 repo = core.decode(args.repo)
335 if repo.startswith('file:'):
336 repo = repo[len('file:'):]
337 repo = core.realpath(repo)
338 if not core.isdir(repo):
339 errmsg = N_('fatal: "%s" is not a directory. '
340 'Please specify a correct --repo <path>.') % repo
341 core.print_stderr(errmsg)
342 sys.exit(core.EXIT_USAGE)
345 def restore_session(args):
346 """Load a session based on the window-manager provided arguments"""
347 # args.settings is provided when restoring from a session.
348 args.settings = None
349 if args.session is None:
350 return
351 session = Session(args.session)
352 if session.load():
353 args.settings = session
354 args.repo = session.repo
357 def application_init(args, update=False):
358 """Parses the command-line arguments and starts git-cola
360 # Ensure that we're working in a valid git repository.
361 # If not, try to find one. When found, chdir there.
362 setup_environment()
363 process_args(args)
365 context = new_context(args)
366 timer = context.timer
367 timer.start('init')
369 new_worktree(context, args.repo, args.prompt, args.settings)
371 if update:
372 context.model.update_status()
374 timer.stop('init')
375 if args.perf:
376 timer.display('init')
377 return context
380 def new_context(args):
381 """Create top-level ApplicationContext objects"""
382 context = ApplicationContext(args)
383 context.git = git.create()
384 context.cfg = gitcfg.create(context)
385 context.fsmonitor = fsmonitor.create(context)
386 context.selection = selection.create()
387 context.model = main.create(context)
388 context.app = new_application(context, args)
389 context.timer = Timer()
391 return context
394 def application_run(context, view, start=None, stop=None):
395 """Run the application main loop"""
396 context.set_view(view)
397 view.show()
399 # Startup callbacks
400 if start:
401 start(context, view)
402 # Start the event loop
403 result = context.app.start()
404 # Finish
405 if stop:
406 stop(context, view)
407 context.app.stop()
409 return result
412 def application_start(context, view):
413 """Show the GUI and start the main event loop"""
414 # Store the view for session management
415 return application_run(context, view,
416 start=default_start, stop=default_stop)
419 def default_start(context, _view):
420 """Scan for the first time"""
421 QtCore.QTimer.singleShot(0, startup_message)
422 QtCore.QTimer.singleShot(0, lambda: async_update(context))
425 def default_stop(_context, _view):
426 """All done, cleanup"""
427 QtCore.QThreadPool.globalInstance().waitForDone()
430 def add_common_arguments(parser):
431 """Add command arguments to the ArgumentParser"""
432 # We also accept 'git cola version'
433 parser.add_argument('--version', default=False, action='store_true',
434 help='print version number')
436 # Specifies a git repository to open
437 parser.add_argument('-r', '--repo', metavar='<repo>', default=core.getcwd(),
438 help='open the specified git repository')
440 # Specifies that we should prompt for a repository at startup
441 parser.add_argument('--prompt', action='store_true', default=False,
442 help='prompt for a repository')
444 # Specify the icon theme
445 parser.add_argument('--icon-theme', metavar='<theme>',
446 dest='icon_themes', action='append', default=[],
447 help='specify an icon theme (name or directory)')
449 # Resume an X Session Management session
450 parser.add_argument('-session', metavar='<session>', default=None,
451 help=argparse.SUPPRESS)
453 # Enable timing information
454 parser.add_argument('--perf', action='store_true', default=False,
455 help=argparse.SUPPRESS)
458 def new_application(context, args):
459 """Create a new ColaApplication"""
460 return ColaApplication(context, sys.argv, icon_themes=args.icon_themes)
463 def new_worktree(context, repo, prompt, settings):
464 """Find a Git repository, or prompt for one when not found"""
465 model = context.model
466 cfg = context.cfg
467 parent = qtutils.active_window()
468 valid = False
470 if not prompt:
471 valid = model.set_worktree(repo)
472 if not valid:
473 # We are not currently in a git repository so we need to find one.
474 # Before prompting the user for a repository, check if they've
475 # configured a default repository and attempt to use it.
476 default_repo = cfg.get('cola.defaultrepo')
477 if default_repo:
478 valid = model.set_worktree(default_repo)
480 while not valid:
481 # If we've gotten into this loop then that means that neither the
482 # current directory nor the default repository were available.
483 # Prompt the user for a repository.
484 startup_dlg = startup.StartupDialog(context, parent, settings=settings)
485 gitdir = startup_dlg.find_git_repo()
486 if not gitdir:
487 sys.exit(core.EXIT_NOINPUT)
488 valid = model.set_worktree(gitdir)
491 def async_update(context):
492 """Update the model in the background
494 git-cola should startup as quickly as possible.
497 update_status = partial(context.model.update_status, update_index=True)
498 task = qtutils.SimpleTask(context.view, update_status)
499 context.runtask.start(task)
502 def startup_message():
503 """Print debug startup messages"""
504 trace = git.GIT_COLA_TRACE
505 if trace in ('2', 'trace'):
506 msg1 = 'info: debug level 2: trace mode enabled'
507 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
508 Interaction.log(msg1)
509 Interaction.log(msg2)
510 elif trace:
511 msg1 = 'info: debug level 1'
512 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
513 Interaction.log(msg1)
514 Interaction.log(msg2)
517 class Timer(object):
518 """Simple performance timer"""
520 def __init__(self):
521 self._data = {}
523 def start(self, key):
524 """Start a timer"""
525 now = time.time()
526 self._data[key] = [now, now]
528 def stop(self, key):
529 """Stop a timer and return its elapsed time"""
530 entry = self._data[key]
531 entry[1] = time.time()
532 return self.elapsed(key)
534 def elapsed(self, key):
535 """Return the elapsed time for a timer"""
536 entry = self._data[key]
537 return entry[1] - entry[0]
539 def display(self, key):
540 """Display a timer"""
541 elapsed = self.elapsed(key)
542 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
545 class ApplicationContext(object):
546 """Context for performing operations on Git and related data models"""
548 def __init__(self, args):
549 self.args = args
550 self.app = None # ColaApplication
551 self.git = None # git.Git
552 self.cfg = None # gitcfg.GitConfig
553 self.model = None # main.MainModel
554 self.timer = None # Timer
555 self.runtask = None # qtutils.RunTask
556 self.selection = None # selection.SelectionModel
557 self.fsmonitor = None # fsmonitor
558 self.view = None # QWidget
560 def set_view(self, view):
561 """Initialize view-specific members"""
562 self.view = view
563 self.runtask = qtutils.RunTask(parent=view)
566 def winmain(main_fn, *argv):
567 """Find Git and launch main(argv)"""
568 git_path = find_git()
569 if git_path:
570 prepend_path(git_path)
571 return main_fn(*argv)
574 def find_git():
575 """Return the path of git.exe, or None if we can't find it."""
576 if not utils.is_win32():
577 return None # UNIX systems have git in their $PATH
579 # If the user wants to use a Git/bin/ directory from a non-standard
580 # directory then they can write its location into
581 # ~/.config/git-cola/git-bindir
582 git_bindir = os.path.expanduser(os.path.join('~', '.config', 'git-cola',
583 'git-bindir'))
584 if core.exists(git_bindir):
585 custom_path = core.read(git_bindir).strip()
586 if custom_path and core.exists(custom_path):
587 return custom_path
589 # Try to find Git's bin/ directory in one of the typical locations
590 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
591 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
592 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
593 for p in [pf64, pf32, pf, 'C:\\']:
594 candidate = os.path.join(p, 'Git\\bin')
595 if os.path.isdir(candidate):
596 return candidate
598 return None
601 def prepend_path(path):
602 """Adds git to the PATH. This is needed on Windows."""
603 path = core.decode(path)
604 path_entries = core.getenv('PATH', '').split(os.pathsep)
605 if path not in path_entries:
606 path_entries.insert(0, path)
607 compat.setenv('PATH', os.pathsep.join(path_entries))