# coding: utf-8
# python 2 only

# Copyright (c) 2026 TormachTips.com. All rights reserved.
# Licensed under the TormachTips Personal Use License.
# Permission is granted only for private personal use and private personal modification.
# No sharing, publication, distribution, resale, sublicensing, screenshots, code excerpts,
# benchmarks, or videos are permitted without prior written permission.
# Requests:         tormach.1100m@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#######################################
##                                   ##
##   File Chooser Edit Button 0.97   ##
##        www.tormachtips.com        ##
##                                   ##
#######################################

# 0.97 - Added current folder full path display beside the file chooser buttons.                                            - 5/12/2026
# 0.96 - Added shared file lock, chronological backups, staged writes, asset copy checks, and verbose patch debug logging.  - 5/02/2026
# 0.95 - Public beta.                                                                                                       - 5/02/2026

import os
import re
import time
import fcntl
import shutil
import gtk
import glib
import constants
from ui_hooks import plugin, version_list

CURRENT_VER                  = "0.97"
SCRIPT_NAME                  = "File Chooser Edit Button Patcher"
DESCRIPTION                  = "Adds an Edit button and current folder path display to the PathPilot file chooser toolbar."

ENABLED                      = 1
DEV_MACHINE                  = 1
DEV_MACHINE_FLAG             = "/home/operator/gcode/python/dev_machine.txt"

SOURCE_IMAGE_PATH            = "/home/operator/gcode/python/edit-gcode-button3.png"
IMAGE_FILENAME               = "edit-gcode-button3.png"

GLADE_MARKER                 = "<!-- BEAGLE: FILE CHOOSER EDIT BUTTON GLADE PATCH INSTALLED -->"
GLADE_PATCH_START            = "<!-- BEAGLE: FILE CHOOSER EDIT BUTTON GLADE PATCH START -->"
GLADE_PATCH_END              = "<!-- BEAGLE: FILE CHOOSER EDIT BUTTON GLADE PATCH END -->"

GLADE_PATH_MARKER            = "<!-- BEAGLE: FILE CHOOSER CURRENT FOLDER PATH PATCH INSTALLED -->"
GLADE_PATH_START             = "<!-- BEAGLE: FILE CHOOSER CURRENT FOLDER PATH PATCH START -->"
GLADE_PATH_END               = "<!-- BEAGLE: FILE CHOOSER CURRENT FOLDER PATH PATCH END -->"

PY_SETUP_MARKER              = "# BEAGLE: FILE CHOOSER EDIT BUTTON SETUP PATCH INSTALLED"
PY_SETUP_START               = "# BEAGLE: FILE CHOOSER EDIT BUTTON SETUP PATCH START"
PY_SETUP_END                 = "# BEAGLE: FILE CHOOSER EDIT BUTTON SETUP PATCH END"

PY_HANDLER_MARKER            = "# BEAGLE: FILE CHOOSER EDIT BUTTON HANDLER PATCH INSTALLED"
PY_HANDLER_START             = "# BEAGLE: FILE CHOOSER EDIT BUTTON HANDLER PATCH START"
PY_HANDLER_END               = "# BEAGLE: FILE CHOOSER EDIT BUTTON HANDLER PATCH END"

PY_PATH_SETUP_MARKER         = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH SETUP PATCH INSTALLED"
PY_PATH_SETUP_START          = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH SETUP PATCH START"
PY_PATH_SETUP_END            = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH SETUP PATCH END"

PY_PATH_METHOD_MARKER        = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH METHOD PATCH INSTALLED"
PY_PATH_METHOD_START         = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH METHOD PATCH START"
PY_PATH_METHOD_END           = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH METHOD PATCH END"

PY_PATH_INIT_MARKER          = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH INIT PATCH INSTALLED"
PY_PATH_INIT_START           = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH INIT PATCH START"
PY_PATH_INIT_END             = "# BEAGLE: FILE CHOOSER CURRENT FOLDER PATH INIT PATCH END"

PY_FOLDER_HANDLER_MARKER     = "# BEAGLE: FILE CHOOSER CURRENT FOLDER HANDLER PATCH INSTALLED"
PY_FOLDER_HANDLER_START      = "# BEAGLE: FILE CHOOSER CURRENT FOLDER HANDLER PATCH START"
PY_FOLDER_HANDLER_END        = "# BEAGLE: FILE CHOOSER CURRENT FOLDER HANDLER PATCH END"

GLADE_FILE                   = os.path.join("python", "images", "file_chooser.glade")
UTIL_FILE                    = os.path.join("python", "tormach_file_util.py")
IMAGE_FILE                   = os.path.join("python", "images", IMAGE_FILENAME)

PATCH_LOCK_PATH              = "/tmp/tt_pathpilot_file_patch.lock"
PATCH_LOCK_TIMEOUT_SECONDS   = 30.0

class TTFilePatchLock(object):
    def __init__(self, owner, error_handler, lock_path, timeout_seconds):
        self.owner = owner
        self.error_handler = error_handler
        self.lock_path = lock_path
        self.timeout_seconds = timeout_seconds
        self.fp = None

    def write(self, message):
        try:
            self.error_handler.write("[%s] %s" % (self.owner, message), constants.ALARM_LEVEL_QUIET)
        except:
            pass

    def __enter__(self):
        start_time = time.time()
        self.write("Opening patch lock file: %s" % self.lock_path)
        self.fp = open(self.lock_path, "a+")
        self.write("Waiting for patch lock: %s" % self.lock_path)
        while True:
            try:
                fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                self.write("Patch lock acquired: %s" % self.lock_path)
                return self
            except IOError:
                elapsed = time.time() - start_time
                if elapsed >= self.timeout_seconds:
                    self.write("Patch lock timeout after %.1f seconds: %s" % (elapsed, self.lock_path))
                    raise RuntimeError("Timed out waiting for patch lock: %s" % self.lock_path)
                time.sleep(0.1)

    def __exit__(self, exc_type, exc_value, traceback_obj):
        try:
            if self.fp:
                self.write("Releasing patch lock: %s" % self.lock_path)
                fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
                self.write("Patch lock released: %s" % self.lock_path)
        finally:
            try:
                if self.fp:
                    self.fp.close()
                    self.write("Closed patch lock file: %s" % self.lock_path)
            except:
                pass
        return False


