Skip to content

Instantly share code, notes, and snippets.

@Erriez
Last active January 9, 2024 06:03
Show Gist options
  • Save Erriez/08f461cb8a8fc6872426f3b5f2e268ae to your computer and use it in GitHub Desktop.
Save Erriez/08f461cb8a8fc6872426f3b5f2e268ae to your computer and use it in GitHub Desktop.
Add Meld compare menu to Nautilus file manager on Ubuntu 22.04

Sources:

Install the nautilus python3 bindings

$ sudo apt-get install python3-nautilus

Download the source

# Original link:
$ wget https://launchpad.net/~boamaod/+archive/ubuntu/nautilus-compare/+sourcefiles/nautilus-compare/1.0.0~focal1/nautilus-compare_1.0.0~focal1.tar.xz

# Extract
$ tar -xvf nautilus-compare_1.0.0~focal1.tar.xz 

Install the source files

$ cd nautilus-compare-1.0.0/src
$ sudo cp nautilus-compare.py /usr/share/nautilus-python/extensions/

$ sudo mkdir /usr/share/nautilus-compare
$ sudo cp utils.py /usr/share/nautilus-compare
$ sudo cp nautilus-compare-preferences.py /usr/share/nautilus-compare

Restart nautilus

$ nautilus -q && nautilus &

Usage

  1. Open a directory in Nautilus
  2. Select two files
  3. Right mouse click on the selected files: Compare

This automatically opens Meld for file comparison:

image

