Mercurial > hg
changeset 40602:c36175456350
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
author | Augie Fackler <augie@google.com> |
---|---|
date | Wed, 17 Oct 2018 17:15:42 -0400 |
parents | da4478ca0e32 |
children | 2f7e531ef3e7 |
files | hgext/histedit.py mercurial/ui.py |
diffstat | 2 files changed, 574 insertions(+), 1 deletions(-) [+] |
line wrap: on
line diff
--- 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()