class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, '%s %s' % (SCRIPT_NAME, CURRENT_VER))
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = DEV_MACHINE
        else:
            plugin_enabled = ENABLED
        if plugin_enabled:
            glib.timeout_add(3000, self.try_patch)
            return
        if dev_machine_found:
            self.error_handler.write("[%s] Dev machine found. Plugin loaded, but disabled by DEV_MACHINE." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
        else:
            self.error_handler.write("[%s] Plugin loaded, but is disabled." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
            self.error_handler.write("[%s] To enable it, open the script, find ENABLED = 0, and change it to ENABLED = 1." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)

    def debug(self, message):
        self.error_handler.write("[%s] %s" % (SCRIPT_NAME, message), constants.ALARM_LEVEL_QUIET)

    def show_success_dialog(self):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_INFO,
            gtk.BUTTONS_OK,
            "File chooser Edit button and folder path patch successfully applied.\n\n"
            "Please reboot or restart PathPilot for the change to take effect.")
        dialog.set_title(SCRIPT_NAME)
        dialog.set_keep_above(True)
        dialog.set_modal(False)

        def on_response(widget, response_id):
            widget.destroy()
        dialog.connect("response", on_response)
        dialog.show_all()

    def safe_script_name(self):
        safe_name = SCRIPT_NAME.lower()
        safe_name = safe_name.replace(" ", "_")
        safe_name = re.sub(r"[^a-z0-9_]+", "_", safe_name)
        return safe_name

    def get_paths(self):
        version_path = "v%d.%d.%d" % (version_list[0], version_list[1], version_list[2])
        version_root = os.path.join("/home/operator", version_path)
        python_dir = os.path.join(version_root, "python")
        images_dir = os.path.join(python_dir, "images")
        return {
            "version_root": version_root,
            "python_dir": python_dir,
            "images_dir": images_dir,
            "glade": os.path.join(version_root, GLADE_FILE),
            "util": os.path.join(version_root, UTIL_FILE),
            "image": os.path.join(version_root, IMAGE_FILE)
        }

    def read_file(self, path):
        self.debug("Reading file: %s" % path)
        with open(path, "r") as f:
            content = f.read()
        self.debug("Read %d bytes from file: %s" % (len(content), path))
        return content

    def make_chronological_backup(self, target_path):
        timestamp = time.strftime("%Y%m%d_%H%M%S")
        safe_name = self.safe_script_name()
        backup_path = "%s.%s.%s.bak" % (target_path, timestamp, safe_name)
        counter = 1
        final_path = backup_path
        while os.path.exists(final_path):
            final_path = "%s.%03d" % (backup_path, counter)
            counter += 1
        self.debug("Creating chronological backup.")
        self.debug("Backup source: %s" % target_path)
        self.debug("Backup target: %s" % final_path)
        shutil.copy2(target_path, final_path)
        self.debug("Backup complete: %s" % final_path)
        return final_path

    def staged_new_path(self, target_path):
        return "%s.%s.%d.new" % (
            target_path,
            self.safe_script_name(),
            os.getpid()
        )

    def staged_old_path(self, target_path):
        return "%s.%s.%d.old" % (
            target_path,
            self.safe_script_name(),
            os.getpid()
        )

    def write_staged_file(self, target_path, content):
        new_path = self.staged_new_path(target_path)
        self.debug("Writing staged patched file: %s" % new_path)
        with open(new_path, "w") as f:
            f.write(content)
        self.debug("Staged patched file written: %s" % new_path)
        return new_path

    def verify_staged_file(self, staged_path, marker):
        if not os.path.isfile(staged_path):
            raise RuntimeError("Staged file was not created: %s" % staged_path)
        content = self.read_file(staged_path)
        if marker not in content:
            raise RuntimeError("Staged file does not contain expected marker: %s" % staged_path)
        self.debug("Verified staged file marker: %s" % staged_path)

    def cleanup_staged_files(self, target_paths):
        for path in target_paths:
            for staged_path in (self.staged_new_path(path), self.staged_old_path(path)):
                try:
                    if os.path.exists(staged_path):
                        self.debug("Removing leftover staged file: %s" % staged_path)
                        os.remove(staged_path)
                except Exception as e:
                    self.debug("Could not remove staged file %s: %s" % (staged_path, str(e)))

    def restore_from_old_files(self, target_paths):
        for path in target_paths:
            old_path = self.staged_old_path(path)
            try:
                if os.path.exists(old_path):
                    self.debug("Restoring rollback file.")
                    self.debug("Restore source: %s" % old_path)
                    self.debug("Restore target: %s" % path)
                    if os.path.exists(path):
                        os.remove(path)
                    os.rename(old_path, path)
                    self.debug("Restore complete: %s" % path)
            except Exception as e:
                self.error_handler.write("[%s] ERROR: Rollback restore failed for %s: %s" % (SCRIPT_NAME, path, str(e)), constants.ALARM_LEVEL_HIGH)

    def swap_staged_files_into_place(self, target_paths, staged_paths):
        try:
            for path in target_paths:
                new_path = staged_paths[path]
                old_path = self.staged_old_path(path)
                self.debug("Moving current target to rollback path.")
                self.debug("Rollback source: %s" % path)
                self.debug("Rollback target: %s" % old_path)
                os.rename(path, old_path)
                self.debug("Moving staged patched file into target path.")
                self.debug("Patched source: %s" % new_path)
                self.debug("Patched target: %s" % path)
                os.rename(new_path, path)
                self.debug("Swap complete: %s" % path)
        except Exception:
            self.error_handler.write("[%s] ERROR: Swap failed. Attempting rollback." % SCRIPT_NAME, constants.ALARM_LEVEL_HIGH)
            self.restore_from_old_files(target_paths)
            raise
        for path in target_paths:
            old_path = self.staged_old_path(path)
            try:
                if os.path.exists(old_path):
                    self.debug("Removing rollback file after successful swap: %s" % old_path)
                    os.remove(old_path)
            except Exception as e:
                self.debug("Could not remove rollback file %s: %s" % (old_path, str(e)))

    def patch_glade_content(self, content):
        original_content = content
        object_text = '<object class="GtkEventBox" id="delete_button">'

        if GLADE_MARKER not in content:
            self.debug("Edit button Glade marker not found; applying edit button Glade patch.")
            object_index = content.find(object_text)
            if object_index < 0:
                raise RuntimeError("delete_button object was not found in file_chooser.glade")
            object_line_start = content.rfind("\n", 0, object_index) + 1
            child_line_start = content.rfind("\n", 0, object_line_start - 1) + 1
            child_line = content[child_line_start:object_line_start]
            if "<child>" not in child_line:
                raise RuntimeError("Could not locate delete_button outer child block")
            glade_insert = (
                "            %s\n"
                "            %s\n"
                "            <child>\n"
                "              <object class=\"GtkEventBox\" id=\"edit_button\">\n"
                "                <property name=\"width_request\">99</property>\n"
                "                <property name=\"height_request\">37</property>\n"
                "                <property name=\"visible\">True</property>\n"
                "                <property name=\"can_focus\">False</property>\n"
                "                <signal name=\"button-press-event\" handler=\"on_button_press_event\" swapped=\"no\"/>\n"
                "                <signal name=\"enter-notify-event\" handler=\"on_mouse_enter\" swapped=\"no\"/>\n"
                "                <signal name=\"leave-notify-event\" handler=\"on_mouse_leave\" swapped=\"no\"/>\n"
                "                <signal name=\"button-release-event\" handler=\"on_edit_button_release_event\" swapped=\"no\"/>\n"
                "                <child>\n"
                "                  <object class=\"GtkImage\" id=\"edit_button_image\">\n"
                "                    <property name=\"visible\">True</property>\n"
                "                    <property name=\"can_focus\">False</property>\n"
                "                    <property name=\"pixbuf\">edit-gcode-button3.png</property>\n"
                "                  </object>\n"
                "                </child>\n"
                "              </object>\n"
                "              <packing>\n"
                "                <property name=\"expand\">False</property>\n"
                "                <property name=\"fill\">False</property>\n"
                "                <property name=\"position\">3</property>\n"
                "              </packing>\n"
                "            </child>\n"
                "            %s\n"
            ) % (GLADE_MARKER, GLADE_PATCH_START, GLADE_PATCH_END)
            content = content[:child_line_start] + glade_insert + content[child_line_start:]
        else:
            self.debug("Edit button Glade marker already present.")

        delete_object_index = content.find(object_text)
        if delete_object_index < 0:
            raise RuntimeError("delete_button object was not found after edit button patch")
        search_limit = delete_object_index + 2500
        before = content[:delete_object_index]
        delete_block = content[delete_object_index:search_limit]
        after = content[search_limit:]
        delete_block, count = re.subn(
            r'<property name="position">\d+</property>',
            '<property name="position">4</property>',
            delete_block,
            count=1
        )
        if count < 1:
            raise RuntimeError("Could not update delete_button packing position")
        content = before + delete_block + after

        if GLADE_PATH_MARKER not in content:
            self.debug("Current folder path Glade marker not found; applying path label Glade patch.")
            delete_object_index = content.find(object_text)
            if delete_object_index < 0:
                raise RuntimeError("delete_button object was not found before path label insertion")
            next_child_index = content.find("\n            <child>", delete_object_index + len(object_text))
            if next_child_index < 0:
                bottom_toolbar_end = content.find("\n          </object>", delete_object_index)
                if bottom_toolbar_end < 0:
                    raise RuntimeError("Could not find bottom toolbar end after delete_button")
                insert_index = bottom_toolbar_end
            else:
                insert_index = next_child_index
            path_insert = (
                "\n"
                "            %s\n"
                "            %s\n"
                "            <child>\n"
                "              <object class=\"GtkEventBox\" id=\"current_folder_path_box\">\n"
                "                <property name=\"height_request\">37</property>\n"
                "                <property name=\"visible\">True</property>\n"
                "                <property name=\"visible_window\">True</property>\n"
                "                <property name=\"can_focus\">False</property>\n"
                "                <property name=\"name\">current_folder_path_box</property>\n"
                "                <child>\n"
                "                  <object class=\"GtkLabel\" id=\"current_folder_path_label\">\n"
                "                    <property name=\"visible\">True</property>\n"
                "                    <property name=\"can_focus\">False</property>\n"
                "                    <property name=\"xalign\">0</property>\n"
                "                    <property name=\"yalign\">0.5</property>\n"
                "                    <property name=\"xpad\">8</property>\n"
                "                    <property name=\"ellipsize\">start</property>\n"
                "                    <property name=\"single_line_mode\">True</property>\n"
                "                    <property name=\"label\">/home/operator/gcode</property>\n"
                "                  </object>\n"
                "                </child>\n"
                "              </object>\n"
                "              <packing>\n"
                "                <property name=\"expand\">True</property>\n"
                "                <property name=\"fill\">True</property>\n"
                "                <property name=\"position\">5</property>\n"
                "              </packing>\n"
                "            </child>\n"
                "            %s\n"
            ) % (GLADE_PATH_MARKER, GLADE_PATH_START, GLADE_PATH_END)
            content = content[:insert_index] + path_insert + content[insert_index:]
        else:
            self.debug("Current folder path Glade marker already present.")

        if content == original_content:
            return content, False
        return content, True

    def patch_tormach_file_util_content(self, content):
        original_content = content

        if PY_PATH_SETUP_MARKER not in content and "self.current_folder_path_label = self.builder.get_object('current_folder_path_label')" not in content:
            freespace_pattern = re.compile(
                r'(?P<line>^[ \t]*self\.freespace_label = self\.builder\.get_object\([\'"]freespace_label[\'"]\)\n)',
                re.MULTILINE
            )

            def replace_path_setup(match):
                line = match.group("line")
                indent = re.match(r'^[ \t]*', line).group(0)
                return (
                    line +
                    "\n" +
                    indent + PY_PATH_SETUP_MARKER + "\n" +
                    indent + PY_PATH_SETUP_START + "\n" +
                    indent + "self.current_folder_path_label = self.builder.get_object('current_folder_path_label')\n" +
                    indent + "if self.current_folder_path_label:\n" +
                    indent + "    self.current_folder_path_label.modify_font(pango.FontDescription('Roboto Condensed 10'))\n" +
                    indent + "    self.current_folder_path_label.modify_fg(gtk.STATE_NORMAL, gtk.gdk.Color('white'))\n" +
                    indent + PY_PATH_SETUP_END + "\n"
                )
            content, path_setup_count = freespace_pattern.subn(replace_path_setup, content, count=1)
            self.debug("Current folder path setup replacement count: %d" % path_setup_count)
            if path_setup_count < 1:
                raise RuntimeError("freespace_label setup line was not found in tormach_file_util.py")
        else:
            self.debug("Current folder path setup already present.")

        if PY_SETUP_MARKER not in content:
            setup_pattern = re.compile(
                r'(?P<block>^[ \t]*button = self\.builder\.get_object\([\'"]rename_button[\'"]\)\n'
                r'^[ \t]*ui_misc\.adjust_eventbox_for_fixed_image_button_child\(button\)\n)',
                re.MULTILINE
            )

            def replace_setup(match):
                block = match.group("block")
                indent = re.match(r'^[ \t]*', block).group(0)
                return (
                    block +
                    "\n" +
                    indent + PY_SETUP_MARKER + "\n" +
                    indent + PY_SETUP_START + "\n" +
                    indent + "button = self.builder.get_object('edit_button')\n" +
                    indent + "ui_misc.adjust_eventbox_for_fixed_image_button_child(button)\n" +
                    indent + PY_SETUP_END + "\n"
                )
            content, setup_count = setup_pattern.subn(replace_setup, content, count=1)
            self.debug("Edit button setup replacement count: %d" % setup_count)
            if setup_count < 1:
                raise RuntimeError("rename_button setup block was not found in tormach_file_util.py")
        else:
            self.debug("Edit button setup marker already present.")

        if PY_PATH_INIT_MARKER not in content and "self.refresh_current_folder_path_label()" not in content:
            folder_connect_pattern = re.compile(
                r'(?P<line>^[ \t]*self\.file_chooser\.connect\([\'"]current-folder-changed[\'"], self\.on_current_folder_changed\)\n)',
                re.MULTILINE
            )

            def replace_path_init(match):
                line = match.group("line")
                indent = re.match(r'^[ \t]*', line).group(0)
                return (
                    line +
                    indent + PY_PATH_INIT_MARKER + "\n" +
                    indent + PY_PATH_INIT_START + "\n" +
                    indent + "self.refresh_current_folder_path_label()\n" +
                    indent + PY_PATH_INIT_END + "\n"
                )
            
            content, path_init_count = folder_connect_pattern.subn(replace_path_init, content, count=1)
            self.debug("Current folder path init replacement count: %d" % path_init_count)
            if path_init_count < 1:
                raise RuntimeError("current-folder-changed connection line was not found in tormach_file_util.py")
        else:
            self.debug("Current folder path init already present.")

        if PY_PATH_METHOD_MARKER not in content and "def refresh_current_folder_path_label(self, path=None):" not in content:
            refresh_free_pattern = re.compile(
                r'(?P<indent>^[ \t]*)def refresh_free_space_label\(self, path=None\):',
                re.MULTILINE
            )
            refresh_free_match = refresh_free_pattern.search(content)
            if not refresh_free_match:
                raise RuntimeError("refresh_free_space_label was not found in tormach_file_util.py")
            indent = refresh_free_match.group("indent")
            method_code = (
                indent + PY_PATH_METHOD_MARKER + "\n"
                + indent + PY_PATH_METHOD_START + "\n"
                + indent + "def refresh_current_folder_path_label(self, path=None):\n"
                + indent + "    if path is None:\n"
                + indent + "        path = self.file_chooser.get_current_folder()\n"
                + indent + "    if path is None:\n"
                + indent + "        path = ''\n"
                + indent + "    if hasattr(self, 'current_folder_path_label') and self.current_folder_path_label:\n"
                + indent + "        self.current_folder_path_label.set_text(path)\n"
                + indent + "        self.current_folder_path_label.set_tooltip_text(path)\n"
                + indent + PY_PATH_METHOD_END + "\n\n"
            )
            content = content[:refresh_free_match.start()] + method_code + content[refresh_free_match.start():]
            self.debug("Inserted current folder path refresh method.")
        else:
            self.debug("Current folder path refresh method already present.")

        if PY_FOLDER_HANDLER_MARKER not in content and "self.refresh_current_folder_path_label(directory)" not in content:
            handler_pattern = re.compile(
                r'(?P<indent>^[ \t]*)def on_current_folder_changed\(self, widget\):\n'
                r'(?P<body>(?:^(?P=indent)[ \t]+.*\n)+)',
                re.MULTILINE
            )
            handler_match = handler_pattern.search(content)
            if not handler_match:
                raise RuntimeError("on_current_folder_changed was not found in tormach_file_util.py")
            indent = handler_match.group("indent")
            replacement_handler = (
                indent + PY_FOLDER_HANDLER_MARKER + "\n"
                + indent + PY_FOLDER_HANDLER_START + "\n"
                + indent + "def on_current_folder_changed(self, widget):\n"
                + indent + "    directory = self.file_chooser.get_current_folder()\n"
                + indent + "    if directory and directory.startswith(self.restricted_directory):\n"
                + indent + "        self.refresh_current_folder_path_label(directory)\n"
                + indent + "    else:\n"
                + indent + "        self.file_chooser.set_current_folder(self.restricted_directory)\n"
                + indent + "        self.refresh_current_folder_path_label(self.restricted_directory)\n"
                + indent + PY_FOLDER_HANDLER_END + "\n"
            )
            content = content[:handler_match.start()] + replacement_handler + content[handler_match.end():]
            self.debug("Replaced current folder changed handler.")
        else:
            self.debug("Current folder handler already updates path label.")

        if PY_HANDLER_MARKER not in content:
            handler_pattern = re.compile(
                r'(?P<indent>^[ \t]*)def on_delete_button_release_event\(self, widget, data=None\):',
                re.MULTILINE
            )
            handler_match = handler_pattern.search(content)
            if not handler_match:
                raise RuntimeError("on_delete_button_release_event was not found in tormach_file_util.py")
            indent = handler_match.group("indent")
            handler_code = (
                indent + PY_HANDLER_MARKER + "\n"
                + indent + PY_HANDLER_START + "\n"
                + indent + "def on_edit_button_release_event(self, widget, data=None):\n"
                + indent + "    if not btn.ImageButton.unshift_button(widget): return\n"
                + indent + "    try:\n"
                + indent + "        path = self.selected_path\n"
                + indent + "    except:\n"
                + indent + "        path = \"\"\n"
                + indent + "    if path and os.path.isfile(path):\n"
                + indent + "        self.error_handler.write(\"Opening %s in editor.\" % path, const.ALARM_LEVEL_QUIET)\n"
                + indent + "        subprocess.Popen(['editscript', path])\n"
                + indent + "        return True\n"
                + indent + "    self.error_handler.write(\"No file selected to open in editor.\", const.ALARM_LEVEL_LOW)\n"
                + indent + "    return True\n"
                + indent + PY_HANDLER_END + "\n\n"
            )
            content = content[:handler_match.start()] + handler_code + content[handler_match.start():]
            self.debug("Inserted edit button handler.")
        else:
            self.debug("Edit button handler marker already present.")

        if content == original_content:
            return content, False
        return content, True

    def copy_button_asset_if_needed(self, source_path, dest_path):
        if not os.path.isfile(source_path):
            raise RuntimeError("Source image not found: %s" % source_path)
        if os.path.isfile(dest_path):
            self.debug("Button image already exists: %s" % dest_path)
            return False
        self.debug("Copying button image.")
        self.debug("Image source: %s" % source_path)
        self.debug("Image target: %s" % dest_path)
        shutil.copy2(source_path, dest_path)
        self.debug("Button image copied: %s" % dest_path)
        return True

    def try_patch(self):
        backup_paths = []
        try:
            paths = self.get_paths()
            self.debug("Python directory: %s" % paths["python_dir"])
            self.debug("Images directory: %s" % paths["images_dir"])
            self.debug("Glade file: %s" % paths["glade"])
            self.debug("Python util file: %s" % paths["util"])
            self.debug("Destination image: %s" % paths["image"])
            with TTFilePatchLock(SCRIPT_NAME, self.error_handler, PATCH_LOCK_PATH, PATCH_LOCK_TIMEOUT_SECONDS):
                self.debug("Inside locked multi-file patch section.")
                self.cleanup_staged_files([paths["glade"], paths["util"]])
                if not os.path.isfile(paths["glade"]):
                    raise RuntimeError("File not found: %s" % paths["glade"])
                if not os.path.isfile(paths["util"]):
                    raise RuntimeError("File not found: %s" % paths["util"])
                copied_asset = self.copy_button_asset_if_needed(SOURCE_IMAGE_PATH, paths["image"])
                glade_content = self.read_file(paths["glade"])
                util_content = self.read_file(paths["util"])
                patched_glade_content, glade_changed = self.patch_glade_content(glade_content)
                patched_util_content, util_changed = self.patch_tormach_file_util_content(util_content)
                if not copied_asset and not glade_changed and not util_changed:
                    self.error_handler.write("[%s] Already patched. No changes made." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
                    self.debug("No asset, Glade, or Python changes needed.")
                    return False
                staged_paths = {}
                target_paths = []
                if glade_changed:
                    backup_paths.append(self.make_chronological_backup(paths["glade"]))
                    staged_paths[paths["glade"]] = self.write_staged_file(paths["glade"], patched_glade_content)
                    self.verify_staged_file(staged_paths[paths["glade"]], GLADE_MARKER)
                    self.verify_staged_file(staged_paths[paths["glade"]], GLADE_PATH_MARKER)
                    target_paths.append(paths["glade"])
                if util_changed:
                    backup_paths.append(self.make_chronological_backup(paths["util"]))
                    staged_paths[paths["util"]] = self.write_staged_file(paths["util"], patched_util_content)
                    self.verify_staged_file(staged_paths[paths["util"]], PY_SETUP_MARKER)
                    self.verify_staged_file(staged_paths[paths["util"]], PY_HANDLER_MARKER)
                    target_paths.append(paths["util"])
                if target_paths:
                    self.debug("All staged files verified. Beginning staged swap.")
                    self.swap_staged_files_into_place(target_paths, staged_paths)
                self.debug("Leaving locked multi-file patch section.")
            self.error_handler.write(" ", constants.ALARM_LEVEL_QUIET)
            if copied_asset:
                self.error_handler.write("[%s] Copied edit button image to PathPilot images directory." % SCRIPT_NAME, constants.ALARM_LEVEL_MEDIUM)
            if backup_paths:
                for backup_path in backup_paths:
                    self.error_handler.write("[%s] Backup created: %s" % (SCRIPT_NAME, backup_path), constants.ALARM_LEVEL_MEDIUM)
            if glade_changed:
                self.error_handler.write("[%s] Patched: %s" % (SCRIPT_NAME, paths["glade"]), constants.ALARM_LEVEL_MEDIUM)
            if util_changed:
                self.error_handler.write("[%s] Patched: %s" % (SCRIPT_NAME, paths["util"]), constants.ALARM_LEVEL_MEDIUM)
            self.error_handler.write("[%s] Please reboot or restart PathPilot." % SCRIPT_NAME, constants.ALARM_LEVEL_MEDIUM)
            self.error_handler.write(" ", constants.ALARM_LEVEL_QUIET)
            glib.idle_add(self.show_success_dialog)
        except Exception as e:
            self.error_handler.write("[%s] Error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return False


DESCRIPTION_LONG = """Gives you an edit button in the File Tab, and adds a 
    current-folder path display beside the file chooser buttons.</font></p>
    <p><a href="images/filetabedit.png">
    <img border="2" src="images/filetabedit_small.png" xthumbnail-orig-image="images/filetabedit.png" width="200" height="150"></a></p>
        <p><font face="Verdana" size="2">Also requires this
        <a href="images/edit-gcode-button3.png">image</a> (or use your own)."""