#!/usr/bin/python3
# -*- coding: utf-8 -*-
# nautilus-compare --- Context menu extension for Nautilus file manager
# Copyright (C) 2011, 2015, 2020 Märt Põder <tramm@infoaed.ee>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject
import os
import gettext
import locale
import utils
def combo_add_and_select(combo, text):
'''Convenience function to add/selct item in ComboBoxEntry'''
combo.append_text(text)
model=combo.get_model()
iter=model.get_iter_first()
while model.iter_next(iter):
iter=model.iter_next(iter)
combo.set_active_iter(iter)
class NautilusCompareExtensionPreferences:
'''The main class for preferences dialog using PyGTK'''
combo = None
combo_3way = None
combo_multi = None
def cancel_event(self, widget, event, data = None):
'''This callback quits the program'''
Gtk.main_quit()
return False
def changed_cb(self, combo):
'''Any of the comboboxes has changed, change the data accordingly'''
model = combo.get_model()
index = combo.get_active()
entry = combo.get_child().get_text()
selection=""
if len(entry)>0:
selection=entry
elif index:
selection=model[index][0]
if combo is self.combo:
self.config.diff_engine = selection
elif combo is self.combo_3way:
self.config.diff_engine_3way = selection
elif combo is self.combo_multi:
self.config.diff_engine_multi = selection
return
def save_event(self, widget, event, data = None):
'''This callback saves the settings and quits the program.'''
self.config.save()
Gtk.main_quit()
return False
def __init__(self):
'''Load config and create UI'''
self.config = utils.NautilusCompareConfig()
self.config.load()
# find out if some new engines have been installed
self.config.add_missing_predefined_engines()
# initialize i18n
locale.setlocale(locale.LC_ALL, '')
gettext.bindtextdomain(utils.APP)
gettext.textdomain(utils.APP)
_ = gettext.gettext
self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
self.window.set_resizable(False)
self.window.set_title(_("Nautilus Compare Extension Preferences"))
self.window.connect("delete_event",self.cancel_event)
self.window.set_border_width(15)
main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.window.add(main_vbox)
# normal diff
frame = Gtk.Frame(label=_("Normal Diff"))
self.combo = Gtk.ComboBoxText.new_with_entry()
for text in self.config.engines:
if text == self.config.diff_engine:
combo_add_and_select(self.combo, text)
else:
self.combo.append_text(text)
self.combo.connect('changed', self.changed_cb)
frame.add(self.combo)
main_vbox.pack_start(frame, True, True, 0)
# 3-way diff
frame_3way = Gtk.Frame(label=_("Three-Way Diff"))
self.combo_3way = Gtk.ComboBoxText.new_with_entry()
for text in self.config.engines:
if text == self.config.diff_engine_3way:
combo_add_and_select(self.combo_3way, text)
else:
self.combo_3way.append_text(text)
self.combo_3way.connect('changed', self.changed_cb)
frame_3way.add(self.combo_3way)
main_vbox.pack_start(frame_3way, True, True, 0)
# n-way diff
frame_multi = Gtk.Frame(label=_("N-Way Diff"))
self.combo_multi = Gtk.ComboBoxText.new_with_entry()
for text in self.config.engines:
if text == self.config.diff_engine_multi:
combo_add_and_select(self.combo_multi, text)
else:
self.combo_multi.append_text(text)
self.combo_multi.connect('changed', self.changed_cb)
frame_multi.add(self.combo_multi)
main_vbox.pack_start(frame_multi, True, True, 0)
separator = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
main_vbox.pack_start(separator, False, True, 5)
# cancel / ok
confirm_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
main_vbox.pack_start(confirm_hbox, False, False, 0)
cancel_button = Gtk.Button.new_with_mnemonic(_("_Cancel"))
cancel_button.connect_object("clicked", self.cancel_event, self.window, None)
confirm_hbox.pack_start(cancel_button, True, True, 5)
ok_button = Gtk.Button.new_with_mnemonic(_("_Save"))
ok_button.connect_object("clicked", self.save_event, self.window, None)
confirm_hbox.pack_start(ok_button, True, True, 5)
self.window.show_all()
def main(self):
'''GTK main method'''
Gtk.main()
if __name__ == "__main__":
prefs = NautilusCompareExtensionPreferences()
prefs.main()
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# nautilus-compare --- Context menu extension for Nautilus file manager
# Copyright (C) 2011, 2015, 2020 Märt Põder <tramm@infoaed.ee>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import os
import urllib.request, urllib.parse, urllib.error
import gettext
import locale
from gi.repository import Nautilus, GObject, Gio
sys.path.insert(0, "/usr/share/nautilus-compare")
import utils
class NautilusCompareExtension(GObject.GObject, Nautilus.MenuProvider):
'''Class for the extension itself'''
# to hold an item for later comparison
for_later = None
def __init__(self):
'''Load config'''
GObject.Object.__init__(self)
self.config = utils.NautilusCompareConfig()
self.config.load()
def menu_activate_cb(self, menu, paths):
'''Telling from amount of paths runs appropriate comparator engine'''
if len(paths) == 1:
self.for_later = paths[0]
return
args = ""
for path in paths:
args += "\"%s\" " % path
cmd = None
if len(paths) == 2:
cmd = (self.config.diff_engine + " " + args + "&")
elif len(paths) == 3 and len(self.config.diff_engine_3way.strip()) > 0:
cmd = (self.config.diff_engine_3way + " " + args + "&")
elif len(self.config.diff_engine_multi.strip()) > 0:
cmd = (self.config.diff_engine_multi + " " + args + "&")
if cmd is not None:
os.system(cmd)
def valid_file(self, file):
'''Tests if the file is valid comparable'''
if file.get_uri_scheme() == 'file' and file.get_file_type() in (Gio.FileType.DIRECTORY, Gio.FileType.REGULAR, Gio.FileType.SYMBOLIC_LINK):
return True
elif self.config.diff_engine in utils.URI_COMPAT_ENGINES and file.get_location().get_path() is not None and file.get_file_type() in (Gio.FileType.DIRECTORY, Gio.FileType.REGULAR, Gio.FileType.SYMBOLIC_LINK):
return True
else:
return False
def get_file_items(self, window, files):
'''Main method to detect what choices should be offered in the context menu'''
paths = []
for file in files:
if self.valid_file(file):
if self.config.diff_engine in utils.URI_COMPAT_ENGINES:
path = urllib.parse.unquote(file.get_uri())
else:
path = urllib.parse.unquote(file.get_uri()[7:])
paths.append(path)
# no files selected
if len(paths) < 1:
return
# initialize i18n
locale.setlocale(locale.LC_ALL, '')
gettext.bindtextdomain(utils.APP)
gettext.textdomain(utils.APP)
_ = gettext.gettext
item1 = None
item2 = None
item3 = None
# for paths with remembered items
new_paths = list(paths)
# exactly one file selected
if len(paths) == 1:
# and one was already selected for later comparison
if self.for_later is not None:
# we don't want to compare file to itself
if self.for_later not in paths:
item1 = Nautilus.MenuItem(
name="NautilusCompareExtension::CompareTo",
label=_('Compare to ') + utils.prepare_for_menu(self.for_later),
tip=_("Compare to the file remembered before")
)
# compare the one saved for later to the one selected now
new_paths.insert(0, self.for_later)
# if only one file selected, we offer to remember it for later anyway
item3 = Nautilus.MenuItem(
name="NautilusCompareExtension::CompareLater",
label=_('Compare Later'),
tip=_("Remember file for later comparison")
)
# can always compare, if more than one selected
else:
# if we have already remembered one file and add some more, we can do n-way compare
if self.for_later is not None:
if self.for_later not in paths:
# if multi compare enabled and in case of 2 files selected 3way compare enabled
if len(self.config.diff_engine_multi.strip()) > 0 or (len(paths) == 2 and len(self.config.diff_engine_3way.strip()) > 0):
item1 = Nautilus.MenuItem(
name="NautilusCompareExtension::MultiCompare",
label=_('Compare to ') + utils.prepare_for_menu(self.for_later),
tip=_("Compare selected files to the file remembered before")
)
# compare the one saved for later to the ones selected now
new_paths.insert(0, self.for_later)
# if multi compare enabled, we can compare any number
# if there are two files selected we can always compare
# if three files selected and 3-way compare is on, we can do it
if len(self.config.diff_engine_multi.strip()) > 0 or len(paths) == 2 or (len(paths) == 3 and len(self.config.diff_engine_3way.strip()) > 0):
item2 = Nautilus.MenuItem(
name="NautilusCompareExtension::CompareWithin",
label=_('Compare'),
tip=_("Compare selected files")
)
if item1: item1.connect('activate', self.menu_activate_cb, new_paths)
if item2: item2.connect('activate', self.menu_activate_cb, paths)
if item3: item3.connect('activate', self.menu_activate_cb, paths)
items = [item1, item2, item3]
while None in items:
items.remove(None)
return items
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# nautilus-compare --- Context menu extension for Nautilus file manager
# Copyright (C) 2011, 2015, 2020 Märt Põder <tramm@infoaed.ee>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import xdg.BaseDirectory
import configparser
APP = 'nautilus-compare'
# settings
CONFIG_FILES = [os.path.join(xdg.BaseDirectory.xdg_config_home, APP + ".conf"), os.path.join("/etc", APP + ".conf")]
CONFIG_FILE = CONFIG_FILES[0]
SETTINGS_MAIN = 'Settings'
DIFF_PATH = 'diff_engine_path'
DIFF_PATH_3WAY = 'diff_engine_path_3way'
DIFF_PATH_MULTI = 'diff_engine_path_multi'
COMPARATORS = 'defined_comparators'
# ordered by preference
PREDEFINED_ENGINES = ['meld', 'kdiff3', 'diffuse', 'kompare', 'fldiff', 'tkdiff', 'xxdiff']
URI_COMPAT_ENGINES = ['meld']
DEFAULT_DIFF_ENGINE = 'meld'
# where comparator engines are sought
COMPARATOR_PATHS = ['/usr/bin', '/usr/local/bin']
def prepare_for_menu(item):
'''Formats file name URI to be compared for less garbage and easier readability.'''
prep = item.replace("_", "__") # escaping the underscores to avoid accelerators
prep = prep.split("file://").pop() # remove default file descriptor for less garbage
return prep
class NautilusCompareConfig:
diff_engine = DEFAULT_DIFF_ENGINE
diff_engine_3way = DEFAULT_DIFF_ENGINE
diff_engine_multi = ""
engines = []
config = None
def load(self):
'''Loads config options if available. If not, creates them using the best heuristics availabe.'''
self.config = configparser.ConfigParser()
# allow system-wide default settings from /etc/*
if os.path.isfile(CONFIG_FILES[0]):
self.config.read(CONFIG_FILES[0])
else:
self.config.read(CONFIG_FILES[1])
# read from start or flush from the point where cancelled
try:
self.diff_engine = self.config.get(SETTINGS_MAIN, DIFF_PATH)
self.diff_engine_3way = self.config.get(SETTINGS_MAIN, DIFF_PATH_3WAY)
self.diff_engine_multi = self.config.get(SETTINGS_MAIN, DIFF_PATH_MULTI)
self.engines = eval(self.config.get(SETTINGS_MAIN, COMPARATORS))
except (configparser.NoOptionError, configparser.NoSectionError):
# maybe settings were half loaded when exception was thrown
try:
self.config.add_section(SETTINGS_MAIN)
except configparser.DuplicateSectionError:
pass
self.add_missing_predefined_engines()
# add choice for "engine not enabled"
# (always needed, because at least self.engines cannot be loaded)
self.engines.insert(0, "")
# if default engine is not installed, replace with preferred installed engine
if len(self.engines) > 0:
if self.diff_engine not in self.engines:
self.diff_engine = self.engines[0]
if self.diff_engine_3way not in self.engines:
self.diff_engine_3way = self.engines[0]
if self.diff_engine_multi not in self.engines:
self.diff_engine_multi = self.engines[0]
self.engines.sort()
self.config.set(SETTINGS_MAIN, DIFF_PATH, self.diff_engine)
self.config.set(SETTINGS_MAIN, DIFF_PATH_3WAY, self.diff_engine_3way)
self.config.set(SETTINGS_MAIN, DIFF_PATH_MULTI, self.diff_engine_multi)
self.config.set(SETTINGS_MAIN, COMPARATORS, str(self.engines))
with open(CONFIG_FILE, 'w') as f:
self.config.write(f)
def add_missing_predefined_engines(self):
'''Adds predefined engines which are installed, but missing in engines list.'''
system_utils = []
for path in COMPARATOR_PATHS:
system_utils += os.listdir(path)
for engine in PREDEFINED_ENGINES:
if engine not in self.engines and engine in system_utils:
self.engines.append(engine)
def save(self):
'''Saves config options'''
try:
self.config.add_section(SETTINGS_MAIN)
except configparser.DuplicateSectionError:
pass
self.config.set(SETTINGS_MAIN, DIFF_PATH, self.diff_engine)
self.config.set(SETTINGS_MAIN, DIFF_PATH_3WAY, self.diff_engine_3way)
self.config.set(SETTINGS_MAIN, DIFF_PATH_MULTI, self.diff_engine_multi)
if self.diff_engine not in self.engines:
self.engines.append(self.diff_engine)
if self.diff_engine_3way not in self.engines:
self.engines.append(self.diff_engine_3way)
if self.diff_engine_multi not in self.engines:
self.engines.append(self.diff_engine_multi)
self.config.set(SETTINGS_MAIN, COMPARATORS, str(self.engines))
with open(CONFIG_FILE, 'w') as f:
self.config.write(f)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment