1# git-gui diff viewer2# Copyright (C) 2006, 2007 Shawn Pearce34proc apply_tab_size {{firsttab {}}} {5global have_tk85 repo_config ui_diff67set w [font measure font_diff "0"]8if {$have_tk85 && $firsttab != 0} {9$ui_diff configure -tabs [list [expr {$firsttab * $w}] [expr {($firsttab + $repo_config(gui.tabsize)) * $w}]]10} elseif {$have_tk85 || $repo_config(gui.tabsize) != 8} {11$ui_diff configure -tabs [expr {$repo_config(gui.tabsize) * $w}]12} else {13$ui_diff configure -tabs {}14}15}1617proc clear_diff {} {18global ui_diff current_diff_path current_diff_header19global ui_index ui_workdir2021$ui_diff conf -state normal22$ui_diff delete 0.0 end23$ui_diff conf -state disabled2425set current_diff_path {}26set current_diff_header {}2728$ui_index tag remove in_diff 0.0 end29$ui_workdir tag remove in_diff 0.0 end30}3132proc reshow_diff {{after {}}} {33global file_states file_lists34global current_diff_path current_diff_side35global ui_diff3637set p $current_diff_path38if {$p eq {}} {39# No diff is being shown.40} elseif {$current_diff_side eq {}} {41clear_diff42} elseif {[catch {set s $file_states($p)}]43|| [lsearch -sorted -exact $file_lists($current_diff_side) $p] == -1} {4445if {[find_next_diff $current_diff_side $p {} {[^O]}]} {46next_diff $after47} else {48clear_diff49}50} else {51set save_pos [lindex [$ui_diff yview] 0]52show_diff $p $current_diff_side {} $save_pos $after53}54}5556proc force_diff_encoding {enc} {57global current_diff_path5859if {$current_diff_path ne {}} {60force_path_encoding $current_diff_path $enc61reshow_diff62}63}6465proc handle_empty_diff {} {66global current_diff_path file_states file_lists67global diff_empty_count6869set path $current_diff_path70set s $file_states($path)71if {[lindex $s 0] ne {_M} || [has_textconv $path]} return7273# Prevent infinite rescan loops74incr diff_empty_count75if {$diff_empty_count > 1} return7677info_popup [mc "No differences detected.7879%s has no changes.8081The modification date of this file was updated by another application, but the content within the file was not changed.8283A rescan will be automatically started to find other files which may have the same state." [short_path $path]]8485clear_diff86display_file $path __87rescan ui_ready 088}8990proc show_diff {path w {lno {}} {scroll_pos {}} {callback {}}} {91global file_states file_lists92global is_3way_diff is_conflict_diff diff_active repo_config93global ui_diff ui_index ui_workdir94global current_diff_path current_diff_side current_diff_header95global current_diff_queue9697if {$diff_active || ![lock_index read]} return9899clear_diff100if {$lno == {}} {101set lno [lsearch -sorted -exact $file_lists($w) $path]102if {$lno >= 0} {103incr lno104}105}106if {$lno >= 1} {107$w tag add in_diff $lno.0 [expr {$lno + 1}].0108$w see $lno.0109}110111set s $file_states($path)112set m [lindex $s 0]113set is_conflict_diff 0114set current_diff_path $path115set current_diff_side $w116set current_diff_queue {}117ui_status [mc "Loading diff of %s..." [escape_path $path]]118119set cont_info [list $scroll_pos $callback]120121apply_tab_size 0122123if {[string first {U} $m] >= 0} {124merge_load_stages $path [list show_unmerged_diff $cont_info]125} elseif {$m eq {_O}} {126show_other_diff $path $w $m $cont_info127} else {128start_show_diff $cont_info129}130131global current_diff_path selected_paths132set selected_paths($current_diff_path) 1133}134135proc show_unmerged_diff {cont_info} {136global current_diff_path current_diff_side137global merge_stages ui_diff is_conflict_diff138global current_diff_queue139140if {$merge_stages(2) eq {}} {141set is_conflict_diff 1142lappend current_diff_queue \143[list [mc "LOCAL: deleted\nREMOTE:\n"] d= \144[list ":1:$current_diff_path" ":3:$current_diff_path"]]145} elseif {$merge_stages(3) eq {}} {146set is_conflict_diff 1147lappend current_diff_queue \148[list [mc "REMOTE: deleted\nLOCAL:\n"] d= \149[list ":1:$current_diff_path" ":2:$current_diff_path"]]150} elseif {[lindex $merge_stages(1) 0] eq {120000}151|| [lindex $merge_stages(2) 0] eq {120000}152|| [lindex $merge_stages(3) 0] eq {120000}} {153set is_conflict_diff 1154lappend current_diff_queue \155[list [mc "LOCAL:\n"] d= \156[list ":1:$current_diff_path" ":2:$current_diff_path"]]157lappend current_diff_queue \158[list [mc "REMOTE:\n"] d= \159[list ":1:$current_diff_path" ":3:$current_diff_path"]]160} else {161start_show_diff $cont_info162return163}164165advance_diff_queue $cont_info166}167168proc advance_diff_queue {cont_info} {169global current_diff_queue ui_diff170171set item [lindex $current_diff_queue 0]172set current_diff_queue [lrange $current_diff_queue 1 end]173174$ui_diff conf -state normal175$ui_diff insert end [lindex $item 0] [lindex $item 1]176$ui_diff conf -state disabled177178start_show_diff $cont_info [lindex $item 2]179}180181proc show_other_diff {path w m cont_info} {182global file_states file_lists183global is_3way_diff diff_active repo_config184global ui_diff ui_index ui_workdir185global current_diff_path current_diff_side current_diff_header186187# - Git won't give us the diff, there's nothing to compare to!188#189if {$m eq {_O}} {190set max_sz 100000191set type unknown192if {[catch {193set type [file type $path]194switch -- $type {195directory {196set type submodule197set content {}198set sz 0199}200link {201set content [file readlink $path]202set sz [string length $content]203}204file {205set fd [open $path r]206fconfigure $fd \207-eofchar {} \208-encoding [get_path_encoding $path]209set content [read $fd $max_sz]210close $fd211set sz [file size $path]212}213default {214error "'$type' not supported"215}216}217} err ]} {218set diff_active 0219unlock_index220ui_status [mc "Unable to display %s" [escape_path $path]]221error_popup [strcat [mc "Error loading file:"] "\n\n$err"]222return223}224$ui_diff conf -state normal225if {$type eq {submodule}} {226$ui_diff insert end [append \227"* " \228[mc "Git Repository (subproject)"] \229"\n"] d_info230} elseif {![catch {set type [exec file $path]}]} {231set n [string length $path]232if {[string equal -length $n $path $type]} {233set type [string range $type $n end]234regsub {^:?\s*} $type {} type235}236$ui_diff insert end "* $type\n" d_info237}238if {[string first "\0" $content] != -1} {239$ui_diff insert end \240[mc "* Binary file (not showing content)."] \241d_info242} else {243if {$sz > $max_sz} {244$ui_diff insert end [mc \245"* Untracked file is %d bytes.246* Showing only first %d bytes.247" $sz $max_sz] d_info248}249$ui_diff insert end $content250if {$sz > $max_sz} {251$ui_diff insert end [mc "252* Untracked file clipped here by %s.253* To see the entire file, use an external editor.254" [appname]] d_info255}256}257$ui_diff conf -state disabled258set diff_active 0259unlock_index260set scroll_pos [lindex $cont_info 0]261if {$scroll_pos ne {}} {262update263$ui_diff yview moveto $scroll_pos264}265ui_ready266set callback [lindex $cont_info 1]267if {$callback ne {}} {268eval $callback269}270return271}272}273274proc get_conflict_marker_size {path} {275set size 7276catch {277set fd_rc [eval [list git_read check-attr "conflict-marker-size" -- $path]]278set ret [gets $fd_rc line]279close $fd_rc280if {$ret > 0} {281regexp {.*: conflict-marker-size: (\d+)$} $line line size282}283}284return $size285}286287proc start_show_diff {cont_info {add_opts {}}} {288global file_states file_lists289global is_3way_diff is_submodule_diff diff_active repo_config290global ui_diff ui_index ui_workdir291global current_diff_path current_diff_side current_diff_header292293set path $current_diff_path294set w $current_diff_side295296set s $file_states($path)297set m [lindex $s 0]298set is_3way_diff 0299set is_submodule_diff 0300set diff_active 1301set current_diff_header {}302set conflict_size [get_conflict_marker_size $path]303304set cmd [list]305if {$w eq $ui_index} {306lappend cmd diff-index307lappend cmd --cached308if {[git-version >= "1.7.2"]} {309lappend cmd --ignore-submodules=dirty310}311} elseif {$w eq $ui_workdir} {312if {[string first {U} $m] >= 0} {313lappend cmd diff314} else {315lappend cmd diff-files316}317}318if {![is_config_false gui.textconv] && [git-version >= 1.6.1]} {319lappend cmd --textconv320}321322if {[string match {160000 *} [lindex $s 2]]323|| [string match {160000 *} [lindex $s 3]]} {324set is_submodule_diff 1325326if {[git-version >= "1.6.6"]} {327lappend cmd --submodule328}329}330331lappend cmd -p332lappend cmd --color333set cmd [concat $cmd $repo_config(gui.diffopts)]334if {$repo_config(gui.diffcontext) >= 1} {335lappend cmd "-U$repo_config(gui.diffcontext)"336}337if {$w eq $ui_index} {338lappend cmd [PARENT]339}340if {$add_opts ne {}} {341eval lappend cmd $add_opts342} else {343lappend cmd --344lappend cmd $path345}346347if {$is_submodule_diff && [git-version < "1.6.6"]} {348if {$w eq $ui_index} {349set cmd [list submodule summary --cached -- $path]350} else {351set cmd [list submodule summary --files -- $path]352}353}354355if {[catch {set fd [eval git_read --nice $cmd]} err]} {356set diff_active 0357unlock_index358ui_status [mc "Unable to display %s" [escape_path $path]]359error_popup [strcat [mc "Error loading diff:"] "\n\n$err"]360return361}362363set ::current_diff_inheader 1364fconfigure $fd \365-blocking 0 \366-encoding [get_path_encoding $path] \367-translation lf368fileevent $fd readable [list read_diff $fd $conflict_size $cont_info]369}370371proc parse_color_line {line} {372set start 0373set result ""374set markup [list]375set regexp {\033\[((?:\d+;)*\d+)?m}376set need_reset 0377while {[regexp -indices -start $start $regexp $line match code]} {378foreach {begin end} $match break379append result [string range $line $start [expr {$begin - 1}]]380set pos [string length $result]381set col [eval [linsert $code 0 string range $line]]382set start [incr end]383if {$col eq "0" || $col eq ""} {384if {!$need_reset} continue385set need_reset 0386} else {387set need_reset 1388}389lappend markup $pos $col390}391append result [string range $line $start end]392if {[llength $markup] < 4} {set markup {}}393return [list $result $markup]394}395396proc read_diff {fd conflict_size cont_info} {397global ui_diff diff_active is_submodule_diff398global is_3way_diff is_conflict_diff current_diff_header399global current_diff_queue400global diff_empty_count401402$ui_diff conf -state normal403while {[gets $fd line] >= 0} {404foreach {line markup} [parse_color_line $line] break405set line [string map {\033 ^} $line]406407set tags {}408409# -- Check for start of diff header.410if { [string match {diff --git *} $line]411|| [string match {diff --cc *} $line]412|| [string match {diff --combined *} $line]} {413set ::current_diff_inheader 1414}415416# -- Check for end of diff header (any hunk line will do this).417#418if {[regexp {^@@+ } $line]} {set ::current_diff_inheader 0}419420# -- Automatically detect if this is a 3 way diff.421#422if {[string match {@@@ *} $line]} {423set is_3way_diff 1424apply_tab_size 1425}426427if {$::current_diff_inheader} {428429# -- These two lines stop a diff header and shouldn't be in there430if { [string match {Binary files * and * differ} $line]431|| [regexp {^\* Unmerged path } $line]} {432set ::current_diff_inheader 0433} else {434append current_diff_header $line "\n"435}436437# -- Cleanup uninteresting diff header lines.438#439if { [string match {diff --git *} $line]440|| [string match {diff --cc *} $line]441|| [string match {diff --combined *} $line]442|| [string match {--- *} $line]443|| [string match {+++ *} $line]444|| [string match {index *} $line]} {445continue446}447448# -- Name it symlink, not 120000449# Note, that the original line is in $current_diff_header450regsub {^(deleted|new) file mode 120000} $line {\1 symlink} line451452} elseif { $line eq {\ No newline at end of file}} {453# -- Handle some special lines454} elseif {$is_3way_diff} {455set op [string range $line 0 1]456switch -- $op {457{ } {set tags {}}458{@@} {set tags d_@}459{ +} {set tags d_s+}460{ -} {set tags d_s-}461{+ } {set tags d_+s}462{- } {set tags d_-s}463{--} {set tags d_--}464{++} {465set regexp [string map [list %conflict_size $conflict_size]\466{^\+\+([<>=]){%conflict_size}(?: |$)}]467if {[regexp $regexp $line _g op]} {468set is_conflict_diff 1469set line [string replace $line 0 1 { }]470set tags d$op471} else {472set tags d_++473}474}475default {476puts "error: Unhandled 3 way diff marker: {$op}"477set tags {}478}479}480} elseif {$is_submodule_diff} {481if {$line == ""} continue482if {[regexp {^Submodule } $line]} {483set tags d_info484} elseif {[regexp {^\* } $line]} {485set line [string replace $line 0 1 {Submodule }]486set tags d_info487} else {488set op [string range $line 0 2]489switch -- $op {490{ <} {set tags d_-}491{ >} {set tags d_+}492{ W} {set tags {}}493default {494puts "error: Unhandled submodule diff marker: {$op}"495set tags {}496}497}498}499} else {500set op [string index $line 0]501switch -- $op {502{ } {set tags {}}503{@} {set tags d_@}504{-} {set tags d_-}505{+} {506set regexp [string map [list %conflict_size $conflict_size]\507{^\+([<>=]){%conflict_size}(?: |$)}]508if {[regexp $regexp $line _g op]} {509set is_conflict_diff 1510set tags d$op511} else {512set tags d_+513}514}515default {516puts "error: Unhandled 2 way diff marker: {$op}"517set tags {}518}519}520}521set mark [$ui_diff index "end - 1 line linestart"]522$ui_diff insert end $line $tags523if {[string index $line end] eq "\r"} {524$ui_diff tag add d_cr {end - 2c}525}526$ui_diff insert end "\n" $tags527528foreach {posbegin colbegin posend colend} $markup {529set prefix clr530foreach style [lsort -integer [split $colbegin ";"]] {531if {$style eq "7"} {append prefix i; continue}532if {$style != 4 && ($style < 30 || $style > 47)} {continue}533set a "$mark linestart + $posbegin chars"534set b "$mark linestart + $posend chars"535catch {$ui_diff tag add $prefix$style $a $b}536}537}538}539$ui_diff conf -state disabled540541if {[eof $fd]} {542close $fd543544if {$current_diff_queue ne {}} {545advance_diff_queue $cont_info546return547}548549set diff_active 0550unlock_index551set scroll_pos [lindex $cont_info 0]552if {$scroll_pos ne {}} {553update554$ui_diff yview moveto $scroll_pos555}556ui_ready557558if {[$ui_diff index end] eq {2.0}} {559handle_empty_diff560} else {561set diff_empty_count 0562}563564set callback [lindex $cont_info 1]565if {$callback ne {}} {566eval $callback567}568}569}570571proc apply_hunk {x y} {572global current_diff_path current_diff_header current_diff_side573global ui_diff ui_index file_states574575if {$current_diff_path eq {} || $current_diff_header eq {}} return576if {![lock_index apply_hunk]} return577578set apply_cmd {apply --cached --whitespace=nowarn}579set mi [lindex $file_states($current_diff_path) 0]580if {$current_diff_side eq $ui_index} {581set failed_msg [mc "Failed to unstage selected hunk."]582lappend apply_cmd --reverse583if {[string index $mi 0] ne {M}} {584unlock_index585return586}587} else {588set failed_msg [mc "Failed to stage selected hunk."]589if {[string index $mi 1] ne {M}} {590unlock_index591return592}593}594595set s_lno [lindex [split [$ui_diff index @$x,$y] .] 0]596set s_lno [$ui_diff search -backwards -regexp ^@@ $s_lno.0 0.0]597if {$s_lno eq {}} {598unlock_index599return600}601602set e_lno [$ui_diff search -forwards -regexp ^@@ "$s_lno + 1 lines" end]603if {$e_lno eq {}} {604set e_lno end605}606607if {[catch {608set enc [get_path_encoding $current_diff_path]609set p [eval git_write $apply_cmd]610fconfigure $p -translation binary -encoding $enc611puts -nonewline $p $current_diff_header612puts -nonewline $p [$ui_diff get $s_lno $e_lno]613close $p} err]} {614error_popup [append $failed_msg "\n\n$err"]615unlock_index616return617}618619$ui_diff conf -state normal620$ui_diff delete $s_lno $e_lno621$ui_diff conf -state disabled622623if {[$ui_diff get 1.0 end] eq "\n"} {624set o _625} else {626set o ?627}628629if {$current_diff_side eq $ui_index} {630set mi ${o}M631} elseif {[string index $mi 0] eq {_}} {632set mi M$o633} else {634set mi ?$o635}636unlock_index637display_file $current_diff_path $mi638# This should trigger shift to the next changed file639if {$o eq {_}} {640reshow_diff641}642}643644proc apply_range_or_line {x y} {645global current_diff_path current_diff_header current_diff_side646global ui_diff ui_index file_states647648set selected [$ui_diff tag nextrange sel 0.0]649650if {$selected == {}} {651set first [$ui_diff index "@$x,$y"]652set last $first653} else {654set first [lindex $selected 0]655set last [lindex $selected 1]656}657658set first_l [$ui_diff index "$first linestart"]659set last_l [$ui_diff index "$last lineend"]660661if {$current_diff_path eq {} || $current_diff_header eq {}} return662if {![lock_index apply_hunk]} return663664set apply_cmd {apply --cached --whitespace=nowarn}665set mi [lindex $file_states($current_diff_path) 0]666if {$current_diff_side eq $ui_index} {667set failed_msg [mc "Failed to unstage selected line."]668set to_context {+}669lappend apply_cmd --reverse670if {[string index $mi 0] ne {M}} {671unlock_index672return673}674} else {675set failed_msg [mc "Failed to stage selected line."]676set to_context {-}677if {[string index $mi 1] ne {M}} {678unlock_index679return680}681}682683set wholepatch {}684685while {$first_l < $last_l} {686set i_l [$ui_diff search -backwards -regexp ^@@ $first_l 0.0]687if {$i_l eq {}} {688# If there's not a @@ above, then the selected range689# must have come before the first_l @@690set i_l [$ui_diff search -regexp ^@@ $first_l $last_l]691}692if {$i_l eq {}} {693unlock_index694return695}696# $i_l is now at the beginning of a line697698# pick start line number from hunk header699set hh [$ui_diff get $i_l "$i_l + 1 lines"]700set hh [lindex [split $hh ,] 0]701set hln [lindex [split $hh -] 1]702703# There is a special situation to take care of. Consider this704# hunk:705#706# @@ -10,4 +10,4 @@707# context before708# -old 1709# -old 2710# +new 1711# +new 2712# context after713#714# We used to keep the context lines in the order they appear in715# the hunk. But then it is not possible to correctly stage only716# "-old 1" and "+new 1" - it would result in this staged text:717#718# context before719# old 2720# new 1721# context after722#723# (By symmetry it is not possible to *un*stage "old 2" and "new724# 2".)725#726# We resolve the problem by introducing an asymmetry, namely,727# when a "+" line is *staged*, it is moved in front of the728# context lines that are generated from the "-" lines that are729# immediately before the "+" block. That is, we construct this730# patch:731#732# @@ -10,4 +10,5 @@733# context before734# +new 1735# old 1736# old 2737# context after738#739# But we do *not* treat "-" lines that are *un*staged in a740# special way.741#742# With this asymmetry it is possible to stage the change "old743# 1" -> "new 1" directly, and to stage the change "old 2" ->744# "new 2" by first staging the entire hunk and then unstaging745# the change "old 1" -> "new 1".746#747# Applying multiple lines adds complexity to the special748# situation. The pre_context must be moved after the entire749# first block of consecutive staged "+" lines, so that750# staging both additions gives the following patch:751#752# @@ -10,4 +10,6 @@753# context before754# +new 1755# +new 2756# old 1757# old 2758# context after759760# This is non-empty if and only if we are _staging_ changes;761# then it accumulates the consecutive "-" lines (after762# converting them to context lines) in order to be moved after763# "+" change lines.764set pre_context {}765766set n 0767set m 0768set i_l [$ui_diff index "$i_l + 1 lines"]769set patch {}770while {[$ui_diff compare $i_l < "end - 1 chars"] &&771[$ui_diff get $i_l "$i_l + 2 chars"] ne {@@}} {772set next_l [$ui_diff index "$i_l + 1 lines"]773set c1 [$ui_diff get $i_l]774if {[$ui_diff compare $first_l <= $i_l] &&775[$ui_diff compare $i_l < $last_l] &&776($c1 eq {-} || $c1 eq {+})} {777# a line to stage/unstage778set ln [$ui_diff get $i_l $next_l]779if {$c1 eq {-}} {780set n [expr $n+1]781set patch "$patch$pre_context$ln"782set pre_context {}783} else {784set m [expr $m+1]785set patch "$patch$ln"786}787} elseif {$c1 ne {-} && $c1 ne {+}} {788# context line789set ln [$ui_diff get $i_l $next_l]790set patch "$patch$pre_context$ln"791# Skip the "\ No newline at end of792# file". Depending on the locale setting793# we don't know what this line looks794# like exactly. The only thing we do795# know is that it starts with "\ "796if {![string match {\\ *} $ln]} {797set n [expr $n+1]798set m [expr $m+1]799}800set pre_context {}801} elseif {$c1 eq $to_context} {802# turn change line into context line803set ln [$ui_diff get "$i_l + 1 chars" $next_l]804if {$c1 eq {-}} {805set pre_context "$pre_context $ln"806} else {807set patch "$patch $ln"808}809set n [expr $n+1]810set m [expr $m+1]811} else {812# a change in the opposite direction of813# to_context which is outside the range of814# lines to apply.815set patch "$patch$pre_context"816set pre_context {}817}818set i_l $next_l819}820set patch "$patch$pre_context"821set wholepatch "$wholepatch@@ -$hln,$n +$hln,$m @@\n$patch"822set first_l [$ui_diff index "$next_l + 1 lines"]823}824825if {[catch {826set enc [get_path_encoding $current_diff_path]827set p [eval git_write $apply_cmd]828fconfigure $p -translation binary -encoding $enc829puts -nonewline $p $current_diff_header830puts -nonewline $p $wholepatch831close $p} err]} {832error_popup [append $failed_msg "\n\n$err"]833}834835unlock_index836}