
# Copyright 2009-2018 Jaap Karssenberg <jaap.karssenberg@gmail.com>
# Copyright 2012 Damien Accorsi <damien.accorsi@free.fr>



from gi.repository import GObject
from gi.repository import Gtk

import os
import logging
import threading

from functools import partial

from zim.newfs import LocalFolder, TmpFile
from zim.plugins import PluginClass, find_extension
from zim.actions import action
from zim.signals import ConnectorMixin
from zim.errors import Error
from zim.applications import Application
from zim.gui.applications import DesktopEntryFile
from zim.config import value_is_coord, data_dirs
from zim.notebook import NotebookExtension
from zim.notebook.operations import NotebookState, SimpleAsyncOperation
from zim.utils import natural_sort_key

from zim.gui.mainwindow import MainWindowExtension
from zim.gui.widgets import ErrorDialog, QuestionDialog, Dialog, \
	PageEntry, IconButton, SingleClickTreeView, \
	ScrolledWindow, ScrolledTextView, VPaned, \
	ProgressDialog


if os.environ.get('ZIM_TEST_RUNNING'):
	TEST_MODE = True
else:
	TEST_MODE = False

# FUTURE allow vcs modules like bzr to have their own UI classes
# these can add additional menu items e.g. Tools->Bazaar-> ...
# or use their own graphical interfaces, like bzr gdiff

# FUTURE add option to also pull & push versions automatically

# FUTURE add versions... menu item to note right-click

logger = logging.getLogger('zim.plugins.versioncontrol')


class VersionControlPlugin(PluginClass):

	plugin_info = {
		'name': _('Version Control'), # T: plugin name
		'description': _('''\
This plugin adds version control for notebooks.

This plugin supports the Bazaar, Git and Mercurial version control systems.

This is a core plugin shipping with zim.
'''), # T: plugin description
		'author': 'Jaap Karssenberg & John Drinkwater & Damien Accorsi',
		'help': 'Plugins:Version Control',
	}

	plugin_preferences = (
		('autosave', 'bool', _('Autosave version when the notebook is closed'), False), # T: Label for plugin preference
		('autosave_at_interval', 'bool', _('Autosave version on regular intervals'), False), # T: Label for plugin preference
		('autosave_interval', 'int', _('Autosave interval in minutes'), 10, (1, 3600)), # T: Label for plugin preference
	)

	@classmethod
	def check_dependencies(klass):
		has_bzr = VCS.check_dependencies(VCS.BZR)
		has_git = VCS.check_dependencies(VCS.GIT)
		has_hg = VCS.check_dependencies(VCS.HG)
		has_fossil = VCS.check_dependencies(VCS.FOSSIL)
		#TODO parameterize the return, so that a new backend will be automatically available
		return has_bzr | has_hg | has_git | has_fossil, [('bzr', has_bzr, False), ('hg', has_hg, False), ('git', has_git, False), ('fossil', has_fossil, False)]


class VersionControlNotebookExtension(NotebookExtension):

	def __init__(self, plugin, notebook):
		NotebookExtension.__init__(self, plugin, notebook)
		self.vcs = None
		self.detect_vcs()

	def _get_notebook_dir(self):
		assert isinstance(self.notebook.folder, LocalFolder)
		return self.notebook.folder

	def detect_vcs(self):
		try:
			dir = self._get_notebook_dir()
		except AssertionError:
			return

		self.vcs = VCS.detect_in_folder(dir)

	def init_vcs(self, vcs):
		dir = self._get_notebook_dir()
		self.vcs = VCS.create(vcs, dir, dir)

		if self.vcs and not self.vcs.repo_exists():
			with NotebookState(self.notebook):
				self.vcs.init_repo()


	def teardown(self):
		if self.vcs:
			self.vcs.disconnect_all()


