├── .python-version ├── plugin ├── commands │ ├── __init__.py │ └── wcm_toggle_open_with.py ├── constants.py ├── settings.py ├── __init__.py ├── types.py ├── helpers.py └── core.py ├── messages.json ├── WindowsContextMenu.sublime-settings ├── boot.py ├── messages └── update_message.md ├── CHANGELOG.md ├── LICENSE ├── README.md └── menus └── Main.sublime-menu /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /plugin/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.2.1": "messages/update_message.md", 3 | "install": "README.md" 4 | } 5 | -------------------------------------------------------------------------------- /plugin/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | assert __package__ 4 | 5 | PLUGIN_NAME = __package__.partition(".")[0] 6 | -------------------------------------------------------------------------------- /WindowsContextMenu.sublime-settings: -------------------------------------------------------------------------------- 1 | { 2 | // Customize the menu text in your localization. 3 | // If you change this, the text in existing menus will be updated immediately. 4 | "menu_text": "Open with {app.name}" 5 | } 6 | -------------------------------------------------------------------------------- /boot.py: -------------------------------------------------------------------------------- 1 | def reload_plugin() -> None: 2 | import sys 3 | 4 | # remove all previously loaded plugin modules 5 | prefix = f"{__package__}." 6 | for module_name in tuple(filter(lambda m: m.startswith(prefix) and m != __name__, sys.modules)): 7 | del sys.modules[module_name] 8 | 9 | 10 | reload_plugin() 11 | 12 | try: 13 | import winreg # noqa: F401 14 | except ModuleNotFoundError: 15 | pass 16 | else: 17 | from .plugin import * # noqa: F401, F403 18 | -------------------------------------------------------------------------------- /messages/update_message.md: -------------------------------------------------------------------------------- 1 | WindowsContextMenu has been updated. To see the changelog, visit 2 | Preferences » Package Settings » Windows Context Menu » CHANGELOG 3 | 4 | ## 1.2.1 5 | 6 | - refactor: use `sublime_text.exe` rather than `subl.exe` 7 | 8 | Same for `sublime_merge.exe` / `smerge.exe`. 9 | See https://github.com/jfcherng-sublime/ST-WindowsContextMenu/issues/2 for more details. 10 | 11 | Unfortunately, to make this change work for you, you have to re-add existing context menus. 12 | -------------------------------------------------------------------------------- /plugin/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import sublime 6 | 7 | from .constants import PLUGIN_NAME 8 | 9 | 10 | def get_plugin_setting(key: str, default: Any | None = None) -> Any: 11 | return get_plugin_settings().get(key, default) 12 | 13 | 14 | def get_plugin_settings() -> sublime.Settings: 15 | return sublime.load_settings(f"{PLUGIN_NAME}.sublime-settings") 16 | 17 | 18 | def get_st_setting(key: str, default: Any | None = None) -> Any: 19 | return get_st_settings().get(key, default) 20 | 21 | 22 | def get_st_settings() -> sublime.Settings: 23 | return sublime.load_settings("Preferences.sublime-settings") 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # WindowsContextMenu Changelog 2 | 3 | ## 2.0.0 4 | 5 | - refactor: use py313 features 6 | 7 | ## 1.2.2 8 | 9 | - refactor: update py38 syntax 10 | - chore: update env 11 | - docs: fix build status badge in readme 12 | - chore: update `.gitattributes` 13 | 14 | ## 1.2.1 15 | 16 | - refactor: use `sublime_text.exe` rather than `subl.exe` 17 | 18 | Same for `sublime_merge.exe` / `smerge.exe`. 19 | See https://github.com/jfcherng-sublime/ST-WindowsContextMenu/issues/2 for more details. 20 | 21 | Unfortunately, to make this change work for you, you have to re-add existing context menus. 22 | 23 | ## 1.2.0 24 | 25 | - feat: update existing menu text when text in settings changed 26 | 27 | ## 1.1.1 28 | 29 | - fix: SM dir detection when `sublime_merge_path` is set 30 | 31 | ## 1.1.0 32 | 33 | - feat: ability to customize menu text 34 | 35 | ## 1.0.0 36 | 37 | - initial release 38 | -------------------------------------------------------------------------------- /plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | # import all listeners and commands 4 | from .commands.wcm_toggle_open_with import WcmToggleOpenWithCommand 5 | from .constants import PLUGIN_NAME 6 | from .helpers import enabled_app_context_menu_sets 7 | from .settings import get_plugin_setting, get_plugin_settings 8 | 9 | __all__ = ( 10 | # ST: core 11 | "plugin_loaded", 12 | "plugin_unloaded", 13 | # ST: commands 14 | "WcmToggleOpenWithCommand", 15 | ) 16 | 17 | 18 | def plugin_loaded() -> None: 19 | """Called when the plugin is loaded.""" 20 | settings = get_plugin_settings() 21 | settings.add_on_change(PLUGIN_NAME, _on_settings_changed) 22 | 23 | 24 | def plugin_unloaded() -> None: 25 | """Called when the plugin is unloaded.""" 26 | 27 | 28 | def _on_settings_changed() -> None: 29 | for app_menu_set in enabled_app_context_menu_sets(): 30 | app_menu_set.update_menu_text(get_plugin_setting("menu_text")) 31 | -------------------------------------------------------------------------------- /plugin/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import Literal 6 | 7 | import sublime 8 | 9 | from .settings import get_st_setting 10 | 11 | 12 | @dataclass 13 | class AppInfo: 14 | name: str 15 | nickname: str 16 | exe_name: str 17 | cmd_exe_name: str 18 | 19 | @property 20 | def app_dir(self) -> Path | None: 21 | if self.nickname == "st": 22 | return Path(sublime.executable_path()).parent 23 | if self.nickname == "sm": 24 | if (sm_path := Path(get_st_setting("sublime_merge_path", ""))).is_file(): 25 | return sm_path.parent 26 | # convention 27 | if (default := Path(R"C:\Program Files\Sublime Merge")).is_dir(): 28 | return default 29 | return None 30 | 31 | 32 | @dataclass 33 | class MenuTarget: 34 | type: Literal["file", "directory", "directory_background"] 35 | reg_key: str 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 Jack Cherng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /plugin/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | from itertools import product 5 | 6 | from .core import AppContextMenuSet 7 | from .types import AppInfo, MenuTarget 8 | 9 | APP_INFOS = { 10 | "sublime_text": AppInfo( 11 | name="Sublime Text", 12 | nickname="st", 13 | exe_name="sublime_text.exe", 14 | cmd_exe_name="subl.exe", 15 | ), 16 | "sublime_merge": AppInfo( 17 | name="Sublime Merge", 18 | nickname="sm", 19 | exe_name="sublime_merge.exe", 20 | cmd_exe_name="smerge.exe", 21 | ), 22 | } 23 | 24 | MENU_TARGETS = { 25 | "file": MenuTarget( 26 | type="file", 27 | reg_key=R"SOFTWARE\Classes\*\shell", 28 | ), 29 | "directory": MenuTarget( 30 | type="directory", 31 | reg_key=R"SOFTWARE\Classes\Directory\shell", 32 | ), 33 | "directory_background": MenuTarget( 34 | type="directory_background", 35 | reg_key=R"SOFTWARE\Classes\Directory\Background\shell", 36 | ), 37 | } 38 | 39 | 40 | def enabled_app_context_menu_sets() -> Generator[AppContextMenuSet]: 41 | for app, target in product(APP_INFOS.keys(), MENU_TARGETS.keys()): 42 | if (app_menu_set := parse_app_and_target(app, target)).exists(): 43 | yield app_menu_set 44 | 45 | 46 | def parse_app_and_target(app_name: str, target_name: str) -> AppContextMenuSet: 47 | if not (app_info := APP_INFOS.get(app_name)): 48 | raise ValueError 49 | 50 | if target_name == "_all_": 51 | targets = [ 52 | target 53 | for target in MENU_TARGETS.values() 54 | # cannot open a file with SM 55 | if not (app_info.nickname == "sm" and target.type == "file") 56 | ] 57 | else: 58 | targets = [MENU_TARGETS[target_name]] # may KeyError 59 | 60 | return AppContextMenuSet(app_info, targets) 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ST-WindowsContextMenu 2 | 3 | [![Required ST Build](https://img.shields.io/badge/ST-4105+-orange.svg?style=flat-square&logo=sublime-text)](https://www.sublimetext.com) 4 | [![GitHub Actions](https://img.shields.io/github/actions/workflow/status/jfcherng-sublime/ST-WindowsContextMenu/python.yml?branch=main&style=flat-square)](https://github.com/jfcherng-sublime/ST-WindowsContextMenu/actions) 5 | [![Package Control](https://img.shields.io/packagecontrol/dt/WindowsContextMenu?style=flat-square)](https://packagecontrol.io/packages/WindowsContextMenu) 6 | [![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/jfcherng-sublime/ST-WindowsContextMenu?style=flat-square&logo=github)](https://github.com/jfcherng-sublime/ST-WindowsContextMenu/tags) 7 | [![Project license](https://img.shields.io/github/license/jfcherng-sublime/ST-WindowsContextMenu?style=flat-square&logo=github)](https://github.com/jfcherng-sublime/ST-WindowsContextMenu/blob/main/LICENSE) 8 | [![GitHub stars](https://img.shields.io/github/stars/jfcherng-sublime/ST-WindowsContextMenu?style=flat-square&logo=github)](https://github.com/jfcherng-sublime/ST-WindowsContextMenu/stargazers) 9 | [![Donate to this project using Paypal](https://img.shields.io/badge/paypal-donate-blue.svg?style=flat-square&logo=paypal)](https://www.paypal.me/jfcherng/5usd) 10 | 11 | This is a Sublime Text plugin which adds/removes "classic" Windows context menu for Sublime Text/Merge. 12 | 13 | ![screenshot](https://raw.githubusercontent.com/jfcherng-sublime/ST-WindowsContextMenu/main/docs/screenshot.png) 14 | 15 | ⚠️ This plugin 16 | 17 | - Adds/removes the context menu only for the current Windows user, so no admin privilege is required. 18 | - Won't work with Win11's new context menu since it doesn't seem to be purely controlled by registry. 19 | 20 | ## Installation 21 | 22 | This package is available on [Package Control][package-control] by the name of [WindowsContextMenu][windowscontextmenu]. 23 | 24 | ## Usage 25 | 26 | Go to Sublime Text's main menu » `Preferences` » `Windows Context Menu`. 27 | 28 | - Sublime Text 29 | 30 | - All context menus 31 | - Context menu for file 32 | - Context menu for directory 33 | - Context menu for directory background 34 | 35 | - Sublime Merge 36 | 37 | - All context menus 38 | - Context menu for directory 39 | - Context menu for directory background 40 | 41 | ## Settings 42 | 43 | ```js 44 | { 45 | // Customize the menu text in your localization. 46 | // If you change this, the text in existing menus will be updated immediately. 47 | "menu_text": "Open with {app.name}", 48 | } 49 | ``` 50 | 51 | [windowscontextmenu]: https://packagecontrol.io/packages/WindowsContextMenu 52 | [package-control]: https://packagecontrol.io 53 | -------------------------------------------------------------------------------- /plugin/commands/wcm_toggle_open_with.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from functools import wraps 5 | from pathlib import Path 6 | from typing import Any, TypeVar, cast 7 | 8 | import sublime 9 | import sublime_plugin 10 | 11 | from ..core import AppContextMenuSet 12 | from ..helpers import parse_app_and_target 13 | from ..settings import get_plugin_setting 14 | 15 | _T_AnyCallable = TypeVar("_T_AnyCallable", bound=Callable[..., Any]) 16 | 17 | 18 | def _provide_app_menu_set(error_prompt: bool = False) -> Callable[[_T_AnyCallable], _T_AnyCallable]: 19 | def decorator(func: _T_AnyCallable) -> _T_AnyCallable: 20 | @wraps(func) 21 | def wrapper(self: sublime_plugin.Command, app: str, target: str, **kwargs) -> Any: 22 | try: 23 | app_menu_set = parse_app_and_target(app, target) 24 | except Exception: 25 | if error_prompt: 26 | sublime.error_message(f"Unsupported args: {app = }, {target = }") 27 | return 28 | app_menu_set = None 29 | return func(self, app_menu_set=app_menu_set, **kwargs) 30 | 31 | return cast(_T_AnyCallable, wrapper) 32 | 33 | return decorator 34 | 35 | 36 | class WcmToggleOpenWithCommand(sublime_plugin.ApplicationCommand): 37 | @_provide_app_menu_set(error_prompt=False) 38 | def is_checked(self, app_menu_set: AppContextMenuSet | None) -> bool: # type: ignore 39 | return bool(app_menu_set and self._is_checked(app_menu_set)) 40 | 41 | @_provide_app_menu_set(error_prompt=True) 42 | def run(self, app_menu_set: AppContextMenuSet) -> None: 43 | # currently enabled => we want to disable 44 | if self._is_checked(app_menu_set): 45 | app_menu_set.remove() 46 | # currently disable => we want to enabled 47 | else: 48 | app = app_menu_set.app 49 | menu_text: str = get_plugin_setting("menu_text") 50 | sublime.message_dialog(f"Please select {app.name} directory...") 51 | sublime.select_folder_dialog( 52 | lambda folder: self._add_select_app_dir_callback( 53 | app_menu_set, 54 | folder, # type: ignore 55 | menu_text, 56 | ), 57 | str(app.app_dir or ""), # default folder for selecting is not working on Windows (ST bug?) 58 | ) 59 | 60 | @staticmethod 61 | def _is_checked(app_menu_set: AppContextMenuSet) -> bool: 62 | return app_menu_set.exists() 63 | 64 | @staticmethod 65 | def _add_select_app_dir_callback( 66 | app_menu_set: AppContextMenuSet, 67 | app_dir: str | Path | None, 68 | menu_text: str, 69 | ) -> None: 70 | if not app_dir: 71 | return 72 | app_dir = Path(app_dir) 73 | 74 | # ensure necessary executables exist 75 | app = app_menu_set.app 76 | for file in {app.exe_name, app.cmd_exe_name}: 77 | if not (app_dir / file).is_file(): 78 | sublime.error_message(f'Can not find "{file}" in "{app_dir}".') 79 | return 80 | 81 | app_menu_set.add(app_dir, menu_text) 82 | -------------------------------------------------------------------------------- /plugin/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | import winreg 5 | from collections.abc import Sequence 6 | from pathlib import Path 7 | 8 | from .types import AppInfo, MenuTarget 9 | 10 | type KeyType = winreg.HKEYType | int 11 | 12 | 13 | class AppContextMenuSet: 14 | def __init__(self, app: AppInfo, targets: Sequence[MenuTarget]) -> None: 15 | self.app = app 16 | self.targets = list(targets) 17 | 18 | def __len__(self) -> int: 19 | return len(self.targets) 20 | 21 | def exists(self) -> bool: 22 | return bool(self.targets and all(self._exists(self.app, target) for target in self.targets)) 23 | 24 | def add(self, app_dir: Path, menu_text: str) -> None: 25 | for target in self.targets: 26 | self._add(self.app, target, app_dir=app_dir, menu_text=menu_text) 27 | 28 | def remove(self) -> None: 29 | for target in self.targets: 30 | self._remove(self.app, target) 31 | 32 | def update_menu_text(self, menu_text: str) -> None: 33 | for target in self.targets: 34 | self._update_menu_text(self.app, target, menu_text) 35 | 36 | @staticmethod 37 | def _exists(app: AppInfo, target: MenuTarget) -> bool: 38 | try: 39 | winreg.OpenKey(winreg.HKEY_CURRENT_USER, Rf"{target.reg_key}\{app.name}") 40 | return True 41 | except OSError: 42 | return False 43 | 44 | @staticmethod 45 | def _add(app: AppInfo, target: MenuTarget, *, app_dir: Path, menu_text: str) -> None: 46 | with winreg.CreateKey(winreg.HKEY_CURRENT_USER, Rf"{target.reg_key}\{app.name}") as app_key: 47 | winreg.SetValueEx(app_key, None, 0, winreg.REG_SZ, menu_text.format(app=app)) 48 | winreg.SetValueEx(app_key, "Icon", 0, winreg.REG_SZ, f"{app_dir / app.exe_name},0") 49 | with winreg.CreateKey(winreg.HKEY_CURRENT_USER, Rf"{target.reg_key}\{app.name}\command") as app_cmd_key: 50 | arg = '"%V"' if target.type == "directory_background" else '"%1"' 51 | winreg.SetValueEx(app_cmd_key, None, 0, winreg.REG_SZ, f"{app_dir / app.exe_name} {arg}") 52 | 53 | @staticmethod 54 | def _remove(app: AppInfo, target: MenuTarget) -> None: 55 | def delete_key(key: KeyType, subkey: str, recursive: bool = False) -> None: 56 | # delete children first 57 | if recursive: 58 | with winreg.OpenKey(key, subkey) as parent: 59 | try: 60 | for idx in itertools.count(0, step=1): 61 | delete_key(parent, winreg.EnumKey(parent, idx), recursive=recursive) 62 | except OSError: 63 | pass # end of enumeration 64 | winreg.DeleteKey(key, subkey) 65 | 66 | delete_key(winreg.HKEY_CURRENT_USER, Rf"{target.reg_key}\{app.name}", recursive=True) 67 | 68 | @staticmethod 69 | def _update_menu_text(app: AppInfo, target: MenuTarget, menu_text: str) -> None: 70 | try: 71 | with winreg.OpenKeyEx( 72 | winreg.HKEY_CURRENT_USER, Rf"{target.reg_key}\{app.name}", 0, winreg.KEY_WRITE 73 | ) as app_key: 74 | winreg.SetValueEx(app_key, None, 0, winreg.REG_SZ, menu_text.format(app=app)) 75 | except OSError: 76 | pass 77 | -------------------------------------------------------------------------------- /menus/Main.sublime-menu: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "preferences", 4 | "children": [ 5 | { 6 | "id": "package-settings", 7 | "children": [ 8 | { 9 | "caption": "WindowsContextMenu", 10 | "children": [ 11 | { 12 | "caption": "-", 13 | }, 14 | { 15 | "caption": "Settings", 16 | "command": "edit_settings", 17 | "args": { 18 | "base_file": "${packages}/WindowsContextMenu/WindowsContextMenu.sublime-settings", 19 | "default": "{\n\t$0\n}\n", 20 | }, 21 | }, 22 | { 23 | "caption": "-", 24 | }, 25 | { 26 | "caption": "README", 27 | "command": "open_file", 28 | "args": { 29 | "file": "${packages}/WindowsContextMenu/README.md", 30 | }, 31 | }, 32 | { 33 | "caption": "CHANGELOG", 34 | "command": "open_file", 35 | "args": { 36 | "file": "${packages}/WindowsContextMenu/CHANGELOG.md", 37 | }, 38 | }, 39 | { 40 | "caption": "-", 41 | }, 42 | ], 43 | }, 44 | ], 45 | }, 46 | { 47 | "caption": "-", 48 | }, 49 | { 50 | "id": "wcm", 51 | "caption": "Windows Context Menu", 52 | "children": [ 53 | { 54 | "caption": "Sublime Text", 55 | "children": [ 56 | { 57 | "caption": "All context menus", 58 | "command": "wcm_toggle_open_with", 59 | "args": { 60 | "app": "sublime_text", 61 | "target": "_all_", 62 | }, 63 | }, 64 | { 65 | "caption": "-", 66 | }, 67 | { 68 | "caption": "Context menu for file", 69 | "command": "wcm_toggle_open_with", 70 | "args": { 71 | "app": "sublime_text", 72 | "target": "file", 73 | }, 74 | }, 75 | { 76 | "caption": "Context menu for directory", 77 | "command": "wcm_toggle_open_with", 78 | "args": { 79 | "app": "sublime_text", 80 | "target": "directory", 81 | }, 82 | }, 83 | { 84 | "caption": "Context menu for directory background", 85 | "command": "wcm_toggle_open_with", 86 | "args": { 87 | "app": "sublime_text", 88 | "target": "directory_background", 89 | }, 90 | }, 91 | ], 92 | }, 93 | { 94 | "caption": "Sublime Merge", 95 | "children": [ 96 | { 97 | "caption": "All context menus", 98 | "command": "wcm_toggle_open_with", 99 | "args": { 100 | "app": "sublime_merge", 101 | "target": "_all_", 102 | }, 103 | }, 104 | { 105 | "caption": "-", 106 | }, 107 | { 108 | "caption": "Context menu for directory", 109 | "command": "wcm_toggle_open_with", 110 | "args": { 111 | "app": "sublime_merge", 112 | "target": "directory", 113 | }, 114 | }, 115 | { 116 | "caption": "Context menu for directory background", 117 | "command": "wcm_toggle_open_with", 118 | "args": { 119 | "app": "sublime_merge", 120 | "target": "directory_background", 121 | }, 122 | }, 123 | ], 124 | }, 125 | ], 126 | }, 127 | ], 128 | }, 129 | ] 130 | --------------------------------------------------------------------------------