Skip to content

Commit ac06718

Browse files
authored
Merge pull request #35 from NikiforovAll/32-feat-copy-hooks-to-user-project-and-project-local-configs
feat: add delete action and copy/move support for hooks, MCPs, and memory files
2 parents 27c4f6b + 2298840 commit ac06718

File tree

16 files changed

+1968
-82
lines changed

16 files changed

+1968
-82
lines changed

src/lazyclaude/app.py

Lines changed: 159 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from lazyclaude.services.filter import FilterService
2222
from lazyclaude.services.writer import CustomizationWriter
2323
from lazyclaude.widgets.combined_panel import CombinedPanel
24+
from lazyclaude.widgets.delete_confirm import DeleteConfirm
2425
from lazyclaude.widgets.detail_pane import MainPane
2526
from lazyclaude.widgets.filter_input import FilterInput
2627
from lazyclaude.widgets.level_selector import LevelSelector
@@ -41,6 +42,7 @@ class LazyClaude(App):
4142
Binding("e", "open_in_editor", "Edit"),
4243
Binding("c", "copy_customization", "Copy"),
4344
Binding("m", "move_customization", "Move"),
45+
Binding("d", "delete_customization", "Delete"),
4446
Binding("C", "copy_config_path", "Copy Path"),
4547
Binding("tab", "focus_next_panel", "Next Panel", show=False),
4648
Binding("shift+tab", "focus_previous_panel", "Prev Panel", show=False),
@@ -67,6 +69,16 @@ class LazyClaude(App):
6769
TITLE = "LazyClaude"
6870
SUB_TITLE = "Claude Code Customization Viewer"
6971

