├── .python-version ├── .gitignore ├── icons ├── dots.png └── comment.png ├── messages ├── 3.0.1.txt ├── 3.0.3.txt ├── 3.0.0.txt ├── 4.0.0.txt ├── 4.0.1.txt ├── 3.0.2.txt └── install.txt ├── dependencies.json ├── messages.json ├── Main.sublime-menu ├── Default.sublime-commands ├── Default.sublime-keymap ├── plugin ├── logger.py └── settings.py ├── templates.py ├── LICENSE ├── colored_comments.sublime-settings ├── README.md └── colored_comments.py /.python-version: -------------------------------------------------------------------------------- 1 | 3.8 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .DS_Store 3 | *.pyc -------------------------------------------------------------------------------- /icons/dots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/ColoredComments/HEAD/icons/dots.png -------------------------------------------------------------------------------- /icons/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/ColoredComments/HEAD/icons/comment.png -------------------------------------------------------------------------------- /messages/3.0.1.txt: -------------------------------------------------------------------------------- 1 | Version 3.0.1 (May 14, 2020) 2 | ---------------------------- 3 | 4 | * Enable hot reload of settings -------------------------------------------------------------------------------- /dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": { 3 | "*": [ 4 | "sublime_aio", 5 | "sublime_lib" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /messages/3.0.3.txt: -------------------------------------------------------------------------------- 1 | Version 3.0.3 (May 18, 2020) 2 | ---------------------------- 3 | 4 | * NEW Sublime Text 4 Fork 5 | - f-strings 6 | - python 3.8 -------------------------------------------------------------------------------- /messages/3.0.0.txt: -------------------------------------------------------------------------------- 1 | Version 3.0.0 (May 13, 2020) 2 | -------------------------- 3 | 4 | * Fix the Color Manager from overriding user files 5 | * Modify command names -------------------------------------------------------------------------------- /messages/4.0.0.txt: -------------------------------------------------------------------------------- 1 | Version 3.0.3 (May 18, 2020) 2 | ---------------------------- 3 | 4 | * Remove color manager in favor of User creating Color Scheme Overrides 5 | -------------------------------------------------------------------------------- /messages/4.0.1.txt: -------------------------------------------------------------------------------- 1 | Version 3.0.3 (May 18, 2020) 2 | ---------------------------- 3 | 4 | * Implement commands using `sublime_aio` 5 | * Fix flicker for most highlighting 6 | * Add GoTo comment commands 7 | -------------------------------------------------------------------------------- /messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "install": "messages/install.txt", 3 | "3.0.0": "messages/3.0.0.txt", 4 | "3.0.1": "messages/3.0.1.txt", 5 | "3.0.2": "messages/3.0.2.txt", 6 | "3.0.3": "messages/3.0.3.txt" 7 | } -------------------------------------------------------------------------------- /messages/3.0.2.txt: -------------------------------------------------------------------------------- 1 | Version 3.0.2 (May 18, 2020) 2 | ---------------------------- 3 | 4 | * NEW Setting: disabled_syntax 5 | - Now you can specify if colored comments 6 | should be disabled on certain syntax's 7 | 8 | * NEW Command: Colored Comments: Clear Colorization 9 | - Useful when the file was edited outside of 10 | Sublime Text and the color regions were messed up -------------------------------------------------------------------------------- /messages/install.txt: -------------------------------------------------------------------------------- 1 | Colored Comments is the missing comment helpers that allows you to 2 | create patterns for comment identification. Which then colors the comments 3 | the color of your choice. 4 | 5 | Quick Start 6 | ----------- 7 | 8 | All commands are available in the command palette. 9 | 10 | To get started, first run the `Colored Comments: Generate Color Scheme` 11 | command from the command palette. Once done, comment of your choice will 12 | begin to be colored. 13 | 14 | Removing the comment color scheme, run the following: 15 | `Colored Comments: Remove Generated Color Scheme` 16 | 17 | -------------------------------------------------------------------------------- /Main.sublime-menu: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "preferences", 4 | "children": [ 5 | { 6 | "id": "package-settings", 7 | "children": [ 8 | { 9 | "caption": "Colored Comments", 10 | "children": [ 11 | { 12 | "caption": "Settings", 13 | "command": "edit_settings", 14 | "args": 15 | { 16 | "base_file": "${packages}/Colored Comments/colored_comments.sublime-settings", 17 | "default": "{\n\t$0\n}\n" 18 | } 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | ] -------------------------------------------------------------------------------- /Default.sublime-commands: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Colored Comments: Edit Color Scheme", 4 | "command": "colored_comments_edit_scheme" 5 | }, 6 | { 7 | "caption": "Colored Comments: GoTo Comment", 8 | "command": "colored_comments_list_tags", 9 | "args": { 10 | "tag_filter": null 11 | } 12 | }, 13 | { 14 | "caption": "Colored Comments: List All Tags", 15 | "command": "colored_comments_list_tags" 16 | }, 17 | { 18 | "caption": "Colored Comments: List All Tags in Current File", 19 | "command": "colored_comments_list_tags", 20 | "args": { 21 | "current_file_only": true 22 | } 23 | }, 24 | { 25 | "caption": "Colored Comments: Show Debug Logs", 26 | "command": "colored_comments_show_logs" 27 | }, 28 | { 29 | "caption": "Colored Comments: Settings", 30 | "command": "edit_settings", 31 | "args": { 32 | "base_file": "${packages}/Colored Comments/colored_comments.sublime-settings", 33 | "default": "{\n\t$0\n}\n" 34 | } 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /Default.sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | // List all colored comment tags in project 3 | { 4 | "keys": ["ctrl+shift+t"], 5 | "command": "colored_comments_list_tags" 6 | }, 7 | 8 | // List all colored comment tags in current file only 9 | { 10 | "keys": ["ctrl+shift+alt+t"], 11 | "command": "colored_comments_list_tags", 12 | "args": {"current_file_only": true} 13 | }, 14 | 15 | // List only TODOs in project 16 | { 17 | "keys": ["ctrl+alt+t"], 18 | "command": "colored_comments_list_tags", 19 | "args": {"tag_filter": "TODO"} 20 | }, 21 | 22 | // List only TODOs in current file 23 | { 24 | "keys": ["ctrl+alt+shift+t"], 25 | "command": "colored_comments_list_tags", 26 | "args": {"tag_filter": "TODO", "current_file_only": true} 27 | }, 28 | 29 | // List only FIXMEs in project 30 | { 31 | "keys": ["ctrl+alt+f"], 32 | "command": "colored_comments_list_tags", 33 | "args": {"tag_filter": "FIXME"} 34 | }, 35 | 36 | // List only FIXMEs in current file 37 | { 38 | "keys": ["ctrl+alt+shift+f"], 39 | "command": "colored_comments_list_tags", 40 | "args": {"tag_filter": "FIXME", "current_file_only": true} 41 | } 42 | ] -------------------------------------------------------------------------------- /plugin/logger.py: -------------------------------------------------------------------------------- 1 | log_debug = False 2 | log_exceptions = True 3 | _log_buffer = [] 4 | _MAX_LOG_BUFFER = 1000 # Maximum number of log entries to keep in memory 5 | 6 | 7 | def set_debug_logging(logging_enabled: bool) -> None: 8 | global log_debug 9 | log_debug = logging_enabled 10 | 11 | 12 | def debug(msg: str) -> None: 13 | if log_debug: 14 | _log_buffer.append(msg) 15 | if len(_log_buffer) > _MAX_LOG_BUFFER: 16 | _log_buffer.pop(0) # Remove oldest entry 17 | printf(msg) 18 | 19 | 20 | def printf(msg: str, prefix: str = "Colored Comments") -> None: 21 | print(f"{prefix}:{msg}") 22 | 23 | 24 | def dump_logs_to_panel(window) -> None: 25 | """Dump the log buffer to an output panel. 26 | 27 | Args: 28 | window: The Sublime Text window to create the panel in 29 | """ 30 | if not window: 31 | return 32 | 33 | panel = window.create_output_panel('colored_comments_logs') 34 | panel.set_read_only(False) 35 | panel.run_command('erase_view') 36 | 37 | # Add a header 38 | panel.run_command('append', {'characters': "=== Colored Comments Debug Logs ===\n\n"}) 39 | 40 | # Add each log entry 41 | for entry in _log_buffer: 42 | panel.run_command('append', {'characters': f"{entry}\n"}) 43 | 44 | panel.set_read_only(True) 45 | 46 | # Show the panel 47 | window.run_command('show_panel', {'panel': 'output.colored_comments_logs'}) 48 | -------------------------------------------------------------------------------- /templates.py: -------------------------------------------------------------------------------- 1 | """ 2 | Templates used by the Colored Comments plugin. 3 | """ 4 | 5 | # Color scheme template for comments highlighting 6 | SCHEME_TEMPLATE = """\ 7 | { 8 | // http://www.sublimetext.com/docs/3/color_schemes.html 9 | "variables": { 10 | "important_comment": "var(region.redish)", 11 | "deprecated_comment": "var(region.purplish)", 12 | "question_comment": "var(region.cyanish)", 13 | "todo_comment": "var(region.greenish)", 14 | "fixme_comment": "var(region.bluish)", 15 | "undefined_comment": "var(region.accent)", 16 | }, 17 | "globals": { 18 | // "foreground": "var(green)", 19 | }, 20 | "rules": [ 21 | { 22 | "name": "IMPORTANT COMMENTS", 23 | "scope": "comments.important", 24 | "foreground": "var(important_comment)", 25 | "background": "rgba(1,22,38, 0.1)", 26 | }, 27 | { 28 | "name": "DEPRECATED COMMENTS", 29 | "scope": "comments.deprecated", 30 | "foreground": "var(deprecated_comment)", 31 | "background": "rgba(1,22,38, 0.1)", 32 | }, 33 | { 34 | "name": "QUESTION COMMENTS", 35 | "scope": "comments.question", 36 | "foreground": "var(question_comment)", 37 | "background": "rgba(1,22,38, 0.1)", 38 | }, 39 | { 40 | "name": "TODO COMMENTS", 41 | "scope": "comments.todo", 42 | "foreground": "var(todo_comment)", 43 | "background": "rgba(1,22,38, 0.1)", 44 | }, 45 | { 46 | "name": "FIXME COMMENTS", 47 | "scope": "comments.fixme", 48 | "foreground": "var(fixme_comment)", 49 | "background": "rgba(1,22,38, 0.1)", 50 | }, 51 | { 52 | "name": "UNDEFINED COMMENTS", 53 | "scope": "comments.undefined", 54 | "foreground": "var(undefined_comment)", 55 | "background": "rgba(1,22,38, 0.1)", 56 | }, 57 | ], 58 | }""".replace(" ", "\t") 59 | 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Zachary Schulze 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 | 23 | MIT License 24 | 25 | Copyright (c) 2010-2014 Guillermo López-Anglada (Vintageous) 26 | Copyright (c) 2012-2019 FichteFoll 27 | 28 | Permission is hereby granted, free of charge, to any person obtaining a copy of 29 | this software and associated documentation files (the "Software"), to deal in 30 | the Software without restriction, including without limitation the rights to 31 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 32 | of the Software, and to permit persons to whom the Software is furnished to do 33 | so, subject to the following conditions: 34 | 35 | The above copyright notice and this permission notice shall be included in all 36 | copies or substantial portions of the Software. 37 | 38 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 39 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 40 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 41 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 42 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 43 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 44 | SOFTWARE. -------------------------------------------------------------------------------- /colored_comments.sublime-settings: -------------------------------------------------------------------------------- 1 | { 2 | // Enable debug logging 3 | "debug": false, 4 | // Enables continued matching of the previous tag 5 | "continued_matching": true, 6 | // Character to continue matching on 7 | "continued_matching_pattern": "-", 8 | // Automatically continue highlighting based on previous line without requiring the continuation pattern 9 | "auto_continue_highlight": false, 10 | // Delay in milliseconds before applying comment decorations after typing (debounce) 11 | "debounce_delay": 300, 12 | // Shows comment icon next to comments 13 | "comment_icon_enabled": false, 14 | // Which comment icon to use 15 | // Valid options: comment, dots 16 | "comment_icon": "dots", 17 | // Ignored Syntax List 18 | "disabled_syntax": [ 19 | "Packages/Text/Plain text.tmLanguage", 20 | "Packages/Markdown/MultiMarkdown.sublime-syntax", 21 | "Packages/Markdown/Markdown.sublime-syntax" 22 | ], 23 | // File scanning settings for tag search functionality 24 | // File extensions to skip when scanning for tags (case-insensitive) 25 | "skip_extensions": [ 26 | // Compiled/Binary files 27 | ".pyc", 28 | ".pyo", 29 | ".class", 30 | ".o", 31 | ".obj", 32 | ".exe", 33 | ".dll", 34 | ".so", 35 | ".dylib", 36 | // Archives 37 | ".jar", 38 | ".war", 39 | ".ear", 40 | ".zip", 41 | ".tar", 42 | ".gz", 43 | ".bz2", 44 | ".7z", 45 | // Images 46 | ".jpg", 47 | ".jpeg", 48 | ".png", 49 | ".gif", 50 | ".bmp", 51 | ".ico", 52 | ".svg", 53 | // Media files 54 | ".mp3", 55 | ".mp4", 56 | ".avi", 57 | ".mov", 58 | ".wmv", 59 | ".flv", 60 | ".webm", 61 | // Documents 62 | ".pdf", 63 | ".doc", 64 | ".docx", 65 | ".xls", 66 | ".xlsx", 67 | ".ppt", 68 | ".pptx" 69 | ], 70 | // Directory names to skip when scanning for tags 71 | "skip_dirs": [ 72 | "__pycache__", 73 | ".git", 74 | ".svn", 75 | ".hg", 76 | "node_modules", 77 | ".vscode", 78 | ".idea", 79 | ".vs", 80 | "bin", 81 | "obj", 82 | "build", 83 | "dist" 84 | ], 85 | // Default tags provided by the plugin 86 | // Set to {} to disable all default tags 87 | "default_tags": { 88 | "Important": { 89 | // The name of the scope being use in your color scheme file 90 | "scope": "source.python comments.important", 91 | // The actual identifier used to highlight the comments 92 | "identifier": "!", 93 | // Emoji icon for this tag type (used in tag lists and previews) 94 | "icon_emoji": "⚠️", 95 | // Enables sublime.DRAW_SOLID_UNDERLINE 96 | // Only noticable if outline = true 97 | "underline": false, 98 | // Enables sublime.DRAW_STIPPLED_UNDERLINE 99 | // Only noticable if outline = true 100 | "stippled_underline": false, 101 | // Enables sublime.DRAW_SSQUIGGLY_UNDERLINE 102 | // Only noticable if outline = true 103 | "squiggly_underline": false, 104 | // Enables sublime.DRAW_NO_FILL 105 | // This disables coloring of text 106 | // and allows for the outline of the text 107 | "outline": false, 108 | // Treats the identifier 109 | // as an regular expression 110 | "is_regex": false, 111 | // Enables ignorecase for the ideentifier 112 | "ignorecase": true, 113 | }, 114 | "Deprecated": { 115 | "scope": "comments.deprecated", 116 | "identifier": "*", 117 | "icon_emoji": "⚠️", 118 | }, 119 | "Question": { 120 | "scope": "comments.question", 121 | "identifier": "?", 122 | "icon_emoji": "❓", 123 | }, 124 | "TODO": { 125 | "scope": "comments.todo", 126 | "identifier": "TODO[:]?|todo[:]?", 127 | "is_regex": true, 128 | "ignorecase": true, 129 | "icon_emoji": "📋", 130 | }, 131 | "FIXME": { 132 | "scope": "comments.fixme", 133 | "identifier": "FIXME[:]?|fixme[:]?", 134 | "is_regex": true, 135 | "icon_emoji": "🔧", 136 | }, 137 | "UNDEFINED": { 138 | "scope": "comments.undefined", 139 | "identifier": "//[:]?", 140 | "is_regex": true, 141 | "icon_emoji": "❔", 142 | } 143 | }, 144 | // Custom tags that extend or override default_tags 145 | // These will be merged with default_tags 146 | // Custom tags with the same name will override default ones 147 | "tags": { 148 | // Example: Add a custom NOTE tag 149 | // "NOTE": { 150 | // "scope": "comments.note", 151 | // "identifier": "NOTE[:]?|note[:]?", 152 | // "is_regex": true, 153 | // "ignorecase": true 154 | // }, 155 | // Example: Override the Important tag to use different settings 156 | // "Important": { 157 | // "scope": "comments.critical", 158 | // "identifier": "!!!", 159 | // "outline": true 160 | // } 161 | // USAGE EXAMPLES: 162 | // 1. To add custom tags while keeping defaults: 163 | // Just add your tags here, they'll be merged with default_tags 164 | // 2. To disable ALL default tags and use only custom ones: 165 | // Set "default_tags": {} above, then add your tags here 166 | // 3. To modify a default tag: 167 | // Add a tag with the same name here - it will override the default 168 | // 4. To use only defaults: 169 | // Leave this section empty: "tags": {} 170 | // 5. To customize emoji icons: 171 | // Add "icon_emoji": "🔥" to any tag configuration 172 | // 173 | // Example custom tag with emoji: 174 | // "NOTE": { 175 | // "scope": "comments.note", 176 | // "identifier": "NOTE[:]?|note[:]?", 177 | // "is_regex": true, 178 | // "ignorecase": true, 179 | // "icon_emoji": "📝" 180 | // } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /plugin/settings.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import OrderedDict 3 | 4 | import sublime 5 | 6 | from . import logger as log 7 | class Settings(object): 8 | def __init__(self) -> None: 9 | self.debug = False 10 | self.continued_matching = True 11 | self.continued_matching_pattern = "-" 12 | self.auto_continue_highlight = False 13 | self.debounce_delay = 300 # Debounce delay in milliseconds 14 | self.comment_icon_enabled = True 15 | self.comment_icon = "dots" 16 | self.disabled_syntax = list() 17 | self.tags = dict() 18 | self.tag_regex = OrderedDict() 19 | self.region_keys = list() 20 | 21 | # File scanning settings 22 | self.skip_extensions = set() 23 | self.skip_dirs = set() 24 | self.text_extensions = set() 25 | 26 | def get_icon(self) -> str: 27 | if self.comment_icon_enabled: 28 | return self.comment_icon 29 | return "" 30 | 31 | def get_regex(self, identifier: str) -> re.Pattern: 32 | return self.tag_regex.get(identifier) 33 | 34 | def get_matching_pattern(self): 35 | return self.continued_matching_pattern 36 | 37 | def get_flags(self, tag: dict) -> int: 38 | options = { 39 | "outline": sublime.DRAW_NO_FILL, 40 | "underline": sublime.DRAW_SOLID_UNDERLINE, 41 | "stippled_underline": sublime.DRAW_STIPPLED_UNDERLINE, 42 | "squiggly_underline": sublime.DRAW_SQUIGGLY_UNDERLINE, 43 | "persistent": sublime.PERSISTENT, 44 | } 45 | flags = 0 46 | for index, option in options.items(): 47 | if tag.get(index) is True: 48 | flags |= option 49 | return flags 50 | 51 | def get_scope_for_region(self, key: str, tag: dict) -> str: 52 | if tag.get("scope"): 53 | return tag.get("scope") 54 | scope_name = f"comments.{key.lower()}" 55 | return scope_name.replace(" ", ".").lower() 56 | 57 | def get_icon_emoji(self, tag_name: str) -> str: 58 | """Get the emoji icon for a tag, with fallback to a default emoji.""" 59 | if tag_name in self.tags: 60 | tag = self.tags[tag_name] 61 | return tag.get("icon_emoji", "💬") 62 | return "💬" 63 | 64 | 65 | _settings_obj = None 66 | settings = Settings() 67 | 68 | 69 | def load_settings() -> None: 70 | global _settings_obj 71 | settings_obj = sublime.load_settings("colored_comments.sublime-settings") 72 | _settings_obj = settings_obj 73 | update_settings(settings, settings_obj) 74 | settings_obj.add_on_change( 75 | "_on_updated_settings", lambda: update_settings(settings, settings_obj) 76 | ) 77 | 78 | 79 | def unload_settings() -> None: 80 | if _settings_obj: 81 | _settings_obj.clear_on_change("_on_updated_settings") 82 | 83 | 84 | def get_boolean_setting( 85 | settings_obj: sublime.Settings, key: str, default: bool 86 | ) -> bool: 87 | val = settings_obj.get(key) 88 | if isinstance(val, bool): 89 | return val 90 | else: 91 | return default 92 | 93 | 94 | def get_dictionary_setting( 95 | settings_obj: sublime.Settings, key: str, default: dict 96 | ) -> dict: 97 | val = settings_obj.get(key) 98 | if isinstance(val, dict): 99 | return val 100 | else: 101 | return default 102 | 103 | 104 | def get_list_setting(settings_obj: sublime.Settings, key: str, default: list) -> list: 105 | val = settings_obj.get(key) 106 | if isinstance(val, list): 107 | return val 108 | else: 109 | return default 110 | 111 | 112 | def get_str_setting(settings_obj: sublime.Settings, key: str, default: str) -> str: 113 | val = settings_obj.get(key) 114 | if isinstance(val, str): 115 | return val 116 | else: 117 | return default 118 | 119 | 120 | def get_dict_setting(settings_obj: sublime.Settings, key: str, default: dict) -> dict: 121 | val = settings_obj.get(key) 122 | if isinstance(val, dict): 123 | return val 124 | else: 125 | return default 126 | 127 | 128 | def get_int_setting(settings_obj: sublime.Settings, key: str, default: int) -> int: 129 | """Get an integer setting with fallback. 130 | 131 | Args: 132 | settings_obj: The settings object to fetch from 133 | key: The setting key 134 | default: The default value if not found or wrong type 135 | 136 | Returns: 137 | int: The setting value or default 138 | """ 139 | val = settings_obj.get(key) 140 | if isinstance(val, int): 141 | return val 142 | else: 143 | return default 144 | 145 | 146 | def update_settings(settings: Settings, settings_obj: sublime.Settings) -> None: 147 | settings.debug = get_boolean_setting(settings_obj, "debug", True) 148 | settings.continued_matching = get_boolean_setting( 149 | settings_obj, "continued_matching", True 150 | ) 151 | settings.continued_matching_pattern = get_str_setting( 152 | settings_obj, "continued_matching_pattern", "-" 153 | ) 154 | settings.auto_continue_highlight = get_boolean_setting( 155 | settings_obj, "auto_continue_highlight", False 156 | ) 157 | settings.debounce_delay = get_int_setting( 158 | settings_obj, "debounce_delay", 300 159 | ) 160 | settings.comment_icon_enabled = get_boolean_setting( 161 | settings_obj, "comment_icon_enabled", True 162 | ) 163 | settings.comment_icon = "Packages/Colored Comments/icons/{}.png".format( 164 | get_str_setting(settings_obj, "comment_icon", "dots") 165 | ) 166 | settings.disabled_syntax = get_list_setting( 167 | settings_obj, "disabled_syntax", [ 168 | "Packages/Text/Plain text.tmLanguage"] 169 | ) 170 | 171 | # File scanning settings 172 | skip_extensions_list = get_list_setting(settings_obj, "skip_extensions", []) 173 | settings.skip_extensions = set(ext.lower() for ext in skip_extensions_list) 174 | 175 | skip_dirs_list = get_list_setting(settings_obj, "skip_dirs", []) 176 | settings.skip_dirs = set(skip_dirs_list) 177 | 178 | log.debug(f"File scanning settings loaded:") 179 | log.debug(f" Skip extensions: {len(settings.skip_extensions)} items") 180 | log.debug(f" Skip directories: {len(settings.skip_dirs)} items") 181 | 182 | # Handle tag merging: default_tags + tags 183 | # Users can set "default_tags": {} to disable all defaults 184 | # Users can set "tags": {} to have no additional tags 185 | user_default_tags = get_dict_setting(settings_obj, "default_tags", {}) 186 | user_custom_tags = get_dict_setting(settings_obj, "tags", {}) 187 | 188 | # Log tag loading information 189 | log.debug(f"Loading default tags: {list(user_default_tags.keys())}") 190 | log.debug(f"Loading custom tags: {list(user_custom_tags.keys())}") 191 | 192 | # Merge default tags with custom tags (custom tags override defaults with same name) 193 | merged_tags = {} 194 | merged_tags.update(user_default_tags) 195 | 196 | # Track overrides for logging 197 | overridden_tags = [] 198 | for tag_name, tag_config in user_custom_tags.items(): 199 | if tag_name in merged_tags: 200 | overridden_tags.append(tag_name) 201 | merged_tags[tag_name] = tag_config 202 | 203 | if overridden_tags: 204 | log.debug(f"Custom tags overriding defaults: {overridden_tags}") 205 | 206 | log.debug(f"Final merged tags: {list(merged_tags.keys())}") 207 | 208 | settings.tags = merged_tags 209 | settings.tag_regex = _generate_identifier_expression(settings.tags) 210 | settings.region_keys = _generate_region_keys(settings.tags) 211 | 212 | 213 | def _generate_region_keys(tags: dict) -> list: 214 | region_keys = list() 215 | for key in tags: 216 | if key.lower() not in region_keys: 217 | region_keys.append(key.lower()) 218 | return region_keys 219 | 220 | 221 | def escape_regex(pattern: str) -> str: 222 | pattern = re.escape(pattern) 223 | for character in "'<>`": 224 | pattern = pattern.replace("\\" + character, character) 225 | return pattern 226 | 227 | 228 | def _generate_identifier_expression(tags: dict) -> OrderedDict: 229 | unordered_tags = dict() 230 | identifiers = OrderedDict() 231 | for key, value in tags.items(): 232 | priority = 2147483647 233 | if value.get("priority", False): 234 | tag_priority = value.get("priority") 235 | try: 236 | tag_priority = int(priority) 237 | priority = tag_priority 238 | except ValueError as ex: 239 | log.debug( 240 | f"[Colored Comments]: {_generate_identifier_expression.__name__} - {ex}" 241 | ) 242 | unordered_tags.setdefault(priority, list()).append( 243 | {"name": key, "settings": value} 244 | ) 245 | for key in sorted(unordered_tags): 246 | for tag in unordered_tags.get(key): 247 | tag_identifier = ["^("] 248 | tag_identifier.append( 249 | tag.get("settings").get("identifier") 250 | if tag.get("settings").get("is_regex", False) 251 | else escape_regex(tag.get("settings").get("identifier")) 252 | ) 253 | tag_identifier.append(")[ \t]+(?:.*)") 254 | flag = re.I if tag.get("settings").get("ignorecase", False) else 0 255 | identifiers[tag.get("name")] = re.compile( 256 | "".join(tag_identifier), flags=flag 257 | ) 258 | return identifiers 259 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Colored Comments 2 | 3 | A powerful Sublime Text plugin for creating more readable and organized comments throughout your code. Colored Comments allows you to highlight different types of comments with custom colors, search across your entire project for tagged comments, and maintain code documentation standards. 4 | 5 | The plugin was heavily inspired by [Better Comments by aaron-bond](https://github.com/aaron-bond/better-comments) but has been completely rewritten with modern async architecture and enhanced functionality. 6 | 7 | ## ✨ Features 8 | 9 | - **🎨 Colorful Comment Highlighting** - Automatically highlight comments based on configurable tags 10 | - **🔍 Project-wide Tag Search** - Quickly find all TODO, FIXME, and custom tags across your entire project 11 | - **⚡ Async Performance** - Non-blocking file scanning with optimized batch processing 12 | - **🎯 Smart Preview** - Preview tag locations without losing your current position 13 | - **📋 Enhanced Quick Panel** - Rich HTML formatting with emojis and file context 14 | - **⚙️ Fully Configurable** - Customize everything from tags to file scanning behavior 15 | - **🔄 Continuation Support** - Continue highlighting across multiple comment lines 16 | - **🚀 Modern Architecture** - Built with async/await and optimized for large projects 17 | 18 | ## 🚀 Quick Start 19 | 20 | 1. Install the plugin via Package Control 21 | 2. Add comment tags to your code: 22 | ```python 23 | # TODO: Implement user authentication 24 | # FIXME: Fix memory leak in data processing 25 | # ! Important: This affects security 26 | # ? Question: Should we cache this result? 27 | ``` 28 | 3. Use `Ctrl+Shift+P` → "Colored Comments: List All Tags" to search your project 29 | 4. Customize tags and colors in your settings 30 | 31 | ## 📖 Available Commands 32 | 33 | | Command | Description | Default Keybinding | 34 | |---------|-------------|-------------------| 35 | | `Colored Comments: GoTo Comment` | Command to list tag indicated comments across entire project | - | 36 | | `Colored Comments: List All Tags` | Similar to GoTo Comment, but limited to specific tags | - | 37 | | `Colored Comments: Edit Color Scheme` | Open color scheme editor with template | - | 38 | | `Colored Comments: Show Debug Logs` | View debug information | - | 39 | 40 | ## ⚙️ Configuration 41 | 42 | ### Global Settings 43 | 44 | Configure the plugin by editing `Preferences` → `Package Settings` → `Colored Comments` → `Settings`: 45 | 46 | ```jsonc 47 | { 48 | // Enable debug logging 49 | "debug": false, 50 | 51 | // Enables continued matching of the previous tag 52 | "continued_matching": true, 53 | 54 | // Character to continue matching on 55 | "continued_matching_pattern": "-", 56 | 57 | // Automatically continue highlighting based on previous line 58 | "auto_continue_highlight": false, 59 | 60 | // Delay in milliseconds before applying decorations (debounce) 61 | "debounce_delay": 300, 62 | 63 | // Shows comment icon next to comments 64 | "comment_icon_enabled": false, 65 | 66 | // Which comment icon to use (comment, dots) 67 | "comment_icon": "dots", 68 | 69 | // Syntax files to ignore 70 | "disabled_syntax": [ 71 | "Packages/Text/Plain text.tmLanguage", 72 | "Packages/Markdown/MultiMarkdown.sublime-syntax" 73 | ] 74 | } 75 | ``` 76 | 77 | ### Continued Matching 78 | 79 | When enabled, comments can span multiple lines with continuation: 80 | 81 | ```python 82 | # TODO: Implement user authentication system 83 | # - Check password strength requirements 84 | # - Add two-factor authentication support 85 | # - Integrate with OAuth providers 86 | # This comment won't be highlighted (no continuation marker) 87 | ``` 88 | 89 | ### File Scanning Settings 90 | 91 | Control which files are scanned during project-wide tag searches: 92 | 93 | ```jsonc 94 | { 95 | // File extensions to skip when scanning for tags 96 | "skip_extensions": [ 97 | ".pyc", ".class", ".exe", ".dll", ".zip", ".jpg", ".mp4", ".pdf" 98 | ], 99 | 100 | // Directory names to skip when scanning 101 | "skip_dirs": [ 102 | "__pycache__", ".git", "node_modules", ".vscode", "build", "dist" 103 | ] 104 | } 105 | ``` 106 | 107 | ## 🏷️ Tag Configuration 108 | 109 | ### Default Tags 110 | 111 | The plugin comes with these default tags: 112 | 113 | | Tag | Identifier | Description | Emoji | 114 | |-----|------------|-------------|-------| 115 | | **TODO** | `TODO:?` or `todo:?` | Tasks to be completed | 📋 | 116 | | **FIXME** | `FIXME:?` or `fixme:?` | Code that needs fixing | 🔧 | 117 | | **Important** | `!` | Critical information | ⚠️ | 118 | | **Question** | `?` | Questions or uncertainties | ❓ | 119 | | **Deprecated** | `*` | Deprecated code | ⚠️ | 120 | | **UNDEFINED** | `//:?` | Placeholder comments | ❔ | 121 | 122 | ### Custom Tags 123 | 124 | Add your own tags or override defaults: 125 | 126 | ```jsonc 127 | { 128 | "tags": { 129 | "NOTE": { 130 | "scope": "comments.note", 131 | "identifier": "NOTE[:]?|note[:]?", 132 | "is_regex": true, 133 | "ignorecase": true, 134 | "icon_emoji": "📝" 135 | }, 136 | "HACK": { 137 | "scope": "comments.hack", 138 | "identifier": "HACK[:]?|hack[:]?", 139 | "is_regex": true, 140 | "icon_emoji": "🔨", 141 | "outline": true 142 | } 143 | } 144 | } 145 | ``` 146 | 147 | ### Tag Properties 148 | 149 | Each tag supports these properties: 150 | 151 | - **`identifier`** - Text or regex pattern to match (required) 152 | - **`is_regex`** - Set to `true` if identifier is a regex pattern 153 | - **`ignorecase`** - Case-insensitive matching 154 | - **`scope`** - Color scheme scope name 155 | - **`icon_emoji`** - Emoji shown in quick panel and previews 156 | - **`underline`** - Enable solid underline 157 | - **`stippled_underline`** - Enable stippled underline 158 | - **`squiggly_underline`** - Enable squiggly underline 159 | - **`outline`** - Enable outline only (no background fill) 160 | - **`priority`** - Matching priority (lower numbers = higher priority) 161 | 162 | ### Advanced Tag Examples 163 | 164 | ```jsonc 165 | { 166 | "tags": { 167 | // Simple plaintext tag 168 | "BUG": { 169 | "identifier": "BUG:", 170 | "scope": "comments.bug", 171 | "icon_emoji": "🐛" 172 | }, 173 | 174 | // Regex tag with high priority 175 | "CRITICAL": { 176 | "identifier": "CRITICAL[!]*:?", 177 | "is_regex": true, 178 | "priority": -1, 179 | "scope": "comments.critical", 180 | "icon_emoji": "🚨", 181 | "outline": true, 182 | "underline": true 183 | }, 184 | 185 | // Case-sensitive tag 186 | "API": { 187 | "identifier": "API:", 188 | "ignorecase": false, 189 | "scope": "comments.api", 190 | "icon_emoji": "🔌" 191 | } 192 | } 193 | } 194 | ``` 195 | 196 | ## 🎨 Color Scheme Integration 197 | 198 | ### Automatic Template Injection 199 | 200 | When you use "Edit Color Scheme", the plugin automatically injects comment color definitions: 201 | 202 | ```jsonc 203 | { 204 | "rules": [ 205 | { 206 | "name": "Comments: TODO", 207 | "scope": "comments.todo", 208 | "foreground": "var(bluish)" 209 | }, 210 | { 211 | "name": "Comments: FIXME", 212 | "scope": "comments.fixme", 213 | "foreground": "var(redish)" 214 | }, 215 | { 216 | "name": "Comments: Important", 217 | "scope": "comments.important", 218 | "foreground": "var(orangish)" 219 | } 220 | // ... more comment styles 221 | ] 222 | } 223 | ``` 224 | 225 | ### Built-in Color Variables 226 | 227 | Use these predefined color variables in your color scheme: 228 | 229 | - `var(redish)` - Red tones 230 | - `var(orangish)` - Orange tones 231 | - `var(yellowish)` - Yellow tones 232 | - `var(greenish)` - Green tones 233 | - `var(bluish)` - Blue tones 234 | - `var(purplish)` - Purple tones 235 | - `var(pinkish)` - Pink tones 236 | - `var(cyanish)` - Cyan tones 237 | 238 | ## 🔍 Tag Search Features 239 | 240 | ### Project-wide Search 241 | 242 | Search for tags across your entire project with advanced filtering: 243 | 244 | 1. **All Tags**: `Ctrl+Shift+P` → "Colored Comments: List All Tags" 245 | 2. **Filtered Search**: Choose specific tag types from the input handler 246 | 3. **Current File Only**: Search only the active file 247 | 248 | ### Rich Quick Panel 249 | 250 | The search results show: 251 | - **Tag Type** with emoji icon 252 | - **File Location** with relative path 253 | - **Line Number** and preview 254 | - **Syntax Highlighting** in preview 255 | - **HTML Formatting** for better readability 256 | 257 | ### Preview Navigation 258 | 259 | - **Preview Mode** - Hover over results to preview without navigation 260 | - **Transient Views** - Quick preview without opening permanent tabs 261 | - **Position Restoration** - Return to original position when canceling 262 | - **Smart Navigation** - Jump to exact line and column 263 | 264 | ## 🚀 Performance Features 265 | 266 | ### Async Architecture 267 | - **Non-blocking** file scanning 268 | - **Batch processing** for large projects 269 | - **Debounced updates** to prevent excessive processing 270 | - **Smart caching** of comment regions 271 | 272 | ### Optimized File Scanning 273 | - **Heuristic detection** of text files 274 | - **Configurable filtering** to skip binary files 275 | - **Directory exclusion** for faster scanning 276 | - **Progress reporting** during large scans 277 | 278 | ### Memory Efficiency 279 | - **Lazy loading** of file contents 280 | - **Temporary views** for unopened files 281 | - **Cleanup routines** to prevent memory leaks 282 | - **Optimized data structures** for large projects 283 | 284 | ## 🛠️ Development & Debugging 285 | 286 | ### Debug Mode 287 | 288 | Enable debug logging to troubleshoot issues: 289 | 290 | ```jsonc 291 | { 292 | "debug": true 293 | } 294 | ``` 295 | 296 | Then use "Colored Comments: Show Debug Logs" to view detailed information about: 297 | - Tag regex compilation 298 | - File scanning progress 299 | - Comment region detection 300 | - Performance metrics 301 | 302 | ### Contributing 303 | 304 | The plugin welcomes contributions! Key areas: 305 | 306 | - **Tag patterns** for new languages 307 | - **Color scheme templates** 308 | - **Performance optimizations** 309 | - **UI/UX improvements** 310 | 311 | ## 🙏 Credits 312 | 313 | - Inspired by [Better Comments by aaron-bond](https://github.com/aaron-bond/better-comments) 314 | - Built on [sublime_aio](https://github.com/packagecontrol/sublime_aio) for async support 315 | - Uses [sublime_lib](https://github.com/SublimeText/sublime_lib) for enhanced functionality 316 | 317 | ## 📄 License 318 | 319 | This project is licensed under the MIT License - see the LICENSE file for details. 320 | -------------------------------------------------------------------------------- /colored_comments.py: -------------------------------------------------------------------------------- 1 | import sublime 2 | import sublime_plugin 3 | import asyncio 4 | from pathlib import Path 5 | from typing import Optional, Dict, List, Set 6 | from dataclasses import dataclass, field 7 | from contextlib import asynccontextmanager 8 | import threading 9 | import time 10 | 11 | import sublime_aio 12 | 13 | from .plugin import logger as log 14 | from sublime_lib import ResourcePath 15 | from .plugin.settings import load_settings, settings, unload_settings 16 | from .templates import SCHEME_TEMPLATE 17 | 18 | NAME = "Colored Comments" 19 | VERSION = "4.0.1" 20 | 21 | comment_selector = "comment - punctuation.definition.comment" 22 | KIND_SCHEME = (sublime.KIND_ID_VARIABLE, "s", "Scheme") 23 | DEFAULT_CS = 'Packages/Color Scheme - Default/Mariana.sublime-color-scheme' 24 | 25 | 26 | @dataclass 27 | class TagResult: 28 | """Data class for tag scan results.""" 29 | tag: str 30 | line: str 31 | line_num: int 32 | file: str 33 | relative_path: str = field(init=False) 34 | 35 | def __post_init__(self): 36 | try: 37 | # Try to get relative path from first project folder 38 | folders = sublime.active_window().folders() if sublime.active_window() else [] 39 | if folders: 40 | self.relative_path = str(Path(self.file).relative_to(Path(folders[0]))) 41 | else: 42 | self.relative_path = Path(self.file).name 43 | except (ValueError, AttributeError): 44 | self.relative_path = Path(self.file).name 45 | 46 | 47 | 48 | 49 | 50 | class BaseCommentProcessor: 51 | """Base class for comment processing functionality.""" 52 | 53 | def __init__(self, view: sublime.View): 54 | self.view = view 55 | 56 | def should_process_view(self) -> bool: 57 | """Check if view should be processed based on syntax settings.""" 58 | syntax = self.view.settings().get("syntax") 59 | should_process = syntax not in settings.disabled_syntax 60 | log.debug(f"View {self.view.id()} syntax check: {syntax}, should_process: {should_process}") 61 | return should_process 62 | 63 | def find_comment_regions(self) -> List[sublime.Region]: 64 | """Find all comment regions in the view.""" 65 | regions = self.view.find_by_selector(comment_selector) 66 | log.debug(f"View {self.view.id()} found {len(regions)} comment regions") 67 | return regions 68 | 69 | 70 | class CommentDecorationManager(BaseCommentProcessor): 71 | """Manages comment decorations with optimized processing.""" 72 | 73 | def __init__(self, view: sublime.View): 74 | super().__init__(view) 75 | self._last_change_count = 0 76 | self._last_region_row = -1 77 | self._processing = False 78 | log.debug(f"CommentDecorationManager created for view {view.id()}") 79 | 80 | def needs_update(self) -> bool: 81 | """Check if view needs update and update change count.""" 82 | current_change = self.view.change_count() 83 | needs_update = current_change != self._last_change_count 84 | self._last_change_count = current_change 85 | log.debug(f"View {self.view.id()} needs update: {needs_update}") 86 | return needs_update 87 | 88 | async def process_comment_line(self, line: str, reg: sublime.Region, line_num: int, 89 | to_decorate: Dict[str, List[sublime.Region]], 90 | prev_match: str = "") -> Optional[str]: 91 | """Process a single comment line for decoration.""" 92 | if not (stripped_line := line.strip()): 93 | return None 94 | 95 | if not settings.get_matching_pattern().startswith(" "): 96 | line = stripped_line 97 | 98 | # Check adjacency for continuation 99 | current_row = line_num - 1 100 | is_adjacent = (self._last_region_row != -1 and 101 | current_row == self._last_region_row + 1) 102 | 103 | # Try tag patterns first 104 | for identifier, regex in settings.tag_regex.items(): 105 | if regex.search(line.strip()): 106 | to_decorate.setdefault(identifier, []).append(reg) 107 | self._last_region_row = current_row 108 | log.debug(f"Matched tag '{identifier}' at line: {stripped_line[:50]}...") 109 | return identifier 110 | 111 | # Check for continuation 112 | if (prev_match and is_adjacent and 113 | ((settings.continued_matching and line.startswith(settings.get_matching_pattern())) or 114 | settings.auto_continue_highlight)): 115 | to_decorate.setdefault(prev_match, []).append(reg) 116 | self._last_region_row = current_row 117 | log.debug(f"Continued tag '{prev_match}' at line: {stripped_line[:50]}...") 118 | return prev_match 119 | 120 | return None 121 | 122 | def apply_region_styles(self, to_decorate: Dict[str, List[sublime.Region]]): 123 | """Apply visual styles to decorated regions.""" 124 | total_regions = sum(len(regions) for regions in to_decorate.values()) 125 | log.debug(f"Applying styles to {total_regions} regions across {len(to_decorate)} tag types") 126 | 127 | for identifier, regions in to_decorate.items(): 128 | if tag := settings.tags.get(identifier): 129 | self.view.add_regions( 130 | identifier.lower(), 131 | regions, 132 | settings.get_scope_for_region(identifier, tag), 133 | icon=settings.get_icon(), 134 | flags=settings.get_flags(tag) 135 | ) 136 | 137 | def clear_decorations(self): 138 | """Clear all existing decorations.""" 139 | log.debug(f"Clearing decorations for view {self.view.id()}") 140 | for key in settings.region_keys: 141 | self.view.erase_regions(key) 142 | 143 | async def apply_decorations(self): 144 | """Apply decorations asynchronously with batching.""" 145 | if self._processing or not self.should_process_view(): 146 | return 147 | 148 | self._processing = True 149 | try: 150 | # Check if update is needed 151 | needs_update = self.needs_update() 152 | has_existing = any(len(self.view.get_regions(key)) > 0 for key in settings.region_keys) 153 | 154 | if not needs_update and has_existing: 155 | return 156 | 157 | to_decorate: Dict[str, List[sublime.Region]] = {} 158 | prev_match = "" 159 | self._last_region_row = -1 160 | 161 | # Process comment regions in batches 162 | for region in self.find_comment_regions(): 163 | line = self.view.substr(region) 164 | line_num = self.view.rowcol(region.begin())[0] + 1 165 | 166 | if result := await self.process_comment_line(line, region, line_num, to_decorate, prev_match): 167 | prev_match = result 168 | 169 | self.clear_decorations() 170 | self.apply_region_styles(to_decorate) 171 | log.debug(f"Decoration process complete for view {self.view.id()}") 172 | 173 | except Exception as e: 174 | log.debug(f"Error in apply_decorations: {e}") 175 | finally: 176 | self._processing = False 177 | 178 | 179 | class FileScanner: 180 | """Handles file scanning operations with optimized filtering.""" 181 | 182 | @classmethod 183 | def should_skip_file(cls, file_path: Path) -> bool: 184 | """Check if file should be skipped.""" 185 | return (file_path.suffix.lower() in settings.skip_extensions or 186 | any(part in settings.skip_dirs for part in file_path.parts)) 187 | 188 | @classmethod 189 | async def get_project_files(cls, folders: List[str]) -> List[Path]: 190 | """Get all valid text files from project folders.""" 191 | files = [] 192 | for folder in folders: 193 | folder_path = Path(folder) 194 | try: 195 | all_files = list(folder_path.rglob('*')) 196 | valid_files = [ 197 | f for f in all_files 198 | if f.is_file() and not cls.should_skip_file(f) 199 | ] 200 | files.extend(valid_files) 201 | except (OSError, PermissionError) as e: 202 | log.debug(f"Error scanning folder {folder_path}: {e}") 203 | 204 | return files 205 | 206 | 207 | class AsyncTagScanner(BaseCommentProcessor): 208 | """Async tag scanner with optimized file processing.""" 209 | 210 | def __init__(self, window: sublime.Window): 211 | self.window = window 212 | self._temp_panel = None 213 | log.debug(f"AsyncTagScanner created for window {window.id()}") 214 | 215 | async def scan_for_tags(self, *, tag_filter: Optional[str] = None, 216 | current_file_only: bool = False) -> List[TagResult]: 217 | """Scan for tags with optimized batch processing.""" 218 | files = await self._get_files_to_scan(current_file_only) 219 | if not files: 220 | return [] 221 | 222 | results = [] 223 | batch_size = 12 # Larger batch size since async sleeps were removed 224 | 225 | for i in range(0, len(files), batch_size): 226 | batch = files[i:i + batch_size] 227 | 228 | # Create parallel tasks with unique panel names 229 | batch_tasks = [] 230 | for j, file_path in enumerate(batch): 231 | panel_name = f'_colored_comments_temp_view_{i}_{j}' 232 | batch_tasks.append(self._scan_file_with_unique_panel(file_path, panel_name, tag_filter)) 233 | 234 | batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True) 235 | 236 | for result in batch_results: 237 | if isinstance(result, list): 238 | results.extend(result) 239 | 240 | progress = min(100, int(((i + batch_size) / len(files)) * 100)) 241 | sublime.status_message(f"Scanning tags... {progress}% ({len(results)} found)") 242 | 243 | return results 244 | 245 | async def _get_files_to_scan(self, current_file_only: bool) -> List[Path]: 246 | """Get files to scan based on scope.""" 247 | if current_file_only: 248 | if (view := self.window.active_view()) and (file_name := view.file_name()): 249 | return [Path(file_name)] 250 | return [] 251 | 252 | folders = self.window.folders() 253 | return await FileScanner.get_project_files(folders) if folders else [] 254 | 255 | @asynccontextmanager 256 | async def _get_view_for_file(self, file_path: Path, panel_name: str = '_colored_comments_temp_view'): 257 | """Context manager for getting a view for a file with unique panel name.""" 258 | # Check if already open 259 | for view in self.window.views(): 260 | if view.file_name() == str(file_path): 261 | yield view 262 | return 263 | 264 | # Create a fresh temp panel for this file 265 | temp_panel = None 266 | try: 267 | # Read file asynchronously to avoid blocking UI 268 | content = await self._read_file_async(file_path) 269 | if content is None: 270 | yield None 271 | return 272 | 273 | # Create fresh panel for this file with unique name 274 | temp_panel = self.window.create_output_panel(panel_name) 275 | temp_panel.run_command('append', {'characters': content}) 276 | 277 | if syntax := sublime.find_syntax_for_file(str(file_path)): 278 | temp_panel.assign_syntax(syntax) 279 | 280 | yield temp_panel 281 | finally: 282 | # Immediately destroy the panel after use 283 | if temp_panel: 284 | self.window.destroy_output_panel(panel_name) 285 | 286 | async def _read_file_async(self, file_path: Path) -> Optional[str]: 287 | """Read file content asynchronously to avoid blocking UI.""" 288 | try: 289 | with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: 290 | content = f.read() 291 | 292 | return content 293 | except Exception as e: 294 | log.debug(f"Error reading file {file_path}: {e}") 295 | return None 296 | 297 | 298 | 299 | async def _scan_file(self, file_path: Path, tag_filter: Optional[str]) -> List[TagResult]: 300 | """Scan a single file for tags.""" 301 | return await self._scan_file_with_unique_panel(file_path, '_colored_comments_temp_view', tag_filter) 302 | 303 | async def _scan_file_with_unique_panel(self, file_path: Path, panel_name: str, tag_filter: Optional[str]) -> List[TagResult]: 304 | """Scan a single file for tags using a unique panel name.""" 305 | results = [] 306 | try: 307 | async with self._get_view_for_file(file_path, panel_name) as view: 308 | if not view: 309 | return results 310 | 311 | # Use base class functionality 312 | super().__init__(view) 313 | if not self.should_process_view(): 314 | return results 315 | 316 | for region in self.find_comment_regions(): 317 | for reg in view.split_by_newlines(region): 318 | line = view.substr(reg) 319 | line_num = view.rowcol(reg.begin())[0] + 1 320 | 321 | if tag_results := await self._process_comment_line(line, line_num, file_path, tag_filter): 322 | results.extend(tag_results) 323 | 324 | 325 | except Exception as e: 326 | log.debug(f"Error scanning file {file_path}: {e}") 327 | 328 | return results 329 | 330 | async def _process_comment_line(self, line: str, line_num: int, file_path: Path, 331 | tag_filter: Optional[str]) -> List[TagResult]: 332 | """Process a comment line for tags.""" 333 | if not (stripped_line := line.strip()): 334 | return [] 335 | 336 | if not settings.get_matching_pattern().startswith(" "): 337 | line = stripped_line 338 | 339 | results = [] 340 | for tag_name, regex in settings.tag_regex.items(): 341 | if tag_filter and tag_name.lower() != tag_filter.lower(): 342 | continue 343 | 344 | if regex.search(line.strip()): 345 | results.append(TagResult( 346 | tag=tag_name, 347 | line=stripped_line, 348 | line_num=line_num, 349 | file=str(file_path) 350 | )) 351 | 352 | return results 353 | 354 | 355 | class TagIndex: 356 | """Thread-safe tag index with optimized operations.""" 357 | 358 | def __init__(self): 359 | self._file_index: Dict[str, List[TagResult]] = {} 360 | self._tag_index: Dict[str, List[TagResult]] = {} 361 | self._file_timestamps: Dict[str, float] = {} 362 | self._indexed_folders: Set[str] = set() 363 | self._lock = threading.RLock() 364 | self._indexing = False 365 | self._indexing_complete_event: Optional[asyncio.Event] = None # Lazy initialization 366 | 367 | def _get_or_create_event(self) -> asyncio.Event: 368 | """Get or create the indexing complete event for the current loop.""" 369 | if self._indexing_complete_event is None: 370 | self._indexing_complete_event = asyncio.Event() 371 | return self._indexing_complete_event 372 | 373 | def is_indexed(self, folders: List[str]) -> bool: 374 | """Check if folders are already indexed.""" 375 | with self._lock: 376 | return all(folder in self._indexed_folders for folder in folders) 377 | 378 | def is_indexing(self) -> bool: 379 | """Check if indexing is currently in progress.""" 380 | with self._lock: 381 | return self._indexing 382 | 383 | async def wait_for_indexing_complete(self) -> None: 384 | """Wait for indexing to complete using an event.""" 385 | if not self.is_indexing(): 386 | return # Already complete 387 | 388 | # Get event for current loop 389 | event = self._get_or_create_event() 390 | await event.wait() 391 | 392 | async def build_initial_index(self, window: sublime.Window) -> None: 393 | """Build initial tag index for the window's project folders.""" 394 | folders = window.folders() 395 | if not folders: 396 | log.debug("No project folders found for indexing") 397 | return 398 | 399 | with self._lock: 400 | if self._indexing: 401 | log.debug("Index building already in progress") 402 | return 403 | if self.is_indexed(folders): 404 | log.debug("Folders already indexed") 405 | return 406 | 407 | self._indexing = True 408 | # Create fresh event for current loop 409 | self._indexing_complete_event = asyncio.Event() 410 | 411 | try: 412 | start_time = time.perf_counter() 413 | log.debug(f"Starting tag index build for folders: {folders}") 414 | 415 | # Get all files to index 416 | files = await FileScanner.get_project_files(folders) 417 | if not files: 418 | log.debug("No files found to index") 419 | return 420 | 421 | scanner = AsyncTagScanner(window) 422 | total_tags = 0 423 | files_processed = 0 424 | 425 | sublime.status_message(f"🔍 Building tag index... (0/{len(files)} files)") 426 | 427 | # Index files in batches - each file gets its own temp panel 428 | batch_size = 8 429 | for i in range(0, len(files), batch_size): 430 | batch = files[i:i + batch_size] 431 | 432 | # Create tasks for parallel processing with unique panel names 433 | tasks = [] 434 | for j, file_path in enumerate(batch): 435 | panel_name = f'_colored_comments_temp_view_{i}_{j}' 436 | task = asyncio.create_task( 437 | self._index_file(file_path, scanner, force_update=False, panel_name=panel_name) 438 | ) 439 | tasks.append(task) 440 | 441 | # Wait for batch to complete 442 | await asyncio.gather(*tasks, return_exceptions=True) 443 | 444 | files_processed += len(batch) 445 | current_tags = self.get_total_tag_count() 446 | 447 | sublime.status_message( 448 | f"🔍 Building tag index... " 449 | f"({files_processed}/{len(files)} files, {current_tags} tags found)" 450 | ) 451 | 452 | # Mark folders as indexed 453 | with self._lock: 454 | self._indexed_folders.update(folders) 455 | 456 | elapsed = time.perf_counter() - start_time 457 | total_tags = self.get_total_tag_count() 458 | 459 | sublime.status_message( 460 | f"✅ Tag index built: {total_tags} tags in {files_processed} files ({elapsed:.2f}s)" 461 | ) 462 | sublime.set_timeout(lambda: sublime.status_message(""), 3000) 463 | 464 | log.debug(f"Index build complete: {total_tags} tags, {elapsed:.2f}s") 465 | 466 | except Exception as e: 467 | log.debug(f"Error building tag index: {e}") 468 | sublime.status_message(f"❌ Tag index build failed: {str(e)}") 469 | sublime.set_timeout(lambda: sublime.status_message(""), 5000) 470 | finally: 471 | with self._lock: 472 | self._indexing = False 473 | # Signal completion on the event if it exists 474 | if self._indexing_complete_event: 475 | self._indexing_complete_event.set() 476 | 477 | async def _index_file(self, file_path: Path, scanner: AsyncTagScanner, force_update: bool = False, panel_name: Optional[str] = None) -> None: 478 | """Index a single file.""" 479 | file_str = str(file_path) 480 | current_time = time.time() 481 | 482 | # Check if file needs indexing 483 | if not force_update: 484 | with self._lock: 485 | if file_str in self._file_timestamps: 486 | try: 487 | file_mtime = file_path.stat().st_mtime 488 | if file_mtime <= self._file_timestamps[file_str]: 489 | return # File hasn't changed 490 | except (OSError, AttributeError): 491 | pass # File may not exist, proceed with indexing 492 | 493 | try: 494 | # Use unique panel name if provided 495 | if panel_name: 496 | results = await scanner._scan_file_with_unique_panel(file_path, panel_name, None) 497 | else: 498 | results = await scanner._scan_file(file_path, None) 499 | 500 | # Update index 501 | with self._lock: 502 | # Remove old entries for this file 503 | self._remove_file_from_internal_index(file_str) 504 | 505 | # Add new results 506 | if results: 507 | self._file_index[file_str] = results 508 | for result in results: 509 | self._tag_index.setdefault(result.tag, []).append(result) 510 | 511 | # Update timestamp 512 | self._file_timestamps[file_str] = current_time 513 | 514 | log.debug(f"Indexed {len(results)} tags from {file_path.name}") 515 | 516 | except Exception as e: 517 | log.debug(f"Error indexing file {file_path}: {e}") 518 | 519 | def update_file_index(self, file_path: str, window: sublime.Window) -> None: 520 | """Update index for a specific file (called when file is modified).""" 521 | if self._indexing: 522 | return 523 | 524 | # Run async update in background using sublime_aio 525 | def on_update_done(future): 526 | try: 527 | future.result() 528 | except Exception as e: 529 | log.debug(f"Error in file index update callback: {e}") 530 | 531 | sublime_aio.run_coroutine(self._update_file_async(file_path, window)).add_done_callback(on_update_done) 532 | 533 | async def _update_file_async(self, file_path: str, window: sublime.Window) -> None: 534 | """Async helper for updating file index.""" 535 | try: 536 | path = Path(file_path) 537 | if not path.exists() or FileScanner.should_skip_file(path): 538 | with self._lock: 539 | if file_path in self._file_index: 540 | self.remove_file_from_index(file_path) 541 | sublime.status_message(f"🗑️ Removed {Path(file_path).name} from tag index") 542 | sublime.set_timeout(lambda: sublime.status_message(""), 2000) 543 | return 544 | 545 | scanner = AsyncTagScanner(window) 546 | old_count = len(self._file_index.get(file_path, [])) 547 | # Force update to ensure fresh line numbers 548 | await self._index_file(path, scanner, force_update=True) 549 | new_count = len(self._file_index.get(file_path, [])) 550 | 551 | # Show brief status update for significant changes 552 | if new_count != old_count: 553 | filename = Path(file_path).name 554 | if new_count > old_count: 555 | sublime.status_message(f"📝 Updated tag index: +{new_count - old_count} tags in {filename}") 556 | elif old_count > 0: 557 | sublime.status_message(f"📝 Updated tag index: -{old_count - new_count} tags in {filename}") 558 | else: 559 | sublime.status_message(f"📝 Updated tag index: {filename}") 560 | sublime.set_timeout(lambda: sublime.status_message(""), 3000) 561 | 562 | log.debug(f"Updated index for file: {file_path} ({old_count} -> {new_count} tags)") 563 | 564 | except Exception as e: 565 | log.debug(f"Error updating file index for {file_path}: {e}") 566 | sublime.status_message(f"❌ Error updating tag index for {Path(file_path).name}") 567 | sublime.set_timeout(lambda: sublime.status_message(""), 3000) 568 | 569 | def remove_file_from_index(self, file_path: str) -> None: 570 | """Remove a file from the index (when deleted).""" 571 | with self._lock: 572 | self._remove_file_from_internal_index(file_path) 573 | self._file_timestamps.pop(file_path, None) 574 | 575 | def _remove_file_from_internal_index(self, file_path: str) -> None: 576 | """Internal method to remove file from index (assumes lock is held).""" 577 | # Remove from main index 578 | old_results = self._file_index.pop(file_path, []) 579 | 580 | # Remove from tag index 581 | for result in old_results: 582 | if result.tag in self._tag_index: 583 | self._tag_index[result.tag] = [ 584 | r for r in self._tag_index[result.tag] 585 | if r.file != file_path 586 | ] 587 | if not self._tag_index[result.tag]: 588 | del self._tag_index[result.tag] 589 | 590 | def get_all_tags(self, tag_filter: Optional[str] = None, current_file_only: bool = False, 591 | current_file_path: Optional[str] = None) -> List[TagResult]: 592 | """Get all tags from the index.""" 593 | with self._lock: 594 | results = [] 595 | 596 | if current_file_only and current_file_path: 597 | # Get tags only from current file 598 | results = self._file_index.get(current_file_path, []) 599 | else: 600 | # Get all tags 601 | if tag_filter and tag_filter in self._tag_index: 602 | results = self._tag_index[tag_filter][:] 603 | else: 604 | for file_results in self._file_index.values(): 605 | results.extend(file_results) 606 | 607 | # Apply tag filter if specified and not using tag index 608 | if tag_filter and not current_file_only: 609 | results = [r for r in results if r.tag.lower() == tag_filter.lower()] 610 | 611 | return results 612 | 613 | def get_total_tag_count(self) -> int: 614 | """Get total number of tags in the index.""" 615 | with self._lock: 616 | return sum(len(results) for results in self._file_index.values()) 617 | 618 | def clear_index(self) -> None: 619 | """Clear the entire index.""" 620 | with self._lock: 621 | self._file_index.clear() 622 | self._tag_index.clear() 623 | self._file_timestamps.clear() 624 | self._indexed_folders.clear() 625 | # Reset the event to avoid loop attachment issues 626 | self._indexing_complete_event = None 627 | log.debug("Tag index cleared") 628 | 629 | 630 | # Global tag index instance 631 | tag_index = TagIndex() 632 | 633 | 634 | class QuickPanelBuilder: 635 | """Builder for creating enhanced quick panel items.""" 636 | 637 | TAG_KINDS = { 638 | 'TODO': (sublime.KIND_ID_FUNCTION, "T", "Todo"), 639 | 'FIXME': (sublime.KIND_ID_VARIABLE, "F", "Fix Me"), 640 | 'Important': (sublime.KIND_ID_MARKUP, "!", "Important"), 641 | 'Question': (sublime.KIND_ID_NAMESPACE, "?", "Question"), 642 | 'Deprecated': (sublime.KIND_ID_TYPE, "D", "Deprecated"), 643 | 'UNDEFINED': (sublime.KIND_ID_SNIPPET, "U", "Undefined"), 644 | } 645 | 646 | @classmethod 647 | def create_tag_panel_items(cls, results: List[TagResult]) -> List[sublime.QuickPanelItem]: 648 | """Create quick panel items for tag results.""" 649 | return [cls._create_tag_item(result) for result in results] 650 | 651 | @classmethod 652 | def _create_tag_item(cls, result: TagResult) -> sublime.QuickPanelItem: 653 | """Create a single quick panel item for a tag result.""" 654 | kind = cls.TAG_KINDS.get(result.tag, (sublime.KIND_ID_MARKUP, "C", "Comment")) 655 | comment_text = result.line.strip() 656 | 657 | if len(comment_text) > 120: 658 | comment_text = comment_text[:117] + "..." 659 | 660 | trigger = f"[{result.tag}] {comment_text}" 661 | annotation = f"{result.relative_path}:{result.line_num}" 662 | 663 | tag_emoji = settings.get_icon_emoji(result.tag) 664 | file_icon = "📄" if result.file.endswith('.py') else "📝" 665 | 666 | details = [ 667 | f"
" 668 | f"{tag_emoji} {result.tag} " 669 | f"in " 670 | f"{file_icon} {result.relative_path}" 671 | f"
", 672 | 673 | f"
" 674 | f"Line {result.line_num}: " 675 | f"" 676 | f"{sublime.html.escape(comment_text)}" 677 | f"" 678 | f"
" 679 | ] 680 | 681 | return sublime.QuickPanelItem( 682 | trigger=trigger, 683 | details=details, 684 | annotation=annotation, 685 | kind=kind 686 | ) 687 | 688 | 689 | class ViewportManager: 690 | """Manages viewport positions for preview functionality.""" 691 | 692 | def __init__(self, window: sublime.Window): 693 | self.window = window 694 | self.original_positions = {} 695 | self._store_original_positions() 696 | 697 | def _store_original_positions(self): 698 | """Store original viewport positions for all open views.""" 699 | for view in self.window.views(): 700 | if view.file_name(): 701 | self.original_positions[view.file_name()] = { 702 | 'viewport_position': view.viewport_position(), 703 | 'selection': [r for r in view.sel()], 704 | 'view_id': view.id() 705 | } 706 | 707 | def restore_original_positions(self): 708 | """Restore all views to their original positions.""" 709 | for file_path, pos_data in self.original_positions.items(): 710 | for view in self.window.views(): 711 | if (view.file_name() == file_path and 712 | view.id() == pos_data['view_id']): 713 | view.set_viewport_position(pos_data['viewport_position'], False) 714 | view.sel().clear() 715 | for region in pos_data['selection']: 716 | view.sel().add(region) 717 | break 718 | 719 | def preview_location(self, result: TagResult): 720 | """Preview a location with enhanced status display.""" 721 | tag_emoji = settings.get_icon_emoji(result.tag) 722 | preview_line = result.line.strip()[:100] 723 | sublime.status_message(f"{tag_emoji} [{result.tag}] {preview_line}") 724 | 725 | # Navigate to location 726 | target_view = self._find_view_for_file(result.file) 727 | if target_view: 728 | self._navigate_existing_view(target_view, result.line_num) 729 | else: 730 | self._open_transient_preview(result) 731 | 732 | def _find_view_for_file(self, file_path: str) -> Optional[sublime.View]: 733 | """Find existing view for a file.""" 734 | return next((v for v in self.window.views() if v.file_name() == file_path), None) 735 | 736 | def _navigate_existing_view(self, view: sublime.View, line_num: int): 737 | """Navigate within an existing view.""" 738 | point = view.text_point(line_num - 1, 0) 739 | view.sel().clear() 740 | view.sel().add(point) 741 | view.show_at_center(point) 742 | self.window.focus_view(view) 743 | 744 | def _open_transient_preview(self, result: TagResult): 745 | """Open file as transient preview.""" 746 | preview_view = self.window.open_file( 747 | f"{result.file}:{result.line_num}", 748 | sublime.ENCODED_POSITION | sublime.TRANSIENT 749 | ) 750 | 751 | def center_preview(): 752 | if not preview_view.is_loading(): 753 | point = preview_view.text_point(result.line_num - 1, 0) 754 | preview_view.show_at_center(point) 755 | else: 756 | sublime.set_timeout(center_preview, 10) 757 | 758 | sublime.set_timeout(center_preview, 10) 759 | 760 | 761 | class ColoredCommentsEditSchemeCommand(sublime_plugin.WindowCommand): 762 | """Command to edit color scheme with enhanced scheme selection.""" 763 | 764 | def run(self): 765 | current_scheme = self._get_current_scheme() 766 | schemes = self._get_available_schemes(current_scheme) 767 | 768 | def on_done(i): 769 | if i >= 0: 770 | self._open_scheme(schemes[i][2]) 771 | 772 | self.window.show_quick_panel(schemes, on_done) 773 | 774 | def _get_current_scheme(self) -> str: 775 | """Get current color scheme path.""" 776 | view = self.window.active_view() 777 | scheme = (view.settings().get("color_scheme") if view else None) or \ 778 | sublime.load_settings("Preferences.sublime-settings").get("color_scheme") 779 | 780 | if scheme and not scheme.startswith('Packages/'): 781 | scheme = '/'.join(['Packages'] + scheme.split('/')[1:]) if '/' in scheme else scheme 782 | 783 | return scheme or DEFAULT_CS 784 | 785 | def _get_available_schemes(self, current_scheme: str) -> List[List[str]]: 786 | """Get list of available color schemes.""" 787 | schemes = [['Edit Current: ' + current_scheme.split('/')[-1], current_scheme, current_scheme]] 788 | 789 | resources = sublime.find_resources("*.sublime-color-scheme") + sublime.find_resources("*.tmTheme") 790 | schemes.extend([[r.split('/')[-1], r, r] for r in resources]) 791 | 792 | return schemes 793 | 794 | def _open_scheme(self, scheme_path: str): 795 | """Open and potentially inject template into scheme.""" 796 | try: 797 | resource = ResourcePath.from_file_path(scheme_path) 798 | new_view = self.window.open_file(str(resource)) 799 | 800 | def check_loaded_and_inject(): 801 | if new_view.is_loading(): 802 | sublime.set_timeout(check_loaded_and_inject, 50) 803 | else: 804 | self._inject_scheme_template(new_view) 805 | 806 | check_loaded_and_inject() 807 | except Exception as e: 808 | log.debug(f"Error opening scheme: {e}") 809 | 810 | def _inject_scheme_template(self, view: sublime.View): 811 | """Inject scheme template if needed.""" 812 | content = view.substr(sublime.Region(0, view.size())) 813 | if "comments.important" not in content: 814 | insertion_point = view.size() 815 | if content.strip().endswith('}'): 816 | lines = content.split('\n') 817 | for i in range(len(lines) - 1, -1, -1): 818 | if '}' in lines[i]: 819 | insertion_point = sum(len(line) + 1 for line in lines[:i]) 820 | break 821 | 822 | view.run_command('insert', { 823 | 'characters': '\n' + SCHEME_TEMPLATE.rstrip() + '\n' 824 | }) 825 | 826 | 827 | class ColoredCommentsEventListener(sublime_aio.ViewEventListener): 828 | """Optimized event listener using new structure.""" 829 | 830 | def __init__(self, view): 831 | super().__init__(view) 832 | self.manager = CommentDecorationManager(view) 833 | 834 | @sublime_aio.debounced(settings.debounce_delay) 835 | async def on_modified(self): 836 | """Handle view modifications.""" 837 | if self.view.settings().get("syntax") not in settings.disabled_syntax: 838 | await self.manager.apply_decorations() 839 | 840 | # Update tag index for this file if it has a file path 841 | if self.view.file_name(): 842 | tag_index.update_file_index(self.view.file_name(), self.view.window()) 843 | 844 | async def on_load(self): 845 | """Handle view loading.""" 846 | if self.view.settings().get("syntax") not in settings.disabled_syntax: 847 | await self.manager.apply_decorations() 848 | 849 | # Check if we need to build index when loading files in a project 850 | if self.view.file_name() and self.view.window() and self.view.window().folders(): 851 | if not tag_index.is_indexed(self.view.window().folders()): 852 | def on_index_done(future): 853 | try: 854 | future.result() 855 | except Exception as e: 856 | log.debug(f"Error building index on file load: {e}") 857 | 858 | sublime_aio.run_coroutine( 859 | tag_index.build_initial_index(self.view.window()) 860 | ).add_done_callback(on_index_done) 861 | 862 | async def on_activated(self): 863 | """Handle view activation.""" 864 | if self.view.settings().get("syntax") not in settings.disabled_syntax: 865 | await self.manager.apply_decorations() 866 | 867 | def on_close(self): 868 | """Handle view closing.""" 869 | self.manager.clear_decorations() 870 | 871 | 872 | class ColoredCommentsWindowEventListener(sublime_plugin.EventListener): 873 | """Window-level event listener for tag index management.""" 874 | 875 | def on_window_command(self, window, command_name, args): 876 | """Handle window commands that might affect project structure.""" 877 | if command_name in ['new_window', 'close_window', 'open_project', 'close_project']: 878 | # Delay to let the window/project state settle 879 | sublime.set_timeout(lambda: self._check_index_for_window(window), 200) 880 | 881 | def on_load_project(self, window): 882 | """Handle project loading.""" 883 | sublime.set_timeout(lambda: self._check_index_for_window(window), 300) 884 | 885 | def on_activated(self, view): 886 | """Handle view activation - check if we need to build index.""" 887 | if view and view.window() and view.window().folders(): 888 | window = view.window() 889 | if not tag_index.is_indexed(window.folders()) and not tag_index.is_indexing(): 890 | def on_index_done(future): 891 | try: 892 | future.result() 893 | except Exception as e: 894 | log.debug(f"Error building index on view activation: {e}") 895 | 896 | sublime_aio.run_coroutine( 897 | tag_index.build_initial_index(window) 898 | ).add_done_callback(on_index_done) 899 | 900 | def _check_index_for_window(self, window): 901 | """Check if window needs index building.""" 902 | if window and window.folders(): 903 | if not tag_index.is_indexed(window.folders()) and not tag_index.is_indexing(): 904 | def on_index_done(future): 905 | try: 906 | future.result() 907 | except Exception as e: 908 | log.debug(f"Error building index for window: {e}") 909 | 910 | sublime_aio.run_coroutine( 911 | tag_index.build_initial_index(window) 912 | ).add_done_callback(on_index_done) 913 | 914 | 915 | class ColoredCommentsCommand(sublime_aio.ViewCommand): 916 | """Manual decoration command.""" 917 | 918 | async def run(self): 919 | manager = CommentDecorationManager(self.view) 920 | if not manager.should_process_view(): 921 | sublime.status_message("View type not supported for colored comments") 922 | return 923 | 924 | manager._last_change_count = 0 # Force update 925 | await manager.apply_decorations() 926 | sublime.status_message("Comment decorations applied") 927 | 928 | 929 | class ColoredCommentsListTagsCommand(sublime_aio.WindowCommand): 930 | """Enhanced tag listing command with optimized processing.""" 931 | 932 | async def run(self, tag_filter=None, current_file_only=False): 933 | if tag_filter and tag_filter not in settings.tag_regex: 934 | available_tags = ", ".join(settings.tag_regex.keys()) 935 | sublime.error_message(f"Unknown tag filter: '{tag_filter}'\nAvailable tags: {available_tags}") 936 | return 937 | 938 | try: 939 | # Check if we need to build initial index 940 | if not tag_index.is_indexed(self.window.folders()) and not tag_index.is_indexing(): 941 | sublime.status_message("🔍 Tag index not found, building now...") 942 | await tag_index.build_initial_index(self.window) 943 | elif tag_index.is_indexing(): 944 | sublime.status_message("⏳ Waiting for tag index to complete...") 945 | # Wait for indexing to complete 946 | await tag_index.wait_for_indexing_complete() 947 | 948 | # Get current file path for current_file_only mode 949 | current_file_path = None 950 | if current_file_only: 951 | active_view = self.window.active_view() 952 | if active_view and active_view.file_name(): 953 | current_file_path = active_view.file_name() 954 | 955 | # Get results from index (very fast!) 956 | scope_text = "current file" if current_file_only else "project" 957 | filter_text = f" ({tag_filter} tags)" if tag_filter else "" 958 | sublime.status_message(f"📋 Loading {scope_text} tags{filter_text}...") 959 | 960 | results = tag_index.get_all_tags( 961 | tag_filter=tag_filter, 962 | current_file_only=current_file_only, 963 | current_file_path=current_file_path 964 | ) 965 | 966 | if results: 967 | self._show_results(results, tag_filter, current_file_only) 968 | scope_text = "current file" if current_file_only else "project" 969 | filter_text = f" (filtered by '{tag_filter}')" if tag_filter else "" 970 | sublime.status_message(f"Found {len(results)} comment tags in {scope_text}{filter_text}") 971 | else: 972 | scope_text = "current file" if current_file_only else "project" 973 | filter_text = f" matching '{tag_filter}'" if tag_filter else "" 974 | sublime.status_message(f"No comment tags found in {scope_text}{filter_text}") 975 | 976 | except Exception as e: 977 | log.debug(f"Error in tag listing: {e}") 978 | sublime.error_message(f"Error listing tags: {str(e)}") 979 | 980 | def _show_results(self, results: List[TagResult], tag_filter=None, current_file_only=False): 981 | """Show results using optimized components.""" 982 | results.sort(key=lambda x: (x.tag, x.relative_path, x.line_num)) 983 | 984 | viewport_manager = ViewportManager(self.window) 985 | panel_items = QuickPanelBuilder.create_tag_panel_items(results) 986 | 987 | scope_text = "Current File" if current_file_only else "Project" 988 | filter_text = f" - {tag_filter} Tags" if tag_filter else " - All Tags" 989 | header_text = f"{scope_text}{filter_text} ({len(results)} found)" 990 | 991 | def on_done(index): 992 | if index >= 0: 993 | result = results[index] 994 | self.window.open_file(f"{result.file}:{result.line_num}", sublime.ENCODED_POSITION) 995 | else: 996 | viewport_manager.restore_original_positions() 997 | 998 | def on_highlight(index): 999 | if index >= 0: 1000 | viewport_manager.preview_location(results[index]) 1001 | else: 1002 | sublime.status_message("") 1003 | 1004 | self.window.show_quick_panel( 1005 | panel_items, on_done, 1006 | flags=sublime.MONOSPACE_FONT, 1007 | on_highlight=on_highlight, 1008 | placeholder=header_text 1009 | ) 1010 | 1011 | def input(self, args): 1012 | if "tag_filter" not in args: 1013 | return TagFilterInputHandler() 1014 | return None 1015 | 1016 | def input_description(self): 1017 | return "Tag Filter (optional)" 1018 | 1019 | 1020 | class TagFilterInputHandler(sublime_plugin.ListInputHandler): 1021 | """Optimized input handler for tag filters.""" 1022 | 1023 | def name(self): 1024 | return "tag_filter" 1025 | 1026 | def placeholder(self): 1027 | return "Select tag type to filter (or leave blank for all)" 1028 | 1029 | def list_items(self): 1030 | items = [sublime.ListInputItem("All Tags", None, "Show all comment tags")] 1031 | 1032 | for tag_name in settings.tag_regex.keys(): 1033 | tag_def = settings.tags.get(tag_name, {}) 1034 | identifier = tag_def.get('identifier', tag_name) 1035 | items.append(sublime.ListInputItem( 1036 | f"{tag_name} Tags", tag_name, 1037 | f"Show only {tag_name} tags (identifier: {identifier})" 1038 | )) 1039 | 1040 | return items 1041 | 1042 | 1043 | class ColoredCommentsShowLogsCommand(sublime_plugin.WindowCommand): 1044 | """Show logs command.""" 1045 | 1046 | def run(self): 1047 | if not settings.debug: 1048 | sublime.message_dialog( 1049 | "Debug logging is currently disabled.\n\n" 1050 | "Enable debug logging first using:\n" 1051 | "Command Palette → 'Colored Comments: Toggle Debug Logging'" 1052 | ) 1053 | return 1054 | log.dump_logs_to_panel(self.window) 1055 | 1056 | def is_enabled(self): 1057 | return settings.debug 1058 | 1059 | 1060 | def plugin_loaded() -> None: 1061 | """Handle plugin loading.""" 1062 | load_settings() 1063 | log.set_debug_logging(settings.debug) 1064 | log.debug(f"Colored Comments v{VERSION} loaded with optimized structure") 1065 | 1066 | # Clear any existing index state from previous plugin loads 1067 | tag_index.clear_index() 1068 | 1069 | # Initialize tag index for open windows using sublime_aio 1070 | async def initialize_index_with_delay(delay_ms: int): 1071 | """Initialize index after a delay to let Sublime settle.""" 1072 | await asyncio.sleep(delay_ms / 1000.0) # Convert ms to seconds 1073 | 1074 | windows_with_folders = [w for w in sublime.windows() if w.folders()] 1075 | if windows_with_folders: 1076 | log.debug(f"Initializing tag index for {len(windows_with_folders)} windows with projects (attempt after {delay_ms}ms)") 1077 | 1078 | # Process windows concurrently 1079 | tasks = [] 1080 | for window in windows_with_folders: 1081 | if not tag_index.is_indexed(window.folders()): 1082 | tasks.append(asyncio.create_task(tag_index.build_initial_index(window))) 1083 | 1084 | if tasks: 1085 | await asyncio.gather(*tasks, return_exceptions=True) 1086 | log.debug(f"Completed initialization for {len(tasks)} windows") 1087 | else: 1088 | log.debug("All windows already indexed") 1089 | else: 1090 | log.debug(f"No windows with project folders found after {delay_ms}ms delay") 1091 | 1092 | async def initialize_plugin(): 1093 | """Initialize plugin with multiple attempts.""" 1094 | log.debug("Starting tag index initialization...") 1095 | 1096 | # Try initialization at different delays to catch windows that load slowly 1097 | init_tasks = [ 1098 | asyncio.create_task(initialize_index_with_delay(500)), # Quick attempt 1099 | asyncio.create_task(initialize_index_with_delay(1500)) # Delayed attempt 1100 | ] 1101 | 1102 | await asyncio.gather(*init_tasks, return_exceptions=True) 1103 | log.debug("Plugin initialization tasks completed") 1104 | 1105 | def on_initialization_done(future): 1106 | """Callback when initialization completes.""" 1107 | try: 1108 | future.result() # This will raise any exceptions that occurred 1109 | log.debug("Tag index initialization completed successfully") 1110 | except Exception as e: 1111 | log.debug(f"Tag index initialization failed: {e}") 1112 | 1113 | # Initialize plugin on asyncio event loop 1114 | sublime_aio.run_coroutine(initialize_plugin()).add_done_callback(on_initialization_done) 1115 | 1116 | 1117 | def plugin_unloaded() -> None: 1118 | """Handle plugin unloading.""" 1119 | unload_settings() 1120 | tag_index.clear_index() 1121 | log.debug("Colored Comments unloaded") 1122 | --------------------------------------------------------------------------------