Commits

Steve Borho committed 7f8169f Merge

Merge with default (Code Freeze for 2.5)

Comments (0)

Files changed (36)

contrib/nautilus-thg.py

         from tortoisehg.util import menuthg
         self.hgtk = paths.find_in_path(thg_main)
         self.menu = menuthg.menuThg()
-        self.notify = os.path.expanduser('~/.tortoisehg/notify')
 
-        f = open(self.notify, 'w')
-        f.close()
+        # Get the configuration directory path
+        try:
+            self.notify = os.environ['XDG_CONFIG_HOME']
+        except KeyError:
+            self.notify = os.path.join('$HOME', '.config')
+
+        self.notify = os.path.expandvars(os.path.join(
+            self.notify,
+            'TortoiseHg'))
+
+        # Create folder if it does not exist
+        if not os.path.isdir(self.notify):
+            os.makedirs(self.notify)
+
+        # Create the notify file
+        self.notify = os.path.join(self.notify, 'notify')
+        open(self.notify, 'w').close()
+
         self.gmon = Gio.file_new_for_path(self.notify).monitor(Gio.FileMonitorFlags.NONE, None)
         self.gmon.connect('changed', self.notified)
 

contrib/tortoisehg.spec

 %{!?python_sitelib: %global python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print get_python_lib()")}
 # Pure python package
-%define debug_package %{nil} 
+%define debug_package %{nil}
 
-Name:		tortoisehg
-Version:	hg
-Release:	hg
-Summary:	Mercurial GUI command line tool thg
-Group:		Development/Tools
-License:	GPLv2
+Name:       tortoisehg
+Version:    hg
+Release:    hg
+Summary:    Mercurial GUI command line tool thg
+Group:      Development/Tools
+License:    GPLv2
 # Few files are under the more permissive GPLv2+
-URL:		http://tortoisehg.org
-Source0:	%{name}-%{version}.tar.gz
-BuildRoot:	%{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)
-# This package should be noarch, but we can't do it because the nautilus
-# subpackage has to be arch-specific (because of lib64)
-# BuildArch:	noarch
-BuildRequires:	python, python-devel, gettext, python-sphinx
-BuildRequires:	PyQt4-devel
-Requires:	python >= 2.4, python-iniparse, mercurial >= 1.6
+URL:        http://tortoisehg.org
+Source0:    %{name}-%{version}.tar.gz
+BuildRoot:  %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)
+BuildArch:  noarch
+BuildRequires:  python, python-devel, gettext, python-sphinx
+BuildRequires:  PyQt4-devel
+Requires:   python >= 2.4, python-iniparse, mercurial >= 1.6
 # gconf needs at util/shlib.py for browse_url(url).
-Requires:	gnome-python2-gconf
-Requires:	PyQt4 >= 4.6, qscintilla-python
+Requires:   gnome-python2-gconf
+Requires:   PyQt4 >= 4.6, qscintilla-python
 
 %description
-This package contains the thg command line tool which provides a 
-graphical user interface to the Mercurial distributed revision control system. 
+This package contains the thg command line tool which provides a
+graphical user interface to the Mercurial distributed revision control system.
 
-%package	nautilus
-Summary:	Mercurial GUI plugin to Nautilus file manager 
-Group:		Development/Tools
-Requires:	%{name} = %{version}-%{release}, nautilus-python
+%package    nautilus
+Summary:    Mercurial GUI plugin to Nautilus file manager
+Group:      Development/Tools
+Requires:   %{name} = %{version}-%{release}, nautilus-python
 
-%description	nautilus
+%description    nautilus
 This package contains the TortoiseHg Gnome/Nautilus extension,
-which makes the Mercurial distributed revision control 
-system available in the file manager with a graphical interface. 
+which makes the Mercurial distributed revision control
+system available in the file manager with a graphical interface.
 
 %prep
 %setup -q
 
-# Fedora Nautilus python extensions lives in lib64 on x86_64 (https://bugzilla.redhat.com/show_bug.cgi?id=509633) ...
-%{__sed} -i "s,lib/nautilus,%{_lib}/nautilus,g" setup.py
-
 cat > tortoisehg/util/config.py << EOT
 bin_path     = "%{_bindir}"
 license_path = "%{_docdir}/%{name}-%{version}/COPYING.txt"
 %doc COPYING.txt doc/build/html/
 %{_bindir}/thg
 %{python_sitelib}/tortoisehg/
-%if "%{?pythonver}" > "2.4"
+%if "%{?python_version}" > "2.4"
 %{python_sitelib}/tortoisehg-*.egg-info
 %endif
 %{_datadir}/pixmaps/tortoisehg/
-%{_datadir}/pixmaps/%{name}_logo.svg
+%{_datadir}/pixmaps/thg_logo.svg
 %{_datadir}/applications/%{name}.desktop
 
 # /usr/share/zsh/site-functions/ is owned by zsh package which we don't want to
 
 %files nautilus
 %defattr(-,root,root,-)
-%{_libdir}/nautilus/extensions-2.0/python/nautilus-thg.py*
+%{_datadir}/nautilus-python/extensions/nautilus-thg.py*
 
 %changelog
              'description':'TortoiseHg GUI tools for Mercurial SCM',
              'copyright':thgcopyright,
              'product_version':version},
