browse: display errors when saving blobs
[git-cola.git] / cola / app.py
blobc977efd67469ae7835a1132734dfab126a8fedda
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(err=error)
32 sys.exit(1)
34 from qtpy import QtWidgets
35 from qtpy.QtCore import Qt
37 try:
38 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
39 # imported before QApplication is constructed.
40 from qtpy import QtWebEngineWidgets # noqa
41 except ImportError:
42 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
43 pass
45 # Import cola modules
46 from .i18n import N_
47 from .interaction import Interaction
48 from .models import main
49 from .models import selection
50 from .widgets import cfgactions
51 from .widgets import standard
52 from .widgets import startup
53 from .settings import Session
54 from .settings import Settings
55 from . import cmds
56 from . import core
57 from . import compat
58 from . import fsmonitor
59 from . import git
60 from . import gitcfg
61 from . import guicmds
62 from . import hidpi
63 from . import icons
64 from . import i18n
65 from . import qtcompat
66 from . import qtutils
67 from . import resources
68 from . import themes
69 from . import utils
70 from . import version
73 def setup_environment():
74 """Set environment variables to control git's behavior"""
75 # Allow Ctrl-C to exit
76 signal.signal(signal.SIGINT, signal.SIG_DFL)
78 # Session management wants an absolute path when restarting
79 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
81 # Spoof an X11 display for SSH
82 os.environ.setdefault('DISPLAY', ':0')
84 if not core.getenv('SHELL', ''):
85 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
86 if os.path.exists(shell):
87 compat.setenv('SHELL', shell)
88 break
90 # Setup the path so that git finds us when we run 'git cola'
91 path_entries = core.getenv('PATH', '').split(os.pathsep)
92 bindir = core.decode(os.path.dirname(sys_argv0))
93 path_entries.append(bindir)
94 path = os.pathsep.join(path_entries)
95 compat.setenv('PATH', path)
97 # We don't ever want a pager
98 compat.setenv('GIT_PAGER', '')
100 # Setup *SSH_ASKPASS
101 git_askpass = core.getenv('GIT_ASKPASS')
102 ssh_askpass = core.getenv('SSH_ASKPASS')
103 if git_askpass:
104 askpass = git_askpass
105 elif ssh_askpass:
106 askpass = ssh_askpass
107 elif sys.platform == 'darwin':
108 askpass = resources.package_command('ssh-askpass-darwin')
109 else:
110 askpass = resources.package_command('ssh-askpass')
112 compat.setenv('GIT_ASKPASS', askpass)
113 compat.setenv('SSH_ASKPASS', askpass)
115 # --- >8 --- >8 ---
116 # Git v1.7.10 Release Notes
117 # =========================
119 # Compatibility Notes
120 # -------------------
122 # * From this release on, the "git merge" command in an interactive
123 # session will start an editor when it automatically resolves the
124 # merge for the user to explain the resulting commit, just like the
125 # "git commit" command does when it wasn't given a commit message.
127 # If you have a script that runs "git merge" and keeps its standard
128 # input and output attached to the user's terminal, and if you do not
129 # want the user to explain the resulting merge commits, you can
130 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
131 # this:
133 # #!/bin/sh
134 # GIT_MERGE_AUTOEDIT=no
135 # export GIT_MERGE_AUTOEDIT
137 # to disable this behavior (if you want your users to explain their
138 # merge commits, you do not have to do anything). Alternatively, you
139 # can give the "--no-edit" option to individual invocations of the
140 # "git merge" command if you know everybody who uses your script has
141 # Git v1.7.8 or newer.
142 # --- >8 --- >8 ---
143 # Longer-term: Use `git merge --no-commit` so that we always
144 # have a chance to explain our merges.
145 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
147 # Gnome3 on Debian has XDG_SESSION_TYPE=wayland and
148 # XDG_CURRENT_DESKTOP=GNOME, which Qt warns about at startup:
150 # Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
151 # Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
153 # Follow the advice and opt-in to QT_QPA_PLATFORM=wayland unless the user
154 # has a custom QT_QPA_PLATFORM.
156 # In the past we would compat.unsetenv('XDG_SESSION_TYPE') when
157 # XDG_CURRENT_DESKTOP == GNOME and XDG_SESSION_TYPE == wayland
158 # to silence the warning. This was before
159 # https://bugreports.qt.io/browse/QTBUG-68619 was resolved.
161 # These days we can opt-in instead.
162 if (
163 core.getenv('XDG_SESSION_TYPE', '') == 'wayland'
164 and not core.getenv('QT_QPA_PLATFORM', '')
166 compat.setenv('QT_QPA_PLATFORM', 'wayland')
169 def get_icon_themes(context):
170 """Return the default icon theme names"""
171 result = []
173 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
174 if icon_themes_env:
175 result.extend([x for x in icon_themes_env.split(':') if x])
177 icon_themes_cfg = list(reversed(context.cfg.get_all('cola.icontheme')))
178 if icon_themes_cfg:
179 result.extend(icon_themes_cfg)
181 if not result:
182 result.append('light')
184 return result
187 # style note: we use camelCase here since we're masquerading a Qt class
188 class ColaApplication(object):
189 """The main cola application
191 ColaApplication handles i18n of user-visible data
194 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
195 cfgactions.install()
196 i18n.install(locale)
197 qtcompat.install()
198 guicmds.install()
199 standard.install()
200 icons.install(icon_themes or get_icon_themes(context))
202 self.context = context
203 self.theme = None
204 self._install_hidpi_config()
205 self._app = ColaQApplication(context, list(argv))
206 self._app.setWindowIcon(icons.cola())
207 self._app.setDesktopFileName('git-cola')
208 self._install_style(gui_theme)
210 def _install_style(self, theme_str):
211 """Generate and apply a stylesheet to the app"""
212 if theme_str is None:
213 theme_str = self.context.cfg.get('cola.theme', default='default')
214 theme = themes.find_theme(theme_str)
215 self.theme = theme
216 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
217 if theme_str != 'default':
218 self._app.setPalette(theme.build_palette(self._app.palette()))
220 def _install_hidpi_config(self):
221 """Sets QT HIDPI scalling (requires Qt 5.6)"""
222 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
223 hidpi.apply_choice(value)
225 def activeWindow(self):
226 """QApplication::activeWindow() pass-through"""
227 return self._app.activeWindow()
229 def desktop(self):
230 """QApplication::desktop() pass-through"""
231 return self._app.desktop()
233 def palette(self):
234 """QApplication::palette() pass-through"""
235 return self._app.palette()
237 def start(self):
238 """Wrap exec_() and start the application"""
239 # Defer connection so that local cola.inotify is honored
240 context = self.context
241 monitor = context.fsmonitor
242 monitor.files_changed.connect(
243 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
245 monitor.config_changed.connect(
246 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
248 # Start the filesystem monitor thread
249 monitor.start()
250 return self._app.exec_()
252 def stop(self):
253 """Finalize the application"""
254 self.context.fsmonitor.stop()
255 # Workaround QTBUG-52988 by deleting the app manually to prevent a
256 # crash during app shutdown.
257 # https://bugreports.qt.io/browse/QTBUG-52988
258 try:
259 del self._app
260 except (AttributeError, RuntimeError):
261 pass
262 self._app = None
264 def exit(self, status):
265 """QApplication::exit(status) pass-through"""
266 return self._app.exit(status)
269 class ColaQApplication(QtWidgets.QApplication):
270 """QApplication implementation for handling custom events"""
272 def __init__(self, context, argv):
273 super(ColaQApplication, self).__init__(argv)
274 self.context = context
275 # Make icons sharp in HiDPI screen
276 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
277 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
279 def event(self, e):
280 """Respond to focus events for the cola.refreshonfocus feature"""
281 if e.type() == QtCore.QEvent.ApplicationActivate:
282 context = self.context
283 if context:
284 cfg = context.cfg
285 if context.git.is_valid() and cfg.get(
286 'cola.refreshonfocus', default=False
288 cmds.do(cmds.Refresh, context)
289 return super(ColaQApplication, self).event(e)
291 def commitData(self, session_mgr):
292 """Save session data"""
293 if not self.context or not self.context.view:
294 return
295 view = self.context.view
296 if not hasattr(view, 'save_state'):
297 return
298 sid = session_mgr.sessionId()
299 skey = session_mgr.sessionKey()
300 session_id = '%s_%s' % (sid, skey)
301 session = Session(session_id, repo=core.getcwd())
302 session.update()
303 view.save_state(settings=session)
306 def process_args(args):
307 """Process and verify command-line arguments"""
308 if args.version:
309 # Accept 'git cola --version' or 'git cola version'
310 version.print_version()
311 sys.exit(core.EXIT_SUCCESS)
313 # Handle session management
314 restore_session(args)
316 # Bail out if --repo is not a directory
317 repo = core.decode(args.repo)
318 if repo.startswith('file:'):
319 repo = repo[len('file:') :]
320 repo = core.realpath(repo)
321 if not core.isdir(repo):
322 errmsg = (
324 'fatal: "%s" is not a directory. '
325 'Please specify a correct --repo <path>.'
327 % repo
329 core.print_stderr(errmsg)
330 sys.exit(core.EXIT_USAGE)
333 def restore_session(args):
334 """Load a session based on the window-manager provided arguments"""
335 # args.settings is provided when restoring from a session.
336 args.settings = None
337 if args.session is None:
338 return
339 session = Session(args.session)
340 if session.load():
341 args.settings = session
342 args.repo = session.repo
345 def application_init(args, update=False):
346 """Parses the command-line arguments and starts git-cola"""
347 # Ensure that we're working in a valid git repository.
348 # If not, try to find one. When found, chdir there.
349 setup_environment()
350 process_args(args)
352 context = new_context(args)
353 timer = context.timer
354 timer.start('init')
356 new_worktree(context, args.repo, args.prompt)
358 if update:
359 context.model.update_status()
361 timer.stop('init')
362 if args.perf:
363 timer.display('init')
364 return context
367 def new_context(args):
368 """Create top-level ApplicationContext objects"""
369 context = ApplicationContext(args)
370 context.settings = args.settings or Settings.read()
371 context.git = git.create()
372 context.cfg = gitcfg.create(context)
373 context.fsmonitor = fsmonitor.create(context)
374 context.selection = selection.create()
375 context.model = main.create(context)
376 context.app = new_application(context, args)
377 context.timer = Timer()
379 return context
382 def application_run(context, view, start=None, stop=None):
383 """Run the application main loop"""
384 initialize_view(context, view)
385 # Startup callbacks
386 if start:
387 start(context, view)
388 # Start the event loop
389 result = context.app.start()
390 # Finish
391 if stop:
392 stop(context, view)
393 context.app.stop()
395 return result
398 def initialize_view(context, view):
399 """Register the main widget and display it"""
400 context.set_view(view)
401 view.show()
402 if sys.platform == 'darwin':
403 view.raise_()
406 def application_start(context, view):
407 """Show the GUI and start the main event loop"""
408 # Store the view for session management
409 return application_run(context, view, start=default_start, stop=default_stop)
412 def default_start(context, _view):
413 """Scan for the first time"""
414 QtCore.QTimer.singleShot(0, startup_message)
415 QtCore.QTimer.singleShot(0, lambda: async_update(context))
418 def default_stop(_context, _view):
419 """All done, cleanup"""
420 QtCore.QThreadPool.globalInstance().waitForDone()
423 def add_common_arguments(parser):
424 """Add command arguments to the ArgumentParser"""
425 # We also accept 'git cola version'
426 parser.add_argument(
427 '--version', default=False, action='store_true', help='print version number'
430 # Specifies a git repository to open
431 parser.add_argument(
432 '-r',
433 '--repo',
434 metavar='<repo>',
435 default=core.getcwd(),
436 help='open the specified git repository',
439 # Specifies that we should prompt for a repository at startup
440 parser.add_argument(
441 '--prompt', action='store_true', default=False, help='prompt for a repository'
444 # Specify the icon theme
445 parser.add_argument(
446 '--icon-theme',
447 metavar='<theme>',
448 dest='icon_themes',
449 action='append',
450 default=[],
451 help='specify an icon theme (name or directory)',
454 # Resume an X Session Management session
455 parser.add_argument(
456 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
459 # Enable timing information
460 parser.add_argument(
461 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
464 # Specify the GUI theme
465 parser.add_argument(
466 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
470 def new_application(context, args):
471 """Create a new ColaApplication"""
472 return ColaApplication(
473 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
477 def new_worktree(context, repo, prompt):
478 """Find a Git repository, or prompt for one when not found"""
479 model = context.model
480 cfg = context.cfg
481 parent = qtutils.active_window()
482 valid = False
484 if not prompt:
485 valid = model.set_worktree(repo)
486 if not valid:
487 # We are not currently in a git repository so we need to find one.
488 # Before prompting the user for a repository, check if they've
489 # configured a default repository and attempt to use it.
490 default_repo = cfg.get('cola.defaultrepo')
491 if default_repo:
492 valid = model.set_worktree(default_repo)
494 while not valid:
495 # If we've gotten into this loop then that means that neither the
496 # current directory nor the default repository were available.
497 # Prompt the user for a repository.
498 startup_dlg = startup.StartupDialog(context, parent)
499 gitdir = startup_dlg.find_git_repo()
500 if not gitdir:
501 sys.exit(core.EXIT_NOINPUT)
503 if not core.exists(os.path.join(gitdir, '.git')):
504 offer_to_create_repo(context, gitdir)
505 valid = model.set_worktree(gitdir)
506 continue
508 valid = model.set_worktree(gitdir)
509 if not valid:
510 err = model.error
511 standard.critical(
512 N_('Error Opening Repository'),
513 message=N_('Could not open %s.' % gitdir),
514 details=err,
518 def offer_to_create_repo(context, gitdir):
519 """Offer to create a new repo"""
520 title = N_('Repository Not Found')
521 text = N_('%s is not a Git repository.') % gitdir
522 informative_text = N_('Create a new repository at that location?')
523 if standard.confirm(title, text, informative_text, N_('Create')):
524 status, out, err = context.git.init(gitdir)
525 title = N_('Error Creating Repository')
526 if status != 0:
527 Interaction.command_error(title, 'git init', status, out, err)
530 def async_update(context):
531 """Update the model in the background
533 git-cola should startup as quickly as possible.
536 update_status = partial(context.model.update_status, update_index=True)
537 task = qtutils.SimpleTask(update_status)
538 context.runtask.start(task)
541 def startup_message():
542 """Print debug startup messages"""
543 trace = git.GIT_COLA_TRACE
544 if trace in ('2', 'trace'):
545 msg1 = 'info: debug level 2: trace mode enabled'
546 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
547 Interaction.log(msg1)
548 Interaction.log(msg2)
549 elif trace:
550 msg1 = 'info: debug level 1'
551 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
552 Interaction.log(msg1)
553 Interaction.log(msg2)
556 def initialize():
557 """System-level initialization"""
558 # We support ~/.config/git-cola/git-bindir on Windows for configuring
559 # a custom location for finding the "git" executable.
560 git_path = find_git()
561 if git_path:
562 prepend_path(git_path)
564 # The current directory may have been deleted while we are still
565 # in that directory. We rectify this situation by walking up the
566 # directory tree and retrying.
568 # This is needed because because Python throws exceptions in lots of
569 # stdlib functions when in this situation, e.g. os.path.abspath() and
570 # os.path.realpath(), so it's simpler to mitigate the damage by changing
571 # the current directory to one that actually exists.
572 while True:
573 try:
574 return core.getcwd()
575 except OSError:
576 os.chdir('..')
579 class Timer(object):
580 """Simple performance timer"""
582 def __init__(self):
583 self._data = {}
585 def start(self, key):
586 """Start a timer"""
587 now = time.time()
588 self._data[key] = [now, now]
590 def stop(self, key):
591 """Stop a timer and return its elapsed time"""
592 entry = self._data[key]
593 entry[1] = time.time()
594 return self.elapsed(key)
596 def elapsed(self, key):
597 """Return the elapsed time for a timer"""
598 entry = self._data[key]
599 return entry[1] - entry[0]
601 def display(self, key):
602 """Display a timer"""
603 elapsed = self.elapsed(key)
604 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
607 class NullArgs(object):
608 """Stub arguments for interactive API use"""
610 def __init__(self):
611 self.icon_themes = []
612 self.theme = None
613 self.settings = None
616 def null_args():
617 """Create a new instance of application arguments"""
618 return NullArgs()
621 class ApplicationContext(object):
622 """Context for performing operations on Git and related data models"""
624 def __init__(self, args):
625 self.args = args
626 self.app = None # ColaApplication
627 self.git = None # git.Git
628 self.cfg = None # gitcfg.GitConfig
629 self.model = None # main.MainModel
630 self.timer = None # Timer
631 self.runtask = None # qtutils.RunTask
632 self.settings = None # settings.Settings
633 self.selection = None # selection.SelectionModel
634 self.fsmonitor = None # fsmonitor
635 self.view = None # QWidget
636 self.browser_windows = [] # list of browse.Browser
638 def set_view(self, view):
639 """Initialize view-specific members"""
640 self.view = view
641 self.runtask = qtutils.RunTask(parent=view)
644 def find_git():
645 """Return the path of git.exe, or None if we can't find it."""
646 if not utils.is_win32():
647 return None # UNIX systems have git in their $PATH
649 # If the user wants to use a Git/bin/ directory from a non-standard
650 # directory then they can write its location into
651 # ~/.config/git-cola/git-bindir
652 git_bindir = resources.config_home('git-bindir')
653 if core.exists(git_bindir):
654 custom_path = core.read(git_bindir).strip()
655 if custom_path and core.exists(custom_path):
656 return custom_path
658 # Try to find Git's bin/ directory in one of the typical locations
659 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
660 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
661 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
662 for p in [pf64, pf32, pf, 'C:\\']:
663 candidate = os.path.join(p, 'Git\\bin')
664 if os.path.isdir(candidate):
665 return candidate
667 return None
670 def prepend_path(path):
671 """Adds git to the PATH. This is needed on Windows."""
672 path = core.decode(path)
673 path_entries = core.getenv('PATH', '').split(os.pathsep)
674 if path not in path_entries:
675 path_entries.insert(0, path)
676 compat.setenv('PATH', os.pathsep.join(path_entries))