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:26:12 -05:00
parent 0fc440e7ce
commit 0b231ac749
73 changed files with 1157 additions and 222 deletions

View File

@@ -5,13 +5,14 @@
# Application imports
from libs.event_factory import Event_Factory, Code_Event_Types
from ..mixins.command_system_mixin import CommandSystemMixin
from ..source_view import SourceView
from . import commands
class CommandSystem:
class CommandSystem(CommandSystemMixin):
def __init__(self):
super(CommandSystem, self).__init__()
@@ -37,6 +38,10 @@ class CommandSystem:
def add_command(self, command_name: str, command: callable):
setattr(commands, command_name, command)
def remove_command(self, command_name: str, command: callable):
if hasattr(commands, command_name):
delattr(commands, command_name)
def emit(self, event: Code_Event_Types.CodeEvent):
""" Monkey patch 'emit' from command controller... """
@@ -47,69 +52,69 @@ class CommandSystem:
...
def filter_out_loaded_files(self, uris: list[str]):
event = Event_Factory.create_event(
"filter_out_loaded_files",
uris = uris
)
self.emit_to("files", event)
return event.response
def set_info_labels(self, data: tuple[str]):
event = Event_Factory.create_event(
"set_info_labels",
info = data
)
self.emit_to("plugins", event)
def get_file(self, view: SourceView):
event = Event_Factory.create_event(
"get_file",
view = view,
buffer = view.get_buffer()
)
self.emit_to("files", event)
return event.response
def get_swap_file(self, view: SourceView):
event = Event_Factory.create_event(
"get_swap_file",
view = view,
buffer = view.get_buffer()
)
self.emit_to("files", event)
return event.response
def new_file(self, view: SourceView):
event = Event_Factory.create_event("add_new_file", view = view)
self.emit_to("files", event)
return event.response
def remove_file(self, view: SourceView):
event = Event_Factory.create_event(
"remove_file",
view = view,
buffer = view.get_buffer()
)
self.emit_to("files", event)
return event.response
def request_completion(self, view: SourceView):
event = Event_Factory.create_event(
"request_completion",
view = view,
buffer = view.get_buffer()
)
self.emit_to("completion", event)
# def filter_out_loaded_files(self, uris: list[str]):
# event = Event_Factory.create_event(
# "filter_out_loaded_files",
# uris = uris
# )
#
# self.emit_to("files", event)
#
# return event.response
#
# def set_info_labels(self, data: tuple[str]):
# event = Event_Factory.create_event(
# "set_info_labels",
# info = data
# )
#
# self.emit_to("plugins", event)
#
# def get_file(self, view: SourceView):
# event = Event_Factory.create_event(
# "get_file",
# view = view,
# buffer = view.get_buffer()
# )
#
# self.emit_to("files", event)
#
# return event.response
#
# def get_swap_file(self, view: SourceView):
# event = Event_Factory.create_event(
# "get_swap_file",
# view = view,
# buffer = view.get_buffer()
# )
#
# self.emit_to("files", event)
#
# return event.response
#
# def new_file(self, view: SourceView):
# event = Event_Factory.create_event("add_new_file", view = view)
#
# self.emit_to("files", event)
#
# return event.response
#
# def remove_file(self, view: SourceView):
# event = Event_Factory.create_event(
# "remove_file",
# view = view,
# buffer = view.get_buffer()
# )
#
# self.emit_to("files", event)
#
# return event.response
#
# def request_completion(self, view: SourceView):
# event = Event_Factory.create_event(
# "request_completion",
# view = view,
# buffer = view.get_buffer()
# )
#
# self.emit_to("completion", event)

View File

@@ -0,0 +1,21 @@
# Python imports
# Lib imports
import gi
gi.require_version('GtkSource', '4')
from gi.repository import GtkSource
# Application imports
from libs.event_factory import Event_Factory, Code_Event_Types
def execute(
view: GtkSource.View,
*args,
**kwargs
):
logger.debug("Command: Toggle Plugins UI")
view.command.toggle_plugins_ui()

View File

@@ -28,6 +28,8 @@ class FilesController(ControllerBase, list):
self.remove_file(event)
elif isinstance(event, Code_Event_Types.GetFileEvent):
self.get_file(event)
elif isinstance(event, Code_Event_Types.GetFilesEvent):
event.response = self
elif isinstance(event, Code_Event_Types.GetSwapFileEvent):
self.get_swap_file(event)

View File

@@ -29,7 +29,11 @@ class SourceViewSignalMapper:
def connect_signals(self, source_view: SourceView):
signal_mappings = self._get_signal_mappings()
for signal, handler in signal_mappings.items():
source_view.connect(signal, handler)
if not signal == "populate-popup":
source_view.connect(signal, handler)
continue
source_view.connect_after(signal, handler)
def disconnect_signals(self, source_view: SourceView):
signal_mappings = self._get_signal_mappings()

View File

@@ -33,11 +33,15 @@ class SourceViewsController(ControllerBase, list):
self._remove_file(event)
elif isinstance(event, Code_Event_Types.RegisterCommandEvent):
self._register_command(event)
elif isinstance(event, Code_Event_Types.UnregisterCommandEvent):
self._unregister_command(event)
if not self.signal_mapper.active_view: return
if isinstance(event, Code_Event_Types.GetActiveViewEvent):
event.response = self.signal_mapper.active_view
elif isinstance(event, Code_Event_Types.GetSourceViewsEvent):
event.response = self
elif isinstance(event, Code_Event_Types.TextChangedEvent):
self.signal_mapper.active_view.command.exec("update_info_bar")
elif isinstance(event, Code_Event_Types.SetActiveFileEvent):
@@ -63,6 +67,24 @@ class SourceViewsController(ControllerBase, list):
event.command
)
def _unregister_command(self, event: Code_Event_Types.UnregisterCommandEvent):
if not isinstance(event.binding, list):
event.binding = [ event.binding ]
for binding in event.binding:
self.state_manager.key_mapper.unmap_command(
event.command_name,
{
f"{event.binding_mode}": binding
}
)
for view in self:
view.command.remove_command(
event.command_name,
event.command
)
def _get_command_system(self):
event = Event_Factory.create_event("get_new_command_system")
self.message_to("commands", event)

View File

@@ -95,6 +95,28 @@ class KeyMapper:
getattr(self.states[state], press_state)[keyname] = command
def unmap_command(self, command, entry):
press_state = "held" if "held" in entry else "released"
keyname = entry[press_state]
state = NoKeyState
if "<Control>" in keyname:
state = state | CtrlKeyState
if "<Shift>" in keyname:
state = state | ShiftKeyState
if "<Alt>" in keyname:
state = state | AltKeyState
keyname = keyname.replace("<Control>", "") \
.replace("<Shift>", "") \
.replace("<Alt>", "") \
.lower()
mapping = getattr(self.states[state], press_state)
if keyname in mapping and mapping[keyname] == command:
del mapping[keyname]
def _key_press_event(self, eve):
keyname = self.get_keyname(eve)
char_str = self.get_char(eve)

View File

@@ -0,0 +1,83 @@
# Python imports
# Lib imports
# Application imports
from libs.event_factory import Event_Factory, Code_Event_Types
from ..source_view import SourceView
class CommandSystemMixin:
def toggle_plugins_ui(self):
event = Event_Factory.create_event( "toggle_plugins_ui" )
self.emit_to("plugins", event)
def filter_out_loaded_files(self, uris: list[str]):
event = Event_Factory.create_event(
"filter_out_loaded_files",
uris = uris
)
self.emit_to("files", event)
return event.response
def set_info_labels(self, data: tuple[str]):
event = Event_Factory.create_event(
"set_info_labels",
info = data
)
self.emit_to("plugins", event)
def get_file(self, view: SourceView):
event = Event_Factory.create_event(
"get_file",
view = view,
buffer = view.get_buffer()
)
self.emit_to("files", event)
return event.response
def get_swap_file(self, view: SourceView):
event = Event_Factory.create_event(
"get_swap_file",
view = view,
buffer = view.get_buffer()
)
self.emit_to("files", event)
return event.response
def new_file(self, view: SourceView):
event = Event_Factory.create_event("add_new_file", view = view)
self.emit_to("files", event)
return event.response
def remove_file(self, view: SourceView):
event = Event_Factory.create_event(
"remove_file",
view = view,
buffer = view.get_buffer()
)
self.emit_to("files", event)
return event.response
def request_completion(self, view: SourceView):
event = Event_Factory.create_event(
"request_completion",
view = view,
buffer = view.get_buffer()
)
self.emit_to("completion", event)