class VersionControlMainWindowExtension(MainWindowExtension):

	def __init__(self, plugin, window):
		MainWindowExtension.__init__(self, plugin, window)

		self.notebook_ext = find_extension(window.notebook, VersionControlNotebookExtension)

		self._autosave_thread = None
		self._autosave_timer = None

		if self.notebook_ext.vcs is None:
			gaction = self.actiongroup.get_action('show_versions')
			gaction.set_sensitive(False)
		else:
			self.on_preferences_changed(None, start=True)

		def on_close(o):
			if self.plugin.preferences['autosave'] \
			or self.plugin.preferences['autosave_at_interval']:
				self.do_save_version()

		self.window.connect('close', on_close)

		self.connectto(self.plugin.preferences, 'changed',
			self.on_preferences_changed)

	def on_preferences_changed(self, o, start=False):
		self._stop_timer()

		if (start and self.plugin.preferences['autosave']) \
		or self.plugin.preferences['autosave_at_interval']:
			self.do_save_version_async()

		if self.plugin.preferences['autosave_at_interval']:
			self._start_timer()

	def _start_timer(self):
		timeout = 60000 * self.plugin.preferences['autosave_interval']
		self._autosave_timer = GObject.timeout_add(
			timeout, self.do_save_version_async)

	def _stop_timer(self):
		if self._autosave_timer:
			GObject.source_remove(self._autosave_timer)
			self._autosave_timer = None

	def teardown(self):
		self._stop_timer()

	def do_save_version_async(self, msg=None):
		if not self.notebook_ext.vcs:
			return False # stop timer

		if self._autosave_thread and self._autosave_thread.is_alive():
			return True # continue time

		with NotebookState(self.notebook_ext.notebook):
			op, thread = self._commit_op(msg)
			self._autosave_thread = thread
			op.run_on_idle()

		return True # continue timer

	def _commit_op(self, msg):
		thread = threading.Thread(
			target=partial(self._save_version, msg)
		)
		thread.start()
		return SimpleAsyncOperation(
			notebook=self.notebook_ext.notebook,
			message='Saving version in progress',
			thread=thread
		), thread

	def do_save_version(self, msg=None):
		if not self.notebook_ext.vcs:
			return

		if self._autosave_thread and self._autosave_thread.is_alive():
			self._autosave_thread.join()

		with NotebookState(self.notebook_ext.notebook):
			self._save_version(msg)

	def _save_version(self, msg=None):
		if msg is None:
			msg = _('Automatically saved version from zim')
				# T: default version comment for auto-saved versions

		try:
			self.notebook_ext.vcs.commit_version(msg)
		except NoChangesError:
			logger.debug('No autosave version needed - no changes')

	@action(_('S_ave Version...'), '<Primary><shift>S', menuhints='notebook:edit') # T: menu item
	def save_version(self):
		if not self.notebook_ext.vcs:
			vcs = VersionControlInitDialog(self.window).run()
			logger.debug("Selected VCS: %s", vcs)
			if vcs is None:
				return # Canceled

			self.notebook_ext.init_vcs(vcs)
			if self.notebook_ext.vcs:
				gaction = self.actiongroup.get_action('show_versions')
				gaction.set_sensitive(True)
				self.on_preferences_changed(None, start=False)

		with NotebookState(self.notebook_ext.notebook):
			SaveVersionDialog(self.window, self, self.notebook_ext.vcs).run()

	@action(_('_Versions...'), menuhints='notebook') # T: menu item
	def show_versions(self):
		dialog = VersionsDialog.unique(self, self.window,
			self.notebook_ext.vcs,
			self.notebook_ext.notebook,
			self.window.page
		)
		dialog.present()


class NoChangesError(Error):

	description = _('There are no changes in this notebook since the last version that was saved') # T: verbose error description

	def __init__(self, root):
		self.msg = _('No changes since last version')
		# T: Short error descriotion


