Source

thg / tortoisehg / hgtk / guess.py

Full commit
# guess.py - TortoiseHg's dialogs for detecting copies and renames
#
# Copyright 2009 Steve Borho <steve@borho.org>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2, incorporated herein by reference.

import os
import sys
import gtk
import gobject
import pango
import cStringIO
import Queue

from mercurial import hg, ui, mdiff, cmdutil, match, util, error
from mercurial import similar

from tortoisehg.util.i18n import _
from tortoisehg.util import hglib, shlib, paths, thread2, settings

from tortoisehg.hgtk import gtklib, statusbar

class DetectRenameDialog(gtk.Window):
    'Detect renames after they occur'
    def __init__(self):
        'Initialize the Dialog'
        gtk.Window.__init__(self, gtk.WINDOW_TOPLEVEL)
        gtklib.set_tortoise_icon(self, 'detect_rename.ico')
        gtklib.set_tortoise_keys(self)

        try:
            repo = hg.repository(ui.ui(), path=paths.find_root())
        except error.RepoError:
            gtklib.idle_add_single_call(self.destroy)
            return
        self.repo = repo
        self.notify_func = None
        reponame = hglib.get_reponame(repo)
        self.set_title(_('Detect Copies/Renames in %s') % reponame)
        self.settings = settings.Settings('guess')
        dims = self.settings.get_value('dims', (800, 600))
        self.set_default_size(dims[0], dims[1])

        # vbox for dialog main & status bar
        mainvbox = gtk.VBox()
        self.add(mainvbox)

        # vsplit for top & diff
        self.vpaned = gtk.VPaned()
        mainvbox.pack_start(self.vpaned, True, True, 2)
        pos = self.settings.get_value('vpaned', None)
        if pos: self.vpaned.set_position(pos)

        # vbox for top contents
        topvbox = gtk.VBox()
        self.vpaned.pack1(topvbox, True, False)

        # frame for simularity
        frame = gtk.Frame(_('Minimum Simularity Percentage'))
        topvbox.pack_start(frame, False, False, 2)

        #$ simularity slider
        self.adjustment = gtk.Adjustment(50, 0, 100, 1)
        value = self.settings.get_value('percent', None)
        if value: self.adjustment.set_value(value)
        hscale = gtk.HScale(self.adjustment)
        frame.add(hscale)

        # horizontal splitter for unknown & candidate
        self.hpaned = gtk.HPaned()
        topvbox.pack_start(self.hpaned, True, True, 2)
        pos = self.settings.get_value('hpaned', None)
        if pos: self.hpaned.set_position(pos)

        #$ frame for unknown list
        unknownframe = gtk.Frame(_('Unrevisioned Files'))
        self.hpaned.pack1(unknownframe, True, True)

        #$$ vbox for unknown list & rename/copy buttons
        unkvbox = gtk.VBox()
        unknownframe.add(unkvbox)

        #$$$ scroll window for unknown list
        scroller = gtk.ScrolledWindow()
        unkvbox.pack_start(scroller, True, True, 2)
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)

        #$$$$ unknown list
        unkmodel = gtk.ListStore(str, # path
                                 str) # path (utf-8)
        self.unktree = gtk.TreeView(unkmodel)
        scroller.add(self.unktree)
        self.unktree.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
        cell = gtk.CellRendererText()
        cell.set_property("ellipsize", pango.ELLIPSIZE_START)

        col = gtk.TreeViewColumn('File', cell, text=1)
        self.unktree.append_column(col)
        self.unktree.set_enable_search(True)
        self.unktree.set_headers_visible(False)

        #$$$ hbox for rename/copy buttons
        btnhbox = gtk.HBox()
        unkvbox.pack_start(btnhbox, False, False, 2)

        #$$$$ rename/copy buttons in unknown frame
        self.renamebtn = gtk.Button(_('Find Renames'))
        self.renamebtn.set_sensitive(False)
        btnhbox.pack_start(self.renamebtn, False, False, 2)
        self.copybtn = gtk.Button(_('Find Copies'))
        self.copybtn.set_sensitive(False)
        btnhbox.pack_start(self.copybtn, False, False, 2)

        #$ frame for candidate list
        candidateframe = gtk.Frame(_('Candidate Matches'))
        self.hpaned.pack2(candidateframe, True, True)

        #$$ vbox for candidate list & accept button
        canvbox = gtk.VBox()
        candidateframe.add(canvbox)

        #$$$ scroll window for candidate list
        scroller = gtk.ScrolledWindow()
        canvbox.pack_start(scroller, True, True, 2)
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)

        #$$$$ candidate list
        canmodel = gtk.ListStore(str,  # source
                                 str,  # source (utf-8)
                                 str,  # dest
                                 str,  # dest (utf-8)
                                 str,  # percent
                                 bool) # sensitive
        self.cantree = gtk.TreeView(canmodel)
        scroller.add(self.cantree)
        self.cantree.set_rules_hint(True)
        self.cantree.set_reorderable(False)
        self.cantree.set_enable_search(False)
        self.cantree.get_selection().set_mode(gtk.SELECTION_MULTIPLE)

        cell = gtk.CellRendererText()
        cell.set_property('width-chars', 30)
        cell.set_property('ellipsize', pango.ELLIPSIZE_START)
        col = gtk.TreeViewColumn(_('Source'), cell, text=1, sensitive=5)
        col.set_resizable(True)
        self.cantree.append_column(col)

        cell = gtk.CellRendererText()
        cell.set_property('width-chars', 30)
        cell.set_property('ellipsize', pango.ELLIPSIZE_START)
        col = gtk.TreeViewColumn(_('Dest'), cell, text=3, sensitive=5)
        col.set_resizable(True)
        self.cantree.append_column(col)

        cell = gtk.CellRendererText()
        cell.set_property('width-chars', 5)
        cell.set_property('ellipsize', pango.ELLIPSIZE_NONE)
        col = gtk.TreeViewColumn('%', cell, text=4, sensitive=5)
        col.set_resizable(True)
        self.cantree.append_column(col)

        #$$$ hbox for accept button
        btnhbox = gtk.HBox()
        canvbox.pack_start(btnhbox, False, False, 2)

        #$$$$ accept button in candidate frame
        self.acceptbtn = gtk.Button(_('Accept Match'))
        btnhbox.pack_start(self.acceptbtn, False, False, 2)
        self.acceptbtn.set_sensitive(False)

        # frame for diff
        diffframe = gtk.Frame(_('Differences from Source to Dest'))
        self.vpaned.pack2(diffframe)
        diffframe.set_shadow_type(gtk.SHADOW_ETCHED_IN)

        #$ scroll window for diff
        scroller = gtk.ScrolledWindow()
        diffframe.add(scroller)
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)

        #$$ text view for diff
        self.buf = gtk.TextBuffer()
        self.buf.create_tag('removed', foreground=gtklib.DRED)
        self.buf.create_tag('added', foreground=gtklib.DGREEN)
        self.buf.create_tag('position', foreground=gtklib.DORANGE)
        self.buf.create_tag('header', foreground=gtklib.DBLUE)
        diffview = gtk.TextView(self.buf)
        scroller.add(diffview)
        fontdiff = hglib.getfontconfig()['fontdiff']
        diffview.modify_font(pango.FontDescription(fontdiff))
        diffview.set_wrap_mode(gtk.WRAP_NONE)
        diffview.set_editable(False)

        # status bar
        self.stbar = statusbar.StatusBar()
        mainvbox.pack_start(self.stbar, False, False, 2)

        # register signal handlers
        self.copybtn.connect('pressed', lambda b: self.find_copies())
        self.renamebtn.connect('pressed', lambda b: self.find_renames())
        self.acceptbtn.connect('pressed', lambda b: self.accept_match())

        self.unktree.get_selection().connect('changed', self.unknown_sel_change)
        self.cantree.connect('row-activated', lambda t,p,c: self.accept_match())
        self.cantree.get_selection().connect('changed', self.show_diff)
        self.connect('delete-event', lambda *a: self.save_settings())
        gtklib.idle_add_single_call(self.refresh)

    def set_notify_func(self, func):
        self.notify_func = func

    def refresh(self):
        q = Queue.Queue()
        unkmodel = self.unktree.get_model()
        unkmodel.clear()
        thread = thread2.Thread(target=self.unknown_thread, args=(q,))
        thread.start()
        gobject.timeout_add(50, self.unknown_wait, thread, q)

    def unknown_thread(self, q):
        hglib.invalidaterepo(self.repo)
        matcher = match.always(self.repo.root, self.repo.root)
        status = self.repo.status(node1=self.repo.dirstate.parents()[0],
                                  node2=None, match=matcher, ignored=False,
                                  clean=False, unknown=True)
        (modified, added, removed, deleted, unknown, ignored, clean) = status
        for u in unknown:
            q.put( u )
        for a in added:
            if not self.repo.dirstate.copied(a):
                q.put( a )

    def unknown_wait(self, thread, q):
        unkmodel = self.unktree.get_model()
        while q.qsize():
            wfile = q.get(0)
            if unkmodel is not None:
                unkmodel.append( [wfile, hglib.toutf(wfile)] )
        return thread.isAlive()

    def save_settings(self):
        self.settings.set_value('vpaned', self.vpaned.get_position())
        self.settings.set_value('hpaned', self.hpaned.get_position())
        self.settings.set_value('percent', self.adjustment.get_value())
        rect = self.get_allocation()
        self.settings.set_value('dims', (rect.width, rect.height))
        self.settings.write()

    def find_renames(self, copy=False):
        'User pressed "find renames" button'
        canmodel = self.cantree.get_model()
        canmodel.clear()
        umodel, upaths = self.unktree.get_selection().get_selected_rows()
        if not upaths:
            return
        tgts = [ umodel[p][0] for p in upaths ]
        q = Queue.Queue()
        thread = thread2.Thread(target=self.search_thread,
                                args=(q, tgts, copy))
        thread.start()
        self.stbar.begin()
        text = _('finding source of ') + ', '.join(tgts)
        if len(text) > 60:
            text = text[:60]+'...'
        self.stbar.set_text(text)
        gobject.timeout_add(50, self.search_wait, thread, q)

    def search_thread(self, q, tgts, copy):
        hglib.invalidaterepo(self.repo)
        srcs = []
        audit_path = util.path_auditor(self.repo.root)
        m = cmdutil.match(self.repo)
        for abs in self.repo.walk(m):
            target = self.repo.wjoin(abs)
            good = True
            try:
                audit_path(abs)
            except:
                good = False
            status = self.repo.dirstate[abs]
            if (not good or not os.path.lexists(target)
                or (os.path.isdir(target) and not os.path.islink(target))):
                srcs.append(abs)
            elif copy and status == 'n':
                # looking for copies, so any revisioned file is a
                # potential source (yes, this will be expensive)
                # Added and removed files are not considered as copy
                # sources.
                srcs.append(abs)
        if copy:
            simularity = 1.0
        else:
            simularity = self.adjustment.get_value() / 100.0;

        gen = similar.findrenames(self.repo, tgts, srcs, simularity)
        for old, new, score in gen:
            q.put( [old, new, '%d%%' % (score*100)] )

    def search_wait(self, thread, q):
        canmodel = self.cantree.get_model()
        while canmodel and q.qsize():
            source, dest, sim = q.get(0)
            canmodel.append( [source, hglib.toutf(source), dest,
                              hglib.toutf(dest), sim, True] )
        if thread.isAlive():
            return True
        else:
            self.stbar.end()
            return False

    def find_copies(self):
        'User pressed "find copies" button'
        # call rename function with simularity = 100%
        self.find_renames(copy=True)

    def accept_match(self):
        'User pressed "accept match" button'
        hglib.invalidaterepo(self.repo)
        wctx = self.repo[None]
        canmodel, upaths = self.cantree.get_selection().get_selected_rows()
        for path in upaths:
            row = canmodel[path]
            src, usrc, dest, udest, percent, sensitive = row
            if not sensitive:
                continue
            if not os.path.exists(self.repo.wjoin(src)):
                # Mark missing rename source as removed
                wctx.remove([src])
            wctx.copy(src, dest)
            shlib.shell_notify([self.repo.wjoin(src), self.repo.wjoin(dest)])
            if self.notify_func:
                self.notify_func()
            # Mark all rows with this target file as non-sensitive
            for row in canmodel:
                if row[2] == dest:
                    row[5] = False
        self.refresh()

    def unknown_sel_change(self, selection):
        'User selected a row in the unknown tree'
        model, upaths = selection.get_selected_rows()
        sensitive = upaths and True or False
        self.renamebtn.set_sensitive(sensitive)
        self.copybtn.set_sensitive(sensitive)

    def show_diff(self, selection):
        'User selected a row in the candidate tree'
        hglib.invalidaterepo(self.repo)
        model, cpaths = selection.get_selected_rows()
        sensitive = cpaths and True or False
        self.acceptbtn.set_sensitive(sensitive)

        self.buf.set_text('')
        bufiter = self.buf.get_start_iter()
        for path in cpaths:
            row = model[path]
            src, usrc, dest, udest, percent, sensitive = row
            if not sensitive:
                continue
            ctx = self.repo['.']
            aa = self.repo.wread(dest)
            rr = ctx.filectx(src).data()
            opts = mdiff.defaultopts
            difftext = mdiff.unidiff(rr, '', aa, '', src, dest, None, opts=opts)
            if not difftext:
                l = _('== %s and %s have identical contents ==\n\n') % (src, dest)
                self.buf.insert(bufiter, l)
                continue
            difflines = difftext.splitlines(True)
            for line in difflines:
                line = hglib.toutf(line)
                if line.startswith('---') or line.startswith('+++'):
                    self.buf.insert_with_tags_by_name(bufiter, line, 'header')
                elif line.startswith('-'):
                    line = hglib.diffexpand(line)
                    self.buf.insert_with_tags_by_name(bufiter, line, 'removed')
                elif line.startswith('+'):
                    line = hglib.diffexpand(line)
                    self.buf.insert_with_tags_by_name(bufiter, line, 'added')
                elif line.startswith('@@'):
                    self.buf.insert_with_tags_by_name(bufiter, line, 'position')
                else:
                    line = hglib.diffexpand(line)
                    self.buf.insert(bufiter, line)

def run(ui, *pats, **opts):
    return DetectRenameDialog()