##// END OF EJS Templates
histedit: import chistedit curses UI from hg-experimental...
Augie Fackler -
r40959:c3617545 default
parent child Browse files
Show More
This diff has been collapsed as it changes many lines, (569 lines changed) Show them Hide them
@@ -183,7 +183,11 b' unexpectedly::'
183
183
184 from __future__ import absolute_import
184 from __future__ import absolute_import
185
185
186 import fcntl
187 import functools
186 import os
188 import os
189 import struct
190 import termios
187
191
188 from mercurial.i18n import _
192 from mercurial.i18n import _
189 from mercurial import (
193 from mercurial import (
@@ -198,6 +202,7 b' from mercurial import ('
198 extensions,
202 extensions,
199 hg,
203 hg,
200 lock,
204 lock,
205 logcmdutil,
201 merge as mergemod,
206 merge as mergemod,
202 mergeutil,
207 mergeutil,
203 node,
208 node,
@@ -235,6 +240,9 b" configitem('histedit', 'linelen',"
235 configitem('histedit', 'singletransaction',
240 configitem('histedit', 'singletransaction',
236 default=False,
241 default=False,
237 )
242 )
243 configitem('ui', 'interface.histedit',
244 default=None,
245 )
238
246
239 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
247 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
240 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
248 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
@@ -915,6 +923,562 b' def findoutgoing(ui, repo, remote=None, '
915 raise error.Abort(msg, hint=hint)
923 raise error.Abort(msg, hint=hint)
916 return repo[roots[0]].node()
924 return repo[roots[0]].node()
917
925
926 # Curses Support
927 try:
928 import curses
929 except ImportError:
930 curses = None
931
932 KEY_LIST = ['pick', 'edit', 'fold', 'drop', 'mess', 'roll']
933 ACTION_LABELS = {
934 'fold': '^fold',
935 'roll': '^roll',
936 }
937
938 COLOR_HELP, COLOR_SELECTED, COLOR_OK, COLOR_WARN = 1, 2, 3, 4
939
940 E_QUIT, E_HISTEDIT = 1, 2
941 E_PAGEDOWN, E_PAGEUP, E_LINEUP, E_LINEDOWN, E_RESIZE = 3, 4, 5, 6, 7
942 MODE_INIT, MODE_PATCH, MODE_RULES, MODE_HELP = 0, 1, 2, 3
943
944 KEYTABLE = {
945 'global': {
946 'h': 'next-action',
947 'KEY_RIGHT': 'next-action',
948 'l': 'prev-action',
949 'KEY_LEFT': 'prev-action',
950 'q': 'quit',
951 'c': 'histedit',
952 'C': 'histedit',
953 'v': 'showpatch',
954 '?': 'help',
955 },
956 MODE_RULES: {
957 'd': 'action-drop',
958 'e': 'action-edit',
959 'f': 'action-fold',
960 'm': 'action-mess',
961 'p': 'action-pick',
962 'r': 'action-roll',
963 ' ': 'select',
964 'j': 'down',
965 'k': 'up',
966 'KEY_DOWN': 'down',
967 'KEY_UP': 'up',
968 'J': 'move-down',
969 'K': 'move-up',
970 'KEY_NPAGE': 'move-down',
971 'KEY_PPAGE': 'move-up',
972 '0': 'goto', # Used for 0..9
973 },
974 MODE_PATCH: {
975 ' ': 'page-down',
976 'KEY_NPAGE': 'page-down',
977 'KEY_PPAGE': 'page-up',
978 'j': 'line-down',
979 'k': 'line-up',
980 'KEY_DOWN': 'line-down',
981 'KEY_UP': 'line-up',
982 'J': 'down',
983 'K': 'up',
984 },
985 MODE_HELP: {
986 },
987 }
988
989 def screen_size():
990 return struct.unpack('hh', fcntl.ioctl(1, termios.TIOCGWINSZ, ' '))
991
992 class histeditrule(object):
993 def __init__(self, ctx, pos, action='pick'):
994 self.ctx = ctx
995 self.action = action
996 self.origpos = pos
997 self.pos = pos
998 self.conflicts = []
999
1000 def __str__(self):
1001 # Some actions ('fold' and 'roll') combine a patch with a previous one.
1002 # Add a marker showing which patch they apply to, and also omit the
1003 # description for 'roll' (since it will get discarded). Example display:
1004 #
1005 # #10 pick 316392:06a16c25c053 add option to skip tests
1006 # #11 ^roll 316393:71313c964cc5
1007 # #12 pick 316394:ab31f3973b0d include mfbt for mozilla-config.h
1008 # #13 ^fold 316395:14ce5803f4c3 fix warnings
1009 #
1010 # The carets point to the changeset being folded into ("roll this
1011 # changeset into the changeset above").
1012 action = ACTION_LABELS.get(self.action, self.action)
1013 h = self.ctx.hex()[0:12]
1014 r = self.ctx.rev()
1015 desc = self.ctx.description().splitlines()[0].strip()
1016 if self.action == 'roll':
1017 desc = ''
1018 return "#{0:<2} {1:<6} {2}:{3} {4}".format(
1019 self.origpos, action, r, h, desc)
1020
1021 def checkconflicts(self, other):
1022 if other.pos > self.pos and other.origpos <= self.origpos:
1023 if set(other.ctx.files()) & set(self.ctx.files()) != set():
1024 self.conflicts.append(other)
1025 return self.conflicts
1026
1027 if other in self.conflicts:
1028 self.conflicts.remove(other)
1029 return self.conflicts
1030
1031 # ============ EVENTS ===============
1032 def movecursor(state, oldpos, newpos):
1033 '''Change the rule/changeset that the cursor is pointing to, regardless of
1034 current mode (you can switch between patches from the view patch window).'''
1035 state['pos'] = newpos
1036
1037 mode, _ = state['mode']
1038 if mode == MODE_RULES:
1039 # Scroll through the list by updating the view for MODE_RULES, so that
1040 # even if we are not currently viewing the rules, switching back will
1041 # result in the cursor's rule being visible.
1042 modestate = state['modes'][MODE_RULES]
1043 if newpos < modestate['line_offset']:
1044 modestate['line_offset'] = newpos
1045 elif newpos > modestate['line_offset'] + state['page_height'] - 1:
1046 modestate['line_offset'] = newpos - state['page_height'] + 1
1047
1048 # Reset the patch view region to the top of the new patch.
1049 state['modes'][MODE_PATCH]['line_offset'] = 0
1050
1051 def changemode(state, mode):
1052 curmode, _ = state['mode']
1053 state['mode'] = (mode, curmode)
1054
1055 def makeselection(state, pos):
1056 state['selected'] = pos
1057
1058 def swap(state, oldpos, newpos):
1059 """Swap two positions and calculate necessary conflicts in
1060 O(|newpos-oldpos|) time"""
1061
1062 rules = state['rules']
1063 assert 0 <= oldpos < len(rules) and 0 <= newpos < len(rules)
1064
1065 rules[oldpos], rules[newpos] = rules[newpos], rules[oldpos]
1066
1067 # TODO: swap should not know about histeditrule's internals
1068 rules[newpos].pos = newpos
1069 rules[oldpos].pos = oldpos
1070
1071 start = min(oldpos, newpos)
1072 end = max(oldpos, newpos)
1073 for r in pycompat.xrange(start, end + 1):
1074 rules[newpos].checkconflicts(rules[r])
1075 rules[oldpos].checkconflicts(rules[r])
1076
1077 if state['selected']:
1078 makeselection(state, newpos)
1079
1080 def changeaction(state, pos, action):
1081 """Change the action state on the given position to the new action"""
1082 rules = state['rules']
1083 assert 0 <= pos < len(rules)
1084 rules[pos].action = action
1085
1086 def cycleaction(state, pos, next=False):
1087 """Changes the action state the next or the previous action from
1088 the action list"""
1089 rules = state['rules']
1090 assert 0 <= pos < len(rules)
1091 current = rules[pos].action
1092
1093 assert current in KEY_LIST
1094
1095 index = KEY_LIST.index(current)
1096 if next:
1097 index += 1
1098 else:
1099 index -= 1
1100 changeaction(state, pos, KEY_LIST[index % len(KEY_LIST)])
1101
1102 def changeview(state, delta, unit):
1103 '''Change the region of whatever is being viewed (a patch or the list of
1104 changesets). 'delta' is an amount (+/- 1) and 'unit' is 'page' or 'line'.'''
1105 mode, _ = state['mode']
1106 if mode != MODE_PATCH:
1107 return
1108 mode_state = state['modes'][mode]
1109 num_lines = len(patchcontents(state))
1110 page_height = state['page_height']
1111 unit = page_height if unit == 'page' else 1
1112 num_pages = 1 + (num_lines - 1) / page_height
1113 max_offset = (num_pages - 1) * page_height
1114 newline = mode_state['line_offset'] + delta * unit
1115 mode_state['line_offset'] = max(0, min(max_offset, newline))
1116
1117 def event(state, ch):
1118 """Change state based on the current character input
1119
1120 This takes the current state and based on the current character input from
1121 the user we change the state.
1122 """
1123 selected = state['selected']
1124 oldpos = state['pos']
1125 rules = state['rules']
1126
1127 if ch in (curses.KEY_RESIZE, "KEY_RESIZE"):
1128 return E_RESIZE
1129
1130 lookup_ch = ch
1131 if '0' <= ch <= '9':
1132 lookup_ch = '0'
1133
1134 curmode, prevmode = state['mode']
1135 action = KEYTABLE[curmode].get(lookup_ch, KEYTABLE['global'].get(lookup_ch))
1136 if action is None:
1137 return
1138 if action in ('down', 'move-down'):
1139 newpos = min(oldpos + 1, len(rules) - 1)
1140 movecursor(state, oldpos, newpos)
1141 if selected is not None or action == 'move-down':
1142 swap(state, oldpos, newpos)
1143 elif action in ('up', 'move-up'):
1144 newpos = max(0, oldpos - 1)
1145 movecursor(state, oldpos, newpos)
1146 if selected is not None or action == 'move-up':
1147 swap(state, oldpos, newpos)
1148 elif action == 'next-action':
1149 cycleaction(state, oldpos, next=True)
1150 elif action == 'prev-action':
1151 cycleaction(state, oldpos, next=False)
1152 elif action == 'select':
1153 selected = oldpos if selected is None else None
1154 makeselection(state, selected)
1155 elif action == 'goto' and int(ch) < len(rules) and len(rules) <= 10:
1156 newrule = next((r for r in rules if r.origpos == int(ch)))
1157 movecursor(state, oldpos, newrule.pos)
1158 if selected is not None:
1159 swap(state, oldpos, newrule.pos)
1160 elif action.startswith('action-'):
1161 changeaction(state, oldpos, action[7:])
1162 elif action == 'showpatch':
1163 changemode(state, MODE_PATCH if curmode != MODE_PATCH else prevmode)
1164 elif action == 'help':
1165 changemode(state, MODE_HELP if curmode != MODE_HELP else prevmode)
1166 elif action == 'quit':
1167 return E_QUIT
1168 elif action == 'histedit':
1169 return E_HISTEDIT
1170 elif action == 'page-down':
1171 return E_PAGEDOWN
1172 elif action == 'page-up':
1173 return E_PAGEUP
1174 elif action == 'line-down':
1175 return E_LINEDOWN
1176 elif action == 'line-up':
1177 return E_LINEUP
1178
1179 def makecommands(rules):
1180 """Returns a list of commands consumable by histedit --commands based on
1181 our list of rules"""
1182 commands = []
1183 for rules in rules:
1184 commands.append("{0} {1}\n".format(rules.action, rules.ctx))
1185 return commands
1186
1187 def addln(win, y, x, line, color=None):
1188 """Add a line to the given window left padding but 100% filled with
1189 whitespace characters, so that the color appears on the whole line"""
1190 maxy, maxx = win.getmaxyx()
1191 length = maxx - 1 - x
1192 line = ("{0:<%d}" % length).format(str(line).strip())[:length]
1193 if y < 0:
1194 y = maxy + y
1195 if x < 0:
1196 x = maxx + x
1197 if color:
1198 win.addstr(y, x, line, color)
1199 else:
1200 win.addstr(y, x, line)
1201
1202 def patchcontents(state):
1203 repo = state['repo']
1204 rule = state['rules'][state['pos']]
1205 displayer = logcmdutil.changesetdisplayer(repo.ui, repo, {
1206 'patch': True, 'verbose': True
1207 }, buffered=True)
1208 displayer.show(rule.ctx)
1209 displayer.close()
1210 return displayer.hunk[rule.ctx.rev()].splitlines()
1211
1212 def _chisteditmain(repo, rules, stdscr):
1213 # initialize color pattern
1214 curses.init_pair(COLOR_HELP, curses.COLOR_WHITE, curses.COLOR_BLUE)
1215 curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE)
1216 curses.init_pair(COLOR_WARN, curses.COLOR_BLACK, curses.COLOR_YELLOW)
1217 curses.init_pair(COLOR_OK, curses.COLOR_BLACK, curses.COLOR_GREEN)
1218
1219 # don't display the cursor
1220 try:
1221 curses.curs_set(0)
1222 except curses.error:
1223 pass
1224
1225 def rendercommit(win, state):
1226 """Renders the commit window that shows the log of the current selected
1227 commit"""
1228 pos = state['pos']
1229 rules = state['rules']
1230 rule = rules[pos]
1231
1232 ctx = rule.ctx
1233 win.box()
1234
1235 maxy, maxx = win.getmaxyx()
1236 length = maxx - 3
1237
1238 line = "changeset: {0}:{1:<12}".format(ctx.rev(), ctx)
1239 win.addstr(1, 1, line[:length])
1240
1241 line = "user: {0}".format(stringutil.shortuser(ctx.user()))
1242 win.addstr(2, 1, line[:length])
1243
1244 bms = repo.nodebookmarks(ctx.node())
1245 line = "bookmark: {0}".format(' '.join(bms))
1246 win.addstr(3, 1, line[:length])
1247
1248 line = "files: {0}".format(','.join(ctx.files()))
1249 win.addstr(4, 1, line[:length])
1250
1251 line = "summary: {0}".format(ctx.description().splitlines()[0])
1252 win.addstr(5, 1, line[:length])
1253
1254 conflicts = rule.conflicts
1255 if len(conflicts) > 0:
1256 conflictstr = ','.join(map(lambda r: str(r.ctx), conflicts))
1257 conflictstr = "changed files overlap with {0}".format(conflictstr)
1258 else:
1259 conflictstr = 'no overlap'
1260
1261 win.addstr(6, 1, conflictstr[:length])
1262 win.noutrefresh()
1263
1264 def helplines(mode):
1265 if mode == MODE_PATCH:
1266 help = """\
1267 ?: help, k/up: line up, j/down: line down, v: stop viewing patch
1268 pgup: prev page, space/pgdn: next page, c: commit, q: abort
1269 """
1270 else:
1271 help = """\
1272 ?: help, k/up: move up, j/down: move down, space: select, v: view patch
1273 d: drop, e: edit, f: fold, m: mess, p: pick, r: roll
1274 pgup/K: move patch up, pgdn/J: move patch down, c: commit, q: abort
1275 """
1276 return help.splitlines()
1277
1278 def renderhelp(win, state):
1279 maxy, maxx = win.getmaxyx()
1280 mode, _ = state['mode']
1281 for y, line in enumerate(helplines(mode)):
1282 if y >= maxy:
1283 break
1284 addln(win, y, 0, line, curses.color_pair(COLOR_HELP))
1285 win.noutrefresh()
1286
1287 def renderrules(rulesscr, state):
1288 rules = state['rules']
1289 pos = state['pos']
1290 selected = state['selected']
1291 start = state['modes'][MODE_RULES]['line_offset']
1292
1293 conflicts = [r.ctx for r in rules if r.conflicts]
1294 if len(conflicts) > 0:
1295 line = "potential conflict in %s" % ','.join(map(str, conflicts))
1296 addln(rulesscr, -1, 0, line, curses.color_pair(COLOR_WARN))
1297
1298 for y, rule in enumerate(rules[start:]):
1299 if y >= state['page_height']:
1300 break
1301 if len(rule.conflicts) > 0:
1302 rulesscr.addstr(y, 0, " ", curses.color_pair(COLOR_WARN))
1303 else:
1304 rulesscr.addstr(y, 0, " ", curses.COLOR_BLACK)
1305 if y + start == selected:
1306 addln(rulesscr, y, 2, rule, curses.color_pair(COLOR_SELECTED))
1307 elif y + start == pos:
1308 addln(rulesscr, y, 2, rule, curses.A_BOLD)
1309 else:
1310 addln(rulesscr, y, 2, rule)
1311 rulesscr.noutrefresh()
1312
1313 def renderstring(win, state, output):
1314 maxy, maxx = win.getmaxyx()
1315 length = min(maxy - 1, len(output))
1316 for y in range(0, length):
1317 win.addstr(y, 0, output[y])
1318 win.noutrefresh()
1319
1320 def renderpatch(win, state):
1321 start = state['modes'][MODE_PATCH]['line_offset']
1322 renderstring(win, state, patchcontents(state)[start:])
1323
1324 def layout(mode):
1325 maxy, maxx = stdscr.getmaxyx()
1326 helplen = len(helplines(mode))
1327 return {
1328 'commit': (8, maxx),
1329 'help': (helplen, maxx),
1330 'main': (maxy - helplen - 8, maxx),
1331 }
1332
1333 def drawvertwin(size, y, x):
1334 win = curses.newwin(size[0], size[1], y, x)
1335 y += size[0]
1336 return win, y, x
1337
1338 state = {
1339 'pos': 0,
1340 'rules': rules,
1341 'selected': None,
1342 'mode': (MODE_INIT, MODE_INIT),
1343 'page_height': None,
1344 'modes': {
1345 MODE_RULES: {
1346 'line_offset': 0,
1347 },
1348 MODE_PATCH: {
1349 'line_offset': 0,
1350 }
1351 },
1352 'repo': repo,
1353 }
1354
1355 # eventloop
1356 ch = None
1357 stdscr.clear()
1358 stdscr.refresh()
1359 while True:
1360 try:
1361 oldmode, _ = state['mode']
1362 if oldmode == MODE_INIT:
1363 changemode(state, MODE_RULES)
1364 e = event(state, ch)
1365
1366 if e == E_QUIT:
1367 return False
1368 if e == E_HISTEDIT:
1369 return state['rules']
1370 else:
1371 if e == E_RESIZE:
1372 size = screen_size()
1373 if size != stdscr.getmaxyx():
1374 curses.resizeterm(*size)
1375
1376 curmode, _ = state['mode']
1377 sizes = layout(curmode)
1378 if curmode != oldmode:
1379 state['page_height'] = sizes['main'][0]
1380 # Adjust the view to fit the current screen size.
1381 movecursor(state, state['pos'], state['pos'])
1382
1383 # Pack the windows against the top, each pane spread across the
1384 # full width of the screen.
1385 y, x = (0, 0)
1386 helpwin, y, x = drawvertwin(sizes['help'], y, x)
1387 mainwin, y, x = drawvertwin(sizes['main'], y, x)
1388 commitwin, y, x = drawvertwin(sizes['commit'], y, x)
1389
1390 if e in (E_PAGEDOWN, E_PAGEUP, E_LINEDOWN, E_LINEUP):
1391 if e == E_PAGEDOWN:
1392 changeview(state, +1, 'page')
1393 elif e == E_PAGEUP:
1394 changeview(state, -1, 'page')
1395 elif e == E_LINEDOWN:
1396 changeview(state, +1, 'line')
1397 elif e == E_LINEUP:
1398 changeview(state, -1, 'line')
1399
1400 # start rendering
1401 commitwin.erase()
1402 helpwin.erase()
1403 mainwin.erase()
1404 if curmode == MODE_PATCH:
1405 renderpatch(mainwin, state)
1406 elif curmode == MODE_HELP:
1407 renderstring(mainwin, state, __doc__.strip().splitlines())
1408 else:
1409 renderrules(mainwin, state)
1410 rendercommit(commitwin, state)
1411 renderhelp(helpwin, state)
1412 curses.doupdate()
1413 # done rendering
1414 ch = stdscr.getkey()
1415 except curses.error:
1416 pass
1417
1418 def _chistedit(ui, repo, *freeargs, **opts):
1419 """interactively edit changeset history via a curses interface
1420
1421 Provides a ncurses interface to histedit. Press ? in chistedit mode
1422 to see an extensive help. Requires python-curses to be installed."""
1423
1424 if curses is None:
1425 raise error.Abort(_("Python curses library required"))
1426
1427 # disable color
1428 ui._colormode = None
1429
1430 try:
1431 keep = opts.get('keep')
1432 revs = opts.get('rev', [])[:]
1433 cmdutil.checkunfinished(repo)
1434 cmdutil.bailifchanged(repo)
1435
1436 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
1437 raise error.Abort(_('history edit already in progress, try '
1438 '--continue or --abort'))
1439 revs.extend(freeargs)
1440 if not revs:
1441 defaultrev = destutil.desthistedit(ui, repo)
1442 if defaultrev is not None:
1443 revs.append(defaultrev)
1444 if len(revs) != 1:
1445 raise error.Abort(
1446 _('histedit requires exactly one ancestor revision'))
1447
1448 rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
1449 if len(rr) != 1:
1450 raise error.Abort(_('The specified revisions must have '
1451 'exactly one common root'))
1452 root = rr[0].node()
1453
1454 topmost, empty = repo.dirstate.parents()
1455 revs = between(repo, root, topmost, keep)
1456 if not revs:
1457 raise error.Abort(_('%s is not an ancestor of working directory') %
1458 node.short(root))
1459
1460 ctxs = []
1461 for i, r in enumerate(revs):
1462 ctxs.append(histeditrule(repo[r], i))
1463 rc = curses.wrapper(functools.partial(_chisteditmain, repo, ctxs))
1464 curses.echo()
1465 curses.endwin()
1466 if rc is False:
1467 ui.write(_("chistedit aborted\n"))
1468 return 0
1469 if type(rc) is list:
1470 ui.status(_("running histedit\n"))
1471 rules = makecommands(rc)
1472 filename = repo.vfs.join('chistedit')
1473 with open(filename, 'w+') as fp:
1474 for r in rules:
1475 fp.write(r)
1476 opts['commands'] = filename
1477 return _texthistedit(ui, repo, *freeargs, **opts)
1478 except KeyboardInterrupt:
1479 pass
1480 return -1
1481
918 @command('histedit',
1482 @command('histedit',
919 [('', 'commands', '',
1483 [('', 'commands', '',
920 _('read history edits from the specified file'), _('FILE')),
1484 _('read history edits from the specified file'), _('FILE')),
@@ -1029,6 +1593,11 b' def histedit(ui, repo, *freeargs, **opts'
1029 for intentional "edit" command, but also for resolving unexpected
1593 for intentional "edit" command, but also for resolving unexpected
1030 conflicts).
1594 conflicts).
1031 """
1595 """
1596 if ui.interface('histedit') == 'curses':
1597 return _chistedit(ui, repo, *freeargs, **opts)
1598 return _texthistedit(ui, repo, *freeargs, **opts)
1599
1600 def _texthistedit(ui, repo, *freeargs, **opts):
1032 state = histeditstate(repo)
1601 state = histeditstate(repo)
1033 try:
1602 try:
1034 state.wlock = repo.wlock()
1603 state.wlock = repo.wlock()
@@ -1245,7 +1245,11 b' class ui(object):'
1245 "chunkselector": [
1245 "chunkselector": [
1246 "text",
1246 "text",
1247 "curses",
1247 "curses",
1248 ]
1248 ],
1249 "histedit": [
1250 "text",
1251 "curses",
1252 ],
1249 }
1253 }
1250
1254
1251 # Feature-specific interface
1255 # Feature-specific interface
General Comments 0
You need to be logged in to leave comments. Login now