#!/usr/bin/perl -l # # Giddy - Git History Digger, a glorified pickaxe frontend # (c) Petr Baudis 2008 # GPLv2 / Perl Artistic License # # Please bear in mind that this has been written in 24 hours by a tired # hacker who saw GTK for about the first time. That's my excuse why # it looks like crap, yes. ;-) # TODO: # General: # Deep pickaxe - carry through small changes within string # UTF8 stuff does not work # Tree browser: # Decorate executables # Highlight files that serve as commit filter keys # File viewer: # Double click to select whole C identifiers # Double click on braces selects brace bodies # Show marks in place of removed stuff # Highlight changes on word-level # Highlight changes in older revisions (decaying colors) # Mark pickaxed area (dashed outline?) # Lock given file at certain commit # Easily bring two files side-by-side (right-click on tab or pickaxe mv indicator) # Syntax highlighting (Gtk2::SourceView) # Line numbering (http://www.perlmonks.org/?node_id=524941; gtk.TEXT_WINDOW_LEFT) # Dynamic scale widget to quickly navigate per-file history # Commit browser: # Restore original file position after a switch # Smoother incremental loading # Progressbar during incremental loading # DAG visualisation (steal from gitview?) # Show dates? # Tooltips with commit details use warnings; use strict; use utf8; use Gtk2 -init; use Gtk2::SimpleList; use Git; ## Parse arguments our ($Revspec, $Tree, $File); $Revspec = "HEAD"; if (@ARGV == 1) { if ($ARGV[0] eq '-h' or $ARGV[0] eq '--help') { die "Usage: giddy [[] ]"; } $File = shift; } elsif (@ARGV == 2) { $Revspec = shift; $File = shift; } ## Build window structure our $ctrl = Giddy::Controller->new(); our $fixed_font = Gtk2::Pango::FontDescription->from_string("Monospace"); my $window = Gtk2::Window->new('toplevel'); $window->signal_connect(destroy => sub { Gtk2->main_quit }); $window->set_default_size(1024, 650); # Toolbar: our $toolbar = Giddy::Toolbar->new($ctrl); # Statusbar: our $statusbar = Gtk2::Statusbar->new; # First column: our $tree_browser = Giddy::TreeBrowser->new($ctrl); $tree_browser->{widget}->set_property('width-request', 100); # Second column: our $files_panel = Giddy::FilesPanel->new($ctrl); # Third column: our $commit_browser = Giddy::CommitBrowser->new($ctrl); $commit_browser->{widget}->set_property('width-request', 200); # Compose middle section: my $hpan1 = Gtk2::HPaned->new(); my $hpan2 = Gtk2::HPaned->new(); $hpan1->pack1($tree_browser->{widget}, 0, 0); $hpan1->pack2($hpan2, 1, 1); $hpan2->pack1($files_panel->{widget}, 1, 1); $hpan2->pack2($commit_browser->{widget}, 0, 0); # Pack together: my $vbox = Gtk2::VBox->new(); $vbox->pack_start($toolbar->{widget}, 0, 0, 0); $vbox->pack_start($hpan1, 1, 1, 0); $vbox->pack_end($statusbar, 0, 0, 0); $window->add($vbox); ## Initialize Git our $repo = Git->repository(); # Canonical form e.g. for autoselecting in commit browser $Tree = $repo->command_oneline('rev-list', '-1', $Revspec); ## Initialize widgets $tree_browser->load($Tree); $files_panel->open($Tree, $File) if $File; $commit_browser->load(revspec => $Revspec); # TODO ## Main loop $window->show_all; Gtk2->main; exit 0; ### Utility sub midtrim { my ($str, $len) = @_; $str =~ s/\n//g; if (length($str) > $len) { my $elen = $len - 3; my $str2; $str2 = substr($str, 0, $elen / 2); $str2 .= '...'; $str2 .= substr($str, -$elen / 2); $str = $str2; } $str; } ### Objects # When hacking anything below this line, you are supposed to listen # to The Dø - A Mouthful, the official soundtrack of this script. # Otherwise, your code will be no good. Sorry. package Giddy::Controller; # Tying the widgets together sub new { my $class = shift; my $self = {}; bless $self, $class; } sub file_selected { my $self = shift; my ($entry, $popup) = @_; $File = $entry->{name}; if ($popup) { $files_panel->open($Tree, $File); } else { $files_panel->load($Tree, $File); } } sub file_open { my $self = shift; my ($popup) = @_; my @files = $tree_browser->selected_files(); if ($popup) { $self->file_selected($_, 1) for @files; } else { $self->file_selected($files[0]); } } sub commit_selected { my $self = shift; my ($entry) = @_; $Tree = $entry->{id}; $tree_browser->load($Tree); $files_panel->commit_changed(); } sub trim_commits { my $self = shift; my @files = map { $_->{name} } $tree_browser->selected_files(); $commit_browser->load( revspec => $commit_browser->{revspec}, fileset => \@files); } sub show_all_commits { my $self = shift; $commit_browser->load(revspec => $commit_browser->{revspec}); } sub set_revspec { my $self = shift; my ($revspec) = @_; $Revspec = $revspec; $commit_browser->load(revspec => $Revspec); } sub pickaxe { my $self = shift; my ($text, @files) = @_; $commit_browser->load( revspec => $commit_browser->{revspec}, (@files ? (fileset => \@files) : ()), pickaxe => $text); } sub open_gitk { my $self = shift; unless (fork()) { exec('gitk', $commit_browser->log_args()); } } 1; package Giddy::Toolbar; sub new { my $class = shift; my ($ctrl) = @_; my $self = { ctrl => $ctrl }; my $i = 0; $self->{_tb_widget} = Gtk2::Toolbar->new; $self->{_tb_widget}->set_icon_size('small-toolbar'); $self->{_tb_widget}->insert_stock('gtk-open', 'Open in current tab', '...', \&load, $self, $i++); $self->{_tb_widget}->insert_stock('gtk-new', 'Open in new tab', '...', \&open, $self, $i++); $self->{_tb_widget}->insert_stock('gtk-zoom-fit', 'Limit commits to selected files', '...', \&limit, $self, $i++); $self->{_tb_widget}->insert_stock('gtk-zoom-100', 'Show all commits', '...', \&unlimit, $self, $i++); $self->{_tb_widget}->insert_stock('gtk-convert', 'Open commits in gitk', '...', \&gitk, $self, $i++); $self->{_revspec_widget} = Gtk2::Entry->new; $self->{_revspec_widget}->set_text($Revspec); $self->{_revspec_widget}->set_width_chars(20); $self->{_revspec_widget}->signal_connect(activate => \&_set_revspec, $self); $self->{widget} = Gtk2::HBox->new; $self->{widget}->pack_start($self->{_tb_widget}, 1, 1, 0); $self->{widget}->pack_end($self->{_revspec_widget}, 0, 0, 0); bless $self, $class; } sub load { my ($b, $self) = @_; $self->{ctrl}->file_open(0); } sub open { my ($b, $self) = @_; $self->{ctrl}->file_open(1); } sub limit { my ($b, $self) = @_; $self->{ctrl}->trim_commits(); } sub unlimit { my ($b, $self) = @_; $self->{ctrl}->show_all_commits(); } sub gitk { my ($b, $self) = @_; $self->{ctrl}->open_gitk(); } sub _set_revspec { my ($e, $self) = @_; $self->{ctrl}->set_revspec($e->get_text()); } package Giddy::TreeBrowser; # Columns sub COL_REF { 0; } sub COL_NAME { 1; } sub COL_MODE { 2; } # Create object and set up the widgets sub new { my $class = shift; my ($ctrl) = @_; my $self = { ctrl => $ctrl }; # Holds data about the tree; each item (keyed by name) # is hashref with keys {mode}, {type}, {id}, {iter}; # iter points into the store. $self->{_tree} = {}; $self->{_tree_store} = Gtk2::TreeStore->new(qw/Glib::Scalar Glib::String Glib::String/); $self->{_tree_widget} = Gtk2::TreeView->new($self->{_tree_store}); $self->{_tree_widget}->set_headers_visible(0); $self->{_tree_widget}->get_selection()->set_mode('GTK_SELECTION_MULTIPLE'); $self->{_tree_widget}->signal_connect(row_activated => \&_row_activated, $self); $self->{_tree_widget}->signal_connect(button_press_event => \&_button_press, $self); # I hate the overengineered crap called "GTK". --pasky $self->{_tree_widget}->append_column( Gtk2::TreeViewColumn->new_with_attributes( "Name", Gtk2::CellRendererText->new, "text", COL_NAME ) ); $self->{widget} = Gtk2::ScrolledWindow->new; $self->{widget}->add($self->{_tree_widget}); bless $self, $class; } # Load given tree-ish to the browser sub load { my $self = shift; my ($tree) = @_; $self->{tree} = $tree; my %selection; if (keys %{$self->{_tree}}) { %selection = map { $_->{name} => 1 } $self->selected_files(); } else { # First load $selection{$File} = 1 if $File; } $self->{_tree} = {}; $self->{_tree_store}->clear(); my @files = $repo->command('ls-tree', '-r', $tree); foreach (@files) { my %entry; @entry{'mode', 'type', 'id', 'name'} = /^(\d+) (\w+) ([0-9a-f]+)\t(.+)/; my $iter = $self->_add(\%entry); if ($selection{$entry{'name'}}) { $self->{_tree_widget}->expand_to_path($self->{_tree_store}->get_path($iter)); $self->{_tree_widget}->get_selection()->select_iter($iter); } } 1; } # Get list of selected files sub selected_files { my $self = shift; my @paths = $self->{_tree_widget}->get_selection->get_selected_rows(); map { $self->{_tree_store}->get($self->{_tree_store}->get_iter($_), COL_REF); } @paths; } # Get iter of directory where given entry belongs sub _parent { my $self = shift; my ($entry) = @_; my ($dir) = ($entry->{'name'} =~ m#(.*)/#); return undef unless $dir; return $self->{_tree}->{$dir}->{'iter'} if $self->{_tree}->{$dir}; my $tree = { type => 'tree', name => $dir }; $self->_add($tree); } # Add entry to the tree sub _add { my $self = shift; my ($entry) = @_; $entry->{'iter'} = $self->{_tree_store}->append($self->_parent($entry)); my $s = $entry->{'name'}; $s =~ s#.*/##; $self->{_tree_store}->set($entry->{'iter'}, COL_REF, $entry, COL_NAME, $s, COL_MODE, $entry->{'mode'} ); $self->{_tree}->{$entry->{'name'}} = $entry; $entry->{'iter'}; } sub _row_activated { my ($tv, $path, $column, $self, $popup) = @_; my $model = $tv->get_model(); my ($entry) = $model->get($model->get_iter($path), COL_REF); if ($entry->{type} eq 'tree') { return $tv->row_expanded($path) ? $tv->collapse_row($path) : $tv->expand_row($path, 0); } $self->{ctrl}->file_selected($entry, $popup); }; sub _button_press { my ($tv, $ev, $self) = @_; # Middle button? if ($ev->type() eq 'button-press' and $ev->button() == 2) { my $path = $tv->get_path_at_pos($ev->x(), $ev->y()); return _row_activated($tv, $path, undef, $self, 1); } 0; } 1; package Giddy::CommitBrowser; # Columns sub COL_REF { 0; } sub COL_ID { 1; } sub COL_AUTHOR { 2; } sub COL_SUBJECT { 3; } # Create object and set up the widgets sub new { my $class = shift; my ($ctrl) = @_; my $self = { ctrl => $ctrl }; # Holds data about the history tree; each item (keyed by id) # is hashref with keys {id}, {author}, {subject}, {parents}, {iter}; # iter points into the store, parent is []. $self->{_commits} = {}; $self->{_tree_store} = Gtk2::TreeStore->new(qw/Glib::Scalar Glib::String Glib::String Glib::String/); $self->{_tree_widget} = Gtk2::TreeView->new($self->{_tree_store}); $self->{_tree_widget}->set_rules_hint(1); #$self->{_tree_widget}->set_headers_clickable(1); $self->{_tree_widget}->set_reorderable(1); # This does not work; why? $self->{_tree_widget}->set_grid_lines('GTK_TREE_VIEW_GRID_LINES_VERTICAL'); $self->{_tree_widget}->get_selection()->set_mode('GTK_SELECTION_MULTIPLE'); $self->{_tree_widget}->signal_connect(row_activated => \&_row_activated, $self); # I hate the overengineered crap called "GTK". --pasky sub col { my ($name, $id, $monospace) = @_; my $cell = Gtk2::CellRendererText->new; $cell->set_property('font-desc', $fixed_font) if $monospace; my $col = Gtk2::TreeViewColumn->new_with_attributes($name, $cell, "text", $id); $col->set_resizable(1); $col->set_clickable(1); $col; } $self->{_tree_widget}->append_column(col("Subject", COL_SUBJECT)); $self->{_tree_widget}->append_column(col("Author", COL_AUTHOR)); $self->{_tree_widget}->append_column(col("ID", COL_ID, 1)); $self->{_sbc} = $statusbar->get_context_id('c'); $self->{widget} = Gtk2::ScrolledWindow->new; $self->{widget}->add($self->{_tree_widget}); bless $self, $class; } # Load given revision range (limited to given files) to the browser sub load { my $self = shift; my %args = @_; my $gdkwin = $self->{widget}->window(); my $sigh; if ($gdkwin) { $gdkwin->set_cursor(Gtk2::Gdk::Cursor->new('watch')); Gtk2::Gdk->flush(); } else { $self->{_load_sigh} = $self->{widget}->signal_connect(realize => sub { $self->{widget}->window()->set_cursor(Gtk2::Gdk::Cursor->new('watch')); }); } $self->{revspec} = $args{revspec}; $self->{fileset} = $args{fileset}; $self->{pickaxe} = $args{pickaxe}; if (keys %{$self->{_commits}}) { $self->{_load_selection} = { map { $_->{id} => 1 } $self->selected_commits() }; } else { # First load $self->{_load_selection} = { $Tree => 1 }; } $self->{_commits} = {}; $self->{_tree_store}->clear(); my @args = $self->log_args(); $statusbar->push($self->{_sbc}, join(' ', 'git', 'log', (map { ::midtrim($_, 40); } @args))); # Asynchronous reading, this can take *long* ($self->{_load_fh}, $self->{_load_ctx}) = $repo->command_output_pipe('log', join("\t", '--pretty=format:%H', '%P', '%an', '%s'), @args); $self->{_glib_is_crap1} = Glib::IO->add_watch(fileno($self->{_load_fh}), 'in', \&_log_line_read, $self); $self->{_glib_is_crap2} = Glib::IO->add_watch(fileno($self->{_load_fh}), 'hup', \&_log_line_read, $self); 1; } # Get list of git log arguments corresponding to current list sub log_args { my $self = shift; ( #'-M', '-C', # makes git log output garbage ($self->{pickaxe} ? ('-S' . $self->{pickaxe}) : ()), $self->{revspec}, ($self->{fileset} ? @{$self->{fileset}} : ()) ); } # Get list of selected commits sub selected_commits { my $self = shift; my @paths = $self->{_tree_widget}->get_selection->get_selected_rows(); map { $self->{_tree_store}->get($self->{_tree_store}->get_iter($_), COL_REF); } @paths; } sub _log_line_read { my ($fd, $sel, $self) = @_; my $fh = $self->{_load_fh}; my $line = <$fh>; unless ($line) { # EOF Glib::Source->remove($self->{_glib_is_crap1}); Glib::Source->remove($self->{_glib_is_crap2}); $repo->command_close_pipe($self->{_load_fh}, $self->{_load_ctx}); my $gdkwin = $self->{widget}->window(); if ($gdkwin) { $gdkwin->set_cursor(undef); } else { $self->{widget}->signal_handler_disconnect($self->{_load_sigh}); } return 0; } # XXX: git-log is broken and inserts spurious empty lines in case # of some extensive pickaxes. TODO: Figure out why and fix. chomp $line; my %entry; @entry{'id', 'parents', 'author', 'subject'} = split(/\t/, $line); $entry{'parents'} = [ split(/ /, $entry{'parents'}) ]; my $iter = $self->_add(\%entry); if ($self->{_load_selection}->{$entry{'id'}}) { $self->{_tree_widget}->get_selection()->select_iter($iter); Gtk2::Gdk->flush(); } 1; } # Add commit entry sub _add { my $self = shift; my ($entry) = @_; $entry->{'iter'} = $self->{_tree_store}->append(undef); my $id = substr($entry->{'id'}, 0, 8); my $author = ::midtrim($entry->{'author'}, 20); my $subj = ::midtrim($entry->{'subject'}, 80); $self->{_tree_store}->set($entry->{'iter'}, COL_REF, $entry, COL_ID, $id, COL_AUTHOR, $author, COL_SUBJECT, $subj ); $self->{_commits}->{$entry->{'id'}} = $entry; $entry->{'iter'}; } sub _row_activated { my ($tv, $path, $column, $self) = @_; my $model = $tv->get_model(); my ($entry) = $model->get($model->get_iter($path), COL_REF); $self->{ctrl}->commit_selected($entry); }; 1; package Giddy::FileViewer; # Create object and set up the widgets sub new { my $class = shift; my ($ctrl) = @_; my $self = { ctrl => $ctrl }; $self->{widget} = Gtk2::TextView->new; $self->{widget}->set_editable(0); $self->{widget}->modify_font($fixed_font); $self->{widget}->signal_connect(populate_popup => \&_populate_popup, $self); $self->{buffer} = $self->{widget}->get_buffer(); $self->{_tag_ins} = $self->{buffer}->create_tag('ins', background => 'green'); $self->{_tag_par_ins} = $self->{buffer}->create_tag('parins', paragraph_background => 'green'); $self->{_tag_chg} = $self->{buffer}->create_tag('chg', background => 'orange'); $self->{_tag_par_chg} = $self->{buffer}->create_tag('parchg', paragraph_background => 'orange'); bless $self, $class; } # Load given file to the viewer sub load { my $self = shift; my ($tree, $name) = @_; $self->{tree} = $tree; $self->{name} = $name; my $text = $repo->command('cat-file', 'blob', $tree.':'.$name); $self->{buffer}->set_text($text); $self->_analyse_diff($tree); } # Check diff introduced by $tree and tag buffer appropriately sub _analyse_diff { my $self = shift; my ($tree) = @_; my @diff = $repo->command('diff', $tree.'^', $tree, $self->{name}); my $in_diff = 0; my ($line_old, $line_new); my %minihunk = (); sub minihunk_boundary { my ($self, $minihunk, $line) = @_; return unless $minihunk->{start}; my %tags = ( add => '_tag_par_ins', chg => '_tag_par_chg' ); # Gtk::TextBuffer counts lines from 0 my $line1 = $self->{buffer}->get_iter_at_line($minihunk->{start} - 1); my $line2 = $self->{buffer}->get_iter_at_line($line - 1); if ($minihunk->{type} eq 'del') { # TODO: Insert expandable bar %$minihunk = (); return; } $self->{buffer}->apply_tag($self->{$tags{$minihunk->{type}}}, $line1, $line2); %$minihunk = (); } foreach (@diff) { unless ($in_diff) { /^@@/ or next; $in_diff = 1; } if (/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/) { minihunk_boundary($self, \%minihunk, $line_new); ($line_old, $line_new) = ($1, $3); } elsif (/^ /) { minihunk_boundary($self, \%minihunk, $line_new); $line_old++, $line_new++; } elsif (/^-/) { if (!$minihunk{start}) { $minihunk{start} = $line_new; $minihunk{type} = 'del'; } elsif ($minihunk{type} eq 'add') { $minihunk{type} = 'chg'; } $line_old++; } elsif (/^\+(.*)/) { if (!$minihunk{start}) { $minihunk{start} = $line_new; $minihunk{type} = 'add'; } elsif ($minihunk{type} eq 'del') { $minihunk{type} = 'chg'; } $line_new++; } } minihunk_boundary($self, \%minihunk, $line_new); } sub _populate_popup { my ($tv, $m, $self) = @_; # XXX: Cut off the stupid items below second separator my $sep = 0; for ($m->get_children()) { $sep++ if ref $_ eq 'Gtk2::SeparatorMenuItem'; $m->remove($_) if $sep > 1; } my $i = 0; sub menu_item { my ($self, $m, $i, $label, $handler) = @_; my $mi = Gtk2::MenuItem->new_with_label($label); $mi->signal_connect(activate => $handler, $self); $m->insert($mi, $$i++); $mi->show(); } if ($self->{buffer}->get_has_selection()) { menu_item($self, $m, \$i, 'Pickaxe', \&_pickaxe_sel); } else { my ($ll, $px, $py, $mm) = $tv->window()->get_pointer(); my ($bx, $by) = $tv->window_to_buffer_coords('widget', $px, $py); $self->{_popup_at} = $tv->get_iter_at_position($bx, $by); my $idclass = qr/[a-zA-Z0-9_]/; if ($self->{_popup_at}->get_char() =~ $idclass) { my $start = $self->{_popup_at}->copy(); while ($start->backward_char()) { if ($start->get_char() !~ $idclass) { $start->forward_char(); last; } } my $end = $self->{_popup_at}->copy(); while ($end->forward_char()) { if ($end->get_char() !~ $idclass) { last; } } $self->{_popup_id} = $start->get_text($end); menu_item($self, $m, \$i, 'Pickaxe "'.$self->{_popup_id}.'" calls', \&_pickaxe_id); } menu_item($self, $m, \$i, 'Pickaxe this line', \&_pickaxe_line); } if ($i) { my $mi = Gtk2::SeparatorMenuItem->new; $m->insert($mi, $i++); $mi->show(); } } sub _pickaxe_sel { my ($mi, $self) = @_; my $clipboard = Gtk2::Clipboard->get(Gtk2::Gdk->SELECTION_PRIMARY); my $text = $clipboard->wait_for_text(); $self->{ctrl}->pickaxe($text, $self->{name}); } sub _pickaxe_line { my ($mi, $self) = @_; # XXX: Eew... Is there a better way to do this? my $line1 = $self->{buffer}->get_iter_at_line($self->{_popup_at}->get_line()); my $line2 = $self->{buffer}->get_iter_at_line($self->{_popup_at}->get_line() + 1); $self->{ctrl}->pickaxe($line1->get_text($line2), $self->{name}); } sub _pickaxe_id { my ($mi, $self) = @_; $self->{ctrl}->pickaxe($self->{_popup_id}); } 1; # Time for... # 10 - Coda ! # "Simple" widget wrapping FileViewer # (Glib::Object::Subclass is a devilry) package Giddy::FileViewerWidget; use Glib::Object::Subclass 'Gtk2::ScrolledWindow'; sub new { my $class = shift; my ($viewer) = @_; my $self = Gtk2::ScrolledWindow->new; bless $self, $class; $self->{viewer} = $viewer; $self->add($viewer->{widget}); #$self->set_policy('automatic', 'automatic'); $self; } 1; package Giddy::FilesPanel; # Create object and set up the widgets sub new { my $class = shift; my ($ctrl) = @_; my $self = { ctrl => $ctrl }; $self->{widget} = Gtk2::Notebook->new; bless $self, $class; } # Open new viewer tab sub open { my $self = shift; my ($tree, $name) = @_; my $widget = Giddy::FileViewerWidget->new(Giddy::FileViewer->new($self->{ctrl})); $widget->show(); $widget->{viewer}->{widget}->show(); my $tab = $self->{widget}->append_page($widget, Gtk2::Label->new); $self->load($tree, $name, $tab); } # Open file in given/current existing tab sub load { my $self = shift; my ($tree, $name, $tab) = @_; $tab = $self->{widget}->get_current_page() unless defined $tab; if ($tab < 0) { # No tab yet return $self->open($tree, $name); } my $viewer_widget = $self->{widget}->get_nth_page($tab); $viewer_widget->{viewer}->load($tree, $name); $self->{widget}->set_tab_label($viewer_widget, Gtk2::Label->new($name)); } # Reload all tabs for new commit sub commit_changed { my $self = shift; for my $tab (0..($self->{widget}->get_n_pages()-1)) { my $viewer = $self->{widget}->get_nth_page($tab)->{viewer}; $viewer->load($Tree, $viewer->{name}); } } 1;