Refresh files panel when going to previous commit
[giddy.git] / giddy.perl
blobb7f390c37249afe6b5f15944825547ededec0316
1 #!/usr/bin/perl -l
3 # Giddy - Git History Digger, a glorified pickaxe frontend
4 # (c) Petr Baudis <pasky@suse.cz> 2008
5 # GPLv2 / Perl Artistic License
6 # v0.2
8 use warnings;
9 use strict;
10 use utf8;
12 use Gtk2 -init;
13 use Gtk2::SimpleList;
16 our $ctrl = Giddy::Controller->new();
19 ## Parse arguments
21 our ($Revspec, $Tree, $File, $Full_revlist);
22 $Revspec = "HEAD";
23 if (@ARGV == 1) {
24 if ($ARGV[0] eq '-h' or $ARGV[0] eq '--help') {
25 die "Usage: giddy [[<revspec>] <filename>]";
28 $File = shift;
29 } elsif (@ARGV == 2) {
30 $Revspec = shift;
31 $File = shift;
34 # Canonical form e.g. for autoselecting in commit browser
35 $Tree = $ctrl->repo()->command_oneline('rev-list', '-1', $Revspec);
38 ## Build window structure
40 our $fixed_font = Gtk2::Pango::FontDescription->from_string("Monospace");
42 my $window = Gtk2::Window->new('toplevel');
43 $window->signal_connect(destroy => sub { Gtk2->main_quit });
44 $window->set_default_size(1024, 650);
46 # Toolbar:
47 my $toolbar = Giddy::Toolbar->new($ctrl);
49 # Statusbar:
50 our $statusbar = Gtk2::Statusbar->new;
52 # First column:
53 my $tree_browser = Giddy::TreeBrowser->new($ctrl);
54 $tree_browser->{widget}->set_property('width-request', 100);
56 # Second column:
57 my $files_panel = Giddy::FilesPanel->new($ctrl);
59 # Third column:
60 my $commit_browser = Giddy::CommitBrowser->new($ctrl);
61 $commit_browser->{widget}->set_property('width-request', 200);
63 # Compose middle section:
64 my $hpan1 = Gtk2::HPaned->new();
65 my $hpan2 = Gtk2::HPaned->new();
66 $hpan1->pack1($tree_browser->{widget}, 0, 0);
67 $hpan1->pack2($hpan2, 1, 1);
68 $hpan2->pack1($files_panel->{widget}, 1, 1);
69 $hpan2->pack2($commit_browser->{widget}, 0, 0);
71 # Pack together:
72 my $vbox = Gtk2::VBox->new();
73 $vbox->pack_start($toolbar->{widget}, 0, 0, 0);
74 $vbox->pack_start($hpan1, 1, 1, 0);
75 $vbox->pack_end($statusbar, 0, 0, 0);
77 $window->add($vbox);
80 ## Initialize widgets
82 $tree_browser->load($Tree);
83 $files_panel->open_commit($Tree);
84 $files_panel->open($Tree, $File) if $File;
85 $commit_browser->load(revspec => $Revspec); # TODO
86 $Full_revlist = $commit_browser->get_commit_data();
89 ## Main loop
91 $window->show_all;
92 Gtk2->main;
93 exit 0;
97 ### Utility
99 sub midtrim {
100 my ($str, $len) = @_;
101 $str or return $str;
102 $str =~ s/\n//g;
103 if (length($str) > $len) {
104 my $elen = $len - 3;
105 my $str2;
106 $str2 = substr($str, 0, $elen / 2);
107 $str2 .= '...';
108 $str2 .= substr($str, -$elen / 2);
109 $str = $str2;
111 $str;
116 ### Objects
117 # When hacking anything below this line, you are supposed to listen
118 # to The Dø - A Mouthful, the official soundtrack of this script.
119 # Otherwise, your code will be no good. Sorry.
120 # Passable alternatives: Hooverphonic, Massive Attack, Over the Rhine.
122 package Giddy::Controller;
124 # Tying the widgets together
126 use Git;
128 sub new {
129 my $class = shift;
130 my $self = {};
131 $self->{repo} = Git->repository();
132 bless $self, $class;
135 sub repo {
136 my $self = shift;
137 $self->{repo};
140 sub file_selected {
141 my $self = shift;
142 my ($entry, $popup) = @_;
143 $File = $entry->{name};
144 if ($popup) {
145 $files_panel->open($Tree, $File);
146 } else {
147 $files_panel->load($Tree, $File);
151 sub file_open {
152 my $self = shift;
153 my ($popup) = @_;
154 my @files = $tree_browser->selected_files();
155 if ($popup) {
156 $self->file_selected($_, 1) for @files;
157 } else {
158 $self->file_selected($files[0]);
162 sub commit_selected {
163 my $self = shift;
164 my ($entry) = @_;
165 $Tree = $entry->{id};
166 $tree_browser->load($Tree);
167 $files_panel->commit_changed();
170 sub previous_commit {
171 my $self = shift;
172 $self->commit_selected({
173 id => $self->repo()->command_oneline('rev-parse', "$Tree^")
175 $commit_browser->commit_changed();
176 $files_panel->commit_changed();
179 sub trim_commits {
180 my $self = shift;
181 my @files = map { $_->{name} } $tree_browser->selected_files();
182 $commit_browser->load(
183 revspec => $commit_browser->{revspec},
184 fileset => \@files);
187 sub show_all_commits {
188 my $self = shift;
189 $commit_browser->set_commit_data($Full_revlist);
190 $commit_browser->load(revspec => $commit_browser->{revspec});
193 sub set_revspec {
194 my $self = shift;
195 my ($revspec) = @_;
196 $Revspec = $revspec;
197 $commit_browser->load(revspec => $Revspec);
200 # if files is undef, keep current fileset; if files is [], empty fileset
201 sub pickaxe {
202 my $self = shift;
203 my ($text, $files) = @_;
204 $commit_browser->load(
205 revspec => $commit_browser->{revspec},
206 ($files ? @$files ? (fileset => $files) : () : (fileset => $commit_browser->{fileset})),
207 pickaxe => $text);
210 sub open_gitk {
211 my $self = shift;
212 unless (fork()) {
213 exec('gitk', $commit_browser->log_args());
220 package Giddy::Toolbar;
222 sub new {
223 my $class = shift;
224 my ($ctrl) = @_;
225 my $self = { ctrl => $ctrl };
226 my $i = 0;
228 $self->{_tb_widget} = Gtk2::Toolbar->new;
229 $self->{_tb_widget}->set_icon_size('small-toolbar');
230 $self->{_tb_widget}->insert_stock('gtk-open', 'Open in current tab', '...', \&load, $self, $i++);
231 $self->{_tb_widget}->insert_stock('gtk-new', 'Open in new tab', '...', \&open, $self, $i++);
232 $self->{_tb_widget}->insert_stock('gtk-zoom-fit', 'Limit commits to selected files', '...', \&limit, $self, $i++);
233 $self->{_tb_widget}->insert_stock('gtk-zoom-100', 'Show all commits', '...', \&unlimit, $self, $i++);
234 $self->{_tb_widget}->insert_stock('gtk-convert', 'Open commits in gitk', '...', \&gitk, $self, $i++);
235 $self->{_tb_widget}->insert_stock('gtk-go-back', 'Go to previous commit (first parent)', '...', \&prevcommit, $self, $i++);
237 $self->{_revspec_widget} = Gtk2::Entry->new;
238 $self->{_revspec_widget}->set_text($Revspec);
239 $self->{_revspec_widget}->set_width_chars(20);
240 $self->{_revspec_widget}->signal_connect(activate => \&_set_revspec, $self);
242 $self->{widget} = Gtk2::HBox->new;
243 $self->{widget}->pack_start($self->{_tb_widget}, 1, 1, 0);
244 $self->{widget}->pack_end($self->{_revspec_widget}, 0, 0, 0);
246 bless $self, $class;
249 sub load {
250 my ($b, $self) = @_;
251 $self->{ctrl}->file_open(0);
254 sub open {
255 my ($b, $self) = @_;
256 $self->{ctrl}->file_open(1);
259 sub limit {
260 my ($b, $self) = @_;
261 $self->{ctrl}->trim_commits();
264 sub unlimit {
265 my ($b, $self) = @_;
266 $self->{ctrl}->show_all_commits();
269 sub gitk {
270 my ($b, $self) = @_;
271 $self->{ctrl}->open_gitk();
274 sub prevcommit {
275 my ($b, $self) = @_;
276 $self->{ctrl}->previous_commit();
279 sub _set_revspec {
280 my ($e, $self) = @_;
281 $self->{ctrl}->set_revspec($e->get_text());
285 package Giddy::TreeBrowser;
287 # Columns
288 sub COL_REF { 0; }
289 sub COL_NAME { 1; }
290 sub COL_MODE { 2; }
292 # Create object and set up the widgets
293 sub new {
294 my $class = shift;
295 my ($ctrl) = @_;
296 my $self = { ctrl => $ctrl };
298 # Holds data about the tree; each item (keyed by name)
299 # is hashref with keys {mode}, {type}, {id}, {iter};
300 # iter points into the store.
301 $self->{_tree} = {};
303 $self->{_tree_store} = Gtk2::TreeStore->new(qw/Glib::Scalar Glib::String Glib::String/);
304 $self->{_tree_widget} = Gtk2::TreeView->new($self->{_tree_store});
305 $self->{_tree_widget}->set_headers_visible(0);
306 $self->{_tree_widget}->get_selection()->set_mode('GTK_SELECTION_MULTIPLE');
307 $self->{_tree_widget}->signal_connect(row_activated => \&_row_activated, $self);
308 $self->{_tree_widget}->signal_connect(button_press_event => \&_button_press, $self);
310 # I hate the overengineered crap called "GTK". --pasky
311 $self->{_tree_widget}->append_column(
312 Gtk2::TreeViewColumn->new_with_attributes(
313 "Name", Gtk2::CellRendererText->new,
314 "text", COL_NAME
318 $self->{widget} = Gtk2::ScrolledWindow->new;
319 $self->{widget}->add($self->{_tree_widget});
321 bless $self, $class;
324 # Load given tree-ish to the browser
325 sub load {
326 my $self = shift;
327 my ($tree) = @_;
329 $self->{tree} = $tree;
331 my %selection;
332 if (keys %{$self->{_tree}}) {
333 %selection = map { $_->{name} => 1 } $self->selected_files();
334 } else {
335 # First load
336 $selection{$File} = 1 if $File;
339 $self->{_tree} = {};
340 $self->{_tree_store}->clear();
342 my @files = $self->{ctrl}->repo()->command('ls-tree', '-r', $tree);
343 foreach (@files) {
344 utf8::upgrade($_);
345 my %entry;
346 @entry{'mode', 'type', 'id', 'name'} = /^(\d+) (\w+) ([0-9a-f]+)\t(.+)/;
347 my $iter = $self->_add(\%entry);
348 if ($selection{$entry{'name'}}) {
349 $self->{_tree_widget}->expand_to_path($self->{_tree_store}->get_path($iter));
350 $self->{_tree_widget}->get_selection()->select_iter($iter);
357 # Get list of selected files
358 sub selected_files {
359 my $self = shift;
361 my @paths = $self->{_tree_widget}->get_selection->get_selected_rows();
362 map { $self->{_tree_store}->get($self->{_tree_store}->get_iter($_), COL_REF); } @paths;
365 # Get iter of directory where given entry belongs
366 sub _parent {
367 my $self = shift;
368 my ($entry) = @_;
370 my ($dir) = ($entry->{'name'} =~ m#(.*)/#);
371 return undef unless $dir;
372 return $self->{_tree}->{$dir}->{'iter'} if $self->{_tree}->{$dir};
374 my $tree = { type => 'tree', name => $dir };
375 $self->_add($tree);
378 # Add entry to the tree
379 sub _add {
380 my $self = shift;
381 my ($entry) = @_;
383 $entry->{'iter'} = $self->{_tree_store}->append($self->_parent($entry));
384 my $s = $entry->{'name'}; $s =~ s#.*/##;
385 $self->{_tree_store}->set($entry->{'iter'},
386 COL_REF, $entry,
387 COL_NAME, $s,
388 COL_MODE, $entry->{'mode'}
390 $self->{_tree}->{$entry->{'name'}} = $entry;
392 $entry->{'iter'};
395 sub _row_activated {
396 my ($tv, $path, $column, $self, $popup) = @_;
398 my $model = $tv->get_model();
399 my ($entry) = $model->get($model->get_iter($path), COL_REF);
400 if ($entry->{type} eq 'tree') {
401 return $tv->row_expanded($path) ? $tv->collapse_row($path) : $tv->expand_row($path, 0);
403 $self->{ctrl}->file_selected($entry, $popup);
406 sub _button_press {
407 my ($tv, $ev, $self) = @_;
409 # Middle button?
410 if ($ev->type() eq 'button-press' and $ev->button() == 2) {
411 my $path = $tv->get_path_at_pos($ev->x(), $ev->y());
412 return _row_activated($tv, $path, undef, $self, 1);
420 package Giddy::CommitBrowser;
422 # Columns
423 sub COL_REF { 0; }
424 sub COL_ID { 1; }
425 sub COL_AUTHOR { 2; }
426 sub COL_SUBJECT { 3; }
428 # Create object and set up the widgets
429 sub new {
430 my $class = shift;
431 my ($ctrl) = @_;
432 my $self = { ctrl => $ctrl };
434 # Holds data about the history tree; each item (keyed by id)
435 # is hashref with keys {id}, {author}, {subject}, {parents}, {iter};
436 # iter points into the store, parent is [].
437 $self->{_commits} = {};
438 $self->{_commit_sequence} = [];
440 $self->{_tree_store} = Gtk2::TreeStore->new(qw/Glib::Scalar Glib::String Glib::String Glib::String/);
441 $self->{_tree_widget} = Gtk2::TreeView->new($self->{_tree_store});
442 $self->{_tree_widget}->set_rules_hint(1);
443 #$self->{_tree_widget}->set_headers_clickable(1);
444 $self->{_tree_widget}->set_reorderable(1); # This does not work; why?
445 $self->{_tree_widget}->set_grid_lines('GTK_TREE_VIEW_GRID_LINES_VERTICAL');
446 $self->{_tree_widget}->get_selection()->set_mode('GTK_SELECTION_MULTIPLE');
447 $self->{_tree_widget}->signal_connect(row_activated => \&_row_activated, $self);
449 # I hate the overengineered crap called "GTK". --pasky
450 sub col {
451 my ($name, $id, $monospace) = @_;
452 my $cell = Gtk2::CellRendererText->new;
453 $cell->set_property('font-desc', $fixed_font) if $monospace;
454 my $col = Gtk2::TreeViewColumn->new_with_attributes($name, $cell, "text", $id);
455 $col->set_resizable(1);
456 $col->set_clickable(1);
457 $col;
459 $self->{_tree_widget}->append_column(col("Subject", COL_SUBJECT));
460 $self->{_tree_widget}->append_column(col("Author", COL_AUTHOR));
461 $self->{_tree_widget}->append_column(col("ID", COL_ID, 1));
463 $self->{_sbc} = $statusbar->get_context_id('c');
465 $self->{widget} = Gtk2::ScrolledWindow->new;
466 $self->{widget}->add($self->{_tree_widget});
468 bless $self, $class;
471 # Load given revision range (limited to given files) to the browser
472 sub load {
473 my $self = shift;
474 my %args = @_;
476 my $gdkwin = $self->{widget}->window();
477 my $sigh;
478 if ($gdkwin) {
479 $gdkwin->set_cursor(Gtk2::Gdk::Cursor->new('watch'));
480 Gtk2::Gdk->flush();
481 } else {
482 $self->{_load_sigh} = $self->{widget}->signal_connect(realize => sub {
483 $self->{widget}->window()->set_cursor(Gtk2::Gdk::Cursor->new('watch'));
487 $self->{revspec} = $args{revspec};
488 $self->{fileset} = $args{fileset};
489 $self->{pickaxe} = $args{pickaxe};
491 if (keys %{$self->{_commits}}) {
492 $self->{_load_selection} = { map { $_->{id} => 1 } $self->selected_commits() };
493 } else {
494 # First load
495 $self->{_load_selection} = { $Tree => 1 };
498 $self->{_commits} = {};
499 $self->{_commit_sequence} = [];
500 $self->{_tree_store}->clear();
502 my @args = $self->log_args();
503 $statusbar->push($self->{_sbc}, join(' ', 'git', 'log', (map { ::midtrim($_, 40); } @args)));
505 # Asynchronous reading, this can take *long*
507 ($self->{_load_fh}, $self->{_load_ctx}) =
508 $self->{ctrl}->repo()->command_output_pipe('log',
509 join("\t", '--pretty=format:%H', '%P', '%an', '%s'),
510 @args);
511 #binmode($self->{_load_fh}, ':utf8'); # XXX: doesn't work?
513 $self->{_glib_is_crap1} = Glib::IO->add_watch(fileno($self->{_load_fh}), 'in', \&_log_line_read, $self);
514 $self->{_glib_is_crap2} = Glib::IO->add_watch(fileno($self->{_load_fh}), 'hup', \&_log_line_read, $self);
519 # Get list of git log arguments corresponding to current list
520 sub log_args {
521 my $self = shift;
524 #'-M', '-C', # makes git log output garbage
525 ($self->{pickaxe} ? ('-S' . $self->{pickaxe}) : ()),
526 ($self->{fileset} ? ('--follow') : ()),
527 $self->{revspec},
528 ($self->{fileset} ? ('--', @{$self->{fileset}}) : ())
532 # Get list of selected commits
533 sub selected_commits {
534 my $self = shift;
536 my @paths = $self->{_tree_widget}->get_selection->get_selected_rows();
537 map { $self->{_tree_store}->get($self->{_tree_store}->get_iter($_), COL_REF); } @paths;
540 # Select current commit
541 sub commit_changed {
542 my $self = shift;
543 $self->{_tree_widget}->get_selection()->unselect_all();
544 $self->{_tree_widget}->get_selection()->select_iter($self->{_commits}->{$Tree}->{'iter'});
548 # Get the state of commits list
549 sub get_commit_data {
550 my $self = shift;
551 my $data = {};
552 @$data{'revspec', 'fileset', 'pickaxe', 'commits'} =
553 @$self{'revspec', 'fileset', 'pickaxe', '_commit_sequence'};
554 $data;
557 # Load a state of commits list as returned by get_commit_data()
558 sub set_commit_data {
559 my $self = shift;
560 my ($data) = @_;
562 @$self{'revspec', 'fileset', 'pickaxe'} =
563 @$data{'revspec', 'fileset', 'pickaxe'};
564 $self->{_commit_sequence} = [];
565 $self->{_commits} = {};
566 $self->{_tree_store}->clear();
567 foreach my $entry (@{$data->{commits}}) {
568 $self->_add($entry);
573 sub _log_line_read {
574 my ($fd, $sel, $self) = @_;
576 my $fh = $self->{_load_fh};
577 my $line = <$fh>;
578 unless ($line) {
579 # EOF
580 Glib::Source->remove($self->{_glib_is_crap1});
581 Glib::Source->remove($self->{_glib_is_crap2});
582 $self->{ctrl}->repo()->command_close_pipe($self->{_load_fh}, $self->{_load_ctx});
584 my $gdkwin = $self->{widget}->window();
585 if ($gdkwin) {
586 $gdkwin->set_cursor(undef);
587 } else {
588 $self->{widget}->signal_handler_disconnect($self->{_load_sigh});
590 return 0;
593 # XXX: git-log is broken and inserts spurious empty lines in case
594 # of some extensive pickaxes. TODO: Figure out why and fix.
595 chomp $line;
596 my %entry;
597 @entry{'id', 'parents', 'author', 'subject'} = split(/\t/, $line);
598 $entry{'parents'} = [ split(/ /, $entry{'parents'}) ];
599 $self->_add(\%entry);
604 # Add commit entry
605 sub _add {
606 my $self = shift;
607 my ($entry) = @_;
609 $entry->{'iter'} = $self->{_tree_store}->append(undef);
610 my $id = substr($entry->{'id'}, 0, 8);
611 my $author = ::midtrim($entry->{'author'}, 20);
612 my $subj = ::midtrim($entry->{'subject'}, 80);
613 $self->{_tree_store}->set($entry->{'iter'},
614 COL_REF, $entry,
615 COL_ID, $id,
616 COL_AUTHOR, $author,
617 COL_SUBJECT, $subj
619 push @{$self->{_commit_sequence}}, $entry;
620 $self->{_commits}->{$entry->{'id'}} = $entry;
622 if ($self->{_load_selection}->{$entry->{'id'}}) {
623 $self->{_tree_widget}->get_selection()->select_iter($entry->{'iter'});
624 Gtk2::Gdk->flush();
628 sub _row_activated {
629 my ($tv, $path, $column, $self) = @_;
631 my $model = $tv->get_model();
632 my ($entry) = $model->get($model->get_iter($path), COL_REF);
633 $self->{ctrl}->commit_selected($entry);
639 package Giddy::PickaxableViewer;
641 use Glib::Object::Subclass
642 'Gtk2::ScrolledWindow';
644 sub _populate_popup {
645 my ($tv, $m, $self) = @_;
647 # XXX: Cut off the stupid items below second separator
648 my $sep = 0;
649 for ($m->get_children()) {
650 $sep++ if ref $_ eq 'Gtk2::SeparatorMenuItem';
651 $m->remove($_) if $sep > 1;
654 my $i = 0;
655 sub menu_item {
656 my ($self, $m, $i, $label, $handler) = @_;
657 my $mi = Gtk2::MenuItem->new_with_label($label);
658 $mi->signal_connect(activate => $handler, $self);
659 $m->insert($mi, $$i++);
660 $mi->show();
663 if ($self->{buffer}->get_has_selection()) {
664 menu_item($self, $m, \$i, 'Pickaxe', \&_pickaxe_sel);
665 menu_item($self, $m, \$i, 'Thorough Pickaxe', \&_pickaxe_sel_thorough);
667 } else {
668 my ($ll, $px, $py, $mm) = $tv->window()->get_pointer();
669 my ($bx, $by) = $tv->window_to_buffer_coords('widget', $px, $py);
670 $self->{_popup_at} = $tv->get_iter_at_position($bx, $by);
672 my $idclass = qr/[a-zA-Z0-9_]/;
673 if ($self->{_popup_at}->get_char() =~ $idclass) {
674 my $start = $self->{_popup_at}->copy();
675 while ($start->backward_char()) {
676 if ($start->get_char() !~ $idclass) {
677 $start->forward_char();
678 last;
681 my $end = $self->{_popup_at}->copy();
682 while ($end->forward_char()) {
683 if ($end->get_char() !~ $idclass) {
684 last;
687 $self->{_popup_id} = $start->get_text($end);
688 menu_item($self, $m, \$i, 'Pickaxe "'.$self->{_popup_id}.'" calls', \&_pickaxe_id);
691 menu_item($self, $m, \$i, 'Pickaxe this line', \&_pickaxe_line);
694 if ($i) {
695 my $mi = Gtk2::SeparatorMenuItem->new;
696 $m->insert($mi, $i++);
697 $mi->show();
701 sub _pickaxe_selection {
702 my ($mi, $self, $thorough) = @_;
704 my $clipboard = Gtk2::Clipboard->get(Gtk2::Gdk->SELECTION_PRIMARY);
705 my $text = $clipboard->wait_for_text();
706 $text =~ s/^[ +-]//mg;
707 $self->{ctrl}->pickaxe($text, $thorough ? undef : $self->{name} ? [$self->{name}] : undef);
710 sub _pickaxe_sel {
711 _pickaxe_selection(@_);
714 sub _pickaxe_sel_thorough {
715 _pickaxe_selection(@_, 1);
718 sub _pickaxe_line {
719 my ($mi, $self) = @_;
721 # XXX: Eew... Is there a better way to do this?
722 my $line1 = $self->{buffer}->get_iter_at_line($self->{_popup_at}->get_line());
723 my $line2 = $self->{buffer}->get_iter_at_line($self->{_popup_at}->get_line() + 1);
724 $self->{ctrl}->pickaxe($line1->get_text($line2), $self->{name} ? [$self->{name}] : undef);
727 sub _pickaxe_id {
728 my ($mi, $self) = @_;
730 $self->{ctrl}->pickaxe($self->{_popup_id});
736 package Giddy::FileViewer;
738 use base 'Giddy::PickaxableViewer';
740 # Create object and set up the widgets
741 sub new {
742 my $class = shift;
743 my ($ctrl) = @_;
744 my $self = Gtk2::ScrolledWindow->new;
746 $self->{ctrl} = $ctrl;
748 $self->{widget} = Gtk2::TextView->new;
749 $self->{widget}->set_editable(0);
750 $self->{widget}->modify_font($fixed_font);
751 $self->{widget}->set_border_window_size('GTK_TEXT_WINDOW_LEFT', 40);
752 $self->{widget}->signal_connect(expose_event => \&_expose_lineno, $self);
753 $self->{widget}->signal_connect(populate_popup => \&Giddy::PickaxableViewer::_populate_popup, $self);
754 $self->{buffer} = $self->{widget}->get_buffer();
755 $self->{lineno} = $self->{widget}->get_window('GTK_TEXT_WINDOW_LEFT');
757 $self->{_tag_ins} = $self->{buffer}->create_tag('ins', background => 'green');
758 $self->{_tag_par_ins} = $self->{buffer}->create_tag('parins', paragraph_background => 'green');
759 $self->{_tag_chg} = $self->{buffer}->create_tag('chg', background => 'orange');
760 $self->{_tag_par_chg} = $self->{buffer}->create_tag('parchg', paragraph_background => 'orange');
762 bless $self, $class;
764 $self->add($self->{widget});
765 $self;
768 # Load given file to the viewer
769 sub load {
770 my $self = shift;
771 my ($tree, $name) = @_;
773 $self->{tree} = $tree;
774 $self->{name} = $name;
776 my $text = $self->{ctrl}->repo()->command('cat-file', 'blob', $tree.':'.$name);
777 utf8::upgrade($text);
779 my $buffer = Gtk2::TextBuffer->new($self->{buffer}->get_tag_table());
780 $buffer->set_text($text);
781 # TODO: Recompute cursor position based on diff offsets
782 $buffer->place_cursor($buffer->get_iter_at_offset($self->{buffer}->get_property('cursor-position')));
783 $self->{widget}->set_buffer($buffer); $self->{buffer} = $buffer;
785 $self->_analyse_diff($tree);
788 # Check diff introduced by $tree and tag buffer appropriately
789 sub _analyse_diff {
790 my $self = shift;
791 my ($tree) = @_;
793 my @diff = $self->{ctrl}->repo()->command('diff', $tree.'^', $tree, '--', $self->{name});
794 my $in_diff = 0;
795 my ($line_old, $line_new);
796 my %minihunk = ();
798 sub minihunk_boundary {
799 my ($self, $minihunk, $line) = @_;
800 return unless $minihunk->{start};
801 my %tags = ( add => '_tag_par_ins', chg => '_tag_par_chg' );
803 # Gtk::TextBuffer counts lines from 0
804 my $line1 = $self->{buffer}->get_iter_at_line($minihunk->{start} - 1);
805 my $line2 = $self->{buffer}->get_iter_at_line($line - 1);
807 if ($minihunk->{type} eq 'del') {
808 # TODO: Insert expandable bar
809 %$minihunk = ();
810 return;
812 $self->{buffer}->apply_tag($self->{$tags{$minihunk->{type}}}, $line1, $line2);
813 %$minihunk = ();
816 foreach (@diff) {
817 unless ($in_diff) {
818 /^@@/ or next;
819 $in_diff = 1;
821 if (/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/) {
822 minihunk_boundary($self, \%minihunk, $line_new);
823 ($line_old, $line_new) = ($1, $3);
824 } elsif (/^ /) {
825 minihunk_boundary($self, \%minihunk, $line_new);
826 $line_old++, $line_new++;
827 } elsif (/^-/) {
828 if (!$minihunk{start}) {
829 $minihunk{start} = $line_new;
830 $minihunk{type} = 'del';
831 } elsif ($minihunk{type} eq 'add') {
832 $minihunk{type} = 'chg';
834 $line_old++;
835 } elsif (/^\+(.*)/) {
836 if (!$minihunk{start}) {
837 $minihunk{start} = $line_new;
838 $minihunk{type} = 'add';
839 } elsif ($minihunk{type} eq 'del') {
840 $minihunk{type} = 'chg';
842 $line_new++;
845 minihunk_boundary($self, \%minihunk, $line_new);
848 sub _expose_lineno {
849 my ($tv, $ev, $self) = @_;
851 my $lineno = $self->{widget}->get_window('GTK_TEXT_WINDOW_LEFT');
853 my $first_y = $ev->area->y;
854 my $last_y = $first_y + $ev->area->height;
856 $first_y = ($self->{widget}->window_to_buffer_coords('GTK_TEXT_WINDOW_LEFT', 0, $first_y))[1];
857 $last_y = ($self->{widget}->window_to_buffer_coords('GTK_TEXT_WINDOW_LEFT', 0, $last_y))[1];
859 my $layout = $self->{widget}->create_pango_layout('');
861 my $iter = ($self->{widget}->get_line_at_y($first_y))[0];
863 while (not $iter->is_end()) {
864 $layout->set_text($iter->get_line());
866 my ($buf_y, $height) = $self->{widget}->get_line_yrange($iter);
867 my $y = ($self->{widget}->buffer_to_window_coords('GTK_TEXT_WINDOW_LEFT', 0, $buf_y))[1];
868 $self->{widget}->style->paint_layout($lineno,
869 $self->{widget}->state, 0,
870 undef, $self->{widget}, undef, 2,
871 $y + 2,
872 $layout);
874 $iter->forward_line();
875 last if ($buf_y + $height > $last_y);
884 package Giddy::CommitViewer;
886 use Glib::Object::Subclass
887 'Gtk2::ScrolledWindow';
889 # Create object and set up the widgets
890 sub new {
891 my $class = shift;
892 my ($ctrl) = @_;
893 my $self = Gtk2::ScrolledWindow->new;
895 $self->{ctrl} = $ctrl;
897 $self->{widget} = Gtk2::TextView->new;
898 $self->{widget}->set_editable(0);
899 $self->{widget}->modify_font($fixed_font);
900 $self->{buffer} = $self->{widget}->get_buffer();
902 bless $self, $class;
904 $self->add($self->{widget});
905 $self;
908 # Load given commit to the viewer
909 sub load {
910 my $self = shift;
911 my ($commit) = @_;
913 $self->{commit} = $commit;
915 my $text = $self->{ctrl}->repo()->command('cat-file', 'commit', $commit);
916 utf8::upgrade($text);
917 $self->{buffer}->set_text($text);
923 package Giddy::DiffViewer;
925 use base 'Giddy::PickaxableViewer';
927 # Create object and set up the widgets
928 sub new {
929 my $class = shift;
930 my ($ctrl) = @_;
931 my $self = Gtk2::ScrolledWindow->new;
933 $self->{ctrl} = $ctrl;
935 $self->{widget} = Gtk2::TextView->new;
936 $self->{widget}->set_editable(0);
937 $self->{widget}->modify_font($fixed_font);
938 $self->{widget}->signal_connect(populate_popup => \&Giddy::PickaxableViewer::_populate_popup, $self);
939 $self->{buffer} = $self->{widget}->get_buffer();
941 bless $self, $class;
943 $self->add($self->{widget});
944 $self;
947 # Load given file to the viewer
948 sub load {
949 my $self = shift;
950 my ($commit, @files) = @_;
952 $self->{commit} = $commit;
953 $self->{files} = \@files;
955 my $text = $self->{ctrl}->repo()->command('show', '--pretty=format:', $commit, '--', @files);
956 utf8::upgrade($text);
957 $text =~ s/^\n//; # spurious leading newline inserted by show
958 $self->{buffer}->set_text($text);
964 # Time for...
965 # 10 - Coda !
967 # Simple widget wrapping {File,Commit}Viewer | DiffViewer
969 package Giddy::FileTab;
971 use Glib::Object::Subclass
972 'Gtk2::VPaned';
974 sub new {
975 my $class = shift;
976 my ($viewer, $diff, $of_commit) = @_;
977 my $self = Gtk2::VPaned->new;
978 bless $self, $class;
980 $self->{viewer} = $viewer;
981 $self->{diff} = $diff;
982 $self->pack1($viewer, 1, 1);
983 $self->pack2($diff, 0, 0);
984 $self->set_position($of_commit ? 200 : 400);
985 $self;
988 sub load {
989 my $self = shift;
990 my ($tree, @files) = @_;
991 $self->{tree} = $tree;
992 $self->{files} = \@files;
994 $self->{viewer}->load($tree, @files);
995 $self->{diff}->load($tree, @files);
1001 package Giddy::FilesPanel;
1003 # Create object and set up the widgets
1004 sub new {
1005 my $class = shift;
1006 my ($ctrl) = @_;
1007 my $self = { ctrl => $ctrl };
1009 $self->{widget} = Gtk2::Notebook->new;
1011 bless $self, $class;
1014 # Open new viewer tab
1015 sub open {
1016 my $self = shift;
1017 my ($tree, $name) = @_;
1019 my $widget = Giddy::FileTab->new(Giddy::FileViewer->new($self->{ctrl}),
1020 Giddy::DiffViewer->new($self->{ctrl}));
1021 $widget->show_all();
1022 my $tab = $self->{widget}->append_page($widget, Gtk2::Label->new);
1023 $self->load($tree, $name, $tab);
1024 $tab;
1027 # Open file in given/current existing tab
1028 sub load {
1029 my $self = shift;
1030 my ($tree, $name, $tab) = @_;
1031 my $chfocus = 0;
1032 $tab = $self->{widget}->get_current_page() unless defined $tab;
1034 if ($tab == 0) {
1035 # We won't load into commit tab; open new tab and shift focus
1036 $tab = -1;
1037 $chfocus = 1;
1040 if ($tab < 0) {
1041 # No tab yet
1042 $tab = $self->open($tree, $name);
1043 $chfocus and $self->{widget}->set_current_page($tab);
1044 return $tab;
1047 my $viewer_widget = $self->{widget}->get_nth_page($tab);
1048 $viewer_widget->load($tree, $name);
1049 $self->{widget}->set_tab_label($viewer_widget, Gtk2::Label->new($name));
1050 return $tab;
1053 # Open new commit tab
1054 sub open_commit {
1055 my $self = shift;
1056 my ($commit) = @_;
1058 my $widget = Giddy::FileTab->new(Giddy::CommitViewer->new($self->{ctrl}),
1059 Giddy::DiffViewer->new($self->{ctrl}), 1);
1060 $widget->show_all();
1061 my $tab = $self->{widget}->append_page($widget, Gtk2::Label->new);
1062 $self->load_commit($commit, $tab);
1065 # Load new commit in the commit tab
1066 sub load_commit {
1067 my $self = shift;
1068 my ($commit) = @_;
1070 my $viewer_widget = $self->{widget}->get_nth_page(0);
1071 $viewer_widget->load($commit);
1072 # <Delta> commitid
1073 my $label = "\x{0394} ".substr($Tree, 0, 8);
1074 $self->{widget}->set_tab_label($viewer_widget, Gtk2::Label->new($label));
1077 # Reload all tabs for new commit
1078 sub commit_changed {
1079 my $self = shift;
1081 $self->load_commit($Tree);
1083 for my $tab (1..($self->{widget}->get_n_pages()-1)) {
1084 my $viewer_widget = $self->{widget}->get_nth_page($tab);
1085 $viewer_widget->load($Tree, @{$viewer_widget->{files}});