themes: use the core module for io
[git-cola.git] / cola / app.py
blob24f3aaa48e7dcf2f752eed6b7b10307526603d01
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-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 .settings import Settings
50 from . import cmds
51 from . import core
52 from . import compat
53 from . import fsmonitor
54 from . import git
55 from . import gitcfg
56 from . import guicmds
57 from . import hidpi
58 from . import icons
59 from . import i18n
60 from . import qtcompat
61 from . import qtutils
62 from . import resources
63 from . import themes
64 from . import utils
65 from . import version
68 def setup_environment():
69 """Set environment variables to control git's behavior"""
70 # Allow Ctrl-C to exit
71 signal.signal(signal.SIGINT, signal.SIG_DFL)
73 # Session management wants an absolute path when restarting
74 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
76 # Spoof an X11 display for SSH
77 os.environ.setdefault('DISPLAY', ':0')
79 if not core.getenv('SHELL', ''):
80 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
81 if os.path.exists(shell):
82 compat.setenv('SHELL', shell)
83 break
85 # Setup the path so that git finds us when we run 'git cola'
86 path_entries = core.getenv('PATH', '').split(os.pathsep)
87 bindir = core.decode(os.path.dirname(sys_argv0))
88 path_entries.append(bindir)
89 path = os.pathsep.join(path_entries)
90 compat.setenv('PATH', path)
92 # We don't ever want a pager
93 compat.setenv('GIT_PAGER', '')
95 # Setup *SSH_ASKPASS
96 git_askpass = core.getenv('GIT_ASKPASS')
97 ssh_askpass = core.getenv('SSH_ASKPASS')
98 if git_askpass:
99 askpass = git_askpass
100 elif ssh_askpass:
101 askpass = ssh_askpass
102 elif sys.platform == 'darwin':
103 askpass = resources.package_command('ssh-askpass-darwin')
104 else:
105 askpass = resources.package_command('ssh-askpass')
107 compat.setenv('GIT_ASKPASS', askpass)
108 compat.setenv('SSH_ASKPASS', askpass)
110 # --- >8 --- >8 ---
111 # Git v1.7.10 Release Notes
112 # =========================
114 # Compatibility Notes
115 # -------------------
117 # * From this release on, the "git merge" command in an interactive
118 # session will start an editor when it automatically resolves the
119 # merge for the user to explain the resulting commit, just like the
120 # "git commit" command does when it wasn't given a commit message.
122 # If you have a script that runs "git merge" and keeps its standard
123 # input and output attached to the user's terminal, and if you do not
124 # want the user to explain the resulting merge commits, you can
125 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
126 # this:
128 # #!/bin/sh
129 # GIT_MERGE_AUTOEDIT=no
130 # export GIT_MERGE_AUTOEDIT
132 # to disable this behavior (if you want your users to explain their
133 # merge commits, you do not have to do anything). Alternatively, you
134 # can give the "--no-edit" option to individual invocations of the
135 # "git merge" command if you know everybody who uses your script has
136 # Git v1.7.8 or newer.
137 # --- >8 --- >8 ---
138 # Longer-term: Use `git merge --no-commit` so that we always
139 # have a chance to explain our merges.
140 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
142 # Gnome3 on Debian has XDG_SESSION_TYPE=wayland and
143 # XDG_CURRENT_DESKTOP=GNOME, which Qt warns about at startup:
145 # Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
146 # Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
148 # This annoying, so we silence the warning.
149 # We'll need to keep this hack here until a future version of Qt provides
150 # Qt Wayland widgets that are usable in gnome-shell.
151 # Cf. https://bugreports.qt.io/browse/QTBUG-68619
152 if (
153 core.getenv('XDG_CURRENT_DESKTOP', '') == 'GNOME'
154 and core.getenv('XDG_SESSION_TYPE', '') == 'wayland'
156 compat.unsetenv('XDG_SESSION_TYPE')
159 def get_icon_themes(context):
160 """Return the default icon theme names"""
161 result = []
163 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
164 if icon_themes_env:
165 result.extend([x for x in icon_themes_env.split(':') if x])
167 icon_themes_cfg = context.cfg.get_all('cola.icontheme')
168 if icon_themes_cfg:
169 result.extend(icon_themes_cfg)
171 if not result:
172 result.append('light')
174 return result
177 # style note: we use camelCase here since we're masquerading a Qt class
178 class ColaApplication(object):
179 """The main cola application
181 ColaApplication handles i18n of user-visible data
184 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
185 cfgactions.install()
186 i18n.install(locale)
187 qtcompat.install()
188 guicmds.install()
189 standard.install()
190 icons.install(icon_themes or get_icon_themes(context))
192 self.context = context
193 self._install_hidpi_config()
194 self._app = ColaQApplication(context, list(argv))
195 self._app.setWindowIcon(icons.cola())
196 self._install_style(gui_theme)
198 def _install_style(self, theme_str):
199 """Generate and apply a stylesheet to the app"""
200 if theme_str is None:
201 theme_str = self.context.cfg.get('cola.theme', default='default')
202 theme = themes.find_theme(theme_str)
203 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
204 if theme_str != 'default':
205 self._app.setPalette(theme.build_palette(self._app.palette()))
207 def _install_hidpi_config(self):
208 """Sets QT HIDPI scalling (requires Qt 5.6)"""
209 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
210 hidpi.apply_choice(value)
212 def activeWindow(self):
213 """QApplication::activeWindow() pass-through"""
214 return self._app.activeWindow()
216 def desktop(self):
217 """QApplication::desktop() pass-through"""
218 return self._app.desktop()
220 def start(self):
221 """Wrap exec_() and start the application"""
222 # Defer connection so that local cola.inotify is honored
223 context = self.context
224 monitor = context.fsmonitor
225 monitor.files_changed.connect(
226 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
228 monitor.config_changed.connect(
229 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
231 # Start the filesystem monitor thread
232 monitor.start()
233 return self._app.exec_()
235 def stop(self):
236 """Finalize the application"""
237 self.context.fsmonitor.stop()
238 # Workaround QTBUG-52988 by deleting the app manually to prevent a
239 # crash during app shutdown.
240 # https://bugreports.qt.io/browse/QTBUG-52988
241 try:
242 del self._app
243 except (AttributeError, RuntimeError):
244 pass
245 self._app = None
247 def exit(self, status):
248 """QApplication::exit(status) pass-through"""
249 return self._app.exit(status)
252 class ColaQApplication(QtWidgets.QApplication):
253 """QApplication implementation for handling custom events"""
255 def __init__(self, context, argv):
256 super(ColaQApplication, self).__init__(argv)
257 self.context = context
258 # Make icons sharp in HiDPI screen
259 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
260 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
262 def event(self, e):
263 """Respond to focus events for the cola.refreshonfocus feature"""
264 if e.type() == QtCore.QEvent.ApplicationActivate:
265 context = self.context
266 if context:
267 cfg = context.cfg
268 if context.git.is_valid() and cfg.get(
269 'cola.refreshonfocus', default=False
271 cmds.do(cmds.Refresh, context)
272 return super(ColaQApplication, self).event(e)
274 def commitData(self, session_mgr):
275 """Save session data"""
276 if not self.context or not self.context.view:
277 return
278 view = self.context.view
279 if not hasattr(view, 'save_state'):
280 return
281 sid = session_mgr.sessionId()
282 skey = session_mgr.sessionKey()
283 session_id = '%s_%s' % (sid, skey)
284 session = Session(session_id, repo=core.getcwd())
285 session.update()
286 view.save_state(settings=session)
289 def process_args(args):
290 """Process and verify command-line arguments"""
291 if args.version:
292 # Accept 'git cola --version' or 'git cola version'
293 version.print_version()
294 sys.exit(core.EXIT_SUCCESS)
296 # Handle session management
297 restore_session(args)
299 # Bail out if --repo is not a directory
300 repo = core.decode(args.repo)
301 if repo.startswith('file:'):
302 repo = repo[len('file:') :]
303 repo = core.realpath(repo)
304 if not core.isdir(repo):
305 errmsg = (
307 'fatal: "%s" is not a directory. '
308 'Please specify a correct --repo <path>.'
310 % repo
312 core.print_stderr(errmsg)
313 sys.exit(core.EXIT_USAGE)
316 def restore_session(args):
317 """Load a session based on the window-manager provided arguments"""
318 # args.settings is provided when restoring from a session.
319 args.settings = None
320 if args.session is None:
321 return
322 session = Session(args.session)
323 if session.load():
324 args.settings = session
325 args.repo = session.repo
328 def application_init(args, update=False):
329 """Parses the command-line arguments and starts git-cola"""
330 # Ensure that we're working in a valid git repository.
331 # If not, try to find one. When found, chdir there.
332 setup_environment()
333 process_args(args)
335 context = new_context(args)
336 timer = context.timer
337 timer.start('init')
339 new_worktree(context, args.repo, args.prompt)
341 if update:
342 context.model.update_status()
344 timer.stop('init')
345 if args.perf:
346 timer.display('init')
347 return context
350 def new_context(args):
351 """Create top-level ApplicationContext objects"""
352 context = ApplicationContext(args)
353 context.settings = args.settings or Settings.read()
354 context.git = git.create()
355 context.cfg = gitcfg.create(context)
356 context.fsmonitor = fsmonitor.create(context)
357 context.selection = selection.create()
358 context.model = main.create(context)
359 context.app = new_application(context, args)
360 context.timer = Timer()
362 return context
365 def application_run(context, view, start=None, stop=None):
366 """Run the application main loop"""
367 initialize_view(context, view)
368 # Startup callbacks
369 if start:
370 start(context, view)
371 # Start the event loop
372 result = context.app.start()
373 # Finish
374 if stop:
375 stop(context, view)
376 context.app.stop()
378 return result
381 def initialize_view(context, view):
382 """Register the main widget and display it"""
383 context.set_view(view)
384 view.show()
385 if sys.platform == 'darwin':
386 view.raise_()
389 def application_start(context, view):
390 """Show the GUI and start the main event loop"""
391 # Store the view for session management
392 return application_run(context, view, start=default_start, stop=default_stop)
395 def default_start(context, _view):
396 """Scan for the first time"""
397 QtCore.QTimer.singleShot(0, startup_message)
398 QtCore.QTimer.singleShot(0, lambda: async_update(context))
401 def default_stop(_context, _view):
402 """All done, cleanup"""
403 QtCore.QThreadPool.globalInstance().waitForDone()
406 def add_common_arguments(parser):
407 """Add command arguments to the ArgumentParser"""
408 # We also accept 'git cola version'
409 parser.add_argument(
410 '--version', default=False, action='store_true', help='print version number'
413 # Specifies a git repository to open
414 parser.add_argument(
415 '-r',
416 '--repo',
417 metavar='<repo>',
418 default=core.getcwd(),
419 help='open the specified git repository',
422 # Specifies that we should prompt for a repository at startup
423 parser.add_argument(
424 '--prompt', action='store_true', default=False, help='prompt for a repository'
427 # Specify the icon theme
428 parser.add_argument(
429 '--icon-theme',
430 metavar='<theme>',
431 dest='icon_themes',
432 action='append',
433 default=[],
434 help='specify an icon theme (name or directory)',
437 # Resume an X Session Management session
438 parser.add_argument(
439 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
442 # Enable timing information
443 parser.add_argument(
444 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
447 # Specify the GUI theme
448 parser.add_argument(
449 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
453 def new_application(context, args):
454 """Create a new ColaApplication"""
455 return ColaApplication(
456 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
460 def new_worktree(context, repo, prompt):
461 """Find a Git repository, or prompt for one when not found"""
462 model = context.model
463 cfg = context.cfg
464 parent = qtutils.active_window()
465 valid = False
467 if not prompt:
468 valid = model.set_worktree(repo)
469 if not valid:
470 # We are not currently in a git repository so we need to find one.
471 # Before prompting the user for a repository, check if they've
472 # configured a default repository and attempt to use it.
473 default_repo = cfg.get('cola.defaultrepo')
474 if default_repo:
475 valid = model.set_worktree(default_repo)
477 while not valid:
478 # If we've gotten into this loop then that means that neither the
479 # current directory nor the default repository were available.
480 # Prompt the user for a repository.
481 startup_dlg = startup.StartupDialog(context, parent)
482 gitdir = startup_dlg.find_git_repo()
483 if not gitdir:
484 sys.exit(core.EXIT_NOINPUT)
486 if not core.exists(os.path.join(gitdir, '.git')):
487 offer_to_create_repo(context, gitdir)
488 valid = model.set_worktree(gitdir)
489 continue
491 valid = model.set_worktree(gitdir)
492 if not valid:
493 standard.critical(
494 N_('Error Opening Repository'), N_('Could not open %s.' % gitdir)
498 def offer_to_create_repo(context, gitdir):
499 """Offer to create a new repo"""
500 title = N_('Repository Not Found')
501 text = N_('%s is not a Git repository.') % gitdir
502 informative_text = N_('Create a new repository at that location?')
503 if standard.confirm(title, text, informative_text, N_('Create')):
504 status, out, err = context.git.init(gitdir)
505 title = N_('Error Creating Repository')
506 if status != 0:
507 Interaction.command_error(title, 'git init', status, out, err)
510 def async_update(context):
511 """Update the model in the background
513 git-cola should startup as quickly as possible.
516 update_status = partial(context.model.update_status, update_index=True)
517 task = qtutils.SimpleTask(update_status)
518 context.runtask.start(task)
521 def startup_message():
522 """Print debug startup messages"""
523 trace = git.GIT_COLA_TRACE
524 if trace in ('2', 'trace'):
525 msg1 = 'info: debug level 2: trace mode enabled'
526 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
527 Interaction.log(msg1)
528 Interaction.log(msg2)
529 elif trace:
530 msg1 = 'info: debug level 1'
531 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
532 Interaction.log(msg1)
533 Interaction.log(msg2)
536 def initialize():
537 """System-level initialization"""
538 # The current directory may have been deleted while we are still
539 # in that directory. We rectify this situation by walking up the
540 # directory tree and retrying.
542 # This is needed because because Python throws exceptions in lots of
543 # stdlib functions when in this situation, e.g. os.path.abspath() and
544 # os.path.realpath(), so it's simpler to mitigate the damage by changing
545 # the current directory to one that actually exists.
546 while True:
547 try:
548 return core.getcwd()
549 except OSError:
550 os.chdir('..')
553 class Timer(object):
554 """Simple performance timer"""
556 def __init__(self):
557 self._data = {}
559 def start(self, key):
560 """Start a timer"""
561 now = time.time()
562 self._data[key] = [now, now]
564 def stop(self, key):
565 """Stop a timer and return its elapsed time"""
566 entry = self._data[key]
567 entry[1] = time.time()
568 return self.elapsed(key)
570 def elapsed(self, key):
571 """Return the elapsed time for a timer"""
572 entry = self._data[key]
573 return entry[1] - entry[0]
575 def display(self, key):
576 """Display a timer"""
577 elapsed = self.elapsed(key)
578 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
581 class ApplicationContext(object):
582 """Context for performing operations on Git and related data models"""
584 def __init__(self, args):
585 self.args = args
586 self.app = None # ColaApplication
587 self.git = None # git.Git
588 self.cfg = None # gitcfg.GitConfig
589 self.model = None # main.MainModel
590 self.timer = None # Timer
591 self.runtask = None # qtutils.RunTask
592 self.settings = None # settings.Settings
593 self.selection = None # selection.SelectionModel
594 self.fsmonitor = None # fsmonitor
595 self.view = None # QWidget
596 self.browser_windows = [] # list of browse.Browser
598 def set_view(self, view):
599 """Initialize view-specific members"""
600 self.view = view
601 self.runtask = qtutils.RunTask(parent=view)
604 def winmain(main_fn, *argv):
605 """Find Git and launch main(argv)"""
606 git_path = find_git()
607 if git_path:
608 prepend_path(git_path)
609 return main_fn(*argv)
612 def find_git():
613 """Return the path of git.exe, or None if we can't find it."""
614 if not utils.is_win32():
615 return None # UNIX systems have git in their $PATH
617 # If the user wants to use a Git/bin/ directory from a non-standard
618 # directory then they can write its location into
619 # ~/.config/git-cola/git-bindir
620 git_bindir = os.path.expanduser(
621 os.path.join('~', '.config', 'git-cola', 'git-bindir')
623 if core.exists(git_bindir):
624 custom_path = core.read(git_bindir).strip()
625 if custom_path and core.exists(custom_path):
626 return custom_path
628 # Try to find Git's bin/ directory in one of the typical locations
629 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
630 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
631 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
632 for p in [pf64, pf32, pf, 'C:\\']:
633 candidate = os.path.join(p, 'Git\\bin')
634 if os.path.isdir(candidate):
635 return candidate
637 return None
640 def prepend_path(path):
641 """Adds git to the PATH. This is needed on Windows."""
642 path = core.decode(path)
643 path_entries = core.getenv('PATH', '').split(os.pathsep)
644 if path not in path_entries:
645 path_entries.insert(0, path)
646 compat.setenv('PATH', os.pathsep.join(path_entries))