histedit: import chistedit curses UI from hg-experimental
authorAugie Fackler <augie@google.com>
Wed, 17 Oct 2018 17:15:42 -0400
changeset 40649 c36175456350
parent 40648 da4478ca0e32
child 40650 2f7e531ef3e7
histedit: import chistedit curses UI from hg-experimental I don't tend to like curses interfaces, but this gets enough use at work that it seems like it's worth bringing into core. This is a minimal import from hg-experimental revision 4c7f33bf5f00, in that I've done the smallest amount of code movement and editing in order to import the functionality. .. feature:: `hg histedit` will now present a curses UI if curses is available and `ui.interface` or `ui.interface.histedit` is set to `curses`. Differential Revision: https://phab.mercurial-scm.org/D5146
hgext/histedit.py
mercurial/ui.py
--- a/hgext/histedit.py	Mon Nov 12 20:32:58 2018 -0500
+++ b/hgext/histedit.py	Wed Oct 17 17:15:42 2018 -0400
@@ -183,7 +183,11 @@
 
 from __future__ import absolute_import
 
+import fcntl
+import functools
 import os
+import struct
+import termios
 
 from mercurial.i18n import _
 from mercurial import (
@@ -198,6 +202,7 @@
     extensions,
     hg,
     lock,
+    logcmdutil,
     merge as mergemod,
     mergeutil,
     node,
@@ -235,6 +240,9 @@
 configitem('histedit', 'singletransaction',
     default=False,
 )
+configitem('ui', 'interface.histedit',
+    default=None,
+)
 
 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
@@ -915,6 +923,562 @@
         raise error.Abort(msg, hint=hint)
     return repo[roots[0]].node()
 
+# Curses Support
+try:
+    import curses
+except ImportError:
+    curses = None
+
+KEY_LIST = ['pick', 'edit', 'fold', 'drop', 'mess', 'roll']
+ACTION_LABELS = {
+    'fold': '^fold',
+    'roll': '^roll',
+}
+
+COLOR_HELP, COLOR_SELECTED, COLOR_OK, COLOR_WARN  = 1, 2, 3, 4
+
+E_QUIT, E_HISTEDIT = 1, 2
+E_PAGEDOWN, E_PAGEUP, E_LINEUP, E_LINEDOWN, E_RESIZE = 3, 4, 5, 6, 7
+MODE_INIT, MODE_PATCH, MODE_RULES, MODE_HELP = 0, 1, 2, 3
+
+KEYTABLE = {
+    'global': {
+        'h':         'next-action',
+        'KEY_RIGHT': 'next-action',
+        'l':         'prev-action',
+        'KEY_LEFT':  'prev-action',
+        'q':         'quit',
+        'c':         'histedit',
+        'C':         'histedit',
+        'v':         'showpatch',
+        '?':         'help',
+    },
+    MODE_RULES: {
+        'd':         'action-drop',
+        'e':         'action-edit',
+        'f':         'action-fold',
+        'm':         'action-mess',
+        'p':         'action-pick',
+        'r':         'action-roll',
+        ' ':         'select',
+        'j':         'down',
+        'k':         'up',
+        'KEY_DOWN':  'down',
+        'KEY_UP':    'up',
+        'J':         'move-down',
+        'K':         'move-up',
+        'KEY_NPAGE': 'move-down',
+        'KEY_PPAGE': 'move-up',
+        '0':         'goto',  # Used for 0..9
+    },
+    MODE_PATCH: {
+        ' ':         'page-down',
+        'KEY_NPAGE': 'page-down',
+        'KEY_PPAGE': 'page-up',
+        'j':         'line-down',
+        'k':         'line-up',
+        'KEY_DOWN':  'line-down',
+        'KEY_UP':    'line-up',
+        'J':         'down',
+        'K':         'up',
+    },
+    MODE_HELP: {
+    },
+}
+
+def screen_size():
+    return struct.unpack('hh', fcntl.ioctl(1, termios.TIOCGWINSZ, '    '))
+
+class histeditrule(object):
+    def __init__(self, ctx, pos, action='pick'):
+        self.ctx = ctx
+        self.action = action
+        self.origpos = pos
+        self.pos = pos
+        self.conflicts = []
+
+    def __str__(self):
+        # Some actions ('fold' and 'roll') combine a patch with a previous one.
+        # Add a marker showing which patch they apply to, and also omit the
+        # description for 'roll' (since it will get discarded). Example display:
+        #
+        #  #10 pick   316392:06a16c25c053   add option to skip tests
+        #  #11 ^roll  316393:71313c964cc5
+        #  #12 pick   316394:ab31f3973b0d   include mfbt for mozilla-config.h
+        #  #13 ^fold  316395:14ce5803f4c3   fix warnings
+        #
+        # The carets point to the changeset being folded into ("roll this
+        # changeset into the changeset above").
+        action = ACTION_LABELS.get(self.action, self.action)
+        h = self.ctx.hex()[0:12]
+        r = self.ctx.rev()
+        desc = self.ctx.description().splitlines()[0].strip()
+        if self.action == 'roll':
+            desc = ''
+        return "#{0:<2} {1:<6} {2}:{3}   {4}".format(
+                self.origpos, action, r, h, desc)
+
+    def checkconflicts(self, other):
+        if other.pos > self.pos and other.origpos <= self.origpos:
+            if set(other.ctx.files()) & set(self.ctx.files()) != set():
+                self.conflicts.append(other)
+                return self.conflicts
+
+        if other in self.conflicts:
+            self.conflicts.remove(other)
+        return self.conflicts
+
+# ============ EVENTS ===============
+def movecursor(state, oldpos, newpos):
+    '''Change the rule/changeset that the cursor is pointing to, regardless of
+    current mode (you can switch between patches from the view patch window).'''
+    state['pos'] = newpos
+
+    mode, _ = state['mode']
+    if mode == MODE_RULES:
+        # Scroll through the list by updating the view for MODE_RULES, so that
+        # even if we are not currently viewing the rules, switching back will
+        # result in the cursor's rule being visible.
+        modestate = state['modes'][MODE_RULES]
+        if newpos < modestate['line_offset']:
+            modestate['line_offset'] = newpos
+        elif newpos > modestate['line_offset'] + state['page_height'] - 1:
+            modestate['line_offset'] = newpos - state['page_height'] + 1
+
+    # Reset the patch view region to the top of the new patch.
+    state['modes'][MODE_PATCH]['line_offset'] = 0
+
+def changemode(state, mode):
+    curmode, _ = state['mode']
+    state['mode'] = (mode, curmode)
+
+def makeselection(state, pos):
+    state['selected'] = pos
+
+def swap(state, oldpos, newpos):
+    """Swap two positions and calculate necessary conflicts in
+    O(|newpos-oldpos|) time"""
+
+    rules = state['rules']
+    assert 0 <= oldpos < len(rules) and 0 <= newpos < len(rules)
+
+    rules[oldpos], rules[newpos] = rules[newpos], rules[oldpos]
+
+    # TODO: swap should not know about histeditrule's internals
+    rules[newpos].pos = newpos
+    rules[oldpos].pos = oldpos
+
+    start = min(oldpos, newpos)
+    end = max(oldpos, newpos)
+    for r in pycompat.xrange(start, end + 1):
+        rules[newpos].checkconflicts(rules[r])
+        rules[oldpos].checkconflicts(rules[r])
+
+    if state['selected']:
+        makeselection(state, newpos)
+
+def changeaction(state, pos, action):
+    """Change the action state on the given position to the new action"""
+    rules = state['rules']
+    assert 0 <= pos < len(rules)
+    rules[pos].action = action
+
+def cycleaction(state, pos, next=False):
+    """Changes the action state the next or the previous action from
+    the action list"""
+    rules = state['rules']
+    assert 0 <= pos < len(rules)
+    current = rules[pos].action
+
+    assert current in KEY_LIST
+
+    index = KEY_LIST.index(current)
+    if next:
+        index += 1
+    else:
+        index -= 1
+    changeaction(state, pos, KEY_LIST[index % len(KEY_LIST)])
+
+def changeview(state, delta, unit):
+    '''Change the region of whatever is being viewed (a patch or the list of
+    changesets). 'delta' is an amount (+/- 1) and 'unit' is 'page' or 'line'.'''
+    mode, _ = state['mode']
+    if mode != MODE_PATCH:
+        return
+    mode_state = state['modes'][mode]
+    num_lines = len(patchcontents(state))
+    page_height = state['page_height']
+    unit = page_height if unit == 'page' else 1
+    num_pages = 1 + (num_lines - 1) / page_height
+    max_offset = (num_pages - 1) * page_height
+    newline = mode_state['line_offset'] + delta * unit
+    mode_state['line_offset'] = max(0, min(max_offset, newline))
+
+def event(state, ch):
+    """Change state based on the current character input
+
+    This takes the current state and based on the current character input from
+    the user we change the state.
+    """
+    selected = state['selected']
+    oldpos = state['pos']
+    rules = state['rules']
+
+    if ch in (curses.KEY_RESIZE, "KEY_RESIZE"):
+        return E_RESIZE
+
+    lookup_ch = ch
+    if '0' <= ch <= '9':
+        lookup_ch = '0'
+
+    curmode, prevmode = state['mode']
+    action = KEYTABLE[curmode].get(lookup_ch, KEYTABLE['global'].get(lookup_ch))
+    if action is None:
+        return
+    if action in ('down', 'move-down'):
+        newpos = min(oldpos + 1, len(rules) - 1)
+        movecursor(state, oldpos, newpos)
+        if selected is not None or action == 'move-down':
+            swap(state, oldpos, newpos)
+    elif action in ('up', 'move-up'):
+        newpos = max(0, oldpos - 1)
+        movecursor(state, oldpos, newpos)
+        if selected is not None or action == 'move-up':
+            swap(state, oldpos, newpos)
+    elif action == 'next-action':
+        cycleaction(state, oldpos, next=True)
+    elif action == 'prev-action':
+        cycleaction(state, oldpos, next=False)
+    elif action == 'select':
+        selected = oldpos if selected is None else None
+        makeselection(state, selected)
+    elif action == 'goto' and int(ch) < len(rules) and len(rules) <= 10:
+        newrule = next((r for r in rules if r.origpos == int(ch)))
+        movecursor(state, oldpos, newrule.pos)
+        if selected is not None:
+            swap(state, oldpos, newrule.pos)
+    elif action.startswith('action-'):
+        changeaction(state, oldpos, action[7:])
+    elif action == 'showpatch':
+        changemode(state, MODE_PATCH if curmode != MODE_PATCH else prevmode)
+    elif action == 'help':
+        changemode(state, MODE_HELP if curmode != MODE_HELP else prevmode)
+    elif action == 'quit':
+        return E_QUIT
+    elif action == 'histedit':
+        return E_HISTEDIT
+    elif action == 'page-down':
+        return E_PAGEDOWN
+    elif action == 'page-up':
+        return E_PAGEUP
+    elif action == 'line-down':
+        return E_LINEDOWN
+    elif action == 'line-up':
+        return E_LINEUP
+
+def makecommands(rules):
+    """Returns a list of commands consumable by histedit --commands based on
+    our list of rules"""
+    commands = []
+    for rules in rules:
+        commands.append("{0} {1}\n".format(rules.action, rules.ctx))
+    return commands
+
+def addln(win, y, x, line, color=None):
+    """Add a line to the given window left padding but 100% filled with
+    whitespace characters, so that the color appears on the whole line"""
+    maxy, maxx = win.getmaxyx()
+    length = maxx - 1 - x
+    line = ("{0:<%d}" % length).format(str(line).strip())[:length]
+    if y < 0:
+        y = maxy + y
+    if x < 0:
+        x = maxx + x
+    if color:
+        win.addstr(y, x, line, color)
+    else:
+        win.addstr(y, x, line)
+
+def patchcontents(state):
+    repo = state['repo']
+    rule = state['rules'][state['pos']]
+    displayer = logcmdutil.changesetdisplayer(repo.ui, repo, {
+        'patch': True, 'verbose': True
+    }, buffered=True)
+    displayer.show(rule.ctx)
+    displayer.close()
+    return displayer.hunk[rule.ctx.rev()].splitlines()
+
+def _chisteditmain(repo, rules, stdscr):
+    # initialize color pattern
+    curses.init_pair(COLOR_HELP, curses.COLOR_WHITE, curses.COLOR_BLUE)
+    curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE)
+    curses.init_pair(COLOR_WARN, curses.COLOR_BLACK, curses.COLOR_YELLOW)
+    curses.init_pair(COLOR_OK, curses.COLOR_BLACK, curses.COLOR_GREEN)
+
+    # don't display the cursor
+    try:
+        curses.curs_set(0)
+    except curses.error:
+        pass
+
+    def rendercommit(win, state):
+        """Renders the commit window that shows the log of the current selected
+        commit"""
+        pos = state['pos']
+        rules = state['rules']
+        rule = rules[pos]
+
+        ctx = rule.ctx
+        win.box()
+
+        maxy, maxx = win.getmaxyx()
+        length = maxx - 3
+
+        line = "changeset: {0}:{1:<12}".format(ctx.rev(), ctx)
+        win.addstr(1, 1, line[:length])
+
+        line = "user:      {0}".format(stringutil.shortuser(ctx.user()))
+        win.addstr(2, 1, line[:length])
+
+        bms = repo.nodebookmarks(ctx.node())
+        line = "bookmark:  {0}".format(' '.join(bms))
+        win.addstr(3, 1, line[:length])
+
+        line = "files:     {0}".format(','.join(ctx.files()))
+        win.addstr(4, 1, line[:length])
+
+        line = "summary:   {0}".format(ctx.description().splitlines()[0])
+        win.addstr(5, 1, line[:length])
+
+        conflicts = rule.conflicts
+        if len(conflicts) > 0:
+            conflictstr = ','.join(map(lambda r: str(r.ctx), conflicts))
+            conflictstr = "changed files overlap with {0}".format(conflictstr)
+        else:
+            conflictstr = 'no overlap'
+
+        win.addstr(6, 1, conflictstr[:length])
+        win.noutrefresh()
+
+    def helplines(mode):
+        if mode == MODE_PATCH:
+            help = """\
+?: help, k/up: line up, j/down: line down, v: stop viewing patch
+pgup: prev page, space/pgdn: next page, c: commit, q: abort
+"""
+        else:
+            help = """\
+?: help, k/up: move up, j/down: move down, space: select, v: view patch
+d: drop, e: edit, f: fold, m: mess, p: pick, r: roll
+pgup/K: move patch up, pgdn/J: move patch down, c: commit, q: abort
+"""
+        return help.splitlines()
+
+    def renderhelp(win, state):
+        maxy, maxx = win.getmaxyx()
+        mode, _ = state['mode']
+        for y, line in enumerate(helplines(mode)):
+            if y >= maxy:
+                break
+            addln(win, y, 0, line, curses.color_pair(COLOR_HELP))
+        win.noutrefresh()
+
+    def renderrules(rulesscr, state):
+        rules = state['rules']
+        pos = state['pos']
+        selected = state['selected']
+        start = state['modes'][MODE_RULES]['line_offset']
+
+        conflicts = [r.ctx for r in rules if r.conflicts]
+        if len(conflicts) > 0:
+            line = "potential conflict in %s" % ','.join(map(str, conflicts))
+            addln(rulesscr, -1, 0, line, curses.color_pair(COLOR_WARN))
+
+        for y, rule in enumerate(rules[start:]):
+            if y >= state['page_height']:
+                break
+            if len(rule.conflicts) > 0:
+                rulesscr.addstr(y, 0, " ", curses.color_pair(COLOR_WARN))
+            else:
+                rulesscr.addstr(y, 0, " ", curses.COLOR_BLACK)
+            if y + start == selected:
+                addln(rulesscr, y, 2, rule, curses.color_pair(COLOR_SELECTED))
+            elif y + start == pos:
+                addln(rulesscr, y, 2, rule, curses.A_BOLD)
+            else:
+                addln(rulesscr, y, 2, rule)
+        rulesscr.noutrefresh()
+
+    def renderstring(win, state, output):
+        maxy, maxx = win.getmaxyx()
+        length = min(maxy - 1, len(output))
+        for y in range(0, length):
+            win.addstr(y, 0, output[y])
+        win.noutrefresh()
+
+    def renderpatch(win, state):
+        start = state['modes'][MODE_PATCH]['line_offset']
+        renderstring(win, state, patchcontents(state)[start:])
+
+    def layout(mode):
+        maxy, maxx = stdscr.getmaxyx()
+        helplen = len(helplines(mode))
+        return {
+            'commit': (8, maxx),
+            'help': (helplen, maxx),
+            'main': (maxy - helplen - 8, maxx),
+        }
+
+    def drawvertwin(size, y, x):
+        win = curses.newwin(size[0], size[1], y, x)
+        y += size[0]
+        return win, y, x
+
+    state = {
+        'pos': 0,
+        'rules': rules,
+        'selected': None,
+        'mode': (MODE_INIT, MODE_INIT),
+        'page_height': None,
+        'modes': {
+            MODE_RULES: {
+                'line_offset': 0,
+            },
+            MODE_PATCH: {
+                'line_offset': 0,
+            }
+        },
+        'repo': repo,
+    }
+
+    # eventloop
+    ch = None
+    stdscr.clear()
+    stdscr.refresh()
+    while True:
+        try:
+            oldmode, _ = state['mode']
+            if oldmode == MODE_INIT:
+                changemode(state, MODE_RULES)
+            e = event(state, ch)
+
+            if e == E_QUIT:
+                return False
+            if e == E_HISTEDIT:
+                return state['rules']
+            else:
+                if e == E_RESIZE:
+                    size = screen_size()
+                    if size != stdscr.getmaxyx():
+                        curses.resizeterm(*size)
+
+                curmode, _ = state['mode']
+                sizes = layout(curmode)
+                if curmode != oldmode:
+                    state['page_height'] = sizes['main'][0]
+                    # Adjust the view to fit the current screen size.
+                    movecursor(state, state['pos'], state['pos'])
+
+                # Pack the windows against the top, each pane spread across the
+                # full width of the screen.
+                y, x = (0, 0)
+                helpwin, y, x = drawvertwin(sizes['help'], y, x)
+                mainwin, y, x = drawvertwin(sizes['main'], y, x)
+                commitwin, y, x = drawvertwin(sizes['commit'], y, x)
+
+                if e in (E_PAGEDOWN, E_PAGEUP, E_LINEDOWN, E_LINEUP):
+                    if e == E_PAGEDOWN:
+                        changeview(state, +1, 'page')
+                    elif e == E_PAGEUP:
+                        changeview(state, -1, 'page')
+                    elif e == E_LINEDOWN:
+                        changeview(state, +1, 'line')
+                    elif e == E_LINEUP:
+                        changeview(state, -1, 'line')
+
+                # start rendering
+                commitwin.erase()
+                helpwin.erase()
+                mainwin.erase()
+                if curmode == MODE_PATCH:
+                    renderpatch(mainwin, state)
+                elif curmode == MODE_HELP:
+                    renderstring(mainwin, state, __doc__.strip().splitlines())
+                else:
+                    renderrules(mainwin, state)
+                    rendercommit(commitwin, state)
+                renderhelp(helpwin, state)
+                curses.doupdate()
+                # done rendering
+                ch = stdscr.getkey()
+        except curses.error:
+            pass
+
+def _chistedit(ui, repo, *freeargs, **opts):
+    """interactively edit changeset history via a curses interface
+
+    Provides a ncurses interface to histedit. Press ? in chistedit mode
+    to see an extensive help. Requires python-curses to be installed."""
+
+    if curses is None:
+        raise error.Abort(_("Python curses library required"))
+
+    # disable color
+    ui._colormode = None
+
+    try:
+        keep = opts.get('keep')
+        revs = opts.get('rev', [])[:]
+        cmdutil.checkunfinished(repo)
+        cmdutil.bailifchanged(repo)
+
+        if os.path.exists(os.path.join(repo.path, 'histedit-state')):
+            raise error.Abort(_('history edit already in progress, try '
+                               '--continue or --abort'))
+        revs.extend(freeargs)
+        if not revs:
+            defaultrev = destutil.desthistedit(ui, repo)
+            if defaultrev is not None:
+                revs.append(defaultrev)
+        if len(revs) != 1:
+            raise error.Abort(
+                _('histedit requires exactly one ancestor revision'))
+
+        rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
+        if len(rr) != 1:
+            raise error.Abort(_('The specified revisions must have '
+                'exactly one common root'))
+        root = rr[0].node()
+
+        topmost, empty = repo.dirstate.parents()
+        revs = between(repo, root, topmost, keep)
+        if not revs:
+            raise error.Abort(_('%s is not an ancestor of working directory') %
+                             node.short(root))
+
+        ctxs = []
+        for i, r in enumerate(revs):
+            ctxs.append(histeditrule(repo[r], i))
+        rc = curses.wrapper(functools.partial(_chisteditmain, repo, ctxs))
+        curses.echo()
+        curses.endwin()
+        if rc is False:
+            ui.write(_("chistedit aborted\n"))
+            return 0
+        if type(rc) is list:
+            ui.status(_("running histedit\n"))
+            rules = makecommands(rc)
+            filename = repo.vfs.join('chistedit')
+            with open(filename, 'w+') as fp:
+                for r in rules:
+                    fp.write(r)
+            opts['commands'] = filename
+            return _texthistedit(ui, repo, *freeargs, **opts)
+    except KeyboardInterrupt:
+        pass
+    return -1
+
 @command('histedit',
     [('', 'commands', '',
       _('read history edits from the specified file'), _('FILE')),
@@ -1029,6 +1593,11 @@
     for intentional "edit" command, but also for resolving unexpected
     conflicts).
     """
+    if ui.interface('histedit') == 'curses':
+        return _chistedit(ui, repo, *freeargs, **opts)
+    return _texthistedit(ui, repo, *freeargs, **opts)
+
+def _texthistedit(ui, repo, *freeargs, **opts):
     state = histeditstate(repo)
     try:
         state.wlock = repo.wlock()
--- a/mercurial/ui.py	Mon Nov 12 20:32:58 2018 -0500
+++ b/mercurial/ui.py	Wed Oct 17 17:15:42 2018 -0400
@@ -1245,7 +1245,11 @@
             "chunkselector": [
                 "text",
                 "curses",
-            ]
+            ],
+            "histedit": [
+                "text",
+                "curses",
+            ],
         }
 
         # Feature-specific interface