72+
_COPYABLE_TYPES = (
73+
CustomizationType.SLASH_COMMAND,
74+
CustomizationType.SUBAGENT,
75+
CustomizationType.SKILL,
76+
CustomizationType.HOOK,
77+
CustomizationType.MCP,
78+
CustomizationType.MEMORY_FILE,
79+
)
80+
_PROJECT_LOCAL_TYPES = (CustomizationType.HOOK, CustomizationType.MCP)
81+
7082
def __init__(
7183
self,
7284
discovery_service: ConfigDiscoveryService | None = None,
@@ -93,11 +105,13 @@ def __init__(
93105
self._filter_input: FilterInput | None = None
94106
self._level_selector: LevelSelector | None = None
95107
self._plugin_confirm: PluginConfirm | None = None
108+
self._delete_confirm: DeleteConfirm | None = None
96109
self._help_visible = False
97110
self._last_focused_panel: TypePanel | None = None
98111
self._last_focused_combined: bool = False
99112
self._pending_customization: Customization | None = None
100113
self._panel_before_selector: TypePanel | None = None
114+
self._combined_before_selector: bool = False
101115
self._config_path_resolver: ConfigPathResolver | None = None
102116

103117
def _fatal_error(self) -> None:
@@ -138,6 +152,9 @@ def compose(self) -> ComposeResult:
138152
self._plugin_confirm = PluginConfirm(id="plugin-confirm")
139153
yield self._plugin_confirm
140154

155+
self._delete_confirm = DeleteConfirm(id="delete-confirm")
156+
yield self._delete_confirm
157+
141158
yield Footer()
142159

143160
def on_mount(self) -> None:
@@ -160,21 +177,23 @@ def check_action(
160177
return False
161178
return self._main_pane.customization.plugin_info is not None
162179

163-
if action in ("copy_customization", "move_customization"):
180+
if action in (
181+
"copy_customization",
182+
"move_customization",
183+
"delete_customization",
184+
):
164185
if not self._main_pane or not self._main_pane.customization:
165186
return False
166187

188+
if self._is_skill_subfile_selected():
189+
return False
190+
167191
customization = self._main_pane.customization
168192

169-
if customization.type not in [
170-
CustomizationType.SLASH_COMMAND,
171-
CustomizationType.SUBAGENT,
172-
CustomizationType.SKILL,
173-
]:
193+
if customization.type not in self._COPYABLE_TYPES:
174194
return False
175-
176195
if (
177-
action == "move_customization"
196+
action in ("delete_customization", "move_customization")
178197
and customization.level == ConfigLevel.PLUGIN
179198
):
180199
return False
@@ -379,28 +398,22 @@ def action_copy_customization(self) -> None:
379398

380399
customization = self._main_pane.customization
381400

382-
if customization.type not in [
383-
CustomizationType.SLASH_COMMAND,
384-
CustomizationType.SUBAGENT,
385-
CustomizationType.SKILL,
386-
]:
401+
if customization.type not in self._COPYABLE_TYPES:
387402
self._show_status_error(
388403
f"Cannot copy {customization.type_label} customizations"
389404
)
390405
return
391406

392-
available = [
393-
level
394-
for level in [ConfigLevel.USER, ConfigLevel.PROJECT]
395-
if level != customization.level
396-
]
397-
407+
available = self._get_available_target_levels(customization)
398408
if not available:
399409
self._show_status_error("No available target levels")
400410
return
401411

402412
self._pending_customization = customization
403413
self._panel_before_selector = self._get_focused_panel()
414+
self._combined_before_selector = (
415+
self._combined_panel.has_focus if self._combined_panel else False
416+
)
404417
if self._level_selector:
405418
self._level_selector.show(available, "copy")
406419

@@ -411,11 +424,7 @@ def action_move_customization(self) -> None:
411424

412425
customization = self._main_pane.customization
413426

414-
if customization.type not in [
415-
CustomizationType.SLASH_COMMAND,
416-
CustomizationType.SUBAGENT,
417-
CustomizationType.SKILL,
418-
]:
427+
if customization.type not in self._COPYABLE_TYPES:
419428
self._show_status_error(
420429
f"Cannot move {customization.type_label} customizations"
421430
)
@@ -425,21 +434,43 @@ def action_move_customization(self) -> None:
425434
self._show_status_error("Cannot move from plugin-level customizations")
426435
return
427436

428-
available = [
429-
level
430-
for level in [ConfigLevel.USER, ConfigLevel.PROJECT]
431-
if level != customization.level
432-
]
433-
437+
available = self._get_available_target_levels(customization)
434438
if not available:
435439
self._show_status_error("No available target levels")
436440
return
437441

438442
self._pending_customization = customization
439443
self._panel_before_selector = self._get_focused_panel()
444+
self._combined_before_selector = (
445+
self._combined_panel.has_focus if self._combined_panel else False
446+
)
440447
if self._level_selector:
441448
self._level_selector.show(available, "move")
442449

450+
def action_delete_customization(self) -> None:
451+
"""Delete selected customization."""
452+
if not self._main_pane or not self._main_pane.customization:
453+
return
454+
455+
customization = self._main_pane.customization
456+
457+
if customization.type not in self._COPYABLE_TYPES:
458+
self._show_status_error(
459+
f"Cannot delete {customization.type_label} customizations"
460+
)
461+
return
462+
463+
if customization.level == ConfigLevel.PLUGIN:
464+
self._show_status_error("Cannot delete plugin-level customizations")
465+
return
466+
467+
self._panel_before_selector = self._get_focused_panel()
468+
self._combined_before_selector = (
469+
self._combined_panel.has_focus if self._combined_panel else False
470+
)
471+
if self._delete_confirm:
472+
self._delete_confirm.show(customization)
473+
443474
async def action_back(self) -> None:
444475
"""Go back - return focus to panel from main pane, keep content visible."""
445476
if self._main_pane and self._main_pane.has_focus:
@@ -483,6 +514,46 @@ def _get_focused_panel(self) -> TypePanel | None:
483514
return panel
484515
return None
485516

517+
def _is_skill_subfile_selected(self) -> bool:
518+
"""Check if a skill subfile is currently selected (not root skill)."""
519+
panel = self._get_focused_panel()
520+
if (
521+
panel
522+
and panel._is_skills_panel
523+
and panel._flat_items
524+
and 0 <= panel.selected_index < len(panel._flat_items)
525+
):
526+
_, file_path = panel._flat_items[panel.selected_index]
527+
return file_path is not None
528+
return False
529+
530+
def _get_available_target_levels(
531+
self, customization: Customization
532+
) -> list[ConfigLevel]:
533+
"""Get available target levels for copy/move based on customization type."""
534+
if customization.type in self._PROJECT_LOCAL_TYPES:
535+
all_levels = [
536+
ConfigLevel.USER,
537+
ConfigLevel.PROJECT,
538+
ConfigLevel.PROJECT_LOCAL,
539+
]
540+
else:
541+
all_levels = [ConfigLevel.USER, ConfigLevel.PROJECT]
542+
return [level for level in all_levels if level != customization.level]
543+
544+
def _delete_customization(
545+
self, customization: Customization, writer: CustomizationWriter
546+
) -> tuple[bool, str]:
547+
"""Delete customization using type-specific method."""
548+
if customization.type == CustomizationType.MCP:
549+
return writer.delete_mcp_customization(
550+
customization, self._discovery_service.project_config_path
551+
)
552+
elif customization.type == CustomizationType.HOOK:
553+
return writer.delete_hook_customization(customization)
554+
else:
555+
return writer.delete_customization(customization)
556+
486557
def _get_focused_panel_index(self) -> int | None:
487558
"""Get the index of the currently focused panel (combined panel = len(panels))."""
488559
for i, panel in enumerate(self._panels):
@@ -611,6 +682,9 @@ def action_toggle_plugin_enabled(self) -> None:
611682
return
612683

613684
self._panel_before_selector = self._get_focused_panel()
685+
self._combined_before_selector = (
686+
self._combined_panel.has_focus if self._combined_panel else False
687+
)
614688
if self._plugin_confirm:
615689
self._plugin_confirm.show(
616690
plugin_info=customization.plugin_info,
@@ -714,33 +788,80 @@ def on_plugin_confirm_confirmation_cancelled(
714788
"""Handle plugin confirmation cancellation."""
715789
self._restore_focus_after_selector()
716790

791+
def on_delete_confirm_delete_confirmed(
792+
self, message: DeleteConfirm.DeleteConfirmed
793+
) -> None:
794+
"""Handle delete confirmation."""
795+
customization = message.customization
796+
writer = CustomizationWriter()
797+
success, msg = self._delete_customization(customization, writer)
798+
799+
if success:
800+
self.notify(msg, severity="information")
801+
self.action_refresh()
802+
else:
803+
self.notify(msg, severity="error")
804+
self._restore_focus_after_selector()
805+
806+
def on_delete_confirm_delete_cancelled(
807+
self,
808+
message: DeleteConfirm.DeleteCancelled, # noqa: ARG002
809+
) -> None:
810+
"""Handle delete cancellation."""
811+
self._restore_focus_after_selector()
812+
717813
def _restore_focus_after_selector(self) -> None:
718814
"""Restore focus to the panel that was focused before the level selector."""
719-
if self._panel_before_selector:
815+
if self._combined_before_selector and self._combined_panel:
816+
self._combined_panel.focus()
817+
self._combined_before_selector = False
818+
self._panel_before_selector = None
819+
elif self._panel_before_selector:
720820
self._panel_before_selector.focus()
721821
self._panel_before_selector = None
822+
self._combined_before_selector = False
722823
elif self._panels:
723824
self._panels[0].focus()
724825

725826
def _handle_copy_or_move(
726827
self, customization: Customization, target_level: ConfigLevel, operation: str
727828
) -> None:
728829
"""Handle copy or move operation."""
830+
if operation == "move" and customization.level == ConfigLevel.PLUGIN:
831+
self._show_status_error("Cannot move from plugin (read-only source)")
832+
return
833+
729834
writer = CustomizationWriter()
730835

731-
success, msg = writer.write_customization(
732-
customization,
733-
target_level,
734-
self._discovery_service.user_config_path,
735-
self._discovery_service.project_config_path,
736-
)
836+
if customization.type == CustomizationType.MCP:
837+
success, msg = writer.write_mcp_customization(
838+
customization,
839+
target_level,
840+
self._discovery_service.project_config_path,
841+
)
842+
elif customization.type == CustomizationType.HOOK:
843+
success, msg = writer.write_hook_customization(
844+
customization,
845+
target_level,
846+
self._discovery_service.user_config_path,
847+
self._discovery_service.project_config_path,
848+
)
849+
else:
850+
success, msg = writer.write_customization(
851+
customization,
852+
target_level,
853+
self._discovery_service.user_config_path,
854+
self._discovery_service.project_config_path,
855+
)
737856

738857
if not success:
739858
self._show_status_error(msg)
740859
return
741860

742861
if operation == "move":
743-
delete_success, delete_msg = writer.delete_customization(customization)
862+
delete_success, delete_msg = self._delete_customization(
863+
customization, writer
864+
)
744865
if not delete_success:
745866
self._show_status_error(
746867
f"Copied but failed to delete source: {delete_msg}"

0 commit comments

Comments
 (0)