├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── build.yaml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Directory.Build.props ├── LICENSE ├── README.md ├── StringResourceVisualizer.sln ├── art ├── screenshot.png └── settings.png ├── nuget.config └── src ├── AnalyticsConfig.cs ├── App.config ├── ConstFinder.cs ├── Constants.cs ├── GlobalSuppressions.cs ├── Messenger.cs ├── MyLineTransformSource.cs ├── MyLineTransformSourceProvider.cs ├── MyRunningDocTableEvents.cs ├── OptionsGrid.cs ├── OutputPane.cs ├── Properties └── AssemblyInfo.cs ├── ResourceAdornmentManager.cs ├── ResourceAdornmentManagerFactory.cs ├── Resources └── Icon.png ├── SponsorDetector.cs ├── SponsorRequestHelper.cs ├── StringExtensions.cs ├── StringResVizPackage.cs ├── StringResourceVisualizer.Tests ├── Properties │ └── AssemblyInfo.cs ├── ResourceAdornmentManagerTests.cs └── StringResourceVisualizer.Tests.csproj ├── StringResourceVisualizer.csproj ├── StringResourceVisualizer.ruleset ├── TaskExtensions.cs ├── filestosign.txt ├── signvsix.targets ├── source.extension.cs ├── source.extension.vsixmanifest └── stylecop.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | # SA0001: XML comment analysis is disabled due to project configuration 4 | dotnet_diagnostic.SA0001.severity = none 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mrlacey 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 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Installed product versions** 27 | - Visual Studio: [example 17.9.5] 28 | - This extension: [example 1.21] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | name: "Build" 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | paths-ignore: 8 | - '*.md' 9 | pull_request: 10 | branches: [main] 11 | paths-ignore: 12 | - '*.md' 13 | 14 | jobs: 15 | build: 16 | outputs: 17 | version: ${{ steps.vsix_version.outputs.version-number }} 18 | name: Build 19 | runs-on: windows-2022 20 | env: 21 | Configuration: Debug 22 | DeployExtension: False 23 | VsixManifestPath: src\source.extension.vsixmanifest 24 | VsixManifestSourcePath: src\source.extension.cs 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Setup .NET build dependencies 30 | uses: timheuer/bootstrap-dotnet@v2 31 | with: 32 | nuget: 'false' 33 | sdk: 'false' 34 | msbuild: 'true' 35 | 36 | - name: Increment VSIX version 37 | id: vsix_version 38 | uses: timheuer/vsix-version-stamp@v2 39 | with: 40 | manifest-file: ${{ env.VsixManifestPath }} 41 | vsix-token-source-file: ${{ env.VsixManifestSourcePath }} 42 | 43 | - name: Build 44 | run: msbuild /v:m -restore /p:OutDir=\_built 45 | 46 | - name: Upload artifact 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: ${{ github.event.repository.name }}.vsix 50 | path: /_built/**/*.vsix 51 | 52 | - name: Run Tests 53 | # See https://github.com/microsoft/vstest-action/issues/31 54 | # uses: microsoft/vstest-action@v1.0.0 55 | uses: josepho0918/vstest-action@main 56 | with: 57 | searchFolder: /_built/ 58 | testAssembly: /**/*Tests.dll 59 | 60 | - name: Publish Test Results 61 | uses: EnricoMi/publish-unit-test-result-action/windows@v2 62 | id: test-results 63 | with: 64 | files: testresults\**\*.trx 65 | 66 | - name: Set badge color 67 | shell: bash 68 | run: | 69 | case ${{ fromJSON( steps.test-results.outputs.json ).conclusion }} in 70 | success) 71 | echo "BADGE_COLOR=31c653" >> $GITHUB_ENV 72 | ;; 73 | failure) 74 | echo "BADGE_COLOR=800000" >> $GITHUB_ENV 75 | ;; 76 | neutral) 77 | echo "BADGE_COLOR=696969" >> $GITHUB_ENV 78 | ;; 79 | esac 80 | 81 | - name: Create badge 82 | uses: emibcn/badge-action@808173dd03e2f30c980d03ee49e181626088eee8 83 | with: 84 | label: Tests 85 | status: '${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.tests }} tests: ${{ fromJSON( steps.test-results.outputs.json ).conclusion }}' 86 | color: ${{ env.BADGE_COLOR }} 87 | path: StringResourceVisualizer.badge.svg 88 | 89 | - name: Upload badge to Gist 90 | # Upload only for main branch 91 | if: > 92 | github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main' || 93 | github.event_name != 'workflow_run' && github.ref == 'refs/heads/main' 94 | uses: jpontdia/append-gist-action@master 95 | with: 96 | token: ${{ secrets.GIST_TOKEN }} 97 | gistURL: https://gist.githubusercontent.com/mrlacey/c586ff0f495b4a8dd76ab0dbdf9c89e0 98 | file: StringResourceVisualizer.badge.svg 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | *.rptproj.bak 244 | 245 | # SQL Server files 246 | *.mdf 247 | *.ldf 248 | *.ndf 249 | 250 | # Business Intelligence projects 251 | *.rdl.data 252 | *.bim.layout 253 | *.bim_*.settings 254 | *.rptproj.rsuser 255 | 256 | # Microsoft Fakes 257 | FakesAssemblies/ 258 | 259 | # GhostDoc plugin setting file 260 | *.GhostDoc.xml 261 | 262 | # Node.js Tools for Visual Studio 263 | .ntvs_analysis.dat 264 | node_modules/ 265 | 266 | # Visual Studio 6 build log 267 | *.plg 268 | 269 | # Visual Studio 6 workspace options file 270 | *.opt 271 | 272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 273 | *.vbw 274 | 275 | # Visual Studio LightSwitch build output 276 | **/*.HTMLClient/GeneratedArtifacts 277 | **/*.DesktopClient/GeneratedArtifacts 278 | **/*.DesktopClient/ModelManifest.xml 279 | **/*.Server/GeneratedArtifacts 280 | **/*.Server/ModelManifest.xml 281 | _Pvt_Extensions 282 | 283 | # Paket dependency manager 284 | .paket/paket.exe 285 | paket-files/ 286 | 287 | # FAKE - F# Make 288 | .fake/ 289 | 290 | # JetBrains Rider 291 | .idea/ 292 | *.sln.iml 293 | 294 | # CodeRush 295 | .cr/ 296 | 297 | # Python Tools for Visual Studio (PTVS) 298 | __pycache__/ 299 | *.pyc 300 | 301 | # Cake - Uncomment if you are using it 302 | # tools/** 303 | # !tools/packages.config 304 | 305 | # Tabs Studio 306 | *.tss 307 | 308 | # Telerik's JustMock configuration file 309 | *.jmconfig 310 | 311 | # BizTalk build output 312 | *.btp.cs 313 | *.btm.cs 314 | *.odx.cs 315 | *.xsd.cs 316 | 317 | # OpenCover UI analysis results 318 | OpenCover/ 319 | 320 | # Azure Stream Analytics local run output 321 | ASALocalRun/ 322 | 323 | # MSBuild Binary and Structured Log 324 | *.binlog 325 | 326 | # NVidia Nsight GPU debugger configuration file 327 | *.nvuser 328 | 329 | # MFractors (Xamarin productivity tool) working folder 330 | .mfractor/ 331 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Road map 2 | 3 | - [ ] Performance improvements 4 | - [ ] Support for wrapped lines 5 | - [ ] RESW support 6 | - [ ] [Open to suggestions...](https://github.com/mrlacey/StringResourceVisualizer/issues/new) 7 | 8 | Features that have a checkmark are complete and available for download in the 9 | [CI build](http://vsixgallery.com/extension/StringResourceVisualizer.a05f89b1-98f8-4b37-8f84-4fdebc44aa25/). 10 | 11 | # Change log 12 | 13 | These are the changes to each version that has been released 14 | on the official Visual Studio extension gallery. 15 | 16 | ## 1.23.1 17 | 18 | - [x] Increased logging on exceptions to try and identify the cause of resource load failures. 19 | 20 | 21 | ## 1.23 22 | 23 | - [x] Fix for possible crash when opening files not as part of a project. 24 | 25 | ## 1.22 26 | 27 | - [x] Update minimum supported version of VS to 17.10 28 | - [x] Address security vulnerabilities in dependencies. 29 | - [x] Add basic usage telemetry. 30 | 31 | ## 1.21 32 | 33 | - [x] Avoid locking UI when parsing the solution. 34 | 35 | ## 1.20 36 | 37 | - [x] Startup performance improvements. 38 | 39 | ## 1.19 40 | 41 | - [x] Improve support for Razor files. 42 | - [x] Handle changing solutions without restarting VS. 43 | - [x] Improve extension loading. 44 | - [x] Support opening .csproj files directly (not as part of a solution.) 45 | 46 | ## 1.18 47 | 48 | - [x] Small performance (responsiveness) improvement. 49 | 50 | ## 1.17 51 | 52 | - [x] Fix exception when reloading a solution while the package is loading. 53 | 54 | ## 1.16 55 | 56 | - [x] Added tracking to try and identify the cause of performance issues. 57 | 58 | ## 1.15 59 | 60 | - [x] Improve handling of invalid code in editor. 61 | 62 | ## 1.14 63 | 64 | - [x] Handle internal exception scenario. 65 | 66 | ## 1.13 67 | 68 | - [x] Support adjusting the spacing around the text indicator. 69 | 70 | ## 1.12 71 | 72 | - [x] Increase spacing between indicator and the line above. 73 | 74 | ## 1.11 75 | 76 | - [x] Fix for possible errors when opening some (more) document types. 77 | 78 | ## 1.10 79 | 80 | - [x] Fix for handling some document types in VS2022. 81 | 82 | ## 1.9 83 | 84 | - [x] Fix occassional crash when opening some projects. 85 | 86 | ## 1.8 87 | 88 | - [x] Add Sponsor Request hint. 89 | - [x] Support VS2022. 90 | 91 | ## 1.7 92 | 93 | - [x] Drop support for VS2017 😢 Sorry, I can't get it to compile and work there correctly anymore. 94 | 95 | ## 1.6 96 | 97 | - [x] Add support for ILocalizer in *.cshtml & *.cs files 98 | - [x] Allow ILocalizer keys to be constants 99 | - [x] Support using aliases 100 | 101 | ## 1.5 102 | 103 | - [x] Allow specifying a 'preferred culture' to use when looking up strings to display. 104 | - [x] Small perf improvements. 105 | 106 | ## 1.4 107 | 108 | - [x] Only pad between lines when definitely something to show 109 | - [x] Internal optimization of referenced resources 110 | 111 | 112 | ## 1.3 113 | 114 | - [x] Support VS 2019 115 | - [x] Don't show adorner if usage is in a collapsed section 116 | - [x] Support showing multiple resources in the same line of code 117 | - [x] Prevent resources overlapping if on the same line 118 | - [x] Truncate multi-line strings in adornment and indicate truncation/wrapping 119 | - [x] Use output pane for logging details, not the status bar 120 | 121 | ## 1.2 122 | 123 | - [x] Improve performance and reduce CPU usage 124 | - [x] Support resource files added to the project once opened 125 | 126 | ## 1.1 127 | 128 | - [x] Support VB.Net 129 | - [x] Identify resources followed by a comma or curly brace 130 | - [x] Fix crash when switching branches or opening certain projects 131 | - [x] Enable working with any project type or solution structure 132 | 133 | ## 1.0 134 | 135 | - [x] Initial release 136 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Looking to contribute something? **Here's how you can help.** 4 | 5 | Please take a moment to review this document in order to make the contribution 6 | process easy and effective for everyone involved. 7 | 8 | Following these guidelines helps to communicate that you respect the time of 9 | the developers managing and developing this open source project. In return, 10 | they should reciprocate that respect in addressing your issue or assessing 11 | patches and features. 12 | 13 | 14 | ## Using the issue tracker 15 | 16 | The issue tracker is the preferred channel for [bug reports](#bug-reports), 17 | [features requests](#feature-requests) and 18 | [submitting pull requests](#pull-requests), but please respect the 19 | following restrictions: 20 | 21 | * Please **do not** use the issue tracker for personal support requests. Stack 22 | Overflow is a better place to get help. 23 | 24 | * Please **do not** derail or troll issues. Keep the discussion on topic and 25 | respect the opinions of others. 26 | 27 | * Please **do not** open issues or pull requests which *belongs to* third party 28 | components. 29 | 30 | 31 | ## Bug reports 32 | 33 | A bug is a _demonstrable problem_ that is caused by the code in the repository. 34 | Good bug reports are extremely helpful, so thanks! 35 | 36 | Guidelines for bug reports: 37 | 38 | 1. **Use the GitHub issue search** — check if the issue has already been 39 | reported. 40 | 41 | 2. **Check if the issue has been fixed** — try to reproduce it using the 42 | latest `master` or development branch in the repository. 43 | 44 | 3. **Isolate the problem** — ideally create an 45 | [SSCCE](http://www.sscce.org/) and a live example. 46 | Uploading the project on cloud storage (OneDrive, DropBox, et el.) 47 | or creating a sample GitHub repository is also helpful. 48 | 49 | 50 | A good bug report shouldn't leave others needing to chase you up for more 51 | information. Please try to be as detailed as possible in your report. What is 52 | your environment? What steps will reproduce the issue? What browser(s) and OS 53 | experience the problem? Do other browsers show the bug differently? What 54 | would you expect to be the outcome? All these details will help people to fix 55 | any potential bugs. 56 | 57 | Example: 58 | 59 | > Short and descriptive example bug report title 60 | > 61 | > A summary of the issue and the Visual Studio, browser, OS environments 62 | > in which it occurs. If suitable, include the steps required to reproduce the bug. 63 | > 64 | > 1. This is the first step 65 | > 2. This is the second step 66 | > 3. Further steps, etc. 67 | > 68 | > `` - a link to the project/file uploaded on cloud storage or other publicly accessible medium. 69 | > 70 | > Any other information you want to share that is relevant to the issue being 71 | > reported. This might include the lines of code that you have identified as 72 | > causing the bug, and potential solutions (and your opinions on their 73 | > merits). 74 | 75 | 76 | ## Feature requests 77 | 78 | Feature requests are welcome. But take a moment to find out whether your idea 79 | fits with the scope and aims of the project. It's up to *you* to make a strong 80 | case to convince the project's developers of the merits of this feature. Please 81 | provide as much detail and context as possible. 82 | 83 | 84 | ## Pull requests 85 | 86 | Good pull requests, patches, improvements and new features are a fantastic 87 | help. They should remain focused in scope and avoid containing unrelated 88 | commits. 89 | 90 | **Please ask first** before embarking on any significant pull request (e.g. 91 | implementing features, refactoring code, porting to a different language), 92 | otherwise you risk spending a lot of time working on something that the 93 | project's developers might not want to merge into the project. 94 | 95 | Please adhere to the [coding guidelines](#code-guidelines) used throughout the 96 | project (indentation, accurate comments, etc.) and any other requirements 97 | (such as test coverage). 98 | 99 | Adhering to the following process is the best way to get your work 100 | included in the project: 101 | 102 | 1. [Fork](http://help.github.com/fork-a-repo/) the project, clone your fork, 103 | and configure the remotes: 104 | 105 | ```bash 106 | # Clone your fork of the repo into the current directory 107 | git clone https://github.com//.git 108 | # Navigate to the newly cloned directory 109 | cd 110 | # Assign the original repo to a remote called "upstream" 111 | git remote add upstream https://github.com/madskristensen/.git 112 | ``` 113 | 114 | 2. If you cloned a while ago, get the latest changes from upstream: 115 | 116 | ```bash 117 | git checkout master 118 | git pull upstream master 119 | ``` 120 | 121 | 3. Create a new topic branch (off the main project development branch) to 122 | contain your feature, change, or fix: 123 | 124 | ```bash 125 | git checkout -b 126 | ``` 127 | 128 | 4. Commit your changes in logical chunks. Please adhere to these [git commit 129 | message guidelines](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 130 | or your code is unlikely be merged into the main project. Use Git's 131 | [interactive rebase](https://help.github.com/articles/interactive-rebase) 132 | feature to tidy up your commits before making them public. Also, prepend name of the feature 133 | to the commit message. For instance: "SCSS: Fixes compiler results for IFileListener.\nFixes `#123`" 134 | 135 | 5. Locally merge (or rebase) the upstream development branch into your topic branch: 136 | 137 | ```bash 138 | git pull [--rebase] upstream master 139 | ``` 140 | 141 | 6. Push your topic branch up to your fork: 142 | 143 | ```bash 144 | git push origin 145 | ``` 146 | 147 | 7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) 148 | with a clear title and description against the `master` branch. 149 | 150 | 151 | ## Code guidelines 152 | 153 | - Always use proper indentation. 154 | - In Visual Studio under `Tools > Options > Text Editor > C# > Advanced`, make sure 155 | `Place 'System' directives first when sorting usings` option is enabled (checked). 156 | - Before committing, organize usings for each updated C# source file. Either you can 157 | right-click editor and select `Organize Usings > Remove and sort` OR use extension 158 | like [BatchFormat](http://visualstudiogallery.msdn.microsoft.com/a7f75c34-82b4-4357-9c66-c18e32b9393e). 159 | - Before committing, run Code Analysis in `Debug` configuration and follow the guidelines 160 | to fix CA issues. Code Analysis commits can be made separately. 161 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 12.0 5 | 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Matt Lacey 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 | # String Resource Visualizer 2 | 3 | ![Works with Visual Studio 2022](https://img.shields.io/static/v1.svg?label=VS&message=2022&color=A853C7) 4 | ![Works with Visual Studio 2019](https://img.shields.io/static/v1.svg?label=VS&message=2019&color=5F2E96) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 6 | ![Visual Studio Marketplace 5 Stars](https://img.shields.io/badge/VS%20Marketplace-★★★★★-green) 7 | 8 | [![Build](https://github.com/mrlacey/StringResourceVisualizer/actions/workflows/build.yaml/badge.svg)](https://github.com/mrlacey/StringResourceVisualizer/actions/workflows/build.yaml) 9 | ![Tests](https://gist.githubusercontent.com/mrlacey/c586ff0f495b4a8dd76ab0dbdf9c89e0/raw/StringResourceVisualizer.badge.svg) 10 | 11 | A [Visual Studio extension](https://marketplace.visualstudio.com/items?itemName=MattLaceyLtd.StringResourceVisualizer) that shows the text of a string resource (.resx) when used inline in code. 12 | 13 | ![screenshot](./art/screenshot.png) 14 | 15 | The default (language/culture agnostic) resource file is used to find the text to display but you can override this by specifying a **Preferred Culture** in settings. (Go to **Tools > Options > String Resource Visualizer**) 16 | 17 | ![setting](./art/settings.png) 18 | 19 | If a string is not specified for the preferred culture, the default value is used instead. 20 | 21 | See the [change log](CHANGELOG.md) for changes and road map. 22 | -------------------------------------------------------------------------------- /StringResourceVisualizer.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.32104.313 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StringResourceVisualizer", "src\StringResourceVisualizer.csproj", "{E71A6E00-D6F7-4401-9515-1DD72701EC57}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F0206BFC-99B1-481B-8248-F4C4995858FC}" 9 | ProjectSection(SolutionItems) = preProject 10 | .editorconfig = .editorconfig 11 | CHANGELOG.md = CHANGELOG.md 12 | README.md = README.md 13 | EndProjectSection 14 | EndProject 15 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StringResourceVisualizer.Tests", "src\StringResourceVisualizer.Tests\StringResourceVisualizer.Tests.csproj", "{B36B6804-3DE4-4C5B-9727-25F053CBA7AC}" 16 | EndProject 17 | Global 18 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 19 | Debug|Any CPU = Debug|Any CPU 20 | Release|Any CPU = Release|Any CPU 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {E71A6E00-D6F7-4401-9515-1DD72701EC57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {E71A6E00-D6F7-4401-9515-1DD72701EC57}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {E71A6E00-D6F7-4401-9515-1DD72701EC57}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {E71A6E00-D6F7-4401-9515-1DD72701EC57}.Release|Any CPU.Build.0 = Release|Any CPU 27 | {B36B6804-3DE4-4C5B-9727-25F053CBA7AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {B36B6804-3DE4-4C5B-9727-25F053CBA7AC}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {B36B6804-3DE4-4C5B-9727-25F053CBA7AC}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {B36B6804-3DE4-4C5B-9727-25F053CBA7AC}.Release|Any CPU.Build.0 = Release|Any CPU 31 | EndGlobalSection 32 | GlobalSection(SolutionProperties) = preSolution 33 | HideSolutionNode = FALSE 34 | EndGlobalSection 35 | GlobalSection(ExtensibilityGlobals) = postSolution 36 | SolutionGuid = {CD6B32D1-9303-4C28-8469-72A05632ABE0} 37 | EndGlobalSection 38 | EndGlobal 39 | -------------------------------------------------------------------------------- /art/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlacey/StringResourceVisualizer/ee1f1bfdae997955630b6b8bbf7f7c6669517879/art/screenshot.png -------------------------------------------------------------------------------- /art/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlacey/StringResourceVisualizer/ee1f1bfdae997955630b6b8bbf7f7c6669517879/art/settings.png -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/AnalyticsConfig.cs: -------------------------------------------------------------------------------- 1 | namespace StringResourceVisualizer 2 | { 3 | public static class AnalyticsConfig 4 | { 5 | public static string TelemetryConnectionString { get; set; } = ""; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/App.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/ConstFinder.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Threading.Tasks; 9 | using Microsoft.CodeAnalysis; 10 | using Microsoft.CodeAnalysis.CSharp; 11 | using Microsoft.CodeAnalysis.CSharp.Syntax; 12 | using Microsoft.VisualStudio.ComponentModelHost; 13 | using Microsoft.VisualStudio.LanguageServices; 14 | using Microsoft.VisualStudio.Shell; 15 | using Task = System.Threading.Tasks.Task; 16 | 17 | namespace StringResourceVisualizer 18 | { 19 | internal static class ConstFinder 20 | { 21 | public static bool HasParsedSolution { get; private set; } = false; 22 | 23 | public static List<(string key, string qualification, string value, string source)> KnownConsts { get; } = new List<(string key, string qualification, string value, string source)>(); 24 | 25 | public static string[] SearchValues 26 | { 27 | get 28 | { 29 | return KnownConsts.Select(c => c.key).ToArray(); 30 | } 31 | } 32 | 33 | private static bool _parsingInProgress = false; 34 | 35 | public static async Task TryParseSolutionAsync(IComponentModel componentModel = null) 36 | { 37 | if (_parsingInProgress) 38 | { 39 | return; 40 | } 41 | 42 | _parsingInProgress = true; 43 | 44 | var totalTimer = new System.Diagnostics.Stopwatch(); 45 | var activeTimer = new System.Diagnostics.Stopwatch(); 46 | totalTimer.Start(); 47 | activeTimer.Start(); 48 | 49 | try 50 | { 51 | if (componentModel == null) 52 | { 53 | componentModel = (IComponentModel)Package.GetGlobalService(typeof(SComponentModel)); 54 | } 55 | 56 | var workspace = (Workspace)componentModel.GetService(); 57 | 58 | if (workspace == null) 59 | { 60 | return; 61 | } 62 | 63 | var projectGraph = workspace.CurrentSolution?.GetProjectDependencyGraph(); 64 | 65 | if (projectGraph == null) 66 | { 67 | return; 68 | } 69 | 70 | activeTimer.Stop(); 71 | await Task.Yield(); 72 | activeTimer.Start(); 73 | 74 | foreach (ProjectId projectId in projectGraph.GetTopologicallySortedProjects()) 75 | { 76 | Compilation projectCompilation = await workspace.CurrentSolution?.GetProject(projectId).GetCompilationAsync(); 77 | 78 | activeTimer.Stop(); 79 | await Task.Yield(); 80 | activeTimer.Start(); 81 | 82 | if (projectCompilation != null) 83 | { 84 | foreach (var compiledTree in projectCompilation.SyntaxTrees) 85 | { 86 | GetConstsFromSyntaxRoot(await compiledTree.GetRootAsync(), compiledTree.FilePath); 87 | 88 | activeTimer.Stop(); 89 | await Task.Yield(); 90 | activeTimer.Start(); 91 | } 92 | } 93 | } 94 | 95 | HasParsedSolution = true; 96 | } 97 | catch (Exception exc) 98 | { 99 | // Can get NRE from GetProjectDependencyGraph (& possibly other nulls) when a solution is being reloaded 100 | System.Diagnostics.Debug.WriteLine(exc); 101 | } 102 | finally 103 | { 104 | totalTimer.Stop(); 105 | activeTimer.Stop(); 106 | 107 | await OutputPane.Instance.WriteAsync($"Parse total duration: {totalTimer.Elapsed} (active = {activeTimer.Elapsed})"); 108 | 109 | _parsingInProgress = false; 110 | } 111 | } 112 | 113 | public static async Task ReloadConstsAsync() 114 | { 115 | await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); 116 | 117 | try 118 | { 119 | var componentModel = (IComponentModel)Package.GetGlobalService(typeof(SComponentModel)); 120 | 121 | if (ConstFinder.HasParsedSolution) 122 | { 123 | var dte = Package.GetGlobalService(typeof(EnvDTE.DTE)) as EnvDTE.DTE; 124 | var activeDocument = SafeGetActiveDocument(dte); 125 | if (activeDocument != null) 126 | { 127 | var workspace = (Workspace)componentModel.GetService(); 128 | var documentId = workspace.CurrentSolution.GetDocumentIdsWithFilePath(activeDocument.FullName).FirstOrDefault(); 129 | if (documentId != null) 130 | { 131 | var document = workspace.CurrentSolution.GetDocument(documentId); 132 | 133 | await TrackConstsInDocumentAsync(document); 134 | } 135 | } 136 | } 137 | else 138 | { 139 | await OutputPane.Instance.WriteAsync("About to parse solution due to request to reload constants"); 140 | await ConstFinder.TryParseSolutionAsync(componentModel); 141 | } 142 | } 143 | catch (Exception exc) 144 | { 145 | await OutputPane.Instance?.WriteAsync($"Error in {nameof(ReloadConstsAsync)}"); 146 | await OutputPane.Instance?.WriteAsync(exc.Message); 147 | await OutputPane.Instance?.WriteAsync(exc.Source); 148 | await OutputPane.Instance?.WriteAsync(exc.StackTrace); 149 | } 150 | } 151 | 152 | public static async Task TrackConstsInDocumentAsync(Document document) 153 | { 154 | if (document == null) 155 | { 156 | return false; 157 | } 158 | 159 | System.Diagnostics.Debug.WriteLine(document.FilePath); 160 | 161 | if (document.FilePath == null 162 | || document.FilePath.Contains(".g.") 163 | || document.FilePath.Contains(".Designer.")) 164 | { 165 | return false; 166 | } 167 | 168 | var result = true; 169 | 170 | if (document.TryGetSyntaxTree(out SyntaxTree _)) 171 | { 172 | var root = await document.GetSyntaxRootAsync(); 173 | 174 | if (root != null) 175 | { 176 | GetConstsFromSyntaxRoot(root, document.FilePath); 177 | } 178 | else 179 | { 180 | System.Diagnostics.Debug.WriteLine("No Syntax tree available for: " + document.FilePath); 181 | } 182 | } 183 | 184 | return result; 185 | } 186 | 187 | public static void GetConstsFromSyntaxRoot(SyntaxNode root, string filePath) 188 | { 189 | if (root == null || filePath == null) 190 | { 191 | return; 192 | } 193 | 194 | // Avoid parsing generated code. 195 | // Reduces overhead (as there may be lots) 196 | // Avoids assets included with Android projects. 197 | if (filePath.ToLowerInvariant().EndsWith(".designer.cs") 198 | || filePath.ToLowerInvariant().EndsWith(".g.cs") 199 | || filePath.ToLowerInvariant().EndsWith(".g.i.cs")) 200 | { 201 | return; 202 | } 203 | 204 | var toRemove = new List<(string, string, string, string)>(); 205 | 206 | foreach (var item in KnownConsts) 207 | { 208 | if (item.source == filePath) 209 | { 210 | toRemove.Add(item); 211 | } 212 | } 213 | 214 | foreach (var item in toRemove) 215 | { 216 | KnownConsts.Remove(item); 217 | } 218 | 219 | foreach (var vdec in root.DescendantNodes().OfType()) 220 | { 221 | if (vdec != null) 222 | { 223 | if (vdec.Parent != null && vdec.Parent is MemberDeclarationSyntax dec) 224 | { 225 | if (IsConst(dec)) 226 | { 227 | if (dec is FieldDeclarationSyntax fds) 228 | { 229 | var qualification = GetQualification(fds); 230 | 231 | foreach (var variable in fds.Declaration?.Variables) 232 | { 233 | if (variable.Initializer != null) 234 | { 235 | KnownConsts.Add( 236 | (variable.Identifier.Text, 237 | qualification, 238 | variable.Initializer.Value.ToString().Replace("\\\"", "\""), 239 | filePath)); 240 | } 241 | } 242 | } 243 | } 244 | } 245 | } 246 | } 247 | } 248 | 249 | public static string GetQualification(MemberDeclarationSyntax dec) 250 | { 251 | var result = string.Empty; 252 | var parent = dec.Parent; 253 | 254 | while (parent != null) 255 | { 256 | if (parent is ClassDeclarationSyntax cds) 257 | { 258 | result = $"{cds.Identifier.ValueText}.{result}"; 259 | parent = cds.Parent; 260 | } 261 | else if (parent is NamespaceDeclarationSyntax nds) 262 | { 263 | result = $"{nds.Name}.{result}"; 264 | parent = nds.Parent; 265 | } 266 | else 267 | { 268 | parent = parent.Parent; 269 | } 270 | } 271 | 272 | return result.TrimEnd('.'); 273 | } 274 | 275 | public static bool IsConst(SyntaxNode node) 276 | { 277 | return node.ChildTokens().Any(t => t.IsKind(SyntaxKind.ConstKeyword)); 278 | } 279 | 280 | internal static void Reset() 281 | { 282 | KnownConsts.Clear(); 283 | HasParsedSolution = false; 284 | } 285 | 286 | internal static string GetDisplayText(string constName, string qualifier, string fileName) 287 | { 288 | var constsInThisFile = 289 | KnownConsts.Where(c => c.source == fileName 290 | && c.key == constName 291 | && c.qualification.EndsWith(qualifier)).FirstOrDefault(); 292 | 293 | if (!string.IsNullOrWhiteSpace(constsInThisFile.value)) 294 | { 295 | return constsInThisFile.value; 296 | } 297 | 298 | var (_, _, value, _) = 299 | KnownConsts.Where(c => c.key == constName 300 | && c.qualification.EndsWith(qualifier)).FirstOrDefault(); 301 | 302 | if (!string.IsNullOrWhiteSpace(value)) 303 | { 304 | return value; 305 | } 306 | 307 | return string.Empty; 308 | } 309 | 310 | private static EnvDTE.Document SafeGetActiveDocument(EnvDTE.DTE dte) 311 | { 312 | try 313 | { 314 | // Some document types (inc. .csproj) throw an error when try and get the ActiveDocument 315 | // "The parameter is incorrect. (Exception from HRESULT: 0x80070057 (E_INVALIDARG))" 316 | return dte?.ActiveDocument; 317 | } 318 | catch (Exception exc) 319 | { 320 | System.Diagnostics.Debug.WriteLine(exc); 321 | } 322 | 323 | return null; 324 | } 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/Constants.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | namespace StringResourceVisualizer 6 | { 7 | public static class Constants 8 | { 9 | public const double TextBlockSizeToFontScaleFactor = 1.4; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/GlobalSuppressions.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | //// This file is used by Code Analysis to maintain SuppressMessage 6 | //// attributes that are applied to this project. 7 | //// Project-level suppressions either have no target or are given 8 | //// a specific target and scoped to a namespace, type, member, etc. 9 | 10 | using System.Diagnostics.CodeAnalysis; 11 | 12 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Developer preference")] 13 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented", Justification = "No value in documenting this.")] 14 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1615:Element return value should be documented", Justification = "Don't need this documentation.")] 15 | -------------------------------------------------------------------------------- /src/Messenger.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | namespace StringResourceVisualizer 6 | { 7 | public static class Messenger 8 | { 9 | public delegate void ReloadResourcesEventHandler(); 10 | 11 | public static event ReloadResourcesEventHandler ReloadResources; 12 | 13 | public static void RequestReloadResources() 14 | { 15 | System.Diagnostics.Debug.WriteLine("RequestReloadResources"); 16 | ReloadResources?.Invoke(); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/MyLineTransformSource.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using Microsoft.VisualStudio.Text.Editor; 6 | using Microsoft.VisualStudio.Text.Formatting; 7 | 8 | namespace StringResourceVisualizer 9 | { 10 | /// 11 | /// Resizes relevant lines in the editor. 12 | /// 13 | internal class MyLineTransformSource : ILineTransformSource 14 | { 15 | private readonly ResourceAdornmentManager manager; 16 | 17 | public MyLineTransformSource(ResourceAdornmentManager manager) 18 | { 19 | this.manager = manager; 20 | } 21 | 22 | LineTransform ILineTransformSource.GetLineTransform(ITextViewLine line, double yPosition, ViewRelativePosition placement) 23 | { 24 | int lineNumber = line.Snapshot.GetLineFromPosition(line.Start.Position).LineNumber; 25 | LineTransform lineTransform; 26 | 27 | if (this.manager.DisplayedTextBlocks.ContainsKey(lineNumber) 28 | && this.manager.DisplayedTextBlocks[lineNumber].Count > 0) 29 | { 30 | var spaceAboveLine = line.DefaultLineTransform.TopSpace + ((StringResVizPackage.Instance.Options.TopPadding + StringResVizPackage.Instance.Options.BottomPadding) * Constants.TextBlockSizeToFontScaleFactor); 31 | var spaceBelowLine = line.DefaultLineTransform.BottomSpace; 32 | lineTransform = new LineTransform(spaceAboveLine + ResourceAdornmentManager.TextSize, spaceBelowLine, 1.0); 33 | } 34 | else 35 | { 36 | lineTransform = new LineTransform(0, 0, 1.0); 37 | } 38 | 39 | return lineTransform; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/MyLineTransformSourceProvider.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System.ComponentModel.Composition; 6 | using Microsoft.VisualStudio.Text.Editor; 7 | using Microsoft.VisualStudio.Text.Formatting; 8 | using Microsoft.VisualStudio.Utilities; 9 | 10 | namespace StringResourceVisualizer 11 | { 12 | [Export(typeof(ILineTransformSourceProvider))] 13 | #pragma warning disable SA1133 // Do not combine attributes 14 | [ContentType("CSharp"), ContentType("Basic"), ContentType("Razor"), ContentType("RazorCSharp"), ContentType("RazorCoreCSharp")] 15 | #pragma warning restore SA1133 // Do not combine attributes 16 | [TextViewRole(PredefinedTextViewRoles.Document)] 17 | internal class MyLineTransformSourceProvider : ILineTransformSourceProvider 18 | { 19 | ILineTransformSource ILineTransformSourceProvider.Create(IWpfTextView view) 20 | { 21 | ResourceAdornmentManager manager = view.Properties.GetOrCreateSingletonProperty(() => new ResourceAdornmentManager(view)); 22 | return new MyLineTransformSource(manager); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/MyRunningDocTableEvents.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using Microsoft.VisualStudio; 6 | using Microsoft.VisualStudio.Shell; 7 | using Microsoft.VisualStudio.Shell.Interop; 8 | 9 | namespace StringResourceVisualizer 10 | { 11 | internal class MyRunningDocTableEvents : IVsRunningDocTableEvents 12 | { 13 | private static MyRunningDocTableEvents instance; 14 | 15 | private MyRunningDocTableEvents() 16 | { 17 | } 18 | 19 | public static MyRunningDocTableEvents Instance 20 | { 21 | get 22 | { 23 | if (instance == null) 24 | { 25 | instance = new MyRunningDocTableEvents(); 26 | } 27 | 28 | return instance; 29 | } 30 | } 31 | 32 | public int OnAfterFirstDocumentLock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining) 33 | { 34 | return VSConstants.S_OK; 35 | } 36 | 37 | public int OnBeforeLastDocumentUnlock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining) 38 | { 39 | return VSConstants.S_OK; 40 | } 41 | 42 | public int OnAfterSave(uint docCookie) 43 | { 44 | ThreadHelper.JoinableTaskFactory.Run(async () => await ConstFinder.ReloadConstsAsync()); 45 | 46 | return VSConstants.S_OK; 47 | } 48 | 49 | public int OnAfterAttributeChange(uint docCookie, uint grfAttribs) 50 | { 51 | return VSConstants.S_OK; 52 | } 53 | 54 | public int OnBeforeDocumentWindowShow(uint docCookie, int fFirstShow, IVsWindowFrame pFrame) 55 | { 56 | ThreadHelper.JoinableTaskFactory.Run(async () => await ConstFinder.ReloadConstsAsync()); 57 | 58 | return VSConstants.S_OK; 59 | } 60 | 61 | public int OnAfterDocumentWindowHide(uint docCookie, IVsWindowFrame pFrame) 62 | { 63 | return VSConstants.S_OK; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/OptionsGrid.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.ComponentModel; 7 | using Microsoft.VisualStudio.Shell; 8 | 9 | namespace StringResourceVisualizer 10 | { 11 | public class OptionsGrid : DialogPage 12 | { 13 | [Category("General")] 14 | [DisplayName("Preferred culture")] 15 | [Description("Specify a culture to use in preference to the default.")] 16 | public string PreferredCulture { get; set; } = string.Empty; 17 | 18 | [Category("General")] 19 | [DisplayName("Namespace alias support")] 20 | [Description("Check for namespace aliases that might refer to resources.")] 21 | public bool SupportNamespaceAliases { get; set; } = false; 22 | 23 | [Category("Alignment")] 24 | [DisplayName("Bottom padding")] 25 | [Description("Pixels to add below the displayed value.")] 26 | public int BottomPadding { get; set; } = 0; 27 | 28 | [Category("Alignment")] 29 | [DisplayName("Top padding")] 30 | [Description("Pixels to add above the displayed value.")] 31 | public int TopPadding { get; set; } = 1; 32 | 33 | [Category("Experimental")] 34 | [DisplayName("ASP.NET Core ILocalizer support")] 35 | [Description("Attempt to load and show resources used by ILocalizer.")] 36 | public bool SupportAspNetLocalizer { get; set; } = true; 37 | 38 | [Category("Experimental")] 39 | [DisplayName("Localizer Identifiers")] 40 | [Description("How to identify ILocalizer usages. Case insensitive. Separate multiple values with a semicolon.")] 41 | public string LocalizationIndicators { get; set; } = "localizer[;loc["; 42 | 43 | protected override void OnClosed(EventArgs e) 44 | { 45 | base.OnClosed(e); 46 | 47 | // Settings page has been closed. 48 | // Prompt to reload resources in case of changes. 49 | Messenger.RequestReloadResources(); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/OutputPane.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Threading; 7 | using Microsoft.VisualStudio; 8 | using Microsoft.VisualStudio.Shell; 9 | using Microsoft.VisualStudio.Shell.Interop; 10 | using Task = System.Threading.Tasks.Task; 11 | 12 | namespace StringResourceVisualizer 13 | { 14 | public class OutputPane 15 | { 16 | private static Guid dsPaneGuid = new Guid("C2E704BD-CCBB-4817-83EB-18422895EFC4"); 17 | 18 | private static OutputPane instance; 19 | 20 | private readonly IVsOutputWindowPane pane; 21 | 22 | private OutputPane() 23 | { 24 | ThreadHelper.ThrowIfNotOnUIThread(); 25 | 26 | if (ServiceProvider.GlobalProvider.GetService(typeof(SVsOutputWindow)) is IVsOutputWindow outWindow 27 | && (ErrorHandler.Failed(outWindow.GetPane(ref dsPaneGuid, out this.pane)) || this.pane == null)) 28 | { 29 | if (ErrorHandler.Failed(outWindow.CreatePane(ref dsPaneGuid, Vsix.Name, 1, 0))) 30 | { 31 | System.Diagnostics.Debug.WriteLine("Failed to create output pane."); 32 | return; 33 | } 34 | 35 | if (ErrorHandler.Failed(outWindow.GetPane(ref dsPaneGuid, out this.pane)) || (this.pane == null)) 36 | { 37 | System.Diagnostics.Debug.WriteLine("Failed to get output pane."); 38 | } 39 | } 40 | } 41 | 42 | public static OutputPane Instance => instance ?? (instance = new OutputPane()); 43 | 44 | public async Task ActivateAsync() 45 | { 46 | await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(CancellationToken.None); 47 | 48 | this.pane?.Activate(); 49 | } 50 | 51 | public async Task WriteAsync(string message) 52 | { 53 | await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(CancellationToken.None); 54 | 55 | this.pane?.OutputStringThreadSafe($"{message}{Environment.NewLine}"); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System.Reflection; 6 | using System.Runtime.InteropServices; 7 | using StringResourceVisualizer; 8 | 9 | // General Information about an assembly is controlled through the following 10 | // set of attributes. Change these attribute values to modify the information 11 | // associated with an assembly. 12 | [assembly: AssemblyTitle("StringResourceVisualizer")] 13 | [assembly: AssemblyDescription("")] 14 | [assembly: AssemblyConfiguration("")] 15 | [assembly: AssemblyCompany("Matt Lacey Ltd.")] 16 | [assembly: AssemblyProduct("StringResourceVisualizer")] 17 | [assembly: AssemblyCopyright("Copyright © Matt Lacey Ltd. 2022")] 18 | [assembly: AssemblyTrademark("")] 19 | [assembly: AssemblyCulture("")] 20 | 21 | // Setting ComVisible to false makes the types in this assembly not visible 22 | // to COM components. If you need to access a type in this assembly from 23 | // COM, set the ComVisible attribute to true on that type. 24 | [assembly: ComVisible(false)] 25 | 26 | // Version information for an assembly consists of the following four values: 27 | // 28 | // Major Version 29 | // Minor Version 30 | // Build Number 31 | // Revision 32 | // 33 | // You can specify all the values or you can default the Build and Revision Numbers 34 | // by using the '*' as shown below: 35 | // [assembly: AssemblyVersion("1.0.*")] 36 | [assembly: AssemblyVersion(Vsix.Version)] 37 | [assembly: AssemblyFileVersion(Vsix.Version)] 38 | -------------------------------------------------------------------------------- /src/ResourceAdornmentManager.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Diagnostics; 8 | using System.IO; 9 | using System.Linq; 10 | using System.Threading.Tasks; 11 | using System.Windows; 12 | using System.Windows.Controls; 13 | using System.Windows.Media; 14 | using System.Xml; 15 | using Microsoft.VisualStudio.Shell; 16 | using Microsoft.VisualStudio.Shell.Interop; 17 | using Microsoft.VisualStudio.Text; 18 | using Microsoft.VisualStudio.Text.Editor; 19 | using Microsoft.VisualStudio.Text.Formatting; 20 | using Microsoft.VisualStudio.TextManager.Interop; 21 | using Microsoft.VisualStudio.Threading; 22 | using Task = System.Threading.Tasks.Task; 23 | 24 | namespace StringResourceVisualizer 25 | { 26 | /// 27 | /// Important class. Handles creation of adornments on appropriate lines. 28 | /// 29 | public class ResourceAdornmentManager : IDisposable 30 | { 31 | private readonly IAdornmentLayer layer; 32 | private readonly IWpfTextView view; 33 | private readonly string fileName; 34 | private readonly List<(string alias, int lineNo, string resName)> aliases = new List<(string alias, int lineNo, string resName)>(); 35 | private bool hasDoneInitialCreateVisualsPass = false; 36 | 37 | public ResourceAdornmentManager(IWpfTextView view) 38 | { 39 | this.view = view; 40 | this.layer = view.GetAdornmentLayer("StringResourceCommentLayer"); 41 | 42 | ThreadHelper.ThrowIfNotOnUIThread(); 43 | 44 | if (StringResVizPackage.Instance == null) 45 | { 46 | // Try and force load the project if it hasn't already loaded 47 | // so can access the configured options. 48 | if (ServiceProvider.GlobalProvider.GetService(typeof(SVsShell)) is IVsShell shell) 49 | { 50 | // IVsPackage package = null; 51 | Guid packageToBeLoadedGuid = new Guid(StringResVizPackage.PackageGuidString); 52 | shell.LoadPackage(ref packageToBeLoadedGuid, out _); 53 | } 54 | } 55 | 56 | this.fileName = this.GetFileName(view.TextBuffer); 57 | 58 | this.view.LayoutChanged += this.LayoutChangedHandler; 59 | } 60 | 61 | public static List ResourceFiles { get; set; } = new List(); 62 | 63 | public static List SearchValues { get; set; } = new List(); 64 | 65 | public static List<(string path, XmlDocument xDoc)> XmlDocs { get; private set; } = new List<(string path, XmlDocument xDoc)>(); 66 | 67 | public static bool ResourcesLoaded { get; private set; } 68 | 69 | // Initialize to the same default as VS 70 | public static uint TextSize { get; set; } = 10; 71 | 72 | // Initialize to a reasonable value for display on light or dark themes/background. 73 | public static Color TextForegroundColor { get; set; } = Colors.Gray; 74 | 75 | public static FileSystemWatcher ResxWatcher { get; private set; } = new FileSystemWatcher(); 76 | 77 | public static string PreferredCulture { get; private set; } 78 | 79 | public static bool SupportAspNetLocalizer { get; private set; } 80 | 81 | public static bool SupportNamespaceAliases { get; private set; } 82 | 83 | public static string LocalizationIndicators { get; private set; } 84 | 85 | // Keep a record of displayed text blocks so we can remove them as soon as changed or no longer appropriate 86 | // Also use this to identify lines to pad so the textblocks can be seen 87 | public Dictionary> DisplayedTextBlocks { get; set; } = new Dictionary>(); 88 | 89 | public static void ClearCache() 90 | { 91 | ResourcesLoaded = false; 92 | 93 | ResourceFiles.Clear(); 94 | SearchValues.Clear(); 95 | XmlDocs.Clear(); 96 | } 97 | 98 | public static async Task LoadResourcesAsync(List resxFilesOfInterest, string slnDirectory, OptionsGrid options) 99 | { 100 | await TaskScheduler.Default; 101 | 102 | ClearCache(); 103 | 104 | // Store this as will need it when looking up which text to use in the adornment. 105 | PreferredCulture = options.PreferredCulture; 106 | SupportAspNetLocalizer = options.SupportAspNetLocalizer; 107 | SupportNamespaceAliases = options.SupportNamespaceAliases; 108 | LocalizationIndicators = options.LocalizationIndicators; 109 | 110 | foreach (var resourceFile in resxFilesOfInterest) 111 | { 112 | await Task.Yield(); 113 | 114 | string extraExceptionDetails = string.Empty; 115 | 116 | try 117 | { 118 | extraExceptionDetails = " - before load XmlDocument"; 119 | 120 | var doc = new XmlDocument(); 121 | doc.Load(resourceFile); 122 | 123 | extraExceptionDetails = " - after load XmlDocument"; 124 | 125 | XmlDocs.Add((resourceFile, doc)); 126 | 127 | extraExceptionDetails = " - after add to XmlDocs"; 128 | 129 | ResourceFiles.Add(resourceFile); 130 | 131 | extraExceptionDetails = " - after add to ResourceFiles"; 132 | 133 | var searchTerm = $"{Path.GetFileNameWithoutExtension(resourceFile)}."; 134 | 135 | if (!string.IsNullOrWhiteSpace(PreferredCulture) 136 | && searchTerm.EndsWith($"{PreferredCulture}.", StringComparison.InvariantCultureIgnoreCase)) 137 | { 138 | searchTerm = searchTerm.Substring(0, searchTerm.Length - PreferredCulture.Length - 1); 139 | } 140 | 141 | extraExceptionDetails = $" - searchTerm = '{searchTerm}'"; 142 | 143 | if (!SearchValues.Contains(searchTerm)) 144 | { 145 | extraExceptionDetails = " - about to add to SearchValues"; 146 | SearchValues.Add(searchTerm); 147 | extraExceptionDetails = " - after add to SearchValues"; 148 | } 149 | else 150 | { 151 | extraExceptionDetails = " - didn't add to SearchValues"; 152 | } 153 | } 154 | catch (Exception e) 155 | { 156 | await OutputPane.Instance?.WriteAsync($"Error loading resources from: {resourceFile}"); 157 | await OutputPane.Instance?.WriteAsync(extraExceptionDetails); 158 | await OutputPane.Instance?.WriteAsync(e.Message); 159 | await OutputPane.Instance?.WriteAsync(e.Source); 160 | await OutputPane.Instance?.WriteAsync(e.StackTrace); 161 | } 162 | } 163 | 164 | if (resxFilesOfInterest.Any()) 165 | { 166 | // Need to track changed and renamed events as VS doesn't do a direct overwrite but makes a temp file of the new version and then renames both files. 167 | // Changed event will also pick up changes made by extensions or programs other than VS. 168 | ResxWatcher.Filter = "*.resx"; 169 | ResxWatcher.Path = slnDirectory; 170 | ResxWatcher.IncludeSubdirectories = true; 171 | ResxWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; 172 | ResxWatcher.Changed -= ResxWatcher_Changed; 173 | ResxWatcher.Changed += ResxWatcher_Changed; 174 | ResxWatcher.Renamed -= ResxWatcher_Renamed; 175 | ResxWatcher.Renamed += ResxWatcher_Renamed; 176 | ResxWatcher.EnableRaisingEvents = true; 177 | } 178 | else 179 | { 180 | ResxWatcher.EnableRaisingEvents = false; 181 | ResxWatcher.Changed -= ResxWatcher_Changed; 182 | ResxWatcher.Renamed -= ResxWatcher_Renamed; 183 | } 184 | 185 | ResourcesLoaded = true; 186 | } 187 | 188 | public static string FormatDisplayText(string resourceString) 189 | { 190 | var result = resourceString; 191 | 192 | if (result != null) 193 | { 194 | var returnIndex = result.IndexOfAny(new[] { '\r', '\n' }); 195 | 196 | if (returnIndex == 0) 197 | { 198 | result = result.TrimStart(' ', '\r', '\n'); 199 | returnIndex = result.IndexOfAny(new[] { '\r', '\n' }); 200 | 201 | if (returnIndex >= 0) 202 | { 203 | // Truncate at first wrapping character and add "Return Character" to indicate truncation 204 | result = "⏎" + result.Substring(0, returnIndex) + "⏎"; 205 | } 206 | else 207 | { 208 | result = "⏎" + result; 209 | } 210 | } 211 | else if (returnIndex > 0) 212 | { 213 | // Truncate at first wrapping character and add "Return Character" to indicate truncation 214 | result = result.Substring(0, returnIndex) + "⏎"; 215 | } 216 | } 217 | 218 | return result; 219 | } 220 | 221 | public string GetFileName(ITextBuffer textBuffer) 222 | { 223 | Microsoft.VisualStudio.Shell.ThreadHelper.ThrowIfNotOnUIThread(); 224 | 225 | var rc = textBuffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument textDoc); 226 | 227 | if (rc == true) 228 | { 229 | return textDoc.FilePath; 230 | } 231 | else 232 | { 233 | rc = textBuffer.Properties.TryGetProperty(typeof(IVsTextBuffer), out IVsTextBuffer vsTextBuffer); 234 | 235 | if (rc) 236 | { 237 | if (vsTextBuffer is IPersistFileFormat persistFileFormat) 238 | { 239 | persistFileFormat.GetCurFile(out string filePath, out _); 240 | return filePath; 241 | } 242 | } 243 | 244 | return null; 245 | } 246 | } 247 | 248 | /// 249 | /// This is called by the TextView when closing. Events are unsubscribed here. 250 | /// 251 | /// 252 | /// It's actually called twice - once by the IPropertyOwner instance, and again by the ITagger instance. 253 | /// 254 | public void Dispose() => this.UnsubscribeFromViewerEvents(); 255 | 256 | #pragma warning disable VSTHRD100 // Avoid async void methods 257 | private static async void ResxWatcher_Renamed(object sender, RenamedEventArgs e) 258 | { 259 | try 260 | { 261 | // Don't want to know about files being named from .resx to something else 262 | if (e.FullPath.EndsWith(".resx")) 263 | { 264 | await ReloadResourceFileAsync(e.FullPath); 265 | } 266 | } 267 | catch (Exception exc) 268 | { 269 | await OutputPane.Instance?.WriteAsync("Unexpected error when resx file renamed."); 270 | await OutputPane.Instance?.WriteAsync(exc.Message); 271 | await OutputPane.Instance?.WriteAsync(exc.StackTrace); 272 | } 273 | } 274 | 275 | private static async void ResxWatcher_Changed(object sender, FileSystemEventArgs e) 276 | { 277 | try 278 | { 279 | await ReloadResourceFileAsync(e.FullPath); 280 | } 281 | catch (Exception exc) 282 | { 283 | await OutputPane.Instance?.WriteAsync("Unexpected error when resx file changed."); 284 | await OutputPane.Instance?.WriteAsync(exc.Message); 285 | await OutputPane.Instance?.WriteAsync(exc.StackTrace); 286 | } 287 | } 288 | #pragma warning restore VSTHRD100 // Avoid async void methods 289 | 290 | private static async Task ReloadResourceFileAsync(string filePath) 291 | { 292 | await OutputPane.Instance?.WriteAsync($"(Re)loading {filePath}"); 293 | 294 | const int maxAttemptCount = 5; 295 | const int baseWaitPeriod = 250; 296 | 297 | ResourcesLoaded = false; 298 | 299 | for (var i = 0; i < XmlDocs.Count; i++) 300 | { 301 | var (path, _) = XmlDocs[i]; 302 | 303 | if (path == filePath) 304 | { 305 | // File may still be locked after being moved/renamed/updated 306 | // Allow for retry after delay with back-off. 307 | for (var attempted = 0; attempted < maxAttemptCount; attempted++) 308 | { 309 | try 310 | { 311 | if (attempted > 0) 312 | { 313 | await Task.Delay(attempted * baseWaitPeriod); 314 | } 315 | 316 | var doc = new XmlDocument(); 317 | doc.Load(filePath); 318 | 319 | XmlDocs[i] = (path, doc); 320 | } 321 | catch (Exception ex) 322 | { 323 | // If never load the changed file just stick with the previously loaded version. 324 | // Hopefully get updated version after next change. 325 | Debug.WriteLine(ex); 326 | } 327 | } 328 | 329 | break; 330 | } 331 | } 332 | 333 | ResourcesLoaded = true; 334 | } 335 | 336 | /// 337 | /// On layout change add the adornment to any reformatted lines. 338 | /// 339 | #pragma warning disable VSTHRD100 // Avoid async void methods 340 | private async void LayoutChangedHandler(object sender, TextViewLayoutChangedEventArgs e) 341 | #pragma warning restore VSTHRD100 // Avoid async void methods 342 | { 343 | if (ResourcesLoaded) 344 | { 345 | var collection = this.hasDoneInitialCreateVisualsPass ? (IEnumerable)e.NewOrReformattedLines : this.view.TextViewLines; 346 | 347 | foreach (ITextViewLine line in collection) 348 | { 349 | int lineNumber = line.Snapshot.GetLineFromPosition(line.Start.Position).LineNumber; 350 | 351 | try 352 | { 353 | await this.CreateVisualsAsync(line, lineNumber); 354 | } 355 | catch (InvalidOperationException ex) 356 | { 357 | await OutputPane.Instance?.WriteAsync("Error handling layout changed"); 358 | await OutputPane.Instance?.WriteAsync(ex.Message); 359 | await OutputPane.Instance?.WriteAsync(ex.Source); 360 | await OutputPane.Instance?.WriteAsync(ex.StackTrace); 361 | } 362 | 363 | this.hasDoneInitialCreateVisualsPass = true; 364 | } 365 | } 366 | } 367 | 368 | /// 369 | /// Scans text line for use of resource class, then adds new adornment. 370 | /// 371 | private async Task CreateVisualsAsync(ITextViewLine line, int lineNumber) 372 | { 373 | string localizerIndicator = string.Empty; 374 | 375 | string GetDisplayTextFromDoc(XmlDocument xDoc, string key) 376 | { 377 | string result = null; 378 | 379 | foreach (XmlElement element in xDoc.GetElementsByTagName("data")) 380 | { 381 | if (element.GetAttribute("name") == key) 382 | { 383 | var valueElement = element.GetElementsByTagName("value").Item(0); 384 | result = FormatDisplayText(valueElement?.InnerText); 385 | 386 | break; 387 | } 388 | } 389 | 390 | return result; 391 | } 392 | 393 | // TODO: Cache text retrieved from the resource file based on fileName and key. - Invalidate the cache when reload resource files. This will save querying the XMLDocument each time. 394 | try 395 | { 396 | if (!ResourceFiles.Any()) 397 | { 398 | // If there are no known resource files then there's no point doing anything that follows. 399 | return; 400 | } 401 | 402 | string lineText = line.Extent.GetText(); 403 | 404 | // The extent will include all of a collapsed section 405 | if (lineText.Contains(Environment.NewLine)) 406 | { 407 | // We only want the first "line" here as that's all that can be seen on screen 408 | lineText = lineText.Substring(0, lineText.IndexOf(Environment.NewLine, StringComparison.InvariantCultureIgnoreCase)); 409 | } 410 | 411 | string[] searchArray = SearchValues.ToArray(); 412 | 413 | if (SupportNamespaceAliases) 414 | { 415 | if (lineText.StartsWith("using ") & lineText.Contains(" = ")) 416 | { 417 | // If a line with a known alias has changed forget everything we know about aliases and reload them all. 418 | // Edge cases may temporarily be lost at this point but only if multiple aliases are specified in a file and there are many lones between them. 419 | if (this.aliases.Any(a => a.lineNo == lineNumber)) 420 | { 421 | this.aliases.Clear(); 422 | } 423 | 424 | foreach (var searchTerm in SearchValues) 425 | { 426 | if (lineText.Trim().EndsWith($".{searchTerm.Replace(".", string.Empty)};")) 427 | { 428 | // 6 = "using ".Length 429 | var alias = lineText.Substring(6, lineText.IndexOf(" = ") - 6).Trim(); 430 | 431 | // Add the dot here as it will save adding it for each line when look for usage. 432 | this.aliases.Add(($"{alias}.", lineNumber, searchTerm)); 433 | } 434 | } 435 | } 436 | 437 | searchArray = SearchValues.Concat(this.aliases.Select(a => a.alias).ToList()).ToArray(); 438 | } 439 | 440 | // Remove any textblocks displayed on this line so it won't conflict with anything we add below. 441 | // Handles no textblocks to show or the text to display having changed. 442 | if (this.DisplayedTextBlocks.ContainsKey(lineNumber)) 443 | { 444 | foreach (var (textBlock, _) in this.DisplayedTextBlocks[lineNumber]) 445 | { 446 | this.layer.RemoveAdornment(textBlock); 447 | } 448 | 449 | this.DisplayedTextBlocks.Remove(lineNumber); 450 | } 451 | 452 | var indexes = await lineText.GetAllIndexesAsync(searchArray); 453 | 454 | List localizerIndexes = new List(); 455 | 456 | if (SupportAspNetLocalizer) 457 | { 458 | if (!string.IsNullOrWhiteSpace(LocalizationIndicators)) 459 | { 460 | foreach (var posIndicator in LocalizationIndicators.Split(';')) 461 | { 462 | if (!string.IsNullOrWhiteSpace(posIndicator)) 463 | { 464 | localizerIndexes = await lineText.GetAllIndexesCaseInsensitiveAsync(posIndicator); 465 | 466 | if (localizerIndexes.Any()) 467 | { 468 | localizerIndicator = posIndicator; 469 | break; 470 | } 471 | } 472 | } 473 | } 474 | 475 | indexes.AddRange(localizerIndexes); 476 | } 477 | else 478 | { 479 | localizerIndexes = new List(); 480 | } 481 | 482 | if (indexes.Any()) 483 | { 484 | var lastLeft = double.NaN; 485 | 486 | // Reverse the list to can go through them right-to-left so know if there's anything that might overlap 487 | indexes.Reverse(); 488 | 489 | var sw = new System.Diagnostics.Stopwatch(); 490 | sw.Start(); 491 | 492 | foreach (var matchIndex in indexes) 493 | { 494 | int endPos = -1; 495 | string foundText = string.Empty; 496 | string displayText = null; 497 | 498 | // If the localizer setting isn't enabled this definitely wont match. 499 | if (localizerIndexes.Contains(matchIndex)) 500 | { 501 | var lineSearchStart = matchIndex + localizerIndicator.Length; 502 | 503 | var locClosingPos = lineText.IndexOf(']', lineSearchStart); 504 | 505 | var locKey = lineText.Substring(lineSearchStart, locClosingPos - lineSearchStart); 506 | 507 | if (locKey.StartsWith("\"")) 508 | { 509 | var closingQuotePos = lineText.IndexOf('"', lineSearchStart + 1); 510 | 511 | if (closingQuotePos > -1) 512 | { 513 | foundText = lineText.Substring(lineSearchStart + 1, closingQuotePos - lineSearchStart - 1); 514 | } 515 | } 516 | else 517 | { 518 | // key is a constant so need to look up the value 519 | var lastDot = locKey.LastIndexOf('.'); 520 | 521 | var qualifier = string.Empty; 522 | var constName = locKey; 523 | 524 | if (lastDot >= 0) 525 | { 526 | qualifier = locKey.Substring(0, lastDot); 527 | constName = locKey.Substring(lastDot + 1); 528 | } 529 | 530 | foundText = ConstFinder.GetDisplayText(constName, qualifier, this.fileName).Trim('"'); 531 | } 532 | 533 | if (!string.IsNullOrEmpty(foundText)) 534 | { 535 | foreach (var xDoc in this.GetLocalizerDocsOfInterest(this.fileName, XmlDocs, PreferredCulture)) 536 | { 537 | displayText = GetDisplayTextFromDoc(xDoc, foundText); 538 | 539 | if (!string.IsNullOrWhiteSpace(displayText)) 540 | { 541 | break; 542 | } 543 | } 544 | } 545 | } 546 | else 547 | { 548 | endPos = lineText.IndexOfAny(new[] { ' ', '.', ',', '"', '(', ')', '{', '}', ';' }, lineText.IndexOf('.', matchIndex) + 1); 549 | 550 | foundText = endPos > matchIndex 551 | ? lineText.Substring(matchIndex, endPos - matchIndex) 552 | : lineText.Substring(matchIndex); 553 | 554 | var resourceName = foundText.Substring(foundText.IndexOf('.') + 1); 555 | var fileBaseName = foundText.Substring(0, foundText.IndexOf('.')); 556 | 557 | if (SupportNamespaceAliases) 558 | { 559 | // Look for alias use 560 | var alias = this.aliases.FirstOrDefault(a => a.alias == $"{fileBaseName}."); 561 | 562 | if (alias.resName != null) 563 | { 564 | // Substitute for the value the alias represents. 565 | fileBaseName = alias.resName.Replace(".", string.Empty); 566 | } 567 | } 568 | 569 | foreach (var (_, xDoc) in this.GetDocsOfInterest(fileBaseName, XmlDocs, PreferredCulture)) 570 | { 571 | displayText = GetDisplayTextFromDoc(xDoc, resourceName); 572 | 573 | if (!string.IsNullOrWhiteSpace(displayText)) 574 | { 575 | break; 576 | } 577 | } 578 | } 579 | 580 | if (!this.DisplayedTextBlocks.ContainsKey(lineNumber)) 581 | { 582 | this.DisplayedTextBlocks.Add(lineNumber, new List<(TextBlock textBlock, string resName)>()); 583 | } 584 | 585 | if (!string.IsNullOrWhiteSpace(displayText) && TextSize > 0) 586 | { 587 | var brush = new SolidColorBrush(TextForegroundColor); 588 | brush.Freeze(); 589 | 590 | var height = (TextSize * Constants.TextBlockSizeToFontScaleFactor) + StringResVizPackage.Instance?.Options.TopPadding ?? 0 + StringResVizPackage.Instance?.Options.BottomPadding ?? 0; 591 | 592 | var tb = new TextBlock 593 | { 594 | Foreground = brush, 595 | Text = $"\"{displayText}\"", 596 | FontSize = TextSize, 597 | Height = height, 598 | VerticalAlignment = VerticalAlignment.Top, 599 | Padding = new Thickness(0, StringResVizPackage.Instance?.Options.TopPadding ?? 0, 0, 0), 600 | }; 601 | 602 | this.DisplayedTextBlocks[lineNumber].Add((tb, foundText)); 603 | 604 | // Get coordinates of text 605 | int start = line.Extent.Start.Position + matchIndex; 606 | int end = line.Start + (line.Extent.Length - 1); 607 | var span = new SnapshotSpan(this.view.TextSnapshot, Span.FromBounds(start, end)); 608 | var lineGeometry = this.view.TextViewLines.GetMarkerGeometry(span); 609 | 610 | if (!double.IsNaN(lastLeft)) 611 | { 612 | tb.MaxWidth = lastLeft - lineGeometry.Bounds.Left - 5; // Minus 5 for padding 613 | tb.TextTrimming = TextTrimming.CharacterEllipsis; 614 | } 615 | 616 | Canvas.SetLeft(tb, lineGeometry.Bounds.Left); 617 | Canvas.SetTop(tb, line.TextTop - tb.Height); 618 | 619 | lastLeft = lineGeometry.Bounds.Left; 620 | 621 | this.layer.AddAdornment(AdornmentPositioningBehavior.TextRelative, line.Extent, tag: null, adornment: tb, removedCallback: null); 622 | } 623 | } 624 | 625 | sw.Stop(); 626 | if (sw.Elapsed > TimeSpan.FromSeconds(1)) 627 | { 628 | await OutputPane.Instance.WriteAsync($"Getting text to display took longer than expected: {sw.ElapsedMilliseconds} milliseconds"); 629 | } 630 | } 631 | } 632 | catch (Exception ex) 633 | { 634 | await OutputPane.Instance?.WriteAsync("Error creating visuals"); 635 | await OutputPane.Instance?.WriteAsync(ex.Message); 636 | await OutputPane.Instance?.WriteAsync(ex.Source); 637 | await OutputPane.Instance?.WriteAsync(ex.StackTrace); 638 | } 639 | } 640 | 641 | private IEnumerable<(string path, XmlDocument xDoc)> GetDocsOfInterest(string resourceBaseName, List<(string path, XmlDocument xDoc)> xmlDocs, string preferredCulture) 642 | { 643 | // As may be multiple resource files, only check the ones which have the correct name. 644 | // If multiple projects in the solutions with same resource name (file & name), but different res value, the wrong value *may* be displayed 645 | // Get preferred cutlure files first 646 | if (!string.IsNullOrWhiteSpace(preferredCulture)) 647 | { 648 | var cultureDocs = xmlDocs.Where(x => Path.GetFileNameWithoutExtension(x.path).Equals($"{resourceBaseName}.{preferredCulture}", StringComparison.InvariantCultureIgnoreCase)); 649 | foreach (var item in cultureDocs) 650 | { 651 | yield return item; 652 | } 653 | } 654 | 655 | // Then default resource files 656 | var defaultResources = xmlDocs.Where(x => Path.GetFileNameWithoutExtension(x.path).Equals(resourceBaseName)); 657 | foreach (var item in defaultResources) 658 | { 659 | yield return item; 660 | } 661 | } 662 | 663 | /// 664 | /// Get list of resource docs that are most likely to match based on naming & folder structure. 665 | /// 666 | private IEnumerable GetLocalizerDocsOfInterest(string filePathName, List<(string path, XmlDocument xDoc)> xmlDocs, string preferredCulture) 667 | { 668 | var filteredXmlDocs = new List<(string resPath, string codePath, XmlDocument xDoc)>(); 669 | 670 | // Rationalize paths that may be based on folders or dotted file names by converting dots to directory separators and treat all as folders 671 | // strip matching starts and then do a reverse match on the rationalized paths 672 | // - match preferred culture then no culture at each level 673 | (string, string) StripMatchingStart(string resPath, string codePath) 674 | { 675 | var rp = resPath.Substring(0, resPath.LastIndexOf('.')); 676 | var cp = codePath.Substring(0, codePath.LastIndexOf('.')); 677 | 678 | var maxLen = Math.Min(rp.Length, cp.Length); 679 | 680 | var sameLength = 0; 681 | 682 | for (int i = 0; i < maxLen; i++) 683 | { 684 | if (rp[i] != cp[i]) 685 | { 686 | sameLength = i; 687 | break; 688 | } 689 | } 690 | 691 | return (rp.Substring(sameLength).Replace('.', '\\'), cp.Substring(sameLength).Replace('.', '\\')); 692 | } 693 | 694 | var uniqueRelativeCodePaths = new List(); 695 | 696 | foreach (var (xdocPath, fxDoc) in xmlDocs) 697 | { 698 | var (stripedResPath, strippedCodePath) = StripMatchingStart(xdocPath, filePathName); 699 | 700 | if (!uniqueRelativeCodePaths.Contains(strippedCodePath)) 701 | { 702 | uniqueRelativeCodePaths.Add(strippedCodePath); 703 | } 704 | 705 | filteredXmlDocs.Add((stripedResPath, strippedCodePath, fxDoc)); 706 | } 707 | 708 | var rawName = Path.GetFileNameWithoutExtension(filePathName); 709 | 710 | var pathsOfDocsReturned = new List(); 711 | 712 | foreach (var rationalizedCodePath in uniqueRelativeCodePaths) 713 | { 714 | var rawParts = rationalizedCodePath.Split('\\'); 715 | 716 | for (int i = rawParts.Count() - 1; i > 0; i--) 717 | { 718 | var withCultureSuffix = Path.Combine(string.Join("\\", rawParts.Take(i)), rawName, preferredCulture); 719 | 720 | var wcs = filteredXmlDocs.FirstOrDefault((r) => r.resPath.Equals(withCultureSuffix, StringComparison.InvariantCultureIgnoreCase) 721 | || r.resPath.Substring(r.resPath.IndexOf("\\") + 1).Equals(withCultureSuffix, StringComparison.InvariantCultureIgnoreCase)); 722 | 723 | if (!string.IsNullOrWhiteSpace(wcs.resPath)) 724 | { 725 | pathsOfDocsReturned.Add(wcs.resPath); 726 | yield return wcs.xDoc; 727 | } 728 | 729 | var withCultureFolder = Path.Combine(string.Join("\\", rawParts.Take(i)), preferredCulture, rawName); 730 | 731 | var wcf = filteredXmlDocs.FirstOrDefault((r) => r.resPath.Equals(withCultureFolder, StringComparison.InvariantCultureIgnoreCase) 732 | || r.resPath.Substring(r.resPath.IndexOf("\\") + 1).Equals(withCultureFolder, StringComparison.InvariantCultureIgnoreCase)); 733 | 734 | if (!string.IsNullOrWhiteSpace(wcf.resPath)) 735 | { 736 | pathsOfDocsReturned.Add(wcf.resPath); 737 | yield return wcf.xDoc; 738 | } 739 | 740 | var withoutCulture = Path.Combine(string.Join("\\", rawParts.Take(i)), rawName); 741 | 742 | var wc = filteredXmlDocs.FirstOrDefault((r) => r.resPath.Equals(withoutCulture, StringComparison.InvariantCultureIgnoreCase) 743 | || r.resPath.Substring(r.resPath.IndexOf("\\") + 1).Equals(withoutCulture, StringComparison.InvariantCultureIgnoreCase)); 744 | 745 | if (!string.IsNullOrWhiteSpace(wc.resPath)) 746 | { 747 | pathsOfDocsReturned.Add(wc.resPath); 748 | yield return wc.xDoc; 749 | } 750 | } 751 | } 752 | 753 | // In case we haven't found the relevant file above (acutal file naming doesn't match expectations), 754 | // return the others to avoid not finding it at all. 755 | foreach (var fxd in filteredXmlDocs) 756 | { 757 | if (!pathsOfDocsReturned.Contains(fxd.resPath)) 758 | { 759 | yield return fxd.xDoc; 760 | } 761 | } 762 | } 763 | 764 | private void UnsubscribeFromViewerEvents() 765 | { 766 | this.view.LayoutChanged -= this.LayoutChangedHandler; 767 | } 768 | } 769 | } 770 | -------------------------------------------------------------------------------- /src/ResourceAdornmentManagerFactory.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System.ComponentModel.Composition; 6 | using Microsoft.VisualStudio.Text.Editor; 7 | using Microsoft.VisualStudio.Utilities; 8 | 9 | namespace StringResourceVisualizer 10 | { 11 | /// 12 | /// Establishes an to place the adornment on and exports the 13 | /// that instantiates the adornment on the event of a 's creation. 14 | /// 15 | [Export(typeof(IWpfTextViewCreationListener))] 16 | #pragma warning disable SA1133 // Do not combine attributes 17 | [ContentType("CSharp"), ContentType("Basic"), ContentType("Razor"), ContentType("RazorCSharp"), ContentType("RazorCoreCSharp")] 18 | #pragma warning restore SA1133 // Do not combine attributes 19 | [TextViewRole(PredefinedTextViewRoles.Document)] 20 | internal sealed class ResourceAdornmentManagerFactory : IWpfTextViewCreationListener 21 | { 22 | /// 23 | /// Defines the adornment layer for the adornment. 24 | /// 25 | [Export(typeof(AdornmentLayerDefinition))] 26 | [Name("StringResourceCommentLayer")] 27 | [Order(After = PredefinedAdornmentLayers.Selection, Before = PredefinedAdornmentLayers.Text)] 28 | [TextViewRole(PredefinedTextViewRoles.Document)] 29 | #pragma warning disable SA1401 // Fields should be private 30 | #pragma warning disable SA1307 // Accessible fields should begin with upper-case letter 31 | public AdornmentLayerDefinition editorAdornmentLayer = null; 32 | #pragma warning restore SA1307 // Accessible fields should begin with upper-case letter 33 | #pragma warning restore SA1401 // Fields should be private 34 | 35 | /// 36 | /// Instantiates a ResourceAdornment manager when a textView is created. 37 | /// 38 | /// The upon which the adornment should be placed. 39 | public void TextViewCreated(IWpfTextView textView) 40 | { 41 | textView.Properties.GetOrCreateSingletonProperty(() => new ResourceAdornmentManager(textView)); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlacey/StringResourceVisualizer/ee1f1bfdae997955630b6b8bbf7f7c6669517879/src/Resources/Icon.png -------------------------------------------------------------------------------- /src/SponsorDetector.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System.Threading.Tasks; 6 | 7 | namespace StringResourceVisualizer 8 | { 9 | public class SponsorDetector 10 | { 11 | // This might be the code you see, but it's not what I compile into the extensions when built ;) 12 | public static async Task IsSponsorAsync() 13 | { 14 | return await Task.FromResult(false); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/SponsorRequestHelper.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System; 6 | using Microsoft.VisualStudio.Shell; 7 | using Task = System.Threading.Tasks.Task; 8 | 9 | namespace StringResourceVisualizer 10 | { 11 | public class SponsorRequestHelper 12 | { 13 | public static async Task CheckIfNeedToShowAsync() 14 | { 15 | if (await SponsorDetector.IsSponsorAsync()) 16 | { 17 | if (new Random().Next(1, 10) == 2) 18 | { 19 | await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); 20 | await ShowThanksForSponsorshipMessageAsync(); 21 | } 22 | } 23 | else 24 | { 25 | await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); 26 | await ShowPromptForSponsorshipAsync(); 27 | } 28 | } 29 | 30 | private static async Task ShowThanksForSponsorshipMessageAsync() 31 | { 32 | await OutputPane.Instance.WriteAsync("Thank you for your sponsorship. It really helps."); 33 | await OutputPane.Instance.WriteAsync("If you have ideas for new features or suggestions for new features"); 34 | await OutputPane.Instance.WriteAsync("please raise an issue at https://github.com/mrlacey/StringResourceVisualizer/issues"); 35 | await OutputPane.Instance.WriteAsync(string.Empty); 36 | } 37 | 38 | private static async Task ShowPromptForSponsorshipAsync() 39 | { 40 | await OutputPane.Instance.WriteAsync("********************************************************************************************************"); 41 | await OutputPane.Instance.WriteAsync("This is a free extension that is made possible thanks to the kind and generous donations of:"); 42 | await OutputPane.Instance.WriteAsync(""); 43 | await OutputPane.Instance.WriteAsync("Daniel, James, Mike, Bill, unicorns39283, Martin, Richard, Alan, Howard, Mike, Dave, Joe, "); 44 | await OutputPane.Instance.WriteAsync("Alvin, Anders, Melvyn, Nik, Kevin, Richard, Orien, Shmueli, Gabriel, Martin, Neil, Daniel, "); 45 | await OutputPane.Instance.WriteAsync("Victor, Uno, Paula, Tom, Nick, Niki, chasingcode, luatnt, holeow, logarrhythmic, kokolorix, "); 46 | await OutputPane.Instance.WriteAsync("Guiorgy, Jessé, pharmacyhalo, MXM-7, atexinspect, João, hals1010, WTD-leachA, andermikael, "); 47 | await OutputPane.Instance.WriteAsync("spudwa, Cleroth, relentless-dev-purchases & 20+ more"); 48 | await OutputPane.Instance.WriteAsync(""); 49 | await OutputPane.Instance.WriteAsync("Join them to show you appreciation and ensure future maintenance and development by becoming a sponsor."); 50 | await OutputPane.Instance.WriteAsync(""); 51 | await OutputPane.Instance.WriteAsync("Go to https://github.com/sponsors/mrlacey"); 52 | await OutputPane.Instance.WriteAsync(""); 53 | await OutputPane.Instance.WriteAsync("Any amount, as either a one-off or on a monthly basis, is appreciated more than you can imagine."); 54 | await OutputPane.Instance.WriteAsync(""); 55 | await OutputPane.Instance.WriteAsync("I'll also tell you how to hide this message too. ;)"); 56 | await OutputPane.Instance.WriteAsync(""); 57 | await OutputPane.Instance.WriteAsync(""); 58 | await OutputPane.Instance.WriteAsync("If you can't afford to support financially, you can always"); 59 | await OutputPane.Instance.WriteAsync("leave a positive review at https://marketplace.visualstudio.com/items?itemName=MattLaceyLtd.StringResourceVisualizer&ssr=false#review-details"); 60 | await OutputPane.Instance.WriteAsync(""); 61 | await OutputPane.Instance.WriteAsync("********************************************************************************************************"); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Threading.Tasks; 9 | 10 | namespace StringResourceVisualizer 11 | { 12 | public static class StringExtensions 13 | { 14 | public static async Task IndexOfAnyAsync(this string source, params string[] values) 15 | { 16 | try 17 | { 18 | var valuePositions = new Dictionary(); 19 | 20 | // Values may be duplicated if multiple apps in the project have resources with the same name. 21 | foreach (var value in values.Distinct()) 22 | { 23 | valuePositions.Add(value, source.IndexOf(value)); 24 | } 25 | 26 | if (valuePositions.Any(v => v.Value > -1)) 27 | { 28 | var result = valuePositions.Where(v => v.Value > -1).OrderByDescending(v => v.Key.Length).FirstOrDefault().Value; 29 | 30 | return result; 31 | } 32 | } 33 | catch (Exception ex) 34 | { 35 | await OutputPane.Instance?.WriteAsync("Error in IndexOfAnyAsync"); 36 | await OutputPane.Instance?.WriteAsync(source); 37 | await OutputPane.Instance?.WriteAsync(string.Join("|", values)); 38 | await OutputPane.Instance?.WriteAsync(ex.Message); 39 | await OutputPane.Instance?.WriteAsync(ex.Source); 40 | await OutputPane.Instance?.WriteAsync(ex.StackTrace); 41 | } 42 | 43 | return -1; 44 | } 45 | 46 | public static async Task> GetAllIndexesAsync(this string source, params string[] values) 47 | { 48 | var result = new List(); 49 | 50 | try 51 | { 52 | var startPos = 0; 53 | 54 | while (startPos > -1 && startPos <= source.Length) 55 | { 56 | var index = await source.Substring(startPos).IndexOfAnyAsync(values); 57 | 58 | if (index > -1) 59 | { 60 | result.Add(startPos + index); 61 | startPos = startPos + index + 1; 62 | } 63 | else 64 | { 65 | break; 66 | } 67 | } 68 | } 69 | catch (Exception ex) 70 | { 71 | await OutputPane.Instance?.WriteAsync("Error in GetAllIndexesAsync"); 72 | await OutputPane.Instance?.WriteAsync(source); 73 | await OutputPane.Instance?.WriteAsync(string.Join("|", values)); 74 | await OutputPane.Instance?.WriteAsync(ex.Message); 75 | await OutputPane.Instance?.WriteAsync(ex.Source); 76 | await OutputPane.Instance?.WriteAsync(ex.StackTrace); 77 | } 78 | 79 | return result; 80 | } 81 | 82 | public static async Task> GetAllIndexesCaseInsensitiveAsync(this string source, string searchTerm) 83 | { 84 | var result = new List(); 85 | 86 | try 87 | { 88 | var startPos = 0; 89 | 90 | while (startPos > -1 && startPos <= source.Length) 91 | { 92 | var index = source.Substring(startPos).IndexOf(searchTerm, StringComparison.InvariantCultureIgnoreCase); 93 | 94 | if (index > -1) 95 | { 96 | result.Add(startPos + index); 97 | startPos = startPos + index + 1; 98 | } 99 | else 100 | { 101 | break; 102 | } 103 | } 104 | } 105 | catch (Exception ex) 106 | { 107 | await OutputPane.Instance?.WriteAsync("Error in GetAllIndexesCaseInsensitiveAsync"); 108 | await OutputPane.Instance?.WriteAsync(source); 109 | await OutputPane.Instance?.WriteAsync(searchTerm); 110 | await OutputPane.Instance?.WriteAsync(ex.Message); 111 | await OutputPane.Instance?.WriteAsync(ex.Source); 112 | await OutputPane.Instance?.WriteAsync(ex.StackTrace); 113 | } 114 | 115 | return result; 116 | } 117 | 118 | public static async Task<(int index, string value, bool retry)> IndexOfAnyWithRetryAsync(this string source, params string[] values) 119 | { 120 | try 121 | { 122 | var valuePositions = new Dictionary(); 123 | 124 | // Values may be duplicated if multiple apps in the project have resources with the same name. 125 | foreach (var value in values.Distinct()) 126 | { 127 | valuePositions.Add(value, source.IndexOf(value)); 128 | } 129 | 130 | if (valuePositions.Any(v => v.Value > -1)) 131 | { 132 | var found = valuePositions.Where(v => v.Value > -1) 133 | .OrderBy(v => v.Value) 134 | .ToList(); 135 | 136 | if (found.Any()) 137 | { 138 | var result = found.Where(f => f.Value == found.First().Value) 139 | .OrderBy(v => v.Key.Length) 140 | .First(); 141 | 142 | return (result.Value, result.Key, found.Count(c => c.Value == result.Value) > 1); 143 | } 144 | else 145 | { 146 | return (-1, string.Empty, false); 147 | } 148 | } 149 | } 150 | catch (Exception ex) 151 | { 152 | await OutputPane.Instance?.WriteAsync("Error in IndexOfAnyAsync"); 153 | await OutputPane.Instance?.WriteAsync(source); 154 | await OutputPane.Instance?.WriteAsync(string.Join("|", values)); 155 | await OutputPane.Instance?.WriteAsync(ex.Message); 156 | await OutputPane.Instance?.WriteAsync(ex.Source); 157 | await OutputPane.Instance?.WriteAsync(ex.StackTrace); 158 | } 159 | 160 | return (-1, string.Empty, false); 161 | } 162 | 163 | public static async Task> GetAllWholeWordIndexesAsync(this string source, params string[] values) 164 | { 165 | var result = new List<(int, string)>(); 166 | 167 | try 168 | { 169 | var startPos = 0; 170 | 171 | var ignoreInRetries = new List(); 172 | 173 | while (startPos > -1 && startPos <= source.Length) 174 | { 175 | var toSearchFor = values.Where(v => !ignoreInRetries.Contains(v)).ToArray(); 176 | 177 | var (index, value, retry) = await source.Substring(startPos) 178 | .IndexOfAnyWithRetryAsync(toSearchFor); 179 | 180 | if (index > -1) 181 | { 182 | var prevChar = source[startPos + index - 1]; 183 | 184 | // Account for matching text being at the end of the line. 185 | // It won't be in normal use but could be in comments. 186 | var nextCharPos = startPos + index + value.Length; 187 | char nextChar = ' '; 188 | 189 | if (nextCharPos < source.Length) 190 | { 191 | nextChar = source[nextCharPos]; 192 | } 193 | 194 | if (await source.IsValidVariableNameAsync(prevChar, nextChar)) 195 | { 196 | result.Add((startPos + index, value)); 197 | } 198 | 199 | if (retry) 200 | { 201 | ignoreInRetries.Add(value); 202 | } 203 | else 204 | { 205 | ignoreInRetries.Clear(); 206 | startPos = startPos + index + 1; 207 | } 208 | } 209 | else 210 | { 211 | break; 212 | } 213 | } 214 | } 215 | catch (Exception ex) 216 | { 217 | await OutputPane.Instance?.WriteAsync("Error in GetAllWholeWordIndexesAsync"); 218 | await OutputPane.Instance?.WriteAsync(source); 219 | await OutputPane.Instance?.WriteAsync(string.Join("|", values)); 220 | await OutputPane.Instance?.WriteAsync(ex.Message); 221 | await OutputPane.Instance?.WriteAsync(ex.Source); 222 | await OutputPane.Instance?.WriteAsync(ex.StackTrace); 223 | } 224 | 225 | return result; 226 | } 227 | 228 | /// 229 | /// Given a string, by looking at the characters either side of it, could it be a valid variable name 230 | /// Valid names 231 | /// - start with @, _, or letter 232 | /// - other characters are: letter, digit, or underscore. 233 | /// 234 | public static async Task IsValidVariableNameAsync(this string source, char charBefore, char charAfter) 235 | { 236 | try 237 | { 238 | if (char.IsLetterOrDigit(charBefore)) 239 | { 240 | return false; 241 | } 242 | else if (charBefore == '_') 243 | { 244 | return false; 245 | } 246 | else if (charBefore == '@') 247 | { 248 | return false; 249 | } 250 | 251 | if (char.IsLetterOrDigit(charAfter)) 252 | { 253 | return false; 254 | } 255 | else if (charAfter == '_') 256 | { 257 | return false; 258 | } 259 | 260 | return true; 261 | } 262 | catch (Exception ex) 263 | { 264 | await OutputPane.Instance?.WriteAsync("Error in GetAllIndexesCaseInsensitiveAsync"); 265 | await OutputPane.Instance?.WriteAsync(source); 266 | await OutputPane.Instance?.WriteAsync(charBefore.ToString()); 267 | await OutputPane.Instance?.WriteAsync(charAfter.ToString()); 268 | await OutputPane.Instance?.WriteAsync(ex.Message); 269 | await OutputPane.Instance?.WriteAsync(ex.Source); 270 | await OutputPane.Instance?.WriteAsync(ex.StackTrace); 271 | 272 | return false; 273 | } 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/StringResVizPackage.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.IO; 8 | using System.Linq; 9 | using System.Runtime.InteropServices; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using System.Windows.Media; 13 | using EnvDTE; 14 | using Microsoft; 15 | using Microsoft.ApplicationInsights; 16 | using Microsoft.ApplicationInsights.Extensibility; 17 | using Microsoft.VisualStudio; 18 | using Microsoft.VisualStudio.ComponentModelHost; 19 | using Microsoft.VisualStudio.PlatformUI; 20 | using Microsoft.VisualStudio.Shell; 21 | using Microsoft.VisualStudio.Shell.Interop; 22 | using Microsoft.VisualStudio.Threading; 23 | using static Microsoft.VisualStudio.VSConstants; 24 | using SolutionEvents = Microsoft.VisualStudio.Shell.Events.SolutionEvents; 25 | using Task = System.Threading.Tasks.Task; 26 | 27 | namespace StringResourceVisualizer 28 | { 29 | [ProvideAutoLoad(UICONTEXT.CSharpProject_string, PackageAutoLoadFlags.BackgroundLoad)] 30 | [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] 31 | [InstalledProductRegistration(Vsix.Name, Vsix.Description, Vsix.Version)] // Info on this package for Help/About 32 | [ProvideOptionPage(typeof(OptionsGrid), Vsix.Name, "General", 0, 0, true)] 33 | [Guid(StringResVizPackage.PackageGuidString)] 34 | public sealed class StringResVizPackage : AsyncPackage 35 | { 36 | public const string PackageGuidString = "8c14dc72-9022-42ff-a85c-1cfe548a8956"; 37 | 38 | public StringResVizPackage() 39 | { 40 | // Inside this method you can place any initialization code that does not require 41 | // any Visual Studio service because at this point the package object is created but 42 | // not sited yet inside Visual Studio environment. The place to do all the other 43 | // initialization is the Initialize method. 44 | } 45 | 46 | public static StringResVizPackage Instance { get; private set; } 47 | 48 | public OptionsGrid Options 49 | { 50 | get 51 | { 52 | return (OptionsGrid)this.GetDialogPage(typeof(OptionsGrid)); 53 | } 54 | } 55 | 56 | public FileSystemWatcher SlnWatcher { get; private set; } = new FileSystemWatcher(); 57 | 58 | public FileSystemWatcher ProjWatcher { get; private set; } = new FileSystemWatcher(); 59 | 60 | /// 61 | /// Initialization of the package; this method is called right after the package is sited, so this is the place 62 | /// where you can put all the initialization code that rely on services provided by VisualStudio. 63 | /// 64 | /// A cancellation token to monitor for initialization cancellation, which can occur when VS is shutting down. 65 | /// A provider for progress updates. 66 | /// A task representing the async work of package initialization, or an already completed task if there is none. Do not return null from this method. 67 | protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) 68 | { 69 | // When initialized asynchronously, the current thread may be a background thread at this point. 70 | // Do any initialization that requires the UI thread after switching to the UI thread. 71 | await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); 72 | 73 | // Since this package might not be initialized until after a solution has finished loading, 74 | // we need to check if a solution has already been loaded and then handle it. 75 | bool isSolutionLoaded = await this.IsSolutionLoadedAsync(cancellationToken); 76 | 77 | if (isSolutionLoaded) 78 | { 79 | await this.HandleOpenSolutionAsync(cancellationToken); 80 | } 81 | 82 | // Listen for subsequent solution events 83 | SolutionEvents.OnAfterOpenSolution += this.HandleOpenSolution; 84 | SolutionEvents.OnAfterCloseSolution += this.HandleCloseSolution; 85 | 86 | await this.SetUpRunningDocumentTableEventsAsync(cancellationToken).ConfigureAwait(false); 87 | 88 | var componentModel = GetGlobalService(typeof(SComponentModel)) as IComponentModel; 89 | 90 | if (!ConstFinder.HasParsedSolution) 91 | { 92 | await OutputPane.Instance.WriteAsync("About to parse solution after package load."); 93 | await ConstFinder.TryParseSolutionAsync(componentModel); 94 | } 95 | 96 | await this.LoadSystemTextSettingsAsync(cancellationToken); 97 | 98 | VSColorTheme.ThemeChanged += (e) => this.LoadSystemTextSettingsAsync(CancellationToken.None).LogAndForget(nameof(StringResourceVisualizer)); 99 | 100 | await SponsorRequestHelper.CheckIfNeedToShowAsync(); 101 | 102 | Instance = this; 103 | 104 | await TrackBasicUsageAnalyticsAsync(); 105 | } 106 | 107 | private static async Task TrackBasicUsageAnalyticsAsync() 108 | { 109 | try 110 | { 111 | #if !DEBUG 112 | if (string.IsNullOrWhiteSpace(AnalyticsConfig.TelemetryConnectionString)) 113 | { 114 | return; 115 | } 116 | 117 | var config = new TelemetryConfiguration 118 | { 119 | ConnectionString = AnalyticsConfig.TelemetryConnectionString, 120 | }; 121 | 122 | var client = new TelemetryClient(config); 123 | 124 | var properties = new Dictionary 125 | { 126 | { "VsixVersion", Vsix.Version }, 127 | { "VsVersion", Microsoft.VisualStudio.Telemetry.TelemetryService.DefaultSession?.GetSharedProperty("VS.Core.ExeVersion") }, 128 | { "Architecture", RuntimeInformation.ProcessArchitecture.ToString() }, 129 | { "MsInternal", Microsoft.VisualStudio.Telemetry.TelemetryService.DefaultSession?.IsUserMicrosoftInternal.ToString() }, 130 | }; 131 | 132 | client.TrackEvent(Vsix.Name, properties); 133 | #endif 134 | } 135 | catch (Exception exc) 136 | { 137 | System.Diagnostics.Debug.WriteLine(exc); 138 | await OutputPane.Instance.WriteAsync("Error tracking usage analytics: " + exc.Message); 139 | } 140 | } 141 | 142 | private async Task IsSolutionLoadedAsync(CancellationToken cancellationToken) 143 | { 144 | await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); 145 | 146 | if (!(await this.GetServiceAsync(typeof(SVsSolution)) is IVsSolution solService)) 147 | { 148 | throw new ArgumentNullException(nameof(solService)); 149 | } 150 | 151 | ErrorHandler.ThrowOnFailure(solService.GetProperty((int)__VSPROPID.VSPROPID_IsSolutionOpen, out object value)); 152 | 153 | return value is bool isSolOpen && isSolOpen; 154 | } 155 | 156 | private void HandleOpenSolution(object sender, EventArgs e) 157 | { 158 | this.JoinableTaskFactory.RunAsync(() => this.HandleOpenSolutionAsync(this.DisposalToken)).Task.LogAndForget("StringResourceVisualizer"); 159 | } 160 | 161 | private void HandleCloseSolution(object sender, EventArgs e) 162 | { 163 | ConstFinder.Reset(); 164 | ResourceAdornmentManager.ClearCache(); 165 | } 166 | 167 | private async Task HandleOpenSolutionAsync(CancellationToken cancellationToken) 168 | { 169 | await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); 170 | 171 | await OutputPane.Instance.WriteAsync($"{Vsix.Name} v{Vsix.Version}"); 172 | 173 | // Get all resource files from the solution 174 | // Do this now, rather than in adornment manager for performance and to avoid thread issues 175 | if (await this.GetServiceAsync(typeof(DTE)) is DTE dte) 176 | { 177 | var fileName = dte.Solution.FileName; 178 | string rootDir = null; 179 | 180 | if (!string.IsNullOrWhiteSpace(fileName) && File.Exists(fileName)) 181 | { 182 | rootDir = Path.GetDirectoryName(fileName); 183 | } 184 | 185 | if (string.IsNullOrWhiteSpace(rootDir)) 186 | { 187 | await OutputPane.Instance?.WriteAsync("No solution file found so attempting to load resources for project file."); 188 | 189 | try 190 | { 191 | fileName = ((dte.ActiveSolutionProjects as Array).GetValue(0) as EnvDTE.Project).FileName; 192 | } 193 | catch (Exception) 194 | { 195 | // Assuming this happens due to opening a csproj file rather than an sln. 196 | await OutputPane.Instance?.WriteAsync("No projects found in ActiveSolution so attempting to load resources for project file."); 197 | 198 | IVsSolution sol = ServiceProvider.GlobalProvider.GetService(typeof(SVsSolution)) as IVsSolution; 199 | Assumes.Present(sol); 200 | sol.GetProjectFilesInSolution((uint)__VSGETPROJFILESFLAGS.GPFF_SKIPUNLOADEDPROJECTS, 0, null, out uint numProjects); 201 | string[] projects = new string[numProjects]; 202 | sol.GetProjectFilesInSolution((uint)__VSGETPROJFILESFLAGS.GPFF_SKIPUNLOADEDPROJECTS, numProjects, projects, out numProjects); 203 | 204 | // GetProjectFilesInSolution also returns solution folders, so we want to do some filtering 205 | // things that don't exist on disk certainly can't be project files 206 | // Need to allow for opening a .cs file directly - a "virtual solution" will be created for this but it won't be part of a project. 207 | fileName = projects.FirstOrDefault(p => !string.IsNullOrEmpty(p) && System.IO.File.Exists(p)); 208 | } 209 | 210 | if (!string.IsNullOrWhiteSpace(fileName) && File.Exists(fileName)) 211 | { 212 | rootDir = Path.GetDirectoryName(fileName); 213 | } 214 | } 215 | 216 | if (!string.IsNullOrWhiteSpace(rootDir) && Directory.Exists(rootDir)) 217 | { 218 | await this.SetOrUpdateListOfResxFilesAsync(rootDir); 219 | #pragma warning disable VSTHRD101 // Avoid unsupported async delegates 220 | Messenger.ReloadResources += async () => 221 | { 222 | try 223 | { 224 | await this.SetOrUpdateListOfResxFilesAsync(rootDir); 225 | } 226 | catch (Exception exc) 227 | { 228 | await OutputPane.Instance?.WriteAsync("Unexpected error when reloading resources."); 229 | await OutputPane.Instance?.WriteAsync(exc.Message); 230 | await OutputPane.Instance?.WriteAsync(exc.StackTrace); 231 | } 232 | }; 233 | #pragma warning restore VSTHRD101 // Avoid unsupported async delegates 234 | 235 | this.WatchForSolutionOrProjectChanges(fileName); 236 | } 237 | 238 | if (ResourceAdornmentManager.ResourceFiles.Any()) 239 | { 240 | var plural = ResourceAdornmentManager.ResourceFiles.Count > 1 ? "s" : string.Empty; 241 | await OutputPane.Instance?.WriteAsync($"String Resource Visualizer initialized with {ResourceAdornmentManager.ResourceFiles.Count} resource file{plural}."); 242 | 243 | foreach (var resourceFile in ResourceAdornmentManager.ResourceFiles) 244 | { 245 | await OutputPane.Instance?.WriteAsync(resourceFile); 246 | } 247 | } 248 | else 249 | { 250 | await OutputPane.Instance?.WriteAsync("String Resource Visualizer could not find any resource files to load."); 251 | } 252 | } 253 | 254 | if (!ConstFinder.HasParsedSolution) 255 | { 256 | await OutputPane.Instance.WriteAsync("About to parse solution due to solution load."); 257 | await ConstFinder.TryParseSolutionAsync(); 258 | } 259 | } 260 | 261 | private async Task SetUpRunningDocumentTableEventsAsync(CancellationToken cancellationToken) 262 | { 263 | await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); 264 | 265 | var runningDocumentTable = new RunningDocumentTable(this); 266 | 267 | runningDocumentTable.Advise(MyRunningDocTableEvents.Instance); 268 | } 269 | 270 | private void WatchForSolutionOrProjectChanges(string solutionFileName) 271 | { 272 | // It might actually be the project file name if no solution file exists 273 | if (solutionFileName.EndsWith(".sln", StringComparison.InvariantCultureIgnoreCase)) 274 | { 275 | this.SlnWatcher.Filter = Path.GetFileName(solutionFileName); 276 | this.SlnWatcher.Path = Path.GetDirectoryName(solutionFileName); 277 | this.SlnWatcher.IncludeSubdirectories = false; 278 | this.SlnWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; 279 | this.SlnWatcher.Changed -= this.SlnWatcher_Changed; 280 | this.SlnWatcher.Changed += this.SlnWatcher_Changed; 281 | this.SlnWatcher.Renamed -= this.SlnWatcher_Renamed; 282 | this.SlnWatcher.Renamed += this.SlnWatcher_Renamed; 283 | this.SlnWatcher.EnableRaisingEvents = true; 284 | } 285 | 286 | // Get both .csproj & .vbproj 287 | this.ProjWatcher.Filter = "*.*proj"; 288 | this.ProjWatcher.Path = Path.GetDirectoryName(solutionFileName); 289 | this.ProjWatcher.IncludeSubdirectories = true; 290 | this.ProjWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; 291 | this.ProjWatcher.Changed -= this.ProjWatcher_Changed; 292 | this.ProjWatcher.Changed += this.ProjWatcher_Changed; 293 | this.ProjWatcher.Renamed -= this.ProjWatcher_Renamed; 294 | this.ProjWatcher.Renamed += this.ProjWatcher_Renamed; 295 | this.ProjWatcher.EnableRaisingEvents = true; 296 | } 297 | 298 | #pragma warning disable VSTHRD100 // Avoid async void methods 299 | private async void SlnWatcher_Changed(object sender, FileSystemEventArgs e) 300 | { 301 | try 302 | { 303 | await this.SetOrUpdateListOfResxFilesAsync(Path.GetDirectoryName(e.FullPath)); 304 | } 305 | catch (Exception exc) 306 | { 307 | await OutputPane.Instance?.WriteAsync("Unexpected error when solution changed."); 308 | await OutputPane.Instance?.WriteAsync(exc.Message); 309 | await OutputPane.Instance?.WriteAsync(exc.StackTrace); 310 | } 311 | } 312 | 313 | private async void SlnWatcher_Renamed(object sender, RenamedEventArgs e) 314 | { 315 | try 316 | { 317 | // Don't want to know about temporary files created during save. 318 | if (e.FullPath.EndsWith(".sln")) 319 | { 320 | await this.SetOrUpdateListOfResxFilesAsync(Path.GetDirectoryName(e.FullPath)); 321 | } 322 | } 323 | catch (Exception exc) 324 | { 325 | await OutputPane.Instance?.WriteAsync("Unexpected error when solution renamed."); 326 | await OutputPane.Instance?.WriteAsync(exc.Message); 327 | await OutputPane.Instance?.WriteAsync(exc.StackTrace); 328 | } 329 | } 330 | 331 | private async void ProjWatcher_Changed(object sender, FileSystemEventArgs e) 332 | { 333 | try 334 | { 335 | // Only interested in C# & VB.Net projects as that's all we visualize for. 336 | if (e.FullPath.EndsWith(".csproj") || e.FullPath.EndsWith(".vbproj")) 337 | { 338 | await this.SetOrUpdateListOfResxFilesAsync(((FileSystemWatcher)sender).Path); 339 | } 340 | } 341 | catch (Exception exc) 342 | { 343 | await OutputPane.Instance?.WriteAsync("Unexpected error when project changed."); 344 | await OutputPane.Instance?.WriteAsync(exc.Message); 345 | await OutputPane.Instance?.WriteAsync(exc.StackTrace); 346 | } 347 | } 348 | 349 | private async void ProjWatcher_Renamed(object sender, RenamedEventArgs e) 350 | { 351 | try 352 | { 353 | // Only interested in C# & VB.Net projects as that's all we visualize for. 354 | if (e.FullPath.EndsWith(".csproj") || e.FullPath.EndsWith(".vbproj")) 355 | { 356 | await this.SetOrUpdateListOfResxFilesAsync(((FileSystemWatcher)sender).Path); 357 | } 358 | } 359 | catch (Exception exc) 360 | { 361 | await OutputPane.Instance?.WriteAsync("Unexpected error when project renamed."); 362 | await OutputPane.Instance?.WriteAsync(exc.Message); 363 | await OutputPane.Instance?.WriteAsync(exc.StackTrace); 364 | } 365 | } 366 | #pragma warning restore VSTHRD100 // Avoid async void methods 367 | 368 | private async Task LoadSystemTextSettingsAsync(CancellationToken cancellationToken) 369 | { 370 | await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); 371 | 372 | IVsFontAndColorStorage storage = (IVsFontAndColorStorage)StringResVizPackage.GetGlobalService(typeof(IVsFontAndColorStorage)); 373 | 374 | var guid = new Guid("A27B4E24-A735-4d1d-B8E7-9716E1E3D8E0"); 375 | 376 | if (storage != null && storage.OpenCategory(ref guid, (uint)(__FCSTORAGEFLAGS.FCSF_READONLY | __FCSTORAGEFLAGS.FCSF_LOADDEFAULTS)) == Microsoft.VisualStudio.VSConstants.S_OK) 377 | { 378 | #pragma warning disable SA1129 // Do not use default value type constructor 379 | LOGFONTW[] fnt = new LOGFONTW[] { new LOGFONTW() }; 380 | FontInfo[] info = new FontInfo[] { new FontInfo() }; 381 | #pragma warning restore SA1129 // Do not use default value type constructor 382 | 383 | if (storage.GetFont(fnt, info) == Microsoft.VisualStudio.VSConstants.S_OK) 384 | { 385 | var fontSize = info[0].wPointSize; 386 | 387 | if (fontSize > 0) 388 | { 389 | ResourceAdornmentManager.TextSize = fontSize; 390 | } 391 | } 392 | } 393 | 394 | if (storage != null && storage.OpenCategory(ref guid, (uint)(__FCSTORAGEFLAGS.FCSF_NOAUTOCOLORS | __FCSTORAGEFLAGS.FCSF_LOADDEFAULTS)) == Microsoft.VisualStudio.VSConstants.S_OK) 395 | { 396 | var info = new ColorableItemInfo[1]; 397 | 398 | // Get the color value configured for regular string display 399 | if (storage.GetItem("String", info) == Microsoft.VisualStudio.VSConstants.S_OK) 400 | { 401 | var win32Color = (int)info[0].crForeground; 402 | 403 | int r = win32Color & 0x000000FF; 404 | int g = (win32Color & 0x0000FF00) >> 8; 405 | int b = (win32Color & 0x00FF0000) >> 16; 406 | 407 | var textColor = Color.FromRgb((byte)r, (byte)g, (byte)b); 408 | 409 | ResourceAdornmentManager.TextForegroundColor = textColor; 410 | } 411 | } 412 | } 413 | 414 | private async Task SetOrUpdateListOfResxFilesAsync(string slnDirectory) 415 | { 416 | await OutputPane.Instance?.WriteAsync("Reloading list of resx files."); 417 | 418 | await TaskScheduler.Default; 419 | 420 | var sw = new System.Diagnostics.Stopwatch(); 421 | sw.Start(); 422 | 423 | var allResxFiles = Directory.EnumerateFiles(slnDirectory, "*.resx", SearchOption.AllDirectories); 424 | 425 | sw.Stop(); 426 | 427 | if (sw.Elapsed > TimeSpan.FromSeconds(1)) 428 | { 429 | await OutputPane.Instance.WriteAsync($"Enumerating files took longer than expected: {sw.ElapsedMilliseconds} milliseconds"); 430 | } 431 | 432 | var resxFilesOfInterest = new List(); 433 | 434 | await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(CancellationToken.None); 435 | 436 | var preferredCulture = this.Options.PreferredCulture; 437 | 438 | foreach (var resxFile in allResxFiles) 439 | { 440 | await Task.Yield(); 441 | 442 | if (!Path.GetFileNameWithoutExtension(resxFile).Contains(".")) 443 | { 444 | // Neutral language resources, not locale specific ones 445 | resxFilesOfInterest.Add(resxFile); 446 | } 447 | else if (!string.IsNullOrWhiteSpace(preferredCulture)) 448 | { 449 | // Locale specific resource if specified 450 | if (Path.GetFileNameWithoutExtension(resxFile).EndsWith($".{preferredCulture}", StringComparison.InvariantCultureIgnoreCase)) 451 | { 452 | resxFilesOfInterest.Add(resxFile); 453 | } 454 | } 455 | } 456 | 457 | await ResourceAdornmentManager.LoadResourcesAsync(resxFilesOfInterest, slnDirectory, this.Options); 458 | } 459 | } 460 | } 461 | -------------------------------------------------------------------------------- /src/StringResourceVisualizer.Tests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | [assembly: AssemblyTitle("StringResourceVisualizer.Tests")] 6 | [assembly: AssemblyDescription("")] 7 | [assembly: AssemblyConfiguration("")] 8 | [assembly: AssemblyCompany("")] 9 | [assembly: AssemblyProduct("StringResourceVisualizer.Tests")] 10 | [assembly: AssemblyCopyright("Copyright © 2022")] 11 | [assembly: AssemblyTrademark("")] 12 | [assembly: AssemblyCulture("")] 13 | 14 | [assembly: ComVisible(false)] 15 | 16 | [assembly: Guid("b36b6804-3de4-4c5b-9727-25f053cba7ac")] 17 | 18 | // [assembly: AssemblyVersion("1.0.*")] 19 | [assembly: AssemblyVersion("1.0.0.0")] 20 | [assembly: AssemblyFileVersion("1.0.0.0")] 21 | -------------------------------------------------------------------------------- /src/StringResourceVisualizer.Tests/ResourceAdornmentManagerTests.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using Microsoft.VisualStudio.TestTools.UnitTesting; 6 | 7 | namespace StringResourceVisualizer.Tests 8 | { 9 | [TestClass] 10 | public class ResourceAdornmentManagerTests 11 | { 12 | [TestMethod] 13 | public void FormatDisplayString_SingleLine() 14 | { 15 | var input = "I am a single line string"; 16 | 17 | TestIt(input, input); 18 | } 19 | 20 | [TestMethod] 21 | public void FormatDisplayString_MultipleLines() 22 | { 23 | var input = "I am a the first line\r\nand this is the second line"; 24 | var expected = "I am a the first line⏎"; 25 | 26 | TestIt(input, expected); 27 | } 28 | 29 | [TestMethod] 30 | public void FormatDisplayString_TwoLines_FirstIsBlank() 31 | { 32 | var input = "\r\nthis is the second line"; 33 | var expected = "⏎this is the second line"; 34 | 35 | TestIt(input, expected); 36 | } 37 | 38 | [TestMethod] 39 | public void FormatDisplayString_MultipleLines_FirstIsBlank() 40 | { 41 | var input = "\r\nthis is the second line\r\nand this is the third line"; 42 | var expected = "⏎this is the second line⏎"; 43 | 44 | TestIt(input, expected); 45 | } 46 | 47 | private void TestIt(string input, string expected) 48 | { 49 | var actual = ResourceAdornmentManager.FormatDisplayText(input); 50 | 51 | Assert.AreEqual(expected, actual); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/StringResourceVisualizer.Tests/StringResourceVisualizer.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {B36B6804-3DE4-4C5B-9727-25F053CBA7AC} 8 | Library 9 | Properties 10 | StringResourceVisualizer.Tests 11 | StringResourceVisualizer.Tests 12 | v4.8.1 13 | 512 14 | {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 15 | 15.0 16 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 17 | $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages 18 | False 19 | UnitTest 20 | 21 | 22 | 23 | 24 | 25 | true 26 | full 27 | false 28 | bin\Debug\ 29 | DEBUG;TRACE 30 | prompt 31 | 4 32 | 33 | 34 | pdbonly 35 | true 36 | bin\Release\ 37 | TRACE 38 | prompt 39 | 4 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 2.5.192 52 | 53 | 54 | 4.11.0 55 | 56 | 57 | 4.11.0 58 | 59 | 60 | 6.1.0 61 | 62 | 63 | 4.7.36 64 | 65 | 66 | 4.11.0 67 | 68 | 69 | 3.6.3 70 | 71 | 72 | 3.6.3 73 | 74 | 75 | 3.6.3 76 | runtime; build; native; contentfiles; analyzers; buildtransitive 77 | all 78 | 79 | 80 | 2.20.17 81 | 82 | 83 | 8.0.0 84 | 85 | 86 | 9.0.0 87 | 88 | 89 | 9.0.0 90 | 91 | 92 | 1.7.0 93 | runtime; build; native; contentfiles; analyzers; buildtransitive 94 | all 95 | 96 | 97 | 98 | 99 | {e71a6e00-d6f7-4401-9515-1dd72701ec57} 100 | StringResourceVisualizer 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /src/StringResourceVisualizer.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 15.0 5 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 6 | 7 | 8 | true 9 | 10 | 11 | 12 | true 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Debug 21 | AnyCPU 22 | 2.0 23 | {82b43b9b-a64c-4715-b499-d71e9ca2bd60};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 24 | {E71A6E00-D6F7-4401-9515-1DD72701EC57} 25 | Library 26 | Properties 27 | StringResourceVisualizer 28 | StringResourceVisualizer 29 | v4.8.1 30 | true 31 | true 32 | true 33 | true 34 | true 35 | false 36 | Program 37 | $(DevEnvDir)devenv.exe 38 | /rootsuffix Exp 39 | 40 | 41 | true 42 | full 43 | false 44 | bin\Debug\ 45 | DEBUG;TRACE 46 | prompt 47 | 4 48 | StringResourceVisualizer.ruleset 49 | 50 | 51 | pdbonly 52 | true 53 | bin\Release\ 54 | TRACE 55 | prompt 56 | 4 57 | StringResourceVisualizer.ruleset 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Component 70 | 71 | 72 | 73 | 74 | 75 | 76 | True 77 | True 78 | source.extension.vsixmanifest 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | Resources\LICENSE 89 | true 90 | 91 | 92 | .editorconfig 93 | 94 | 95 | 96 | 97 | Designer 98 | VsixManifestGenerator 99 | source.extension.cs 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | PreserveNewest 108 | true 109 | 110 | 111 | 112 | 113 | 2.5.192 114 | 115 | 116 | 2.22.0 117 | 118 | 119 | 4.11.0 120 | 121 | 122 | 4.11.0 123 | 124 | 125 | 6.1.0 126 | 127 | 128 | 4.7.36 129 | 130 | 131 | 4.11.0 132 | 133 | 134 | 17.10.40171 135 | 136 | 137 | 2.20.17 138 | 139 | 140 | 8.0.0 141 | 142 | 143 | 4.3.0 144 | 145 | 146 | 9.0.0 147 | 148 | 149 | 4.3.0 150 | 151 | 152 | 9.0.0 153 | 154 | 155 | 1.7.0 156 | runtime; build; native; contentfiles; analyzers; buildtransitive 157 | all 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. 173 | 174 | 175 | 182 | -------------------------------------------------------------------------------- /src/StringResourceVisualizer.ruleset: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/TaskExtensions.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Matt Lacey. All rights reserved. 3 | // 4 | 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.VisualStudio.Shell; 8 | using Task = System.Threading.Tasks.Task; 9 | 10 | namespace StringResourceVisualizer 11 | { 12 | internal static class TaskExtensions 13 | { 14 | internal static void LogAndForget(this Task task, string source) => 15 | task.ContinueWith( 16 | (t, s) => VsShellUtilities.LogError(s as string, t.Exception?.ToString()), 17 | source, 18 | CancellationToken.None, 19 | TaskContinuationOptions.OnlyOnFaulted, 20 | TaskScheduler.Default); 21 | 22 | // Use the helper library (instead of TaskScheduler.Default) when drop support for VS2017 23 | ////VsTaskLibraryHelper.GetTaskScheduler(VsTaskRunContext.UIThreadNormalPriority)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/filestosign.txt: -------------------------------------------------------------------------------- 1 | StringResourceVisualizer.dll -------------------------------------------------------------------------------- /src/signvsix.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/source.extension.cs: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------ 2 | // 3 | // This file was generated by VSIX Synchronizer 4 | // 5 | // ------------------------------------------------------------------------------ 6 | namespace StringResourceVisualizer 7 | { 8 | internal sealed partial class Vsix 9 | { 10 | public const string Id = "StringResourceVisualizer.a05f89b1-98f8-4b37-8f84-4fdebc44aa25"; 11 | public const string Name = "String Resource Visualizer"; 12 | public const string Description = @"View the text of string resources (.resx) inline when they're used in code."; 13 | public const string Language = "en-US"; 14 | public const string Version = "1.23.3"; 15 | public const string Author = "Matt Lacey"; 16 | public const string Tags = "Strings, resources, localization, resx, I18N, ILocalizer"; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/source.extension.vsixmanifest: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | String Resource Visualizer 6 | View the text of string resources (.resx) inline when they're used in code. 7 | https://github.com/mrlacey/StringResourceVisualizer 8 | Resources\LICENSE 9 | https://github.com/mrlacey/StringResourceVisualizer/CHANGELOG.md 10 | Resources\Icon.png 11 | Resources\Icon.png 12 | Strings, resources, localization, resx, I18N, ILocalizer 13 | 14 | 15 | 16 | amd64 17 | 18 | 19 | arm64 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | // ACTION REQUIRED: This file was automatically added to your project, but it 3 | // will not take effect until additional steps are taken to enable it. See the 4 | // following page for additional information: 5 | // 6 | // https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/EnableConfiguration.md 7 | 8 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 9 | "settings": { 10 | "documentationRules": { 11 | "companyName": "Matt Lacey" 12 | } 13 | } 14 | } 15 | --------------------------------------------------------------------------------