Skip to content

Instantly share code, notes, and snippets.

@OdatNurd
Last active July 8, 2024 03:37
Show Gist options
  • Save OdatNurd/fd6322a665c1730c7e16930b3a84999a to your computer and use it in GitHub Desktop.
Save OdatNurd/fd6322a665c1730c7e16930b3a84999a to your computer and use it in GitHub Desktop.
Browse all Sublime Text commands provided by plugins
import sublime
import sublime_plugin
from sublime import QuickPanelItem
import inspect
import re
import sys
from sublime_plugin import application_command_classes
from sublime_plugin import window_command_classes
from sublime_plugin import text_command_classes
## ----------------------------------------------------------------------------
KIND_APPLICATION = (sublime.KIND_ID_FUNCTION, "A", "Application Command")
KIND_WINDOW = (sublime.KIND_ID_FUNCTION, "W", "Window Command")
KIND_TEXT = (sublime.KIND_ID_FUNCTION, "T", "Text Command")
cmd_types = {
"app": {
"name": "ApplicationCommand",
"commands": application_command_classes,
"kind": KIND_APPLICATION
},
"wnd": {
"name": "WindowCommand",
"commands": window_command_classes,
"kind": KIND_WINDOW
},
"txt": {
"name": "TextCommand",
"commands": text_command_classes,
"kind": KIND_TEXT
}
}
## ----------------------------------------------------------------------------
def _navigate_to(view, symbol):
"""
Navigate to the symbol in the given view.
"""
view.window().run_command("goto_definition", {"symbol": symbol})
## ----------------------------------------------------------------------------
class BrowseCommandsCommand(sublime_plugin.ApplicationCommand):
"""
Open a quick panel with a list of all commands known to the Sublime plugin
host for the user to choose from. Picking a command opens the plugin file
containing the command and navigates to the location where the command is
defined.
This command is exposed to both plugin hosts (by way of being in two
packages) with the Python 3.3 plugin host triggering the command from the
Python 3.8 host, so that we can gather all available commands.
"""
arg_re = re.compile(r"^\(self(?:, )?(?:edit, |edit)?(.*)\)$")
def legacy(self):
return sys.version_info < (3, 8, 0)
def name(self):
return "browse_commands_33" if self.legacy() else "browse_commands"
def run(self, cmd_dict=None):
cmd_dict = cmd_dict or {}
for cmd_type, cmd_info in cmd_types.items():
self.get_commands(cmd_type, cmd_info["commands"], cmd_dict)
if self.legacy():
return sublime.run_command('browse_commands', {"cmd_dict": cmd_dict})
items = []
for command,details in cmd_dict.items():
items.append(QuickPanelItem(details["name"],
"<i>%s</i>" % details["args"],
"%s.%s" % (details["pkg"] , details["mod"]),
cmd_types[details["type"]]["kind"]))
items.sort(key=lambda o: o.trigger)
sublime.active_window().show_quick_panel(items,
lambda i: self.pick(i, items, cmd_dict))
def pick(self, idx, items, cmd_dict):
if idx == -1:
return
cmd = cmd_dict[items[idx].trigger]
module = cmd["mod"].replace('.', '/')
res = '${packages}/%s/%s.py' % (cmd["pkg"], module)
sublime.active_window().run_command('open_file', {'file': res})
view = sublime.active_window().active_view()
if view.is_loading():
view.settings().set("_jump_to_class", cmd["class"])
else:
_navigate_to(view, cmd["class"])
def get_commands(self, cmd_type, commands, cmd_dict_out):
"""
Given a list of commands of a particular type, decode each command in
the list into a dictionary that describes it and store it into the
output dictionary keyed by the package that defined it.
The output dictionary gains keys for each package, where the values are
dictionaries which contain keys that describe the commands of each of
the supported typed.
"""
for command in commands:
decoded = self.decode_cmd(command, cmd_type)
cmd_dict_out[decoded["name"]] = decoded
def decode_cmd(self, command, cmd_type):
"""
Given a class that implements a command of the provided type, return
back a dictionary that contains the properties of the command for later
display.
"""
return {
"type": cmd_type,
"pkg": command.__module__.split(".")[0],
"mod": ".".join(command.__module__.split(".")[1:]),
"name": self.get_name(command),
"args": self.get_args(command),
"class": command.__name__
}
def get_args(self, cmd_class):
"""
Return a string that represents the arguments to the run method of the
Sublime command class provided, edited to remove the internal python
arguments that are not needed to invoke the command from Sublime.
"""
args = str(inspect.signature(cmd_class.run))
return self.arg_re.sub(r"{ \1 }", args)
def get_name(self, cmd_class):
"""
Return the internal Sublime command name as Sublime would infer it from
the name of the implementing class. This is taken from the name()
method of the underlying Command class in sublime_plugin.py.
"""
clsname = cmd_class.__name__
name = clsname[0].lower()
last_upper = False
for c in clsname[1:]:
if c.isupper() and not last_upper:
name += '_'
name += c.lower()
else:
name += c
last_upper = c.isupper()
if name.endswith("_command"):
name = name[0:-8]
return name
## ----------------------------------------------------------------------------
class CommandJumpListener(sublime_plugin.ViewEventListener):
@classmethod
def is_applicable(cls, settings):
return settings.has("_jump_to_class")
def on_load(self):
symbol = self.view.settings().get("_jump_to_class")
self.view.settings().erase("_jump_to_class")
sublime.set_timeout(lambda: _navigate_to(self.view, symbol), 0)
[
{ "caption": "Browse Command List", "command": "browse_commands_33" },
]
@OdatNurd
Copy link
Author

This is a simple plugin that allows you to browse all commands provided by plugins (so everything but commands implemented in the core) in a quick panel, which allows you to filter by name. Each entry has a kind that indicates if it is an ApplicationCommand, WindowCommand or TextCommand, and also provides details on arguments expected and the plugin and module where the command is defined. Choosing a command will open the appropriate file and jump you to the definition of the command.

This requires a build of Sublime Text somewhere >= 4080 or so (i.e. it will not work in Sublime Text 3).

The plugin can only detect commands that are running in the same plugin host that it's running in. So to deploy this, you need to install it twice; once into a package with all of the files seen here, and then again in a different package containing only the plugin file.

Choosing the command from the command palette will run the version running in the legacy host, which will gather commands running there and forward them to the command running in the newer host, which will display the whole list.

@OdatNurd
Copy link
Author

The development of this plugin was done in Live Stream #76, for those interested in how it works.

@AmjadHD
Copy link

AmjadHD commented Dec 11, 2020

Did you consider adding it to PackageDev ?

@OdatNurd
Copy link
Author

I did not, but to work fully it requires the package be installed once for each plugin host so there needs to be some extra stuff happening there to get things going. I'm not sure what the best method to do that in a generic sense is, although AutomaticPackageReloader does something similar to support reloading in both plugin hosts.

@AmjadHD
Copy link

AmjadHD commented Dec 11, 2020

I don't know, but I see it as potentially helpful to be included in PD, maybe ask on Discord ?

@jfcherng
Copy link

I seem to find a weird problem. Theoretically, if I want it to work for finding both py33/38 commands, I should have two copy of this plugin in different directories. One of them is executed in the py33 plugin_host and the other one is in py38, which needs a .python-version with 3.8 in it. So that console shows the following when ST starts up.

reloading plugin my_plugin_38.browse_all_commands
reloading python 3.3 plugin my_plugin_33.browse_all_commands

After doing that, I will need the following menu for each of them:

[
  {
    "caption": "Browse Command List (py33)",
    "command": "browse_commands_33",
  },
  {
    "caption": "Browse Command List (py38)",
    "command": "browse_commands",
  },
]

The above setup works well. Both commands are doing their jobs indeed.

The weird thing is that, say, I change something in the py38 file, such as the ApplicationCommand kind icon from A to X, it affects both browse_commands_33 and browse_commands commands from the command palette. Changing something in the py33 file seems useless.

@OdatNurd
Copy link
Author

If you want them to both be separate commands, you need to adjust these lines: https://gist.github.com/OdatNurd/fd6322a665c1730c7e16930b3a84999a#file-browse-py-L79-L80

In the 3.3 host, the command gathers a list of commands and then executes the version of the command in the 3.8 host with them so that it can produce one unified list with all commands regardless of what host they're in. So as presented above modifying how things display in the 3.3 version has no effect because that part of the command never gets executed.

@jfcherng
Copy link

Ah, it's that I didn't read the codes fully but just saw name() and legacy(). Thanks for explanation!

@FichteFoll
Copy link

I did not, but to work fully it requires the package be installed once for each plugin host so there needs to be some extra stuff happening there to get things going.

FYI, PD needs that functionality as well for its command completions, which are partially sourced from the plugin environment (like this plugin does) and partially from a bundled file containing information about the built-in commands.

@OdatNurd
Copy link
Author

OdatNurd commented Nov 1, 2021

For anyone interested in using this plugin, there's now an official video that talks about it and tells you how to install it.

@FichteFoll
Copy link

This is also available as a package on Package Control now: CommandsBrowser (github).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment