1919logger = script .get_logger ()
2020
2121
22+ def _repo_name_from_git_url (git_url ):
23+ """Derive repo/folder name from a Git URL (e.g. .../owner/repo.git -> repo)."""
24+ if not git_url :
25+ return ""
26+ # Strip .git suffix
27+ url = git_url .rstrip ("/" )
28+ if url .lower ().endswith (".git" ):
29+ url = url [:- 4 ]
30+ # Last path segment: after last / or, for git@host:path, after last /
31+ if "/" in url :
32+ url = url .rsplit ("/" , 1 )[- 1 ]
33+ elif url .startswith ("git@" ):
34+ # git@host:owner/repo -> owner/repo -> repo
35+ if ":" in url :
36+ url = url .split (":" , 1 )[1 ]
37+ if "/" in url :
38+ url = url .rsplit ("/" , 1 )[- 1 ]
39+ return url .strip () or ""
40+
41+
2242class ExtensionPackageListItem :
2343 """Extension object that is used in Extensions list ui.
2444
@@ -88,32 +108,18 @@ def searchable_values(self):
88108 )
89109
90110
91- class InstallPackageMenuItem (framework .Controls .MenuItem ):
92- """Context menu item for package installation destinations
93-
94- Instances of this class will be set to the possible directory paths
95- that are appropriate to install extensions in. This includes pyRevit
96- default extension folder and all other extension folders set by the
97- user. When installing an extension, user can select the destination
98- from the destinations menu which contains instances of this class.
99-
100- Attributes:
101- InstallPackageMenuItem.install_path (str): Destination address
102-
103- """
104-
105- install_path = ""
106-
107-
108111class ExtensionsWindow (forms .WPFWindow ):
109112 """Extension window managing installation and removal of extensions"""
110113
111114 def __init__ (self , xaml_file_name ):
112115 forms .WPFWindow .__init__ (self , xaml_file_name )
113- self ._setup_ext_dirs_ui (user_config .get_thirdparty_ext_root_dirs ())
114116 self ._setup_ext_pkg_ui (extpkgs .get_ext_packages ())
115117 default_path = user_config .get_thirdparty_ext_root_dirs (include_default = True )[0 ]
116118 self .custom_ext_install_path_tb .Text = default_path
119+ if self .selected_pkg :
120+ self ._update_add_custom_section_for_selection (self .selected_pkg )
121+ else :
122+ self ._update_add_custom_section_for_new ()
117123
118124 @property
119125 def selected_pkg (self ):
@@ -138,24 +144,6 @@ def selected_pkgs(self):
138144 """
139145 return self .extpkgs_lb .SelectedItems
140146
141- def _setup_ext_dirs_ui (self , ext_dirs_list ):
142- """Creates the installation destination context menu. Creates a menu
143- item for each directory address provided in ext_dirs_list
144-
145- Args:
146- ext_dirs_list (list): List of destination directories
147-
148- """
149-
150- for ext_dir in ext_dirs_list :
151- ext_dir_install_menu_item = InstallPackageMenuItem ()
152- ext_dir_install_menu_item .install_path = ext_dir
153- ext_dir_install_menu_item .Header = self .get_locale_string (
154- "Extension.InstallPath"
155- ).format (ext_dir )
156- ext_dir_install_menu_item .Click += self .install_ext_pkg
157- self .ext_install_b .ContextMenu .AddChild (ext_dir_install_menu_item )
158-
159147 def _setup_ext_pkg_ui (self , ext_pkgs_list ):
160148 """Creates a list of initialized ExtensionPackageListItem objects,
161149 one for each extension package object in ext_pkgs_list
@@ -172,7 +160,13 @@ def _setup_ext_pkg_ui(self, ext_pkgs_list):
172160 self .extpkgs_lb .ItemsSource = sorted (
173161 self ._exts_list , key = lambda x : x .Builtin , reverse = True
174162 )
175- self .extpkgs_lb .SelectedIndex = 0
163+ self .extpkgs_lb .SelectedIndex = - 1
164+
165+ def _refresh_extension_list (self ):
166+ """Reload extension packages and refresh the grid (e.g. after installing)."""
167+ ext_pkgs_list = extpkgs .get_ext_packages ()
168+ self ._setup_ext_pkg_ui (ext_pkgs_list )
169+ self ._update_add_custom_section_for_new ()
176170
177171 def _update_ext_info_panel (self , ext_pkg_item ):
178172 """Updated the extension information panel based on the info
@@ -217,14 +211,15 @@ def _update_ext_info_panel(self, ext_pkg_item):
217211 else :
218212 self .ext_repolink_t .Text = ""
219213
220- # Update Installed folder info
214+ # Update install path (own line, like Developed by)
221215 if ext_pkg_item .ext_pkg .is_installed :
222- self .show_element (self .ext_installed_l )
223- self .ext_installed_l .Content = self .get_locale_string (
224- "Extension.InstalledPath"
225- ).format (ext_pkg_item .ext_pkg .is_installed )
216+ self .show_element (self .ext_installpath_tb )
217+ self .ext_installpath_tb .Text = (
218+ self .get_locale_string ("ExtensionInfo.InstallPathLink" )
219+ + ext_pkg_item .ext_pkg .is_installed
220+ )
226221 else :
227- self .hide_element (self .ext_installed_l )
222+ self .hide_element (self .ext_installpath_tb )
228223
229224 # Update dependencies
230225 if ext_pkg_item .ext_pkg .dependencies :
@@ -267,9 +262,6 @@ def _update_ext_action_buttons(self, ext_pkg_items):
267262 if len (ext_pkg_items ) == 1 :
268263 ext_pkg_item = ext_pkg_items [0 ]
269264 if ext_pkg_item .ext_pkg .is_installed :
270- # Action Button: Install
271- self .hide_element (self .ext_install_b )
272-
273265 # Action Button: Remove
274266 if ext_pkg_item .ext_pkg .builtin :
275267 self .hide_element (self .ext_remove_b )
@@ -279,10 +271,8 @@ def _update_ext_action_buttons(self, ext_pkg_items):
279271 # Action Button: Toggle (Enable / Disable)
280272 self ._update_toggle_button (enable = ext_pkg_item .ext_pkg .config .disabled )
281273 else :
282- self .show_element (self .ext_install_b )
283274 self .hide_element (self .ext_toggle_b , self .ext_remove_b )
284275 elif len (ext_pkg_items ) > 1 :
285- self .hide_element (self .ext_install_b )
286276 self .hide_element (self .ext_remove_b )
287277 # hide the button if includes any cli extensions
288278 if any ([not x .ext_pkg .is_installed for x in ext_pkg_items ]):
@@ -330,55 +320,123 @@ def update_ext_info(self, sender, args):
330320 self .show_element (self .ext_infopanel )
331321 self ._update_ext_info_panel (self .selected_pkg )
332322 self ._update_ext_action_buttons ([self .selected_pkg ])
323+ self ._update_add_custom_section_for_selection (self .selected_pkg )
333324 elif self .selected_pkgs :
334325 self .hide_element (self .ext_infostack )
335326 self ._update_ext_action_buttons (self .selected_pkgs )
327+ self ._update_add_custom_section_for_new ()
336328 else :
337- self .hide_element (self .ext_infopanel )
338-
339- def handle_install_button_popup (self , sender , args ):
340- """Callback for Install package destination context menu
341-
342- This callback method will popup a menu with a list of install
343- destinations, when the install button is clicked.
344- """
345- sender .ContextMenu .IsEnabled = True
346- sender .ContextMenu .PlacementTarget = sender
347- sender .ContextMenu .Placement = (
348- framework .Controls .Primitives .PlacementMode .Bottom
349- )
350- sender .ContextMenu .IsOpen = True
351-
352- def install_ext_pkg (self , sender , args ):
353- """Installs the selected extension, then reloads pyRevit"""
329+ self .show_element (self .ext_infopanel )
330+ self .hide_element (self .ext_infostack )
331+ self .hide_element (self .ext_toggle_b , self .ext_remove_b )
332+ self ._update_add_custom_section_for_new ()
333+
334+ def _update_add_custom_section_for_selection (self , ext_pkg_item ):
335+ """Populate Add Custom section from selected extension; disable Pick, show Install only if not installed."""
336+ self .custom_git_url_tb .Text = ext_pkg_item .GitURL or ""
337+ if getattr (self , "custom_ext_name_tb" , None ):
338+ self .custom_ext_name_tb .Text = ext_pkg_item .Name or ""
339+ self .custom_git_url_tb .IsReadOnly = True
340+ if getattr (self , "custom_ext_name_tb" , None ):
341+ self .custom_ext_name_tb .IsReadOnly = True
342+ self .path_custom_ext_b .IsEnabled = False
343+ if ext_pkg_item .ext_pkg .is_installed :
344+ self .custom_ext_install_path_tb .Text = ext_pkg_item .ext_pkg .is_installed
345+ else :
346+ default_path = user_config .get_thirdparty_ext_root_dirs (include_default = True )[0 ]
347+ self .custom_ext_install_path_tb .Text = default_path
348+ if ext_pkg_item .ext_pkg .is_installed :
349+ self .hide_element (self .install_custom_ext_b )
350+ else :
351+ self .show_element (self .install_custom_ext_b )
352+ self .install_custom_ext_b .Content = self .get_locale_string ("Buttons.InstallExtension" )
353+
354+ def _update_add_custom_section_for_new (self ):
355+ """Reset Add Custom section for adding a new extension; enable Pick, show Add and install."""
356+ self .custom_git_url_tb .Text = ""
357+ if getattr (self , "custom_ext_name_tb" , None ):
358+ self .custom_ext_name_tb .Text = ""
359+ self .custom_git_url_tb .IsReadOnly = False
360+ if getattr (self , "custom_ext_name_tb" , None ):
361+ self .custom_ext_name_tb .IsReadOnly = False
362+ self .path_custom_ext_b .IsEnabled = True
363+ default_path = user_config .get_thirdparty_ext_root_dirs (include_default = True )[0 ]
364+ self .custom_ext_install_path_tb .Text = default_path
365+ self .show_element (self .install_custom_ext_b )
366+ self ._update_ext_info_from_git_fields ()
367+
368+ def _update_ext_info_from_git_fields (self ):
369+ """Update extension details panel from Git information fields when in add-new mode."""
370+ if self .custom_git_url_tb .IsReadOnly :
371+ return
372+ name = ""
373+ if getattr (self , "custom_ext_name_tb" , None ):
374+ name = self .custom_ext_name_tb .Text .strip ()
375+ url = self .custom_git_url_tb .Text .strip ()
376+ path = self .custom_ext_install_path_tb .Text .strip ()
377+ if name or url or path :
378+ self .show_element (self .ext_infostack )
379+ display_name = name or (_repo_name_from_git_url (url ) if url else "" ) or "(enter Git URL)"
380+ self .ext_name_l .Content = display_name
381+ self .ext_desc_l .Text = (url + " " ) if url else ""
382+ if url and (url .startswith ("http://" ) or url .startswith ("https://" )):
383+ self .ext_gitlink_t .Text = "(" + url + ")"
384+ self .ext_gitlink_hl .NavigateUri = framework .Uri (url )
385+ else :
386+ self .ext_gitlink_t .Text = ""
387+ self .ext_author_t .Text = ""
388+ self .ext_author_nolink_t .Text = ""
389+ self .ext_repolink_t .Text = ""
390+ if path :
391+ self .show_element (self .ext_installpath_tb )
392+ self .ext_installpath_tb .Text = (
393+ self .get_locale_string ("ExtensionInfo.InstallPathLink" ) + path
394+ )
395+ else :
396+ self .hide_element (self .ext_installpath_tb )
397+ self .hide_element (self .ext_dependencies_l )
398+ else :
399+ self .hide_element (self .ext_infostack )
354400
355- try :
356- extpkgs .install (self .selected_pkg .ext_pkg , sender .install_path )
357- self .Close ()
358- call_reload ()
359- except Exception as pkg_install_err :
360- logger .error ("Error installing package." " | {}" .format (pkg_install_err ))
361- self .Close ()
401+ def git_info_text_changed (self , sender , args ):
402+ """When Git information fields change, update the details panel if in add-new mode."""
403+ if self .custom_git_url_tb .IsReadOnly :
404+ return
405+ self ._update_ext_info_from_git_fields ()
406+ self .install_custom_ext_b .Content = self .get_locale_string ("AddCustomExtension.AddAndInstall" )
362407
363408 def custom_extension_path (self , sender , args ):
364409 "Picks a folder to install to"
365410 custom_path = forms .pick_folder (owner = self )
366411 if custom_path :
367412 custom_path = os .path .normpath (custom_path )
368413 self .custom_ext_install_path_tb .Text = custom_path if custom_path else ""
414+ self ._update_ext_info_from_git_fields ()
369415
370416 def install_custom_extension (self , sender , args ):
371- """Installs a custom extension from a Git URL
372-
373- This mimics the behavior of:
374- pyrevit extend ui ExtName https://github.com/user/repo.git
375- --dest="path" --token=token
376- """
417+ """Installs a custom extension from a Git URL or the selected catalog extension."""
377418
378419 try :
379- # Get values from UI
420+ # Catalog install: selected extension from list, not yet installed
421+ if self .selected_pkg and not self .selected_pkg .ext_pkg .is_installed :
422+ dest_path = self .custom_ext_install_path_tb .Text
423+ if not dest_path :
424+ ext_dirs = user_config .get_thirdparty_ext_root_dirs (include_default = True )
425+ dest_path = ext_dirs [0 ]
426+ token = self .custom_token_pb .Password .strip ()
427+ if token :
428+ self .selected_pkg .ext_pkg .config .private_repo = True
429+ self .selected_pkg .ext_pkg .config .token = token
430+ extpkgs .install (self .selected_pkg .ext_pkg , dest_path )
431+ self ._refresh_extension_list ()
432+ self .Close ()
433+ call_reload ()
434+ return
435+
436+ # Add new extension from Git URL
380437 git_url = self .custom_git_url_tb .Text .strip ()
381- ext_name = self .custom_ext_name_tb .Text .strip ()
438+ _name_tb = getattr (self , "custom_ext_name_tb" , None )
439+ ext_name = (_name_tb .Text .strip () if _name_tb else "" ) or _repo_name_from_git_url (git_url )
382440 token = self .custom_token_pb .Password .strip ()
383441
384442 # Validation
@@ -387,7 +445,7 @@ def install_custom_extension(self, sender, args):
387445 return
388446
389447 if not ext_name :
390- forms .alert ("Please enter an extension name." , exitscript = False )
448+ forms .alert ("Could not derive extension name from URL. Please enter a Git URL with a repo path (e.g. .../owner/repo.git) ." , exitscript = False )
391449 return
392450
393451 # Check if URL is valid git URL
@@ -402,24 +460,15 @@ def install_custom_extension(self, sender, args):
402460 )
403461 return
404462
405- # If token is provided, inject it into the URL
406- if token :
407- # For HTTPS URLs, inject token
408- if git_url .startswith ("https://" ) or git_url .startswith ("http://" ):
409- # Parse URL to inject token
410- # Format: https://oauth2:TOKEN@github.com/user/repo.git
411- url_parts = git_url .split ("://" , 1 )
412- if len (url_parts ) == 2 :
413- protocol = url_parts [0 ]
414- rest = url_parts [1 ]
415-
416- # Remove any existing credentials
417- if "@" in rest :
418- # Already has credentials, replace them
419- rest = rest .split ("@" , 1 )[1 ]
420-
421- # Inject token (use 'oauth2' as username for GitLab compatibility)
422- git_url = "{0}://oauth2:{1}@{2}" .format (protocol , token , rest )
463+ # Use a clean URL; token is passed via config and used by git_clone
464+ # (embedding credentials in the URL can cause "too many redirects or
465+ # authentication replays" with libgit2)
466+ if git_url .startswith ("https://" ) or git_url .startswith ("http://" ):
467+ if "@" in git_url .split ("://" , 1 )[1 ].split ("/" )[0 ]:
468+ # Strip existing credentials from URL
469+ protocol , rest = git_url .split ("://" , 1 )
470+ rest = rest .split ("@" , 1 )[- 1 ]
471+ git_url = protocol + "://" + rest
423472
424473 # Get default extension directory
425474 dest_path = self .custom_ext_install_path_tb .Text
@@ -451,6 +500,7 @@ def install_custom_extension(self, sender, args):
451500 temp_pkg .config .token = token
452501
453502 extpkgs .install (temp_pkg , dest_path )
503+ self ._refresh_extension_list ()
454504
455505 forms .alert (
456506 'Extension "{}" installed successfully! \n '
@@ -489,6 +539,10 @@ def remove_ext_pkg(self, sender, args):
489539 call_reload ()
490540 except Exception as pkg_remove_err :
491541 logger .error ("Error removing package. | {}" .format (pkg_remove_err ))
542+ forms .alert (
543+ "Error removing extension:\n {}" .format (str (pkg_remove_err )),
544+ exitscript = False ,
545+ )
492546
493547
494548def open_ext_dirs_in_explorer (ext_dirs_list ):
0 commit comments