class VCS(object):
	"""
	This class is the main entry for all Version Control System Stuff.
	It is a factory, a dependencies checker, the enumeration of supported VCS.

	@implementation: If you add a VCS backend, then you have to: \
	- add a file named <your_backend>.py
	- create there a class inheriting from VCSApplicationBase \
	- add here the stuff to manage it
	"""

	# Enumeration of all available backends
	BZR = _('Bazaar') # T: option value
	HG = _('Mercurial') # T: option value
	GIT = _('Git') # T: option value
	FOSSIL = _('Fossil') # T: option value

	@classmethod
	def detect_in_folder(klass, dir):
		"""Detect if a version control system has already been setup in the folder.
		It also create the instance by calling the VCS.create() method
		@param dir: a L{Dir} instance representing the notebook root folder
		@returns: a vcs backend object or C{None}
		"""
		name, root = klass._detect_in_folder(dir)

		if name == 'bzr':
			vcs = VCS.create(VCS.BZR, root, dir)
		elif name == 'hg':
			vcs = VCS.create(VCS.HG, root, dir)
		elif name == 'git':
			vcs = VCS.create(VCS.GIT, root, dir)
		elif name == 'fossil':
			vcs = VCS.create(VCS.FOSSIL, root, dir)
		else:
			# else maybe detected something, but no backend available
			vcs = None

		if vcs:
			logger.info('VCS detected: %s - %s', name, root)
			return vcs
		else:
			logger.info('No VCS detected')
			return None

	@classmethod
	def _detect_in_folder(klass, folder):
		# Included unsupported systems as well, to make sure we stop
		# looking for parents if these are detected.
		if folder.folder('.bzr').exists():
			return 'bzr', folder
		elif folder.folder('.hg').exists():
			return 'hg', folder
		elif folder.folder('.git').exists() or folder.file('.git').exists():
			return 'git', folder
		elif folder.folder('.svn').exists():
			return 'svn', folder
		elif folder.file('.fslckout').exists() or folder.file('_FOSSIL_').exists():
			return 'fossil', folder
		## Commented CVS out since it potentially
		## conflicts with like-named pages
		# elif path.folder('CVS').exists():
			# return 'cvs', path
		##
		else:
			parent = folder.parent()
			if parent:
				return klass._detect_in_folder(parent) # recurs
			else:
				return None, None

	@classmethod
	def get_backend(klass, vcs):
		"""Return the class of backend to instantiate according to vcs given as parameter.
		@param vcs: the wanted vcs backend (VCS.BZR, VCS.GIT, VCS.HG, ...)
		@returns: the related backend class. The returned class is a VCSApplicationBase child class
		"""
		vcs_klass = None
		if vcs == VCS.BZR:
			from zim.plugins.versioncontrol.bzr import BZRApplicationBackend
			vcs_klass = BZRApplicationBackend
		elif vcs == VCS.HG:
			from zim.plugins.versioncontrol.hg import HGApplicationBackend
			vcs_klass = HGApplicationBackend
		elif vcs == VCS.GIT:
			from zim.plugins.versioncontrol.git import GITApplicationBackend
			vcs_klass = GITApplicationBackend
		elif vcs == VCS.FOSSIL:
			from zim.plugins.versioncontrol.fossil import FOSSILApplicationBackend
			vcs_klass = FOSSILApplicationBackend
		else:
			assert False, 'Unkown VCS: %s' % vcs

		return vcs_klass


	@classmethod
	def create(klass, vcs, vcs_dir, notebook_dir):
		"""Build the required instance of a Version Control System

		@param vcs: Version Control System to build (choose between VCS.BZR, VCS.HG, VCS.GIT, VCS.FOSSIL)
		@param vcs_dir: a L{Dir} instance representing the VCS root folder
		@param notebook_dir: a L{Dir} instance representing the notebook root folder
		(must be equal to or below vcs_dir)
		@returns: a vcs backend object
		"""
		if not (notebook_dir == vcs_dir or notebook_dir.ischild(vcs_dir)):
			raise AssertionError('Notebook %s is not part of version control dir %s' % (notebook_dir, vcs_dir))

		vcs_backend_klass = VCS.get_backend(vcs)
		return vcs_backend_klass(vcs_dir, notebook_dir)

	@classmethod
	def check_dependencies(klass, vcs):
		"""Check if the dependencies for the requested vcs are ok
		@param vcs: the requested vcs: VCS.BZR, VCS.GIT, VCS.HG or VCS.FOSSIL
		@returns: C{True} if dependencies are checked ok.
		"""
		return VCS.get_backend(vcs).tryexec()


class VCSApplicationBase(ConnectorMixin):
	"""This class is the base class for the classes representing the
	specific version control applications.
	"""

	def __init__(self, vcs_dir, notebook_dir):
		"""Constructor.
		@param vcs_dir: a L{Dir} instance representing the VCS root folder
		@param notebook_dir: a L{Dir} instance representing the notebook root folder
		(must be equal to or below vcs_dir)
		"""
		assert isinstance(vcs_dir, LocalFolder)

		if not (notebook_dir == vcs_dir or notebook_dir.ischild(vcs_dir)):
			raise AssertionError('Notebook %s is not part of version control dir %s' % (notebook_dir, vcs_dir))
		self._app = self.build_bin_application_instance()
		self.root = vcs_dir
		self.notebook_dir = notebook_dir

		if notebook_dir.watcher is None:
			from zim.newfs.helpers import FileTreeWatcher
			notebook_dir.watcher = FileTreeWatcher()

		self.connectto_all(
			notebook_dir.watcher,
			('created', 'moved', 'removed')
		)

	def on_created(self, fs, path):
		"""Callback when a file has been created
		@param fs: the watcher object
		@param path: a L{File} or L{Folder} object
		"""
		if path.ischild(self.root) and not self._ignored(path):
			self.add(path)

	def on_moved(self, fs, oldpath, newpath):
		"""Callback when a file has been moved
		@param fs: the watcher object
		@param oldpath: a L{File} or L{Folder} object
		@param newpath: a L{File} or L{Folder} object
		"""
		if newpath.ischild(self.root) and not self._ignored(newpath):
			if oldpath.ischild(self.root):
				# Parent of newpath needs to be versioned in order to make mv succeed
				self.move(oldpath, newpath)
			else:
				self.add(newpath)
		elif oldpath.ischild(self.root) and not self._ignored(oldpath):
			self.on_path_deleted(self, fs, oldpath)

	def on_removed(self, fs, path):
		"""Callback when a file has been delted
		@param fs: the watcher object
		@param path: a L{File} or L{Folder} object
		"""
		if path.ischild(self.root) and not self._ignored(path):
			self.remove(path)

	@classmethod
	def build_bin_application_instance(cls):
		"""Builds an L{Application} object for the backend command
		@returns: an L{Application} object
		@implementation: must be implemented in child classes.
		"""
		raise NotImplementedError

	@classmethod
	def tryexec(cls):
		"""Check if the command associated with the backend is available.
		@returns: C{True} if the command is available
		"""
		return cls.build_bin_application_instance().tryexec()

	def run(self, params):
		"""Execute a command with the associated binary with 'params' parameters.
		Note: the working directory is the root associated to the repository

		@param params: a list of parameters to be added to the command line
		@returns: nothing
		@implementation: should not be overriden by child classes
		"""
		self._app.run(params, self.root)

	def pipe(self, params):
		"""Execute a command with the associated binary with 'params' parameters
		and return the command line output.
		@param params: a list of parameters to be added to the command line
		@returns: a list of str() representing each line of the output
		@implementation: should not be overriden by child classes
		"""
		return self._app.pipe(params, self.root)

	def _ignored(self, file):
		"""return True if the file should be ignored by the version control system
		@param file: a L{File} representing the file that we want to know if it should be ignored
		@returns: C{True} if the file should be ignored by the VCS.
		@implementation: may be overridden if some files are to be ignored \
		                 specifically for the backend
		"""
		return '.zim' in file.pathnames

	########
	#
	# NOW ARE ALL REVISION CONTROL SYSTEM SHORTCUTS

	def add(self, file=None):
		"""Add a file to the repository. If None, then the add must be for the \
		entire repository
		@param file: a L{File} instance representing the file to add.
		@returns: C{True} if the command was successfull

		Exemple: for Mercurial, the implementation will run "hg add <file>" or
		"hg add" if file=None (meaning the entire repository

		@implementation: must be implemented in child classes
		"""
		raise NotImplementedError

	def annotate(self, file, version=None):
		"""return the annotated version of a file. This is commonly related
		to the VCS command annotate

		@param file: a L{File} instance representing the file
		@param version: a  None/int/str representing the revision of the file
		@returns: a list of lines representing the command result output

		Eg. for mercurial, it will return something like:
		  0: line1
		  2: line1
		  ...

		@implementation: must be implemented in child classes
		"""
		raise NotImplementedError

	def cat(self, file, version):
		"""Return the context of a file at a specific version
		@param file: a L{File} object in this repository
		@param version: a version id
		@returns: a list of lines
		"""
		raise NotImplementedError

	def commit_version(self, msg):
		"""Run a commit operation.

		@param msg: commit message (str)
		@returns: nothing
		"""
		if self.is_modified():
			self.add()
			self.commit(None, msg)
		else:
			raise NoChangesError(self.root)

	def commit(self, file, msg):
		"""Execute a commit for the file or for the entire repository
		@param file: a L{File} instance representing the file or None for the entire repository
		@param msg: a str() representing the commit message
		@returns: C{True} if the command was successfull

		Example for Mercurial. It will run:
		- hg commit -m <msg> <file>    (file not None and message not empty
		- hg commit -m <msg>           (file=None and message not empty)
		- hg commit <file>             (file not None and message empty)
		- hg commit                    (file=None and message empty)

		@implementation: must be implemented in child class
		"""
		raise NotImplementedError

	def diff(self, versions=None, file=None):
		"""Returns the result of a diff between two revisions as a list of str()
		representing the diff operation output.
		@param versions: int, str, couple or tuple representing the versions to compare
		@param file: a L{File} instance representing the file, or None
		@returns: a list of str() representing the output of the diff operation

		Example for Mercurial. It could run:
		- hg diff --git <version1> <version2> <file>
		- hg diff --git <version1> <file>
		- ...

		Note: the --git option allow to show the result a better way
		@implementation: must be implemented in child class
		"""
		raise NotImplementedError

	def ignore(self, file_to_ignore_regexp):
		"""initialize the .XXignore file used by the VCS.

		@param file_to_ignore_regexp: a str() representing the content of the \
		                              .XXignore file. It's commonly a set of
		                              regular expressions separated by a line end.

		Note: the behavior is to overwrite the entire file content, so you must
		first concatenate the regexp if you need several.
		@returns: nothing

		@implementation: must be implemented in child class. The ignore file may
		                 be easyly created by running simply the following line
		                 of code:
		                 self.root.file('nameoftheignorefile').write(file_to_ignore_regexp)

		Example: for Mercurial, the content of the method is:
		  self.root.file('.hgignore').write(file_to_ignore_regexp)
		"""
		#TODO: append the rule instead of overwrite the full content
		raise NotImplementedError


	def init_repo(self):
		"""initialize a repository in the associated folder.
		Runs L{init()}, adds existing files etc.
		@returns: nothing.
		@implementation: must be implemented in the child class.
		"""
		raise NotImplementedError

	def repo_exists(self):
		"""Returns True if a repository is already setup
		@returns: C{True} if a repository is already setup in the root directory
		@implementation: must be implemented in child classes.
		"""
		raise NotImplementedError

	def init(self):
		""" runs the VCS init command
		@returns: C{True} if the command was successfull
		@implementation: must be implemented in child class

		Example: for mercurial or bazaar it runs "hg init" (or "bzr init")
		"""
		raise NotImplementedError

	def is_modified(self):
		"""Returns True if the repo is not up-to-date, or False
		@returns: C{True} if the repo is not up-to-date, or False
		@implementation: must be implemented in the child class.
		"""
		raise NotImplementedError

	def list_versions(self, file=None):
		"""Returns a list of all versions, for a file or for the entire repo

		@param file: a L{File} object representing the path to the file, or None
		@returns: a list of tuples (revision (int), date, user (str), msg (str))
		"""
		# TODO see if we can get this directly from bzrlib as well
		lines = self.log(file)
		versions = self.log_to_revision_list(lines)
		return versions

	def log(self, file=None):
		"""Returns the history related to a file.
		@param file: a L{File} instance representing the file or None (for the entire repository)
		@returns: a list of str() representing the output of the command. (not parsed)
		@implementation: must be implemented in child class. It must return the \
		                 output for a file or for the entire repository, and the \
		                 order must be from the oldest to the newest commits
		"""
		raise NotImplementedError

	def log_to_revision_list(self, log_op_output):
		"""Converts the result of a log() call into a list of tuples representing \
		the commits.
		@param log_op_output: a list of str() representing the log operation output
		                      before being parsed.
		@returns: a list of tuple (revision-id, date, user, commit-message) \
		          representing the entire life.
		@implementation: must be implemented in the child class. \
		                 Actually, this method is a "log" operation parser which
		                 will convert str lines into list of 4-str tuples :
		                 (revision-id, date, user, commit-message)
		"""
		raise NotImplementedError

	def move(self, oldfile, newfile):
		"""Must implement the VCS operation required after a file has been moved
		into the repository.

		Note: this is only for files being move from somewhere in the repository
		to somewhere in the repository
		@param oldfile: a L{File} representing the old location of the file
		@param newfile: a L{File} representing the new location of the file
		@returns : C{True} if the VCS operation representing this move was successfull
		@implementation: must be implemented in child class. \
		                 CAUTION: this must not implement a move operation but \
		                 a "a file has moved on the filesystem" operation, \
		                 ordering the VCS to take into account the new state.

		                 Example: with mercurial, it is implemented by running:
		                   hg mv --after <oldfile> <newfile>
		"""
		raise NotImplementedError


	def remove(self, file):
		"""Remove a file from the repository.
		@param file: a L{File} instance representing the file that have been deleted
		@returns: C{True} if the command was successfull
		@implementation: must be implemented in child class. \
		                 CAUTION: this must implement the VCS operation required \
		                 after a versionned file has been deleted from the file system. \
		                 \
		                 Example: in mercurial it has been implemented with:
		                   hg rm <file>
		"""
		raise NotImplementedError

	def revert(self, file=None, version=None):
		"""Reverts a file to an older version
		@param file: a L{File} instance representing the file or None for the entire repo
		@param version: a str() or int() representing the expected version
		@returns: C{True} if the command was successfull
		@implementation: must be implemented in child class

		Example: in mercurial it will run:
		- hg revert --no-backup <file> <version>     if file is not None
		- hg revert --no-backup --all                if file=None
		"""
		raise NotImplementedError

	def stage(self):
		"""Prepares the repo for a commit. Used, for example, by git to stage changes so that the status message in SaveVersionDialog shows what will be committed.

		@implementation: optional to be implemented in child class
		"""
		pass

	def status(self):
		"""Returns the status of the repository
		@returns: a list of str() representing the output of a "status" command
		related to the repository
		@implementation: must be implemented in child classes
		"""
		raise NotImplementedError


