1#! /usr/bin/env python
2
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2 of the License, or
6# (at your option) any later version.
7
8""" gitview
9GUI browser for git repository
10This program is based on bzrk by Scott James Remnant <scott@ubuntu.com>
11"""
12__copyright__ = "Copyright (C) 2006 Hewlett-Packard Development Company, L.P."
13__copyright__ = "Copyright (C) 2007 Aneesh Kumar K.V <aneesh.kumar@gmail.com"
14__author__ = "Aneesh Kumar K.V <aneesh.kumar@gmail.com>"
15
16
17import sys
18import os
19import gtk
20import pygtk
21import pango
22import re
23import time
24import gobject
25import cairo
26import math
27import string
28import fcntl
29
30try:
31 import gtksourceview
32 have_gtksourceview = True
33except ImportError:
34 have_gtksourceview = False
35 print "Running without gtksourceview module"
36
37re_ident = re.compile('(author|committer) (?P<ident>.*) (?P<epoch>\d+) (?P<tz>[+-]\d{4})')
38
39def list_to_string(args, skip):
40 count = len(args)
41 i = skip
42 str_arg=" "
43 while (i < count ):
44 str_arg = str_arg + args[i]
45 str_arg = str_arg + " "
46 i = i+1
47
48 return str_arg
49
50def show_date(epoch, tz):
51 secs = float(epoch)
52 tzsecs = float(tz[1:3]) * 3600
53 tzsecs += float(tz[3:5]) * 60
54 if (tz[0] == "+"):
55 secs += tzsecs
56 else:
57 secs -= tzsecs
58
59 return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(secs))
60
61
62class CellRendererGraph(gtk.GenericCellRenderer):
63 """Cell renderer for directed graph.
64
65 This module contains the implementation of a custom GtkCellRenderer that
66 draws part of the directed graph based on the lines suggested by the code
67 in graph.py.
68
69 Because we're shiny, we use Cairo to do this, and because we're naughty
70 we cheat and draw over the bits of the TreeViewColumn that are supposed to
71 just be for the background.
72
73 Properties:
74 node (column, colour, [ names ]) tuple to draw revision node,
75 in_lines (start, end, colour) tuple list to draw inward lines,
76 out_lines (start, end, colour) tuple list to draw outward lines.
77 """
78
79 __gproperties__ = {
80 "node": ( gobject.TYPE_PYOBJECT, "node",
81 "revision node instruction",
82 gobject.PARAM_WRITABLE
83 ),
84 "in-lines": ( gobject.TYPE_PYOBJECT, "in-lines",
85 "instructions to draw lines into the cell",
86 gobject.PARAM_WRITABLE
87 ),
88 "out-lines": ( gobject.TYPE_PYOBJECT, "out-lines",
89 "instructions to draw lines out of the cell",
90 gobject.PARAM_WRITABLE
91 ),
92 }
93
94 def do_set_property(self, property, value):
95 """Set properties from GObject properties."""
96 if property.name == "node":
97 self.node = value
98 elif property.name == "in-lines":
99 self.in_lines = value
100 elif property.name == "out-lines":
101 self.out_lines = value
102 else:
103 raise AttributeError, "no such property: '%s'" % property.name
104
105 def box_size(self, widget):
106 """Calculate box size based on widget's font.
107
108 Cache this as it's probably expensive to get. It ensures that we
109 draw the graph at least as large as the text.
110 """
111 try:
112 return self._box_size
113 except AttributeError:
114 pango_ctx = widget.get_pango_context()
115 font_desc = widget.get_style().font_desc
116 metrics = pango_ctx.get_metrics(font_desc)
117
118 ascent = pango.PIXELS(metrics.get_ascent())
119 descent = pango.PIXELS(metrics.get_descent())
120
121 self._box_size = ascent + descent + 6
122 return self._box_size
123
124 def set_colour(self, ctx, colour, bg, fg):
125 """Set the context source colour.
126
127 Picks a distinct colour based on an internal wheel; the bg
128 parameter provides the value that should be assigned to the 'zero'
129 colours and the fg parameter provides the multiplier that should be
130 applied to the foreground colours.
131 """
132 colours = [
133 ( 1.0, 0.0, 0.0 ),
134 ( 1.0, 1.0, 0.0 ),
135 ( 0.0, 1.0, 0.0 ),
136 ( 0.0, 1.0, 1.0 ),
137 ( 0.0, 0.0, 1.0 ),
138 ( 1.0, 0.0, 1.0 ),
139 ]
140
141 colour %= len(colours)
142 red = (colours[colour][0] * fg) or bg
143 green = (colours[colour][1] * fg) or bg
144 blue = (colours[colour][2] * fg) or bg
145
146 ctx.set_source_rgb(red, green, blue)
147
148 def on_get_size(self, widget, cell_area):
149 """Return the size we need for this cell.
150
151 Each cell is drawn individually and is only as wide as it needs
152 to be, we let the TreeViewColumn take care of making them all
153 line up.
154 """
155 box_size = self.box_size(widget)
156
157 cols = self.node[0]
158 for start, end, colour in self.in_lines + self.out_lines:
159 cols = int(max(cols, start, end))
160
161 (column, colour, names) = self.node
162 names_len = 0
163 if (len(names) != 0):
164 for item in names:
165 names_len += len(item)
166
167 width = box_size * (cols + 1 ) + names_len
168 height = box_size
169
170 # FIXME I have no idea how to use cell_area properly
171 return (0, 0, width, height)
172
173 def on_render(self, window, widget, bg_area, cell_area, exp_area, flags):
174 """Render an individual cell.
175
176 Draws the cell contents using cairo, taking care to clip what we
177 do to within the background area so we don't draw over other cells.
178 Note that we're a bit naughty there and should really be drawing
179 in the cell_area (or even the exposed area), but we explicitly don't
180 want any gutter.
181
182 We try and be a little clever, if the line we need to draw is going
183 to cross other columns we actually draw it as in the .---' style
184 instead of a pure diagonal ... this reduces confusion by an
185 incredible amount.
186 """
187 ctx = window.cairo_create()
188 ctx.rectangle(bg_area.x, bg_area.y, bg_area.width, bg_area.height)
189 ctx.clip()
190
191 box_size = self.box_size(widget)
192
193 ctx.set_line_width(box_size / 8)
194 ctx.set_line_cap(cairo.LINE_CAP_SQUARE)
195
196 # Draw lines into the cell
197 for start, end, colour in self.in_lines:
198 ctx.move_to(cell_area.x + box_size * start + box_size / 2,
199 bg_area.y - bg_area.height / 2)
200
201 if start - end > 1:
202 ctx.line_to(cell_area.x + box_size * start, bg_area.y)
203 ctx.line_to(cell_area.x + box_size * end + box_size, bg_area.y)
204 elif start - end < -1:
205 ctx.line_to(cell_area.x + box_size * start + box_size,
206 bg_area.y)
207 ctx.line_to(cell_area.x + box_size * end, bg_area.y)
208
209 ctx.line_to(cell_area.x + box_size * end + box_size / 2,
210 bg_area.y + bg_area.height / 2)
211
212 self.set_colour(ctx, colour, 0.0, 0.65)
213 ctx.stroke()
214
215 # Draw lines out of the cell
216 for start, end, colour in self.out_lines:
217 ctx.move_to(cell_area.x + box_size * start + box_size / 2,
218 bg_area.y + bg_area.height / 2)
219
220 if start - end > 1:
221 ctx.line_to(cell_area.x + box_size * start,
222 bg_area.y + bg_area.height)
223 ctx.line_to(cell_area.x + box_size * end + box_size,
224 bg_area.y + bg_area.height)
225 elif start - end < -1:
226 ctx.line_to(cell_area.x + box_size * start + box_size,
227 bg_area.y + bg_area.height)
228 ctx.line_to(cell_area.x + box_size * end,
229 bg_area.y + bg_area.height)
230
231 ctx.line_to(cell_area.x + box_size * end + box_size / 2,
232 bg_area.y + bg_area.height / 2 + bg_area.height)
233
234 self.set_colour(ctx, colour, 0.0, 0.65)
235 ctx.stroke()
236
237 # Draw the revision node in the right column
238 (column, colour, names) = self.node
239 ctx.arc(cell_area.x + box_size * column + box_size / 2,
240 cell_area.y + cell_area.height / 2,
241 box_size / 4, 0, 2 * math.pi)
242
243
244 self.set_colour(ctx, colour, 0.0, 0.5)
245 ctx.stroke_preserve()
246
247 self.set_colour(ctx, colour, 0.5, 1.0)
248 ctx.fill_preserve()
249
250 if (len(names) != 0):
251 name = " "
252 for item in names:
253 name = name + item + " "
254
255 ctx.set_font_size(13)
256 if (flags & 1):
257 self.set_colour(ctx, colour, 0.5, 1.0)
258 else:
259 self.set_colour(ctx, colour, 0.0, 0.5)
260 ctx.show_text(name)
261
262class Commit(object):
263 """ This represent a commit object obtained after parsing the git-rev-list
264 output """
265
266 __slots__ = ['children_sha1', 'message', 'author', 'date', 'committer',
267 'commit_date', 'commit_sha1', 'parent_sha1']
268
269 children_sha1 = {}
270
271 def __init__(self, commit_lines):
272 self.message = ""
273 self.author = ""
274 self.date = ""
275 self.committer = ""
276 self.commit_date = ""
277 self.commit_sha1 = ""
278 self.parent_sha1 = [ ]
279 self.parse_commit(commit_lines)
280
281
282 def parse_commit(self, commit_lines):
283
284 # First line is the sha1 lines
285 line = string.strip(commit_lines[0])
286 sha1 = re.split(" ", line)
287 self.commit_sha1 = sha1[0]
288 self.parent_sha1 = sha1[1:]
289
290 #build the child list
291 for parent_id in self.parent_sha1:
292 try:
293 Commit.children_sha1[parent_id].append(self.commit_sha1)
294 except KeyError:
295 Commit.children_sha1[parent_id] = [self.commit_sha1]
296
297 # IF we don't have parent
298 if (len(self.parent_sha1) == 0):
299 self.parent_sha1 = [0]
300
301 for line in commit_lines[1:]:
302 m = re.match("^ ", line)
303 if (m != None):
304 # First line of the commit message used for short log
305 if self.message == "":
306 self.message = string.strip(line)
307 continue
308
309 m = re.match("tree", line)
310 if (m != None):
311 continue
312
313 m = re.match("parent", line)
314 if (m != None):
315 continue
316
317 m = re_ident.match(line)
318 if (m != None):
319 date = show_date(m.group('epoch'), m.group('tz'))
320 if m.group(1) == "author":
321 self.author = m.group('ident')
322 self.date = date
323 elif m.group(1) == "committer":
324 self.committer = m.group('ident')
325 self.commit_date = date
326
327 continue
328
329 def get_message(self, with_diff=0):
330 if (with_diff == 1):
331 message = self.diff_tree()
332 else:
333 fp = os.popen("git cat-file commit " + self.commit_sha1)
334 message = fp.read()
335 fp.close()
336
337 return message
338
339 def diff_tree(self):
340 fp = os.popen("git diff-tree --pretty --cc -v -p --always " + self.commit_sha1)
341 diff = fp.read()
342 fp.close()
343 return diff
344
345class AnnotateWindow(object):
346 """Annotate window.
347 This object represents and manages a single window containing the
348 annotate information of the file
349 """
350
351 def __init__(self):
352 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
353 self.window.set_border_width(0)
354 self.window.set_title("Git repository browser annotation window")
355 self.prev_read = ""
356
357 # Use two thirds of the screen by default
358 screen = self.window.get_screen()
359 monitor = screen.get_monitor_geometry(0)
360 width = int(monitor.width * 0.66)
361 height = int(monitor.height * 0.66)
362 self.window.set_default_size(width, height)
363
364 def add_file_data(self, filename, commit_sha1, line_num):
365 fp = os.popen("git cat-file blob " + commit_sha1 +":"+filename)
366 i = 1;
367 for line in fp.readlines():
368 line = string.rstrip(line)
369 self.model.append(None, ["HEAD", filename, line, i])
370 i = i+1
371 fp.close()
372
373 # now set the cursor position
374 self.treeview.set_cursor(line_num-1)
375 self.treeview.grab_focus()
376
377 def _treeview_cursor_cb(self, *args):
378 """Callback for when the treeview cursor changes."""
379 (path, col) = self.treeview.get_cursor()
380 commit_sha1 = self.model[path][0]
381 commit_msg = ""
382 fp = os.popen("git cat-file commit " + commit_sha1)
383 for line in fp.readlines():
384 commit_msg = commit_msg + line
385 fp.close()
386
387 self.commit_buffer.set_text(commit_msg)
388
389 def _treeview_row_activated(self, *args):
390 """Callback for when the treeview row gets selected."""
391 (path, col) = self.treeview.get_cursor()
392 commit_sha1 = self.model[path][0]
393 filename = self.model[path][1]
394 line_num = self.model[path][3]
395
396 window = AnnotateWindow();
397 fp = os.popen("git rev-parse "+ commit_sha1 + "~1")
398 commit_sha1 = string.strip(fp.readline())
399 fp.close()
400 window.annotate(filename, commit_sha1, line_num)
401
402 def data_ready(self, source, condition):
403 while (1):
404 try :
405 # A simple readline doesn't work
406 # a readline bug ??
407 buffer = source.read(100)
408
409 except:
410 # resource temporary not available
411 return True
412
413 if (len(buffer) == 0):
414 gobject.source_remove(self.io_watch_tag)
415 source.close()
416 return False
417
418 if (self.prev_read != ""):
419 buffer = self.prev_read + buffer
420 self.prev_read = ""
421
422 if (buffer[len(buffer) -1] != '\n'):
423 try:
424 newline_index = buffer.rindex("\n")
425 except ValueError:
426 newline_index = 0
427
428 self.prev_read = buffer[newline_index:(len(buffer))]
429 buffer = buffer[0:newline_index]
430
431 for buff in buffer.split("\n"):
432 annotate_line = re.compile('^([0-9a-f]{40}) (.+) (.+) (.+)$')
433 m = annotate_line.match(buff)
434 if not m:
435 annotate_line = re.compile('^(filename) (.+)$')
436 m = annotate_line.match(buff)
437 if not m:
438 continue
439 filename = m.group(2)
440 else:
441 self.commit_sha1 = m.group(1)
442 self.source_line = int(m.group(2))
443 self.result_line = int(m.group(3))
444 self.count = int(m.group(4))
445 #set the details only when we have the file name
446 continue
447
448 while (self.count > 0):
449 # set at result_line + count-1 the sha1 as commit_sha1
450 self.count = self.count - 1
451 iter = self.model.iter_nth_child(None, self.result_line + self.count-1)
452 self.model.set(iter, 0, self.commit_sha1, 1, filename, 3, self.source_line)
453
454
455 def annotate(self, filename, commit_sha1, line_num):
456 # verify the commit_sha1 specified has this filename
457
458 fp = os.popen("git ls-tree "+ commit_sha1 + " -- " + filename)
459 line = string.strip(fp.readline())
460 if line == '':
461 # pop up the message the file is not there as a part of the commit
462 fp.close()
463 dialog = gtk.MessageDialog(parent=None, flags=0,
464 type=gtk.MESSAGE_WARNING, buttons=gtk.BUTTONS_CLOSE,
465 message_format=None)
466 dialog.set_markup("The file %s is not present in the parent commit %s" % (filename, commit_sha1))
467 dialog.run()
468 dialog.destroy()
469 return
470
471 fp.close()
472
473 vpan = gtk.VPaned();
474 self.window.add(vpan);
475 vpan.show()
476
477 scrollwin = gtk.ScrolledWindow()
478 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
479 scrollwin.set_shadow_type(gtk.SHADOW_IN)
480 vpan.pack1(scrollwin, True, True);
481 scrollwin.show()
482
483 self.model = gtk.TreeStore(str, str, str, int)
484 self.treeview = gtk.TreeView(self.model)
485 self.treeview.set_rules_hint(True)
486 self.treeview.set_search_column(0)
487 self.treeview.connect("cursor-changed", self._treeview_cursor_cb)
488 self.treeview.connect("row-activated", self._treeview_row_activated)
489 scrollwin.add(self.treeview)
490 self.treeview.show()
491
492 cell = gtk.CellRendererText()
493 cell.set_property("width-chars", 10)
494 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
495 column = gtk.TreeViewColumn("Commit")
496 column.set_resizable(True)
497 column.pack_start(cell, expand=True)
498 column.add_attribute(cell, "text", 0)
499 self.treeview.append_column(column)
500
501 cell = gtk.CellRendererText()
502 cell.set_property("width-chars", 20)
503 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
504 column = gtk.TreeViewColumn("File Name")
505 column.set_resizable(True)
506 column.pack_start(cell, expand=True)
507 column.add_attribute(cell, "text", 1)
508 self.treeview.append_column(column)
509
510 cell = gtk.CellRendererText()
511 cell.set_property("width-chars", 20)
512 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
513 column = gtk.TreeViewColumn("Data")
514 column.set_resizable(True)
515 column.pack_start(cell, expand=True)
516 column.add_attribute(cell, "text", 2)
517 self.treeview.append_column(column)
518
519 # The commit message window
520 scrollwin = gtk.ScrolledWindow()
521 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
522 scrollwin.set_shadow_type(gtk.SHADOW_IN)
523 vpan.pack2(scrollwin, True, True);
524 scrollwin.show()
525
526 commit_text = gtk.TextView()
527 self.commit_buffer = gtk.TextBuffer()
528 commit_text.set_buffer(self.commit_buffer)
529 scrollwin.add(commit_text)
530 commit_text.show()
531
532 self.window.show()
533
534 self.add_file_data(filename, commit_sha1, line_num)
535
536 fp = os.popen("git blame --incremental -C -C -- " + filename + " " + commit_sha1)
537 flags = fcntl.fcntl(fp.fileno(), fcntl.F_GETFL)
538 fcntl.fcntl(fp.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
539 self.io_watch_tag = gobject.io_add_watch(fp, gobject.IO_IN, self.data_ready)
540
541
542class DiffWindow(object):
543 """Diff window.
544 This object represents and manages a single window containing the
545 differences between two revisions on a branch.
546 """
547
548 def __init__(self):
549 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
550 self.window.set_border_width(0)
551 self.window.set_title("Git repository browser diff window")
552
553 # Use two thirds of the screen by default
554 screen = self.window.get_screen()
555 monitor = screen.get_monitor_geometry(0)
556 width = int(monitor.width * 0.66)
557 height = int(monitor.height * 0.66)
558 self.window.set_default_size(width, height)
559
560
561 self.construct()
562
563 def construct(self):
564 """Construct the window contents."""
565 vbox = gtk.VBox()
566 self.window.add(vbox)
567 vbox.show()
568
569 menu_bar = gtk.MenuBar()
570 save_menu = gtk.ImageMenuItem(gtk.STOCK_SAVE)
571 save_menu.connect("activate", self.save_menu_response, "save")
572 save_menu.show()
573 menu_bar.append(save_menu)
574 vbox.pack_start(menu_bar, expand=False, fill=True)
575 menu_bar.show()
576
577 hpan = gtk.HPaned()
578
579 scrollwin = gtk.ScrolledWindow()
580 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
581 scrollwin.set_shadow_type(gtk.SHADOW_IN)
582 hpan.pack1(scrollwin, True, True)
583 scrollwin.show()
584
585 if have_gtksourceview:
586 self.buffer = gtksourceview.SourceBuffer()
587 slm = gtksourceview.SourceLanguagesManager()
588 gsl = slm.get_language_from_mime_type("text/x-patch")
589 self.buffer.set_highlight(True)
590 self.buffer.set_language(gsl)
591 sourceview = gtksourceview.SourceView(self.buffer)
592 else:
593 self.buffer = gtk.TextBuffer()
594 sourceview = gtk.TextView(self.buffer)
595
596
597 sourceview.set_editable(False)
598 sourceview.modify_font(pango.FontDescription("Monospace"))
599 scrollwin.add(sourceview)
600 sourceview.show()
601
602 # The file hierarchy: a scrollable treeview
603 scrollwin = gtk.ScrolledWindow()
604 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
605 scrollwin.set_shadow_type(gtk.SHADOW_IN)
606 scrollwin.set_size_request(20, -1)
607 hpan.pack2(scrollwin, True, True)
608 scrollwin.show()
609
610 self.model = gtk.TreeStore(str, str, str)
611 self.treeview = gtk.TreeView(self.model)
612 self.treeview.set_search_column(1)
613 self.treeview.connect("cursor-changed", self._treeview_clicked)
614 scrollwin.add(self.treeview)
615 self.treeview.show()
616
617 cell = gtk.CellRendererText()
618 cell.set_property("width-chars", 20)
619 column = gtk.TreeViewColumn("Select to annotate")
620 column.pack_start(cell, expand=True)
621 column.add_attribute(cell, "text", 0)
622 self.treeview.append_column(column)
623
624 vbox.pack_start(hpan, expand=True, fill=True)
625 hpan.show()
626
627 def _treeview_clicked(self, *args):
628 """Callback for when the treeview cursor changes."""
629 (path, col) = self.treeview.get_cursor()
630 specific_file = self.model[path][1]
631 commit_sha1 = self.model[path][2]
632 if specific_file == None :
633 return
634 elif specific_file == "" :
635 specific_file = None
636
637 window = AnnotateWindow();
638 window.annotate(specific_file, commit_sha1, 1)
639
640
641 def commit_files(self, commit_sha1, parent_sha1):
642 self.model.clear()
643 add = self.model.append(None, [ "Added", None, None])
644 dele = self.model.append(None, [ "Deleted", None, None])
645 mod = self.model.append(None, [ "Modified", None, None])
646 diff_tree = re.compile('^(:.{6}) (.{6}) (.{40}) (.{40}) (A|D|M)\s(.+)$')
647 fp = os.popen("git diff-tree -r --no-commit-id " + parent_sha1 + " " + commit_sha1)
648 while 1:
649 line = string.strip(fp.readline())
650 if line == '':
651 break
652 m = diff_tree.match(line)
653 if not m:
654 continue
655
656 attr = m.group(5)
657 filename = m.group(6)
658 if attr == "A":
659 self.model.append(add, [filename, filename, commit_sha1])
660 elif attr == "D":
661 self.model.append(dele, [filename, filename, commit_sha1])
662 elif attr == "M":
663 self.model.append(mod, [filename, filename, commit_sha1])
664 fp.close()
665
666 self.treeview.expand_all()
667
668 def set_diff(self, commit_sha1, parent_sha1, encoding):
669 """Set the differences showed by this window.
670 Compares the two trees and populates the window with the
671 differences.
672 """
673 # Diff with the first commit or the last commit shows nothing
674 if (commit_sha1 == 0 or parent_sha1 == 0 ):
675 return
676
677 fp = os.popen("git diff-tree -p " + parent_sha1 + " " + commit_sha1)
678 self.buffer.set_text(unicode(fp.read(), encoding).encode('utf-8'))
679 fp.close()
680 self.commit_files(commit_sha1, parent_sha1)
681 self.window.show()
682
683 def save_menu_response(self, widget, string):
684 dialog = gtk.FileChooserDialog("Save..", None, gtk.FILE_CHOOSER_ACTION_SAVE,
685 (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
686 gtk.STOCK_SAVE, gtk.RESPONSE_OK))
687 dialog.set_default_response(gtk.RESPONSE_OK)
688 response = dialog.run()
689 if response == gtk.RESPONSE_OK:
690 patch_buffer = self.buffer.get_text(self.buffer.get_start_iter(),
691 self.buffer.get_end_iter())
692 fp = open(dialog.get_filename(), "w")
693 fp.write(patch_buffer)
694 fp.close()
695 dialog.destroy()
696
697class GitView(object):
698 """ This is the main class
699 """
700 version = "0.9"
701
702 def __init__(self, with_diff=0):
703 self.with_diff = with_diff
704 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
705 self.window.set_border_width(0)
706 self.window.set_title("Git repository browser")
707
708 self.get_encoding()
709 self.get_bt_sha1()
710
711 # Use three-quarters of the screen by default
712 screen = self.window.get_screen()
713 monitor = screen.get_monitor_geometry(0)
714 width = int(monitor.width * 0.75)
715 height = int(monitor.height * 0.75)
716 self.window.set_default_size(width, height)
717
718 # FIXME AndyFitz!
719 icon = self.window.render_icon(gtk.STOCK_INDEX, gtk.ICON_SIZE_BUTTON)
720 self.window.set_icon(icon)
721
722 self.accel_group = gtk.AccelGroup()
723 self.window.add_accel_group(self.accel_group)
724 self.accel_group.connect_group(0xffc2, 0, gtk.ACCEL_LOCKED, self.refresh);
725 self.accel_group.connect_group(0xffc1, 0, gtk.ACCEL_LOCKED, self.maximize);
726 self.accel_group.connect_group(0xffc8, 0, gtk.ACCEL_LOCKED, self.fullscreen);
727 self.accel_group.connect_group(0xffc9, 0, gtk.ACCEL_LOCKED, self.unfullscreen);
728
729 self.window.add(self.construct())
730
731 def refresh(self, widget, event=None, *arguments, **keywords):
732 self.get_encoding()
733 self.get_bt_sha1()
734 Commit.children_sha1 = {}
735 self.set_branch(sys.argv[without_diff:])
736 self.window.show()
737 return True
738
739 def maximize(self, widget, event=None, *arguments, **keywords):
740 self.window.maximize()
741 return True
742
743 def fullscreen(self, widget, event=None, *arguments, **keywords):
744 self.window.fullscreen()
745 return True
746
747 def unfullscreen(self, widget, event=None, *arguments, **keywords):
748 self.window.unfullscreen()
749 return True
750
751 def get_bt_sha1(self):
752 """ Update the bt_sha1 dictionary with the
753 respective sha1 details """
754
755 self.bt_sha1 = { }
756 ls_remote = re.compile('^(.{40})\trefs/([^^]+)(?:\\^(..))?$');
757 fp = os.popen('git ls-remote "${GIT_DIR-.git}"')
758 while 1:
759 line = string.strip(fp.readline())
760 if line == '':
761 break
762 m = ls_remote.match(line)
763 if not m:
764 continue
765 (sha1, name) = (m.group(1), m.group(2))
766 if not self.bt_sha1.has_key(sha1):
767 self.bt_sha1[sha1] = []
768 self.bt_sha1[sha1].append(name)
769 fp.close()
770
771 def get_encoding(self):
772 fp = os.popen("git config --get i18n.commitencoding")
773 self.encoding=string.strip(fp.readline())
774 fp.close()
775 if (self.encoding == ""):
776 self.encoding = "utf-8"
777
778
779 def construct(self):
780 """Construct the window contents."""
781 vbox = gtk.VBox()
782 paned = gtk.VPaned()
783 paned.pack1(self.construct_top(), resize=False, shrink=True)
784 paned.pack2(self.construct_bottom(), resize=False, shrink=True)
785 menu_bar = gtk.MenuBar()
786 menu_bar.set_pack_direction(gtk.PACK_DIRECTION_RTL)
787 help_menu = gtk.MenuItem("Help")
788 menu = gtk.Menu()
789 about_menu = gtk.MenuItem("About")
790 menu.append(about_menu)
791 about_menu.connect("activate", self.about_menu_response, "about")
792 about_menu.show()
793 help_menu.set_submenu(menu)
794 help_menu.show()
795 menu_bar.append(help_menu)
796 menu_bar.show()
797 vbox.pack_start(menu_bar, expand=False, fill=True)
798 vbox.pack_start(paned, expand=True, fill=True)
799 paned.show()
800 vbox.show()
801 return vbox
802
803
804 def construct_top(self):
805 """Construct the top-half of the window."""
806 vbox = gtk.VBox(spacing=6)
807 vbox.set_border_width(12)
808 vbox.show()
809
810
811 scrollwin = gtk.ScrolledWindow()
812 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
813 scrollwin.set_shadow_type(gtk.SHADOW_IN)
814 vbox.pack_start(scrollwin, expand=True, fill=True)
815 scrollwin.show()
816
817 self.treeview = gtk.TreeView()
818 self.treeview.set_rules_hint(True)
819 self.treeview.set_search_column(4)
820 self.treeview.connect("cursor-changed", self._treeview_cursor_cb)
821 scrollwin.add(self.treeview)
822 self.treeview.show()
823
824 cell = CellRendererGraph()
825 column = gtk.TreeViewColumn()
826 column.set_resizable(True)
827 column.pack_start(cell, expand=True)
828 column.add_attribute(cell, "node", 1)
829 column.add_attribute(cell, "in-lines", 2)
830 column.add_attribute(cell, "out-lines", 3)
831 self.treeview.append_column(column)
832
833 cell = gtk.CellRendererText()
834 cell.set_property("width-chars", 65)
835 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
836 column = gtk.TreeViewColumn("Message")
837 column.set_resizable(True)
838 column.pack_start(cell, expand=True)
839 column.add_attribute(cell, "text", 4)
840 self.treeview.append_column(column)
841
842 cell = gtk.CellRendererText()
843 cell.set_property("width-chars", 40)
844 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
845 column = gtk.TreeViewColumn("Author")
846 column.set_resizable(True)
847 column.pack_start(cell, expand=True)
848 column.add_attribute(cell, "text", 5)
849 self.treeview.append_column(column)
850
851 cell = gtk.CellRendererText()
852 cell.set_property("ellipsize", pango.ELLIPSIZE_END)
853 column = gtk.TreeViewColumn("Date")
854 column.set_resizable(True)
855 column.pack_start(cell, expand=True)
856 column.add_attribute(cell, "text", 6)
857 self.treeview.append_column(column)
858
859 return vbox
860
861 def about_menu_response(self, widget, string):
862 dialog = gtk.AboutDialog()
863 dialog.set_name("Gitview")
864 dialog.set_version(GitView.version)
865 dialog.set_authors(["Aneesh Kumar K.V <aneesh.kumar@gmail.com>"])
866 dialog.set_website("http://www.kernel.org/pub/software/scm/git/")
867 dialog.set_copyright("Use and distribute under the terms of the GNU General Public License")
868 dialog.set_wrap_license(True)
869 dialog.run()
870 dialog.destroy()
871
872
873 def construct_bottom(self):
874 """Construct the bottom half of the window."""
875 vbox = gtk.VBox(False, spacing=6)
876 vbox.set_border_width(12)
877 (width, height) = self.window.get_size()
878 vbox.set_size_request(width, int(height / 2.5))
879 vbox.show()
880
881 self.table = gtk.Table(rows=4, columns=4)
882 self.table.set_row_spacings(6)
883 self.table.set_col_spacings(6)
884 vbox.pack_start(self.table, expand=False, fill=True)
885 self.table.show()
886
887 align = gtk.Alignment(0.0, 0.5)
888 label = gtk.Label()
889 label.set_markup("<b>Revision:</b>")
890 align.add(label)
891 self.table.attach(align, 0, 1, 0, 1, gtk.FILL, gtk.FILL)
892 label.show()
893 align.show()
894
895 align = gtk.Alignment(0.0, 0.5)
896 self.revid_label = gtk.Label()
897 self.revid_label.set_selectable(True)
898 align.add(self.revid_label)
899 self.table.attach(align, 1, 2, 0, 1, gtk.EXPAND | gtk.FILL, gtk.FILL)
900 self.revid_label.show()
901 align.show()
902
903 align = gtk.Alignment(0.0, 0.5)
904 label = gtk.Label()
905 label.set_markup("<b>Committer:</b>")
906 align.add(label)
907 self.table.attach(align, 0, 1, 1, 2, gtk.FILL, gtk.FILL)
908 label.show()
909 align.show()
910
911 align = gtk.Alignment(0.0, 0.5)
912 self.committer_label = gtk.Label()
913 self.committer_label.set_selectable(True)
914 align.add(self.committer_label)
915 self.table.attach(align, 1, 2, 1, 2, gtk.EXPAND | gtk.FILL, gtk.FILL)
916 self.committer_label.show()
917 align.show()
918
919 align = gtk.Alignment(0.0, 0.5)
920 label = gtk.Label()
921 label.set_markup("<b>Timestamp:</b>")
922 align.add(label)
923 self.table.attach(align, 0, 1, 2, 3, gtk.FILL, gtk.FILL)
924 label.show()
925 align.show()
926
927 align = gtk.Alignment(0.0, 0.5)
928 self.timestamp_label = gtk.Label()
929 self.timestamp_label.set_selectable(True)
930 align.add(self.timestamp_label)
931 self.table.attach(align, 1, 2, 2, 3, gtk.EXPAND | gtk.FILL, gtk.FILL)
932 self.timestamp_label.show()
933 align.show()
934
935 align = gtk.Alignment(0.0, 0.5)
936 label = gtk.Label()
937 label.set_markup("<b>Parents:</b>")
938 align.add(label)
939 self.table.attach(align, 0, 1, 3, 4, gtk.FILL, gtk.FILL)
940 label.show()
941 align.show()
942 self.parents_widgets = []
943
944 align = gtk.Alignment(0.0, 0.5)
945 label = gtk.Label()
946 label.set_markup("<b>Children:</b>")
947 align.add(label)
948 self.table.attach(align, 2, 3, 3, 4, gtk.FILL, gtk.FILL)
949 label.show()
950 align.show()
951 self.children_widgets = []
952
953 scrollwin = gtk.ScrolledWindow()
954 scrollwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
955 scrollwin.set_shadow_type(gtk.SHADOW_IN)
956 vbox.pack_start(scrollwin, expand=True, fill=True)
957 scrollwin.show()
958
959 if have_gtksourceview:
960 self.message_buffer = gtksourceview.SourceBuffer()
961 slm = gtksourceview.SourceLanguagesManager()
962 gsl = slm.get_language_from_mime_type("text/x-patch")
963 self.message_buffer.set_highlight(True)
964 self.message_buffer.set_language(gsl)
965 sourceview = gtksourceview.SourceView(self.message_buffer)
966 else:
967 self.message_buffer = gtk.TextBuffer()
968 sourceview = gtk.TextView(self.message_buffer)
969
970 sourceview.set_editable(False)
971 sourceview.modify_font(pango.FontDescription("Monospace"))
972 scrollwin.add(sourceview)
973 sourceview.show()
974
975 return vbox
976
977 def _treeview_cursor_cb(self, *args):
978 """Callback for when the treeview cursor changes."""
979 (path, col) = self.treeview.get_cursor()
980 commit = self.model[path][0]
981
982 if commit.committer is not None:
983 committer = commit.committer
984 timestamp = commit.commit_date
985 message = commit.get_message(self.with_diff)
986 revid_label = commit.commit_sha1
987 else:
988 committer = ""
989 timestamp = ""
990 message = ""
991 revid_label = ""
992
993 self.revid_label.set_text(revid_label)
994 self.committer_label.set_text(committer)
995 self.timestamp_label.set_text(timestamp)
996 self.message_buffer.set_text(unicode(message, self.encoding).encode('utf-8'))
997
998 for widget in self.parents_widgets:
999 self.table.remove(widget)
1000
1001 self.parents_widgets = []
1002 self.table.resize(4 + len(commit.parent_sha1) - 1, 4)
1003 for idx, parent_id in enumerate(commit.parent_sha1):
1004 self.table.set_row_spacing(idx + 3, 0)
1005
1006 align = gtk.Alignment(0.0, 0.0)
1007 self.parents_widgets.append(align)
1008 self.table.attach(align, 1, 2, idx + 3, idx + 4,
1009 gtk.EXPAND | gtk.FILL, gtk.FILL)
1010 align.show()
1011
1012 hbox = gtk.HBox(False, 0)
1013 align.add(hbox)
1014 hbox.show()
1015
1016 label = gtk.Label(parent_id)
1017 label.set_selectable(True)
1018 hbox.pack_start(label, expand=False, fill=True)
1019 label.show()
1020
1021 image = gtk.Image()
1022 image.set_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU)
1023 image.show()
1024
1025 button = gtk.Button()
1026 button.add(image)
1027 button.set_relief(gtk.RELIEF_NONE)
1028 button.connect("clicked", self._go_clicked_cb, parent_id)
1029 hbox.pack_start(button, expand=False, fill=True)
1030 button.show()
1031
1032 image = gtk.Image()
1033 image.set_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU)
1034 image.show()
1035
1036 button = gtk.Button()
1037 button.add(image)
1038 button.set_relief(gtk.RELIEF_NONE)
1039 button.set_sensitive(True)
1040 button.connect("clicked", self._show_clicked_cb,
1041 commit.commit_sha1, parent_id, self.encoding)
1042 hbox.pack_start(button, expand=False, fill=True)
1043 button.show()
1044
1045 # Populate with child details
1046 for widget in self.children_widgets:
1047 self.table.remove(widget)
1048
1049 self.children_widgets = []
1050 try:
1051 child_sha1 = Commit.children_sha1[commit.commit_sha1]
1052 except KeyError:
1053 # We don't have child
1054 child_sha1 = [ 0 ]
1055
1056 if ( len(child_sha1) > len(commit.parent_sha1)):
1057 self.table.resize(4 + len(child_sha1) - 1, 4)
1058
1059 for idx, child_id in enumerate(child_sha1):
1060 self.table.set_row_spacing(idx + 3, 0)
1061
1062 align = gtk.Alignment(0.0, 0.0)
1063 self.children_widgets.append(align)
1064 self.table.attach(align, 3, 4, idx + 3, idx + 4,
1065 gtk.EXPAND | gtk.FILL, gtk.FILL)
1066 align.show()
1067
1068 hbox = gtk.HBox(False, 0)
1069 align.add(hbox)
1070 hbox.show()
1071
1072 label = gtk.Label(child_id)
1073 label.set_selectable(True)
1074 hbox.pack_start(label, expand=False, fill=True)
1075 label.show()
1076
1077 image = gtk.Image()
1078 image.set_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU)
1079 image.show()
1080
1081 button = gtk.Button()
1082 button.add(image)
1083 button.set_relief(gtk.RELIEF_NONE)
1084 button.connect("clicked", self._go_clicked_cb, child_id)
1085 hbox.pack_start(button, expand=False, fill=True)
1086 button.show()
1087
1088 image = gtk.Image()
1089 image.set_from_stock(gtk.STOCK_FIND, gtk.ICON_SIZE_MENU)
1090 image.show()
1091
1092 button = gtk.Button()
1093 button.add(image)
1094 button.set_relief(gtk.RELIEF_NONE)
1095 button.set_sensitive(True)
1096 button.connect("clicked", self._show_clicked_cb,
1097 child_id, commit.commit_sha1, self.encoding)
1098 hbox.pack_start(button, expand=False, fill=True)
1099 button.show()
1100
1101 def _destroy_cb(self, widget):
1102 """Callback for when a window we manage is destroyed."""
1103 self.quit()
1104
1105
1106 def quit(self):
1107 """Stop the GTK+ main loop."""
1108 gtk.main_quit()
1109
1110 def run(self, args):
1111 self.set_branch(args)
1112 self.window.connect("destroy", self._destroy_cb)
1113 self.window.show()
1114 gtk.main()
1115
1116 def set_branch(self, args):
1117 """Fill in different windows with info from the reposiroty"""
1118 fp = os.popen("git rev-parse --sq --default HEAD " + list_to_string(args, 1))
1119 git_rev_list_cmd = fp.read()
1120 fp.close()
1121 fp = os.popen("git rev-list --header --topo-order --parents " + git_rev_list_cmd)
1122 self.update_window(fp)
1123
1124 def update_window(self, fp):
1125 commit_lines = []
1126
1127 self.model = gtk.ListStore(gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT,
1128 gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT, str, str, str)
1129
1130 # used for cursor positioning
1131 self.index = {}
1132
1133 self.colours = {}
1134 self.nodepos = {}
1135 self.incomplete_line = {}
1136 self.commits = []
1137
1138 index = 0
1139 last_colour = 0
1140 last_nodepos = -1
1141 out_line = []
1142 input_line = fp.readline()
1143 while (input_line != ""):
1144 # The commit header ends with '\0'
1145 # This NULL is immediately followed by the sha1 of the
1146 # next commit
1147 if (input_line[0] != '\0'):
1148 commit_lines.append(input_line)
1149 input_line = fp.readline()
1150 continue;
1151
1152 commit = Commit(commit_lines)
1153 if (commit != None ):
1154 self.commits.append(commit)
1155
1156 # Skip the '\0
1157 commit_lines = []
1158 commit_lines.append(input_line[1:])
1159 input_line = fp.readline()
1160
1161 fp.close()
1162
1163 for commit in self.commits:
1164 (out_line, last_colour, last_nodepos) = self.draw_graph(commit,
1165 index, out_line,
1166 last_colour,
1167 last_nodepos)
1168 self.index[commit.commit_sha1] = index
1169 index += 1
1170
1171 self.treeview.set_model(self.model)
1172 self.treeview.show()
1173
1174 def draw_graph(self, commit, index, out_line, last_colour, last_nodepos):
1175 in_line=[]
1176
1177 # | -> outline
1178 # X
1179 # |\ <- inline
1180
1181 # Reset nodepostion
1182 if (last_nodepos > 5):
1183 last_nodepos = -1
1184
1185 # Add the incomplete lines of the last cell in this
1186 try:
1187 colour = self.colours[commit.commit_sha1]
1188 except KeyError:
1189 self.colours[commit.commit_sha1] = last_colour+1
1190 last_colour = self.colours[commit.commit_sha1]
1191 colour = self.colours[commit.commit_sha1]
1192
1193 try:
1194 node_pos = self.nodepos[commit.commit_sha1]
1195 except KeyError:
1196 self.nodepos[commit.commit_sha1] = last_nodepos+1
1197 last_nodepos = self.nodepos[commit.commit_sha1]
1198 node_pos = self.nodepos[commit.commit_sha1]
1199
1200 #The first parent always continue on the same line
1201 try:
1202 # check we alreay have the value
1203 tmp_node_pos = self.nodepos[commit.parent_sha1[0]]
1204 except KeyError:
1205 self.colours[commit.parent_sha1[0]] = colour
1206 self.nodepos[commit.parent_sha1[0]] = node_pos
1207
1208 for sha1 in self.incomplete_line.keys():
1209 if (sha1 != commit.commit_sha1):
1210 self.draw_incomplete_line(sha1, node_pos,
1211 out_line, in_line, index)
1212 else:
1213 del self.incomplete_line[sha1]
1214
1215
1216 for parent_id in commit.parent_sha1:
1217 try:
1218 tmp_node_pos = self.nodepos[parent_id]
1219 except KeyError:
1220 self.colours[parent_id] = last_colour+1
1221 last_colour = self.colours[parent_id]
1222 self.nodepos[parent_id] = last_nodepos+1
1223 last_nodepos = self.nodepos[parent_id]
1224
1225 in_line.append((node_pos, self.nodepos[parent_id],
1226 self.colours[parent_id]))
1227 self.add_incomplete_line(parent_id)
1228
1229 try:
1230 branch_tag = self.bt_sha1[commit.commit_sha1]
1231 except KeyError:
1232 branch_tag = [ ]
1233
1234
1235 node = (node_pos, colour, branch_tag)
1236
1237 self.model.append([commit, node, out_line, in_line,
1238 commit.message, commit.author, commit.date])
1239
1240 return (in_line, last_colour, last_nodepos)
1241
1242 def add_incomplete_line(self, sha1):
1243 try:
1244 self.incomplete_line[sha1].append(self.nodepos[sha1])
1245 except KeyError:
1246 self.incomplete_line[sha1] = [self.nodepos[sha1]]
1247
1248 def draw_incomplete_line(self, sha1, node_pos, out_line, in_line, index):
1249 for idx, pos in enumerate(self.incomplete_line[sha1]):
1250 if(pos == node_pos):
1251 #remove the straight line and add a slash
1252 if ((pos, pos, self.colours[sha1]) in out_line):
1253 out_line.remove((pos, pos, self.colours[sha1]))
1254 out_line.append((pos, pos+0.5, self.colours[sha1]))
1255 self.incomplete_line[sha1][idx] = pos = pos+0.5
1256 try:
1257 next_commit = self.commits[index+1]
1258 if (next_commit.commit_sha1 == sha1 and pos != int(pos)):
1259 # join the line back to the node point
1260 # This need to be done only if we modified it
1261 in_line.append((pos, pos-0.5, self.colours[sha1]))
1262 continue;
1263 except IndexError:
1264 pass
1265 in_line.append((pos, pos, self.colours[sha1]))
1266
1267
1268 def _go_clicked_cb(self, widget, revid):
1269 """Callback for when the go button for a parent is clicked."""
1270 try:
1271 self.treeview.set_cursor(self.index[revid])
1272 except KeyError:
1273 dialog = gtk.MessageDialog(parent=None, flags=0,
1274 type=gtk.MESSAGE_WARNING, buttons=gtk.BUTTONS_CLOSE,
1275 message_format=None)
1276 dialog.set_markup("Revision <b>%s</b> not present in the list" % revid)
1277 # revid == 0 is the parent of the first commit
1278 if (revid != 0 ):
1279 dialog.format_secondary_text("Try running gitview without any options")
1280 dialog.run()
1281 dialog.destroy()
1282
1283 self.treeview.grab_focus()
1284
1285 def _show_clicked_cb(self, widget, commit_sha1, parent_sha1, encoding):
1286 """Callback for when the show button for a parent is clicked."""
1287 window = DiffWindow()
1288 window.set_diff(commit_sha1, parent_sha1, encoding)
1289 self.treeview.grab_focus()
1290
1291without_diff = 0
1292if __name__ == "__main__":
1293
1294 if (len(sys.argv) > 1 ):
1295 if (sys.argv[1] == "--without-diff"):
1296 without_diff = 1
1297
1298 view = GitView( without_diff != 1)
1299 view.run(sys.argv[without_diff:])