doc: add Giovanni to the credits
[git-cola.git] / cola / app.py
blobabe4228ea41902298e91c3c305fe4d86b67ceadd
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(
31 err=error
34 sys.exit(1)
36 from qtpy import QtWidgets
37 from qtpy.QtCore import Qt
39 try:
40 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
41 # imported before QApplication is constructed.
42 from qtpy import QtWebEngineWidgets # noqa
43 except ImportError:
44 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
45 pass
47 # Import cola modules
48 from .i18n import N_
49 from .interaction import Interaction
50 from .models import main
51 from .models import selection
52 from .widgets import cfgactions
53 from .widgets import standard
54 from .widgets import startup
55 from .settings import Session
56 from .settings import Settings
57 from . import cmds
58 from . import core
59 from . import compat
60 from . import fsmonitor
61 from . import git
62 from . import gitcfg
63 from . import guicmds
64 from . import hidpi
65 from . import icons
66 from . import i18n
67 from . import qtcompat
68 from . import qtutils
69 from . import resources
70 from . import themes
71 from . import utils
72 from . import version
75 def setup_environment():
76 """Set environment variables to control git's behavior"""
77 # Allow Ctrl-C to exit
78 signal.signal(signal.SIGINT, signal.SIG_DFL)
80 # Session management wants an absolute path when restarting
81 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
83 # Spoof an X11 display for SSH
84 os.environ.setdefault('DISPLAY', ':0')
86 if not core.getenv('SHELL', ''):
87 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
88 if os.path.exists(shell):
89 compat.setenv('SHELL', shell)
90 break
92 # Setup the path so that git finds us when we run 'git cola'
93 path_entries = core.getenv('PATH', '').split(os.pathsep)
94 bindir = core.decode(os.path.dirname(sys_argv0))
95 path_entries.append(bindir)
96 path = os.pathsep.join(path_entries)
97 compat.setenv('PATH', path)
99 # We don't ever want a pager
100 compat.setenv('GIT_PAGER', '')
102 # Setup *SSH_ASKPASS
103 git_askpass = core.getenv('GIT_ASKPASS')
104 ssh_askpass = core.getenv('SSH_ASKPASS')
105 if git_askpass:
106 askpass = git_askpass
107 elif ssh_askpass:
108 askpass = ssh_askpass
109 elif sys.platform == 'darwin':
110 askpass = resources.package_command('ssh-askpass-darwin')
111 else:
112 askpass = resources.package_command('ssh-askpass')
114 compat.setenv('GIT_ASKPASS', askpass)
115 compat.setenv('SSH_ASKPASS', askpass)
117 # --- >8 --- >8 ---
118 # Git v1.7.10 Release Notes
119 # =========================
121 # Compatibility Notes
122 # -------------------
124 # * From this release on, the "git merge" command in an interactive
125 # session will start an editor when it automatically resolves the
126 # merge for the user to explain the resulting commit, just like the
127 # "git commit" command does when it wasn't given a commit message.
129 # If you have a script that runs "git merge" and keeps its standard
130 # input and output attached to the user's terminal, and if you do not
131 # want the user to explain the resulting merge commits, you can
132 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
133 # this:
135 # #!/bin/sh
136 # GIT_MERGE_AUTOEDIT=no
137 # export GIT_MERGE_AUTOEDIT
139 # to disable this behavior (if you want your users to explain their
140 # merge commits, you do not have to do anything). Alternatively, you
141 # can give the "--no-edit" option to individual invocations of the
142 # "git merge" command if you know everybody who uses your script has
143 # Git v1.7.8 or newer.
144 # --- >8 --- >8 ---
145 # Longer-term: Use `git merge --no-commit` so that we always
146 # have a chance to explain our merges.
147 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
150 def get_icon_themes(context):
151 """Return the default icon theme names"""
152 result = []
154 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
155 if icon_themes_env:
156 result.extend([x for x in icon_themes_env.split(':') if x])
158 icon_themes_cfg = list(reversed(context.cfg.get_all('cola.icontheme')))
159 if icon_themes_cfg:
160 result.extend(icon_themes_cfg)
162 if not result:
163 result.append('light')
165 return result
168 # style note: we use camelCase here since we're masquerading a Qt class
169 class ColaApplication(object):
170 """The main cola application
172 ColaApplication handles i18n of user-visible data
175 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
176 cfgactions.install()
177 i18n.install(locale)
178 qtcompat.install()
179 guicmds.install()
180 standard.install()
181 icons.install(icon_themes or get_icon_themes(context))
183 self.context = context
184 self.theme = None
185 self._install_hidpi_config()
186 self._app = ColaQApplication(context, list(argv))
187 self._app.setWindowIcon(icons.cola())
188 self._app.setDesktopFileName('git-cola')
189 self._install_style(gui_theme)
191 def _install_style(self, theme_str):
192 """Generate and apply a stylesheet to the app"""
193 if theme_str is None:
194 theme_str = self.context.cfg.get('cola.theme', default='default')
195 theme = themes.find_theme(theme_str)
196 self.theme = theme
197 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
198 if theme_str != 'default':
199 self._app.setPalette(theme.build_palette(self._app.palette()))
201 def _install_hidpi_config(self):
202 """Sets QT HIDPI scalling (requires Qt 5.6)"""
203 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
204 hidpi.apply_choice(value)
206 def activeWindow(self):
207 """QApplication::activeWindow() pass-through"""
208 return self._app.activeWindow()
210 def desktop(self):
211 """QApplication::desktop() pass-through"""
212 return self._app.desktop()
214 def palette(self):
215 """QApplication::palette() pass-through"""
216 return self._app.palette()
218 def start(self):
219 """Wrap exec_() and start the application"""
220 # Defer connection so that local cola.inotify is honored
221 context = self.context
222 monitor = context.fsmonitor
223 monitor.files_changed.connect(
224 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
226 monitor.config_changed.connect(
227 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
229 # Start the filesystem monitor thread
230 monitor.start()
231 return self._app.exec_()
233 def stop(self):
234 """Finalize the application"""
235 self.context.fsmonitor.stop()
236 # Workaround QTBUG-52988 by deleting the app manually to prevent a
237 # crash during app shutdown.
238 # https://bugreports.qt.io/browse/QTBUG-52988
239 try:
240 del self._app
241 except (AttributeError, RuntimeError):
242 pass
243 self._app = None
245 def exit(self, status):
246 """QApplication::exit(status) pass-through"""
247 return self._app.exit(status)
250 class ColaQApplication(QtWidgets.QApplication):
251 """QApplication implementation for handling custom events"""
253 def __init__(self, context, argv):
254 super(ColaQApplication, self).__init__(argv)
255 self.context = context
256 # Make icons sharp in HiDPI screen
257 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
258 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
260 def event(self, e):
261 """Respond to focus events for the cola.refreshonfocus feature"""
262 if e.type() == QtCore.QEvent.ApplicationActivate:
263 context = self.context
264 if context:
265 cfg = context.cfg
266 if context.git.is_valid() and cfg.get(
267 'cola.refreshonfocus', default=False
269 cmds.do(cmds.Refresh, context)
270 return super(ColaQApplication, self).event(e)
272 def commitData(self, session_mgr):
273 """Save session data"""
274 if not self.context or not self.context.view:
275 return
276 view = self.context.view
277 if not hasattr(view, 'save_state'):
278 return
279 sid = session_mgr.sessionId()
280 skey = session_mgr.sessionKey()
281 session_id = '%s_%s' % (sid, skey)
282 session = Session(session_id, repo=core.getcwd())
283 session.update()
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"""
328 # Ensure that we're working in a valid git repository.
329 # If not, try to find one. When found, chdir there.
330 setup_environment()
331 process_args(args)
333 context = new_context(args)
334 timer = context.timer
335 timer.start('init')
337 new_worktree(context, args.repo, args.prompt)
339 if update:
340 context.model.update_status()
342 timer.stop('init')
343 if args.perf:
344 timer.display('init')
345 return context
348 def new_context(args):
349 """Create top-level ApplicationContext objects"""
350 context = ApplicationContext(args)
351 context.settings = args.settings or Settings.read()
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):
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)
480 gitdir = startup_dlg.find_git_repo()
481 if not gitdir:
482 sys.exit(core.EXIT_NOINPUT)
484 if not core.exists(os.path.join(gitdir, '.git')):
485 offer_to_create_repo(context, gitdir)
486 valid = model.set_worktree(gitdir)
487 continue
489 valid = model.set_worktree(gitdir)
490 if not valid:
491 err = model.error
492 standard.critical(
493 N_('Error Opening Repository'),
494 message=N_('Could not open %s.' % gitdir),
495 details=err,
499 def offer_to_create_repo(context, gitdir):
500 """Offer to create a new repo"""
501 title = N_('Repository Not Found')
502 text = N_('%s is not a Git repository.') % gitdir
503 informative_text = N_('Create a new repository at that location?')
504 if standard.confirm(title, text, informative_text, N_('Create')):
505 status, out, err = context.git.init(gitdir)
506 title = N_('Error Creating Repository')
507 if status != 0:
508 Interaction.command_error(title, 'git init', status, out, err)
511 def async_update(context):
512 """Update the model in the background
514 git-cola should startup as quickly as possible.
517 update_status = partial(context.model.update_status, update_index=True)
518 task = qtutils.SimpleTask(update_status)
519 context.runtask.start(task)
522 def startup_message():
523 """Print debug startup messages"""
524 trace = git.GIT_COLA_TRACE
525 if trace in ('2', 'trace'):
526 msg1 = 'info: debug level 2: trace mode enabled'
527 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
528 Interaction.log(msg1)
529 Interaction.log(msg2)
530 elif trace:
531 msg1 = 'info: debug level 1'
532 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
533 Interaction.log(msg1)
534 Interaction.log(msg2)
537 def initialize():
538 """System-level initialization"""
539 # We support ~/.config/git-cola/git-bindir on Windows for configuring
540 # a custom location for finding the "git" executable.
541 git_path = find_git()
542 if git_path:
543 prepend_path(git_path)
545 # The current directory may have been deleted while we are still
546 # in that directory. We rectify this situation by walking up the
547 # directory tree and retrying.
549 # This is needed because because Python throws exceptions in lots of
550 # stdlib functions when in this situation, e.g. os.path.abspath() and
551 # os.path.realpath(), so it's simpler to mitigate the damage by changing
552 # the current directory to one that actually exists.
553 while True:
554 try:
555 return core.getcwd()
556 except OSError:
557 os.chdir('..')
560 class Timer(object):
561 """Simple performance timer"""
563 def __init__(self):
564 self._data = {}
566 def start(self, key):
567 """Start a timer"""
568 now = time.time()
569 self._data[key] = [now, now]
571 def stop(self, key):
572 """Stop a timer and return its elapsed time"""
573 entry = self._data[key]
574 entry[1] = time.time()
575 return self.elapsed(key)
577 def elapsed(self, key):
578 """Return the elapsed time for a timer"""
579 entry = self._data[key]
580 return entry[1] - entry[0]
582 def display(self, key):
583 """Display a timer"""
584 elapsed = self.elapsed(key)
585 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
588 class NullArgs(object):
589 """Stub arguments for interactive API use"""
591 def __init__(self):
592 self.icon_themes = []
593 self.theme = None
594 self.settings = None
597 def null_args():
598 """Create a new instance of application arguments"""
599 return NullArgs()
602 class ApplicationContext(object):
603 """Context for performing operations on Git and related data models"""
605 def __init__(self, args):
606 self.args = args
607 self.app = None # ColaApplication
608 self.git = None # git.Git
609 self.cfg = None # gitcfg.GitConfig
610 self.model = None # main.MainModel
611 self.timer = None # Timer
612 self.runtask = None # qtutils.RunTask
613 self.settings = None # settings.Settings
614 self.selection = None # selection.SelectionModel
615 self.fsmonitor = None # fsmonitor
616 self.view = None # QWidget
617 self.browser_windows = [] # list of browse.Browser
619 def set_view(self, view):
620 """Initialize view-specific members"""
621 self.view = view
622 self.runtask = qtutils.RunTask(parent=view)
625 def find_git():
626 """Return the path of git.exe, or None if we can't find it."""
627 if not utils.is_win32():
628 return None # UNIX systems have git in their $PATH
630 # If the user wants to use a Git/bin/ directory from a non-standard
631 # directory then they can write its location into
632 # ~/.config/git-cola/git-bindir
633 git_bindir = resources.config_home('git-bindir')
634 if core.exists(git_bindir):
635 custom_path = core.read(git_bindir).strip()
636 if custom_path and core.exists(custom_path):
637 return custom_path
639 # Try to find Git's bin/ directory in one of the typical locations
640 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
641 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
642 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
643 for p in [pf64, pf32, pf, 'C:\\']:
644 candidate = os.path.join(p, 'Git\\bin')
645 if os.path.isdir(candidate):
646 return candidate
648 return None
651 def prepend_path(path):
652 """Adds git to the PATH. This is needed on Windows."""
653 path = core.decode(path)
654 path_entries = core.getenv('PATH', '').split(os.pathsep)
655 if path not in path_entries:
656 path_entries.insert(0, path)
657 compat.setenv('PATH', os.pathsep.join(path_entries))