def get_side_by_side_app():
	for dir in data_dirs('helpers/compare_files/'):
		for file in dir.list_files():
			if file.basename.endswith('.desktop'):
				app = DesktopEntryFile(file)
				if app.tryexec():
					return app
	else:
		return None


class VersionControlInitDialog(Dialog):

	def __init__(self, parent):
		Dialog.__init__(self, parent, _("Enable Version Control?")) # T: Question dialog
		self.add_text(
			_("Version control is currently not enabled for this notebook.\n"
			  "Do you want to enable it?") # T: Detailed question
		)

		self.combobox = Gtk.ComboBoxText()
		for option in (VCS.BZR, VCS.GIT, VCS.HG, VCS.FOSSIL):
			if VCS.check_dependencies(option):
				self.combobox.append_text(option)
		self.combobox.set_active(0)

		hbox = Gtk.Box(spacing=5)
		hbox.add(Gtk.Label(_('Backend') + ':'))
		hbox.add(self.combobox)
			# T: option to chose versioncontrol backend
		hbox.set_halign(Gtk.Align.CENTER)
		self.vbox.pack_start(hbox, False, False, 0)
		hbox.show_all()

	def do_response_ok(self):
		self.result = self.combobox.get_active_text()
		return True


class SaveVersionDialog(Dialog):

	def __init__(self, parent, window_ext, vcs):
		Dialog.__init__(
			self,
			parent,
			_('Save Version'), # T: dialog title
			button=_('_Save'), # T: button label
			help='Plugins:Version Control'
		)
		self.window_ext = window_ext
		self.vcs = vcs

		label = Gtk.Label(_("Please enter a comment for this version"))  # T: Dialog text
		self.vbox.pack_start(label, False, True, 0)

		vpaned = VPaned()
		self.vbox.pack_start(vpaned, True, True, 0)

		window, self.textview = ScrolledTextView(_('Saved version from zim'))
			# T: default version comment in the "save version" dialog
		self.textview.set_editable(True)
		vpaned.add1(window)

		vbox = Gtk.VBox()
		vpaned.add2(vbox)

		label = Gtk.Label(label='<b>' + _('Details') + '</b>')
			# T: section for version details in "save version" dialog
		label.set_use_markup(True)
		label.set_alignment(0, 0.5)
		vbox.pack_start(label, False, True, 0)

		self.vcs.stage()
		status = self.vcs.status()
		window, textview = ScrolledTextView(text=''.join(status), monospace=True)
		vbox.add(window)

	def do_response_ok(self):
		buffer = self.textview.get_buffer()
		start, end = buffer.get_bounds()
		msg = start.get_text(end).strip()
		if not msg:
			return False

		op, thread = self.window_ext._commit_op(msg)
		ProgressDialog(self, op).run()
		return True


