/* * This file is part of gitg * * Copyright (C) 2015 - Jesse van den Kieboom * * gitg is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * gitg is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with gitg. If not, see . */ [GtkTemplate( ui = "/org/gnome/gitg/ui/gitg-diff-view.ui" )] public class Gitg.DiffView : Gtk.Grid { [GtkChild( name = "commit_details" )] private unowned Gitg.DiffViewCommitDetails d_commit_details; [GtkChild( name = "scrolledwindow" )] private unowned Gtk.ScrolledWindow d_scrolledwindow; [GtkChild( name = "grid_files" )] private unowned Gtk.Grid d_grid_files; [GtkChild( name = "event_box" )] private unowned Gtk.EventBox d_event_box; [GtkChild( name = "revealer_options" )] private unowned Gtk.Revealer d_revealer_options; [GtkChild( name = "diff_view_options" )] private unowned DiffViewOptions d_diff_view_options; [GtkChild( name = "text_view_message" )] private unowned Gtk.TextView d_text_view_message; private Ggit.Diff? d_diff; private Commit? d_commit; private Ggit.DiffOptions? d_options; private Cancellable d_cancellable; private ulong d_expanded_notify; private ulong d_parent_commit_notify; private bool d_changes_inline; Gdk.RGBA d_color_link; Gdk.RGBA color_hovered_link; bool hovering_over_link = false; Gtk.TextTag hover_tag = null; private uint d_reveal_options_timeout; private uint d_unreveal_options_timeout; private static Gee.HashSet s_image_mime_types; public Ggit.DiffOptions options { get { if (d_options == null) { d_options = new Ggit.DiffOptions(); } return d_options; } } public bool has_selection { get; private set; } public Ggit.Diff? diff { get { return d_diff; } set { if (d_diff != value) { d_diff = value; d_commit = null; } update(false); } } public Commit? commit { get { return d_commit; } set { if (d_commit != value) { d_commit = value; d_diff = null; } update(false); } } public virtual signal void options_changed() { if (d_commit != null) { update(true); } } public bool wrap_lines { get; construct set; default = true; } public bool staged { get; set; default = false; } public bool unstaged { get; set; default = false; } public bool show_parents { get; set; default = false; } public bool default_collapse_all { get; construct set; default = true; } public bool use_gravatar { get; construct set; default = true; } public int tab_width { get; construct set; default = 4; } public bool handle_selection { get; construct set; default = false; } public bool highlight { get; construct set; default = true; } private Repository? d_repository; private GLib.Regex regex_custom_links = /gitg\.custom-link\.(.+)\.regex/; public Repository? repository { get { return d_repository; } set { d_repository = value; if (d_repository!=null) { d_commit_details.repository = d_repository; } } } public bool new_is_workdir { get; set; } private GLib.Regex regex_url = /\w+:(\/?\/?)[^\s]+/; private bool flag_get(Ggit.DiffOption f) { return (options.flags & f) != 0; } private void flag_set(Ggit.DiffOption f, bool val) { var flags = options.flags; if (val) { flags |= f; } else { flags &= ~f; } if (flags != options.flags) { options.flags = flags; options_changed(); } } public bool ignore_whitespace { get { return flag_get(Ggit.DiffOption.IGNORE_WHITESPACE); } set { flag_set(Ggit.DiffOption.IGNORE_WHITESPACE, value); } } public bool changes_inline { get { return d_changes_inline; } set { if (d_changes_inline != value) { d_changes_inline = value; // TODO //options_changed(); } } } public int context_lines { get { return options.n_context_lines; } construct set { if (options.n_context_lines != value) { options.n_context_lines = value; options.n_interhunk_lines = value; options_changed(); } } } protected override void constructed() { d_expanded_notify = d_commit_details.notify["expanded"].connect(update_expanded_files); d_parent_commit_notify = d_commit_details.notify["parent-commit"].connect(parent_commit_changed); bind_property("use-gravatar", d_commit_details, "use-gravatar", BindingFlags.SYNC_CREATE); d_text_view_message.event_after.connect (on_event_after); d_text_view_message.key_press_event.connect (on_key_press); d_text_view_message.motion_notify_event.connect (on_motion_notify_event); d_text_view_message.has_tooltip = true; d_text_view_message.query_tooltip.connect (on_query_tooltip_event); d_text_view_message.style_updated.connect (load_colors_from_theme); load_colors_from_theme(d_text_view_message); d_event_box.motion_notify_event.connect(motion_notify_event_on_event_box); d_diff_view_options.view = this; } public override void dispose() { if (d_cancellable != null) { d_cancellable.cancel(); } base.dispose(); } private void parent_commit_changed() { update(false); } private void update_expanded_files() { var expanded = d_commit_details.expanded; foreach (var file in d_grid_files.get_children()) { ((Gitg.DiffViewFile) file).expanded = expanded; } } private static Regex s_message_regexp; static construct { s_image_mime_types = new Gee.HashSet(); foreach (var format in Gdk.Pixbuf.get_formats()) { foreach (var mime_type in format.get_mime_types()) { s_image_mime_types.add(mime_type); } } try { s_message_regexp = new Regex(".*(\\R|\\s)*(?P(?:.|\\R)*?)\\s*$"); } catch (Error e) { stderr.printf(@"Failed to compile regex: $(e.message)\n"); } } construct { context_lines = 3; } private string message_without_subject(Commit commit) { var message = commit.get_message(); MatchInfo minfo; if (s_message_regexp.match(message, 0, out minfo)) { return minfo.fetch_named("message"); } return ""; } private bool on_query_tooltip_event(int x, int y, bool keyboard_tooltip, Gtk.Tooltip tooltip) { Gtk.TextIter iter; if (d_text_view_message.get_iter_at_location (out iter, x, y)) { var tags = iter.get_tags (); foreach (Gtk.TextTag tag in tags) { if (tag.get_data("type") == "url" && tag.get_data("is_custom_link")) { string url = tag.get_data("url"); tooltip.set_text (url); return true; } } } return false; } public void apply_link_tags(Gtk.TextBuffer buffer, Regex regex, string? replacement, Gdk.RGBA custom_color_link, bool is_custom_color, bool is_custom_link) { try { GLib.MatchInfo matchInfo; var buffer_text = buffer.text; regex.match (buffer_text, 0, out matchInfo); while (matchInfo.matches ()) { Gtk.TextIter start, end; int start_pos, end_pos; string text = matchInfo.fetch(0); matchInfo.fetch_pos (0, out start_pos, out end_pos); buffer.get_iter_at_offset(out start, start_pos -1); buffer.get_iter_at_offset(out end, end_pos -1); var tag = buffer.create_tag(null, "underline", Pango.Underline.SINGLE); tag.foreground_rgba = custom_color_link; tag.set_data("type", "url"); tag.set_data("color_link", custom_color_link); if (replacement != null) { text = regex.replace(text, text.length, 0, replacement); } tag.set_data("url", text); tag.set_data("is_custom_color_link", is_custom_color); tag.set_data("is_custom_link", is_custom_link); buffer.apply_tag(tag, start, end); matchInfo.next(); } } catch(Error e) { } } private void load_colors_from_theme(Gtk.Widget widget) { Gtk.TextView textview = (Gtk.TextView)widget; Gtk.StyleContext context = textview.get_style_context (); context.save (); context.set_state (Gtk.StateFlags.LINK); d_color_link = context.get_color (context.get_state ()); context.set_state (Gtk.StateFlags.LINK | Gtk.StateFlags.PRELIGHT); color_hovered_link = context.get_color (context.get_state ()); context.restore (); textview.buffer.tag_table.foreach ((tag) => { if (!tag.get_data("is_custom_color_link")) { tag.set_data("color_link", d_color_link); tag.foreground_rgba = d_color_link; } }); } private bool on_key_press (Gtk.Widget widget, Gdk.EventKey evt) { if (evt.keyval == Gdk.Key.Return || evt.keyval == Gdk.Key.KP_Enter) { Gtk.TextIter iter; Gtk.TextView textview = (Gtk.TextView) widget; textview.buffer.get_iter_at_mark(out iter, textview.buffer.get_insert()); follow_if_link (widget, iter); } return false; } public void follow_if_link(Gtk.Widget texview, Gtk.TextIter iter) { var tags = iter.get_tags (); foreach (Gtk.TextTag tag in tags) { if (tag.get_data("type") == "url") { string url = tag.get_data("url"); try { GLib.AppInfo.launch_default_for_uri(url, null); } catch(Error e) { warning ("Cannot open %s: %s", url, e.message); } } } } private void on_event_after (Gtk.Widget widget, Gdk.Event evt) { Gtk.TextIter start, end, iter; Gtk.TextBuffer buffer; double ex, ey; int x, y; if (evt.type == Gdk.EventType.BUTTON_RELEASE) { Gdk.EventButton event; event = (Gdk.EventButton)evt; if (event.button != Gdk.BUTTON_PRIMARY) return; ex = event.x; ey = event.y; } else if (evt.type == Gdk.EventType.TOUCH_END) { Gdk.EventTouch event; event = (Gdk.EventTouch)evt; ex = event.x; ey = event.y; } else { return; } Gtk.TextView textview = (Gtk.TextView)widget; buffer = textview.buffer; /* we shouldn't follow a link if the user has selected something */ buffer.get_selection_bounds (out start, out end); if (start.get_offset () != end.get_offset ()) { return; } textview.window_to_buffer_coords (Gtk.TextWindowType.WIDGET,(int)ex, (int)ey, out x, out y); if (textview.get_iter_at_location (out iter, x, y)) { follow_if_link (textview, iter); } } private bool on_motion_notify_event (Gtk.Widget widget, Gdk.EventMotion evt) { int x, y; Gtk.TextView textview = ((Gtk.TextView)widget); textview.window_to_buffer_coords (Gtk.TextWindowType.WIDGET,(int)evt.x, (int)evt.y, out x, out y); Gtk.TextIter iter; bool hovering = false; if (textview.get_iter_at_location (out iter, x, y)) { var tags = iter.get_tags (); foreach (Gtk.TextTag tag in tags) { if (tag.get_data("type") == "url") { hovering = true; if (hover_tag != null && hover_tag != tag) { restore_tag_color_link (hover_tag); hovering_over_link = false; } hover_tag = tag; break; } } } if (hovering != hovering_over_link) { hovering_over_link = hovering; Gdk.Display display = textview.get_display(); Gdk.Cursor hand_cursor = new Gdk.Cursor.from_name (display, "pointer"); Gdk.Cursor regular_cursor = new Gdk.Cursor.from_name (display, "text"); Gdk.Window window = textview.get_window (Gtk.TextWindowType.TEXT); if (hovering_over_link) { window.set_cursor (hand_cursor); if (hover_tag != null) { hover_tag.foreground_rgba = color_hovered_link; } } else { window.set_cursor (regular_cursor); if (hover_tag != null) { restore_tag_color_link (hover_tag); hover_tag = null; } } } return true; } private void restore_tag_color_link (Gtk.TextTag tag) { Gdk.RGBA? color = tag.get_data("color_link"); tag.foreground_rgba = color; } private void update(bool preserve_expanded) { // If both `d_diff` and `d_commit` are null, clear // the diff content if (d_diff == null && d_commit == null) { d_commit_details.hide(); d_scrolledwindow.hide(); return; } d_commit_details.show(); d_scrolledwindow.show(); // Cancel running operations d_cancellable.cancel(); d_cancellable = new Cancellable(); if (d_commit != null) { SignalHandler.block(d_commit_details, d_parent_commit_notify); d_commit_details.commit = d_commit; SignalHandler.unblock(d_commit_details, d_parent_commit_notify); int parent = 0; var parents = d_commit.get_parents(); var parent_commit = d_commit_details.parent_commit; if (parent_commit != null) { for (var i = 0; i < parents.size; i++) { var id = parents.get_id(i); if (id.equal(parent_commit.get_id())) { parent = i; break; } } } d_diff = d_commit.get_diff(options, parent); d_commit_details.show(); var message = message_without_subject(d_commit); d_text_view_message.buffer.set_text(message); var buffer = d_text_view_message.get_buffer(); apply_link_tags(buffer, regex_url, null, d_color_link, false, false); parse_smart_text(buffer); d_text_view_message.visible = (message != ""); } else { d_commit_details.commit = null; d_commit_details.hide(); d_text_view_message.hide(); } if (d_diff != null) { update_diff(d_diff, preserve_expanded, d_cancellable); } } private void parse_smart_text(Gtk.TextBuffer buffer) { if (repository != null) { try { var conf = repository.get_config().snapshot(); conf.match_foreach(regex_custom_links, (match_info, value) => { string group = match_info.fetch(1); debug ("found custom-link group: %s", group); string custom_link_regexp = value; string replacement_key = "gitg.custom-link.%s.replacement".printf(group); try { string custom_link_replacement = conf.get_string(replacement_key); string color_key = "gitg.custom-link.%s.color".printf(group); string custom_color = conf.get_string(color_key); Gdk.RGBA color = d_color_link; bool is_custom_color = custom_color != null; if (is_custom_color) { color = Gdk.RGBA(); color.parse(custom_color); } apply_link_tags(buffer, new Regex (custom_link_regexp), custom_link_replacement, color, is_custom_color, true); } catch (Error e) { warning ("Cannot read git config: %s", e.message); } return 0; }); } catch (Error e) { warning ("Cannot read git config: %s", e.message); } } } private void auto_change_expanded(bool expanded) { SignalHandler.block(d_commit_details, d_expanded_notify); d_commit_details.expanded = expanded; SignalHandler.unblock(d_commit_details, d_expanded_notify); } private void on_selection_changed() { bool something_selected = false; foreach (var file in d_grid_files.get_children()) { if (((Gitg.DiffViewFile) file).has_selection()) { something_selected = true; break; } } if (has_selection != something_selected) { has_selection = something_selected; } } private string? primary_path(Ggit.DiffDelta delta) { var path = delta.get_old_file().get_path(); if (path == null) { path = delta.get_new_file().get_path(); } return path; } private delegate void Anon(); private string key_for_delta(Ggit.DiffDelta delta) { var new_file = delta.get_new_file(); var new_path = new_file.get_path(); if (new_path != null) { return @"path:$(new_path)"; } var old_file = delta.get_old_file(); var old_path = old_file.get_path(); if (old_path != null) { return @"path:$(old_path)"; } return ""; } private void update_diff(Ggit.Diff diff, bool preserve_expanded, Cancellable? cancellable) { var nqueries = 0; var finished = false; var infomap = new Gee.HashMap(); Anon check_finish = () => { if (nqueries == 0 && finished && (cancellable == null || !cancellable.is_cancelled())) { finished = false; update_diff_hunks(diff, preserve_expanded, infomap, cancellable); } }; // Collect file info asynchronously first for (var i = 0; i < diff.get_num_deltas(); i++) { var delta = diff.get_delta(i); var info = new DiffViewFileInfo(repository, delta, new_is_workdir); nqueries++; info.query.begin(cancellable, (obj, res) => { info.query.end(res); infomap[key_for_delta(delta)] = info; nqueries--; check_finish(); }); } finished = true; check_finish(); } private void update_diff_hunks(Ggit.Diff diff, bool preserve_expanded, Gee.HashMap infomap, Cancellable? cancellable) { var files = new Gee.ArrayList(); Gitg.DiffViewFile? current_file = null; Ggit.DiffHunk? current_hunk = null; Gee.ArrayList? current_lines = null; var current_is_binary = false; var maxlines = 0; Anon add_hunk = () => { if (current_hunk != null) { current_file.add_hunk(current_hunk, current_lines); current_lines = null; current_hunk = null; } }; Anon add_file = () => { add_hunk(); if (current_file != null) { current_file.show(); files.add(current_file); current_file = null; } }; try { diff.foreach( (delta, progress) => { if (cancellable != null && cancellable.is_cancelled()) { return 1; } add_file(); DiffViewFileInfo? info = null; var deltakey = key_for_delta(delta); if (infomap.has_key(deltakey)) { info = infomap[deltakey]; } else { info = new DiffViewFileInfo(repository, delta, new_is_workdir); } current_is_binary = ((delta.get_flags() & Ggit.DiffFlag.BINARY) != 0); // List of known binary file types that may be wrongly classified by // libgit2 because it does not contain any null bytes in the first N // bytes. E.g. PDF var known_binary_files_types = new string[] {"application/pdf"}; // Ignore binary based on content type if (info != null && info.new_file_content_type in known_binary_files_types) { current_is_binary = true; } string? mime_type_for_image = null; if (info == null || info.new_file_content_type == null) { // Guess mime type from old file name in the case of a deleted file var oldpath = delta.get_old_file().get_path(); if (oldpath != null) { bool uncertain; var ctype = ContentType.guess(Path.get_basename(oldpath), null, out uncertain); if (ctype != null) { mime_type_for_image = ContentType.get_mime_type(ctype); } } } else { mime_type_for_image = ContentType.get_mime_type(info.new_file_content_type); } bool can_diff_as_image = mime_type_for_image != null && s_image_mime_types.contains(mime_type_for_image); bool can_diff_as_text = ContentType.is_mime_type(mime_type_for_image, "text/plain"); current_file = new Gitg.DiffViewFile(info); if (can_diff_as_image) { current_file.add_image_renderer(); } if (!can_diff_as_image && !current_is_binary && !can_diff_as_text) { //force diff as text if no other diff is possible can_diff_as_text = true; } if (can_diff_as_text) { current_file.add_text_renderer(handle_selection); var renderer_list = current_file.renderer_list; foreach (DiffViewFileRenderer renderer in renderer_list) { var renderer_text = renderer as DiffViewFileRendererTextable; if (renderer_text != null) { bind_property("highlight", renderer_text, "highlight", BindingFlags.SYNC_CREATE); bind_property("wrap-lines", renderer_text, "wrap-lines", BindingFlags.DEFAULT | BindingFlags.SYNC_CREATE); bind_property("tab-width", renderer_text, "tab-width", BindingFlags.DEFAULT | BindingFlags.SYNC_CREATE); renderer_text.maxlines = maxlines; renderer_text.notify["has-selection"].connect(on_selection_changed); } } on_selection_changed(); } if (current_is_binary) { try { var new_file = delta.get_new_file(); var old_file = delta.get_old_file(); if (TextConv.has_textconv_command(repository, old_file) || TextConv.has_textconv_command(repository, new_file)) { uint8[] n_textconv = TextConv.get_textconv_content(repository, new_file); uint8[] o_textconv = TextConv.get_textconv_content(repository, old_file); current_is_binary = false; var opts = new Ggit.DiffOptions(); opts.flags = Ggit.DiffOption.INCLUDE_UNTRACKED | Ggit.DiffOption.IGNORE_WHITESPACE | Ggit.DiffOption.DISABLE_PATHSPEC_MATCH | Ggit.DiffOption.RECURSE_UNTRACKED_DIRS; opts.n_context_lines = 3; opts.n_interhunk_lines = 3; var bdiff = new Ggit.Diff.buffers(o_textconv, old_file.get_path(), n_textconv, new_file.get_path(), opts); bdiff.foreach( (delta, progress) => { if (cancellable != null && cancellable.is_cancelled()) { return 1; } deltakey = key_for_delta(delta); if (infomap.has_key(deltakey)) { info = infomap[deltakey]; } else { info = new DiffViewFileInfo(repository, delta, new_is_workdir); } current_file = new Gitg.DiffViewFile(info); current_file.add_text_renderer(handle_selection); return 0; }, (delta, binary) => { if (cancellable != null && cancellable.is_cancelled()) { return 1; } return 0; }, (delta, hunk) => { if (cancellable != null && cancellable.is_cancelled()) { return 1; } if (!current_is_binary) { maxlines = int.max(maxlines, hunk.get_old_start() + hunk.get_old_lines()); maxlines = int.max(maxlines, hunk.get_new_start() + hunk.get_new_lines()); add_hunk(); current_hunk = hunk; current_lines = new Gee.ArrayList(); } return 0; }, (delta, hunk, line) => { if (cancellable != null && cancellable.is_cancelled()) { return 1; } if (!current_is_binary) { current_lines.add(line); } return 0; } ); add_hunk(); add_file(); } } catch (Error error) { stderr.printf (@"Error: $(error.message)\n"); } if (current_is_binary) current_file.add_binary_renderer(); } return 0; }, (delta, binary) => { // FIXME: do we want to handle binary data? if (cancellable != null && cancellable.is_cancelled()) { return 1; } return 0; }, (delta, hunk) => { if (cancellable != null && cancellable.is_cancelled()) { return 1; } if (!current_is_binary) { maxlines = int.max(maxlines, hunk.get_old_start() + hunk.get_old_lines()); maxlines = int.max(maxlines, hunk.get_new_start() + hunk.get_new_lines()); add_hunk(); current_hunk = hunk; current_lines = new Gee.ArrayList(); } return 0; }, (delta, hunk, line) => { if (cancellable != null && cancellable.is_cancelled()) { return 1; } if (!current_is_binary) { current_lines.add(line); } return 0; } ); } catch {} add_hunk(); add_file(); var file_widgets = d_grid_files.get_children(); var was_expanded = new Gee.HashSet(); foreach (var file in file_widgets) { unowned DiffViewFile f = (DiffViewFile) file; if (preserve_expanded && f.expanded) { var path = primary_path(f.info.delta); if (path != null) { was_expanded.add(path); } } f.destroy(); } d_commit_details.expanded = (files.size <= 1 || !default_collapse_all); d_commit_details.expander_visible = (files.size > 1); for (var i = 0; i < files.size; i++) { var file = files[i]; var path = primary_path(file.info.delta); file.expanded = d_commit_details.expanded || (path != null && was_expanded.contains(path)); if (i == files.size - 1) { file.vexpand = true; } d_grid_files.add(file); file.notify["expanded"].connect(auto_update_expanded); } } private void auto_update_expanded() { foreach (var file in d_grid_files.get_children()) { if (!((Gitg.DiffViewFile) file).expanded) { auto_change_expanded(false); return; } } auto_change_expanded(true); } public PatchSet[] get_selection() { var ret = new PatchSet[0]; foreach (var file in d_grid_files.get_children()) { ret += ((Gitg.DiffViewFile)file).get_selection(); } return ret; } public void clear_selection() { foreach (var file in d_grid_files.get_children()) { ((Gitg.DiffViewFile)file).clear_selection(); } } private void update_hide_show_options(Gdk.Window window, int ex, int ey) { void *data; window.get_user_data(out data); var w = data as Gtk.Widget; if (w == null) { return; } int x, y; w.translate_coordinates(d_event_box, ex, ey, out x, out y); Gtk.Allocation alloc, revealer_alloc; d_event_box.get_allocation(out alloc); d_revealer_options.get_allocation(out revealer_alloc); if (!d_revealer_options.reveal_child && y >= alloc.height - 18 && x >= alloc.width - 150 && d_reveal_options_timeout == 0) { if (d_unreveal_options_timeout != 0) { Source.remove(d_unreveal_options_timeout); d_unreveal_options_timeout = 0; } d_reveal_options_timeout = Timeout.add(300, () => { d_reveal_options_timeout = 0; d_revealer_options.reveal_child = true; return false; }); } else if (d_revealer_options.reveal_child) { var above = (y <= alloc.height - 6 - revealer_alloc.height); if (above && d_unreveal_options_timeout == 0) { if (d_reveal_options_timeout != 0) { Source.remove(d_reveal_options_timeout); d_reveal_options_timeout = 0; } d_unreveal_options_timeout = Timeout.add(1000, () => { d_unreveal_options_timeout = 0; d_revealer_options.reveal_child = false; return false; }); } else if (!above && d_unreveal_options_timeout != 0) { Source.remove(d_unreveal_options_timeout); d_unreveal_options_timeout = 0; } } } [GtkCallback] private bool leave_notify_event_on_event_box(Gtk.Widget widget, Gdk.EventCrossing event) { update_hide_show_options(event.window, (int)event.x, (int)event.y); return false; } [GtkCallback] private bool enter_notify_event_on_event_box(Gtk.Widget widget, Gdk.EventCrossing event) { update_hide_show_options(event.window, (int)event.x, (int)event.y); return false; } [GtkCallback] private bool motion_notify_event_on_event_box(Gtk.Widget widget, Gdk.EventMotion event) { update_hide_show_options(event.window, (int)event.x, (int)event.y); return false; } } // ex:ts=4 noet