1/*
2 * This file is part of gitg
3 *
4 * Copyright (C) 2016 - Jesse van den Kieboom
5 *
6 * gitg is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * gitg is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with gitg. If not, see <http://www.gnu.org/licenses/>.
18 */
19
20[GtkTemplate (ui = "/org/gnome/gitg/ui/gitg-diff-view-file-renderer-text.ui")]
21class Gitg.DiffViewFileRendererText : Gtk.SourceView, DiffSelectable, DiffViewFileRenderer
22{
23	private enum RegionType
24	{
25		ADDED,
26		REMOVED,
27		CONTEXT
28	}
29
30	private struct Region
31	{
32		public RegionType type;
33		public int buffer_line_start;
34		public int source_line_start;
35		public int length;
36	}
37
38	public uint added { get; set; }
39	public uint removed { get; set; }
40
41	private int64 d_doffset;
42
43	private Gee.HashMap<int, PatchSet.Patch?> d_lines;
44
45	private DiffViewFileSelectable d_selectable;
46	private DiffViewLinesRenderer d_old_lines;
47	private DiffViewLinesRenderer d_new_lines;
48	private DiffViewLinesRenderer d_sym_lines;
49
50	private bool d_highlight;
51
52	private Cancellable? d_higlight_cancellable;
53	private Gtk.SourceBuffer? d_old_highlight_buffer;
54	private Gtk.SourceBuffer? d_new_highlight_buffer;
55	private bool d_old_highlight_ready;
56	private bool d_new_highlight_ready;
57	private Gtk.CssProvider css_provider;
58
59	private Region[] d_regions;
60	private bool d_constructed;
61
62	private Settings? d_stylesettings;
63
64	private Settings? d_fontsettings;
65
66	public bool new_is_workdir { get; construct set; }
67
68	public bool wrap_lines
69	{
70		get { return this.wrap_mode != Gtk.WrapMode.NONE; }
71		set
72		{
73			if (value)
74			{
75				this.wrap_mode = Gtk.WrapMode.WORD_CHAR;
76			}
77			else
78			{
79				this.wrap_mode = Gtk.WrapMode.NONE;
80			}
81		}
82	}
83
84	public new int tab_width
85	{
86		get { return (int)get_tab_width(); }
87		set { set_tab_width((uint)value); }
88	}
89
90	public int maxlines { get; set; }
91
92	public DiffViewFileInfo info { get; construct set; }
93
94	public Ggit.DiffDelta? delta
95	{
96		get { return info.delta; }
97	}
98
99	public Repository? repository
100	{
101		get { return info.repository; }
102	}
103
104	public bool highlight
105	{
106		get { return d_highlight; }
107
108		construct set
109		{
110			if (d_highlight != value)
111			{
112				d_highlight = value;
113				update_highlight();
114			}
115		}
116	}
117
118	private bool d_has_selection;
119
120	public bool has_selection
121	{
122		get { return d_has_selection; }
123	}
124
125	public bool can_select { get; construct set; }
126
127	public PatchSet selection
128	{
129		owned get
130		{
131			var ret = new PatchSet();
132
133			ret.filename = delta.get_new_file().get_path();
134
135			var patches = new PatchSet.Patch[0];
136
137			if (!can_select)
138			{
139				return ret;
140			}
141
142			var selected = d_selectable.get_selected_lines();
143
144			for (var i = 0; i < selected.length; i++)
145			{
146				var line = selected[i];
147				var pset = d_lines[line];
148
149				if (i == 0)
150				{
151					patches += pset;
152					continue;
153				}
154
155				var last = patches[patches.length - 1];
156
157				if (last.new_offset + last.length == pset.new_offset &&
158				    last.type == pset.type)
159				{
160					last.length += pset.length;
161					patches[patches.length - 1] = last;
162				}
163				else
164				{
165					patches += pset;
166				}
167			}
168
169			ret.patches = patches;
170			return ret;
171		}
172	}
173
174	public DiffViewFileRendererText(DiffViewFileInfo info, bool can_select)
175	{
176		Object(info: info, can_select: can_select);
177	}
178
179	construct
180	{
181		var gutter = this.get_gutter(Gtk.TextWindowType.LEFT);
182
183		d_old_lines = new DiffViewLinesRenderer(DiffViewLinesRenderer.Style.OLD);
184		d_new_lines = new DiffViewLinesRenderer(DiffViewLinesRenderer.Style.NEW);
185		d_sym_lines = new DiffViewLinesRenderer(DiffViewLinesRenderer.Style.SYMBOL);
186
187		this.bind_property("maxlines", d_old_lines, "maxlines", BindingFlags.DEFAULT | BindingFlags.SYNC_CREATE);
188		this.bind_property("maxlines", d_new_lines, "maxlines", BindingFlags.DEFAULT | BindingFlags.SYNC_CREATE);
189
190		d_old_lines.xpad = 8;
191		d_new_lines.xpad = 8;
192		d_sym_lines.xpad = 6;
193
194		gutter.insert(d_old_lines, 0);
195		gutter.insert(d_new_lines, 1);
196		gutter.insert(d_sym_lines, 2);
197
198		this.set_border_window_size(Gtk.TextWindowType.TOP, 1);
199
200		var settings = Gtk.Settings.get_default();
201		settings.notify["gtk-application-prefer-dark-theme"].connect(update_theme);
202
203		css_provider = new Gtk.CssProvider();
204		get_style_context().add_provider(css_provider,Gtk.STYLE_PROVIDER_PRIORITY_SETTINGS);
205
206		update_theme();
207
208		if (can_select)
209		{
210			d_selectable = new DiffViewFileSelectable(this);
211
212			d_selectable.notify["has-selection"].connect(() => {
213				d_has_selection = d_selectable.has_selection;
214				notify_property("has-selection");
215			});
216		}
217
218		d_lines = new Gee.HashMap<int, PatchSet.Patch?>();
219
220		highlight = true;
221	}
222
223	protected override void dispose()
224	{
225		base.dispose();
226
227		if (d_higlight_cancellable != null)
228		{
229			d_higlight_cancellable.cancel();
230			d_higlight_cancellable = null;
231		}
232	}
233
234	private void update_highlight()
235	{
236		if (!d_constructed)
237		{
238			return;
239		}
240
241		if (d_higlight_cancellable != null)
242		{
243			d_higlight_cancellable.cancel();
244			d_higlight_cancellable = null;
245		}
246
247		d_old_highlight_buffer = null;
248		d_new_highlight_buffer = null;
249
250		d_old_highlight_ready = false;
251		d_new_highlight_ready = false;
252
253		if (highlight && repository != null && delta != null)
254		{
255			var cancellable = new Cancellable();
256			d_higlight_cancellable = cancellable;
257
258			init_highlighting_buffer_old.begin(cancellable, (obj, res) => {
259				init_highlighting_buffer_old.end(res);
260			});
261
262			init_highlighting_buffer_new.begin(cancellable, (obj, res) => {
263				init_highlighting_buffer_new.end(res);
264			});
265		}
266		else
267		{
268			update_highlighting_ready();
269		}
270	}
271
272	private async void init_highlighting_buffer_old(Cancellable cancellable)
273	{
274		var buffer = yield init_highlighting_buffer(delta.get_old_file(), false, cancellable);
275
276		if (!cancellable.is_cancelled())
277		{
278			d_old_highlight_buffer = buffer;
279			d_old_highlight_ready = true;
280
281			update_highlighting_ready();
282		}
283	}
284
285	private File? get_file_location(Ggit.DiffFile file)
286	{
287		var path = file.get_path();
288
289		if (path == null)
290		{
291			return null;
292		}
293
294		var workdir = repository.get_workdir();
295
296		if (workdir == null)
297		{
298			return null;
299		}
300
301		return workdir.get_child(path);
302	}
303
304	private async void init_highlighting_buffer_new(Cancellable cancellable)
305	{
306		Gtk.SourceBuffer? buffer;
307
308		var file = delta.get_new_file();
309
310		if (info.new_file_input_stream != null)
311		{
312			// Use once
313			var stream = info.new_file_input_stream;
314			info.new_file_input_stream = null;
315
316			buffer = yield init_highlighting_buffer_from_stream(delta.get_new_file(),
317			                                                    get_file_location(file),
318			                                                    stream,
319			                                                    info.new_file_content_type,
320			                                                    cancellable);
321		}
322		else
323		{
324			buffer = yield init_highlighting_buffer(delta.get_new_file(), info.from_workdir, cancellable);
325		}
326
327		if (!cancellable.is_cancelled())
328		{
329			d_new_highlight_buffer = buffer;
330			d_new_highlight_ready = true;
331
332			update_highlighting_ready();
333		}
334	}
335
336	private async Gtk.SourceBuffer? init_highlighting_buffer(Ggit.DiffFile file, bool from_workdir, Cancellable cancellable)
337	{
338		var id = file.get_oid();
339		var location = get_file_location(file);
340
341		if ((id.is_zero() && !from_workdir) || (location == null && from_workdir))
342		{
343			return null;
344		}
345
346		uint8[] content;
347
348		if (!from_workdir)
349		{
350			Ggit.Blob blob;
351
352			try
353			{
354				blob = repository.lookup<Ggit.Blob>(id);
355			}
356			catch
357			{
358				return null;
359			}
360
361			content = blob.get_raw_content();
362		}
363		else
364		{
365			// Try to read from disk
366			try
367			{
368				string etag;
369
370				// Read it all into a buffer so we can guess the content type from
371				// it. This isn't really nice, but it's simple.
372				yield location.load_contents_async(cancellable, out content, out etag);
373			}
374			catch
375			{
376				return null;
377			}
378		}
379
380		bool uncertain;
381		var content_type = GLib.ContentType.guess(location.get_basename(), content, out uncertain);
382
383		var stream = new GLib.MemoryInputStream.from_bytes(new Bytes(content));
384
385		return yield init_highlighting_buffer_from_stream(file, location, stream, content_type, cancellable);
386	}
387
388	private async Gtk.SourceBuffer? init_highlighting_buffer_from_stream(Ggit.DiffFile file, File location, InputStream stream, string content_type, Cancellable cancellable)
389	{
390		var manager = Gtk.SourceLanguageManager.get_default();
391		var language = manager.guess_language(location != null ? location.get_basename() : null, content_type);
392
393		if (language == null)
394		{
395			return null;
396		}
397
398		var buffer = new Gtk.SourceBuffer(this.buffer.tag_table);
399
400		var style_scheme_manager = Gtk.SourceStyleSchemeManager.get_default();
401
402		buffer.language = language;
403		buffer.highlight_syntax = true;
404		d_fontsettings = try_settings("org.gnome.desktop.interface");
405		if (d_fontsettings != null)
406		{
407			d_fontsettings.changed["monospace-font-name"].connect((s, k) => {
408				update_font();
409			});
410
411			update_font();
412		}
413		d_stylesettings = try_settings(Gitg.Config.APPLICATION_ID + ".preferences.interface");
414		if (d_stylesettings != null)
415		{
416			d_stylesettings.changed["style-scheme"].connect((s, k) => {
417				update_style();
418			});
419
420			update_style();
421		} else {
422			buffer.style_scheme = style_scheme_manager.get_scheme("classic");
423		}
424
425		var sfile = new Gtk.SourceFile();
426		sfile.location = location;
427
428		var loader = new Gtk.SourceFileLoader.from_stream(buffer, sfile, stream);
429
430		try
431		{
432			yield loader.load_async(GLib.Priority.LOW, cancellable, null);
433			this.strip_carriage_returns(buffer);
434		}
435		catch (Error e)
436		{
437			if (!cancellable.is_cancelled())
438			{
439				stderr.printf(@"ERROR: failed to load $(file.get_path()) for highlighting: $(e.message)\n");
440			}
441
442			return null;
443		}
444
445		return buffer;
446	}
447
448	private void update_style()
449	{
450		var scheme = d_stylesettings.get_string("style-scheme");
451		var manager = Gtk.SourceStyleSchemeManager.get_default();
452		var s = manager.get_scheme(scheme);
453
454		if (s != null)
455		{
456			(buffer as Gtk.SourceBuffer).style_scheme = s;
457		}
458	}
459
460	private void update_font()
461	{
462		var fname = d_fontsettings.get_string("monospace-font-name");
463		var font_desc = Pango.FontDescription.from_string(fname);
464		var css = "textview{%s}".printf(Dazzle.pango_font_description_to_css(font_desc));
465		try
466		{
467			css_provider.load_from_data(css);
468		}
469		catch(Error e)
470		{
471			warning("Error applying font: %s", e.message);
472		}
473	}
474
475	private Settings? try_settings(string schema_id)
476	{
477		var source = SettingsSchemaSource.get_default();
478
479		if (source == null)
480		{
481			return null;
482		}
483
484		if (source.lookup(schema_id, true) != null)
485		{
486			return new Settings(schema_id);
487		}
488
489		return null;
490	}
491
492	private void strip_carriage_returns(Gtk.SourceBuffer buffer)
493	{
494		var search_settings = new Gtk.SourceSearchSettings();
495
496		search_settings.regex_enabled = true;
497		search_settings.search_text = "\\r";
498
499		var search_context = new Gtk.SourceSearchContext(buffer, search_settings);
500
501		try
502		{
503			search_context.replace_all("", 0);
504		} catch (Error e) {}
505	}
506
507	private void update_highlighting_ready()
508	{
509		if (!d_old_highlight_ready && !d_new_highlight_ready)
510		{
511			// Remove highlights
512			return;
513		}
514		else if (!d_old_highlight_ready || !d_new_highlight_ready)
515		{
516			// Both need to be loaded
517			return;
518		}
519
520		var buffer = this.buffer;
521
522		// Go over all the source chunks and match up to old/new buffer. Then,
523		// apply the tags that are applied to the highlighted source buffers.
524		foreach (var region in d_regions)
525		{
526			Gtk.SourceBuffer? source;
527
528			if (region.type == RegionType.REMOVED)
529			{
530				source = d_old_highlight_buffer;
531			}
532			else
533			{
534				source = d_new_highlight_buffer;
535			}
536
537			if (source == null)
538			{
539				continue;
540			}
541
542			Gtk.TextIter buffer_iter, source_iter;
543
544			buffer.get_iter_at_line(out buffer_iter, region.buffer_line_start);
545			source.get_iter_at_line(out source_iter, region.source_line_start);
546
547			var source_end_iter = source_iter;
548			source_end_iter.forward_lines(region.length);
549
550			source.ensure_highlight(source_iter, source_end_iter);
551
552			var buffer_end_iter = buffer_iter;
553			buffer_end_iter.forward_lines(region.length);
554
555			var source_next_iter = source_iter;
556			var tags = source_iter.get_tags();
557
558			while (source_next_iter.forward_to_tag_toggle(null) && source_next_iter.compare(source_end_iter) < 0)
559			{
560				var buffer_next_iter = buffer_iter;
561				buffer_next_iter.forward_chars(source_next_iter.get_offset() - source_iter.get_offset());
562
563				foreach (var tag in tags)
564				{
565					buffer.apply_tag(tag, buffer_iter, buffer_next_iter);
566				}
567
568				source_iter = source_next_iter;
569				buffer_iter = buffer_next_iter;
570
571				tags = source_iter.get_tags();
572			}
573
574			foreach (var tag in tags)
575			{
576				buffer.apply_tag(tag, buffer_iter, buffer_end_iter);
577			}
578		}
579	}
580
581	protected override bool draw(Cairo.Context cr)
582	{
583		base.draw(cr);
584
585		var win = this.get_window(Gtk.TextWindowType.LEFT);
586
587		if (!Gtk.cairo_should_draw_window(cr, win))
588		{
589			return false;
590		}
591
592		var ctx = this.get_style_context();
593
594		var old_lines_width = d_old_lines.size + d_old_lines.xpad * 2;
595		var new_lines_width = d_new_lines.size + d_new_lines.xpad * 2;
596		var sym_lines_width = d_sym_lines.size + d_sym_lines.xpad * 2;
597
598		ctx.save();
599		Gtk.cairo_transform_to_window(cr, this, win);
600		ctx.add_class("diff-lines-separator");
601		ctx.render_frame(cr, 0, 0, old_lines_width, win.get_height());
602		ctx.restore();
603
604		ctx.save();
605		Gtk.cairo_transform_to_window(cr, this, win);
606		ctx.add_class("diff-lines-gutter-border");
607		ctx.render_frame(cr, old_lines_width + new_lines_width, 0, sym_lines_width, win.get_height());
608		ctx.restore();
609
610		return false;
611	}
612
613	private void update_theme()
614	{
615		var header_attributes = new Gtk.SourceMarkAttributes();
616		var added_attributes = new Gtk.SourceMarkAttributes();
617		var removed_attributes = new Gtk.SourceMarkAttributes();
618
619		var dark = new Theme().is_theme_dark();
620
621		if (dark)
622		{
623			header_attributes.background = Gdk.RGBA() { red = 88.0 / 255.0, green = 88.0 / 255.0, blue = 88.0 / 255.0, alpha = 1.0 };
624			added_attributes.background = Gdk.RGBA() { red = 32.0 / 255.0, green = 68.0 / 255.0, blue = 21.0 / 255.0, alpha = 1.0 };
625			removed_attributes.background = Gdk.RGBA() { red = 130.0 / 255.0, green = 55.0 / 255.0, blue = 53.0 / 255.0, alpha = 1.0 };
626		}
627		else
628		{
629			header_attributes.background = Gdk.RGBA() { red = 244.0 / 255.0, green = 247.0 / 255.0, blue = 251.0 / 255.0, alpha = 1.0 };
630			added_attributes.background = Gdk.RGBA() { red = 220.0 / 255.0, green = 1.0, blue = 220.0 / 255.0, alpha = 1.0 };
631			removed_attributes.background = Gdk.RGBA() { red = 1.0, green = 220.0 / 255.0, blue = 220.0 / 255.0, alpha = 1.0 };
632		}
633
634		this.set_mark_attributes("header", header_attributes, 0);
635		this.set_mark_attributes("added", added_attributes, 0);
636		this.set_mark_attributes("removed", removed_attributes, 0);
637	}
638
639	protected override void constructed()
640	{
641		base.constructed();
642
643		d_constructed = true;
644		update_highlight();
645	}
646
647	public void add_hunk(Ggit.DiffHunk hunk, Gee.ArrayList<Ggit.DiffLine> lines)
648	{
649		var buffer = this.buffer as Gtk.SourceBuffer;
650
651		/* Diff hunk */
652		var h = hunk.get_header();
653		var pos = h.last_index_of("@@");
654
655		if (pos >= 0)
656		{
657			h = h.substring(pos + 2).chug();
658		}
659
660		h = h.chomp();
661
662		Gtk.TextIter iter;
663		buffer.get_end_iter(out iter);
664
665		if (!iter.is_start())
666		{
667			buffer.insert(ref iter, "\n", 1);
668		}
669
670		iter.set_line_offset(0);
671		buffer.create_source_mark(null, "header", iter);
672
673		var header = @"@@ -$(hunk.get_old_start()),$(hunk.get_old_lines()) +$(hunk.get_new_start()),$(hunk.get_new_lines()) @@ $h\n";
674		buffer.insert(ref iter, header, -1);
675
676		int buffer_line = iter.get_line();
677
678		/* Diff Content */
679		var content = new StringBuilder();
680
681		var region = Region() {
682			type = RegionType.CONTEXT,
683			buffer_line_start = 0,
684			source_line_start = 0,
685			length = 0
686		};
687
688		this.freeze_notify();
689
690		for (var i = 0; i < lines.size; i++)
691		{
692			var line = lines[i];
693			var text = line.get_text().replace("\r", "");
694			var added = false;
695			var removed = false;
696			var origin = line.get_origin();
697
698			var rtype = RegionType.CONTEXT;
699
700			switch (origin)
701			{
702				case Ggit.DiffLineType.ADDITION:
703					added = true;
704					this.added++;
705
706					rtype = RegionType.ADDED;
707					break;
708				case Ggit.DiffLineType.DELETION:
709					removed = true;
710					this.removed++;
711
712					rtype = RegionType.REMOVED;
713					break;
714				case Ggit.DiffLineType.CONTEXT_EOFNL:
715				case Ggit.DiffLineType.ADD_EOFNL:
716				case Ggit.DiffLineType.DEL_EOFNL:
717					text = text.substring(1);
718					break;
719			}
720
721			if (i == 0 || rtype != region.type)
722			{
723				if (i != 0)
724				{
725					d_regions += region;
726				}
727
728				int source_line_start;
729
730				if (rtype == RegionType.REMOVED)
731				{
732					source_line_start = line.get_old_lineno() - 1;
733				}
734				else
735				{
736					source_line_start = line.get_new_lineno() - 1;
737				}
738
739				region = Region() {
740					type = rtype,
741					buffer_line_start = buffer_line,
742					source_line_start = source_line_start,
743					length = 0
744				};
745			}
746
747			region.length++;
748
749			if (added || removed)
750			{
751				var offset = (size_t)line.get_content_offset();
752				var bytes = line.get_content();
753
754				var pset = PatchSet.Patch() {
755					type = added ? PatchSet.Type.ADD : PatchSet.Type.REMOVE,
756					old_offset = offset,
757					new_offset = offset,
758					length = bytes.length
759				};
760
761				if (added)
762				{
763					pset.old_offset = (size_t)((int64)pset.old_offset - d_doffset);
764				}
765				else
766				{
767					pset.new_offset = (size_t)((int64)pset.new_offset + d_doffset);
768				}
769
770				d_lines[buffer_line] = pset;
771				d_doffset += added ? (int64)bytes.length : -(int64)bytes.length;
772			}
773
774			if (i == lines.size - 1 && text.length > 0 && text[text.length - 1] == '\n')
775			{
776				text = text.slice(0, text.length - 1);
777			}
778
779			content.append(text);
780			buffer_line++;
781		}
782
783		if (lines.size != 0)
784		{
785			d_regions += region;
786		}
787
788		int line_hunk_start = iter.get_line();
789
790		buffer.insert(ref iter, (string)content.data, -1);
791
792		d_old_lines.add_hunk(line_hunk_start, iter.get_line(), hunk, lines);
793		d_new_lines.add_hunk(line_hunk_start, iter.get_line(), hunk, lines);
794		d_sym_lines.add_hunk(line_hunk_start, iter.get_line(), hunk, lines);
795
796		for (var i = 0; i < lines.size; i++)
797		{
798			var line = lines[i];
799			string? category = null;
800
801			switch (line.get_origin())
802			{
803				case Ggit.DiffLineType.ADDITION:
804					category = "added";
805					break;
806				case Ggit.DiffLineType.DELETION:
807					category = "removed";
808					break;
809			}
810
811			if (category != null)
812			{
813				buffer.get_iter_at_line(out iter, line_hunk_start + i);
814				buffer.create_source_mark(null, category, iter);
815			}
816		}
817
818		this.thaw_notify();
819
820		sensitive = true;
821	}
822}
823
824// ex:ts=4 noet
825