gertty/gertty/view/diff.py

517 lines
20 KiB
Python

# Copyright 2014 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import datetime
import logging
import urwid
from gertty import keymap
from gertty import mywid
from gertty import gitrepo
from gertty import sync
from gertty.view import mouse_scroll_decorator
class PatchsetDialog(urwid.WidgetWrap):
signals = ['ok', 'cancel']
def __init__(self, patchsets, old, new):
button_widgets = []
ok_button = mywid.FixedButton('OK')
cancel_button = mywid.FixedButton('Cancel')
urwid.connect_signal(ok_button, 'click',
lambda button:self._emit('ok'))
urwid.connect_signal(cancel_button, 'click',
lambda button:self._emit('cancel'))
button_widgets.append(('pack', ok_button))
button_widgets.append(('pack', cancel_button))
button_columns = urwid.Columns(button_widgets, dividechars=2)
left = []
right = []
left.append(urwid.Text('Old'))
right.append(urwid.Text('New'))
self.old_buttons = []
self.new_buttons = []
self.patchset_keys = {}
oldb = mywid.FixedRadioButton(self.old_buttons, 'Base',
state=(old==None))
left.append(oldb)
right.append(urwid.Text(''))
self.patchset_keys[oldb] = None
for key, num in patchsets:
oldb = mywid.FixedRadioButton(self.old_buttons, 'Patchset %d' % num,
state=(old==key))
newb = mywid.FixedRadioButton(self.new_buttons, 'Patchset %d' % num,
state=(new==key))
left.append(oldb)
right.append(newb)
self.patchset_keys[oldb] = key
self.patchset_keys[newb] = key
left = urwid.Pile(left)
right = urwid.Pile(right)
table = urwid.Columns([left, right])
rows = []
rows.append(table)
rows.append(urwid.Divider())
rows.append(button_columns)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
title = 'Patchsets'
super(PatchsetDialog, self).__init__(urwid.LineBox(fill, title))
def getSelected(self):
old = new = None
for b in self.old_buttons:
if b.state:
old = self.patchset_keys[b]
break
for b in self.new_buttons:
if b.state:
new = self.patchset_keys[b]
break
return old, new
class LineContext(object):
def __init__(self, old_file_key, new_file_key,
old_fn, new_fn, old_ln, new_ln,
header=False):
self.old_file_key = old_file_key
self.new_file_key = new_file_key
self.old_fn = old_fn
self.new_fn = new_fn
self.old_ln = old_ln
self.new_ln = new_ln
self.header = header
class BaseDiffCommentEdit(urwid.Columns):
pass
class BaseDiffComment(urwid.Columns):
pass
class BaseDiffLine(urwid.Button):
def selectable(self):
return True
class BaseFileHeader(urwid.Button):
def selectable(self):
return True
class BaseFileReminder(urwid.WidgetWrap):
pass
class DiffContextButton(urwid.WidgetWrap):
def selectable(self):
return True
def __init__(self, view, diff, chunk):
focus_map={'context-button':'focused-context-button'}
buttons = [mywid.FixedButton(('context-button', "Expand previous 10"),
on_press=self.prev),
mywid.FixedButton(('context-button', "Expand"),
on_press=self.all),
mywid.FixedButton(('context-button', "Expand next 10"),
on_press=self.next)]
self._buttons = buttons
buttons = [('pack', urwid.AttrMap(b, None, focus_map=focus_map)) for b in buttons]
buttons = urwid.Columns([urwid.Text('')] + buttons + [urwid.Text('')],
dividechars=4)
buttons = urwid.AttrMap(buttons, 'context-button')
super(DiffContextButton, self).__init__(buttons)
self.view = view
self.diff = diff
self.chunk = chunk
self.update()
def update(self):
self._buttons[1].set_label("Expand %s lines of context" %
(len(self.chunk.lines)),)
def prev(self, button):
self.view.expandChunk(self.diff, self.chunk, from_start=10)
def all(self, button):
self.view.expandChunk(self.diff, self.chunk, expand_all=True)
def next(self, button):
self.view.expandChunk(self.diff, self.chunk, from_end=-10)
@mouse_scroll_decorator.ScrollByWheel
class BaseDiffView(urwid.WidgetWrap):
def help(self):
key = self.app.config.keymap.formatKeys
return [
(key(keymap.ACTIVATE),
"Add an inline comment"),
(key(keymap.SELECT_PATCHSETS),
"Select old/new patchsets to diff"),
]
def __init__(self, app, new_revision_key):
super(BaseDiffView, self).__init__(urwid.Pile([]))
self.log = logging.getLogger('gertty.view.diff')
self.app = app
self.old_revision_key = None # Base
self.new_revision_key = new_revision_key
self._init()
def _init(self):
del self._w.contents[:]
with self.app.db.getSession() as session:
new_revision = session.getRevision(self.new_revision_key)
old_comments = []
new_comments = []
self.old_file_keys = {}
self.new_file_keys = {}
if self.old_revision_key is not None:
old_revision = session.getRevision(self.old_revision_key)
self.old_revision_num = old_revision.number
old_str = 'patchset %s' % self.old_revision_num
self.base_commit = old_revision.commit
for f in old_revision.files:
old_comments += f.comments
self.old_file_keys[f.path] = f.key
show_old_commit = True
else:
old_revision = None
self.old_revision_num = None
old_str = 'base'
self.base_commit = new_revision.parent
show_old_commit = False
# The old files are the same as the new files since we
# are diffing from base -> change, however, we should
# use the old file names for file lookup.
for f in new_revision.files:
if f.old_path:
self.old_file_keys[f.old_path] = f.key
else:
self.old_file_keys[f.path] = f.key
self.title = u'Diff of %s change %s from %s to patchset %s' % (
new_revision.change.project.name,
new_revision.change.number,
old_str, new_revision.number)
self.new_revision_num = new_revision.number
self.change_key = new_revision.change.key
self.project_name = new_revision.change.project.name
self.commit = new_revision.commit
for f in new_revision.files:
new_comments += f.comments
self.new_file_keys[f.path] = f.key
comment_lists = {}
comment_filenames = set()
for comment in new_comments:
path = comment.file.path
if comment.parent:
if old_revision: # we're not looking at the base
continue
key = 'old'
if comment.file.old_path:
path = comment.file.old_path
else:
key = 'new'
if comment.draft:
key += 'draft'
key += '-' + str(comment.line)
key += '-' + path
comment_list = comment_lists.get(key, [])
if comment.draft:
message = comment.message
else:
message = [('comment-name', comment.author.name),
('comment', u': '+comment.message)]
comment_list.append((comment.key, message))
comment_lists[key] = comment_list
comment_filenames.add(path)
for comment in old_comments:
if comment.parent:
continue
path = comment.file.path
key = 'old'
if comment.draft:
key += 'draft'
key += '-' + str(comment.line)
key += '-' + path
comment_list = comment_lists.get(key, [])
if comment.draft:
message = comment.message
else:
message = [('comment-name', comment.author.name),
('comment', u': '+comment.message)]
comment_list.append((comment.key, message))
comment_lists[key] = comment_list
comment_filenames.add(path)
repo = self.app.getRepo(self.project_name)
self._w.contents.append((self.app.header, ('pack', 1)))
self.file_reminder = self.makeFileReminder()
self._w.contents.append((self.file_reminder, ('pack', 1)))
lines = [] # The initial set of lines to display
self.file_diffs = [{}, {}] # Mapping of fn -> DiffFile object (old, new)
# this is a list of files:
diffs = repo.diff(self.base_commit, self.commit,
show_old_commit=show_old_commit)
for diff in diffs:
comment_filenames.discard(diff.oldname)
comment_filenames.discard(diff.newname)
# There are comments referring to these files which do not
# appear in the diff so we should create fake diff objects
# that contain the full text.
for filename in comment_filenames:
diff = repo.getFile(self.base_commit, self.commit, filename)
if diff:
diffs.append(diff)
else:
self.log.debug("Unable to find file %s in commit %s" % (filename, self.commit))
for i, diff in enumerate(diffs):
if i > 0:
lines.append(urwid.Text(''))
self.file_diffs[gitrepo.OLD][diff.oldname] = diff
self.file_diffs[gitrepo.NEW][diff.newname] = diff
lines.extend(self.makeFileHeader(diff, comment_lists))
for chunk in diff.chunks:
if chunk.context:
if not chunk.first:
lines += self.makeLines(diff, chunk.lines[:10], comment_lists)
del chunk.lines[:10]
button = DiffContextButton(self, diff, chunk)
chunk.button = button
lines.append(button)
if not chunk.last:
lines += self.makeLines(diff, chunk.lines[-10:], comment_lists)
del chunk.lines[-10:]
chunk.calcRange()
if not chunk.lines:
lines.remove(button)
else:
lines += self.makeLines(diff, chunk.lines, comment_lists)
listwalker = urwid.SimpleFocusListWalker(lines)
self.listbox = urwid.ListBox(listwalker)
self._w.contents.append((self.listbox, ('weight', 1)))
self.old_focus = 2
self.draft_comments = []
self._w.set_focus(self.old_focus)
self.handleUndisplayedComments(comment_lists)
self.app.status.update(title=self.title)
def handleUndisplayedComments(self, comment_lists):
# Handle comments that landed outside our default diff context
lastlen = 0
while comment_lists:
if len(comment_lists.keys()) == lastlen:
self.log.error("Unable to display all comments: %s" % comment_lists)
return
lastlen = len(comment_lists.keys())
key = comment_lists.keys()[0]
kind, lineno, path = key.split('-', 2)
lineno = int(lineno)
if kind.startswith('old'):
oldnew = gitrepo.OLD
else:
oldnew = gitrepo.NEW
file_diffs = self.file_diffs[oldnew]
if path not in file_diffs:
self.log.error("Unable to display comment: %s" % key)
del comment_lists[key]
continue
diff = self.file_diffs[oldnew][path]
for chunk in diff.chunks:
if (chunk.range[oldnew][gitrepo.START] <= lineno and
chunk.range[oldnew][gitrepo.END] >= lineno):
i = chunk.indexOfLine(oldnew, lineno)
if i < (len(chunk.lines) / 2):
from_start = True
else:
from_start = False
if chunk.first and from_start:
from_start = False
if chunk.last and (not from_start):
from_start = True
if from_start:
self.expandChunk(diff, chunk, comment_lists, from_start=i+10)
else:
self.expandChunk(diff, chunk, comment_lists, from_end=i-10)
break
def expandChunk(self, diff, chunk, comment_lists={}, from_start=None, from_end=None,
expand_all=None):
self.log.debug("Expand chunk %s %s %s" % (chunk, from_start, from_end))
add_lines = []
if from_start is not None:
index = self.listbox.body.index(chunk.button)
add_lines = chunk.lines[:from_start]
del chunk.lines[:from_start]
if from_end is not None:
index = self.listbox.body.index(chunk.button)+1
add_lines = chunk.lines[from_end:]
del chunk.lines[from_end:]
if expand_all:
index = self.listbox.body.index(chunk.button)
add_lines = chunk.lines[:]
del chunk.lines[:]
if add_lines:
lines = self.makeLines(diff, add_lines, comment_lists)
self.listbox.body[index:index] = lines
chunk.calcRange()
if not chunk.lines:
self.listbox.body.remove(chunk.button)
else:
chunk.button.update()
def makeContext(self, diff, old_ln, new_ln, header=False):
old_key = None
new_key = None
if not diff.old_empty:
if diff.oldname in self.old_file_keys:
old_key = self.old_file_keys[diff.oldname]
elif diff.newname in self.old_file_keys:
old_key = self.old_file_keys[diff.newname]
if not diff.new_empty:
new_key = self.new_file_keys[diff.newname]
return LineContext(
old_key, new_key,
diff.oldname, diff.newname,
old_ln, new_ln, header)
def makeLines(self, diff, lines_to_add, comment_lists):
raise NotImplementedError
def makeFileHeader(self, diff, comment_lists):
raise NotImplementedError
def makeFileReminder(self):
raise NotImplementedError
def interested(self, event):
if not ((isinstance(event, sync.ChangeAddedEvent) and
self.change_key in event.related_change_keys)
or
(isinstance(event, sync.ChangeUpdatedEvent) and
self.change_key in event.related_change_keys)):
#self.log.debug("Ignoring refresh diff due to event %s" % (event,))
return False
#self.log.debug("Refreshing diff due to event %s" % (event,))
return True
def refresh(self, event=None):
#TODO
pass
def getContextAtTop(self, size):
middle, top, bottom = self.listbox.calculate_visible(size, True)
if top and top[1]:
(widget, pos, rows) = top[1][-1]
elif middle:
pos = middle[2]
# Make sure the first header shows up as soon as it scrolls up
if pos > 1:
pos -= 1
context = None
while True:
item = self.listbox.body[pos]
if hasattr(item, 'context'):
break
pos -= 1
if pos > 0:
context = item.context
return context
def keypress(self, size, key):
old_focus = self.listbox.focus
r = super(BaseDiffView, self).keypress(size, key)
new_focus = self.listbox.focus
context = self.getContextAtTop(size)
if context:
self.file_reminder.set(context.old_fn,
context.new_fn)
else:
self.file_reminder.set('', '')
commands = self.app.config.keymap.getCommands(r)
if (isinstance(old_focus, BaseDiffCommentEdit) and
(old_focus != new_focus or (keymap.PREV_SCREEN in commands))):
self.cleanupEdit(old_focus)
if keymap.SELECT_PATCHSETS in commands:
self.openPatchsetDialog()
return None
return r
def mouse_event(self, size, event, button, x, y, focus):
old_focus = self.listbox.focus
r = super(BaseDiffView, self).mouse_event(size, event, button, x, y, focus)
new_focus = self.listbox.focus
if old_focus != new_focus and isinstance(old_focus, BaseDiffCommentEdit):
self.cleanupEdit(old_focus)
return r
def makeCommentEdit(self, edit):
raise NotImplementedError
def onSelect(self, button):
pos = self.listbox.focus_position
e = self.makeCommentEdit(self.listbox.body[pos])
self.listbox.body.insert(pos+1, e)
self.listbox.focus_position = pos+1
def cleanupEdit(self, edit):
raise NotImplementedError
def deleteComment(self, comment_key):
with self.app.db.getSession() as session:
comment = session.getComment(comment_key)
session.delete(comment)
def saveComment(self, context, text, new=True):
if (not new) and (not self.old_revision_num):
parent = True
else:
parent = False
if new:
line_num = context.new_ln
file_key = context.new_file_key
else:
line_num = context.old_ln
file_key = context.old_file_key
if file_key is None:
raise Exception("Comment is not associated with a file")
with self.app.db.getSession() as session:
fileojb = session.getFile(file_key)
account = session.getAccountByUsername(self.app.config.username)
comment = fileojb.createComment(None, account, None,
datetime.datetime.utcnow(),
parent,
line_num, text, draft=True)
key = comment.key
return key
def openPatchsetDialog(self):
revisions = []
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
for r in change.revisions:
revisions.append((r.key, r.number))
dialog = PatchsetDialog(revisions,
self.old_revision_key,
self.new_revision_key)
urwid.connect_signal(dialog, 'cancel',
lambda button: self.app.backScreen())
urwid.connect_signal(dialog, 'ok',
lambda button: self._openPatchsetDialog(dialog))
self.app.popup(dialog, min_width=30, min_height=8)
def _openPatchsetDialog(self, dialog):
self.app.backScreen()
self.old_revision_key, self.new_revision_key = dialog.getSelected()
self._init()