class VersionsDialog(Dialog):

	def __init__(self, parent, vcs, notebook, page=None):
		Dialog.__init__(self, parent, _('Versions'), # T: dialog title
			buttons=Gtk.ButtonsType.CLOSE, help='Plugins:Version Control')
		self.notebook = notebook
		self.vcs = vcs
		self._side_by_side_app = get_side_by_side_app()

		self.uistate.setdefault('windowsize', (600, 500), check=value_is_coord)
		self.uistate.setdefault('vpanepos', 300)

		self.vpaned = VPaned()
		self.vpaned.set_position(self.uistate['vpanepos'])
		self.vbox.pack_start(self.vpaned, True, True, 0)

		vbox = Gtk.VBox(spacing=5)
		self.vpaned.pack1(vbox, resize=True)

		# Choice between whole notebook or page
		label = Gtk.Label(label='<b>' + _('Versions') + ':</b>') # section label
		label.set_use_markup(True)
		label.set_alignment(0, 0.5)
		vbox.pack_start(label, False, True, 0)

		self.notebook_radio = Gtk.RadioButton.new_with_mnemonic_from_widget(None, _('Complete _notebook'))
			# T: Option in versions dialog to show version for complete notebook
		self.page_radio = Gtk.RadioButton.new_with_mnemonic_from_widget(self.notebook_radio, _('_Page') + ':')
			# T: Option in versions dialog to show version for single page
		#~ recursive_box = Gtk.CheckButton.new_with_mnemonic('Recursive')
		vbox.pack_start(self.notebook_radio, False, True, 0)

		# Page entry
		hbox = Gtk.HBox(spacing=5)
		vbox.pack_start(hbox, False, True, 0)
		hbox.pack_start(self.page_radio, False, True, 0)
		self.page_entry = PageEntry(self.notebook)
		if page:
			self.page_entry.set_path(page)
		hbox.pack_start(self.page_entry, False, True, 0)

		# View annotated button
		ann_button = Gtk.Button.new_with_mnemonic(_('View _Annotated')) # T: Button label
		ann_button.connect('clicked', lambda o: self.show_annotated())
		hbox.pack_start(ann_button, False, True, 0)

		# Help text
		label = Gtk.Label(label='<i>\n' + _( '''\
Select a version to see changes between that version and the current
state. Or select multiple versions to see changes between those versions.
''' ).strip() + '</i>') # T: Help text in versions dialog
		label.set_use_markup(True)
		#~ label.set_alignment(0, 0.5)
		vbox.pack_start(label, False, True, 0)

		# Version list
		self.versionlist = VersionsTreeView()
		self.versionlist.load_versions(vcs.list_versions())
		scrolled = ScrolledWindow(self.versionlist)
		vbox.add(scrolled)

		col = self.uistate.setdefault('sortcol', self.versionlist.REV_SORT_COL)
		order = self.uistate.setdefault('sortorder', Gtk.SortType.DESCENDING)
		try:
			self.versionlist.get_model().set_sort_column_id(col, order)
		except:
			logger.exception('Invalid sort column: %s %s', col, order)

		# -----
		vbox = Gtk.VBox(spacing=5)
		self.vpaned.pack2(vbox, resize=False)

		label = Gtk.Label(label='<b>' + _('Comment') + '</b>') # T: version details
		label.set_use_markup(True)
		label.set_alignment(0.0, 0.5)
		vbox.pack_start(label, False, True, 0)

		# Comment text
		window, textview = ScrolledTextView()
		self.comment_textview = textview
		vbox.add(window)

		buttonbox = Gtk.HButtonBox()
		buttonbox.set_layout(Gtk.ButtonBoxStyle.END)
		vbox.pack_start(buttonbox, False, True, 0)

		# Restore version button
		revert_button = Gtk.Button.new_with_mnemonic(_('_Restore Version')) # T: Button label
		revert_button.connect('clicked', lambda o: self.restore_version())
		buttonbox.add(revert_button)

		# Notebook Changes button
		diff_button = Gtk.Button.new_with_mnemonic(_('Show _Changes'))
			# T: button in versions dialog for diff
		diff_button.connect('clicked', lambda o: self.show_changes())
		buttonbox.add(diff_button)

		# Compare page button
		comp_button = Gtk.Button.new_with_mnemonic(_('_Side by Side'))
			# T: button in versions dialog for side by side comparison
		comp_button.connect('clicked', lambda o: self.show_side_by_side())
		buttonbox.add(comp_button)


		# UI interaction between selections and buttons

		def on_row_activated(o, iter, path):
			model = self.versionlist.get_model()
			comment = model[iter][VersionsTreeView.MSG_COL]
			buffer = textview.get_buffer()
			buffer.set_text(comment)

		self.versionlist.connect('row-activated', on_row_activated)


		def on_ui_change(o):
			usepage = self.page_radio.get_active()
			self.page_entry.set_sensitive(usepage)
			ann_button.set_sensitive(usepage)

			# side by side comparison can only be done for one page
			# revert can only be done to one version, not multiple
			selection = self.versionlist.get_selection()
			model, rows = selection.get_selected_rows()
			if not rows:
				revert_button.set_sensitive(False)
				diff_button.set_sensitive(False)
				comp_button.set_sensitive(False)
			elif len(rows) == 1:
				revert_button.set_sensitive(usepage)
				diff_button.set_sensitive(True)
				comp_button.set_sensitive(bool(usepage and self._side_by_side_app))
			else:
				revert_button.set_sensitive(False)
				diff_button.set_sensitive(True)
				comp_button.set_sensitive(bool(usepage and self._side_by_side_app))

		def on_page_change(o):
			pagesource = self._get_file()
			if pagesource:
				self.versionlist.load_versions(vcs.list_versions(self._get_file()))

		def on_book_change(o):
			self.versionlist.load_versions(vcs.list_versions())

		self.page_radio.connect('toggled', on_ui_change)
		self.notebook_radio.connect('toggled', on_book_change)
		self.page_radio.connect('toggled', on_page_change)
		self.page_entry.connect('changed', on_page_change)
		selection = self.versionlist.get_selection()
		selection.connect('changed', on_ui_change)

		# select last version
		self.versionlist.get_selection().select_path((0,))
		col = self.versionlist.get_column(0)
		self.versionlist.row_activated(Gtk.TreePath((0,)), col)

	def save_uistate(self):
		self.uistate['vpanepos'] = self.vpaned.get_position()

		col, order = self.versionlist.get_model().get_sort_column_id()
		self.uistate['sortcol'] = col
		self.uistate['sortorder'] = order

	def _get_file(self):
		if self.notebook_radio.get_active():
			return None
		else:
			path = self.page_entry.get_path()
			if path:
				page = self.notebook.get_page(path)
			else:
				return None # TODO error message valid page name?

			if page \
			and page.source_file is not None \
			and page.source_file.ischild(self.vcs.root):
				return page.source_file
			else:
				return None # TODO error message ?

	def show_annotated(self):
		# TODO check for gannotated
		file = self._get_file()
		assert not file is None
		annotated = self.vcs.annotate(file)
		TextDialog(self, _('Annotated Page Source'), annotated).run()
			# T: dialog title

	def restore_version(self):
		file = self._get_file()
		path = self.page_entry.get_path()
		version = self.versionlist.get_versions()[0]
		assert not file is None
		if QuestionDialog(self, (
			_('Restore page to saved version?'), # T: Confirmation question
			_('Do you want to restore page: %(page)s\n'
			  'to saved version: %(version)s ?\n\n'
			  'All changes since the last saved version will be lost !')
			  % {'page': path.name, 'version': str(version)}
			  # T: Detailed question, "%(page)s" is replaced by the page, "%(version)s" by the version id
		)).run():
			self.vcs.revert(file, version)
			page = self.notebook.get_page(path)
			page.check_source_changed()

	def show_changes(self):
		# TODO check for gdiff
		file = self._get_file()
		versions = self.versionlist.get_versions()
		diff = self.vcs.diff(file=file, versions=versions) or ['=== No Changes\n']
		TextDialog(self, _('Changes'), diff).run()
			# T: dialog title

	def show_side_by_side(self):
		file = self._get_file()
		versions = self.versionlist.get_versions()
		if not (file and versions):
			raise AssertionError

		files = [self._get_tmp_file(file, v) for v in versions]
		if len(files) == 1:
			tmp = TmpFile(file.basename + '--CURRENT', persistent=True)
				# need to be persistent, else it is cleaned up before application spawned
			tmp.writelines(file.readlines())
			files.insert(0, tmp)

		self._side_by_side_app.spawn(files)

	def _get_tmp_file(self, file, version):
		text = self.vcs.cat(file, version)
		tmp = TmpFile(file.basename + '--REV%s' % version, persistent=True)
			# need to be persistent, else it is cleaned up before application spawned
		tmp.writelines(text)
		return tmp


class TextDialog(Dialog):

	def __init__(self, parent, title, lines):
		Dialog.__init__(self, parent, title, buttons=Gtk.ButtonsType.CLOSE)
		self.set_default_size(600, 300)
		self.uistate.setdefault('windowsize', (600, 500), check=value_is_coord)
		window, textview = ScrolledTextView(''.join(lines), monospace=True)
		self.vbox.pack_start(window, True, True, 0)


class VersionsTreeView(SingleClickTreeView):

	# We are on purpose _not_ a subclass of the BrowserTreeView widget
	# because we utilize multiple selection to select versions for diffs

	REV_SORT_COL = 0
	REV_COL = 1
	DATE_COL = 2
	USER_COL = 3
	MSG_COL = 4

	def __init__(self):
		model = Gtk.ListStore(str, str, str, str, str)
			# REV_SORT_COL, REV_COL, DATE_COL, USER_COL, MSG_COL
		GObject.GObject.__init__(self)
		self.set_model(model)

		self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
		self.set_rubber_banding(True)

		cell_renderer = Gtk.CellRendererText()
		for name, i in (
			(_('Rev'), self.REV_COL), # T: Column header versions dialog
			(_('Date'), self.DATE_COL), # T: Column header versions dialog
			(_('Author'), self.USER_COL), # T: Column header versions dialog
		):
			column = Gtk.TreeViewColumn(name, cell_renderer, text=i)
			if i == self.REV_COL:
				column.set_sort_column_id(self.REV_SORT_COL)
			else:
				column.set_sort_column_id(i)

			if i == self.DATE_COL:
				column.set_expand(True)

			self.append_column(column)

		model.set_sort_column_id(self.REV_SORT_COL, Gtk.SortType.DESCENDING)
			# By default sort by rev

	def load_versions(self, versions):
		model = self.get_model()
		model.clear() # Empty for when we update
		model.set_sort_column_id(self.REV_SORT_COL, Gtk.SortType.DESCENDING)
			# By default sort by rev

		for version in versions:
			#~ print version
			key = natural_sort_key(version[0]) # key for REV_SORT_COL
			model.append((key,) + tuple(version))

	def get_versions(self):
		model, rows = self.get_selection().get_selected_rows()
		if len(rows) == 1:
			rev = model[rows[0]][self.REV_COL]
			return (rev,)
		else:
			revs = [model[path][self.REV_COL] for path in rows]
			return (revs[0], revs[-1])
