├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── check.yml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── media ├── all-anchors.png ├── case-sensitive-anchors.png ├── cursor.png ├── custom-tags.png ├── epics.png ├── extension_logo.png ├── folding.gif ├── folding.png ├── icon.png ├── intelli-sense.png ├── lazy-workspace.gif ├── link-anchors.png ├── preview.gif └── workspace-anchors.png ├── package.json ├── res ├── anchor.svg ├── anchor_black.svg ├── anchor_end.svg ├── anchor_end_black.svg ├── anchor_end_white.svg ├── anchor_white.svg ├── cross.svg ├── cursor.svg ├── icon.svg ├── launch.svg └── load.svg ├── src ├── anchor │ ├── entryAnchor.ts │ ├── entryAnchorRegion.ts │ ├── entryBase.ts │ ├── entryCachedFile.ts │ ├── entryCursor.ts │ ├── entryEpic.ts │ ├── entryError.ts │ ├── entryLoading.ts │ └── entryScan.ts ├── anchorEngine.ts ├── anchorIndex.ts ├── anchorListView.ts ├── commands.ts ├── extension.ts ├── provider │ ├── epicAnchorProvider.ts │ ├── fileAnchorProvider.ts │ └── workspaceAnchorProvider.ts ├── typings.d.ts └── util │ ├── asyncDelay.ts │ ├── completionProvider.ts │ ├── customTags.ts │ ├── defaultTags.ts │ ├── escape.ts │ ├── exporting.ts │ ├── flattener.ts │ └── linkProvider.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "unused-imports", 7 | "unicorn" 8 | ], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:unicorn/recommended" 14 | ], 15 | "rules": { 16 | "semi": 2, 17 | "indent": 0, 18 | "unused-imports/no-unused-imports": 1, 19 | "no-constant-condition": [2, { "checkLoops": false }], 20 | "@typescript-eslint/explicit-member-accessibility": 1, 21 | "@typescript-eslint/explicit-module-boundary-types": 0, 22 | "@typescript-eslint/no-explicit-any": 0, 23 | "@typescript-eslint/no-empty-function": 0, 24 | "@typescript-eslint/no-unused-vars": 0, 25 | "@typescript-eslint/no-var-requires": 0, 26 | "@typescript-eslint/no-non-null-assertion": 0, 27 | "@typescript-eslint/indent": 2, 28 | "@typescript-eslint/ban-types": 0, 29 | "@typescript-eslint/ban-ts-comment": 0, 30 | "unicorn/prevent-abbreviations": 0, 31 | "unicorn/catch-error-name": [ 2, { "name": "err" }], 32 | "unicorn/no-null": 0, 33 | "unicorn/no-array-reduce": 0, 34 | "unicorn/filename-case": 0, 35 | "unicorn/prefer-ternary": 0, 36 | "unicorn/prefer-module": 0, 37 | "unicorn/no-useless-undefined": 0, 38 | "unicorn/prefer-event-target": 0 39 | }, 40 | "env": { 41 | "commonjs": true, 42 | "browser": true, 43 | "node": true 44 | } 45 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings. 2 | * text=auto 3 | 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.paypal.com/paypalme/StarlaneStudios'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue you experienced with Comment Anchors 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. If applicable, add screenshots to help explain your problem. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Configure '....' 17 | 3. Open file '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for Comment Anchors 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe your feature request** 11 | A clear and concise description of the functionality you would like to see implemented 12 | 13 | **Are you willing to contribute this feature?** 14 | Yes/No, optionally provide further details... 15 | 16 | **Related pull request (if applicable)** 17 | e.g. PR #4 18 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: CI Check 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | check_and_test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Install Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18 21 | cache: "yarn" 22 | 23 | - name: Install dependencies 24 | run: yarn install 25 | 26 | - name: Linting 27 | run: yarn lint 28 | 29 | - name: Transpile 30 | run: yarn compile 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | .yarn-error.log -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/out/**/*.js" 18 | ], 19 | "preLaunchTask": "npm: watch" 20 | }, 21 | { 22 | "name": "Extension Tests", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "runtimeExecutable": "${execPath}", 26 | "args": [ 27 | "--extensionDevelopmentPath=${workspaceFolder}", 28 | "--extensionTestsPath=${workspaceFolder}/out/test" 29 | ], 30 | "outFiles": [ 31 | "${workspaceFolder}/out/test/**/*.js" 32 | ], 33 | "preLaunchTask": "npm: watch" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "out": false // set this to true to hide the "out" folder with the compiled JS files 4 | }, 5 | 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | 10 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 11 | "typescript.tsc.autoDetect": "off", 12 | 13 | // Insert spaces on indent 14 | "editor.insertSpaces": true, 15 | 16 | // Use local TS version 17 | "typescript.tsdk": "node_modules\\typescript\\lib" 18 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | .github/** 4 | out/test/** 5 | out/**/*.map 6 | src/** 7 | .gitignore 8 | tsconfig.json 9 | vsc-extension-quickstart.md 10 | tslint.json 11 | *.vsix -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.10.4 4 | - Added the ability to toggle rulers per anchor tag (#203) 5 | - Added the ability to style tags with underlines, strikethroughs, and more (#216) 6 | - Allow parsing of anchors in files prefixed with a dot (#209) 7 | - Removed unnecessary hover text (#208) 8 | - Match the `matchFiles` setting against the relative path of the file (#209) 9 | - This may break existing settings which expect an absolute path 10 | - Improved code maintainability (CI, improved linting & issue templates) 11 | 12 | ## 1.10.3 13 | - When selecting an anchor, the cursor position now also moves (#173) 14 | - `bin` and `obj` directories are now excluded from workplace scans by default (#176) 15 | - Intellisense will now only trigger when an entire prefix is typed (#174) 16 | 17 | ## 1.10.2 18 | - Display a badge when the current file contains anchors 19 | 20 | ## 1.10.1 21 | - Added new quick navigation command (Bound to `Alt + C + L`) 22 | - Change default navigation keybind to `Alt + PageUp` & `Alt + PageDown` 23 | 24 | ## 1.10.0 25 | - Added a command to export all anchors in your workspace as JSON or CSV 26 | - Added `matchPrefix` and `matchSuffix` settings 27 | - Added keyboard navigation (Shift+Alt+Up & Shift+Alt+Down) (#27) 28 | - Updated the anchor tag configuration syntax 29 | - The legacy array-based syntax will continue to work 30 | - Since the new syntax is defined using an object notation, individual tags can now be collapsed (#152) 31 | - Made `highlightColor` property optional for tags, allowing uncolored anchors 32 | - Omitting the path from a link anchor will now default to the current file 33 | - Replaced `styleComments` property with more customizeable `styleMode` property (#31) 34 | - Improved the anchor IntelliSense completions 35 | - Fixed epics within regions not displaying in the sidebar 36 | - Fixed instances where a single broken link breaks all links 37 | - Fixed anchors appearing outside of comments (#116) 38 | - Due to the way in which the matcher has been changed, existing anchors *may* break. Please use the new `matchPrefix` and `matchSuffix` settings to further tweak the matcher to your environment. 39 | - Slightly adjust the cursor appearance 40 | 41 | ## 1.9.6 42 | - Added new "hidden" scope (#128) 43 | - Prevents anchors from displaying in the sidebar 44 | - Useful for creating highlight-only anchors 45 | 46 | ## 1.9.5 47 | - Replace link anchor CodeLens with clickable link 48 | - Fixes many issues related to lens jittering such as (#109) 49 | 50 | ## 1.9.4 51 | - Added Show Cursor setting 52 | - Renders a sidebar entry representing the current cursor position 53 | - Useful to view your cursors position relative to your anchors 54 | - Disabled by default, enable in settings to use 55 | 56 | ## 1.9.3 57 | - Fixed link anchors not always working in the same file 58 | - Anchor sections are now expanded by default 59 | - Added a setting to revert to the previous behavior 60 | 61 | ## 1.9.2 62 | - Added `dist` to default excluded directories (#114) 63 | - Added workspace path support for link anchors (#105) 64 | - Fixed multiple tags starting with the same characters not always working 65 | 66 | ## 1.9.1 67 | - Fixed IntelliSense breaking few extensions 68 | 69 | ## 1.9.0 70 | - Added Anchor Epics 71 | - Used to group anchors into individual groups 72 | - Specify the epic using an `epic` attribute, example:
73 | `// EXAMPLE[epic=examples] This anchor appears in the "examples" epic` 74 | - Anchor ordering can be customized per anchor using the `seq` option 75 | - Add "link" anchor support 76 | - Enabled on tags by setting `behavior` to `"link"` 77 | - Allows you to specify an absolute or relative file to travel to 78 | - Renders a clickable link that opens the file in your editor 79 | - You can link to specific line numbers 80 | - Example: `// LINK some/file.txt:50` 81 | - You can link to specific anchors 82 | - Example: `// LINK some/file.txt#my-anchor` 83 | - Takes you to `// ANCHOR[id=my-anchor] This is the destination` 84 | - Added new default `LINK` tag to use together with the new link behavior functionality 85 | - Increase default `maxFiles` value from 50 to 250 86 | - Deprecated `isRegion` in favor of `behavior` set to `"region"` 87 | 88 | ## 1.8.0 89 | - Added support for custom icon hex colors 90 | - Defaults to highlightColor when left out 91 | - Can be set to "auto" or "default" to use black/white icons based on theme 92 | - Continued support for legacy color names 93 | - Added specialized region end tag icons 94 | - Added pathFormat setting to improve workspace anchor tree readability 95 | - Use file icon from theme for workspace anchor tree 96 | - Fallback to displaying tag in the sidebar when no comment is found 97 | - Fix setting description inaccuracies 98 | - Fix displayLineNumber setting not working for section tags 99 | - Fix region end tags not verifying start tag 100 | 101 | ## 1.7.1 102 | - Adopt new Webview API 103 | - Fix anchors not working in certain files 104 | - Provide a "collapse all" button on the anchor trees 105 | 106 | ## 1.7.0 107 | - Changed some default settings to improve performance 108 | - Workspace lazy loading is now enabled by default 109 | - Fixed issues in the documentation 110 | - Added `displayInRuler` setting to disable anchors displaying in the scrollbar ruler 111 | - Added `provideAutoCompletion` setting to disable auto completion support 112 | - Added `maxFiles` setting to change how many workspace files will be indexed 113 | - Improved the sidebar anchor icon to fit better with the default VSCode icons 114 | - Improved the rendering of fully styled comments 115 | - Simplified the searching regex in order to improve anchor recognition 116 | - Removed editor folding due to the many issues it caused 117 | - Fixed workspace anchors not opening the correct file 118 | 119 | ## 1.6.1 120 | - Allow anchor comments to be wrapped in double quotes 121 | - `// ANCHOR: "Like this!"` 122 | - Currently the only way to allow non-english comments to be parsed by Comment Anchors 123 | - Prevent workspace scanning from blocking other extensions 124 | 125 | ## 1.6.0 126 | - Added setting to modify separators 127 | - "Separators" are the characters you are allowed to place between a tag and its message 128 | - By default, ` `, `: ` and ` - ` are allowed 129 | - It is advised to remove " " from the list when disabling case matching, as otherwise many false positives may be detected by accident 130 | - Added IntelliSense autocompletion for anchors 131 | - Fixed issues involving editor folding 132 | - Added setting to change where the editor scrolls to when navigating to an anchor 133 | - Added setting to disable editor folding (May fix fold issues in some languages) 134 | - **Possibly breaking:** Tag matching is now Case Sensitive by default 135 | 136 | ## 1.5.0 137 | - Added anchor regions 138 | - Region tags are defined by setting `isRegion` to `true` 139 | - Defined with a start and end tag 140 | - Collapsible in the anchor list and in the editor 141 | - Support for placing regions within regions 142 | - Provided default region tag "SECTION" 143 | - Greatly improved matching of tags 144 | - Added new icon colors (teal, pink, blurple, emerald) 145 | - Added icon hex codes to the extension documentation 146 | 147 | ## 1.4.3 148 | - Added the ability to disable workspace anchors 149 | - Fixed bad performance by excluding `node_modules` and other folders from the workspace scan by default 150 | - Scanning now no longer triggers other extensions to parse all files 151 | - Improved matching regex 152 | - Now excludes semicolons and other symbols before the comment (e.g. NOTE - Message, ANCHOR: Message) 153 | - Matches are now case insensitive (Can be disabled in settings) 154 | 155 | ## v1.4.0 156 | - Added workspace anchors view 157 | - Allows the viewing of all anchors within one or multiple workspaces 158 | - Can be used for navigation 159 | - Tags can be scoped to display for current file only 160 | - Fixed bugs 161 | 162 | ## v1.3.0 163 | - Added gutter icons 164 | - Added more tag customization styles 165 | - Added command to toggle tag visibility 166 | - Improved comment detecting (Works for XML/HTML files now) 167 | 168 | ## v1.2.0 169 | - Added support for background colors 170 | 171 | ## v1.1.0 172 | - Fixed some minor issues 173 | 174 | ## v1.0.0 175 | - Initial release 176 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute to Comment-Anchors 2 | Welcome to the Comment Anchors Contribution guide! ⚓ 3 | Thank you for showing interest in supporting and maintaining the project. 4 | 5 | Before you start, make sure to install yarn for the project. 6 | 7 | ``` 8 | $ npm install -g yarn 9 | $ yarn install 10 | ``` 11 | 12 | ## Submitting changes 13 | 14 | Please send a [GitHub Pull Request to Comment-anchors](https://github.com/StarlaneStudios/vscode-comment-anchors/pull/new/master) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). When you send a pull request, make sure to commit what you have patched/added. Please follow our coding conventions (below) and make sure all of your commits are atomic (one feature per commit). 15 | 16 | Always write a clear log message for your commits. Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) guidelines while writing your commits. The following commit types are in use in this repository: 17 | 18 | - `feat`: for new features and improvements 19 | - `fix`: for bugfixes and minor rewrites 20 | - `change`: for larger rewrites or improvements to existing code 21 | - `chore`: for updates to project meta files 22 | 23 | #### **Did you find a bug?** 24 | 25 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/StarlaneStudios/vscode-comment-anchors/issues). 26 | 27 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/StarlaneStudios/vscode-comment-anchors/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. 28 | 29 | 30 | #### **Did you write a patch that fixes a bug?** 31 | 32 | * Open a new GitHub pull request with the patch. 33 | 34 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 35 | 36 | #### **Do you intend to add a new feature or change an existing one?** 37 | 38 | * Optionally, suggest your change in [our community Discord](https://discord.gg/exaQDX2), in the #anchor-chat channel, and start writing code in a local fork. 39 | 40 | * Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. 41 | 42 | #### **Do you have questions about the source code?** 43 | 44 | * Ask any question about how to use the Comment Anchors source in our discord, preferably in the channel #comment-anchors. 45 | 46 | * Open an issue ticket, clearly specifying your question, however it is still recommended to ask on our discord first. 47 | 48 | Comment-Anchors is a volunteer effort. We encourage you to pitch in and join! 49 | This repository is maintained by the Starlane Studios team. 50 | 51 | Thanks! :heart: 52 | 53 | ~Starlane Studios Team 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2024 Starlane Studios 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Comment Anchors](media/extension_logo.png) 2 | 3 | Place anchors within comments or strings to place bookmarks within the context of your code. Anchors can be used to track TODOs, write notes, create foldable sections, or to build a simple navigation making it easier to navigate your files. 4 | 5 | Anchors can be viewed for the current file, or throughout the entire workspace, using an easy to use sidebar. 6 | 7 | Comment Anchors provides many configuration options, allowing you to tailor this extension to your personal workflow, and increase productivity. Check below for a complete list of features! 8 | 9 | ### Maintenance notice 10 | 11 | We do not intend to make any major changes or additions to this project ourselves as we have moved on to other projects, however we will continue to review and merge community submitted pull requests from time to time. 12 | 13 | Read more about this [here](https://github.com/StarlaneStudios/vscode-comment-anchors/issues/218). 14 | 15 | ### Changelog 16 | The changelog can be found [here](CHANGELOG.md) 17 | 18 | # Feature breakdown 19 | 20 | * Place anchors in any file for any language 21 | * Anchors can be viewed in the anchor list sidebar 22 | * Anchor names, colors, highlight colors, and much more can be customized (See below for examples) 23 | * Click an anchor in the anchor list to scroll it into view 24 | * Navigate to the previous or next anchor using keybinds 25 | * Quickly toggle tag visibility with commands 26 | * View anchors across your entire workspace 27 | * Scope anchors to be visible in your entire workspace, or just the current file 28 | * Place your anchors into hierarchical sections using region anchors 29 | * Group anchors into custom lists by tagging anchors with an epic 30 | * Export all anchors in your workspace as JSON or CSV 31 | 32 | # Usage 33 | 34 | The default settings come with anchors for the following tags: 35 | 36 | * ANCHOR - Used to indicate a section in your file 37 | * TODO - An item that is awaiting completion 38 | * FIXME - An item that requires a bugfix 39 | * STUB - Used for generated default snippets 40 | * NOTE - An important note for a specific code section 41 | * REVIEW - An item that requires additional review 42 | * SECTION - Used to define a region (See 'Hierarchical anchors') 43 | * LINK - Used to link to a file that can be opened within the editor (See 'Link Anchors') 44 | 45 | Of course you can add your own anchors as well! 46 | In order to make an anchor, simply place the tag name in a comment, with an additional anchor message behind it. The anchor will be automatically detected and added to the Anchor List in the activity sidebar. 47 | 48 | You can also easily navigate between the anchors in the current file using `Alt + PageUp` or `Alt + PageDown`. 49 | 50 | ![Preview](media/preview.gif) 51 | 52 | ## Anchor types 53 | 54 | All anchor types have their own highlight color, and background color, and more, which can all be customized in the settings. Anchor tags can be added and removed, and can share the same icon or color. You can specify and use any hex color for the highlighting and icons, giving you full control over your personal set of anchor tags. 55 | 56 | ![All tags](media/all-anchors.png) 57 | 58 | In case you want to disable one or more default tags, simply set the `enabled` property to `false` (See configuration section). 59 | 60 | ## Workspace anchors 61 | 62 | Besides displaying anchors found in the current file, the sidebar also displays a list of 63 | tags it found across all files in your workspace. These anchors are displayed per file, and can 64 | be used as quick navigation. 65 | 66 | The visibility of anchor tags in the workspace list can be altered using the 'scope' property on each tag (See configuration section). 67 | 68 | ![Workspace Anchors](media/workspace-anchors.png) 69 | 70 | ## Anchor epics 71 | 72 | Epics give you the power to easily tag classes, methods, and entire sections of codes into personal lists. To get started, tag your individual anchors with an Epic to place them into a specific list. Optionally provide a sequence number to customize the ordering of your anchors. 73 | 74 | ![Workspace Anchors](media/epics.png) 75 | 76 | ### Lazy loading 77 | 78 | Since workspace anchors are usually scanned at startup, this can increase load time for projects containing many 79 | files and folders. In this case you can enable lazy loading mode, which will require an additional manual trigger to start the scan. 80 | 81 | Lazy workspace loading can be enabled in the settings (See configuration section). 82 | 83 | ![Lazy Loading](media/lazy-workspace.gif) 84 | 85 | ## Hierarchical anchors 86 | Region Anchors allow you to group relevant Comment Anchors together in regions, which can be 87 | folded within the anchor sidebar. These anchors act nearly identical to regular anchors, 88 | however they require an end tag to be specified, which is simply a tag of the same type, prefixed with an exclamation mark. 89 | 90 | ![Hierarchical Anchors](media/folding.png) 91 | 92 | In order to mark a tag as Region Tag, set the `behavior` property to `"region"` in the tags configuration (See configuration section). 93 | 94 | A default region tag is provided, called "SECTION" 95 | 96 | ## Tag customization 97 | 98 | Comment Anchors supports a vast range of tag customization options. All tags can be modified, including the default tags. This allows you to define tags useful for your workflow. 99 | 100 | See the configuration section for a complete list of tag properties. 101 | 102 | ![Workspace Anchors](media/custom-tags.png) 103 | 104 | ![Case Sensitive Anchors](media/case-sensitive-anchors.png) 105 | 106 | ## Link Anchors 107 | 108 | Sometimes you might want to link to a file from within a comment. In these cases, a link anchor 109 | might provide to be useful to you. Using the default `LINK` tag you can provide a relative or 110 | absolute path to a file. These anchors will render with a clickable CodeLens line, used to quickly open it within your editor. 111 | 112 | Custom link tags can be created by setting `behavior` to `"link"` 113 | 114 | ### Linking to a line number 115 | 116 | You can specify a line number to scroll to by appending the path with `:` followed by the line number. 117 | 118 | Example: `// LINK some/file.txt:50` 119 | 120 | ### Linking to a specific anchor 121 | 122 | Link anchors can take you to another anchor in the target file by appending the path with `#` followed by the anchor id. 123 | The anchor id can be specified as an attribute. 124 | 125 | Example: `// LINK some/file.txt#my-anchor` \ 126 | Takes you here: `// ANCHOR[id=my-anchor] This is the destination!` 127 | 128 | You can even omit the path entirely and link directly to an anchor or line within the current file 129 | 130 | Example: `// LINK #error-handling` 131 | 132 | ![Link Anchors](media/link-anchors.png) 133 | 134 | ## Display cursor position 135 | 136 | Enable the `commentAnchors.showCursor` setting to display a file view entry for your current cursor position, making it even easier to see where you are in your current file relative to your anchors. 137 | 138 | ![Cursor](media/cursor.png) 139 | 140 | ## IntelliSense support 141 | 142 | Comment Anchors can be autocompleted by IntelliSense. 143 | 144 | ![IntelliSense](media/intelli-sense.png) 145 | 146 | # Commands 147 | 148 | \> **List configured anchor tags** 149 | 150 | Displays all configured tags in a preview tab, useful for when you are creating your own tags. 151 | 152 | \> **Toggle the visibility of comment anchors** 153 | 154 | Toggles the visibility of comment anchors (Duh!). Note that his command will update your settings in order to toggle the visibility. 155 | 156 | \> **Export all workspace anchors** 157 | 158 | Export all workspace anchors to a CSV or JSON file 159 | 160 | # Configuration 161 | 162 | Use `commentAnchors.parseDelay` to alter the delay in milliseconds between when you stop with typing and when the anchor parser starts. Increasing this value can result in better performance. (Default 200) 163 | 164 | ``` 165 | { 166 | "commentAnchors.parseDelay": 200 167 | } 168 | ``` 169 | 170 | Use `commentAnchors.scrollPosition` to alter where to position the anchor when scrolled to (Default top) 171 | 172 | ``` 173 | { 174 | "commentAnchors.scrollPosition": "top" 175 | } 176 | ``` 177 | 178 | Use `commentAnchors.showCursor` to display the current cursor as entry in the file anchor view. (Default false) 179 | 180 | ``` 181 | { 182 | "commentAnchors.showCursor": false 183 | } 184 | ``` 185 | 186 | Use `commentAnchors.tagHighlights.enabled` to set whether tags are highlighted. (Default true) 187 | 188 | ``` 189 | { 190 | "commentAnchors.tagHighlights.enabled": true 191 | } 192 | ``` 193 | 194 | Use `commentAnchors.workspace.enabled` to activate workspace wide anchor scanning. This will list out all files containing comment anchors in the "Workspace Anchors" view. (Default true) 195 | 196 | ``` 197 | { 198 | "commentAnchors.workspace.enabled": true 199 | } 200 | ``` 201 | 202 | Use `commentAnchors.workspace.lazyLoad` to delay the loading of workspace anchors until a manual confirmation is given. It is discouraged 203 | to disable this setting for large workspaces. (Default true) 204 | 205 | ``` 206 | { 207 | "commentAnchors.workspace.lazyLoad": true 208 | } 209 | ``` 210 | 211 | Use `commentAnchors.workspace.maxFiles` to change how many workspace files will be indexed and displayed in the workspace anchors list. (Default 50) 212 | 213 | ``` 214 | { 215 | "commentAnchors.workspace.maxFiles": 50 216 | } 217 | ``` 218 | 219 | Use `commentAnchors.workspace.matchFiles` to define which files are scanned by Comment Anchors. This setting can be used to greatly increase performance in your projects, as by default most files are scanned. 220 | 221 | ``` 222 | { 223 | "commentAnchors.workspace.matchFiles": "**/*" 224 | } 225 | ``` 226 | 227 | Use `commentAnchors.workspace.excludeFiles` to define which files are excluded from being scanned by Comment Anchors. This setting can be used to greatly increase performance in your projects, as by default only few directories are excluded. 228 | 229 | ``` 230 | { 231 | "commentAnchors.workspace.excludeFiles": "**/{node_modules,.git,.idea,target,out,build,bin,obj,vendor}/**/*" 232 | } 233 | ``` 234 | 235 | Use `commentAnchors.workspace.pathFormat` to change the way paths are displayed in the workspace anchor tree. You can choose to display full paths, abbreviate folders to single characters, or to hide the path completely. (Default full) 236 | 237 | ``` 238 | { 239 | "commentAnchors.workspace.pathFormat": "full" 240 | } 241 | ``` 242 | 243 | Use `commentAnchors.tags.provideAutoCompletion` to enable autocompletion support for anchor tags. (Default true) 244 | 245 | ``` 246 | { 247 | "commentAnchors.tags.provideAutoCompletion": true 248 | } 249 | ``` 250 | 251 | Use `commentAnchors.tags.displayInSidebar` to set whether tags are included in the sidebar list. (Default true) 252 | 253 | ``` 254 | { 255 | "commentAnchors.tags.displayInSidebar": true 256 | } 257 | ``` 258 | 259 | Use `commentAnchors.tags.displayInGutter` to set whether gutter icons are shown. (Default true) 260 | 261 | ``` 262 | { 263 | "commentAnchors.tags.displayInGutter": true 264 | } 265 | ``` 266 | 267 | Use `commentAnchors.tags.displayInRuler` to set whether icons are represented by colored bars in the scrollbar ruler. (Default true) 268 | 269 | ``` 270 | { 271 | "commentAnchors.tags.displayInRuler": true 272 | } 273 | ``` 274 | 275 | Use `commentAnchors.tags.displayLineNumber` to set whether line numbers are displayed in the sidebar (Default true) 276 | 277 | ``` 278 | { 279 | "commentAnchors.tags.displayLineNumber": true 280 | } 281 | ``` 282 | 283 | Use `commentAnchors.tags.displayTagName` to set whether tag names are displayed in the sidebar (Default true) 284 | 285 | ``` 286 | { 287 | "commentAnchors.tags.displayTagName": true 288 | } 289 | ``` 290 | 291 | Use `commentAnchors.tags.rulerStyle` to set the appearance in the overview ruler (Default "center") 292 | 293 | ``` 294 | { 295 | "commentAnchors.tags.rulerStyle": "center" 296 | } 297 | ``` 298 | 299 | Use `commentAnchors.tags.sortMethod` to set the method used to sort anchors by in the sidebar list. Set this to "line" to sort by line number (Default), or "type" to sort by tag type. 300 | 301 | ``` 302 | { 303 | "commentAnchors.tags.sortMethod": "line" 304 | } 305 | ``` 306 | 307 | Use `commentAnchors.tags.expandSections` to choose whether sections are automatically expanded in the anchor tree. 308 | ``` 309 | { 310 | "commentAnchors.tags.expandSections": true 311 | } 312 | ``` 313 | 314 | Use `commentAnchors.tags.separators` to set the list of accepted separators 315 | 316 | ``` 317 | { 318 | "commentAnchors.tags.separators": [ 319 | " ", 320 | ": ", 321 | " - " 322 | ] 323 | } 324 | ``` 325 | 326 | Use `commentAnchors.epic.provideAutoCompletion` to enable autocompletion support for epic. (Default true) 327 | 328 | ``` 329 | { 330 | "commentAnchors.epic.provideAutoCompletion": true 331 | } 332 | ``` 333 | 334 | Use `commentAnchors.epic.seqStep` to config how much should auto-completion-item add on current max-seq. (Default 1) 335 | 336 | ``` 337 | { 338 | "commentAnchors.epic.seqStep": 1 339 | } 340 | ``` 341 | 342 | ## Custom anchor tags 343 | 344 | Use `commentAnchors.tags.anchors` to configure the anchor tags. Below is a list of properties each tag can have. 345 | 346 | 347 | - scope **(required)** - *The scope of a tag. Specifying "file" will only make these visible in the 'File Anchors' list, while "hidden" completely hides it from the anchor list* 348 | - highlightColor - *The color used for highlighting the tag* 349 | - backgroundColor - *The color used as tag background* 350 | - iconColor - *An optional color to apply to the icon, or "auto" for automatic theme detection. Defaults to using highlightColor* 351 | - styleMode - *Customize what part of the comment is highlighted* 352 | - borderStyle - *Style to be applied to the tag border (See https://www.w3schools.com/cssref/pr_border.asp)* 353 | - borderRadius - *The curvature radius of the border (Requires borderStyle)* 354 | - ruler - *Overwrite the global setting when false* 355 | - textDecorationStyle - *Style to be applied to the tag text-decoration (See https://www.w3schools.com/cssref/pr_text_text-decoration.php)* 356 | - isBold - *Whether to apply bold formatting to the tag* 357 | - isItalic - *Whether to apply italicized formatting to the tag* 358 | - behavior - *Either "link" for link tags, "region" for region tags, or "anchor" for regular tags* 359 | - enabled - *Allows the disabling of default (and custom) tags* 360 | 361 | ``` 362 | "commentAnchors.tags.anchors": { 363 | "ANCHOR": { 364 | "scope": "file", 365 | "iconColor": "default", 366 | "highlightColor": "#A8C023", 367 | "styleMode": "comment" 368 | }, 369 | ... 370 | } 371 | ``` 372 | 373 | You can use the `enabled` property to disable one or more default tags like so: 374 | 375 | ``` 376 | "commentAnchors.tags.anchors": { 377 | "ANCHOR": { 378 | "enabled": false 379 | }, 380 | ... 381 | } 382 | ``` 383 | 384 | If you would like for tags to only provide highlighting without rendering in the anchor sidebar, set the `scope` property to `hidden`: 385 | 386 | ``` 387 | "commentAnchors.tags.anchors": { 388 | "NOTE": { 389 | "scope": "hidden" 390 | }, 391 | ... 392 | } 393 | ``` 394 | 395 | ## Icon colors 396 | At startup, anchor icons are generated for all colors specified on your tags. The icon color defaults to using the tags `highlightColor`, 397 | however a custom color may be specified with `iconColor`. Setting `iconColor` to `auto` will allow VSCode to pick an icon based on your 398 | currentl theme (black or white). 399 | 400 | Besides specifying a custom hex color, the following names may be used as shortcuts. 401 | 402 | | Color | Hex | 403 | |:--------|--------:| 404 | | blue | #3ea8ff | 405 | | blurple | #7d5afc | 406 | | red | #F44336 | 407 | | purple | #BA68C8 | 408 | | teal | #00cec9 | 409 | | orange | #ffa100 | 410 | | green | #64DD17 | 411 | | pink | #e84393 | 412 | | emerald | #2ecc71 | 413 | | yellow | #f4d13d | 414 | 415 | # Issues 416 | 417 | Found a problem or missing feature in Comment Anchors? 418 | Issues and suggestions can be submitted in the GitHub repository [here](https://github.com/StarlaneStudios/vscode-comment-anchors/issues) 419 | 420 | If you prefer more direct help, you can join the [Starlane Studios Discord](https://discord.gg/exaQDX2) where you can find most developers of this extension. 421 | 422 | ## Poor performance? 423 | 424 | Comment Anchor scans your entire workspace for tags. This can cause bad performance when your 425 | workspace contains many files, such as dependency directories and logfiles. It is therefore advised to alter the `matchFiles` and `excludeFiles` settings to limit the amount of directories and files scanned. 426 | 427 | If you'd rather disable workspace anchors all together, you can disable these in the settings. 428 | 429 | # Contribution 430 | 431 | You can contribute to comment-anchors by forking the GitHub [repository](https://github.com/StarlaneStudios/vscode-comment-anchors) and submitting pull requests. 432 | 433 | ## Are you enjoying Comment Anchors? 434 | 435 | Feel free to leave us a tip to support our development! 436 | 437 | Paypal: https://paypal.me/ExodiusStudios 438 | 439 | ### **Thanks for using Comment Anchors! ❤️** 440 | -------------------------------------------------------------------------------- /media/all-anchors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/all-anchors.png -------------------------------------------------------------------------------- /media/case-sensitive-anchors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/case-sensitive-anchors.png -------------------------------------------------------------------------------- /media/cursor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/cursor.png -------------------------------------------------------------------------------- /media/custom-tags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/custom-tags.png -------------------------------------------------------------------------------- /media/epics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/epics.png -------------------------------------------------------------------------------- /media/extension_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/extension_logo.png -------------------------------------------------------------------------------- /media/folding.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/folding.gif -------------------------------------------------------------------------------- /media/folding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/folding.png -------------------------------------------------------------------------------- /media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/icon.png -------------------------------------------------------------------------------- /media/intelli-sense.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/intelli-sense.png -------------------------------------------------------------------------------- /media/lazy-workspace.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/lazy-workspace.gif -------------------------------------------------------------------------------- /media/link-anchors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/link-anchors.png -------------------------------------------------------------------------------- /media/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/preview.gif -------------------------------------------------------------------------------- /media/workspace-anchors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarlaneStudios/vscode-comment-anchors/14680872d0cacf1fb792c300c26da13364f3a41e/media/workspace-anchors.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comment-anchors", 3 | "displayName": "Comment Anchors", 4 | "description": "Place anchor tags within comments for easy file and workspace navigation.", 5 | "version": "1.10.4", 6 | "publisher": "ExodiusStudios", 7 | "engines": { 8 | "vscode": "^1.75.0" 9 | }, 10 | "categories": [ 11 | "Other" 12 | ], 13 | "icon": "media/icon.png", 14 | "galleryBanner": { 15 | "color": "#314f88", 16 | "theme": "dark" 17 | }, 18 | "homepage": "https://github.com/StarlaneStudios/vscode-comment-anchors/blob/master/README.md", 19 | "repository": { 20 | "url": "https://github.com/StarlaneStudios/vscode-comment-anchors", 21 | "type": "git" 22 | }, 23 | "contributes": { 24 | "commands": [ 25 | { 26 | "command": "commentAnchors.parse", 27 | "title": "Parse the current file and look for anchors", 28 | "category": "Comment Anchors" 29 | }, 30 | { 31 | "command": "commentAnchors.toggle", 32 | "title": "Toggle the visibility of comment anchors", 33 | "category": "Comment Anchors" 34 | }, 35 | { 36 | "command": "commentAnchors.listTags", 37 | "title": "List configured anchor tags", 38 | "category": "Comment Anchors" 39 | }, 40 | { 41 | "command": "commentAnchors.exportAnchors", 42 | "title": "Export all workspace anchors", 43 | "category": "Comment Anchors" 44 | }, 45 | { 46 | "command": "commentAnchors.previousAnchor", 47 | "title": "Go to previous anchor", 48 | "category": "Comment Anchors" 49 | }, 50 | { 51 | "command": "commentAnchors.nextAnchor", 52 | "title": "Go to next anchor", 53 | "category": "Comment Anchors" 54 | }, 55 | { 56 | "command": "commentAnchors.navigateToAnchor", 57 | "title": "Quick navigate to anchor", 58 | "category": "Comment Anchors" 59 | }, 60 | { 61 | "command": "commentAnchors.openFileAndRevealLine", 62 | "title": "Open a file and reveal a line" 63 | }, 64 | { 65 | "command": "commentAnchors.launchWorkspaceScan", 66 | "title": "Initiate a workspace scan" 67 | } 68 | ], 69 | "keybindings": [ 70 | { 71 | "key": "alt+pageUp", 72 | "when": "editorTextFocus", 73 | "command": "commentAnchors.previousAnchor" 74 | }, 75 | { 76 | "key": "alt+pageDown", 77 | "when": "editorTextFocus", 78 | "command": "commentAnchors.nextAnchor" 79 | }, 80 | { 81 | "key": "alt+c alt+l", 82 | "when": "editorTextFocus", 83 | "command": "commentAnchors.navigateToAnchor" 84 | } 85 | ], 86 | "viewsContainers": { 87 | "activitybar": [ 88 | { 89 | "id": "comment-anchors", 90 | "title": "Comment Anchors", 91 | "icon": "res/icon.svg" 92 | } 93 | ] 94 | }, 95 | "views": { 96 | "comment-anchors": [ 97 | { 98 | "id": "fileAnchors", 99 | "name": "File Anchors" 100 | }, 101 | { 102 | "id": "workspaceAnchors", 103 | "name": "Workspace Anchors" 104 | }, 105 | { 106 | "id": "epicAnchors", 107 | "name": "Epic Anchors", 108 | "visibility": "collapsed" 109 | } 110 | ] 111 | }, 112 | "menus": { 113 | "view/title": [ 114 | { 115 | "command": "commentAnchors.parse", 116 | "when": "view == fileAnchors", 117 | "group": "operations" 118 | }, 119 | { 120 | "command": "commentAnchors.listTags", 121 | "when": "view == fileAnchors", 122 | "group": "operations" 123 | }, 124 | { 125 | "command": "commentAnchors.exportAnchors", 126 | "when": "view == workspaceAnchors", 127 | "group": "operations" 128 | } 129 | ] 130 | }, 131 | "configuration": { 132 | "type": "object", 133 | "title": "Comment Anchors", 134 | "properties": { 135 | "commentAnchors.tagHighlights.enabled": { 136 | "type": "boolean", 137 | "default": true, 138 | "description": "Whether to highlight the anchor tags", 139 | "scope": "window" 140 | }, 141 | "commentAnchors.parseDelay": { 142 | "type": "number", 143 | "default": 500, 144 | "description": "The delay between stopping with typing and anchors being reloaded. When you experience hangs or freezes, it is recommended to increase this value", 145 | "scope": "window" 146 | }, 147 | "commentAnchors.scrollPosition": { 148 | "type": "string", 149 | "default": "top", 150 | "enum": [ 151 | "top", 152 | "center", 153 | "bottom" 154 | ], 155 | "description": "Where to position the anchor when scrolled to", 156 | "scope": "window" 157 | }, 158 | "commentAnchors.showCursor": { 159 | "type": "boolean", 160 | "default": false, 161 | "description": "Display the current cursor position as entry in the file anchor list", 162 | "scope": "window" 163 | }, 164 | "commentAnchors.workspace.enabled": { 165 | "type": "boolean", 166 | "default": true, 167 | "description": "Scan the entire workspace instead of just the currently opened file", 168 | "scope": "window" 169 | }, 170 | "commentAnchors.workspace.lazyLoad": { 171 | "type": "boolean", 172 | "default": true, 173 | "description": "Require a manual trigger to start the workspace scan. Useful for when you want to reduce load time. (Default true)", 174 | "scope": "window" 175 | }, 176 | "commentAnchors.workspace.maxFiles": { 177 | "type": "integer", 178 | "default": 250, 179 | "description": "The maximum amount of files that will be parsed. Higher values may cause performance degration in large projects. (Default 250)", 180 | "scope": "window" 181 | }, 182 | "commentAnchors.workspace.matchFiles": { 183 | "type": "string", 184 | "default": "**/*", 185 | "description": "The glob pattern of the files that will be parsed by Comment Anchors", 186 | "scope": "window" 187 | }, 188 | "commentAnchors.workspace.excludeFiles": { 189 | "type": "string", 190 | "default": "**/{node_modules,.git,.idea,target,out,build,bin,obj,dist,vendor}/**/*", 191 | "description": "The glob pattern of the files that will be excluded from matching by Comment Anchors", 192 | "scope": "window" 193 | }, 194 | "commentAnchors.workspace.pathFormat": { 195 | "type": "string", 196 | "default": "full", 197 | "description": "Alter how the workspace anchor tree displays path names", 198 | "scope": "window", 199 | "enum": [ 200 | "full", 201 | "abbreviated", 202 | "hidden" 203 | ] 204 | }, 205 | "commentAnchors.epic.provideAutoCompletion": { 206 | "type": "boolean", 207 | "default": true, 208 | "description": "Whether auto complete an epic", 209 | "scope": "window" 210 | }, 211 | "commentAnchors.epic.seqStep": { 212 | "type": "integer", 213 | "default": 1, 214 | "description": "Only useful when autoComplete is enabled. This will be added to the max seq for each epic as shown in the autocomplete item", 215 | "scope": "window" 216 | }, 217 | "commentAnchors.tags.provideAutoCompletion": { 218 | "type": "boolean", 219 | "default": true, 220 | "description": "Whether to provide auto completion entries for anchors", 221 | "scope": "window" 222 | }, 223 | "commentAnchors.tags.displayInSidebar": { 224 | "type": "boolean", 225 | "default": true, 226 | "description": "Whether to display tag names in the sidebar", 227 | "scope": "window" 228 | }, 229 | "commentAnchors.tags.displayInGutter": { 230 | "type": "boolean", 231 | "default": true, 232 | "description": "Whether to display tag icons in the gutter", 233 | "scope": "window" 234 | }, 235 | "commentAnchors.tags.displayInRuler": { 236 | "type": "boolean", 237 | "default": true, 238 | "description": "Whether to highlight tags in the scrollbar ruler", 239 | "scope": "window" 240 | }, 241 | "commentAnchors.tags.rulerStyle": { 242 | "type": "string", 243 | "enum": [ 244 | "center", 245 | "left", 246 | "right", 247 | "full" 248 | ], 249 | "default": "left", 250 | "description": "The display style of tags in the ruler lane (Requires displayInRuler)", 251 | "scope": "window" 252 | }, 253 | "commentAnchors.tags.displayLineNumber": { 254 | "type": "boolean", 255 | "default": true, 256 | "description": "Whether to display line numbers in the sidebar", 257 | "scope": "window" 258 | }, 259 | "commentAnchors.tags.displayTagName": { 260 | "type": "boolean", 261 | "default": true, 262 | "description": "Whether to display tag names in the sidebar", 263 | "scope": "window" 264 | }, 265 | "commentAnchors.tags.sortMethod": { 266 | "type": "string", 267 | "default": "line", 268 | "enum": [ 269 | "type", 270 | "line" 271 | ], 272 | "description": "The method used to sort the anchors in the sidebar", 273 | "scope": "window" 274 | }, 275 | "commentAnchors.tags.sortAlphabetically": { 276 | "type": "boolean", 277 | "default": false, 278 | "description": "Whether to sort tags alphabetically", 279 | "scope": "window" 280 | }, 281 | "commentAnchors.tags.matchCase": { 282 | "type": "boolean", 283 | "default": true, 284 | "description": "When true, performs case sensitive matches on tags", 285 | "scope": "window" 286 | }, 287 | "commentAnchors.tags.displayHierarchyInWorkspace": { 288 | "type": "boolean", 289 | "default": true, 290 | "description": "When true, displays hierarchical anchors in the workspace list", 291 | "scope": "window" 292 | }, 293 | "commentAnchors.tags.expandSections": { 294 | "type": "boolean", 295 | "default": true, 296 | "description": "When true, sections are automatically expanded in the tree", 297 | "scope": "window" 298 | }, 299 | "commentAnchors.tags.separators": { 300 | "type": "array", 301 | "default": [ 302 | " - ", 303 | ": ", 304 | " " 305 | ], 306 | "items": { 307 | "type": "string" 308 | }, 309 | "description": "List of separators that can be placed between tags and comments", 310 | "scope": "window" 311 | }, 312 | "commentAnchors.tags.matchPrefix": { 313 | "type": "array", 314 | "default": [ 315 | "", 332 | "*/" 333 | ], 334 | "items": { 335 | "type": "string" 336 | }, 337 | "description": "List of strings which may suffix a matched comment", 338 | "scope": "window" 339 | }, 340 | "commentAnchors.tags.endTag": { 341 | "type": "string", 342 | "default": "!", 343 | "description": "Specify a custom region end tag", 344 | "scope": "window" 345 | }, 346 | "commentAnchors.tags.list": { 347 | "deprecationMessage": "Please migrate to the new commentAnchors.tags.anchors property." 348 | }, 349 | "commentAnchors.tags.anchors": { 350 | "type": "object", 351 | "description": "List of additional custom Comment Anchor tags", 352 | "scope": "window", 353 | "additionalProperties": { 354 | "type": "object", 355 | "required": [ 356 | "scope" 357 | ], 358 | "properties": { 359 | "enabled": { 360 | "type": "boolean", 361 | "default": true, 362 | "description": "Enable or disable this tag" 363 | }, 364 | "iconColor": { 365 | "type": "string", 366 | "default": null, 367 | "description": "The icon color as hex value. Set to null to default to highlightColor" 368 | }, 369 | "highlightColor": { 370 | "type": "string", 371 | "description": "The foreground color applied to the anchor" 372 | }, 373 | "backgroundColor": { 374 | "type": "string", 375 | "description": "The background color applied to the anchor" 376 | }, 377 | "styleMode": { 378 | "type": "string", 379 | "description": "The style mode to use when styling the anchor. Replaces the legacy 'styleComment' option", 380 | "enum": [ 381 | "tag", 382 | "comment", 383 | "full" 384 | ] 385 | }, 386 | "borderStyle": { 387 | "type": "string", 388 | "description": "The style applied to the border, see https://www.w3schools.com/cssref/pr_border.asp for more information" 389 | }, 390 | "borderRadius": { 391 | "type": "number", 392 | "description": "The curvature radius of the applied border (Requires borderStyle to be set)", 393 | "default": 0 394 | }, 395 | "ruler": { 396 | "type": "boolean", 397 | "default": true, 398 | "description": "Sets whether the tag is rendered in ruler" 399 | }, 400 | "textDecorationStyle": { 401 | "type": "string", 402 | "description": "The style applied to the text-decoration, see https://www.w3schools.com/cssref/pr_text_text-decoration.php for more information" 403 | }, 404 | "isBold": { 405 | "type": "boolean", 406 | "description": "Sets whether the tag is rendered in bold", 407 | "default": true 408 | }, 409 | "isItalic": { 410 | "type": "boolean", 411 | "description": "Sets whether the tag is rendered in italics", 412 | "default": true 413 | }, 414 | "scope": { 415 | "type": "string", 416 | "enum": [ 417 | "hidden", 418 | "file", 419 | "workspace" 420 | ], 421 | "description": "Defines the scope of this tag. Setting this to \"file\" makes these tags only visible in the 'File Anchors' list, while \"hidden\" completely hides it from the anchor list", 422 | "default": "workspace" 423 | }, 424 | "behavior": { 425 | "type": "string", 426 | "enum": [ 427 | "anchor", 428 | "region", 429 | "link" 430 | ], 431 | "description": "Specify the behavior type of this tag", 432 | "default": "anchor" 433 | } 434 | } 435 | }, 436 | "default": { 437 | "ANCHOR": { 438 | "iconColor": "default", 439 | "highlightColor": "#A8C023", 440 | "scope": "file" 441 | }, 442 | "TODO": { 443 | "iconColor": "blue", 444 | "highlightColor": "#3ea8ff", 445 | "scope": "workspace" 446 | }, 447 | "FIXME": { 448 | "iconColor": "red", 449 | "highlightColor": "#F44336", 450 | "scope": "workspace" 451 | }, 452 | "STUB": { 453 | "iconColor": "purple", 454 | "highlightColor": "#BA68C8", 455 | "scope": "file" 456 | }, 457 | "NOTE": { 458 | "iconColor": "orange", 459 | "highlightColor": "#FFB300", 460 | "scope": "file" 461 | }, 462 | "REVIEW": { 463 | "iconColor": "green", 464 | "highlightColor": "#64DD17", 465 | "scope": "workspace" 466 | }, 467 | "SECTION": { 468 | "iconColor": "blurple", 469 | "highlightColor": "#896afc", 470 | "scope": "workspace", 471 | "behavior": "region" 472 | }, 473 | "LINK": { 474 | "iconColor": "#2ecc71", 475 | "highlightColor": "#2ecc71", 476 | "scope": "workspace", 477 | "behavior": "link" 478 | } 479 | } 480 | } 481 | } 482 | } 483 | }, 484 | "activationEvents": [ 485 | "onStartupFinished" 486 | ], 487 | "vsce": { 488 | "yarn": true 489 | }, 490 | "main": "./out/extension", 491 | "scripts": { 492 | "vscode:prepublish": "yarn compile", 493 | "compile": "tsc -p ./", 494 | "watch": "tsc -watch -p ./", 495 | "lint": "eslint src --ext .ts", 496 | "lint:fix": "eslint src --fix --ext .ts" 497 | }, 498 | "devDependencies": { 499 | "@types/debounce": "^1.2.4", 500 | "@types/mocha": "^10.0.6", 501 | "@types/node": "20.11.5", 502 | "@types/vscode": "^1.75.0", 503 | "@typescript-eslint/eslint-plugin": "^6.19.0", 504 | "@typescript-eslint/parser": "^6.19.0", 505 | "eslint": "^8.56.0", 506 | "eslint-plugin-unicorn": "^50.0.1", 507 | "eslint-plugin-unused-imports": "^3.0.0", 508 | "typescript": "^5.3.3" 509 | }, 510 | "dependencies": { 511 | "csv-stringify": "^6.4.5", 512 | "debounce": "^2.0.0", 513 | "minimatch": "^9.0.3" 514 | } 515 | } 516 | -------------------------------------------------------------------------------- /res/anchor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | anchor 4 | 5 | -------------------------------------------------------------------------------- /res/anchor_black.svg: -------------------------------------------------------------------------------- 1 | anchor_black -------------------------------------------------------------------------------- /res/anchor_end.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /res/anchor_end_black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /res/anchor_end_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /res/anchor_white.svg: -------------------------------------------------------------------------------- 1 | anchor_white -------------------------------------------------------------------------------- /res/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | cross 9 | 11 | 12 | -------------------------------------------------------------------------------- /res/cursor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | -------------------------------------------------------------------------------- /res/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | icon 5 | 11 | 12 | -------------------------------------------------------------------------------- /res/launch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | launch 9 | 11 | 13 | 14 | -------------------------------------------------------------------------------- /res/load.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | load 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/anchor/entryAnchor.ts: -------------------------------------------------------------------------------- 1 | import { AnchorEngine, TagAttributes } from "../anchorEngine"; 2 | import { DecorationOptions, Range, TextDocument, Uri, window } from "vscode"; 3 | 4 | import EntryBase from "./entryBase"; 5 | 6 | /** 7 | * Represents an Anchor found a file 8 | */ 9 | export default class EntryAnchor extends EntryBase { 10 | 11 | /** The sorting method to use, defaults to line */ 12 | public static SortMethod = "line"; 13 | 14 | /** The position of the anchor when scrolled to */ 15 | public static ScrollPosition = "top"; 16 | 17 | /** 18 | * Child anchors, only present when this anchor is a region type 19 | */ 20 | private childAnchors: EntryAnchor[] = []; 21 | 22 | public constructor( 23 | engine: AnchorEngine, 24 | public readonly anchorTag: string, // The tag e.g. "ANCHOR" 25 | public readonly anchorText: string, // The text after the anchor tag 26 | public readonly startIndex: number, // The start column of the anchor 27 | public readonly endIndex: number, // The end column of the tag 28 | public readonly anchorLength: number, // The full length of the matched anchor string 29 | public readonly lineNumber: number, // The line number the tag was found on 30 | public readonly iconColor: string, // The icon color to use 31 | public readonly scope: string, // The anchor scope 32 | public readonly showLine: boolean, // Whether to display line numbers 33 | public readonly file: Uri, // The file this anchor is in 34 | public readonly attributes: TagAttributes // The attriibutes this tag has 35 | ) { 36 | super(engine, showLine ? `[${lineNumber}] ${anchorText}` : anchorText); 37 | 38 | this.tooltip = `${anchorText} (Click to reveal)`; 39 | this.command = { 40 | title: "", 41 | command: "commentAnchors.openFileAndRevealLine", 42 | arguments: [ 43 | { 44 | uri: file, 45 | lineNumber: this.lineNumber - 1, 46 | at: EntryAnchor.ScrollPosition, 47 | }, 48 | ], 49 | }; 50 | 51 | if (iconColor == "default" || iconColor == "auto") { 52 | this.iconPath = { 53 | light: this.loadResourceSvg("anchor_black"), 54 | dark: this.loadResourceSvg("anchor_white"), 55 | }; 56 | } else { 57 | this.iconPath = this.loadCacheSvg(iconColor); 58 | } 59 | } 60 | 61 | public contextValue = "anchor"; 62 | 63 | public get isHidden(): boolean { 64 | return this.scope == "hidden"; 65 | } 66 | 67 | public get isVisibleInWorkspace(): boolean { 68 | return this.scope == "workspace"; 69 | } 70 | 71 | public get children(): EntryAnchor[] { 72 | return [...this.childAnchors]; 73 | } 74 | 75 | public getAnchorRange(document: TextDocument, includeText: boolean): Range { 76 | let ending: number; 77 | 78 | if (includeText) { 79 | ending = this.startIndex + this.anchorLength; 80 | } else { 81 | ending = this.endIndex; 82 | } 83 | 84 | return new Range(document.positionAt(this.startIndex), document.positionAt(ending)); 85 | } 86 | 87 | public decorateDocument(document: TextDocument, options: DecorationOptions[]): void { 88 | options.push({ 89 | range: this.getAnchorRange(document, false) 90 | }); 91 | } 92 | 93 | public addChild(child: EntryAnchor): void { 94 | this.childAnchors.push(child); 95 | } 96 | 97 | public toString(): string { 98 | return "EntryAnchor(" + this.label! + ")"; 99 | } 100 | 101 | public copy(copyChilds: boolean, showLine: boolean | undefined = undefined): EntryAnchor { 102 | const copy = new EntryAnchor( 103 | this.engine, 104 | this.anchorTag, 105 | this.anchorText, 106 | this.startIndex, 107 | this.endIndex, 108 | this.anchorLength, 109 | this.lineNumber, 110 | this.iconColor, 111 | this.scope, 112 | showLine === undefined ? this.showLine : showLine, 113 | this.file, 114 | this.attributes 115 | ); 116 | 117 | if (copyChilds) { 118 | for (const child of this.children) { 119 | copy.addChild(child.copy(copyChilds, showLine)); 120 | } 121 | } 122 | 123 | return copy; 124 | } 125 | 126 | /** 127 | * Sort anchors based on the currently defined sort method 128 | * 129 | * @param anchors Anchors to sort 130 | */ 131 | public static sortAnchors(anchors: EntryAnchor[]): EntryAnchor[] { 132 | return anchors.sort((left, right) => { 133 | switch (this.SortMethod) { 134 | case "line": { 135 | return left.startIndex - right.startIndex; 136 | } 137 | case "type": { 138 | return left.anchorTag.localeCompare(right.anchorTag); 139 | } 140 | default: { 141 | window.showErrorMessage("Invalid sorting method: " + this.SortMethod); 142 | return 0; 143 | } 144 | } 145 | }); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/anchor/entryAnchorRegion.ts: -------------------------------------------------------------------------------- 1 | import { AnchorEngine, TagAttributes } from "../anchorEngine"; 2 | import { DecorationOptions, Range, TextDocument, TreeItemCollapsibleState, Uri } from "vscode"; 3 | 4 | import EntryAnchor from "./entryAnchor"; 5 | 6 | /** 7 | * Represents an Anchor found a file 8 | */ 9 | export default class EntryAnchorRegion extends EntryAnchor { 10 | 11 | public closeStartIndex = -1; 12 | public closeEndIndex = -1; 13 | public closeLineNumber = -1; 14 | 15 | public constructor( 16 | engine: AnchorEngine, 17 | public readonly anchorTag: string, // The tag e.g. "ANCHOR" 18 | public readonly anchorText: string, // The text after the anchor tag 19 | public readonly startIndex: number, // The start column of the anchor 20 | public readonly endIndex: number, // The end column of the tag 21 | public readonly anchorLength: number, // The full length of the matched anchor string 22 | public readonly lineNumber: number, // The line number the tag was found on 23 | public readonly iconColor: string, // The icon color to use 24 | public readonly scope: string, // The anchor scope 25 | public readonly showLine: boolean, // Whether to display line numbers 26 | public readonly file: Uri, // The file this anchor is in 27 | public readonly attributes: TagAttributes // The attriibutes this tag has 28 | ) { 29 | super(engine, anchorTag, anchorText, startIndex, endIndex, anchorLength, lineNumber, iconColor, scope, showLine, file, attributes); 30 | 31 | this.label = showLine ? `[${lineNumber} - ?] ${anchorText}` : anchorText; 32 | 33 | const autoExpand = engine._config!.tags.expandSections; 34 | this.collapsibleState = autoExpand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed; 35 | } 36 | 37 | public setEndTag(endTag: { startIndex: number; endIndex: number; lineNumber: number }): void { 38 | this.closeStartIndex = endTag.startIndex; 39 | this.closeEndIndex = endTag.endIndex; 40 | this.closeLineNumber = endTag.lineNumber; 41 | 42 | if (this.showLine) { 43 | this.label = `[${this.lineNumber} - ${endTag.lineNumber}] ${this.anchorText}`; 44 | } 45 | } 46 | 47 | public decorateDocumentEnd(document: TextDocument, options: DecorationOptions[]): void { 48 | if (this.closeStartIndex < 0 || this.closeEndIndex < 0) return; 49 | 50 | const startPos = document.positionAt(this.closeStartIndex); 51 | const endPos = document.positionAt(this.closeEndIndex); 52 | 53 | options.push({ 54 | hoverMessage: "Comment Anchor End Region: " + this.anchorText, 55 | range: new Range(startPos, endPos), 56 | }); 57 | } 58 | 59 | public toString(): string { 60 | return "EntryAnchorRegion(" + this.label! + ")"; 61 | } 62 | 63 | public copy(copyChilds: boolean): EntryAnchorRegion { 64 | const copy = new EntryAnchorRegion( 65 | this.engine, 66 | this.anchorTag, 67 | this.anchorText, 68 | this.startIndex, 69 | this.endIndex, 70 | this.anchorLength, 71 | this.lineNumber, 72 | this.iconColor, 73 | this.scope, 74 | this.showLine, 75 | this.file, 76 | this.attributes 77 | ); 78 | 79 | if (this.closeStartIndex >= 0) { 80 | copy.setEndTag({ 81 | startIndex: this.closeStartIndex, 82 | endIndex: this.closeEndIndex, 83 | lineNumber: this.closeLineNumber, 84 | }); 85 | } 86 | 87 | if (copyChilds) { 88 | for (const child of this.children) { 89 | copy.addChild(child.copy(copyChilds)); 90 | } 91 | } 92 | 93 | return copy; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/anchor/entryBase.ts: -------------------------------------------------------------------------------- 1 | import { TreeItem, TreeItemCollapsibleState } from "vscode"; 2 | import * as path from "node:path"; 3 | import { AnchorEngine } from "../anchorEngine"; 4 | 5 | /** 6 | * Base class extended by all implementions of a TreeItem 7 | * which represent an entity in the anchor panel. 8 | */ 9 | export default class EntryBase extends TreeItem { 10 | 11 | public readonly engine: AnchorEngine; 12 | 13 | public constructor(engine: AnchorEngine, label: string, state?: TreeItemCollapsibleState) { 14 | super(label, state); 15 | 16 | this.engine = engine; 17 | } 18 | 19 | /** 20 | * Load an svg of the given name from the resource directory 21 | * 22 | * @param name Icon name 23 | * @returns The path 24 | */ 25 | public loadResourceSvg(name: string): string { 26 | return path.join(__dirname, "../../res", name + ".svg"); 27 | } 28 | 29 | /** 30 | * Load an svg of the given color from the resource directory. 31 | * The icon must be generated first. 32 | * 33 | * @param name Icon color 34 | * @returns The path 35 | */ 36 | public loadCacheSvg(color: string): string { 37 | return path.join(this.engine.iconCache, "anchor_" + color + ".svg"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/anchor/entryCachedFile.ts: -------------------------------------------------------------------------------- 1 | import { TreeItemCollapsibleState, Uri, workspace, ThemeIcon } from "vscode"; 2 | import EntryAnchor from "./entryAnchor"; 3 | import EntryBase from "./entryBase"; 4 | import * as path from "node:path"; 5 | import { AnchorEngine } from "../anchorEngine"; 6 | 7 | /** 8 | * Represents a workspace file holding one or more anchors 9 | */ 10 | export default class EntryCachedFile extends EntryBase { 11 | 12 | public constructor(engine: AnchorEngine, public readonly file: Uri, public readonly anchors: EntryAnchor[], public readonly format: string) { 13 | super(engine, EntryCachedFile.fileAnchorStats(file, anchors, format), TreeItemCollapsibleState.Expanded); 14 | 15 | this.tooltip = `${this.file.path}`; 16 | this.iconPath = ThemeIcon.File; 17 | } 18 | 19 | public contextValue = "cachedFile"; 20 | 21 | public toString(): string { 22 | return this.label as string; 23 | } 24 | 25 | /** 26 | * Formats a file stats string using the given anchors array 27 | */ 28 | public static fileAnchorStats(file: Uri, anchors: EntryAnchor[], format: string): string { 29 | let visible = 0; 30 | let hidden = 0; 31 | 32 | for (const anchor of anchors) { 33 | if (anchor.isVisibleInWorkspace) { 34 | visible++; 35 | } else { 36 | hidden++; 37 | } 38 | } 39 | 40 | let ret = visible + " Anchors"; 41 | 42 | if (hidden > 0) { 43 | ret += ", " + hidden + " Hidden"; 44 | } 45 | 46 | let title = " (" + ret + ")"; 47 | let titlePath; 48 | 49 | const root = workspace.getWorkspaceFolder(file) || workspace.workspaceFolders![0]; 50 | 51 | if (root) { 52 | titlePath = path.relative(root.uri.path, file.path); 53 | } else { 54 | titlePath = file.path; 55 | } 56 | 57 | // Verify relativity 58 | if (titlePath.startsWith("..")) { 59 | throw new Error("Cannot crate cached file for external documents"); 60 | } 61 | 62 | // Always use unix style separators 63 | titlePath = titlePath.replaceAll('\\', "/"); 64 | 65 | // Tweak the path format based on settings 66 | if (format == "hidden") { 67 | title = titlePath.slice(titlePath.lastIndexOf("/") + 1); 68 | } else if (format == "abbreviated") { 69 | const segments = titlePath.split("/"); 70 | const abbrPath = segments 71 | .map((segment, i) => { 72 | if (i < segments.length - 1 && i > 0) { 73 | return segment[0]; 74 | } else { 75 | return segment; 76 | } 77 | }) 78 | .join("/"); 79 | 80 | title = abbrPath + title; 81 | } else { 82 | title = titlePath + title; 83 | } 84 | 85 | if (workspace.workspaceFolders!.length > 1) { 86 | let ws = root.name; 87 | 88 | if (ws.length > 12) { 89 | ws = ws.slice(0, 12) + "…"; 90 | } 91 | 92 | title = ws + " → " + title; 93 | } 94 | 95 | return title; 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/anchor/entryCursor.ts: -------------------------------------------------------------------------------- 1 | import { AnchorEngine } from "../anchorEngine"; 2 | import { TreeItemCollapsibleState } from "vscode"; 3 | import EntryBase from "./entryBase"; 4 | 5 | /** 6 | * Represents the current cursor 7 | */ 8 | export default class EntryCursor extends EntryBase { 9 | 10 | public constructor(engine: AnchorEngine, line: number) { 11 | super(engine, `➤ Cursor (line ${line})`, TreeItemCollapsibleState.None); 12 | 13 | this.tooltip = this.label as string; 14 | 15 | this.iconPath = { 16 | light: this.loadResourceSvg("cursor"), 17 | dark: this.loadResourceSvg("cursor"), 18 | }; 19 | } 20 | 21 | public toString(): string { 22 | return "EntryCursor{}"; 23 | } 24 | 25 | public contextValue = "cursor"; 26 | } 27 | -------------------------------------------------------------------------------- /src/anchor/entryEpic.ts: -------------------------------------------------------------------------------- 1 | import { TreeItemCollapsibleState } from "vscode"; 2 | import { AnchorEngine } from "../anchorEngine"; 3 | import EntryAnchor from "./entryAnchor"; 4 | import EntryBase from "./entryBase"; 5 | 6 | /** 7 | * Represents an Anchor found a Epic 8 | */ 9 | export default class EntryEpic extends EntryBase { 10 | 11 | public readonly epic: string; 12 | public readonly anchors: EntryAnchor[]; 13 | 14 | public constructor(epic: string, label: string, anchors: EntryAnchor[], engine: AnchorEngine) { 15 | super(engine, label, TreeItemCollapsibleState.Expanded); 16 | 17 | this.epic = epic; 18 | this.anchors = anchors; 19 | this.tooltip = `${this.epic}`; 20 | // this.iconPath = { 21 | // light: path.join(__dirname, '..', 'res', `book.svg`), 22 | // dark: path.join(__dirname, '..', 'res', `book.svg`) 23 | // }; 24 | } 25 | 26 | public toString(): string { 27 | return this.label as string; 28 | } 29 | 30 | public contextValue = "epic"; 31 | } 32 | -------------------------------------------------------------------------------- /src/anchor/entryError.ts: -------------------------------------------------------------------------------- 1 | import { TreeItemCollapsibleState } from "vscode"; 2 | import { AnchorEngine } from "../anchorEngine"; 3 | import EntryBase from "./entryBase"; 4 | 5 | /** 6 | * Represents a caught error 7 | */ 8 | export default class EntryError extends EntryBase { 9 | 10 | private message: string; 11 | 12 | public constructor(engine: AnchorEngine, message: string) { 13 | super(engine, message, TreeItemCollapsibleState.None); 14 | 15 | this.message = message; 16 | this.tooltip = this.message; 17 | 18 | this.iconPath = { 19 | light: this.loadResourceSvg("cross"), 20 | dark: this.loadResourceSvg("cross"), 21 | }; 22 | } 23 | 24 | public toString(): string { 25 | return "EntryError{}"; 26 | } 27 | 28 | public contextValue = "error"; 29 | } 30 | -------------------------------------------------------------------------------- /src/anchor/entryLoading.ts: -------------------------------------------------------------------------------- 1 | import { TreeItemCollapsibleState } from "vscode"; 2 | import { AnchorEngine } from "../anchorEngine"; 3 | import EntryBase from "./entryBase"; 4 | 5 | /** 6 | * Represents an active workspace scan 7 | */ 8 | export default class EntryLoading extends EntryBase { 9 | 10 | public constructor(engine: AnchorEngine) { 11 | super(engine, "Searching for anchors...", TreeItemCollapsibleState.None); 12 | 13 | this.tooltip = this.label as string; 14 | this.iconPath = { 15 | light: this.loadResourceSvg("load"), 16 | dark: this.loadResourceSvg("load"), 17 | }; 18 | } 19 | 20 | public contextValue = "loading"; 21 | 22 | public toString(): string { 23 | return "EntryLoading{}"; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/anchor/entryScan.ts: -------------------------------------------------------------------------------- 1 | import { TreeItemCollapsibleState } from "vscode"; 2 | import { AnchorEngine } from "../anchorEngine"; 3 | import EntryBase from "./entryBase"; 4 | 5 | /** 6 | * Represents a pending workspace scan 7 | */ 8 | export default class EntryScan extends EntryBase { 9 | 10 | public constructor(engine: AnchorEngine) { 11 | super(engine, "Click to start scanning", TreeItemCollapsibleState.None); 12 | 13 | this.tooltip = this.label as string; 14 | this.iconPath = { 15 | light: this.loadResourceSvg("launch"), 16 | dark: this.loadResourceSvg("launch"), 17 | }; 18 | 19 | this.command = { 20 | title: "Initiate scan", 21 | command: "commentAnchors.launchWorkspaceScan", 22 | }; 23 | } 24 | 25 | public contextValue = "launch"; 26 | 27 | public toString(): string { 28 | return "EntryLaunch{}"; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/anchorEngine.ts: -------------------------------------------------------------------------------- 1 | import debounce from "debounce"; 2 | import * as fs from "node:fs"; 3 | import * as path from "node:path"; 4 | 5 | import { 6 | DecorationOptions, 7 | DecorationRenderOptions, 8 | Disposable, 9 | EventEmitter, 10 | ExtensionContext, 11 | FileSystemWatcher, 12 | FoldingRange, 13 | FoldingRangeKind, 14 | OverviewRulerLane, 15 | StatusBarAlignment, 16 | TextDocument, 17 | TextDocumentChangeEvent, 18 | TextEditor, 19 | TextEditorDecorationType, 20 | TreeView, 21 | Uri, 22 | ViewColumn, 23 | WorkspaceConfiguration, 24 | commands, 25 | languages, 26 | window, 27 | workspace, 28 | Selection, 29 | } from "vscode"; 30 | 31 | import EntryAnchor from "./anchor/entryAnchor"; 32 | import EntryAnchorRegion from "./anchor/entryAnchorRegion"; 33 | import EntryBase from "./anchor/entryBase"; 34 | import EntryCursor from "./anchor/entryCursor"; 35 | import EntryError from "./anchor/entryError"; 36 | import EntryLoading from "./anchor/entryLoading"; 37 | import EntryScan from "./anchor/entryScan"; 38 | import escapeStringRegexp from "./util/escape"; 39 | import { MinimatchOptions, minimatch } from 'minimatch'; 40 | import { AnchorIndex } from "./anchorIndex"; 41 | import { LinkProvider } from "./util/linkProvider"; 42 | import { FileAnchorProvider } from "./provider/fileAnchorProvider"; 43 | import { EpicAnchorIntelliSenseProvider, EpicAnchorProvider } from "./provider/epicAnchorProvider"; 44 | import { WorkspaceAnchorProvider } from "./provider/workspaceAnchorProvider"; 45 | import { asyncDelay } from "./util/asyncDelay"; 46 | import { createViewContent } from "./anchorListView"; 47 | import { flattenAnchors } from "./util/flattener"; 48 | import { registerDefaults } from "./util/defaultTags"; 49 | import { setupCompletionProvider } from "./util/completionProvider"; 50 | import { parseCustomAnchors } from "./util/customTags"; 51 | 52 | /* -- Constants -- */ 53 | 54 | const HEX_COLOR_REGEX = /^#([\da-f]{3}){1,2}$/i; 55 | const COLOR_PLACEHOLDER_REGEX = /%COLOR%/g; 56 | 57 | const MATCH_OPTIONS: MinimatchOptions = { 58 | dot: true 59 | }; 60 | 61 | /* -- Anchor entry type aliases -- */ 62 | 63 | export type FileEntry = EntryAnchor | EntryError | EntryLoading | EntryCursor; 64 | export type FileEntryArray = EntryAnchor[] | EntryError[] | EntryLoading[] | EntryCursor[]; 65 | 66 | export type AnyEntry = EntryBase; 67 | export type AnyEntryArray = EntryBase[]; 68 | 69 | const MATCHER_TAG_INDEX = 1; 70 | const MATCHER_ATTR_INDEX = 2; 71 | const MATCHER_COMMENT_INDEX = 3; 72 | 73 | /** 74 | * The main anchor parsing and caching engine 75 | */ 76 | export class AnchorEngine { 77 | 78 | /** The context of Comment Anchors */ 79 | public context: ExtensionContext; 80 | 81 | /** Then event emitter in charge of refreshing the file trees */ 82 | public _onDidChangeTreeData: EventEmitter = new EventEmitter(); 83 | 84 | /** Then event emitter in charge of refreshing the document link */ 85 | public _onDidChangeLinkData: EventEmitter = new EventEmitter(); 86 | 87 | /** Debounced function for performance improvements */ 88 | private _idleRefresh: (() => void) | undefined; 89 | 90 | /** The RegEx used for matching */ 91 | public matcher: RegExp | undefined; 92 | 93 | /** A cache holding all documents */ 94 | public anchorMaps: Map = new Map(); 95 | 96 | /** The decorators used for decorating the anchors */ 97 | public anchorDecorators: Map = new Map(); 98 | 99 | /** The decorators used for decorating the region end anchors */ 100 | public anchorEndDecorators: Map = new Map(); 101 | 102 | /** The list of tags and their settings */ 103 | public tags: Map = new Map(); 104 | 105 | /** Returns true when all anchors have been loaded */ 106 | public anchorsLoaded = false; 107 | 108 | /** Holds whether a scan has been performed since rebuild */ 109 | public anchorsScanned = false; 110 | 111 | /** Holds whether anchors may be outdated */ 112 | public anchorsDirty = true; 113 | 114 | /** The tree view used for displaying file anchors */ 115 | public fileTreeView: TreeView; 116 | 117 | /** The tree view used for displaying workspace anchors */ 118 | public workspaceTreeView: TreeView; 119 | 120 | /** The epic view used for displaying workspace anchors */ 121 | public epicTreeView: TreeView; 122 | 123 | /** The resource for the link provider */ 124 | public linkProvider: LinkProvider; 125 | 126 | /** The currently expanded file tree items */ 127 | public expandedFileTreeViewItems: string[] = []; 128 | 129 | /** The currently expanded workspace tree items */ 130 | public expandedWorkspaceTreeViewItems: string[] = []; 131 | 132 | /** The icon cache directory */ 133 | public iconCache = ""; 134 | 135 | /** The current editor */ 136 | public _editor: TextEditor | undefined; 137 | 138 | /** Anchor comments config settings */ 139 | public _config: WorkspaceConfiguration | undefined; 140 | 141 | /** The current file system watcher */ 142 | private _watcher: FileSystemWatcher | undefined; 143 | 144 | /** List of build subscriptions */ 145 | private _subscriptions: Disposable[] = []; 146 | private linkDisposable!: Disposable; 147 | 148 | /** The debug output for comment anchors */ 149 | public static output: (msg: string) => void; 150 | 151 | // Possible error entries // 152 | public errorUnusableItem: EntryError = new EntryError(this, "Waiting for open editor..."); 153 | public errorEmptyItem: EntryError = new EntryError(this, "No comment anchors detected"); 154 | public errorEmptyWorkspace: EntryError = new EntryError(this, "No comment anchors in workspace"); 155 | public errorEmptyEpics: EntryError = new EntryError(this, "No epics found in workspace"); 156 | public errorWorkspaceDisabled: EntryError = new EntryError(this, "Workspace disabled"); 157 | public errorFileOnly: EntryError = new EntryError(this, "No open workspaces"); 158 | public statusLoading: EntryLoading = new EntryLoading(this); 159 | public statusScan: EntryScan = new EntryScan(this); 160 | 161 | private cursorTask?: NodeJS.Timeout; 162 | 163 | public constructor(context: ExtensionContext) { 164 | this.context = context; 165 | 166 | window.onDidChangeActiveTextEditor((e) => this.onActiveEditorChanged(e), this, context.subscriptions); 167 | workspace.onDidChangeTextDocument((e) => this.onDocumentChanged(e), this, context.subscriptions); 168 | workspace.onDidChangeConfiguration(() => this.buildResources(), this, context.subscriptions); 169 | workspace.onDidChangeWorkspaceFolders(() => this.buildResources(), this, context.subscriptions); 170 | workspace.onDidCloseTextDocument((e) => this.cleanUp(e), this, context.subscriptions); 171 | 172 | const outputChannel = window.createOutputChannel("Comment Anchors"); 173 | 174 | AnchorEngine.output = (m: string) => outputChannel.appendLine("[Comment Anchors] " + m); 175 | 176 | if (window.activeTextEditor) { 177 | this._editor = window.activeTextEditor; 178 | } 179 | 180 | // Create the file anchor view 181 | this.fileTreeView = window.createTreeView("fileAnchors", { 182 | treeDataProvider: new FileAnchorProvider(this), 183 | showCollapseAll: true, 184 | }); 185 | 186 | this.fileTreeView.onDidExpandElement((e) => { 187 | if (e.element instanceof EntryAnchor) { 188 | this.expandedFileTreeViewItems.push(e.element.anchorText); 189 | } 190 | }); 191 | 192 | this.fileTreeView.onDidCollapseElement((e) => { 193 | if (e.element instanceof EntryAnchor) { 194 | const idx = this.expandedFileTreeViewItems.indexOf(e.element.anchorText); 195 | this.expandedFileTreeViewItems.splice(idx, 1); 196 | } 197 | }); 198 | 199 | // Create the workspace anchor view 200 | this.workspaceTreeView = window.createTreeView("workspaceAnchors", { 201 | treeDataProvider: new WorkspaceAnchorProvider(this), 202 | showCollapseAll: true, 203 | }); 204 | 205 | this.workspaceTreeView.onDidExpandElement((e) => { 206 | if (e.element instanceof EntryAnchor) { 207 | this.expandedWorkspaceTreeViewItems.push(e.element.anchorText); 208 | } 209 | }); 210 | 211 | this.workspaceTreeView.onDidCollapseElement((e) => { 212 | if (e.element instanceof EntryAnchor) { 213 | const idx = this.expandedWorkspaceTreeViewItems.indexOf(e.element.anchorText); 214 | this.expandedWorkspaceTreeViewItems.splice(idx, 1); 215 | } 216 | }); 217 | 218 | // Create the workspace anchor view 219 | this.epicTreeView = window.createTreeView("epicAnchors", { 220 | treeDataProvider: new EpicAnchorProvider(this), 221 | showCollapseAll: true, 222 | }); 223 | 224 | // Setup the link provider 225 | const provider = new LinkProvider(this); 226 | 227 | this.linkDisposable = languages.registerDocumentLinkProvider({ language: "*" }, provider); 228 | this.linkProvider = provider; 229 | 230 | // Build required anchor resources 231 | this.buildResources(); 232 | } 233 | 234 | public registerProviders(): void { 235 | const config = this._config!; 236 | 237 | // Provide auto completion 238 | if (config.tags.provideAutoCompletion) { 239 | this._subscriptions.push(setupCompletionProvider(this)); 240 | } 241 | 242 | // Provide epic auto complete 243 | if (config.epic.provideAutoCompletion) { 244 | this._subscriptions.push( 245 | languages.registerCompletionItemProvider({ language: "*" }, new EpicAnchorIntelliSenseProvider(this), "[") 246 | ); 247 | } 248 | } 249 | 250 | public buildResources(): void { 251 | try { 252 | this.anchorsScanned = false; 253 | 254 | const config = (this._config = workspace.getConfiguration("commentAnchors")); 255 | 256 | // Construct the debounce 257 | this._idleRefresh = debounce(() => { 258 | if (this._editor) 259 | this.parse(this._editor!.document.uri).then(() => { 260 | this.refresh(); 261 | }); 262 | }, config.parseDelay); 263 | 264 | // Disable previous build resources 265 | for (const s of this._subscriptions) s.dispose(); 266 | this._subscriptions = []; 267 | 268 | // Store the sorting method 269 | if (config.tags.sortMethod && (config.tags.sortMethod == "line" || config.tags.sortMethod == "type")) { 270 | EntryAnchor.SortMethod = config.tags.sortMethod; 271 | } 272 | 273 | // Store the scroll position 274 | if (config.scrollPosition) { 275 | EntryAnchor.ScrollPosition = config.scrollPosition; 276 | } 277 | 278 | // Prepare icon cache 279 | const storage = this.context.globalStoragePath; 280 | const iconCache = path.join(storage, "icons"); 281 | const baseAnchorSrc = path.join(__dirname, "../res/anchor.svg"); 282 | const baseAnchorEndSrc = path.join(__dirname, "../res/anchor_end.svg"); 283 | const baseAnchor = fs.readFileSync(baseAnchorSrc, "utf8"); 284 | const baseAnchorEnd = fs.readFileSync(baseAnchorEndSrc, "utf8"); 285 | const iconColors: string[] = []; 286 | const regionColors: string[] = []; 287 | 288 | if (!fs.existsSync(storage)) fs.mkdirSync(storage); 289 | if (!fs.existsSync(iconCache)) fs.mkdirSync(iconCache); 290 | 291 | this.iconCache = iconCache; 292 | 293 | // Clear icon cache 294 | for (const file of fs.readdirSync(iconCache)) { 295 | fs.unlinkSync(path.join(iconCache, file)); 296 | } 297 | 298 | // Create a map holding the tags 299 | this.tags.clear(); 300 | 301 | for (const type of this.anchorDecorators.values()) { 302 | type.dispose(); 303 | } 304 | 305 | for (const type of this.anchorEndDecorators.values()) { 306 | type.dispose(); 307 | } 308 | 309 | this.anchorDecorators.clear(); 310 | this.anchorEndDecorators.clear(); 311 | 312 | // Register default tags 313 | registerDefaults(this.tags); 314 | 315 | // Add custom tags 316 | parseCustomAnchors(config, this.tags); 317 | 318 | // Detect the lane style 319 | let laneStyle: OverviewRulerLane; 320 | 321 | if (config.tags.rulerStyle == "left") { 322 | laneStyle = OverviewRulerLane.Left; 323 | } else if (config.tags.rulerStyle == "right") { 324 | laneStyle = OverviewRulerLane.Right; 325 | } else if (config.tags.rulerStyle == "center") { 326 | laneStyle = OverviewRulerLane.Center; 327 | } else { 328 | laneStyle = OverviewRulerLane.Full; 329 | } 330 | 331 | // Start the cursor tracker 332 | if (this.cursorTask) { 333 | clearInterval(this.cursorTask); 334 | } 335 | 336 | let prevLine = 0; 337 | 338 | if (config.showCursor) { 339 | this.cursorTask = setInterval(() => { 340 | const cursor = window.activeTextEditor?.selection?.active?.line; 341 | 342 | if (cursor !== undefined && prevLine != cursor) { 343 | AnchorEngine.output("Updating cursor position"); 344 | this.updateFileAnchors(); 345 | prevLine = cursor; 346 | } 347 | }, 100); 348 | } 349 | 350 | // Configure all tags 351 | for (const tag of this.tags.values()) { 352 | if (!tag.scope) { 353 | tag.scope = "workspace"; 354 | } 355 | 356 | if (config.tagHighlights.enabled) { 357 | 358 | // Create base configuration 359 | const highlight: DecorationRenderOptions = { 360 | fontWeight: tag.isBold || tag.isBold == undefined ? "bold" : "normal", 361 | fontStyle: tag.isItalic || tag.isItalic == undefined ? "italic" : "normal", 362 | color: tag.highlightColor, 363 | backgroundColor: tag.backgroundColor, 364 | border: tag.borderStyle, 365 | borderRadius: tag.borderRadius ? tag.borderRadius + "px" : undefined, 366 | textDecoration: tag.textDecorationStyle, 367 | }; 368 | 369 | // Optionally insert rulers 370 | if (config.tags.displayInRuler && tag.ruler != false) { 371 | highlight.overviewRulerColor = tag.highlightColor || '#828282'; 372 | highlight.overviewRulerLane = laneStyle; 373 | } 374 | 375 | // Save the icon color 376 | let iconColor = tag.iconColor || tag.highlightColor || 'default'; 377 | let skipColor = false; 378 | 379 | switch (iconColor) { 380 | case "blue": { 381 | iconColor = "#3ea8ff"; 382 | break; 383 | } 384 | case "blurple": { 385 | iconColor = "#7d5afc"; 386 | break; 387 | } 388 | case "red": { 389 | iconColor = "#f44336"; 390 | break; 391 | } 392 | case "purple": { 393 | iconColor = "#ba68c8"; 394 | break; 395 | } 396 | case "teal": { 397 | iconColor = "#00cec9"; 398 | break; 399 | } 400 | case "orange": { 401 | iconColor = "#ffa100"; 402 | break; 403 | } 404 | case "green": { 405 | iconColor = "#64dd17"; 406 | break; 407 | } 408 | case "pink": { 409 | iconColor = "#e84393"; 410 | break; 411 | } 412 | case "emerald": { 413 | iconColor = "#2ecc71"; 414 | break; 415 | } 416 | case "yellow": { 417 | iconColor = "#f4d13d"; 418 | break; 419 | } 420 | case "default": 421 | case "auto": { 422 | skipColor = true; 423 | break; 424 | } 425 | default: { 426 | if (!HEX_COLOR_REGEX.test(iconColor)) { 427 | skipColor = true; 428 | window.showErrorMessage("Invalid color: " + iconColor); 429 | } 430 | } 431 | } 432 | 433 | if (skipColor) { 434 | tag.iconColor = "auto"; 435 | } else { 436 | iconColor = iconColor.slice(1); 437 | 438 | if (!iconColors.includes(iconColor)) { 439 | iconColors.push(iconColor); 440 | } 441 | 442 | if (tag.behavior == "region" && !regionColors.includes(iconColor)) { 443 | regionColors.push(iconColor); 444 | } 445 | 446 | tag.iconColor = iconColor.toLowerCase(); 447 | } 448 | 449 | // Optional gutter icons 450 | if (config.tags.displayInGutter) { 451 | if (tag.iconColor == "auto") { 452 | highlight.dark = { 453 | gutterIconPath: path.join(__dirname, "..", "res", "anchor_white.svg"), 454 | }; 455 | 456 | highlight.light = { 457 | gutterIconPath: path.join(__dirname, "..", "res", "anchor_black.svg"), 458 | }; 459 | } else { 460 | highlight.gutterIconPath = path.join(iconCache, "anchor_" + tag.iconColor + ".svg"); 461 | } 462 | } 463 | 464 | // Create the decoration type 465 | this.anchorDecorators.set(tag.tag, window.createTextEditorDecorationType(highlight)); 466 | 467 | if (tag.behavior == "region") { 468 | const endHighlight = { ...highlight }; 469 | 470 | // Optional gutter icons 471 | if (config.tags.displayInGutter) { 472 | if (tag.iconColor == "auto") { 473 | endHighlight.dark = { 474 | gutterIconPath: path.join(__dirname, "..", "res", "anchor_end_white.svg"), 475 | }; 476 | 477 | endHighlight.light = { 478 | gutterIconPath: path.join(__dirname, "..", "res", "anchor_end_black.svg"), 479 | }; 480 | } else { 481 | endHighlight.gutterIconPath = path.join(iconCache, "anchor_end_" + tag.iconColor + ".svg"); 482 | } 483 | } 484 | 485 | // Create the ending decoration type 486 | this.anchorEndDecorators.set(tag.tag, window.createTextEditorDecorationType(endHighlight)); 487 | } 488 | } 489 | } 490 | 491 | // Fetch an array of tags 492 | const tagList = [...this.tags.keys()]; 493 | 494 | // Generate region end tags 495 | const endTag = this._config.tags.endTag; 496 | 497 | for (const [tag, entry] of this.tags.entries()) { 498 | if (entry.behavior == "region") { 499 | tagList.push(endTag + tag); 500 | } 501 | } 502 | 503 | // Create a selection of tags 504 | const tags = tagList 505 | .map((tag) => escapeStringRegexp(tag)) 506 | .sort((left, right) => right.length - left.length) 507 | .join("|"); 508 | 509 | if (tags.length === 0) { 510 | window.showErrorMessage("At least one tag must be defined"); 511 | return; 512 | } 513 | 514 | // Create a selection of separators 515 | const separators = (config.tags.separators as string[]) 516 | .map((seperator) => escapeStringRegexp(seperator).replaceAll(' ', " +")) 517 | .sort((left, right) => right.length - left.length) 518 | .join("|"); 519 | 520 | if (separators.length === 0) { 521 | window.showErrorMessage("At least one separator must be defined"); 522 | return; 523 | } 524 | 525 | // Create a selection of prefixes 526 | const prefixes = (config.tags.matchPrefix as string[]) 527 | .map((match) => escapeStringRegexp(match).replaceAll(' ', " +")) 528 | .sort((left, right) => right.length - left.length) 529 | .join("|"); 530 | 531 | if (prefixes.length === 0) { 532 | window.showErrorMessage("At least one match prefix must be defined"); 533 | return; 534 | } 535 | 536 | // ANCHOR: Regex for matching tags 537 | // group 1 - Anchor tag 538 | // group 2 - Attributes 539 | // group 3 - Text 540 | 541 | const regex = `(?:${prefixes})(?:\\x20{0,4}|\\t{0,1})(${tags})(\\[.*\\])?(?:(?:${separators})(.*))?$`; 542 | const flags = config.tags.matchCase ? "gm" : "img"; 543 | 544 | this.matcher = new RegExp(regex, flags); 545 | 546 | AnchorEngine.output("Using matcher " + this.matcher); 547 | 548 | // Write anchor icons 549 | for (const color of iconColors) { 550 | const filename = "anchor_" + color.toLowerCase() + ".svg"; 551 | const anchorSvg = baseAnchor.replaceAll(COLOR_PLACEHOLDER_REGEX, "#" + color); 552 | 553 | fs.writeFileSync(path.join(iconCache, filename), anchorSvg); 554 | 555 | if (regionColors.includes(color)) { 556 | const filenameEnd = "anchor_end_" + color.toLowerCase() + ".svg"; 557 | const anchorEndSvg = baseAnchorEnd.replaceAll(COLOR_PLACEHOLDER_REGEX, "#" + color); 558 | 559 | fs.writeFileSync(path.join(iconCache, filenameEnd), anchorEndSvg); 560 | } 561 | } 562 | 563 | AnchorEngine.output("Generated icon cache at " + iconCache); 564 | 565 | // Scan in all workspace files 566 | if (config.workspace.enabled && !config.workspace.lazyLoad) { 567 | setTimeout(() => { 568 | this.initiateWorkspaceScan(); 569 | }, 500); 570 | } else { 571 | this.anchorsLoaded = true; 572 | 573 | if (this._editor) { 574 | this.addMap(this._editor!.document.uri); 575 | } 576 | 577 | this.refresh(); 578 | } 579 | 580 | // Dispose the existing file watcher 581 | if (this._watcher) { 582 | this._watcher.dispose(); 583 | } 584 | 585 | // Create a new file watcher 586 | if (config.workspace.enabled) { 587 | this._watcher = workspace.createFileSystemWatcher(config.workspace.matchFiles, true, true, false); 588 | 589 | this._watcher.onDidDelete((file: Uri) => { 590 | for (const [uri, _] of this.anchorMaps.entries()) { 591 | if (uri.toString() == file.toString()) { 592 | this.removeMap(uri); 593 | false; continue; 594 | } 595 | } 596 | }); 597 | } 598 | 599 | // Register editor providers 600 | this.registerProviders(); 601 | } catch (err: any) { 602 | AnchorEngine.output("Failed to build resources: " + err.message); 603 | AnchorEngine.output(err.stack); 604 | } 605 | } 606 | 607 | public initiateWorkspaceScan(): void { 608 | const config = this._config!; 609 | this.anchorsScanned = true; 610 | this.anchorsLoaded = false; 611 | 612 | // Find all files located in this workspace 613 | workspace.findFiles(config.workspace.matchFiles, config.workspace.excludeFiles).then((uris) => { 614 | // Clear all existing mappings 615 | this.anchorMaps.clear(); 616 | 617 | // Resolve all matched URIs 618 | this.loadWorkspace(uris) 619 | .then(() => { 620 | if (this._editor) { 621 | this.addMap(this._editor!.document.uri); 622 | } 623 | 624 | this.anchorsLoaded = true; 625 | this.refresh(); 626 | }) 627 | .catch((err) => { 628 | window.showErrorMessage("Comment Anchors failed to load: " + err); 629 | AnchorEngine.output(err); 630 | }); 631 | }); 632 | 633 | // Update workspace tree 634 | this.updateFileAnchors(); 635 | } 636 | 637 | private async loadWorkspace(uris: Uri[]): Promise { 638 | const maxFiles = this._config!.workspace.maxFiles; 639 | const parseStatus = window.createStatusBarItem(StatusBarAlignment.Left, 0); 640 | let parseCount = 0; 641 | let parsePercentage = 0; 642 | 643 | parseStatus.tooltip = "Provided by the Comment Anchors extension"; 644 | parseStatus.text = `$(telescope) Initializing...`; 645 | parseStatus.show(); 646 | 647 | for (let i = 0; i < uris.length && parseCount < maxFiles; i++) { 648 | // Await a timeout for every 10 documents parsed. This allows 649 | // all files to be slowly parsed without completely blocking 650 | // the main thread for the entire process. 651 | if (i % 10 == 0) { 652 | await asyncDelay(5); 653 | } 654 | 655 | try { 656 | const found = await this.addMap(uris[i]); 657 | 658 | // Only update states when a file containing anchors 659 | // was found and parsed. 660 | if (found) { 661 | parseCount++; 662 | parsePercentage = (parseCount / uris.length) * 100; 663 | 664 | parseStatus.text = `$(telescope) Parsing Comment Anchors... (${parsePercentage.toFixed(1)}%)`; 665 | } 666 | } catch { 667 | // Ignore, already taken care of 668 | } 669 | } 670 | 671 | // Scanning has now completed 672 | parseStatus.text = `Comment Anchors loaded!`; 673 | 674 | setTimeout(() => { 675 | parseStatus.dispose(); 676 | }, 3000); 677 | } 678 | 679 | /** 680 | * Returns the anchors in the current document 681 | */ 682 | public get currentAnchors(): EntryAnchor[] { 683 | if (!this._editor) return []; 684 | 685 | const uri = this._editor.document.uri; 686 | 687 | if (this.anchorMaps.has(uri)) { 688 | return this.anchorMaps.get(uri)!.anchorTree; 689 | } else { 690 | return []; 691 | } 692 | } 693 | 694 | /** 695 | * Dispose anchor list resources 696 | */ 697 | public dispose(): void { 698 | for (const type of this.anchorDecorators.values()) { 699 | type.dispose(); 700 | } 701 | 702 | for (const type of this.anchorEndDecorators.values()) { 703 | type.dispose(); 704 | } 705 | 706 | for (const subscription of this._subscriptions) { 707 | subscription.dispose(); 708 | } 709 | 710 | this.linkDisposable.dispose(); 711 | 712 | if (this.cursorTask) { 713 | clearInterval(this.cursorTask); 714 | } 715 | } 716 | 717 | /** 718 | * Clean up external files 719 | */ 720 | public cleanUp(document: TextDocument): void { 721 | if (document.uri.scheme != "file") return; 722 | 723 | const ws = workspace.getWorkspaceFolder(document.uri); 724 | if (this._config!.workspace.enabled && ws && this.anchorsScanned) return; 725 | 726 | this.removeMap(document.uri); 727 | } 728 | 729 | /** 730 | * Travel to the specified anchor id 731 | * 732 | * @param The anchor id 733 | */ 734 | public travelToAnchor(id: string): void { 735 | if (!this._editor) return; 736 | 737 | const anchors = this.currentAnchors; 738 | const flattened = flattenAnchors(anchors); 739 | 740 | for (const anchor of flattened) { 741 | if (anchor.attributes.id == id) { 742 | const targetLine = anchor.lineNumber - 1; 743 | 744 | commands.executeCommand("revealLine", { 745 | lineNumber: targetLine, 746 | at: EntryAnchor.ScrollPosition, 747 | }); 748 | 749 | return; 750 | } 751 | } 752 | } 753 | 754 | /** 755 | * Parse the given raw attribute string into 756 | * individual attributes. 757 | * 758 | * @param raw The raw attribute string 759 | * @param defaultValue The default attributes 760 | */ 761 | public parseAttributes(raw: string, defaultValue: TagAttributes): TagAttributes { 762 | if (!raw) return defaultValue; 763 | 764 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 765 | const result: TagAttributes = { ...defaultValue }; 766 | const mapping = new Map(); 767 | 768 | // parse all 'key1=value1,key2=value2' 769 | for (const pair of raw.split(",")) { 770 | const [key, value] = pair.trim().split("="); 771 | AnchorEngine.output(`Trying to set key=${key},value=${value}`); 772 | mapping.set(key, value); 773 | } 774 | 775 | // Parse the epic value 776 | if (mapping.has("epic")) { 777 | result.epic = mapping.get("epic")!; 778 | } 779 | 780 | // Parse the sequence value 781 | if (mapping.has("seq")) { 782 | result.seq = Number.parseInt(mapping.get("seq")!, 10); 783 | } 784 | 785 | // Parse the id value 786 | if (mapping.has("id")) { 787 | result.id = mapping.get("id"); 788 | } 789 | 790 | return result; 791 | } 792 | 793 | /** 794 | * Parse the given or current document 795 | * 796 | * @returns true when anchors were found 797 | */ 798 | public async parse(document: Uri): Promise { 799 | let anchorsFound = false; 800 | 801 | try { 802 | const config = this._config!; 803 | const endTag = config.tags.endTag; 804 | 805 | const { 806 | displayTagName, 807 | displayInSidebar, 808 | displayLineNumber, 809 | matchSuffix, 810 | } = config.tags; 811 | 812 | let text = null; 813 | 814 | // Match the document against the configured glob 815 | const relativePath = workspace.asRelativePath(document); 816 | 817 | if (!minimatch(relativePath, config.workspace.matchFiles, MATCH_OPTIONS)) { 818 | return false; 819 | } 820 | 821 | // Read text from open documents 822 | for (const td of workspace.textDocuments) { 823 | if (td.uri == document) { 824 | text = td.getText(); 825 | break; 826 | } 827 | } 828 | 829 | // Read the text from the file system 830 | if (text == null) { 831 | text = await this.readDocument(document); 832 | } 833 | 834 | const currRegions: EntryAnchorRegion[] = []; 835 | const anchors: EntryAnchor[] = []; 836 | const folds: FoldingRange[] = []; 837 | 838 | let match; 839 | 840 | // Find all anchor occurences 841 | while ((match = this.matcher!.exec(text))) { 842 | const tagMatch = match[MATCHER_TAG_INDEX]; 843 | let tagName; 844 | let isRegionEnd; 845 | 846 | if (this.tags.has(tagMatch)) { 847 | tagName = tagMatch; 848 | isRegionEnd = false; 849 | } else { 850 | if (!tagMatch.startsWith(endTag)) throw new TypeError("matched non-existent tag"); 851 | tagName = tagMatch.slice(endTag.length); 852 | isRegionEnd = true; 853 | } 854 | 855 | const tagEntry: TagEntry = this.tags.get(tagName)!; 856 | const isRegionStart = tagEntry.behavior == "region"; 857 | const currRegion: EntryAnchorRegion | null = (currRegions.length > 0 && currRegions.at(-1)) || null; 858 | const style = tagEntry.styleMode; 859 | 860 | // Compute positions and lengths 861 | const offsetPos = match[0].indexOf(tagMatch); 862 | const startPos = match.index + (style == 'full' ? 0 : offsetPos); 863 | const lineNumber = text.slice(0, Math.max(0, startPos)).split(/\r\n|\r|\n/g).length; 864 | const rangeLength = style == 'full' 865 | ? match[0].length 866 | : (style == 'comment' 867 | ? match[0].length - offsetPos 868 | : tagMatch.length); 869 | 870 | // We have found at least one anchor 871 | anchorsFound = true; 872 | 873 | let endPos = startPos + rangeLength; 874 | let comment = (match[MATCHER_COMMENT_INDEX] || "").trim(); 875 | let display = ""; 876 | 877 | const rawAttributeStr = match[MATCHER_ATTR_INDEX] || "[]"; 878 | const attributes = this.parseAttributes(rawAttributeStr.slice(1, 1 + rawAttributeStr.length - 2), { 879 | seq: lineNumber, 880 | }); 881 | 882 | // Clean up the comment and adjust the endPos 883 | for (const endMatch of matchSuffix) { 884 | if (comment.endsWith(endMatch)) { 885 | comment = comment.slice(0, Math.max(0, comment.length - endMatch.length)); 886 | 887 | if (style == 'comment') { 888 | endPos -= endMatch.length; 889 | } 890 | 891 | break; 892 | } 893 | } 894 | 895 | // Handle the closing of a region 896 | if (isRegionEnd) { 897 | if (!currRegion || currRegion.anchorTag != tagEntry.tag) continue; 898 | 899 | currRegion.setEndTag({ 900 | startIndex: startPos, 901 | endIndex: endPos, 902 | lineNumber: lineNumber, 903 | }); 904 | 905 | currRegions.pop(); 906 | folds.push(new FoldingRange(currRegion.lineNumber - 1, lineNumber - 1, FoldingRangeKind.Comment)); 907 | continue; 908 | } 909 | 910 | // Construct the resulting string to display 911 | if (comment.length === 0) { 912 | display = tagEntry.tag; 913 | } else if (displayInSidebar && tagEntry.behavior != "link") { 914 | display = displayTagName ? (tagEntry.tag + ": " + comment) : comment; 915 | } else { 916 | display = comment; 917 | } 918 | 919 | // Remove epics when tag is not workspace visible 920 | if (tagEntry.scope != "workspace") { 921 | attributes.epic = undefined; 922 | } 923 | 924 | let anchor: EntryAnchor; 925 | 926 | // Create a regular or region anchor 927 | if (isRegionStart) { 928 | anchor = new EntryAnchorRegion( 929 | this, 930 | tagEntry.tag, 931 | display, 932 | startPos, 933 | endPos, 934 | match[0].length - 1, 935 | lineNumber, 936 | tagEntry.iconColor!, 937 | tagEntry.scope!, 938 | displayLineNumber, 939 | document, 940 | attributes 941 | ); 942 | } else { 943 | anchor = new EntryAnchor( 944 | this, 945 | tagEntry.tag, 946 | display, 947 | startPos, 948 | endPos, 949 | match[0].length - 1, 950 | lineNumber, 951 | tagEntry.iconColor!, 952 | tagEntry.scope!, 953 | displayLineNumber, 954 | document, 955 | attributes 956 | ); 957 | } 958 | 959 | // Push this region onto the stack 960 | if (isRegionStart) { 961 | currRegions.push(anchor as EntryAnchorRegion); 962 | } 963 | 964 | // Place this anchor on root or child level 965 | if (currRegion) { 966 | currRegion.addChild(anchor); 967 | } else { 968 | anchors.push(anchor); 969 | } 970 | } 971 | 972 | this.matcher!.lastIndex = 0; 973 | this.anchorMaps.set(document, new AnchorIndex(anchors)); 974 | } catch (err: any) { 975 | AnchorEngine.output("Error: " + err.message); 976 | AnchorEngine.output(err.stack); 977 | } 978 | 979 | return anchorsFound; 980 | } 981 | 982 | /** 983 | * Returns the list of anchors parsed from the given 984 | * file. 985 | * 986 | * @param file The file URI 987 | * @returns The anchor array 988 | */ 989 | public async getAnchors(file: Uri): Promise { 990 | const cached = this.anchorMaps.get(file)?.anchorTree; 991 | 992 | if (cached) { 993 | return cached; 994 | } else { 995 | await this.parse(file); 996 | return await this.getAnchors(file); 997 | } 998 | } 999 | 1000 | /** 1001 | * Refresh the visual representation of the anchors 1002 | */ 1003 | public refresh(): void { 1004 | try { 1005 | if (this._editor && this._config!.tagHighlights.enabled) { 1006 | const document = this._editor!.document; 1007 | const doc = document.uri; 1008 | const index = this.anchorMaps.get(doc); 1009 | const tags = new Map(); 1010 | const tagsEnd = new Map(); 1011 | 1012 | // Create a mapping between tags and decorators 1013 | for (const [tag, decorator] of this.anchorDecorators.entries()) { 1014 | tags.set(tag, [decorator, []]); 1015 | } 1016 | 1017 | for (const [tag, decorator] of this.anchorEndDecorators.entries()) { 1018 | tagsEnd.set(tag, [decorator, []]); 1019 | } 1020 | 1021 | // Create a function to handle decorating 1022 | const applyDecorators = (anchors: EntryAnchor[]) => { 1023 | for (const anchor of anchors) { 1024 | const deco = tags.get(anchor.anchorTag)![1]; 1025 | 1026 | anchor.decorateDocument(document, deco); 1027 | 1028 | if (anchor instanceof EntryAnchorRegion) { 1029 | anchor.decorateDocumentEnd(document, tagsEnd.get(anchor.anchorTag)![1]); 1030 | } 1031 | 1032 | if (anchor.children) { 1033 | applyDecorators(anchor.children); 1034 | } 1035 | } 1036 | }; 1037 | 1038 | // Start by decorating the root list 1039 | if (index) { 1040 | applyDecorators(index.anchorTree); 1041 | } 1042 | 1043 | // Apply all decorators to the document 1044 | for (const decorator of tags.values()) { 1045 | this._editor!.setDecorations(decorator[0], decorator[1]); 1046 | } 1047 | 1048 | for (const decorator of tagsEnd.values()) { 1049 | this._editor!.setDecorations(decorator[0], decorator[1]); 1050 | } 1051 | } 1052 | 1053 | // Reset the expansion arrays 1054 | this.expandedFileTreeViewItems = []; 1055 | this.expandedWorkspaceTreeViewItems = []; 1056 | 1057 | // Update the file trees 1058 | this._onDidChangeLinkData.fire(undefined); 1059 | this.updateFileAnchors(); 1060 | this.anchorsDirty = false; 1061 | } catch(err: any) { 1062 | AnchorEngine.output("Failed to refresh: " + err.message); 1063 | AnchorEngine.output(err.stack); 1064 | } 1065 | } 1066 | 1067 | /** 1068 | * Add a TextDocument mapping to the engine 1069 | * 1070 | * @param document TextDocument 1071 | */ 1072 | public addMap(document: Uri): Thenable { 1073 | if (document.scheme !== "file") { 1074 | return Promise.resolve(false); 1075 | } 1076 | 1077 | // Make sure we have no duplicates 1078 | for (const [doc, _] of this.anchorMaps.entries()) { 1079 | if (doc.path == document.path) { 1080 | this.anchorMaps.delete(doc); 1081 | } 1082 | } 1083 | 1084 | this.anchorMaps.set(document, AnchorIndex.EMPTY); 1085 | 1086 | return this.parse(document); 1087 | } 1088 | 1089 | /** 1090 | * Remove a TextDocument mapping from the engine 1091 | * 1092 | * @param editor textDocument 1093 | */ 1094 | public removeMap(document: Uri): void { 1095 | if (document.scheme !== "file") return; 1096 | 1097 | this.anchorMaps.delete(document); 1098 | } 1099 | 1100 | /** 1101 | * Open a new webview panel listing out all configured 1102 | * tags including their applied styles. 1103 | */ 1104 | public openTagListPanel(): void { 1105 | const panel = window.createWebviewPanel("anchorList", "Comment Anchors Tags", { 1106 | viewColumn: ViewColumn.One, 1107 | }); 1108 | 1109 | panel.webview.html = createViewContent(this, panel.webview); 1110 | } 1111 | 1112 | /** 1113 | * Jump to an anchor in the current document 1114 | * 1115 | * @param anchor The anchor to jump to 1116 | */ 1117 | public jumpToAnchor(anchor: EntryAnchor) { 1118 | const selection = new Selection(anchor.lineNumber - 1, 999, anchor.lineNumber - 1, 999); 1119 | 1120 | this._editor!.selection = selection; 1121 | this._editor!.revealRange(selection); 1122 | } 1123 | 1124 | /** 1125 | * Move the cursor to the anchor relative to the current position 1126 | * 1127 | * @param direction The direction 1128 | */ 1129 | public jumpToRelativeAnchor(direction: 'up'|'down') { 1130 | const current = this._editor!.selection.active.line + 1; 1131 | const anchors = [...this.currentAnchors].sort((a, b) => a.lineNumber - b.lineNumber); 1132 | 1133 | if (direction == 'up') { 1134 | for (let i = anchors.length - 1; i >= 0; i--) { 1135 | const anchor = anchors[i]; 1136 | 1137 | if (anchor.lineNumber < current) { 1138 | this.jumpToAnchor(anchor); 1139 | break; 1140 | } 1141 | } 1142 | } else { 1143 | for (const anchor of anchors) { 1144 | if (anchor.lineNumber > current) { 1145 | this.jumpToAnchor(anchor); 1146 | break; 1147 | } 1148 | } 1149 | } 1150 | } 1151 | 1152 | private onActiveEditorChanged(editor: TextEditor | undefined): void { 1153 | if (editor && editor!.document.uri.scheme == "output") return; 1154 | 1155 | this._editor = editor; 1156 | 1157 | if (!this.anchorsLoaded) return; 1158 | 1159 | if (editor && !this.anchorMaps.has(editor.document.uri)) { 1160 | // Bugfix - Replace duplicates 1161 | for (const [document, _] of new Map(this.anchorMaps).entries()) { 1162 | if (document.path.toString() == editor.document.uri.path.toString()) { 1163 | this.anchorMaps.delete(document); 1164 | false; continue; 1165 | } 1166 | } 1167 | 1168 | this.anchorMaps.set(editor.document.uri, AnchorIndex.EMPTY); 1169 | 1170 | this.parse(editor.document.uri).then(() => { 1171 | this.refresh(); 1172 | }); 1173 | } else { 1174 | this.refresh(); 1175 | } 1176 | } 1177 | 1178 | private onDocumentChanged(e: TextDocumentChangeEvent): void { 1179 | if (!e.contentChanges || e.document.uri.scheme == "output") return; 1180 | 1181 | this.anchorsDirty = true; 1182 | this._idleRefresh!(); 1183 | } 1184 | 1185 | /** 1186 | * Reads the document at the given Uri async 1187 | * 1188 | * @param path Document uri 1189 | */ 1190 | private readDocument(path: Uri): Thenable { 1191 | return new Promise((success, reject) => { 1192 | fs.readFile(path.fsPath, "utf8", (err, data) => { 1193 | if (err) { 1194 | reject(err); 1195 | } else { 1196 | success(data); 1197 | } 1198 | }); 1199 | }); 1200 | } 1201 | 1202 | /** 1203 | * Alert subscribed listeners of a change in the file anchors tree 1204 | */ 1205 | private updateFileAnchors() { 1206 | this._onDidChangeTreeData.fire(undefined); 1207 | 1208 | const anchors = this.currentAnchors.length; 1209 | 1210 | this.fileTreeView.badge = anchors > 0 ? { 1211 | tooltip: 'File anchors', 1212 | value: anchors 1213 | } : undefined; 1214 | } 1215 | } 1216 | 1217 | /** 1218 | * A tag entry in the settings 1219 | */ 1220 | export interface TagEntry { 1221 | tag: string; 1222 | enabled?: boolean; 1223 | iconColor?: string; 1224 | highlightColor?: string; 1225 | backgroundColor?: string; 1226 | styleMode?: 'tag' | 'comment' | 'full'; 1227 | borderStyle?: string; 1228 | borderRadius?: number; 1229 | ruler?: boolean; 1230 | textDecorationStyle?: string; 1231 | isBold?: boolean; 1232 | isItalic?: boolean; 1233 | scope?: string; 1234 | isSequential?: boolean; 1235 | isEpic?: boolean; 1236 | behavior: "anchor" | "region" | "link"; 1237 | } 1238 | 1239 | /** 1240 | * Defined for tag attribute 1241 | * Currenly only "seq" and "epic" are used 1242 | */ 1243 | export interface TagAttributes { 1244 | seq: number; 1245 | epic?: string; 1246 | id?: string; 1247 | } 1248 | -------------------------------------------------------------------------------- /src/anchorIndex.ts: -------------------------------------------------------------------------------- 1 | import EntryAnchor from "./anchor/entryAnchor"; 2 | 3 | /** 4 | * An index of all anchors found within a file 5 | */ 6 | export class AnchorIndex { 7 | 8 | /** Constant empty index */ 9 | public static EMPTY = new AnchorIndex([]); 10 | 11 | /** A tree structure of entry anchors */ 12 | public anchorTree: EntryAnchor[]; 13 | 14 | /** Collection of anchors indexed by their content text*/ 15 | public textIndex: Map = new Map(); 16 | 17 | public constructor(anchorTree: EntryAnchor[]) { 18 | this.anchorTree = anchorTree; 19 | this.indexAnchors(anchorTree); 20 | } 21 | 22 | /** 23 | * Index the given anchor array 24 | * 25 | * @param list The anchor list 26 | */ 27 | private indexAnchors(list: EntryAnchor[]) { 28 | for (const anchor of list) { 29 | this.textIndex.set(anchor.anchorText, anchor); 30 | 31 | if (anchor.children.length > 0) { 32 | this.indexAnchors(anchor.children); 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/anchorListView.ts: -------------------------------------------------------------------------------- 1 | import { AnchorEngine } from "./anchorEngine"; 2 | import { Webview } from "vscode"; 3 | 4 | /** 5 | * Generate the contents of the anchor list view 6 | * 7 | * @param engine Engine reference 8 | * @param webview The website used to display 9 | */ 10 | export function createViewContent(engine: AnchorEngine, webview: Webview): string { 11 | let tagList = ""; 12 | 13 | for (const tag of engine.tags.values()) { 14 | const tagFlags = []; 15 | 16 | let tagStyle = ""; 17 | 18 | if (tag.backgroundColor) { 19 | tagStyle += `background-color: ${tag.backgroundColor};`; 20 | } 21 | 22 | if (tag.borderRadius) { 23 | tagStyle += `border-radius: ${tag.borderRadius}px;`; 24 | } 25 | 26 | if (tag.borderStyle) { 27 | tagStyle += `border: ${tag.borderStyle};`; 28 | } 29 | 30 | if (tag.highlightColor) { 31 | tagStyle += `color: ${tag.highlightColor};`; 32 | } 33 | 34 | if (tag.isBold || tag.isBold == undefined) { 35 | tagStyle += "font-weight: bold;"; 36 | } 37 | 38 | if (tag.isItalic) { 39 | tagStyle += "font-style: italic;"; 40 | } 41 | 42 | if (tag.textDecorationStyle) { 43 | tagStyle += `text-decoration: ${tag.textDecorationStyle};`; 44 | } 45 | 46 | if (tag.scope == "workspace") { 47 | tagFlags.push("Workspace Scope"); 48 | } else { 49 | tagFlags.push("File Scope"); 50 | } 51 | 52 | if (tag.styleMode) { 53 | tagFlags.push(`Style mode: ${tag.styleMode}`); 54 | } 55 | 56 | if (tag.behavior == "region") { 57 | tagFlags.push("Region Tag"); 58 | } else if (tag.behavior == "link") { 59 | tagFlags.push("Link Tag"); 60 | } 61 | 62 | const flags = tagFlags.join(", "); 63 | 64 | tagList += ` 65 |
66 |
67 | // 68 | ${tag.tag} 69 | (${flags}) 70 |
71 |
72 | `; 73 | } 74 | 75 | return ` 76 | 77 | 78 | 79 | 80 | 117 | 118 | 119 |

Configured Anchor Tags (${engine.tags.size})

120 |
${tagList}
121 | 122 | 123 | `; 124 | } 125 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "node:fs"; 2 | import { anchorEngine } from "./extension"; 3 | import { window, workspace, Uri, commands, Position, Selection } from "vscode"; 4 | import { createJSONExport, createTableExport } from "./util/exporting"; 5 | 6 | /** 7 | * Reparse anchors in the current file 8 | */ 9 | export function parseCurrentAnchors() { 10 | if (!window.activeTextEditor) return; 11 | 12 | anchorEngine.parse(window.activeTextEditor.document.uri); 13 | } 14 | 15 | /** 16 | * Luanch the workspace scan 17 | */ 18 | export function launchWorkspaceScan() { 19 | anchorEngine.initiateWorkspaceScan(); 20 | } 21 | 22 | /** 23 | * Toggles the visibility of comment anchors 24 | */ 25 | export function toggleVisibilitySetting() { 26 | const config = workspace.getConfiguration("commentAnchors"); 27 | 28 | config.update("tagHighlights.enabled", !config.tagHighlights.enabled); 29 | } 30 | 31 | /** 32 | * Display a full list of registered anchors 33 | */ 34 | export function openTagListPanel() { 35 | anchorEngine.openTagListPanel(); 36 | } 37 | 38 | /** 39 | * Export anchors to a file 40 | */ 41 | export async function exportAnchors() { 42 | const uri = await window.showSaveDialog({ 43 | title: 'Comment Anchors export', 44 | saveLabel: 'Export', 45 | filters: { 46 | 'Table format': ['csv'], 47 | 'JSON format': ['json'] 48 | } 49 | }); 50 | 51 | if (!uri) return; 52 | 53 | const extIndex = uri.path.lastIndexOf('.'); 54 | const extension = uri.path.slice(Math.max(0, extIndex + 1)); 55 | 56 | let exportText = ''; 57 | 58 | if (extension == 'csv') { 59 | exportText = createTableExport(); 60 | } else { 61 | exportText = createJSONExport(); 62 | } 63 | 64 | writeFileSync(uri.fsPath, exportText); 65 | } 66 | 67 | /** 68 | * Go to the previous anchor relative to the cursor 69 | */ 70 | export function goToPreviousAnchor() { 71 | anchorEngine.jumpToRelativeAnchor('up'); 72 | } 73 | 74 | /** 75 | * Go to the next anchor relative to the cursor 76 | */ 77 | export function goToNextAnchor() { 78 | anchorEngine.jumpToRelativeAnchor('down'); 79 | } 80 | 81 | /** 82 | * Open a list of anchors to jump to 83 | */ 84 | export function openAnchorList() { 85 | const anchors = anchorEngine.currentAnchors.map(anchor => ({ 86 | label: anchor.anchorText, 87 | detail: 'Line ' + anchor.lineNumber, 88 | anchor: anchor 89 | })); 90 | 91 | if (anchors.length === 0) { 92 | window.showInformationMessage('No anchors found in this file'); 93 | return; 94 | } 95 | 96 | window.showQuickPick(anchors, { 97 | title: 'Navigate to anchor' 98 | }).then(result => { 99 | if (result) { 100 | anchorEngine.jumpToAnchor(result.anchor); 101 | } 102 | }); 103 | } 104 | 105 | /** 106 | * Opens a file and reveales the given line number 107 | */ 108 | export function openFileAndRevealLine(options: OpenFileAndRevealLineOptions) { 109 | if (!options) return; 110 | 111 | function scrollAndMove() { 112 | commands.executeCommand("revealLine", { 113 | lineNumber: options.lineNumber, 114 | at: options.at, 115 | }); 116 | 117 | // Move cursor to anchor position 118 | if (window.activeTextEditor != undefined) { 119 | const pos = new Position(options.lineNumber, 0); 120 | window.activeTextEditor.selection = new Selection(pos, pos); 121 | } 122 | } 123 | 124 | // Either open right away or wait for the document to open 125 | if (window.activeTextEditor && window.activeTextEditor.document.uri == options.uri) { 126 | scrollAndMove(); 127 | } else { 128 | workspace.openTextDocument(options.uri).then((doc) => { 129 | window.showTextDocument(doc).then(() => { 130 | scrollAndMove(); 131 | }); 132 | }); 133 | } 134 | } 135 | 136 | export type OpenFileAndRevealLineOptions = { 137 | uri: Uri; 138 | lineNumber: number; 139 | at: string; 140 | }; -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { commands, ExtensionContext } from "vscode"; 2 | import { AnchorEngine } from "./anchorEngine"; 3 | 4 | import { 5 | parseCurrentAnchors, 6 | toggleVisibilitySetting, 7 | launchWorkspaceScan, 8 | openFileAndRevealLine, 9 | openTagListPanel, 10 | exportAnchors, 11 | goToNextAnchor, 12 | goToPreviousAnchor, 13 | openAnchorList 14 | } from "./commands"; 15 | 16 | export let anchorEngine: AnchorEngine; 17 | 18 | export function activate(context: ExtensionContext): void { 19 | anchorEngine = new AnchorEngine(context); 20 | 21 | // Register extension commands 22 | commands.registerCommand("commentAnchors.parse", parseCurrentAnchors); 23 | commands.registerCommand("commentAnchors.toggle", toggleVisibilitySetting); 24 | commands.registerCommand("commentAnchors.openFileAndRevealLine", openFileAndRevealLine); 25 | commands.registerCommand("commentAnchors.launchWorkspaceScan", launchWorkspaceScan); 26 | commands.registerCommand("commentAnchors.exportAnchors", exportAnchors); 27 | commands.registerCommand("commentAnchors.listTags", openTagListPanel); 28 | commands.registerCommand("commentAnchors.previousAnchor", goToPreviousAnchor); 29 | commands.registerCommand("commentAnchors.nextAnchor", goToNextAnchor); 30 | commands.registerCommand("commentAnchors.navigateToAnchor", openAnchorList); 31 | } 32 | 33 | export function deactivate(): void { 34 | anchorEngine.dispose(); 35 | } -------------------------------------------------------------------------------- /src/provider/epicAnchorProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TreeDataProvider, 3 | Event, 4 | TreeItem, 5 | workspace, 6 | CompletionItemProvider, 7 | TextDocument, 8 | Position, 9 | CancellationToken, 10 | CompletionContext, 11 | CompletionItem, 12 | CompletionList, 13 | CompletionItemKind, 14 | } from "vscode"; 15 | 16 | import EntryAnchor from "../anchor/entryAnchor"; 17 | import { AnchorEngine, AnyEntry, AnyEntryArray } from "../anchorEngine"; 18 | import EntryEpic from "../anchor/entryEpic"; 19 | import { flattenAnchors } from "../util/flattener"; 20 | 21 | /** 22 | * AnchorProvider implementation in charge of returning the anchors in the current workspace 23 | */ 24 | export class EpicAnchorProvider implements TreeDataProvider { 25 | 26 | public readonly provider: AnchorEngine; 27 | public readonly onDidChangeTreeData: Event; 28 | 29 | public constructor(provider: AnchorEngine) { 30 | this.onDidChangeTreeData = provider._onDidChangeTreeData.event; 31 | this.provider = provider; 32 | } 33 | 34 | public getTreeItem(element: AnyEntry): TreeItem { 35 | return element; 36 | } 37 | 38 | public getChildren(element?: AnyEntry): Thenable { 39 | return new Promise((success) => { 40 | // The default is empty, so you have to build a tree 41 | if (element) { 42 | if (element instanceof EntryAnchor && element.children) { 43 | success(element.children); 44 | return; 45 | } else if (element instanceof EntryEpic) { 46 | const res: EntryAnchor[] = []; 47 | 48 | const epic = element as EntryEpic; 49 | AnchorEngine.output( 50 | `this.provider._config!.tags.displayHierarchyInWorkspace: ${ 51 | this.provider._config!.tags.displayHierarchyInWorkspace 52 | }` 53 | ); 54 | 55 | if (this.provider._config!.tags.displayHierarchyInWorkspace) { 56 | for (const anchor of epic.anchors) { 57 | if (anchor.isVisibleInWorkspace) { 58 | res.push(anchor.copy(true, false)); 59 | } 60 | } 61 | } else { 62 | const flattened = flattenAnchors(epic.anchors); 63 | 64 | for (const anchor of flattened) { 65 | if (anchor.isVisibleInWorkspace) { 66 | res.push(anchor.copy(false, false)); 67 | } 68 | } 69 | } 70 | 71 | const anchors = res.sort((left, right) => { 72 | return left.attributes.seq - right.attributes.seq; 73 | }); 74 | 75 | success(anchors); 76 | } else { 77 | AnchorEngine.output("return empty array"); 78 | success([]); 79 | } 80 | 81 | return; 82 | } 83 | 84 | if (!this.provider._config!.workspace.enabled) { 85 | success([this.provider.errorWorkspaceDisabled]); 86 | return; 87 | } else if (!workspace.workspaceFolders) { 88 | success([this.provider.errorFileOnly]); 89 | return; 90 | } else if (this.provider._config!.workspace.lazyLoad && !this.provider.anchorsScanned) { 91 | success([this.provider.statusScan]); 92 | } else if (!this.provider.anchorsLoaded) { 93 | success([this.provider.statusLoading]); 94 | return; 95 | } 96 | 97 | const res: EntryEpic[] = []; 98 | const epicMaps = new Map(); 99 | 100 | // Build the epic entries 101 | for (const [_, anchorIndex] of this.provider.anchorMaps.entries()) { 102 | const flattened = flattenAnchors(anchorIndex.anchorTree); 103 | 104 | for (const anchor of flattened) { 105 | const epic = anchor.attributes.epic; 106 | if (!epic) continue; 107 | 108 | const anchorEpic = epicMaps.get(epic); 109 | 110 | if (anchorEpic) { 111 | anchorEpic.push(anchor); 112 | } else { 113 | epicMaps.set(epic, [anchor]); 114 | } 115 | } 116 | } 117 | 118 | // Sort and build the entry list 119 | for (const [epic, anchorArr] of epicMaps.entries()) { 120 | anchorArr.sort((left, right) => { 121 | return left.attributes.seq - right.attributes.seq; 122 | }); 123 | 124 | res.push(new EntryEpic(epic, `${epic}`, anchorArr, this.provider)); 125 | } 126 | 127 | if (res.length === 0) { 128 | success([this.provider.errorEmptyEpics]); 129 | return; 130 | } 131 | 132 | success(res); 133 | }); 134 | } 135 | } 136 | 137 | export class EpicAnchorIntelliSenseProvider implements CompletionItemProvider { 138 | public readonly engine: AnchorEngine; 139 | 140 | public constructor(engine: AnchorEngine) { 141 | this.engine = engine; 142 | } 143 | 144 | public provideCompletionItems( 145 | _document: TextDocument, 146 | _position: Position, 147 | _token: CancellationToken, 148 | _context: CompletionContext 149 | ): CompletionItem[] | CompletionList { 150 | const config = this.engine._config!; 151 | 152 | AnchorEngine.output("provideCompletionItems"); 153 | 154 | const keyWord = _document.getText(_document.getWordRangeAtPosition(_position.translate(0, -1))); 155 | const hasKeyWord = [...this.engine.tags.keys()].find((v) => v === keyWord); 156 | 157 | if (hasKeyWord) { 158 | const epicCtr = new Map(); 159 | 160 | for (const [_, anchorIndex] of this.engine.anchorMaps.entries()) { 161 | for (const entryAnchor of anchorIndex.anchorTree) { 162 | const { seq, epic } = entryAnchor.attributes; 163 | 164 | if (epic) { 165 | epicCtr.set(epic, Math.max(epicCtr.get(epic) || 0, seq)); 166 | } 167 | } 168 | } 169 | 170 | return [...epicCtr].map( 171 | ([epic, maxSeq]) => new CompletionItem(`epic=${epic},seq=${maxSeq + config.epic.seqStep}`, CompletionItemKind.Enum) 172 | ); 173 | } 174 | return []; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/provider/fileAnchorProvider.ts: -------------------------------------------------------------------------------- 1 | import { AnchorEngine, FileEntry } from "../anchorEngine"; 2 | import { Event, TreeDataProvider, TreeItem } from "vscode"; 3 | 4 | import EntryAnchor from "../anchor/entryAnchor"; 5 | import EntryBase from "../anchor/entryBase"; 6 | import EntryCursor from "../anchor/entryCursor"; 7 | import { window } from "vscode"; 8 | 9 | /** 10 | * AnchorProvider implementation in charge of returning the anchors in the current file 11 | */ 12 | export class FileAnchorProvider implements TreeDataProvider { 13 | 14 | public readonly provider: AnchorEngine; 15 | public readonly onDidChangeTreeData: Event; 16 | 17 | private renderCursor = true; 18 | private cursorFound = false; 19 | 20 | public constructor(provider: AnchorEngine) { 21 | this.onDidChangeTreeData = provider._onDidChangeTreeData.event; 22 | this.provider = provider; 23 | } 24 | 25 | public getTreeItem(element: FileEntry): TreeItem { 26 | return element; 27 | } 28 | 29 | public getChildren(element?: FileEntry): Thenable { 30 | if (element) { 31 | if (element instanceof EntryAnchor && element.children) { 32 | let children: EntryBase[] = element.children.filter((child) => !child.isHidden); 33 | 34 | if (this.renderCursor) { 35 | children = this.insertCursor(children); 36 | } 37 | 38 | return Promise.resolve(children); // Insert 39 | } 40 | 41 | return Promise.resolve([]); 42 | } 43 | 44 | this.cursorFound = false; 45 | 46 | const fileAnchors = this.provider.currentAnchors.filter((child) => !child.isHidden); 47 | 48 | // Return result 49 | return new Promise((resolve) => { 50 | if (!this.provider.anchorsLoaded) { 51 | resolve([this.provider.statusLoading]); 52 | } else if (this.provider._editor == undefined) { 53 | resolve([this.provider.errorUnusableItem]); 54 | } else if (fileAnchors.length === 0) { 55 | resolve([this.provider.errorEmptyItem]); 56 | } else { 57 | let anchors: EntryBase[] = EntryAnchor.sortAnchors(fileAnchors); 58 | 59 | if (this.renderCursor) { 60 | anchors = this.insertCursor(anchors); 61 | } 62 | 63 | resolve(anchors); // Insert 64 | } 65 | }); 66 | } 67 | 68 | public insertCursor(anchors: EntryBase[]): EntryBase[] { 69 | const cursor = window.activeTextEditor?.selection?.active?.line; 70 | 71 | if (!this.provider._config!.showCursor || cursor === undefined) { 72 | return anchors; 73 | } 74 | 75 | const ret = []; 76 | 77 | for (const anchor of anchors) { 78 | if (!this.cursorFound && anchor instanceof EntryAnchor && anchor.lineNumber > cursor) { 79 | ret.push(new EntryCursor(this.provider, cursor + 1)); 80 | this.cursorFound = true; 81 | } 82 | 83 | ret.push(anchor); 84 | } 85 | 86 | return ret; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/provider/workspaceAnchorProvider.ts: -------------------------------------------------------------------------------- 1 | import { TreeDataProvider, Event, TreeItem, workspace } from "vscode"; 2 | import EntryAnchor from "../anchor/entryAnchor"; 3 | import { AnchorEngine, AnyEntry, AnyEntryArray } from "../anchorEngine"; 4 | import EntryCachedFile from "../anchor/entryCachedFile"; 5 | import { flattenAnchors } from "../util/flattener"; 6 | 7 | /** 8 | * AnchorProvider implementation in charge of returning the anchors in the current workspace 9 | */ 10 | export class WorkspaceAnchorProvider implements TreeDataProvider { 11 | 12 | public readonly provider: AnchorEngine; 13 | public readonly onDidChangeTreeData: Event; 14 | 15 | public constructor(provider: AnchorEngine) { 16 | this.onDidChangeTreeData = provider._onDidChangeTreeData.event; 17 | this.provider = provider; 18 | } 19 | 20 | public getTreeItem(element: AnyEntry): TreeItem { 21 | return element; 22 | } 23 | 24 | public getChildren(element?: AnyEntry): Thenable { 25 | return new Promise((success) => { 26 | if (element) { 27 | if (element instanceof EntryAnchor && element.children) { 28 | success(element.children); 29 | return; 30 | } else if (element instanceof EntryCachedFile) { 31 | const res: EntryAnchor[] = []; 32 | 33 | const cachedFile = element as EntryCachedFile; 34 | 35 | if (this.provider._config!.tags.displayHierarchyInWorkspace) { 36 | for (const anchor of cachedFile.anchors) { 37 | if (anchor.isVisibleInWorkspace) { 38 | res.push(anchor.copy(true)); 39 | } 40 | } 41 | } else { 42 | const flattened = flattenAnchors(cachedFile.anchors); 43 | 44 | for (const anchor of flattened) { 45 | if (anchor.isVisibleInWorkspace) { 46 | res.push(anchor.copy(false)); 47 | } 48 | } 49 | } 50 | 51 | success(EntryAnchor.sortAnchors(res)); 52 | } else { 53 | success([]); 54 | } 55 | 56 | return; 57 | } 58 | 59 | if (!this.provider._config!.workspace.enabled) { 60 | success([this.provider.errorWorkspaceDisabled]); 61 | return; 62 | } else if (!workspace.workspaceFolders) { 63 | success([this.provider.errorFileOnly]); 64 | return; 65 | } else if (this.provider._config!.workspace.lazyLoad && !this.provider.anchorsScanned) { 66 | success([this.provider.statusScan]); 67 | } else if (!this.provider.anchorsLoaded) { 68 | success([this.provider.statusLoading]); 69 | return; 70 | } 71 | 72 | const format = this.provider._config!.workspace.pathFormat; 73 | const res: EntryCachedFile[] = []; 74 | 75 | for (const [document, index] of this.provider.anchorMaps.entries()) { 76 | const anchors = index.anchorTree; 77 | 78 | if (anchors.length === 0) continue; // Skip empty files 79 | 80 | let notVisible = true; 81 | 82 | for (const anchor of anchors) { 83 | if (anchor.isVisibleInWorkspace) notVisible = false; 84 | } 85 | 86 | if (!notVisible) { 87 | try { 88 | res.push(new EntryCachedFile(this.provider, document, anchors, format)); 89 | } catch { 90 | // Simply ignore, we do not want to push this file 91 | } 92 | } 93 | } 94 | 95 | if (res.length === 0) { 96 | success([this.provider.errorEmptyWorkspace]); 97 | return; 98 | } 99 | 100 | success( 101 | res.sort((left, right) => { 102 | const leftLabel = left.label as string; 103 | const rightLabel = right.label as string; 104 | 105 | return leftLabel.localeCompare(rightLabel); 106 | }) 107 | ); 108 | }); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.json"; 2 | -------------------------------------------------------------------------------- /src/util/asyncDelay.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility used for awaiting a timeout 3 | * 4 | * @param delay Delay in ms 5 | */ 6 | export function asyncDelay(delay: number): Promise { 7 | return new Promise((success) => { 8 | setTimeout(() => { 9 | success(); 10 | }, delay); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/util/completionProvider.ts: -------------------------------------------------------------------------------- 1 | import { AnchorEngine } from "../anchorEngine"; 2 | import { 3 | CancellationToken, 4 | CompletionContext, 5 | CompletionItem, 6 | CompletionItemKind, 7 | CompletionItemProvider, 8 | CompletionList, 9 | CompletionTriggerKind, 10 | Disposable, 11 | languages, 12 | Position, 13 | ProviderResult, 14 | TextDocument, 15 | } from "vscode"; 16 | 17 | class TagCompletionProvider implements CompletionItemProvider { 18 | 19 | private engine: AnchorEngine; 20 | 21 | public constructor(engine: AnchorEngine) { 22 | this.engine = engine; 23 | } 24 | 25 | public provideCompletionItems( 26 | document: TextDocument, 27 | position: Position, 28 | _token: CancellationToken, 29 | context: CompletionContext 30 | ): ProviderResult { 31 | const ret = new CompletionList(); 32 | const config = this.engine._config!; 33 | const separator = config.tags.separators[0]; 34 | const endTag = config.tags.endTag; 35 | const prefixes = config.tags.matchPrefix; 36 | 37 | const linePrefix = document.lineAt(position.line).text.slice(0, position.character); 38 | const isAuto = context.triggerKind === CompletionTriggerKind.TriggerCharacter; 39 | 40 | // only match exact prefixes 41 | if (isAuto && !prefixes.some((prefix: string) => linePrefix.endsWith(prefix))) { 42 | return undefined; 43 | } 44 | 45 | for (const tag of this.engine.tags.values()) { 46 | const name = `${tag.tag} Anchor`; 47 | const item = new CompletionItem(name, CompletionItemKind.Event); 48 | 49 | item.insertText = tag.tag + separator; 50 | item.detail = `Insert ${tag.tag} anchor`; 51 | 52 | ret.items.push(item); 53 | 54 | if (tag.behavior == "region") { 55 | const endItem = new CompletionItem(endTag + name, CompletionItemKind.Event); 56 | 57 | endItem.insertText = endTag + tag.tag; 58 | endItem.detail = `Insert ${endTag + tag.tag} comment anchor`; 59 | item.keepWhitespace = true; 60 | 61 | ret.items.push(endItem); 62 | } 63 | } 64 | 65 | return ret; 66 | } 67 | } 68 | 69 | export function setupCompletionProvider(engine: AnchorEngine): Disposable { 70 | const prefixes = engine._config!.tags.matchPrefix; 71 | const triggers = [...new Set(prefixes.map((p: string) => p.at(-1)))]; 72 | 73 | return languages.registerCompletionItemProvider( 74 | { language: "*" }, 75 | new TagCompletionProvider(engine), 76 | ...triggers 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/util/customTags.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceConfiguration } from "vscode"; 2 | import { TagEntry } from "../anchorEngine"; 3 | 4 | type TagConfig = Omit; 5 | 6 | /** 7 | * Parse and register custom anchors to the tagMap 8 | * 9 | * @param config The extension configuration 10 | * @param tagMap The tagMap reference 11 | */ 12 | export function parseCustomAnchors(config: WorkspaceConfiguration, tagMap: Map): void { 13 | const legacy: TagEntry[] = config.tags.list || []; 14 | const custom: Record = {...config.tags.anchors}; 15 | 16 | // Parse legacy configuration format 17 | for (const tag of legacy) { 18 | custom[tag.tag] = tag; 19 | } 20 | 21 | // Parse custom tags 22 | for (const [tag, config] of Object.entries(custom)) { 23 | const def = tagMap.get(tag) || {}; 24 | const opts: any = { ...def, ...config }; 25 | 26 | // Skip disabled default tags 27 | if (config.enabled === false) { 28 | tagMap.delete(tag); 29 | continue; 30 | } 31 | 32 | // Migrate the isRegion property 33 | if (opts.isRegion) { 34 | opts.behavior = "region"; 35 | } 36 | 37 | // Migrate the styleComment property 38 | if (opts.styleComment) { 39 | opts.styleMode = "comment"; 40 | } 41 | 42 | tagMap.set(tag, { 43 | ...opts, 44 | tag: tag 45 | }); 46 | } 47 | } -------------------------------------------------------------------------------- /src/util/defaultTags.ts: -------------------------------------------------------------------------------- 1 | import { TagEntry } from "../anchorEngine"; 2 | 3 | /** 4 | * Register default tags to a tagMap 5 | * 6 | * @param tagMap The tagMap reference 7 | */ 8 | export function registerDefaults(tagMap: Map): void { 9 | function register(entry: TagEntry): void { 10 | tagMap.set(entry.tag, entry); 11 | } 12 | 13 | register({ 14 | tag: "ANCHOR", 15 | iconColor: "default", 16 | highlightColor: "#A8C023", 17 | scope: "file", 18 | behavior: "anchor", 19 | }); 20 | 21 | register({ 22 | tag: "TODO", 23 | iconColor: "blue", 24 | highlightColor: "#3ea8ff", 25 | scope: "workspace", 26 | behavior: "anchor", 27 | }); 28 | 29 | register({ 30 | tag: "FIXME", 31 | iconColor: "red", 32 | highlightColor: "#F44336", 33 | scope: "workspace", 34 | behavior: "anchor", 35 | }); 36 | 37 | register({ 38 | tag: "STUB", 39 | iconColor: "purple", 40 | highlightColor: "#BA68C8", 41 | scope: "file", 42 | behavior: "anchor", 43 | }); 44 | 45 | register({ 46 | tag: "NOTE", 47 | iconColor: "orange", 48 | highlightColor: "#FFB300", 49 | scope: "file", 50 | behavior: "anchor", 51 | }); 52 | 53 | register({ 54 | tag: "REVIEW", 55 | iconColor: "green", 56 | highlightColor: "#64DD17", 57 | scope: "workspace", 58 | behavior: "anchor", 59 | }); 60 | 61 | register({ 62 | tag: "SECTION", 63 | iconColor: "blurple", 64 | highlightColor: "#896afc", 65 | scope: "workspace", 66 | behavior: "region", 67 | }); 68 | 69 | register({ 70 | tag: "LINK", 71 | iconColor: "#2ecc71", 72 | highlightColor: "#2ecc71", 73 | scope: "workspace", 74 | behavior: "link", 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /src/util/escape.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * escape-string-regexp 3 | * 4 | * MIT License 5 | * 6 | * Copyright (c) Sindre Sorhus (https://sindresorhus.com) 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 9 | * 10 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 13 | * 14 | * NOTE: This dependency is embedded due to bundling limitations 15 | */ 16 | export default function escapeStringRegexp(string: string) { 17 | if (typeof string !== 'string') { 18 | throw new TypeError('Expected a string'); 19 | } 20 | 21 | // Escape characters with special meaning either inside or outside character sets. 22 | // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar. 23 | return string 24 | .replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&') 25 | .replaceAll('-', '\\x2d'); 26 | } -------------------------------------------------------------------------------- /src/util/exporting.ts: -------------------------------------------------------------------------------- 1 | import { anchorEngine } from "../extension"; 2 | import { flattenAnchors } from "./flattener"; 3 | import { stringify } from 'csv-stringify/sync'; 4 | 5 | export function createTableExport(): string { 6 | const rows: any[] = []; 7 | 8 | for (const [file, anchors] of anchorEngine.anchorMaps.entries()) { 9 | const fullList = flattenAnchors(anchors.anchorTree); 10 | const filePath = file.fsPath; 11 | 12 | for (const anchor of fullList) { 13 | rows.push({ 14 | Filename: filePath, 15 | Line: anchor.lineNumber, 16 | Tag: anchor.anchorTag, 17 | Text: anchor.anchorText, 18 | Id: anchor.attributes.id, 19 | Epic: anchor.attributes.epic, 20 | }); 21 | } 22 | } 23 | 24 | return stringify(rows, { 25 | header: true, 26 | columns: [ 27 | 'Filename', 28 | 'Line', 29 | 'Tag', 30 | 'Text', 31 | 'Id', 32 | 'Epic' 33 | ] 34 | }); 35 | } 36 | 37 | export function createJSONExport(): string { 38 | const fileMap: any = {}; 39 | 40 | for (const [file, anchors] of anchorEngine.anchorMaps.entries()) { 41 | const fullList = flattenAnchors(anchors.anchorTree); 42 | 43 | fileMap[file.fsPath] = fullList.map(anchor => ({ 44 | tag: anchor.anchorTag, 45 | text: anchor.anchorText, 46 | line: anchor.lineNumber, 47 | id: anchor.attributes.id, 48 | epic: anchor.attributes.epic, 49 | })); 50 | } 51 | 52 | return JSON.stringify(fileMap, null, 2); 53 | } -------------------------------------------------------------------------------- /src/util/flattener.ts: -------------------------------------------------------------------------------- 1 | import EntryAnchor from "../anchor/entryAnchor"; 2 | 3 | /** 4 | * Flattens hierarchical anchors into a single array 5 | * 6 | * @param anchors Array to flatten 7 | */ 8 | export function flattenAnchors(anchors: EntryAnchor[]): EntryAnchor[] { 9 | const list: EntryAnchor[] = []; 10 | 11 | function crawlList(anchors: EntryAnchor[]) { 12 | for (const anchor of anchors) { 13 | list.push(anchor); 14 | 15 | crawlList(anchor.children); 16 | } 17 | } 18 | 19 | crawlList(anchors); 20 | 21 | return list; 22 | } 23 | -------------------------------------------------------------------------------- /src/util/linkProvider.ts: -------------------------------------------------------------------------------- 1 | import { CancellationToken, DocumentLink, DocumentLinkProvider, ProviderResult, TextDocument, Uri, window, workspace } from "vscode"; 2 | import { join, resolve } from "node:path"; 3 | 4 | import { AnchorEngine } from "../anchorEngine"; 5 | import { flattenAnchors } from "./flattener"; 6 | import { existsSync, lstatSync } from "node:fs"; 7 | 8 | const LINK_REGEX = /^(\.{1,2}[/\\])?([^#:]+)?(:\d+|#[\w-]+)?$/; 9 | 10 | export class LinkProvider implements DocumentLinkProvider { 11 | 12 | public readonly engine: AnchorEngine; 13 | 14 | public constructor(engine: AnchorEngine) { 15 | this.engine = engine; 16 | } 17 | 18 | public createTarget(uri: Uri, line: number): Uri { 19 | return Uri.parse(`file://${uri.path}#${line}`); 20 | } 21 | 22 | public provideDocumentLinks(document: TextDocument, _token: CancellationToken): ProviderResult { 23 | if (document.uri.scheme == "output") { 24 | return []; 25 | } 26 | 27 | const index = this.engine.anchorMaps.get(document.uri); 28 | const list: DocumentLink[] = []; 29 | 30 | if (!index) { 31 | return []; 32 | } 33 | 34 | const flattened = flattenAnchors(index.anchorTree); 35 | const basePath = join(document.uri.fsPath, ".."); 36 | const workspacePath = workspace.getWorkspaceFolder(document.uri)?.uri?.fsPath ?? ""; 37 | const tasks: Promise[] = []; 38 | 39 | const flattenedLinks = flattened 40 | .filter((anchor) => { 41 | const tagId = anchor.anchorTag; 42 | const tag = this.engine.tags.get(tagId); 43 | 44 | return tag?.behavior == "link"; 45 | }); 46 | 47 | for (const anchor of flattenedLinks) { 48 | const components = LINK_REGEX.exec(anchor.anchorText)!; 49 | const parameter = components[3] || ''; 50 | const filePath = components[2] || document?.uri?.fsPath || ''; 51 | const relativeFolder = components[1]; 52 | const fullPath = relativeFolder ? resolve(basePath, relativeFolder, filePath) : resolve(workspacePath, filePath); 53 | const fileUri = Uri.file(fullPath); 54 | 55 | if (!existsSync(fullPath) || !lstatSync(fullPath).isFile()) { 56 | continue; 57 | } 58 | 59 | const anchorRange = anchor.getAnchorRange(document, true); 60 | let docLink: DocumentLink; 61 | let task: Promise; 62 | 63 | if (parameter.startsWith(":")) { 64 | const lineNumber = Number.parseInt(parameter.slice(1)); 65 | const targetURI = this.createTarget(fileUri, lineNumber); 66 | 67 | docLink = new DocumentLink(anchorRange, targetURI); 68 | docLink.tooltip = "Click here to open file at line " + (lineNumber + 1); 69 | task = Promise.resolve(); 70 | } else { 71 | if (parameter.startsWith("#")) { 72 | const targetId = parameter.slice(1); 73 | 74 | task = this.engine.getAnchors(fileUri).then((anchors) => { 75 | const flattened = flattenAnchors(anchors); 76 | let targetLine = 0; 77 | 78 | for (const otherAnchor of flattened) { 79 | if (otherAnchor.attributes.id == targetId) { 80 | targetLine = otherAnchor.lineNumber; 81 | break; 82 | } 83 | } 84 | 85 | const targetURI = this.createTarget(fileUri, targetLine); 86 | 87 | if (fileUri.path == window.activeTextEditor?.document?.uri?.path) { 88 | docLink = new DocumentLink(anchorRange, targetURI); 89 | docLink.tooltip = "Click here to go to anchor " + targetId; 90 | } else { 91 | docLink = new DocumentLink(anchorRange, targetURI); 92 | docLink.tooltip = "Click here to open file at anchor " + targetId; 93 | } 94 | }); 95 | } else { 96 | docLink = new DocumentLink(anchorRange, fileUri); 97 | docLink.tooltip = "Click here to open file"; 98 | task = Promise.resolve(); 99 | } 100 | } 101 | 102 | const completion = task.then(() => { 103 | list.push(docLink); 104 | }); 105 | 106 | tasks.push(completion); 107 | } 108 | 109 | return Promise.all(tasks).then(() => { 110 | return list; 111 | }); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2022", 5 | "outDir": "out", 6 | "lib": ["ES2022"], 7 | "sourceMap": true, 8 | "strict": true, 9 | "rootDir": "src" 10 | }, 11 | "exclude": [ 12 | "node_modules", 13 | ".vscode-test" 14 | ] 15 | } --------------------------------------------------------------------------------