log: set the focus proxy to the output text
[git-cola.git] / cola / app.py
blobe2cbfcc32ed2d96b791fd8a215bbb4f9f1b0f91f
1 """Provides the main() routine and ColaApplication"""
2 # pylint: disable=unused-import
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-2022 David Aguilar and contributors
12 """
14 try:
15 from qtpy import QtCore
16 except ImportError as error:
17 sys.stderr.write(
18 """
19 Your Python environment does not have qtpy and PyQt (or PySide).
20 The following error was encountered when importing "qtpy":
22 ImportError: {err}
24 Install qtpy and PyQt (or PySide) into your Python environment.
25 On a Debian/Ubuntu system you can install these modules using apt:
27 sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine python3-qtpy
29 """.format(
30 err=error
33 sys.exit(1)
35 from qtpy import QtWidgets
36 from qtpy.QtCore import Qt
38 try:
39 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
40 # imported before QApplication is constructed.
41 from qtpy import QtWebEngineWidgets # noqa
42 except ImportError:
43 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
44 pass
46 # Import cola modules
47 from .i18n import N_
48 from .interaction import Interaction
49 from .models import main
50 from .models import selection
51 from .widgets import cfgactions
52 from .widgets import standard
53 from .widgets import startup
54 from .settings import Session
55 from .settings import Settings
56 from . import cmds
57 from . import core
58 from . import compat
59 from . import fsmonitor
60 from . import git
61 from . import gitcfg
62 from . import guicmds
63 from . import hidpi
64 from . import icons
65 from . import i18n
66 from . import qtcompat
67 from . import qtutils
68 from . import resources
69 from . import themes
70 from . import utils
71 from . import version
74 def setup_environment():
75 """Set environment variables to control git's behavior"""
76 # Allow Ctrl-C to exit
77 signal.signal(signal.SIGINT, signal.SIG_DFL)
79 # Session management wants an absolute path when restarting
80 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
82 # Spoof an X11 display for SSH
83 os.environ.setdefault('DISPLAY', ':0')
85 if not core.getenv('SHELL', ''):
86 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
87 if os.path.exists(shell):
88 compat.setenv('SHELL', shell)
89 break
91 # Setup the path so that git finds us when we run 'git cola'
92 path_entries = core.getenv('PATH', '').split(os.pathsep)
93 bindir = core.decode(os.path.dirname(sys_argv0))
94 path_entries.append(bindir)
95 path = os.pathsep.join(path_entries)
96 compat.setenv('PATH', path)
98 # We don't ever want a pager
99 compat.setenv('GIT_PAGER', '')
101 # Setup *SSH_ASKPASS
102 git_askpass = core.getenv('GIT_ASKPASS')
103 ssh_askpass = core.getenv('SSH_ASKPASS')
104 if git_askpass:
105 askpass = git_askpass
106 elif ssh_askpass:
107 askpass = ssh_askpass
108 elif sys.platform == 'darwin':
109 askpass = resources.package_command('ssh-askpass-darwin')
110 else:
111 askpass = resources.package_command('ssh-askpass')
113 compat.setenv('GIT_ASKPASS', askpass)
114 compat.setenv('SSH_ASKPASS', askpass)
116 # --- >8 --- >8 ---
117 # Git v1.7.10 Release Notes
118 # =========================
120 # Compatibility Notes
121 # -------------------
123 # * From this release on, the "git merge" command in an interactive
124 # session will start an editor when it automatically resolves the
125 # merge for the user to explain the resulting commit, just like the
126 # "git commit" command does when it wasn't given a commit message.
128 # If you have a script that runs "git merge" and keeps its standard
129 # input and output attached to the user's terminal, and if you do not
130 # want the user to explain the resulting merge commits, you can
131 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
132 # this:
134 # #!/bin/sh
135 # GIT_MERGE_AUTOEDIT=no
136 # export GIT_MERGE_AUTOEDIT
138 # to disable this behavior (if you want your users to explain their
139 # merge commits, you do not have to do anything). Alternatively, you
140 # can give the "--no-edit" option to individual invocations of the
141 # "git merge" command if you know everybody who uses your script has
142 # Git v1.7.8 or newer.
143 # --- >8 --- >8 ---
144 # Longer-term: Use `git merge --no-commit` so that we always
145 # have a chance to explain our merges.
146 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
149 def get_icon_themes(context):
150 """Return the default icon theme names"""
151 result = []
153 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
154 if icon_themes_env:
155 result.extend([x for x in icon_themes_env.split(':') if x])
157 icon_themes_cfg = list(reversed(context.cfg.get_all('cola.icontheme')))
158 if icon_themes_cfg:
159 result.extend(icon_themes_cfg)
161 if not result:
162 result.append('light')
164 return result
167 # style note: we use camelCase here since we're masquerading a Qt class
168 class ColaApplication:
169 """The main cola application
171 ColaApplication handles i18n of user-visible data
174 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
175 cfgactions.install()
176 i18n.install(locale)
177 qtcompat.install()
178 guicmds.install()
179 standard.install()
180 icons.install(icon_themes or get_icon_themes(context))
182 self.context = context
183 self.theme = None
184 self._install_hidpi_config()
185 self._app = ColaQApplication(context, list(argv))
186 self._app.setWindowIcon(icons.cola())
187 self._app.setDesktopFileName('git-cola')
188 self._install_style(gui_theme)
190 def _install_style(self, theme_str):
191 """Generate and apply a stylesheet to the app"""
192 if theme_str is None:
193 theme_str = self.context.cfg.get('cola.theme', default='default')
194 theme = themes.find_theme(theme_str)
195 self.theme = theme
196 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
198 is_macos_theme = theme_str.startswith('macos-')
199 if is_macos_theme:
200 themes.apply_platform_theme(theme_str)
201 elif theme_str != 'default':
202 self._app.setPalette(theme.build_palette(self._app.palette()))
204 def _install_hidpi_config(self):
205 """Sets QT HIDPI scalling (requires Qt 5.6)"""
206 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
207 hidpi.apply_choice(value)
209 def activeWindow(self):
210 """QApplication::activeWindow() pass-through"""
211 return self._app.activeWindow()
213 def palette(self):
214 """QApplication::palette() pass-through"""
215 return self._app.palette()
217 def start(self):
218 """Wrap exec_() and start the application"""
219 # Defer connection so that local cola.inotify is honored
220 context = self.context
221 monitor = context.fsmonitor
222 monitor.files_changed.connect(
223 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
225 monitor.config_changed.connect(
226 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
228 # Start the filesystem monitor thread
229 monitor.start()
230 return self._app.exec_()
232 def stop(self):
233 """Finalize the application"""
234 self.context.fsmonitor.stop()
235 # Workaround QTBUG-52988 by deleting the app manually to prevent a
236 # crash during app shutdown.
237 # https://bugreports.qt.io/browse/QTBUG-52988
238 try:
239 del self._app
240 except (AttributeError, RuntimeError):
241 pass
242 self._app = None
244 def exit(self, status):
245 """QApplication::exit(status) pass-through"""
246 return self._app.exit(status)
249 class ColaQApplication(QtWidgets.QApplication):
250 """QApplication implementation for handling custom events"""
252 def __init__(self, context, argv):
253 super().__init__(argv)
254 self.context = context
255 # Make icons sharp in HiDPI screen
256 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
257 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
259 def event(self, e):
260 """Respond to focus events for the cola.refreshonfocus feature"""
261 if e.type() == QtCore.QEvent.ApplicationActivate:
262 context = self.context
263 if context:
264 cfg = context.cfg
265 if context.git.is_valid() and cfg.get(
266 'cola.refreshonfocus', default=False
268 cmds.do(cmds.Refresh, context)
269 return super().event(e)
271 def commitData(self, session_mgr):
272 """Save session data"""
273 if not self.context or not self.context.view:
274 return
275 view = self.context.view
276 if not hasattr(view, 'save_state'):
277 return
278 sid = session_mgr.sessionId()
279 skey = session_mgr.sessionKey()
280 session_id = f'{sid}_{skey}'
281 session = Session(session_id, repo=core.getcwd())
282 session.update()
283 view.save_state(settings=session)
286 def process_args(args):
287 """Process and verify command-line arguments"""
288 if args.version:
289 # Accept 'git cola --version' or 'git cola version'
290 version.print_version()
291 sys.exit(core.EXIT_SUCCESS)
293 # Handle session management
294 restore_session(args)
296 # Bail out if --repo is not a directory
297 repo = core.decode(args.repo)
298 if repo.startswith('file:'):
299 repo = repo[len('file:') :]
300 repo = core.realpath(repo)
301 if not core.isdir(repo):
302 errmsg = (
304 'fatal: "%s" is not a directory. '
305 'Please specify a correct --repo <path>.'
307 % repo
309 core.print_stderr(errmsg)
310 sys.exit(core.EXIT_USAGE)
313 def restore_session(args):
314 """Load a session based on the window-manager provided arguments"""
315 # args.settings is provided when restoring from a session.
316 args.settings = None
317 if args.session is None:
318 return
319 session = Session(args.session)
320 if session.load():
321 args.settings = session
322 args.repo = session.repo
325 def application_init(args, update=False):
326 """Parses the command-line arguments and starts git-cola"""
327 # Ensure that we're working in a valid git repository.
328 # If not, try to find one. When found, chdir there.
329 setup_environment()
330 process_args(args)
332 context = new_context(args)
333 timer = context.timer
334 timer.start('init')
336 new_worktree(context, args.repo, args.prompt)
338 if update:
339 context.model.update_status()
341 timer.stop('init')
342 if args.perf:
343 timer.display('init')
344 return context
347 def new_context(args):
348 """Create top-level ApplicationContext objects"""
349 context = ApplicationContext(args)
350 context.settings = args.settings or Settings.read()
351 context.git = git.create()
352 context.cfg = gitcfg.create(context)
353 context.fsmonitor = fsmonitor.create(context)
354 context.selection = selection.create()
355 context.model = main.create(context)
356 context.app = new_application(context, args)
357 context.timer = Timer()
359 return context
362 def application_run(context, view, start=None, stop=None):
363 """Run the application main loop"""
364 initialize_view(context, view)
365 # Startup callbacks
366 if start:
367 start(context, view)
368 # Start the event loop
369 result = context.app.start()
370 # Finish
371 if stop:
372 stop(context, view)
373 context.app.stop()
375 return result
378 def initialize_view(context, view):
379 """Register the main widget and display it"""
380 context.set_view(view)
381 view.show()
382 if sys.platform == 'darwin':
383 view.raise_()
386 def application_start(context, view):
387 """Show the GUI and start the main event loop"""
388 # Store the view for session management
389 return application_run(context, view, start=default_start, stop=default_stop)
392 def default_start(context, _view):
393 """Scan for the first time"""
394 QtCore.QTimer.singleShot(0, startup_message)
395 QtCore.QTimer.singleShot(0, lambda: async_update(context))
398 def default_stop(_context, _view):
399 """All done, cleanup"""
400 QtCore.QThreadPool.globalInstance().waitForDone()
403 def add_common_arguments(parser):
404 """Add command arguments to the ArgumentParser"""
405 # We also accept 'git cola version'
406 parser.add_argument(
407 '--version', default=False, action='store_true', help='print version number'
410 # Specifies a git repository to open
411 parser.add_argument(
412 '-r',
413 '--repo',
414 metavar='<repo>',
415 default=core.getcwd(),
416 help='open the specified git repository',
419 # Specifies that we should prompt for a repository at startup
420 parser.add_argument(
421 '--prompt', action='store_true', default=False, help='prompt for a repository'
424 # Specify the icon theme
425 parser.add_argument(
426 '--icon-theme',
427 metavar='<theme>',
428 dest='icon_themes',
429 action='append',
430 default=[],
431 help='specify an icon theme (name or directory)',
434 # Resume an X Session Management session
435 parser.add_argument(
436 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
439 # Enable timing information
440 parser.add_argument(
441 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
444 # Specify the GUI theme
445 parser.add_argument(
446 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
450 def new_application(context, args):
451 """Create a new ColaApplication"""
452 return ColaApplication(
453 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
457 def new_worktree(context, repo, prompt):
458 """Find a Git repository, or prompt for one when not found"""
459 model = context.model
460 cfg = context.cfg
461 parent = qtutils.active_window()
462 valid = False
464 if not prompt:
465 valid = model.set_worktree(repo)
466 if not valid:
467 # We are not currently in a git repository so we need to find one.
468 # Before prompting the user for a repository, check if they've
469 # configured a default repository and attempt to use it.
470 default_repo = cfg.get('cola.defaultrepo')
471 if default_repo:
472 valid = model.set_worktree(default_repo)
474 while not valid:
475 # If we've gotten into this loop then that means that neither the
476 # current directory nor the default repository were available.
477 # Prompt the user for a repository.
478 startup_dlg = startup.StartupDialog(context, parent)
479 gitdir = startup_dlg.find_git_repo()
480 if not gitdir:
481 sys.exit(core.EXIT_NOINPUT)
483 if not core.exists(os.path.join(gitdir, '.git')):
484 offer_to_create_repo(context, gitdir)
485 valid = model.set_worktree(gitdir)
486 continue
488 valid = model.set_worktree(gitdir)
489 if not valid:
490 err = model.error
491 standard.critical(
492 N_('Error Opening Repository'),
493 message=N_('Could not open %s.' % gitdir),
494 details=err,
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 # We support ~/.config/git-cola/git-bindir on Windows for configuring
539 # a custom location for finding the "git" executable.
540 git_path = find_git()
541 if git_path:
542 prepend_path(git_path)
544 # The current directory may have been deleted while we are still
545 # in that directory. We rectify this situation by walking up the
546 # directory tree and retrying.
548 # This is needed because because Python throws exceptions in lots of
549 # stdlib functions when in this situation, e.g. os.path.abspath() and
550 # os.path.realpath(), so it's simpler to mitigate the damage by changing
551 # the current directory to one that actually exists.
552 while True:
553 try:
554 return core.getcwd()
555 except OSError:
556 os.chdir('..')
559 class Timer:
560 """Simple performance timer"""
562 def __init__(self):
563 self._data = {}
565 def start(self, key):
566 """Start a timer"""
567 now = time.time()
568 self._data[key] = [now, now]
570 def stop(self, key):
571 """Stop a timer and return its elapsed time"""
572 entry = self._data[key]
573 entry[1] = time.time()
574 return self.elapsed(key)
576 def elapsed(self, key):
577 """Return the elapsed time for a timer"""
578 entry = self._data[key]
579 return entry[1] - entry[0]
581 def display(self, key):
582 """Display a timer"""
583 elapsed = self.elapsed(key)
584 sys.stdout.write(f'{key}: {elapsed:.5f}s\n')
587 class NullArgs:
588 """Stub arguments for interactive API use"""
590 def __init__(self):
591 self.icon_themes = []
592 self.theme = None
593 self.settings = None
596 def null_args():
597 """Create a new instance of application arguments"""
598 return NullArgs()
601 class ApplicationContext:
602 """Context for performing operations on Git and related data models"""
604 def __init__(self, args):
605 self.args = args
606 self.app = None # ColaApplication
607 self.git = None # git.Git
608 self.cfg = None # gitcfg.GitConfig
609 self.model = None # main.MainModel
610 self.timer = None # Timer
611 self.runtask = None # qtutils.RunTask
612 self.settings = None # settings.Settings
613 self.selection = None # selection.SelectionModel
614 self.fsmonitor = None # fsmonitor
615 self.view = None # QWidget
616 self.browser_windows = [] # list of browse.Browser
618 def set_view(self, view):
619 """Initialize view-specific members"""
620 self.view = view
621 self.runtask = qtutils.RunTask(parent=view)
624 def find_git():
625 """Return the path of git.exe, or None if we can't find it."""
626 if not utils.is_win32():
627 return None # UNIX systems have git in their $PATH
629 # If the user wants to use a Git/bin/ directory from a non-standard
630 # directory then they can write its location into
631 # ~/.config/git-cola/git-bindir
632 git_bindir = resources.config_home('git-bindir')
633 if core.exists(git_bindir):
634 custom_path = core.read(git_bindir).strip()
635 if custom_path and core.exists(custom_path):
636 return custom_path
638 # Try to find Git's bin/ directory in one of the typical locations
639 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
640 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
641 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
642 for p in [pf64, pf32, pf, 'C:\\']:
643 candidate = os.path.join(p, 'Git\\bin')
644 if os.path.isdir(candidate):
645 return candidate
647 return None
650 def prepend_path(path):
651 """Adds git to the PATH. This is needed on Windows."""
652 path = core.decode(path)
653 path_entries = core.getenv('PATH', '').split(os.pathsep)
654 if path not in path_entries:
655 path_entries.insert(0, path)
656 compat.setenv('PATH', os.pathsep.join(path_entries))