-            {'script':'contrib/hg', 
+            {'script':'contrib/hg',
              'icon_resources':[(0,'icons/hg.ico')],
              'description':'Mercurial Distributed SCM',
              'copyright':hgcopyright,
     _data_files += [(os.path.join('share', root),
         [os.path.join(root, file_) for file_ in files])
         for root, dirs, files in os.walk('locale')]
-    _data_files += [('lib/nautilus/extensions-2.0/python',
+    _data_files += [('/usr/share/nautilus-python/extensions/',
                      ['contrib/nautilus-thg.py'])]
 
     # Create a config.py.  Distributions will need to supply their own

tests/hglib_encoding_test.py

 def test_toutf_fallback():
     assert_equals(JAPANESE_KANA_I.encode('utf-8'),
                   hglib.toutf(JAPANESE_KANA_I.encode('euc-jp')))
+
+
+@helpers.with_encoding('ascii')
+def test_lossless_unicode_replaced():
+    l = hglib.fromunicode(JAPANESE_KANA_I, 'replace')
+    assert_equals('?', l)
+    assert_equals(JAPANESE_KANA_I, hglib.tounicode(l))
+
+@helpers.with_encoding('euc-jp')
+def test_lossless_unicode_double_mapped():
+    YEN = u'\u00a5'  # "yen" and "back-slash" are mapped to the same code
+    l = hglib.fromunicode(YEN)
+    assert_equals('\\', l)
+    assert_equals(YEN, hglib.tounicode(l))
+
+@helpers.with_encoding('ascii')
+def test_lossless_utf_replaced():
+    u = JAPANESE_KANA_I.encode('utf-8')
+    l = hglib.fromutf(u)
+    assert_equals('?', l)
+    assert_equals(u, hglib.toutf(l))
+
+@helpers.with_encoding('ascii')
+def test_lossless_utf_cannot_roundtrip():
+    u = JAPANESE_KANA_I.encode('cp932')  # bad encoding
+    l = hglib.fromutf(u)
+    assert_not_equals(u, hglib.toutf(l))

tests/run-tests.py

     r'^[._]',
     r'^setup\.py$',
     r'^TortoiseHgOverlayServer\.py$',
-    r'^prej\.py$',  # TODO broken module; maybe unused?
     # exclude platform-dependent modules
     r'^bugtraq\.py$',
     r'^shellconf\.py$',

tortoisehg/hgqt/chunks.py

         for name, desc, icon, key, tip, cb in [
             ('diff', _('Visual Diff'), 'visualdiff', 'Ctrl+D',
               _('View file changes in external diff tool'), self.vdiff),
-            ('edit', _('Edit Local'), 'edit-file', 'Shift+Ctrl+E',
+            ('edit', _('Edit Local'), 'edit-file', 'Shift+Ctrl+L',
               _('Edit current file in working copy'), self.editCurrentFile),
-            ('revert', _('Revert to Revision'), 'hg-revert', 'Alt+Ctrl+T',
+            ('revert', _('Revert to Revision'), 'hg-revert', 'Shift+Ctrl+R',
               _('Revert file(s) to contents at this revision'),
               self.revertfile),
             ]:

tortoisehg/hgqt/cmdui.py

 # This software may be used and distributed according to the terms of the
 # GNU General Public License version 2, incorporated herein by reference.
 
-import os, glob, shlex, sys, time
+import os, sys, time
 
 from PyQt4.QtCore import *
 from PyQt4.QtGui import *
 from PyQt4.Qsci import QsciScintilla
 
-from mercurial import util
-
 from tortoisehg.util import hglib, paths
 from tortoisehg.hgqt.i18n import _, localgettext
 from tortoisehg.hgqt import qtlib, qscilib, thread
         return iter(self._markers[l] for l in str(label).split()
                     if l in self._markers)
 
-class _LogWidgetForConsole(LogWidget):
-    """Wrapped LogWidget for ConsoleWidget"""
-
-    returnPressed = pyqtSignal(unicode)
-    """Return key pressed when cursor is on prompt line"""
-
-    _prompt = '% '
-
-    def __init__(self, parent=None):
-        super(_LogWidgetForConsole, self).__init__(parent)
-        self._prompt_marker = self.markerDefine(QsciScintilla.Background)
-        self.setMarkerBackgroundColor(QColor('#e8f3fe'), self._prompt_marker)
-        self.cursorPositionChanged.connect(self._updatePrompt)
-
-    def keyPressEvent(self, event):
-        if event.key() in (Qt.Key_Return, Qt.Key_Enter):
-            if self._cursoronpromptline():
-                self.returnPressed.emit(self.commandText())
-            return
-        super(_LogWidgetForConsole, self).keyPressEvent(event)
-
-    def setPrompt(self, text):
-        if text == self._prompt:
-            return
-        self.clearPrompt()
-        self._prompt = text
-        self.openPrompt()
-
-    @pyqtSlot()
-    def openPrompt(self):
-        """Show prompt line and enable user input"""
-        self.closePrompt()
-        self.markerAdd(self.lines() - 1, self._prompt_marker)
-        self.append(self._prompt)
-        self.setCursorPosition(self.lines() - 1, len(self._prompt))
-        self.setReadOnly(False)
-
-        # make sure the prompt line is visible. Because QsciScintilla may
-        # delay line wrapping, setCursorPosition() doesn't always scrolls
-        # to the correct position.
-        # http://www.scintilla.org/ScintillaDoc.html#LineWrapping
-        self.SCN_PAINTED.connect(self._scrollCaretOnPainted)
-
-    @pyqtSlot()
-    def _scrollCaretOnPainted(self):
-        self.SCN_PAINTED.disconnect(self._scrollCaretOnPainted)
-        self.SendScintilla(self.SCI_SCROLLCARET)
-
-    @pyqtSlot()
-    def closePrompt(self):
-        """Disable user input"""
-        if self.commandText():
-            self._setmarker((self.lines() - 1,), 'control')
-        self.markerDelete(self.lines() - 1, self._prompt_marker)
-        self._newline()
-        self.setCursorPosition(self.lines() - 1, 0)
-        self.setReadOnly(True)
-
-    @pyqtSlot()
-    def clearPrompt(self):
-        """Clear prompt line"""
-        line = self.lines() - 1
-        if not (self.markersAtLine(line) & (1 << self._prompt_marker)):
-            return
-        self.markerDelete(line)
-        self.setSelection(line, 0, line, self.lineLength(line))
-        self.removeSelectedText()
-
-    @pyqtSlot(int, int)
-    def _updatePrompt(self, line, pos):
-        """Update availability of user input"""
-        if self.markersAtLine(line) & (1 << self._prompt_marker):
-            self.setReadOnly(False)
-            self._ensurePrompt(line)
-        else:
-            self.setReadOnly(True)
-
-    def _ensurePrompt(self, line):
-        """Insert prompt string if not available"""
-        s = unicode(self.text(line))
-        if s.startswith(self._prompt):
-            return
-        for i, c in enumerate(self._prompt):
-            if s[i:i + 1] != c:
-                self.insertAt(self._prompt[i:], line, i)
-                break
-        self.setCursorPosition(line, self.lineLength(line))
-
-    def commandText(self):
-        """Return the current command text"""
-        l = self.lines() - 1
-        if self.markersAtLine(l) & (1 << self._prompt_marker):
-            return self.text(l)[len(self._prompt):]
-        else:
-            return ''
-
-    def _newline(self):
-        if self.lineLength(self.lines() - 1) > 0:
-            self.append('\n')
-
-    def _cursoronpromptline(self):
-        line = self.getCursorPosition()[0]
-        return self.markersAtLine(line) & (1 << self._prompt_marker)
-
-class _ConsoleCmdTable(dict):
-    """Command table for ConsoleWidget"""
-    _cmdfuncprefix = '_cmd_'
-
-    def __call__(self, func):
-        if not func.__name__.startswith(self._cmdfuncprefix):
-            raise ValueError('bad command function name %s' % func.__name__)
-        self[func.__name__[len(self._cmdfuncprefix):]] = func
-        return func
-
-class ConsoleWidget(QWidget):
-    """Console to run hg/thg command and show output"""
-    closeRequested = pyqtSignal()
-
-    progressReceived = pyqtSignal(QString, object, QString, QString,
-                                  object, object)
-    """Emitted when progress received
-
-    Args: topic, pos, item, unit, total, reporoot
-    """
-
-    _cmdtable = _ConsoleCmdTable()
-
-    # TODO: command history and completion
-
-    def __init__(self, parent=None):
-        super(ConsoleWidget, self).__init__(parent)
-        self.setLayout(QVBoxLayout())
-        self.layout().setContentsMargins(0, 0, 0, 0)
-        self._initlogwidget()
-        self.setFocusProxy(self._logwidget)
-        self.setRepository(None)
-        self.openPrompt()
-        self.suppressPrompt = False
-
-    def _initlogwidget(self):
-        self._logwidget = _LogWidgetForConsole(self)
-        self._logwidget.returnPressed.connect(self._runcommand)
-        self.layout().addWidget(self._logwidget)
-
-        # compatibility methods with LogWidget
-        for name in ('openPrompt', 'closePrompt', 'clear'):
-            setattr(self, name, getattr(self._logwidget, name))
-
-    @util.propertycache
-    def _cmdcore(self):
-        cmdcore = Core(False, self)
-        cmdcore.output.connect(self._logwidget.appendLog)
-        cmdcore.commandStarted.connect(self.closePrompt)
-        cmdcore.commandFinished.connect(self.openPrompt)
-        cmdcore.progress.connect(self._emitProgress)
-        return cmdcore
-
-    @util.propertycache
-    def _extproc(self):
-        extproc = QProcess(self)
-        extproc.started.connect(self.closePrompt)
-        extproc.finished.connect(self.openPrompt)
-
-        def handleerror(error):
-            msgmap = {
-                QProcess.FailedToStart: _('failed to run command\n'),
-                QProcess.Crashed: _('crashed\n')}
-            if extproc.state() == QProcess.NotRunning:
-                self._logwidget.closePrompt()
-            self._logwidget.appendLog(
-                msgmap.get(error, _('error while running command\n')),
-                'ui.error')
-            if extproc.state() == QProcess.NotRunning:
-                self._logwidget.openPrompt()
-        extproc.error.connect(handleerror)
-
-        def put(bytes, label=None):
-            self._logwidget.appendLog(hglib.tounicode(bytes.data()), label)
-        extproc.readyReadStandardOutput.connect(
-            lambda: put(extproc.readAllStandardOutput()))
-        extproc.readyReadStandardError.connect(
-            lambda: put(extproc.readAllStandardError(), 'ui.error'))
-
-        return extproc
-
-    @pyqtSlot(unicode, str)
-    def appendLog(self, msg, label):
-        """Append log text from another cmdui"""
-        self._logwidget.clearPrompt()
-        try:
-            self._logwidget.appendLog(msg, label)
-        finally:
-            if not self.suppressPrompt:
-                self.openPrompt()
-
-    @pyqtSlot(object)
-    def setRepository(self, repo):
-        """Change the current working repository"""
-        self._repo = repo
-        self._logwidget.setPrompt('%s%% ' % (repo and repo.displayname or ''))
-
-    @property
-    def cwd(self):
-        """Return the current working directory"""
-        return self._repo and self._repo.root or os.getcwd()
-
-    @pyqtSlot(unicode, object, unicode, unicode, object)
-    def _emitProgress(self, *args):
-        self.progressReceived.emit(
-            *(args + (self._repo and self._repo.root or None,)))
-
-    @pyqtSlot(unicode)
-    def _runcommand(self, cmdline):
-        try:
-            args = list(self._parsecmdline(cmdline))
-        except ValueError, e:
-            self.closePrompt()
-            self._logwidget.appendLog(unicode(e) + '\n', 'ui.error')
-            self.openPrompt()
-            return
-        if not args:
-            self.openPrompt()
-            return
-        cmd = args.pop(0)
-        try:
-            self._cmdtable[cmd](self, args)
-        except KeyError:
-            return self._runextcommand(cmdline)
-
-    def _parsecmdline(self, cmdline):
-        """Split command line string to imitate a unix shell"""
-        try:
-            args = shlex.split(hglib.fromunicode(cmdline))
-        except ValueError, e:
-            raise ValueError(_('command parse error: %s') % e)
-        for e in args:
-            e = util.expandpath(e)
-            if util.any(c in e for c in '*?[]'):
-                expanded = glob.glob(os.path.join(self.cwd, e))
-                if not expanded:
-                    raise ValueError(_('no matches found: %s')
-                                     % hglib.tounicode(e))
-                for p in expanded:
-                    yield p
-            else:
-                yield e
-
-    def _runextcommand(self, cmdline):
-        self._extproc.setWorkingDirectory(hglib.tounicode(self.cwd))
-        self._extproc.start(cmdline, QIODevice.ReadOnly)
-
-    @_cmdtable
-    def _cmd_hg(self, args):
-        self.closePrompt()
-        if self._repo:
-            args = ['--cwd', self._repo.root] + args
-        self._cmdcore.run(args)
-
-    @_cmdtable
-    def _cmd_thg(self, args):
-        from tortoisehg.hgqt import run
-        self.closePrompt()
-        try:
-            if self._repo:
-                args = ['-R', self._repo.root] + args
-            # TODO: show errors
-            run.dispatch(args)
-        finally:
-            self.openPrompt()
-
-    @_cmdtable
-    def _cmd_clear(self, args):
-        self.clear()
-        self.openPrompt()
-
-    @_cmdtable
-    def _cmd_cls(self, args):
-        self.clear()
-        self.openPrompt()
-
-    @_cmdtable
-    def _cmd_exit(self, args):
-        self.clear()
-        self.openPrompt()
-        self.closeRequested.emit()
-
 
 class Widget(QWidget):
     """An embeddable widget for running Mercurial command"""

tortoisehg/hgqt/commit.py

 
 from mercurial import ui, util, error, scmutil, phases
 
-from tortoisehg.util import hglib, shlib, wconfig, bugtraq
+from tortoisehg.util import hglib, shlib, wconfig
 
 from tortoisehg.hgqt.i18n import _
 from tortoisehg.hgqt.messageentry import MessageEntry
 from tortoisehg.hgqt import qtlib, qscilib, status, cmdui, branchop, revpanel
 from tortoisehg.hgqt import hgrcutil, mq, lfprompt, i18n
-from tortoisehg.util.hgversion import hgversion
 
 from PyQt4.QtCore import *
 from PyQt4.QtGui import *
 from PyQt4.Qsci import QsciAPIs
 
+if os.name == 'nt':
+    from tortoisehg.util import bugtraq
+    _hasbugtraq = True
+else:
+    _hasbugtraq = False
 
 # Technical Debt for CommitWidget
 #  disable commit button while no message is entered or no files are selected
         tbar.addAction(_('Options')).triggered.connect(self.details)
         tbar.setIconSize(QSize(16,16))
 
-        if self.opts['bugtraqplugin'] != None:
+        if _hasbugtraq and self.opts['bugtraqplugin'] != None:
             # We create the "Show Issues" button, but we delay its setup
             # because creating the bugtraq object is slow and blocks the GUI,
             # which would result in a noticeable slow down while creating the commit widget
     def commitSetupButton(self):
         ispatch = lambda r: 'qtip' in r.changectx('.').tags()
         notpatch = lambda r: 'qtip' not in r.changectx('.').tags()
-        canamend = lambda r: False
-        # hg >= 2.2 has amend capabilities
-        if hgversion >= '2.2':
-            def canamend(r):
-                if ispatch(r):
-                    return False
-                ctx = r.changectx('.')
-                return not ctx.children() \
-                    and ctx.phase() != phases.public \
-                    and len(ctx.parents()) < 2 \
-                    and len(r.changectx(None).parents()) < 2
+        def canamend(r):
+            if ispatch(r):
+                return False
+            ctx = r.changectx('.')
+            return not ctx.children() \
+                and ctx.phase() != phases.public \
+                and len(ctx.parents()) < 2 \
+                and len(r.changectx(None).parents()) < 2
 
         acts = [
             ('commit', _('Commit changes'), _('Commit'), notpatch),
         self.msgte.lexer().setAPIs(self._apis)
 
     def bugTrackerPostCommit(self):
-        if self.opts['bugtraqtrigger'] != 'commit':
+        if not _hasbugtraq or self.opts['bugtraqtrigger'] != 'commit':
             return
         # commit already happened, get last message in history
         message = self.lastmessage
         else:
             merge = False
             self.files = self.stwidget.getChecked('MAR?!S')
-        if not (self.files or brcmd or newbranch or amend or merge):
+        canemptycommit = bool(brcmd or newbranch or amend)
+        if not (self.files or canemptycommit or merge):
             qtlib.WarningMsgBox(_('No files checked'),
                                 _('No modified files checkmarked for commit'),
                                 parent=self)
         if amend:
             cmdline.append('--amend')
 
-        if not self.files and (brcmd or newbranch or amend) and not merge:
+        if not self.files and canemptycommit and not merge:
             # make sure to commit empty changeset by excluding all files
             cmdline.extend(['--exclude', repo.root])
 
         self.commit.reload()
         self.updateUndo()
         self.commit.msgte.setFocus()
-        QShortcut(QKeySequence.Refresh, self, self.refresh)
+        qtlib.newshortcutsforstdkey(QKeySequence.Refresh, self, self.refresh)
 
     def done(self, ret):
         self.commit.repo.configChanged.disconnect(self.commit.configChanged)

tortoisehg/hgqt/customtools.py

+# customtools.py - Settings panel and configuration dialog for TortoiseHg custom tools
+#
+# This module implements 3 main classes:
+#
+# 1. A ToolsFrame which is meant to be shown on the settings dialog
+# 2. A ToolList widget, part of the ToolsFrame, showing a list of
+#    configured custom tools
+# 3. A CustomToolConfigDialog, that can be used to add a new or
+#    edit an existing custom tool
+#
+# The ToolsFrame and specially the ToolList must implement some methods
+# which are common to all settings widgets.
+#
+# Copyright 2012 Angel Ezquerra <angel.ezquerra@gmail.com>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2, incorporated herein by reference.
+
+from mercurial import ui
+
+from tortoisehg.hgqt.i18n import _
+from tortoisehg.hgqt import qtlib
+from tortoisehg.util import hglib
+
+from PyQt4.QtCore import *
+from PyQt4.QtGui import *
+
+
+class ToolsFrame(QFrame):
+    def __init__(self, ini, parent=None, **opts):
+        QFrame.__init__(self, parent, **opts)
+        self.widgets = []
+        self.ini = ini
+        self.tortoisehgtools, guidef = hglib.tortoisehgtools(self.ini)
+        self.setValue(self.tortoisehgtools)
+
+        # The frame has a header and 3 columns:
+        # - The header shows a combo with the list of locations
+        # - The columns show:
+        #     - The current location tool list and its associated buttons
+        #     - The add to list button
+        #     - The "available tools" list and its associated buttons
+        topvbox = QVBoxLayout()
+        self.setLayout(topvbox)
+
+        topvbox.addWidget(QLabel(_('Select a GUI location to edit:')))
+
+        self.locationcombo = QComboBox(self,
+            toolTip='Select the toolbar or menu to change')
+
+        def selectlocation(index):
+            location = self.locationcombo.itemText(index)
+            for widget in self.widgets:
+                if widget.location == location:
+                    widget.removeInvalid(self.value())
+                    widget.show()
+                else:
+                    widget.hide()
+        self.locationcombo.currentIndexChanged.connect(selectlocation)
+        topvbox.addWidget(self.locationcombo)
+
+        hbox = QHBoxLayout()
+        topvbox.addLayout(hbox)
+        vbox = QVBoxLayout()
+
+        self.globaltoollist = ToolListBox(self.ini, minimumwidth=100,
+                                          parent=self)
+        self.globaltoollist.doubleClicked.connect(self.editToolItem)
+
+        vbox.addWidget(QLabel(_('Tools shown on selected location')))
+        for location in hglib.tortoisehgtoollocations:
+            self.locationcombo.addItem(location)
+            toollist = ToolListBox(self.ini, location=location,
+                minimumwidth=100, parent=self)
+            toollist.doubleClicked.connect(self.editToolFromName)
+            vbox.addWidget(toollist)
+            toollist.hide()
+            self.widgets.append(toollist)
+
+        deletefromlistbutton = QPushButton(_('Delete from list'), self)
+        deletefromlistbutton.clicked.connect(
+            lambda: self.forwardToCurrentToolList('deleteTool', remove=False))
+        vbox.addWidget(deletefromlistbutton)
+        hbox.addLayout(vbox)
+
+        vbox = QVBoxLayout()
+        vbox.addWidget(QLabel('')) # to align all lists
+        addtolistbutton = QPushButton('<< ' + _('Add to list') + ' <<', self)
+        addtolistbutton.clicked.connect(self.addToList)
+        addseparatorbutton = QPushButton('<< ' + _('Add separator'), self)
+        addseparatorbutton.clicked.connect(
+            lambda: self.forwardToCurrentToolList('addSeparator'))
+
+        vbox.addWidget(addtolistbutton)
+        vbox.addWidget(addseparatorbutton)
+        vbox.addStretch()
+        hbox.addLayout(vbox)
+
+        vbox = QVBoxLayout()
+        vbox.addWidget(QLabel(_('List of all tools')))
+        vbox.addWidget(self.globaltoollist)
+        newbutton = QPushButton(_('New Tool ...'), self)
+        newbutton.clicked.connect(self.newTool)
+        editbutton = QPushButton(_('Edit Tool ...'), self)
+        editbutton.clicked.connect(lambda: self.editTool(row=None))
+        deletebutton = QPushButton(_('Delete Tool'), self)
+        deletebutton.clicked.connect(self.deleteCurrentTool)
+
+        vbox.addWidget(newbutton)
+        vbox.addWidget(editbutton)
+        vbox.addWidget(deletebutton)
+        hbox.addLayout(vbox)
+
+        # Ensure that the first location list is shown
+        selectlocation(0)
+
+    def getCurrentToolList(self):
+        index = self.locationcombo.currentIndex()
+        location = self.locationcombo.itemText(index)
+        for widget in self.widgets:
+            if widget.location == location:
+                return widget
+        return None
+
+    def addToList(self):
+        gtl = self.globaltoollist
+        row = gtl.currentIndex().row()
+        if row < 0:
+            row = 0
+        item = gtl.item(row)
+        if item is None:
+            return
+        toolname = item.text()
+        self.forwardToCurrentToolList('addOrInsertItem', toolname)
+
+    def forwardToCurrentToolList(self, funcname, *args, **opts):
+        w = self.getCurrentToolList()
+        if w is not None:
+            getattr(w, funcname)(*args, **opts)
+        return None
+
+    def newTool(self):
+        td = CustomToolConfigDialog(self)
+        res = td.exec_()
+        if res:
+            toolname, toolconfig = td.value()
+            self.globaltoollist.addOrInsertItem(toolname)
+            self.tortoisehgtools[toolname] = toolconfig
+
+    def editTool(self, row=None):
+        gtl = self.globaltoollist
+        if row is None:
+            row = gtl.currentIndex().row()
+        if row < 0:
+            return self.newTool()
+        else:
+            item = gtl.item(row)
+            toolname = item.text()
+            td = CustomToolConfigDialog(
+                self, toolname=toolname,
+                toolconfig=self.tortoisehgtools[str(toolname)])
+            res = td.exec_()
+            if res:
+                toolname, toolconfig = td.value()
+                gtl.takeItem(row)
+                gtl.insertItem(row, toolname)
+                gtl.setCurrentRow(row)
+                self.tortoisehgtools[toolname] = toolconfig
+
+    def editToolItem(self, item):
+        self.editTool(item.row())
+
+    def editToolFromName(self, name):
+        # [TODO] connect to toollist doubleClick (not global)
+        gtl = self.globaltoollist
+        if name == gtl.SEPARATOR:
+            return
+        guidef = gtl.values()
+        for row, toolname in enumerate(guidef):
+            if toolname == name:
+                self.editTool(row)
+                return
+
+    def deleteCurrentTool(self):
+        row = self.globaltoollist.currentIndex().row()
+        if row >= 0:
+            item = self.globaltoollist.item(row)
+            itemtext = str(item.text())
+            self.globaltoollist.deleteTool(row=row)
+
+            self.deleteTool(itemtext)
+            self.forwardToCurrentToolList('removeInvalid', self.value())
+
+    def deleteTool(self, name):
+        try:
+            del self.tortoisehgtools[name]
+        except KeyError:
+            pass
+
+    def applyChanges(self, ini):
+        # widget.value() returns the _NEW_ values
+        # widget.curvalue returns the _ORIGINAL_ values (yes, this is a bit
+        # misleading! "cur" means "current" as in currently valid)
+        def updateIniValue(section, key, newvalue):
+            section = hglib.fromunicode(section)
+            key = hglib.fromunicode(key)
+            try:
+                del ini[section][key]
+            except KeyError:
+                pass
+            if newvalue is not None:
+                ini.set(section, key, newvalue)
+
+        emitChanged = False
+        if not self.isDirty():
+            return emitChanged
+
+        emitChanged = True
+        # 1. Save the new tool configurations
+        #
+        # In order to keep the tool order we must delete all existing
+        # custom tool configurations, and then set all the configuration
+        # settings anew:
+        section = 'tortoisehg-tools'
+        fieldnames = ('command', 'label', 'tooltip',
+                      'icon', 'location', 'enable', 'showoutput',)
+        for name in self.curvalue:
+            for field in fieldnames:
+                try:
+                    keyname = '%s.%s' % (name, field)
+                    del ini[section][keyname]
+                except KeyError:
+                    pass
+
+        tools = self.value()
+        for uname in tools:
+            name = hglib.fromunicode(uname)
+            if name[0] in '|-':
+                continue
+            for field in sorted(tools[name]):
+                keyname = '%s.%s' % (name, field)
+                value = tools[name][field]
+                if not value is '':
+                    ini.set(section, keyname, value)
+
+        # 2. Save the new guidefs
+        for n, toollistwidget in enumerate(self.widgets):
+            toollocation = self.locationcombo.itemText(n)
+            if not toollistwidget.isDirty():
+                continue
+            emitChanged = True
+            toollist = toollistwidget.value()
+
+            updateIniValue('tortoisehg', toollocation, ' '.join(toollist))
+
+        return emitChanged
+
+    ## common APIs for all edit widgets
+    def setValue(self, curvalue):
+        self.curvalue = dict(curvalue)
+
+    def value(self):
+        return self.tortoisehgtools
+
+    def isDirty(self):
+        for toollistwidget in self.widgets:
+            if toollistwidget.isDirty():
+                return True
+        if self.globaltoollist.isDirty():
+            return True
+        return self.tortoisehgtools != self.curvalue
+
+    def refresh(self):
+        self.tortoisehgtools, guidef = hglib.tortoisehgtools(self.ini)
+        self.setValue(self.tortoisehgtools)
+        self.globaltoollist.refresh()
+        for w in self.widgets:
+            w.refresh()
+
+
+class ToolListBox(QListWidget):
+    SEPARATOR = '------'
+    def __init__(self, ini, parent=None, location=None, minimumwidth=None,
+                 **opts):
+        QListWidget.__init__(self, parent, **opts)
+        self.opts = opts
+        self.curvalue = None
+        self.ini = ini
+        self.location = location
+
+        if minimumwidth:
+            self.setMinimumWidth(minimumwidth)
+
+        self.refresh()
+
+        # Enable drag and drop to reorder the tools
+        self.setDragEnabled(True)
+        self.setDragDropMode(self.InternalMove)
+        self.setDefaultDropAction(Qt.MoveAction)
+
+    def _guidef2toollist(self, guidef):
+        toollist = []
+        for name in guidef:
+            if name == '|':
+                name = self.SEPARATOR
+                # avoid putting multiple separators together
+                if [name] == toollist[-1:]:
+                    continue
+            toollist.append(name)
+        return toollist
+
+    def _toollist2guidef(self, toollist):
+        guidef = []
+        for uname in toollist:
+            if uname == self.SEPARATOR:
+                name = '|'
+                # avoid putting multiple separators together
+                if [name] == toollist[-1:]:
+                    continue
+            else:
+                name = hglib.fromunicode(uname)
+            guidef.append(name)
+        return guidef
+
+    def addOrInsertItem(self, text):
+        row = self.currentIndex().row()
+        if row < 0:
+            self.addItem(text)
+            self.setCurrentRow(self.count()-1)
+        else:
+            self.insertItem(row+1, text)
+            self.setCurrentRow(row+1)
+
+    def deleteTool(self, row=None, remove=False):
+        if row is None:
+            row = self.currentIndex().row()
+        if row >= 0:
+            self.takeItem(row)
+
+    def addSeparator(self):
+        self.addOrInsertItem(self.SEPARATOR)
+
+    def values(self):
+        out = []
+        for row in range(self.count()):
+            out.append(self.item(row).text())
+        return out
+
+    ## common APIs for all edit widgets
+    def setValue(self, curvalue):
+        self.curvalue = curvalue
+
+    def value(self):
+        return self._toollist2guidef(self.values())
+
+    def isDirty(self):
+        return self.value() != self.curvalue
+
+    def refresh(self):
+        toolsdefs, guidef = hglib.tortoisehgtools(self.ini,
+            selectedlocation=self.location)
+        self.toollist = self._guidef2toollist(guidef)
+        self.setValue(guidef)
+        self.clear()
+        self.addItems(self.toollist)
+
+    def removeInvalid(self, validtools):
+        validguidef = []
+        for toolname in self.value():
+            if toolname[0] not in '|-':
+                if toolname not in validtools:
+                    continue
+            validguidef.append(toolname)
+        self.setValue(validguidef)
+        self.clear()
+        self.addItems(self._guidef2toollist(validguidef))
+
+class CustomToolConfigDialog(QDialog):
+    'Dialog for editing the a custom tool configuration'
+
+    _enablemappings = {'All items': 'istrue',
+                        'Working Directory': 'iswd',
+                        'All revisions': 'isrev',
+                        'All contexts': 'isctx',
+                        'Fixed revisions': 'fixed',
+                        'Applied patches': 'applied',
+                        'qgoto': 'qgoto'}
+
+    def __init__(self, parent=None, toolname=None, toolconfig={}):
+        QDialog.__init__(self, parent)
+
+        self.setWindowIcon(qtlib.geticon('tools-spanner-hammer'))
+        self.setWindowTitle('Configure Custom Tool')
+        self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
+
+        self.hbox = QHBoxLayout()
+        vbox = QVBoxLayout()
+
+        command = toolconfig.get('command', '')
+        label = toolconfig.get('label', '')
+        tooltip = toolconfig.get('tooltip', '')
+        ico = toolconfig.get('icon', '')
+        enable = toolconfig.get('enable', 'all')
+        showoutput = str(toolconfig.get('showoutput', False))
+
+        self.name = self._addConfigItem(vbox, _('Tool name'),
+            QLineEdit(toolname), _('The tool name. It cannot contain spaces.'))
+            # Execute a mercurial command. These _MUST_ start with "hg"
+        self.command = self._addConfigItem(vbox, _('Command'),
+            QLineEdit(command), _('The command that will be executed.\n'
+            'To execute a mercurial command use "hg" (rather than "hg.exe") '
+            'as the executable command.\n'
+            'You can use {ROOT} as an alias of the current repository root and\n'
+            '{REV} as an alias of the selected revision.'))
+        self.label = self._addConfigItem(vbox, _('Tool label'),
+            QLineEdit(label),
+            _('The tool label, which is what will be shown '
+            'on the repowidget context menu.\n'
+            'If no label is set, the tool name will be used as the tool label.\n'
+            'If no tooltip is set, the label will be used as the tooltip as well.'))
+        self.tooltip = self._addConfigItem(vbox, _('Tooltip'),
+            QLineEdit(tooltip),
+            _('The tooltip that will be shown on the tool button.\n'
+            'This is only shown when the tool button is shown on\n'
+            'the workbench toolbar.'))
+        self.icon = self._addConfigItem(vbox, _('Icon'),
+            QLineEdit(ico),
+            _('The tool icon.\n'
+            'You can use any built-in TortoiseHg icon\n'
+            'by setting this value to a vaild TortoiseHg icon name\n'
+            '(e.g. clone, add, remove, sync, thg-logo, hg-update, etc).\n'
+            'You can also set this value to the absolute path to\n'
+            'any icon on your file system.'))
+
+        combo = self._genCombo(self._enablemappings.keys(),
+            self._enable2label(enable), 'All items')
+        self.enable = self._addConfigItem(vbox, _('On repowidget, show for'),
+            combo,  _('For which kinds of revisions the tool will be enabled\n'
+            'It is only taken into account when the tool is shown on the\n'
+            'selected revision context menu.'))
+
+        combo = self._genCombo(('True', 'False'), showoutput)
+        self.showoutput = self._addConfigItem(vbox, _('Show Output Log'),
+            combo, _('When enabled, automatically show the Output Log when the '
+            'command is run.\nDefault: False.'))
+
+        self.hbox.addLayout(vbox)
+        vbox = QVBoxLayout()
+        self.okbutton = QPushButton(_('OK'))
+        self.okbutton.clicked.connect(self.okClicked)
+        vbox.addWidget(self.okbutton)
+        self.cancelbutton = QPushButton(_('Cancel'))
+        self.cancelbutton.clicked.connect(self.reject)
+        vbox.addWidget(self.cancelbutton)
+        vbox.addStretch()
+        self.hbox.addLayout(vbox)
+        self.setLayout(self.hbox)
+
+    def value(self):
+        toolname = str(self.name.text()).strip()
+        toolconfig = {
+            'label': str(self.label.text()),
+            'command': str(self.command.text()),
+            'tooltip': str(self.tooltip.text()),
+            'icon': str(self.icon.text()),
+            'enable': self._enablemappings[str(self.enable.currentText())],
+            'showoutput': str(self.showoutput.currentText()),
+        }
+        return toolname, toolconfig
+
+    def _genCombo(self, items, selecteditem=None, defaultitem=None):
+        index = 0
+        if selecteditem:
+            try:
+                index = items.index(selecteditem)
+            except:
+                if defaultitem:
+                    try:
+                        index = items.index(defaultitem)
+                    except:
+                        pass
+        combo = QComboBox()
+        combo.addItems(items)
+        if index:
+            combo.setCurrentIndex(index)
+        return combo
+
+    def _addConfigItem(self, parent, label, configwidget, tooltip=None):
+        if tooltip:
+            configwidget.setToolTip(tooltip)
+        hbox = QHBoxLayout()
+        hbox.addWidget(QLabel(label))
+        hbox.addWidget(configwidget)
+        parent.addLayout(hbox)
+        return configwidget
+
+    def _enable2label(self, label):
+        return self._dictvalue2key(self._enablemappings, label)
+
+    def _dictvalue2key(self, dictionary, value):
+        for key in dictionary:
+            if value == dictionary[key]:
+                return key
+        return None
+
+    def okClicked(self):
+        errormsg = self.validateForm()
+        if errormsg:
+            qtlib.WarningMsgBox(_('Missing information'), errormsg)
+            return
+        return self.accept()
+
+    def validateForm(self):
+        name, config = self.value()
+        if not name:
+            return _('You must set a tool name.')
+        if name.find(' ') >= 0:
+            return _('The tool name cannot have any spaces in it.')
+        if not config['command']:
+            return _('You must set a command to run.')
+        return '' # No error

tortoisehg/hgqt/docklog.py

 # This software may be used and distributed according to the terms of the
 # GNU General Public License version 2 or any later version.
 
-from tortoisehg.hgqt.i18n import _
-from tortoisehg.hgqt import cmdui
+import glob, os, shlex
 
 from PyQt4.QtCore import *
 from PyQt4.QtGui import *
+from PyQt4.Qsci import QsciScintilla
+
+from mercurial import commands, util
+
+from tortoisehg.hgqt.i18n import _
+from tortoisehg.hgqt import cmdui, run
+from tortoisehg.util import hglib
+
+class _LogWidgetForConsole(cmdui.LogWidget):
+    """Wrapped LogWidget for ConsoleWidget"""
+
+    returnPressed = pyqtSignal(unicode)
+    """Return key pressed when cursor is on prompt line"""
+    historyRequested = pyqtSignal(unicode, int)  # keyword, direction
+    completeRequested = pyqtSignal(unicode)
+
+    _prompt = '% '
+
+    def __init__(self, parent=None):
+        super(_LogWidgetForConsole, self).__init__(parent)
+        self._prompt_marker = self.markerDefine(QsciScintilla.Background)
+        self.setMarkerBackgroundColor(QColor('#e8f3fe'), self._prompt_marker)
+        self.cursorPositionChanged.connect(self._updatePrompt)
+        # ensure not moving prompt line even if completion list get shorter,
+        # by allowing to scroll one page below the last line
+        self.SendScintilla(QsciScintilla.SCI_SETENDATLASTLINE, False)
+        # don't reserve "slop" area at top/bottom edge on ensureFooVisible()
+        self.SendScintilla(QsciScintilla.SCI_SETVISIBLEPOLICY, 0, 0)
+
+        self._savedcommands = []  # temporarily-invisible command
+        self._origcolor = None
+        self._flashtimer = QTimer(self, interval=100, singleShot=True)
+        self._flashtimer.timeout.connect(self._restoreColor)
+
+    def keyPressEvent(self, event):
+        cursoronprompt = not self.isReadOnly()
+        if cursoronprompt:
+            if event.key() == Qt.Key_Up:
+                return self.historyRequested.emit(self.commandText(), -1)
+            elif event.key() == Qt.Key_Down:
+                return self.historyRequested.emit(self.commandText(), +1)
+            del self._savedcommands[:]  # settle candidate by user input
+            if event.key() in (Qt.Key_Return, Qt.Key_Enter):
+                return self.returnPressed.emit(self.commandText())
+            if event.key() == Qt.Key_Tab:
+                return self.completeRequested.emit(self.commandText())
+        if event.key() == Qt.Key_Escape:
+            # When ESC is pressed, if the cursor is on the prompt,
+            # this clears it, if not, this moves the cursor to the prompt
+            self.setCommandText('')
+
+        super(_LogWidgetForConsole, self).keyPressEvent(event)
+
+    def setPrompt(self, text):
+        if text == self._prompt:
+            return
+        self.clearPrompt()
+        self._prompt = text
+        self.openPrompt()
+
+    @pyqtSlot()
+    def openPrompt(self):
+        """Show prompt line and enable user input"""
+        self.closePrompt()
+        line = self.lines() - 1
+        self.markerAdd(line, self._prompt_marker)
+        self.append(self._prompt)
+        if self._savedcommands:
+            self.append(self._savedcommands.pop())
+        self.setCursorPosition(line, len(self.text(line)))
+        self.setReadOnly(False)
+
+        # make sure the prompt line is visible. Because QsciScintilla may
+        # delay line wrapping, setCursorPosition() doesn't always scrolls
+        # to the correct position.
+        # http://www.scintilla.org/ScintillaDoc.html#LineWrapping
+        self.SCN_PAINTED.connect(self._scrollCaretOnPainted)
+
+    @pyqtSlot()
+    def _scrollCaretOnPainted(self):
+        self.SCN_PAINTED.disconnect(self._scrollCaretOnPainted)
+        self.SendScintilla(self.SCI_SCROLLCARET)
+
+    def _removeTrailingText(self, line, index):
+        visline = self.firstVisibleLine()
+        lastline = self.lines() - 1
+        self.setSelection(line, index, lastline, len(self.text(lastline)))
+        self.removeSelectedText()
+        # restore scroll position changed by setSelection()
+        self.verticalScrollBar().setValue(visline)
+
+    def _findPromptLine(self):
+        return self.markerFindPrevious(self.lines() - 1,
+                                       1 << self._prompt_marker)
+
+    @pyqtSlot()
+    def closePrompt(self):
+        """Disable user input"""
+        line = self._findPromptLine()
+        if line >= 0:
+            if self.commandText():
+                self._setmarker((line,), 'control')
+            self.markerDelete(line, self._prompt_marker)
+            self._removeTrailingText(line + 1, 0)  # clear completion
+        self._newline()
+        self.setCursorPosition(self.lines() - 1, 0)
+        self.setReadOnly(True)
+
+    @pyqtSlot()
+    def clearPrompt(self):
+        """Clear prompt line and subsequent text"""
+        line = self._findPromptLine()
+        if line < 0:
+            return
+        self._savedcommands = [self.commandText()]
+        self.markerDelete(line)
+        self._removeTrailingText(line, 0)
+
+    @pyqtSlot(int, int)
+    def _updatePrompt(self, line, pos):
+        """Update availability of user input"""
+        if self.markersAtLine(line) & (1 << self._prompt_marker):
+            self.setReadOnly(pos < len(self._prompt))
+            self._ensurePrompt(line)
+            if pos < len(self._prompt):
+                # avoid inconsistency caused by changing pos inside
+                # cursorPositionChanged
+                QTimer.singleShot(0, self._moveCursorToPromptHome)
+        else:
+            self.setReadOnly(True)
+
+    @pyqtSlot()
+    def _moveCursorToPromptHome(self):
+        line = self._findPromptLine()
+        if line >= 0:
+            self.setCursorPosition(line, len(self._prompt))
+
+    def _ensurePrompt(self, line):
+        """Insert prompt string if not available"""
+        s = unicode(self.text(line))
+        if s.startswith(self._prompt):
+            return
+        for i, c in enumerate(self._prompt):
+            if s[i:i + 1] != c:
+                self.insertAt(self._prompt[i:], line, i)
+                break
+
+    def commandText(self):
+        """Return the current command text"""
+        if self._savedcommands:
+            return self._savedcommands[-1]
+        l = self._findPromptLine()
+        if l >= 0:
+            return unicode(self.text(l))[len(self._prompt):].rstrip('\n')
+        else:
+            return ''
+
+    def setCommandText(self, text, candidate=False):
+        """Replace the current command text; subsequent text is also removed.
+
+        If candidate, the specified text is displayed but does not replace
+        commandText() until the user takes some action.
+        """
+        line = self._findPromptLine()
+        if line < 0:
+            return
+        if candidate:
+            self._savedcommands = [self.commandText()]
+        else:
+            del self._savedcommands[:]
+        self._ensurePrompt(line)
+        self._removeTrailingText(line, len(self._prompt))
+        self.insert(text)
+        self.setCursorPosition(line, len(self.text(line)))
+
+    def _newline(self):
+        if self.text(self.lines() - 1):
+            self.append('\n')
+
+    def flash(self, color='brown'):
+        """Briefly change the text color to catch the user attention"""
+        if self._flashtimer.isActive():
+            return
+        self._origcolor = self.color()
+        self.setColor(QColor(color))
+        self._flashtimer.start()
+
+    @pyqtSlot()
+    def _restoreColor(self):
+        assert self._origcolor
+        self.setColor(self._origcolor)
+
+def _searchhistory(items, text, direction, idx):
+    """Search history items and return (item, index_of_item)
+
+    Valid index is zero or negative integer. Zero is reserved for non-history
+    item.
+
+    >>> def searchall(items, text, direction, idx=0):
+    ...     matched = []
+    ...     while True:
+    ...         it, idx = _searchhistory(items, text, direction, idx)
+    ...         if not it:
+    ...             return matched, idx
+    ...         matched.append(it)
+
+    >>> searchall('foo bar baz'.split(), '', direction=-1)
+    (['baz', 'bar', 'foo'], -4)
+    >>> searchall('foo bar baz'.split(), '', direction=+1, idx=-3)
+    (['bar', 'baz'], 0)
+
+    search by keyword:
+
+    >>> searchall('foo bar baz'.split(), 'b', direction=-1)
+    (['baz', 'bar'], -4)
+    >>> searchall('foo bar baz'.split(), 'inexistent', direction=-1)
+    ([], -4)
+
+    empty history:
+
+    >>> searchall([], '', direction=-1)
+    ([], -1)
+
+    initial index out of range:
+
+    >>> searchall('foo bar baz'.split(), '', direction=-1, idx=-3)
+    ([], -4)
+    >>> searchall('foo bar baz'.split(), '', direction=+1, idx=0)
+    ([], 1)
+    """
+    assert direction != 0
+    idx += direction
+    while -len(items) <= idx < 0:
+        curcmdline = items[idx]
+        if curcmdline.startswith(text):
+            return curcmdline, idx
+        idx += direction
+    return None, idx
+
+class _ConsoleCmdTable(dict):
+    """Command table for ConsoleWidget"""
+    _cmdfuncprefix = '_cmd_'
+
+    def __call__(self, func):
+        if not func.__name__.startswith(self._cmdfuncprefix):
+            raise ValueError('bad command function name %s' % func.__name__)
+        self[func.__name__[len(self._cmdfuncprefix):]] = func
+        return func
+
+class ConsoleWidget(QWidget):
+    """Console to run hg/thg command and show output"""
+    closeRequested = pyqtSignal()
+
+    progressReceived = pyqtSignal(QString, object, QString, QString,
+                                  object, object)
+    """Emitted when progress received
+
+    Args: topic, pos, item, unit, total, reporoot
+    """
+
+    _cmdtable = _ConsoleCmdTable()
+
+    def __init__(self, parent=None):
+        super(ConsoleWidget, self).__init__(parent)
+        self.setLayout(QVBoxLayout())
+        self.layout().setContentsMargins(0, 0, 0, 0)
+        self._initlogwidget()
+        self.setFocusProxy(self._logwidget)
+        self.setRepository(None)
+        self.openPrompt()
+        self.suppressPrompt = False
+        self._commandHistory = []
+        self._commandIdx = 0
+
+    def _initlogwidget(self):
+        self._logwidget = _LogWidgetForConsole(self)
+        self._logwidget.returnPressed.connect(self._runcommand)
+        self._logwidget.historyRequested.connect(self.historySearch)
+        self._logwidget.completeRequested.connect(self.completeCommandText)
+        self.layout().addWidget(self._logwidget)
+
+        # compatibility methods with LogWidget
+        for name in ('openPrompt', 'closePrompt', 'clear'):
+            setattr(self, name, getattr(self._logwidget, name))
+
+    @pyqtSlot(unicode, int)
+    def historySearch(self, text, direction):
+        cmdline, idx = _searchhistory(self._commandHistory, unicode(text),
+                                      direction, self._commandIdx)
+        if cmdline:
+            self._commandIdx = idx
+            self._logwidget.setCommandText(cmdline, candidate=True)
+        else:
+            self._logwidget.flash()
+
+    def _commandComplete(self, cmdtype, cmdline):
+        matches = []
+        cmd = cmdline.split()
+        if cmdtype == 'hg':
+            cmdtable = commands.table
+        else:
+            cmdtable = run.table
+        subcmd = ''
+        if len(cmd) >= 2:
+            subcmd = cmd[1].lower()
+        def findhgcmd(cmdstart):
+            matchinfo = {}
+            for cmdspec in cmdtable:
+                for cmdname in cmdspec.split('|'):
+                    if cmdname[0] == '^':
+                        cmdname = cmdname[1:]
+                    if cmdname.startswith(cmdstart):
+                        matchinfo[cmdname] = cmdspec
+            return matchinfo
+        matchingcmds = findhgcmd(subcmd)
+        if not matchingcmds:
+            return matches
+        if len(matchingcmds) > 1:
+            basecmdline = '%s %%s' % (cmdtype)
+            matches = [basecmdline % c for c in matchingcmds]
+        else:
+            scmdtype = matchingcmds.keys()[0]
+            cmdspec = matchingcmds[scmdtype]
+            opts = cmdtable[cmdspec][1]
+            def findcmdopt(cmdopt):
+                cmdopt = cmdopt.lower()
+                while(cmdopt.startswith('-')):
+                    cmdopt = cmdopt[1:]
+                matchingopts = []
+                for opt in opts:
+                    if opt[1].startswith(cmdopt):
+                        matchingopts.append(opt)
+                return matchingopts
+            basecmdline = '%s %s --%%s' % (cmdtype, scmdtype)
+            if len(cmd) == 2:
+                matches = ['%s %s ' % (cmdtype, scmdtype)]
+                matches += [basecmdline % opt[1] for opt in opts]
+            else:
+                cmdopt = cmd[-1]
+                if cmdopt.startswith('-'):
+                    # find the matching options
+                    basecmdline = ' '.join(cmd[:-1]) + ' --%s'
+                    cmdopts = findcmdopt(cmdopt)
+                    matches = [basecmdline % opt[1] for opt in cmdopts]
+        return sorted(matches)
+
+    @pyqtSlot(unicode)
+    def completeCommandText(self, text):
+        """Show the list of history or known commands matching the search text
+
+        Also complete the prompt with the common prefix to the matching items
+        """
+        text = unicode(text).strip()
+        if not text:
+            self._logwidget.flash()
+            return
+        history = set(self._commandHistory)
+        commonprefix = ''
+        matches = []
+        for cmdline in history:
+            if cmdline.startswith(text):
+                matches.append(cmdline)
+        if matches:
+            matches.sort()
+            commonprefix = os.path.commonprefix(matches)
+        cmd = text.split()
+        cmdtype = cmd[0].lower()
+        if cmdtype in ('hg', 'thg'):
+            hgcommandmatches = self._commandComplete(cmdtype, text)
+            if hgcommandmatches:
+                if not commonprefix:
+                    commonprefix = os.path.commonprefix(hgcommandmatches)
+                if matches:
+                    matches.append('------ %s commands ------' % cmdtype)
+                matches += hgcommandmatches
+        if not matches:
+            self._logwidget.flash()
+            return
+        self._logwidget.setCommandText(commonprefix)
+        if len(matches) > 1:
+            self._logwidget.append('\n' + '\n'.join(matches) + '\n')
+            self._logwidget.ensureLineVisible(self._logwidget.lines() - 1)
+            self._logwidget.ensureCursorVisible()
+
+    @util.propertycache
+    def _cmdcore(self):
+        cmdcore = cmdui.Core(False, self)
+        cmdcore.output.connect(self._logwidget.appendLog)
+        cmdcore.commandStarted.connect(self.closePrompt)
+        cmdcore.commandFinished.connect(self.openPrompt)
+        cmdcore.progress.connect(self._emitProgress)
+        return cmdcore
+
+    @util.propertycache
+    def _extproc(self):
+        extproc = QProcess(self)
+        extproc.started.connect(self.closePrompt)
+        extproc.finished.connect(self.openPrompt)
+
+        def handleerror(error):
+            msgmap = {
+                QProcess.FailedToStart: _('failed to run command\n'),
+                QProcess.Crashed: _('crashed\n')}
+            if extproc.state() == QProcess.NotRunning:
+                self._logwidget.closePrompt()
+            self._logwidget.appendLog(
+                msgmap.get(error, _('error while running command\n')),
+                'ui.error')
+            if extproc.state() == QProcess.NotRunning:
+                self._logwidget.openPrompt()
+        extproc.error.connect(handleerror)
+
+        def put(bytes, label=None):
+            self._logwidget.appendLog(hglib.tounicode(bytes.data()), label)
+        extproc.readyReadStandardOutput.connect(
+            lambda: put(extproc.readAllStandardOutput()))
+        extproc.readyReadStandardError.connect(
+            lambda: put(extproc.readAllStandardError(), 'ui.error'))
+
+        return extproc
+
+    @pyqtSlot(unicode, str)
+    def appendLog(self, msg, label):
+        """Append log text from another cmdui"""
+        self._logwidget.clearPrompt()
+        try:
+            self._logwidget.appendLog(msg, label)
+        finally:
+            if not self.suppressPrompt:
+                self.openPrompt()
+
+    @pyqtSlot(object)
+    def setRepository(self, repo):
+        """Change the current working repository"""
+        self._repo = repo
+        self._logwidget.setPrompt('%s%% ' % (repo and repo.displayname or ''))
+
+    @property
+    def cwd(self):
+        """Return the current working directory"""
+        return self._repo and self._repo.root or os.getcwd()
+
+    @pyqtSlot(unicode, object, unicode, unicode, object)
+    def _emitProgress(self, *args):
+        self.progressReceived.emit(
+            *(args + (self._repo and self._repo.root or None,)))
+
+    @pyqtSlot(unicode)
+    def _runcommand(self, cmdline):
+        self._commandIdx = 0
+        try:
+            args = list(self._parsecmdline(cmdline))
+        except ValueError, e:
+            self.closePrompt()
+            self._logwidget.appendLog(unicode(e) + '\n', 'ui.error')
+            self.openPrompt()
+            return
+        if not args:
+            self.openPrompt()
+            return
+        # add command to command history
+        ucmdline = unicode(cmdline)
+        if not self._commandHistory or self._commandHistory[-1] != ucmdline:
+            self._commandHistory.append(ucmdline)
+        # execute the command
+        cmd = args.pop(0)
+        try:
+            self._cmdtable[cmd](self, args)
+        except KeyError:
+            return self._runextcommand(cmdline)
+
+    def _parsecmdline(self, cmdline):
+        """Split command line string to imitate a unix shell"""
+        try:
+            args = shlex.split(hglib.fromunicode(cmdline))
+        except ValueError, e:
+            raise ValueError(_('command parse error: %s') % e)
+        for e in args:
+            e = util.expandpath(e)
+            if util.any(c in e for c in '*?[]'):
+                expanded = glob.glob(os.path.join(self.cwd, e))
+                if not expanded:
+                    raise ValueError(_('no matches found: %s')
+                                     % hglib.tounicode(e))
+                for p in expanded:
+                    yield p
+            else:
+                yield e
+
+    def _runextcommand(self, cmdline):
+        self._extproc.setWorkingDirectory(hglib.tounicode(self.cwd))
+        self._extproc.start(cmdline, QIODevice.ReadOnly)
+
+    @_cmdtable
+    def _cmd_hg(self, args):
+        self.closePrompt()
+        if self._repo:
+            args = ['--cwd', self._repo.root] + args
+        self._cmdcore.run(args)
+
+    @_cmdtable
+    def _cmd_thg(self, args):
+        self.closePrompt()
+        try:
+            if self._repo:
+                args = ['-R', self._repo.root] + args
+            # TODO: show errors
+            run.dispatch(args)
+        finally:
+            self.openPrompt()
+
+    @_cmdtable
+    def _cmd_clear(self, args):
+        self.clear()
+        self.openPrompt()
+
+    @_cmdtable
+    def _cmd_cls(self, args):
+        self.clear()
+        self.openPrompt()
+
+    @_cmdtable
+    def _cmd_exit(self, args):
+        self.clear()
+        self.openPrompt()
+        self.closeRequested.emit()
 
 class LogDockWidget(QDockWidget):
     visibilityChanged = pyqtSignal(bool)
         # Not enabled until we have a way to make it configurable
         #self.setWindowFlags(Qt.Drawer)
 
-        self.logte = cmdui.ConsoleWidget()
+        self.logte = ConsoleWidget(self)
         self.logte.closeRequested.connect(self.close)
         self.setWidget(self.logte)
         for name in ('setRepository', 'progressReceived'):

tortoisehg/hgqt/filectxactions.py

+# filectxactions.py - context menu actions for repository files
+#
+# Copyright 2010 Adrian Buehlmann <adrian@cadifra.com>
+# Copyright 2010 Steve Borho <steve@borho.org>
+# Copyright 2012 Yuya Nishihara <yuya@tcha.org>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2, incorporated herein by reference.
+
+import os
+
+from PyQt4.QtCore import *
+from PyQt4.QtGui import *
+
+from mercurial import util
+
+from tortoisehg.hgqt import qtlib, revert, thgrepo, visdiff
+from tortoisehg.hgqt.filedialogs import FileLogDialog, FileDiffDialog
+from tortoisehg.hgqt.i18n import _
+from tortoisehg.util import hglib
+
+_actionsbytype = {
+    'subrepo': ['opensubrepo', 'explore', 'terminal', 'copypath', None,
+                'revert'],
+    'file': ['diff', 'ldiff', None, 'edit', 'save', None, 'ledit', 'lopen',
+             'copypath', None, 'revert', None, 'navigate', 'diffnavigate'],
+    'dir': ['diff', 'ldiff', None, 'revert', None, 'filter',
+            None, 'explore', 'terminal', 'copypath'],
+    }
+
+class FilectxActions(QObject):
+    """Container for repository file actions"""
+
+    linkActivated = pyqtSignal(unicode)
+    filterRequested = pyqtSignal(QString)
+    """Ask the repowidget to change its revset filter"""
+
+
+    def __init__(self, repo, parent=None, rev=None):
+        super(FilectxActions, self).__init__(parent)
+        if parent is not None and not isinstance(parent, QWidget):
+            raise ValueError('parent must be a QWidget')
+
+        self.repo = repo
+        self.ctx = self.repo[rev]
+        self._selectedfiles = []  # local encoding
+        self._currentfile = None  # local encoding
+        self._itemissubrepo = False
+        self._itemisdir = False
+
+        self._diff_dialogs = {}
+        self._nav_dialogs = {}
+        self._contextmenus = {}
+
+        self._actions = {}
+        for name, desc, icon, key, tip, cb in [
+            ('navigate', _('File history'), 'hg-log', 'Shift+Return',
+             _('Show the history of the selected file'), self.navigate),
+            ('filter', _('Folder history'), 'hg-log', None,
+             _('Show the history of the selected file'), self.filterfile),
+            ('diffnavigate', _('Compare file revisions'), 'compare-files', None,
+             _('Compare revisions of the selected file'), self.diffNavigate),
+            ('diff', _('Diff to parent'), 'visualdiff', 'Ctrl+D',
+             _('View file changes in external diff tool'), self.vdiff),
+            ('ldiff', _('Diff to local'), 'ldiff', 'Shift+Ctrl+D',
+             _('View changes to current in external diff tool'),
+             self.vdifflocal),
+            ('edit', _('View at Revision'), 'view-at-revision', 'Shift+Ctrl+E',
+             _('View file as it appeared at this revision'), self.editfile),
+            ('save', _('Save at Revision'), None, 'Shift+Ctrl+S',
+             _('Save file as it appeared at this revision'), self.savefile),
+            ('ledit', _('Edit Local'), 'edit-file', 'Shift+Ctrl+L',
+             _('Edit current file in working copy'), self.editlocal),
+            ('lopen', _('Open Local'), '', 'Shift+Ctrl+O',
+             _('Edit current file in working copy'), self.openlocal),
+            ('copypath', _('Copy Path'), '', 'Shift+Ctrl+C',
+             _('Copy full path of file(s) to the clipboard'), self.copypath),
+            ('revert', _('Revert to Revision'), 'hg-revert', 'Shift+Ctrl+R',
+             _('Revert file(s) to contents at this revision'),
+             self.revertfile),
+            ('opensubrepo', _('Open subrepository'), 'thg-repository-open',
+             'Shift+Ctrl+O', _('Open the selected subrepository'),
+             self.opensubrepo),
+            ('explore', _('Explore folder'), 'system-file-manager',
+             None, _('Open the selected folder in the system file manager'),
+             self.explore),
+            ('terminal', _('Open terminal here'), 'utilities-terminal', None,
+             _('Open a shell terminal in the selected folder'),
+             self.terminal),
+            ]:
+            act = QAction(desc, self)
+            if icon:
+                act.setIcon(qtlib.getmenuicon(icon))
+            if key:
+                act.setShortcut(key)
+            if tip:
+                act.setStatusTip(tip)
+            if cb:
+                act.triggered.connect(cb)
+            self._actions[name] = act
+
+    def setRepo(self, repo):
+        self.repo = repo
+
+    def setRev(self, rev):
+        self.ctx = self.repo[rev]
+        real = type(rev) is int
+        wd = rev is None
+        for act in ['navigate', 'diffnavigate', 'ldiff', 'edit', 'save']:
+            self._actions[act].setEnabled(real)
+        for act in ['diff', 'revert']:
+            self._actions[act].setEnabled(real or wd)
+
+    def setPaths(self, selectedfiles, currentfile=None, itemissubrepo=False,
+                 itemisdir=False):
+        """Set selected files [unicode]"""
+        self.setPaths_(map(hglib.fromunicode, selectedfiles),
+                       hglib.fromunicode(currentfile), itemissubrepo, itemisdir)
+
+    def setPaths_(self, selectedfiles, currentfile=None, itemissubrepo=False,
+                  itemisdir=False):
+        """Set selected files [local encoding]"""
+        if not currentfile and selectedfiles:
+            currentfile = selectedfiles[0]
+        self._selectedfiles = list(selectedfiles)
+        self._currentfile = currentfile
+        self._itemissubrepo = itemissubrepo
+        self._itemisdir = itemisdir
+
+    def actions(self):
+        """List of the actions; The owner widget should register them"""
+        return self._actions.values()
+
+    def menu(self):
+        """Menu for the current selection if available; otherwise None"""
+        # Subrepos and regular items have different context menus
+        if self._itemissubrepo:
+            contextmenu = self._cachedcontextmenu('subrepo')
+        elif self._itemisdir:
+            contextmenu = self._cachedcontextmenu('dir')
+        else:
+            contextmenu = self._cachedcontextmenu('file')
+
+        ln = len(self._selectedfiles)
+        if ln == 0:
+            return
+        if ln > 1 and not self._itemissubrepo:
+            singlefileactions = False
+        else:
+            singlefileactions = True
+        self._actions['navigate'].setEnabled(singlefileactions)
+        self._actions['diffnavigate'].setEnabled(singlefileactions)
+        return contextmenu
+
+    def _cachedcontextmenu(self, key):
+        contextmenu = self._contextmenus.get(key)
+        if contextmenu:
+            return contextmenu
+
+        contextmenu = QMenu(self.parent())
+        for act in _actionsbytype[key]:
+            if act:
+                contextmenu.addAction(self._actions[act])
+            else:
+                contextmenu.addSeparator()
+        self._contextmenus[key] = contextmenu
+        return contextmenu
+
+    def navigate(self):
+        self._navigate(FileLogDialog, self._nav_dialogs)
+
+    def diffNavigate(self):
+        self._navigate(FileDiffDialog, self._diff_dialogs)
+
+    def filterfile(self):
+        """Ask to only show the revisions in which files on that folder are
+        present"""
+        if not self._selectedfiles:
+            return
+        self.filterRequested.emit("file('%s/**')" % self._selectedfiles[0])
+
+    def vdiff(self):
+        repo, filenames, rev = self._findsub(self._selectedfiles)
+        if not filenames:
+            return
+        if rev in repo.thgmqunappliedpatches:
+            QMessageBox.warning(self,
+                _("Cannot display visual diff"),
+                _("Visual diffs are not supported for unapplied patches"))
+            return
+        opts = {'change': rev}
+        dlg = visdiff.visualdiff(repo.ui, repo, filenames, opts)
+        if dlg:
+            dlg.exec_()
+
+    def vdifflocal(self):
+        repo, filenames, rev = self._findsub(self._selectedfiles)
+        if not filenames:
+            return
+        assert type(rev) is int
+        opts = {'rev': ['rev(%d)' % rev]}
+        dlg = visdiff.visualdiff(repo.ui, repo, filenames, opts)
+        if dlg:
+            dlg.exec_()
+
+    def editfile(self):
+        repo, filenames, rev = self._findsub(self._selectedfiles)
+        if not filenames:
+            return
+        if rev is None:
+            qtlib.editfiles(repo, filenames, parent=self.parent())
+        else:
+            base, _ = visdiff.snapshot(repo, filenames, repo[rev])
+            files = [os.path.join(base, filename)
+                     for filename in filenames]
+            qtlib.editfiles(repo, files, parent=self.parent())
+
+    def savefile(self):
+        repo, filenames, rev = self._findsub(self._selectedfiles)
+        if not filenames:
+            return
+        qtlib.savefiles(repo, filenames, rev, parent=self.parent())
+
+    def editlocal(self):
+        repo, filenames, _rev = self._findsub(self._selectedfiles)
+        if not filenames:
+            return
+        qtlib.editfiles(repo, filenames, parent=self.parent())
+
+    def openlocal(self):
+        repo, filenames, _rev = self._findsub(self._selectedfiles)
+        if not filenames:
+            return
+        qtlib.openfiles(repo, filenames)
+
+    def copypath(self):
+        absfiles = [util.localpath(self.repo.wjoin(f))
+                    for f in self._selectedfiles]
+        QApplication.clipboard().setText(
+            hglib.tounicode(os.linesep.join(absfiles)))
+
+    def revertfile(self):
+        repo, fileSelection, rev = self._findsub(self._selectedfiles)
+        if not fileSelection:
+            return
+        if rev is None:
+            rev = repo[rev].p1().rev()
+        dlg = revert.RevertDialog(repo, fileSelection, rev,
+                                  parent=self.parent())
+        dlg.exec_()
+
+    def _navigate(self, dlgclass, dlgdict):
+        repo, filename, rev = self._findsubsingle(self._currentfile)
+        if filename and len(repo.file(filename)) > 0:
+            if self._currentfile not in dlgdict:
+                # dirty hack to pass workbench only if available
+                from tortoisehg.hgqt import workbench  # avoid cyclic dep
+                repoviewer = None
+                if self.parent() and isinstance(self.parent().window(),
+                                                workbench.Workbench):
+                    repoviewer = self.parent().window()
+                dlg = dlgclass(repo, filename, repoviewer=repoviewer)
+                dlgdict[self._currentfile] = dlg
+                ufname = hglib.tounicode(filename)
+                dlg.setWindowTitle(_('Hg file log viewer - %s') % ufname)
+                dlg.setWindowIcon(qtlib.geticon('hg-log'))
+            dlg = dlgdict[self._currentfile]
+            dlg.goto(rev)
+            dlg.show()
+            dlg.raise_()
+            dlg.activateWindow()
+
+    def _findsub(self, paths):
+        """Find the nearest (sub-)repository for the given paths
+
+        All paths should be in the same repository. Otherwise, unmatched
+        paths are silently omitted.
+        """
+        if not paths:
+            return self.repo, [], self.ctx.rev()
+
+        repopath, _relpath, ctx = hglib.getDeepestSubrepoContainingFile(
+            paths[0], self.ctx)
+        if not repopath:
+            return self.repo, paths, self.ctx.rev()
+
+        repo = thgrepo.repository(self.repo.ui, self.repo.wjoin(repopath))
+        pfx = repopath + '/'
+        relpaths = [e[len(pfx):] for e in paths if e.startswith(pfx)]
+        return repo, relpaths, ctx.rev()
+
+    def _findsubsingle(self, path):
+        if not path:
+            return self.repo, None, self.ctx.rev()
+        repo, relpaths, rev = self._findsub([path])
+        return repo, relpaths[0], rev
+
+    def opensubrepo(self):
+        path = os.path.join(self.repo.root, self._currentfile)
+        if os.path.isdir(path):
+            spath = path[len(self.repo.root)+1:]
+            source, revid, stype = self.ctx.substate[spath]
+            link = u'subrepo:' + hglib.tounicode(path)
+            if stype == 'hg':
+                link = u'%s?%s' % (link, revid)
+            self.linkActivated.emit(link)
+        else:
+            QMessageBox.warning(self,
+                _("Cannot open subrepository"),
+                _("The selected subrepository does not exist on the working "
+                  "directory"))
+
+    def explore(self):
+        root = self.repo.wjoin(self._currentfile)
+        if os.path.isdir(root):
+            qtlib.openlocalurl(root)
+
+    def terminal(self):
+        root = self.repo.wjoin(self._currentfile)
+        if os.path.isdir(root):
+            qtlib.openshell(root, self._currentfile)

tortoisehg/hgqt/filedialogs.py

         self.editToolbar = QToolBar(self)
         self.editToolbar.setContextMenuPolicy(Qt.PreventContextMenu)
         self.addToolBar(Qt.ToolBarArea(Qt.TopToolBarArea), self.editToolbar)
-        self.actionClose = QAction(self, shortcut=QKeySequence.Close)
-        self.actionReload = QAction(self, shortcut=QKeySequence.Refresh)
+        self.actionClose = QAction(self)
+        self.actionClose.setShortcuts(QKeySequence.Close)
+        self.actionReload = QAction(self)
+        self.actionReload.setShortcuts(QKeySequence.Refresh)
         self.editToolbar.addAction(self.actionReload)
         self.addAction(self.actionClose)
 
         self.editToolbar = QToolBar(self)
         self.editToolbar.setContextMenuPolicy(Qt.PreventContextMenu)
         self.addToolBar(Qt.ToolBarArea(Qt.TopToolBarArea), self.editToolbar)
-        self.actionClose = QAction(self, shortcut=QKeySequence.Close)
-        self.actionReload = QAction(self, shortcut=QKeySequence.Refresh)
+        self.actionClose = QAction(self)
+        self.actionClose.setShortcuts(QKeySequence.Close)
+        self.actionReload = QAction(self)
+        self.actionReload.setShortcuts(QKeySequence.Refresh)
         self.editToolbar.addAction(self.actionReload)
         self.addAction(self.actionClose)
 

tortoisehg/hgqt/fileview.py

 import difflib
 import re
 
-from mercurial import error, util, patch<