## changeset.py - Changeset dialog for TortoiseHg## Copyright 2008 Steve Borho <steve@borho.org>#importosimportsubprocessimportsysimporttimeimportpygtkpygtk.require('2.0')importgtkimportgobjectimportpangoimportStringIOfrommercurial.i18nimport_frommercurial.nodeimport*frommercurialimportcmdutil,util,ui,hg,commandsfrommercurialimportcontext,patch,revlogfromgdialogimport*fromhgcmdimportCmdDialogfromhglibimporttoutf,fromutffromgtklibimportStatusBarclassChangeSet(GDialog):"""GTK+ based dialog for displaying repository logs """def__init__(self,ui,repo,cwd,pats,opts,main,stbar=None):GDialog.__init__(self,ui,repo,cwd,pats,opts,main)self.stbar=stbardefget_title(self):title=os.path.basename(self.repo.root)+' changeset 'title+=self.opts['rev'][0]returntitledefget_icon(self):return'menushowchanged.ico'defget_tbbuttons(self):self.parent_toggle=gtk.ToggleToolButton(gtk.STOCK_UNDO)self.parent_toggle.set_use_underline(True)self.parent_toggle.set_label('_other parent')self.parent_toggle.set_tooltip(self.tooltips,'diff other parent')self.parent_toggle.set_sensitive(False)self.parent_toggle.set_active(False)self.parent_toggle.connect('toggled',self._parent_toggled)return[self.parent_toggle]def_parent_toggled(self,button):self.load_details(self.currev)defprepare_display(self):self.currow=Noneself.graphview=Noneself.glog_parent=Nonenode0,node1=cmdutil.revpair(self.repo,self.opts.get('rev'))self.load_details(self.repo.changelog.rev(node0))defsave_settings(self):settings=GDialog.save_settings(self)settings['changeset']=self._hpaned.get_position()returnsettingsdefload_settings(self,settings):GDialog.load_settings(self,settings)ifsettingsand'changeset'insettings:self._setting_hpos=settings['changeset']else:self._setting_hpos=-1defload_details(self,rev):'''Load selected changeset details into buffer and filelist'''self.currev=revself._buffer.set_text('')self._filelist.clear()parents=[xforxinself.repo.changelog.parentrevs(rev) \
ifx!=nullrev]self.parents=parentstitle=self.get_title()iflen(parents)==2:self.parent_toggle.set_sensitive(True)ifself.parent_toggle.get_active():title+=':'+str(self.parents[1])else:title+=':'+str(self.parents[0])else:self.parent_toggle.set_sensitive(False)ifself.parent_toggle.get_active():# Parent button must be pushed out, but this# will cause load_details to be called again# so we exit out to prevent recursion.self.parent_toggle.set_active(False)returnctx=self.repo.changectx(rev)ifnotctx:self._last_rev=NonereturnFalseself.set_title(title)self.textview.freeze_child_notify()try:self._fill_buffer(self._buffer,rev,ctx,self._filelist)finally:self.textview.thaw_child_notify()def_fill_buffer(self,buf,rev,ctx,filelist):self.stbar.begin('Retrieving changeset data...')deftitle_line(title,text,tag):pad=' '*(12-len(title))utext=toutf(title+pad+text)buf.insert_with_tags_by_name(eob,utext,tag)buf.insert(eob,"\n")# TODO: Add toggle for gmtime/localtimeeob=buf.get_end_iter()date=time.strftime("%Y-%m-%d %H:%M:%S",time.gmtime(ctx.date()[0]))ifself.clipboard:self.clipboard.set_text(short(ctx.node()))change=str(rev)+':'+short(ctx.node())tags=' '.join(ctx.tags())parents=self.parentstitle_line('changeset:',change,'changeset')ifctx.branch()!='default':title_line('branch:',ctx.branch(),'greybg')title_line('user/date:',ctx.user()+'\t'+date,'changeset')forpinparents:pctx=self.repo.changectx(p)summary=pctx.description().splitlines()[0]summary=toutf(summary)change=str(p)+':'+short(self.repo.changelog.node(p))title='parent:'title+=' '*(12-len(title))buf.insert_with_tags_by_name(eob,title,'parent')buf.insert_with_tags_by_name(eob,change,'link')buf.insert_with_tags_by_name(eob,' '+summary,'parent')buf.insert(eob,"\n")forninself.repo.changelog.children(ctx.node()):cctx=self.repo.changectx(n)summary=cctx.description().splitlines()[0]summary=toutf(summary)childrev=self.repo.changelog.rev(n)change=str(childrev)+':'+short(n)title='child:'title+=' '*(12-len(title))buf.insert_with_tags_by_name(eob,title,'parent')buf.insert_with_tags_by_name(eob,change,'link')buf.insert_with_tags_by_name(eob,' '+summary,'parent')buf.insert(eob,"\n")forninself.repo.changelog.children(ctx.node()):childrev=self.repo.changelog.rev(n)iftags:title_line('tags:',tags,'tag')log=toutf(ctx.description())buf.insert(eob,'\n'+log+'\n\n')ifself.parent_toggle.get_active():parent=self.repo.changelog.node(parents[1])elifparents:parent=self.repo.changelog.node(parents[0])else:parent=nullidbuf.create_mark('begmark',buf.get_start_iter())filelist.append(('*','[Description]','begmark',False,()))pctx=self.repo.changectx(parent)nodes=parent,ctx.node()iterator=self.diff_generator(*nodes)gobject.idle_add(self.get_diffs,iterator,nodes,pctx,buf,filelist)self.curnodes=nodesdefget_diffs(self,iterator,nodes,pctx,buf,filelist):ifself.curnodes!=nodes:returnFalsetry:status,file,txt=iterator.next()exceptStopIteration:self.stbar.end()returnFalselines=txt.splitlines()eob=buf.get_end_iter()offset=eob.get_offset()fileoffs,tags,lines,statmax=self.prepare_diff(lines,offset,file)forlinlines:buf.insert(eob,l)# inserts the tagsforname,p0,p1intags:i0=buf.get_iter_at_offset(p0)i1=buf.get_iter_at_offset(p1)txt=buf.get_text(i0,i1)buf.apply_tag_by_name(name,i0,i1)# inserts the marksformark,offset,statsinfileoffs:pos=buf.get_iter_at_offset(offset)mark='mark_%d'%offsetbuf.create_mark(mark,pos)filelist.append((status,toutf(file),mark,True,stats))sob,eob=buf.get_bounds()buf.apply_tag_by_name("mono",pos,eob)returnTrue# Hacked up version of mercurial.patch.diff()# Use git mode by default (to show copies, renames, permissions) but# never show binary diffs. It operates as a generator, so it can be# called iteratively to get file diffs from a changesetdefdiff_generator(self,node1,node2):repo=self.repoccache={}defgetctx(r):ifrnotinccache:ccache[r]=context.changectx(repo,r)returnccache[r]flcache={}defgetfilectx(f,ctx):flctx=ctx.filectx(f,filelog=flcache.get(f))iffnotinflcache:flcache[f]=flctx._filelogreturnflctxctx1=context.changectx(repo,node1)# parentctx2=context.changectx(repo,node2)# currentifnode1==repo.changelog.parents(node2)[0]:filelist=ctx2.files()else:changes=repo.status(node1,node2,None)[:5]modified,added,removed,deleted,unknown=changesfilelist=modified+added+removed# force manifest readingman1=ctx1.manifest()date1=util.datestr(ctx1.date())execf2=ctx2.manifest().execflinkf2=ctx2.manifest().linkf# returns False if there was no rename between ctx1 and ctx2# returns None if the file was created between ctx1 and ctx2# returns the (file, node) present in ctx1 that was renamed to f in ctx2# This will only really work if c1 is the Nth 1st parent of c2.defrenamed(c1,c2,man,f):startrev=c1.rev()c=c2crev=c.rev()ifcrevisNone:crev=repo.changelog.count()orig=ffiles=(f,)whilecrev>startrev:iffinfiles:try:src=getfilectx(f,c).renamed()exceptrevlog.LookupError:returnNoneifsrc:f=src[0]crev=c.parents()[0].rev()# try to reusec=getctx(crev)files=c.files()iffnotinman:returnNoneiff==orig:returnFalsereturnfstatus={}deffilestatus(f):iffinstatus:returnstatus[f]try:# Determine file status by presence in manifestss='R'ctx2.filectx(f)s='A'ctx1.filectx(f)s='M'exceptrevlog.LookupError:passstatus[f]=sreturnscopied={}forfinfilelist:src=renamed(ctx1,ctx2,man1,f)ifsrc:copied[f]=srcsrcs=[x[1]forxincopied.iteritems()iffilestatus(x[0])=='A']gone={}forfinfilelist:s=filestatus(f)to=Nonetn=Nonedodiff=Trueheader=[]iffinman1:to=getfilectx(f,ctx1).data()ifs!='R':tn=getfilectx(f,ctx2).data()a,b=f,fdefgitmode(x,l):returnland'120000'or(xand'100755'or'100644')defaddmodehdr(header,omode,nmode):ifomode!=nmode:header.append('old mode %s\n'%omode)header.append('new mode %s\n'%nmode)ifs=='A':mode=gitmode(execf2(f),linkf2(f))iffincopied:a=copied[f]omode=gitmode(man1.execf(a),man1.linkf(a))addmodehdr(header,omode,mode)iffilestatus(a)=='R'andanotingone:op='rename'gone[a]=1else:op='copy'header.append('%s from %s\n'%(op,a))header.append('%s to %s\n'%(op,f))to=getfilectx(a,ctx1).data()else:header.append('new file mode %s\n'%mode)ifutil.binary(tn):dodiff='binary'elifs=='R':iffinsrcs:dodiff=Falseelse:mode=gitmode(man1.execf(f),man1.linkf(f))header.append('deleted file mode %s\n'%mode)else:omode=gitmode(man1.execf(f),man1.linkf(f))nmode=gitmode(execf2(f),linkf2(f))addmodehdr(header,omode,nmode)ifutil.binary(to)orutil.binary(tn):dodiff='binary'header.insert(0,'diff --git a/%s b/%s\n'%(a,b))ifdodiff=='binary':text='binary file has changed.\n'elifdodiff:try:text=patch.mdiff.unidiff(to,date1,tn,util.datestr(ctx2.date()),fn1=a,fn2=b,r=None,opts=patch.mdiff.defaultopts)exceptTypeError:# hg-0.9.5 and beforetext=patch.mdiff.unidiff(to,date1,tn,util.datestr(ctx2.date()),f,None,opts=patch.mdiff.defaultopts)else:text=''ifheaderortext:yield(s,f,''.join(header)+text)defprepare_diff(self,difflines,offset,fname):'''Borrowed from hgview; parses changeset diffs'''DIFFHDR="=== %s ===\n"idx=0outlines=[]tags=[]filespos=[]defaddtag(name,offset,length):iftagsandtags[-1][0]==nameandtags[-1][2]==offset:tags[-1][2]+=lengthelse:tags.append([name,offset,offset+length])stats=[0,0]statmax=0fori,l1inenumerate(difflines):l=toutf(l1)ifl.startswith("diff"):txt=toutf(DIFFHDR%fname)addtag("greybg",offset,len(txt))outlines.append(txt)markname="file%d"%idxidx+=1statmax=max(statmax,stats[0]+stats[1])stats=[0,0]filespos.append((markname,offset,stats))offset+=len(txt.decode('utf-8'))continueelifl.startswith("+++"):continueelifl.startswith("---"):continueelifl.startswith("+"):tag="green"stats[0]+=1elifl.startswith("-"):stats[1]+=1tag="red"elifl.startswith("@@"):tag="blue"else:tag="black"l=l+"\n"length=len(l.decode('utf-8'))addtag(tag,offset,length)outlines.append(l)offset+=lengthstatmax=max(statmax,stats[0]+stats[1])returnfilespos,tags,outlines,statmaxdeflink_event(self,tag,widget,event,iter):ifevent.type!=gtk.gdk.BUTTON_RELEASE:returntext=self.get_link_text(tag,widget,iter)ifnottext:returnlinkrev=long(text.split(':')[0])ifself.graphview:self.graphview.set_revision_id(linkrev)self.graphview.scroll_to_revision(linkrev)else:self.load_details(linkrev)defget_link_text(self,tag,widget,iter):"""handle clicking on a link in a textview"""text_buffer=widget.get_buffer()beg=iter.copy()whilenotbeg.begins_tag(tag):beg.backward_char()end=iter.copy()whilenotend.ends_tag(tag):end.forward_char()text=text_buffer.get_text(beg,end)returntextdeffile_context_menu(self):defcreate_menu(label,callback):menuitem=gtk.MenuItem(label,True)menuitem.connect('activate',callback)menuitem.set_border_width(1)returnmenuitem_menu=gtk.Menu()_menu.append(create_menu('_view at revision',self._view_file_rev))self._save_menu=create_menu('_save at revision',self._save_file_rev)_menu.append(self._save_menu)_menu.append(create_menu('_file history',self._file_history))self._ann_menu=create_menu('_annotate file',self._ann_file)_menu.append(self._ann_menu)_menu.append(create_menu('_revert file contents',self._revert_file))self._file_diff_to_mark_menu=create_menu('_diff file to mark',self._diff_file_to_mark)self._file_diff_from_mark_menu=create_menu('diff file _from mark',self._diff_file_from_mark)_menu.append(self._file_diff_to_mark_menu)_menu.append(self._file_diff_from_mark_menu)_menu.show_all()return_menudefget_body(self):ifself.repo.ui.configbool('tortoisehg','copyhash'):sel=(os.name=='nt')and'CLIPBOARD'or'PRIMARY'self.clipboard=gtk.Clipboard(selection=sel)else:self.clipboard=Noneself._filemenu=self.file_context_menu()details_frame=gtk.Frame()details_frame.set_shadow_type(gtk.SHADOW_ETCHED_IN)scroller=gtk.ScrolledWindow()scroller.set_policy(gtk.POLICY_AUTOMATIC,gtk.POLICY_AUTOMATIC)details_frame.add(scroller)details_text=gtk.TextView()details_text.set_wrap_mode(gtk.WRAP_NONE)details_text.set_editable(False)details_text.modify_font(pango.FontDescription(self.fontcomment))scroller.add(details_text)self._buffer=gtk.TextBuffer()self.setup_tags()details_text.set_buffer(self._buffer)self.textview=details_textfilelist_tree=gtk.TreeView()filesel=filelist_tree.get_selection()filesel.connect("changed",self._filelist_rowchanged)filelist_tree.connect('button-release-event',self._file_button_release)filelist_tree.connect('popup-menu',self._file_popup_menu)filelist_tree.connect('row-activated',self._file_row_act)self._filelist=gtk.ListStore(gobject.TYPE_STRING,# MAR statusgobject.TYPE_STRING,# filename (utf-8 encoded)gobject.TYPE_PYOBJECT,# markgobject.TYPE_PYOBJECT,# give cmenugobject.TYPE_PYOBJECT,# diffstats)filelist_tree.set_model(self._filelist)column=gtk.TreeViewColumn('Stat',gtk.CellRendererText(),text=0)filelist_tree.append_column(column)column=gtk.TreeViewColumn('Files',gtk.CellRendererText(),text=1)filelist_tree.append_column(column)list_frame=gtk.Frame()list_frame.set_shadow_type(gtk.SHADOW_ETCHED_IN)scroller=gtk.ScrolledWindow()scroller.set_policy(gtk.POLICY_AUTOMATIC,gtk.POLICY_AUTOMATIC)scroller.add(filelist_tree)list_frame.add(scroller)self._hpaned=gtk.HPaned()self._hpaned.pack1(list_frame,True,True)self._hpaned.pack2(details_frame,True,True)self._hpaned.set_position(self._setting_hpos)ifself.stbar:# embedded by changelog browserreturnself._hpanedelse:# add status bar for main appvbox=gtk.VBox()vbox.pack_start(self._hpaned,True,True)self.stbar=StatusBar()self.stbar.show()vbox.pack_start(gtk.HSeparator(),False,False)vbox.pack_start(self.stbar,False,False)returnvboxdefsetup_tags(self):"""Creates the tags to be used inside the TextView"""defmake_texttag(name,**kwargs):"""Helper function generating a TextTag"""tag=gtk.TextTag(name)forkey,valueinkwargs.iteritems():key=key.replace("_","-")try:tag.set_property(key,value)exceptTypeError:print"Warning the property %s is unsupported in"%keyprint"this version of pygtk"returntagtag_table=self._buffer.get_tag_table()tag_table.add(make_texttag('changeset',foreground='#000090',paragraph_background='#F0F0F0'))tag_table.add(make_texttag('date',foreground='#000090',paragraph_background='#F0F0F0'))tag_table.add(make_texttag('tag',foreground='#000090',paragraph_background='#F0F0F0'))tag_table.add(make_texttag('files',foreground='#5C5C5C',paragraph_background='#F0F0F0'))tag_table.add(make_texttag('parent',foreground='#000090',paragraph_background='#F0F0F0'))tag_table.add(make_texttag("mono",family="Monospace"))tag_table.add(make_texttag("blue",foreground='blue'))tag_table.add(make_texttag("red",foreground='red'))tag_table.add(make_texttag("green",foreground='darkgreen'))tag_table.add(make_texttag("black",foreground='black'))tag_table.add(make_texttag("greybg",paragraph_background='grey',weight=pango.WEIGHT_BOLD))tag_table.add(make_texttag("yellowbg",background='yellow'))link_tag=make_texttag("link",foreground="blue",underline=pango.UNDERLINE_SINGLE)link_tag.connect("event",self.link_event)tag_table.add(link_tag)def_filelist_rowchanged(self,sel):model,iter=sel.get_selected()ifnotiter:return# scroll to file in details windowmark=self._buffer.get_mark(model[iter][2])self.textview.scroll_to_mark(mark,0.0,True,0.0,0.0)ifmodel[iter][3]:self.curfile=fromutf(model[iter][1])else:self.curfile=Nonedef_file_button_release(self,widget,event):ifevent.button==3andnot(event.state&(gtk.gdk.SHIFT_MASK|gtk.gdk.CONTROL_MASK)):self._file_popup_menu(widget,event.button,event.time)returnFalsedef_file_popup_menu(self,treeview,button=0,time=0):ifself.curfileisNone:returnifself.graphview:is_mark=self.graphview.get_mark_rev()isnotNoneelse:is_mark=Falseself._file_diff_to_mark_menu.set_sensitive(is_mark)self._file_diff_from_mark_menu.set_sensitive(is_mark)self._filemenu.popup(None,None,None,button,time)# If the filelog entry this changeset references does not link# back to this changeset, it means this changeset did not# actually change the contents of this file, and thus the file# cannot be annotated at this revision (since this changeset# does not appear in the filelog)ctx=self.repo.changectx(self.currev)try:fctx=ctx.filectx(self.curfile)has_filelog=fctx.filelog().linkrev(fctx.filenode())==ctx.rev()exceptrevlog.LookupError:has_filelog=Falseself._ann_menu.set_sensitive(has_filelog)self._save_menu.set_sensitive(has_filelog)returnTruedef_file_row_act(self,tree,path,column):"""Default action is the first entry in the context menu """self._filemenu.get_children()[0].activate()returnTruedef_save_file_rev(self,menuitem):file=util.localpath(self.curfile)file,ext=os.path.splitext(os.path.basename(file))filename="%s@%d%s"%(file,self.currev,ext)fd=NativeSaveFileDialogWrapper(Title="Save file to",InitialDir=self.cwd,FileName=filename)result=fd.run()ifresult:importQueueimporthglibq=Queue.Queue()cpath=util.canonpath(self.repo.root,self.cwd,self.curfile)hglib.hgcmd_toq(self.repo.root,q,'cat','--rev',str(self.currev),'--output',result,cpath)def_view_file_rev(self,menuitem):'''User selected view file revision from the file list context menu'''ifnotself.curfile:# ignore view events for the [Description] rowreturnrev=self.currevparents=self.parentsiflen(parents)==0:parent=rev-1else:parent=parents[0]pair='%u:%u'%(parent,rev)self._node1,self._node2=cmdutil.revpair(self.repo,[pair])self._view_file('M',self.curfile,force_left=False)def_diff_file_to_mark(self,menuitem):'''User selected diff to mark from the file list context menu'''fromstatusimportGStatusfromgtoolsimportcmdtablerev0=self.graphview.get_mark_rev()rev1=self.currevstatopts=self.merge_opts(cmdtable['gstatus|gst'][1],('include','exclude','git'))statopts['rev']=['%u:%u'%(rev1,rev0)]statopts['modified']=Truestatopts['added']=Truestatopts['removed']=Truedialog=GStatus(self.ui,self.repo,self.cwd,[self.curfile],statopts,False)dialog.display()returnTruedef_diff_file_from_mark(self,menuitem):'''User selected diff from mark from the file list context menu'''fromstatusimportGStatusfromgtoolsimportcmdtablerev0=self.graphview.get_mark_rev()rev1=self.currevstatopts=self.merge_opts(cmdtable['gstatus|gst'][1],('include','exclude','git'))statopts['rev']=['%u:%u'%(rev0,rev1)]statopts['modified']=Truestatopts['added']=Truestatopts['removed']=Truedialog=GStatus(self.ui,self.repo,self.cwd,[self.curfile],statopts,False)dialog.display()def_ann_file(self,menuitem):'''User selected diff from mark from the file list context menu'''fromdatamineimportDataMineDialogrev=self.currevdialog=DataMineDialog(self.ui,self.repo,self.cwd,[],{},False)dialog.display()dialog.add_annotate_page(self.curfile,str(rev))def_file_history(self,menuitem):'''User selected file history from file list context menu'''ifself.glog_parent:# If this changeset browser is embedded in glog, send# send this event to the main appopts={'filehist':self.curfile}self.glog_parent.custombutton.set_active(True)self.glog_parent.graphview.refresh(True,None,opts)else:# Else launch our own GLog instancefromhistoryimportGLogdialog=GLog(self.ui,self.repo,self.cwd,[self.repo.root],{},False)dialog.open_with_file(self.curfile)dialog.display()def_revert_file(self,menuitem):'''User selected file revert from the file list context menu'''rev=self.currevdialog=Confirm('revert file to old revision',[],self,'Revert %s to contents at revision %d?'%(self.curfile,rev))ifdialog.run()==gtk.RESPONSE_NO:returncmdline=['hg','revert','--verbose','--rev',str(rev),self.curfile]self.restore_cwd()dlg=CmdDialog(cmdline)dlg.run()dlg.hide()shell_notify([self.curfile])defrun(root='',cwd='',files=[],**opts):u=ui.ui()u.updateopts(debug=False,traceback=False)repo=hg.repository(u,path=root)dialog=ChangeSet(u,repo,cwd,files,opts,True)dialog.display()gtk.gdk.threads_init()gtk.gdk.threads_enter()gtk.main()gtk.gdk.threads_leave()if__name__=="__main__":importsysopts={}opts['root']=len(sys.argv)>1andsys.argv[1]oros.getcwd()opts['rev']=['750']run(**opts)