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 \227"* [mc "Git Repository (subproject)"]\n" \228d_info229} elseif {![catch {set type [exec file $path]}]} {230set n [string length $path]231if {[string equal -length $n $path $type]} {232set type [string range $type $n end]233regsub {^:?\s*} $type {} type234}235$ui_diff insert end "* $type\n" d_info236}237if {[string first "\0" $content] != -1} {238$ui_diff insert end \239[mc "* Binary file (not showing content)."] \240d_info241} else {242if {$sz > $max_sz} {243$ui_diff insert end [mc \244"* Untracked file is %d bytes.245* Showing only first %d bytes.246" $sz $max_sz] d_info247}248$ui_diff insert end $content249if {$sz > $max_sz} {250$ui_diff insert end [mc "251* Untracked file clipped here by %s.252* To see the entire file, use an external editor.253" [appname]] d_info254}255}256$ui_diff conf -state disabled257set diff_active 0258unlock_index259set scroll_pos [lindex $cont_info 0]260if {$scroll_pos ne {}} {261update262$ui_diff yview moveto $scroll_pos263}264ui_ready265set callback [lindex $cont_info 1]266if {$callback ne {}} {267eval $callback268}269return270}271}272273proc get_conflict_marker_size {path} {274set size 7275catch {276set fd_rc [eval [list git_read check-attr "conflict-marker-size" -- $path]]277set ret [gets $fd_rc line]278close $fd_rc279if {$ret > 0} {280regexp {.*: conflict-marker-size: (\d+)$} $line line size281}282}283return $size284}285286proc start_show_diff {cont_info {add_opts {}}} {287global file_states file_lists288global is_3way_diff is_submodule_diff diff_active repo_config289global ui_diff ui_index ui_workdir290global current_diff_path current_diff_side current_diff_header291292set path $current_diff_path293set w $current_diff_side294295set s $file_states($path)296set m [lindex $s 0]297set is_3way_diff 0298set is_submodule_diff 0299set diff_active 1300set current_diff_header {}301set conflict_size [get_conflict_marker_size $path]302303set cmd [list]304if {$w eq $ui_index} {305lappend cmd diff-index306lappend cmd --cached307if {[git-version >= "1.7.2"]} {308lappend cmd --ignore-submodules=dirty309}310} elseif {$w eq $ui_workdir} {311if {[string first {U} $m] >= 0} {312lappend cmd diff313} else {314lappend cmd diff-files315}316}317if {![is_config_false gui.textconv] && [git-version >= 1.6.1]} {318lappend cmd --textconv319}320321if {[string match {160000 *} [lindex $s 2]]322|| [string match {160000 *} [lindex $s 3]]} {323set is_submodule_diff 1324325if {[git-version >= "1.6.6"]} {326lappend cmd --submodule327}328}329330lappend cmd -p331lappend cmd --color332set cmd [concat $cmd $repo_config(gui.diffopts)]333if {$repo_config(gui.diffcontext) >= 1} {334lappend cmd "-U$repo_config(gui.diffcontext)"335}336if {$w eq $ui_index} {337lappend cmd [PARENT]338}339if {$add_opts ne {}} {340eval lappend cmd $add_opts341} else {342lappend cmd --343lappend cmd $path344}345346if {$is_submodule_diff && [git-version < "1.6.6"]} {347if {$w eq $ui_index} {348set cmd [list submodule summary --cached -- $path]349} else {350set cmd [list submodule summary --files -- $path]351}352}353354if {[catch {set fd [eval git_read --nice $cmd]} err]} {355set diff_active 0356unlock_index357ui_status [mc "Unable to display %s" [escape_path $path]]358error_popup [strcat [mc "Error loading diff:"] "\n\n$err"]359return360}361362set ::current_diff_inheader 1363fconfigure $fd \364-blocking 0 \365-encoding [get_path_encoding $path] \366-translation lf367fileevent $fd readable [list read_diff $fd $conflict_size $cont_info]368}369370proc parse_color_line {line} {371set start 0372set result ""373set markup [list]374set regexp {\033\[((?:\d+;)*\d+)?m}375set need_reset 0376while {[regexp -indices -start $start $regexp $line match code]} {377foreach {begin end} $match break378append result [string range $line $start [expr {$begin - 1}]]379set pos [string length $result]380set col [eval [linsert $code 0 string range $line]]381set start [incr end]382if {$col eq "0" || $col eq ""} {383if {!$need_reset} continue384set need_reset 0385} else {386set need_reset 1387}388lappend markup $pos $col389}390append result [string range $line $start end]391if {[llength $markup] < 4} {set markup {}}392return [list $result $markup]393}394395proc read_diff {fd conflict_size cont_info} {396global ui_diff diff_active is_submodule_diff397global is_3way_diff is_conflict_diff current_diff_header398global current_diff_queue399global diff_empty_count400401$ui_diff conf -state normal402while {[gets $fd line] >= 0} {403foreach {line markup} [parse_color_line $line] break404set line [string map {\033 ^} $line]405406set tags {}407408# -- Check for start of diff header.409if { [string match {diff --git *} $line]410|| [string match {diff --cc *} $line]411|| [string match {diff --combined *} $line]} {412set ::current_diff_inheader 1413}414415# -- Check for end of diff header (any hunk line will do this).416#417if {[regexp {^@@+ } $line]} {set ::current_diff_inheader 0}418419# -- Automatically detect if this is a 3 way diff.420#421if {[string match {@@@ *} $line]} {422set is_3way_diff 1423apply_tab_size 1424}425426if {$::current_diff_inheader} {427428# -- These two lines stop a diff header and shouldn't be in there429if { [string match {Binary files * and * differ} $line]430|| [regexp {^\* Unmerged path } $line]} {431set ::current_diff_inheader 0432} else {433append current_diff_header $line "\n"434}435436# -- Cleanup uninteresting diff header lines.437#438if { [string match {diff --git *} $line]439|| [string match {diff --cc *} $line]440|| [string match {diff --combined *} $line]441|| [string match {--- *} $line]442|| [string match {+++ *} $line]443|| [string match {index *} $line]} {444continue445}446447# -- Name it symlink, not 120000448# Note, that the original line is in $current_diff_header449regsub {^(deleted|new) file mode 120000} $line {\1 symlink} line450451} elseif { $line eq {\ No newline at end of file}} {452# -- Handle some special lines453} elseif {$is_3way_diff} {454set op [string range $line 0 1]455switch -- $op {456{ } {set tags {}}457{@@} {set tags d_@}458{ +} {set tags d_s+}459{ -} {set tags d_s-}460{+ } {set tags d_+s}461{- } {set tags d_-s}462{--} {set tags d_--}463{++} {464set regexp [string map [list %conflict_size $conflict_size]\465{^\+\+([<>=]){%conflict_size}(?: |$)}]466if {[regexp $regexp $line _g op]} {467set is_conflict_diff 1468set line [string replace $line 0 1 { }]469set tags d$op470} else {471set tags d_++472}473}474default {475puts "error: Unhandled 3 way diff marker: {$op}"476set tags {}477}478}479} elseif {$is_submodule_diff} {480if {$line == ""} continue481if {[regexp {^Submodule } $line]} {482set tags d_info483} elseif {[regexp {^\* } $line]} {484set line [string replace $line 0 1 {Submodule }]485set tags d_info486} else {487set op [string range $line 0 2]488switch -- $op {489{ <} {set tags d_-}490{ >} {set tags d_+}491{ W} {set tags {}}492default {493puts "error: Unhandled submodule diff marker: {$op}"494set tags {}495}496}497}498} else {499set op [string index $line 0]500switch -- $op {501{ } {set tags {}}502{@} {set tags d_@}503{-} {set tags d_-}504{+} {505set regexp [string map [list %conflict_size $conflict_size]\506{^\+([<>=]){%conflict_size}(?: |$)}]507if {[regexp $regexp $line _g op]} {508set is_conflict_diff 1509set tags d$op510} else {511set tags d_+512}513}514default {515puts "error: Unhandled 2 way diff marker: {$op}"516set tags {}517}518}519}520set mark [$ui_diff index "end - 1 line linestart"]521$ui_diff insert end $line $tags522if {[string index $line end] eq "\r"} {523$ui_diff tag add d_cr {end - 2c}524}525$ui_diff insert end "\n" $tags526527foreach {posbegin colbegin posend colend} $markup {528set prefix clr529foreach style [lsort -integer [split $colbegin ";"]] {530if {$style eq "7"} {append prefix i; continue}531if {$style != 4 && ($style < 30 || $style > 47)} {continue}532set a "$mark linestart + $posbegin chars"533set b "$mark linestart + $posend chars"534catch {$ui_diff tag add $prefix$style $a $b}535}536}537}538$ui_diff conf -state disabled539540if {[eof $fd]} {541close $fd542543if {$current_diff_queue ne {}} {544advance_diff_queue $cont_info545return546}547548set diff_active 0549unlock_index550set scroll_pos [lindex $cont_info 0]551if {$scroll_pos ne {}} {552update553$ui_diff yview moveto $scroll_pos554}555ui_ready556557if {[$ui_diff index end] eq {2.0}} {558handle_empty_diff559} else {560set diff_empty_count 0561}562563set callback [lindex $cont_info 1]564if {$callback ne {}} {565eval $callback566}567}568}569570proc apply_or_revert_hunk {x y revert} {571global current_diff_path current_diff_header current_diff_side572global ui_diff ui_index file_states573574if {$current_diff_path eq {} || $current_diff_header eq {}} return575if {![lock_index apply_hunk]} return576577set apply_cmd {apply --whitespace=nowarn}578set mi [lindex $file_states($current_diff_path) 0]579if {$current_diff_side eq $ui_index} {580set failed_msg [mc "Failed to unstage selected hunk."]581lappend apply_cmd --reverse --cached582if {[string index $mi 0] ne {M}} {583unlock_index584return585}586} else {587if {$revert} {588set failed_msg [mc "Failed to revert selected hunk."]589lappend apply_cmd --reverse590} else {591set failed_msg [mc "Failed to stage selected hunk."]592lappend apply_cmd --cached593}594595if {[string index $mi 1] ne {M}} {596unlock_index597return598}599}600601set s_lno [lindex [split [$ui_diff index @$x,$y] .] 0]602set s_lno [$ui_diff search -backwards -regexp ^@@ $s_lno.0 0.0]603if {$s_lno eq {}} {604unlock_index605return606}607608set e_lno [$ui_diff search -forwards -regexp ^@@ "$s_lno + 1 lines" end]609if {$e_lno eq {}} {610set e_lno end611}612613if {[catch {614set enc [get_path_encoding $current_diff_path]615set p [eval git_write $apply_cmd]616fconfigure $p -translation binary -encoding $enc617puts -nonewline $p $current_diff_header618puts -nonewline $p [$ui_diff get $s_lno $e_lno]619close $p} err]} {620error_popup "$failed_msg\n\n$err"621unlock_index622return623}624625$ui_diff conf -state normal626$ui_diff delete $s_lno $e_lno627$ui_diff conf -state disabled628629# Check if the hunk was the last one in the file.630if {[$ui_diff get 1.0 end] eq "\n"} {631set o _632} else {633set o ?634}635636# Update the status flags.637if {$revert} {638set mi [string index $mi 0]$o639} elseif {$current_diff_side eq $ui_index} {640set mi ${o}M641} elseif {[string index $mi 0] eq {_}} {642set mi M$o643} else {644set mi ?$o645}646unlock_index647display_file $current_diff_path $mi648# This should trigger shift to the next changed file649if {$o eq {_}} {650reshow_diff651}652}653654proc apply_or_revert_range_or_line {x y revert} {655global current_diff_path current_diff_header current_diff_side656global ui_diff ui_index file_states657658set selected [$ui_diff tag nextrange sel 0.0]659660if {$selected == {}} {661set first [$ui_diff index "@$x,$y"]662set last $first663} else {664set first [lindex $selected 0]665set last [lindex $selected 1]666}667668set first_l [$ui_diff index "$first linestart"]669set last_l [$ui_diff index "$last lineend"]670671if {$current_diff_path eq {} || $current_diff_header eq {}} return672if {![lock_index apply_hunk]} return673674set apply_cmd {apply --whitespace=nowarn}675set mi [lindex $file_states($current_diff_path) 0]676if {$current_diff_side eq $ui_index} {677set failed_msg [mc "Failed to unstage selected line."]678set to_context {+}679lappend apply_cmd --reverse --cached680if {[string index $mi 0] ne {M}} {681unlock_index682return683}684} else {685if {$revert} {686set failed_msg [mc "Failed to revert selected line."]687set to_context {+}688lappend apply_cmd --reverse689} else {690set failed_msg [mc "Failed to stage selected line."]691set to_context {-}692lappend apply_cmd --cached693}694695if {[string index $mi 1] ne {M}} {696unlock_index697return698}699}700701set wholepatch {}702703while {$first_l < $last_l} {704set i_l [$ui_diff search -backwards -regexp ^@@ $first_l 0.0]705if {$i_l eq {}} {706# If there's not a @@ above, then the selected range707# must have come before the first_l @@708set i_l [$ui_diff search -regexp ^@@ $first_l $last_l]709}710if {$i_l eq {}} {711unlock_index712return713}714# $i_l is now at the beginning of a line715716# pick start line number from hunk header717set hh [$ui_diff get $i_l "$i_l + 1 lines"]718set hh [lindex [split $hh ,] 0]719set hln [lindex [split $hh -] 1]720721# There is a special situation to take care of. Consider this722# hunk:723#724# @@ -10,4 +10,4 @@725# context before726# -old 1727# -old 2728# +new 1729# +new 2730# context after731#732# We used to keep the context lines in the order they appear in733# the hunk. But then it is not possible to correctly stage only734# "-old 1" and "+new 1" - it would result in this staged text:735#736# context before737# old 2738# new 1739# context after740#741# (By symmetry it is not possible to *un*stage "old 2" and "new742# 2".)743#744# We resolve the problem by introducing an asymmetry, namely,745# when a "+" line is *staged*, it is moved in front of the746# context lines that are generated from the "-" lines that are747# immediately before the "+" block. That is, we construct this748# patch:749#750# @@ -10,4 +10,5 @@751# context before752# +new 1753# old 1754# old 2755# context after756#757# But we do *not* treat "-" lines that are *un*staged in a758# special way.759#760# With this asymmetry it is possible to stage the change "old761# 1" -> "new 1" directly, and to stage the change "old 2" ->762# "new 2" by first staging the entire hunk and then unstaging763# the change "old 1" -> "new 1".764#765# Applying multiple lines adds complexity to the special766# situation. The pre_context must be moved after the entire767# first block of consecutive staged "+" lines, so that768# staging both additions gives the following patch:769#770# @@ -10,4 +10,6 @@771# context before772# +new 1773# +new 2774# old 1775# old 2776# context after777778# This is non-empty if and only if we are _staging_ changes;779# then it accumulates the consecutive "-" lines (after780# converting them to context lines) in order to be moved after781# "+" change lines.782set pre_context {}783784set n 0785set m 0786set i_l [$ui_diff index "$i_l + 1 lines"]787set patch {}788while {[$ui_diff compare $i_l < "end - 1 chars"] &&789[$ui_diff get $i_l "$i_l + 2 chars"] ne {@@}} {790set next_l [$ui_diff index "$i_l + 1 lines"]791set c1 [$ui_diff get $i_l]792if {[$ui_diff compare $first_l <= $i_l] &&793[$ui_diff compare $i_l < $last_l] &&794($c1 eq {-} || $c1 eq {+})} {795# a line to stage/unstage796set ln [$ui_diff get $i_l $next_l]797if {$c1 eq {-}} {798set n [expr $n+1]799set patch "$patch$pre_context$ln"800set pre_context {}801} else {802set m [expr $m+1]803set patch "$patch$ln"804}805} elseif {$c1 ne {-} && $c1 ne {+}} {806# context line807set ln [$ui_diff get $i_l $next_l]808set patch "$patch$pre_context$ln"809# Skip the "\ No newline at end of810# file". Depending on the locale setting811# we don't know what this line looks812# like exactly. The only thing we do813# know is that it starts with "\ "814if {![string match {\\ *} $ln]} {815set n [expr $n+1]816set m [expr $m+1]817}818set pre_context {}819} elseif {$c1 eq $to_context} {820# turn change line into context line821set ln [$ui_diff get "$i_l + 1 chars" $next_l]822if {$c1 eq {-}} {823set pre_context "$pre_context $ln"824} else {825set patch "$patch $ln"826}827set n [expr $n+1]828set m [expr $m+1]829} else {830# a change in the opposite direction of831# to_context which is outside the range of832# lines to apply.833set patch "$patch$pre_context"834set pre_context {}835}836set i_l $next_l837}838set patch "$patch$pre_context"839set wholepatch "$wholepatch@@ -$hln,$n +$hln,$m @@\n$patch"840set first_l [$ui_diff index "$next_l + 1 lines"]841}842843if {[catch {844set enc [get_path_encoding $current_diff_path]845set p [eval git_write $apply_cmd]846fconfigure $p -translation binary -encoding $enc847puts -nonewline $p $current_diff_header848puts -nonewline $p $wholepatch849close $p} err]} {850error_popup "$failed_msg\n\n$err"851}852853unlock_index854}