feat: Complete plugin lifecycle management with lazy loading and runtime reload

Major changes:
- Add unload() method to all plugins for proper cleanup (unregister commands/providers/LSP clients, destroy widgets, clear state)
- Implement lazy widget loading via "show" signal across all containers
- Add autoload: false manifest option for manual/conditional plugin loading
- Add Plugins UI with runtime load/unload toggle via Ctrl+Shift+p
- Implement controller unregistration system with proper signal disconnection
- Add new events: UnregisterCommandEvent, GetFilesEvent, GetSourceViewsEvent, TogglePluginsUiEvent
- Fix signal leaks by tracking and disconnecting handlers in widgets (search/replace, LSP manager, tabs, telescope, markdown preview)
- Add Save/Save As to tabs context menu
- Improve search/replace behavior (selection handling, focus management)
- Add telescope file initialization from existing loaded files
- Refactor plugin reload watcher to dynamically add/remove plugins on filesystem changes
- Add new plugins: file_history, extend_source_view_menu, godot_lsp_client
- Fix bug in prettify_json (undefined variable reference)
This commit is contained in:
2026-03-21 13:22:20 -05:00
parent 21dd86ad3d
commit 77a3b71d31
98 changed files with 1520 additions and 297 deletions

View File

@@ -4,25 +4,25 @@ import sys
import importlib
import traceback
from concurrent.futures import ThreadPoolExecutor
from os.path import join
from os.path import isdir
# Lib imports
import gi
from gi.repository import Gtk
from gi.repository import GLib
# Application imports
from libs.event_factory import Event_Factory, Code_Event_Types
from libs.controllers.controller_base import ControllerBase
from libs.dto.plugins.manifest_meta import ManifestMeta
from libs.dto.base_event import BaseEvent
from .manifest_manager import ManifestManager
from .plugins_controller_mixin import PluginsControllerMixin
from .plugin_reload_mixin import PluginReloadMixin
from .plugin_context import PluginContext
from .plugins_ui import PluginsUI
@@ -40,11 +40,12 @@ class PluginsController(ControllerBase, PluginsControllerMixin, PluginReloadMixi
# path = os.path.dirname(os.path.realpath(__file__))
# sys.path.insert(0, path) # NOTE: I think I'm not using this correctly...
self._plugin_collection: list = []
self._plugins_path: str = settings_manager.path_manager.get_plugins_path()
self.plugins_ui: PluginsUI = PluginsUI()
self._manifest_manager: ManifestManager = ManifestManager()
self._plugin_collection: list = []
self._plugins_path: str = settings_manager.path_manager.get_plugins_path()
self._set_plugins_watcher()
@@ -52,6 +53,14 @@ class PluginsController(ControllerBase, PluginsControllerMixin, PluginReloadMixi
for manifest_meta in self._plugin_collection:
manifest_meta.instance._controller_message(event)
if isinstance(event, Code_Event_Types.PopulateSourceViewPopupEvent):
event.menu.append( Gtk.SeparatorMenuItem() )
item = Gtk.MenuItem(label = "Plugins")
item.connect("activate", self.toggle_plugins_ui)
event.menu.append(item)
elif isinstance(event, Code_Event_Types.TogglePluginsUiEvent):
self.toggle_plugins_ui()
def _collect_search_locations(self, path: str, locations: list):
locations.append(path)
for file in os.listdir(path):
@@ -105,33 +114,46 @@ class PluginsController(ControllerBase, PluginsControllerMixin, PluginReloadMixi
):
if not is_pre_launch:
GLib.idle_add(
self._run_with_pool, module, manifest_meta
self.execute_plugin, module, manifest_meta
)
return
self._run_with_pool(module, manifest_meta)
self.execute_plugin(module, manifest_meta)
def _run_with_pool(self, module: type, manifest_meta: ManifestMeta):
with ThreadPoolExecutor(max_workers = 1) as executor:
future = executor.submit(self.execute_plugin, module, manifest_meta)
future.add_done_callback(self._handle_future_exception)
def _handle_future_exception(self, future):
try:
future.result()
except Exception:
logger.exception("Plugin crashed during execution...")
def pre_launch_plugins(self) -> None:
def pre_launch_plugins(self):
logger.info(f"Loading pre-launch plugins...")
manifest_metas: list = self._manifest_manager.get_pre_launch_plugins()
self._load_plugins(manifest_metas, is_pre_launch = True)
def post_launch_plugins(self) -> None:
for manifest_meta in manifest_metas:
self.plugins_ui.add_row(manifest_meta, self.toggle_plugin_load_state)
def post_launch_plugins(self):
logger.info(f"Loading post-launch plugins...")
manifest_metas: list = self._manifest_manager.get_post_launch_plugins()
self._load_plugins(manifest_metas)
for manifest_meta in manifest_metas:
self.plugins_ui.add_row(manifest_meta, self.toggle_plugin_load_state)
def manual_launch_plugins(self):
logger.info(f"Collecting manual-launch plugins...")
manifest_metas: list = self._manifest_manager.get_manual_launch_plugins()
for manifest_meta in manifest_metas:
self.plugins_ui.add_row(manifest_meta, self.toggle_plugin_load_state)
def toggle_plugin_load_state(self, widget, manifest_meta):
if manifest_meta.instance:
self._plugin_collection.remove(manifest_meta)
manifest_meta.instance.unload()
manifest_meta.instance = None
widget.set_label("Load")
return
self._load_plugins( [manifest_meta] )
widget.set_label("Unload")
def execute_plugin(self, module: type, manifest_meta: ManifestMeta):
plugin = module.Plugin()
plugin.plugin_context: PluginContext = self.create_plugin_context()
@@ -148,15 +170,18 @@ class PluginsController(ControllerBase, PluginsControllerMixin, PluginReloadMixi
self._plugin_collection.append(manifest_meta)
def create_plugin_context(self):
plugin_context: PluginContext = PluginContext()
plugin_context: PluginContext = PluginContext()
plugin_context.request_ui_element: callable = self.request_ui_element
plugin_context.emit: callable = self.emit
plugin_context.emit_to: callable = self.emit_to
plugin_context.emit_to_selected: callable = self.emit_to_selected
plugin_context.register_controller: callable = self.register_controller
plugin_context.request_ui_element: callable = self.request_ui_element
plugin_context.emit: callable = self.emit
plugin_context.emit_to: callable = self.emit_to
plugin_context.emit_to_selected: callable = self.emit_to_selected
plugin_context.register_controller: callable = self.register_controller
plugin_context.unregister_controller: callable = self.unregister_controller
return plugin_context
def toggle_plugins_ui(self, widget = None):
self.plugins_ui.hide() if self.plugins_ui.is_visible() else self.plugins_ui.show()
plugins_controller = PluginsController()

View File

@@ -19,10 +19,12 @@ class ManifestMapperException(Exception):
class ManifestManager:
def __init__(self):
self._plugins_path = settings_manager.path_manager.get_plugins_path()
self._plugins_path: str = \
settings_manager.path_manager.get_plugins_path()
self.pre_launch_manifests: list = []
self.post_launch_manifests: list = []
self.pre_launch_manifests: list = []
self.post_launch_manifests: list = []
self.manual_launch_manifests: list = []
self.load_manifests()
@@ -37,7 +39,7 @@ class ManifestManager:
]:
self.load(folder, path)
def load(self, folder, path):
def load(self, folder, path) -> ManifestMeta:
manifest_pth = join(path, "manifest.json")
if not os.path.exists(manifest_pth):
@@ -52,14 +54,22 @@ class ManifestManager:
manifest_meta.path = path
manifest_meta.manifest = manifest
if not manifest.autoload:
self.manual_launch_manifests.append(manifest_meta)
return
if manifest.pre_launch:
self.pre_launch_manifests.append(manifest_meta)
else:
self.post_launch_manifests.append(manifest_meta)
def get_pre_launch_plugins(self) -> dict:
return manifest_meta
def get_pre_launch_plugins(self) -> list:
return self.pre_launch_manifests
def get_post_launch_plugins(self) -> None:
def get_post_launch_plugins(self) -> list:
return self.post_launch_manifests
def get_manual_launch_plugins(self) -> list:
return self.manual_launch_manifests

View File

@@ -37,3 +37,6 @@ class PluginContext:
def register_controller(self, name: str, controller):
raise PluginContextException("Plugin Context 'register_controller' must be overridden...")
def unregister_controller(self, name: str):
raise PluginContextException("Plugin Context 'unregister_controller' must be overridden...")

View File

@@ -12,12 +12,12 @@ class PluginReloadMixin:
_plugins_dir_watcher = None
def _set_plugins_watcher(self) -> None:
self._plugins_dir_watcher = Gio.File.new_for_path(
self._plugins_path
).monitor_directory(
Gio.FileMonitorFlags.WATCH_MOVES,
Gio.Cancellable()
)
self._plugins_dir_watcher = \
Gio.File.new_for_path( self._plugins_path ) \
.monitor_directory(
Gio.FileMonitorFlags.WATCH_MOVES,
Gio.Cancellable()
)
self._plugins_dir_watcher.connect("changed", self._on_plugins_changed, ())
@@ -27,10 +27,40 @@ class PluginReloadMixin:
eve_type = None,
data = None
):
if eve_type in [Gio.FileMonitorEvent.CREATED, Gio.FileMonitorEvent.DELETED,
Gio.FileMonitorEvent.RENAMED, Gio.FileMonitorEvent.MOVED_IN,
Gio.FileMonitorEvent.MOVED_OUT]:
self.reload_plugins(file)
if eve_type is Gio.FileMonitorEvent.RENAMED:
...
def reload_plugins(self, file: str = None) -> None:
logger.info(f"Reloading plugins... stub.")
if eve_type in [Gio.FileMonitorEvent.CREATED, Gio.FileMonitorEvent.MOVED_IN]:
self.add_plugin(file)
if eve_type in [Gio.FileMonitorEvent.DELETED, Gio.FileMonitorEvent.MOVED_OUT]:
self.remove_plugin(file)
def add_plugin(self, file: str) -> None:
logger.info(f"Adding plugin: {file.get_uri()}")
uri = file.get_uri()
path = uri.replace("file://", "")
folder = path.split("/")[-1]
manifest_meta = self._manifest_manager.load(folder, path)
self._load_plugins( [manifest_meta] )
self.plugins_ui.add_row(manifest_meta, self.toggle_plugin_load_state)
def remove_plugin(self, file: str) -> None:
logger.info(f"Removing plugin: {file.get_uri()}")
for manifest_meta in self._plugin_collection[:]:
if not manifest_meta.folder in file.get_uri(): continue
manifest_meta.instance.unload()
manifest_meta.instance = None
self._plugin_collection.remove(manifest_meta)
self.plugins_ui.remove_row(manifest_meta)
if manifest_meta in self._manifest_manager.pre_launch_manifests:
self._manifest_manager.pre_launch_manifests.remove(manifest_meta)
elif manifest_meta in self._manifest_manager.post_launch_manifests:
self._manifest_manager.post_launch_manifests.remove(manifest_meta)
elif manifest_meta in self._manifest_manager.manual_launch_manifests:
self._manifest_manager.manual_launch_manifests.remove(manifest_meta)
break

View File

@@ -27,6 +27,9 @@ class PluginBase:
def load(self):
raise PluginBaseException("Plugin Base 'load' must be overriden by Plugin")
def unload(self):
raise PluginBaseException("Plugin Base 'unload' must be overriden by Plugin")
def run(self):
raise PluginBaseException("Plugin Base 'run' must be overriden by Plugin")

View File

@@ -34,6 +34,9 @@ class PluginCode(PluginBase):
def register_controller(self, name: str, controller):
return self.plugin_context.register_controller(name, controller)
def unregister_controller(self, name: str):
return self.plugin_context.unregister_controller(name)
def request_ui_element(self, element_id: str):
return self.plugin_context.request_ui_element(element_id)

100
src/plugins/plugins_ui.py Normal file
View File

@@ -0,0 +1,100 @@
# Python imports
# Lib imports
import gi
from gi.repository import Gtk
# Application imports
class PluginsUI(Gtk.Dialog):
def __init__(self):
super(PluginsUI, self).__init__()
self._setup_styling()
self._setup_signals()
self._subscribe_to_events()
self._load_widgets()
def _setup_styling(self):
header = Gtk.HeaderBar()
self.ctx = self.get_style_context()
self.ctx.add_class("plugin-ui")
self.set_title("Plugins")
self.set_size_request(450, 530)
self.set_deletable(False)
self.set_skip_pager_hint(True)
self.set_skip_taskbar_hint(True)
header.set_title("Plugins")
self.set_titlebar(header)
header.show()
window = widget_registery.get_object("main-window")
self.set_transient_for(window)
def _setup_signals(self):
...
def _subscribe_to_events(self):
...
def _load_widgets(self):
widget_registery.expose_object("plugin-ui", self)
content_area = self.get_content_area()
scrolled_win = Gtk.ScrolledWindow()
viewport = Gtk.Viewport()
self.list_box = Gtk.ListBox()
self.list_box.set_selection_mode( Gtk.SelectionMode.NONE )
scrolled_win.set_vexpand(True)
viewport.add(self.list_box)
scrolled_win.add(viewport)
content_area.add(scrolled_win)
scrolled_win.show_all()
def add_row(self, manifest_meta, callback: callable):
box = Gtk.Box()
plugin_lbl = Gtk.Label(label = manifest_meta.manifest.name)
author_lbl = Gtk.Label(label = manifest_meta.manifest.author)
version_lbl = Gtk.Label(label = manifest_meta.manifest.version)
is_autoload = manifest_meta.manifest.autoload
toggle_bttn = Gtk.ToggleButton(label = "Unload" if is_autoload else "Load")
toggle_bttn.set_active(is_autoload)
plugin_lbl.set_hexpand(True)
box.set_hexpand(True)
version_lbl.set_margin_left(15)
version_lbl.set_margin_right(15)
toggle_bttn.set_size_request(120, -1)
toggle_bttn.toggle_id = \
toggle_bttn.connect("toggled", callback, manifest_meta)
box.add(plugin_lbl)
box.add(author_lbl)
box.add(version_lbl)
box.add(toggle_bttn)
box.manifest_meta = manifest_meta
box.show_all()
self.list_box.add(box)
def remove_row(self, manifest_meta):
for row in self.list_box.get_children():
child = row.get_children()[0]
if not child.manifest_meta == manifest_meta: continue
child.manifest_meta = None
toggle_bttn = getattr(child, "toggle_bttn", None)
toggle_bttn.disconnect(toggle_bttn.toggle_id)
self.list_box.remove(row)
box.destroy()
break