# 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 gitrepo 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, mywid.LineBoxTitlePropertyMixin): 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 def search(self, search, attribute): pass class BaseFileHeader(urwid.Button): def selectable(self): return True def search(self, search, attribute): pass 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, mywid.Searchable): def getCommands(self): return [ (keymap.ACTIVATE, "Add an inline comment"), (keymap.SELECT_PATCHSETS, "Select old/new patchsets to diff"), (keymap.INTERACTIVE_SEARCH, "Interactive search"), ] def help(self): key = self.app.config.keymap.formatKeys commands = self.getCommands() return [(c[0], key(c[0]), c[1]) for c in commands] 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[:] self.searchInit() 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.short_title = u'Diff of %s' % (new_revision.change.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 = gitrepo.get_repo(self.project_name, self.app.config) 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() chunk.button.update() 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: comment_lists_keys = list(comment_lists.keys()) if len(comment_lists_keys) == lastlen: self.log.error("Unable to display all comments: %s" % comment_lists) return comment_lists_keys = list(comment_lists.keys()) 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=0-(len(chunk.lines)-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.get(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): if self.searchKeypress(size, key): return None old_focus = self.listbox.focus if not self.app.input_buffer: key = super(BaseDiffView, self).keypress(size, key) new_focus = self.listbox.focus keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) context = self.getContextAtTop(size) if context: self.file_reminder.set(context.old_fn, context.new_fn) else: self.file_reminder.set('', '') 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 if keymap.INTERACTIVE_SEARCH in commands: self.searchStart() return None return key 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: fileobj = session.getFile(file_key) account = session.getAccountByUsername(self.app.config.username) comment = fileobj.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()