diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..d255477 --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,137 @@ +name: Build And Publish Xygeni Visual Studio Extension + +on: + workflow_dispatch: + +env: + ASSEMBLY_NAME: xygeni-extension.vsix + +jobs: + build: + name: Build VSIX (Windows) + runs-on: windows-2025 + defaults: + run: + working-directory: ${{ github.workspace }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get release version + id: extract_version + shell: powershell + run: | + $branchName = "${{ github.ref_name }}" + if ($branchName -match '^release\/(\d+\.\d+\.\d+)$') { + $version = $matches[1] + Write-Host "Building Version: $version" + echo "EXT_VERSION=$version" >> $env:GITHUB_ENV + echo "version=$version" >> $env:GITHUB_OUTPUT + } + else { + Write-Error "Branch name does not match expected format 'release/a.b.c'" + exit 1 + } + + - uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.config') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v1 + with: + vs-version: '[17.0, )' + + - name: Restore NuGet packages + run: nuget restore ${{ inputs.solution-file-path }} + + - name: Build + shell: powershell + run: | + msbuild ${{ inputs.solution-file-path }} /p:configuration=Release /p:DeployExtension=false /p:ZipPackageCompressionLevel=normal /v:m + + - name: Locate VSIX artifact + id: find_vsix + shell: pwsh + run: | + $vsix = Get-ChildItem -Path $PWD -Recurse -Filter *.vsix | + Where-Object { $_.FullName -match "\\bin\\Release\\$env:ASSEMBLY_NAME" } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $vsix) { + Write-Error "No .vsix found under bin\Release\$env:ASSEMBLY_NAME. Check your build output paths." + exit 1 + } + + "vsix_path=$($vsix.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + Write-Host "Found VSIX: $($vsix.FullName)" + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: xygeni-extension.vsix + path: ${{ steps.find_vsix.outputs.vsix_path }} + if-no-files-found: error + + approval: + name: Manual approval (Linux) + runs-on: ubuntu-latest + needs: build + + steps: + - uses: trstringer/manual-approval@v1 + timeout-minutes: 5 + with: + secret: ${{ github.TOKEN }} + approvers: fcarnicero,nanoalbelda,vdlr + minimum-approvals: 1 + + publish: + name: Publish VSIX (Windows) + runs-on: windows-2025 + needs: approval + defaults: + run: + working-directory: ${{ github.workspace }} + + steps: + - name: Download VSIX artifact + uses: actions/download-artifact@v4 + with: + name: xygeni-extension.vsix + path: ${{ github.workspace }}\artifact + + - name: Resolve downloaded VSIX path + id: vsix + shell: pwsh + run: | + $vsix = Get-ChildItem -Path "${{ github.workspace }}\artifact" -Recurse -Filter *.vsix | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $vsix) { + Write-Error "Downloaded artifact did not contain a .vsix file." + exit 1 + } + + "path=$($vsix.FullName)" + + - name: Publish to Visual Studio Marketplace + shell: pwsh + env: + VS_MARKETPLACE_PAT: ${{ secrets.VS_MARKETPLACE_PAT }} + run: | + if (-not $env:VS_MARKETPLACE_PAT) { + Write-Error "Missing secret VS_MARKETPLACE_PAT." + } + + tfx extension publish ` + --vsix "${{ steps.find_vsix.outputs.vsix_path }}" ` + --token "$env:VS_MARKETPLACE_PAT" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 1a72a1b..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Build Xygeni Visual Studio Extension - -on: - workflow_dispatch: - -jobs: - build: - runs-on: windows-2025 - defaults: - run: - working-directory: ${{ github.workspace }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Get release version - id: extract_version - shell: powershell - run: | - $branchName = "${{ github.ref_name }}" - if ($branchName -match '^release\/(\d+\.\d+\.\d+)$') { - $version = $matches[1] - Write-Host "Building Version: $version" - echo "EXT_VERSION=$version" >> $env:GITHUB_ENV - echo "version=$version" >> $env:GITHUB_OUTPUT - } - else { - Write-Error "Branch name does not match expected format 'release/a.b.c'" - exit 1 - } - - - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.config') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Add MSBuild to PATH - uses: microsoft/setup-msbuild@v1 - with: - vs-version: '[17.0, )' - - - name: Restore NuGet packages - run: nuget restore ${{ inputs.solution-file-path }} - - - name: Build - run: | - msbuild ${{ inputs.solution-file-path }} /p:configuration=Release /p:DeployExtension=false /p:ZipPackageCompressionLevel=normal /v:m - shell: powershell - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: build-artifacts-${{ env.EXT_VERSION }}-build${{ github.run_number }} - path: | - **/bin - **/obj \ No newline at end of file diff --git a/.gitignore b/.gitignore index 35063fc..a9ad141 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,9 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml +.idea/.gitignore +.idea/misc.xml +.idea/modules.xml +.idea/vcs.xml +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c935bea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ + +## 0.2.0 (2026-02-19) + +- Added issue line decorators in the editor. +- Added scanner issues to Visual Studio Error List. +- Added proxy setting support in extension configuration. +- Improved issue details handling. + +## 0.1.0 (2026-02-04) + +- Initial beta release. +- Added remediation support. diff --git a/Editor/XygeniIssueErrorTagger.cs b/Editor/XygeniIssueErrorTagger.cs new file mode 100644 index 0000000..d0eaff5 --- /dev/null +++ b/Editor/XygeniIssueErrorTagger.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Adornments; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; +using vs2026_plugin.Services; + +namespace vs2026_plugin.Editor +{ + [Export(typeof(IViewTaggerProvider))] + [ContentType("text")] + [TextViewRole(PredefinedTextViewRoles.Document)] + [TagType(typeof(IErrorTag))] + internal sealed class XygeniIssueErrorTaggerProvider : IViewTaggerProvider + { + [Import] + internal ITextDocumentFactoryService TextDocumentFactoryService = null; + + public ITagger CreateTagger(ITextView textView, ITextBuffer buffer) where T : ITag + { + if (textView == null || buffer == null || textView.TextBuffer != buffer) + { + return null; + } + + if (!(textView is IWpfTextView wpfTextView)) + { + return null; + } + + return new XygeniIssueErrorTagger(wpfTextView, buffer, TextDocumentFactoryService) as ITagger; + } + } + + internal sealed class XygeniIssueErrorTagger : ITagger, IDisposable + { + private readonly IWpfTextView _view; + private readonly ITextBuffer _buffer; + private readonly ITextDocumentFactoryService _textDocumentFactoryService; + + private XygeniErrorListService _errorListService; + private bool _isClosed; + private string _currentFilePath; + + public event EventHandler TagsChanged; + + public XygeniIssueErrorTagger(IWpfTextView view, ITextBuffer buffer, ITextDocumentFactoryService textDocumentFactoryService) + { + _view = view ?? throw new ArgumentNullException(nameof(view)); + _buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); + _textDocumentFactoryService = textDocumentFactoryService; + _currentFilePath = GetCurrentFilePath(); + + _view.LayoutChanged += OnLayoutChanged; + _view.Closed += OnViewClosed; + + EnsureErrorListService(); + } + + public IEnumerable> GetTags(NormalizedSnapshotSpanCollection spans) + { + if (_isClosed || spans == null || spans.Count == 0) + { + yield break; + } + + EnsureErrorListService(); + if (_errorListService == null) + { + yield break; + } + + string currentFilePath = GetCurrentFilePath(); + if (string.IsNullOrEmpty(currentFilePath)) + { + yield break; + } + + IReadOnlyList issueLocations = _errorListService.GetIssueLocationsForDocument(currentFilePath); + if (issueLocations == null || issueLocations.Count == 0) + { + yield break; + } + + ITextSnapshot snapshot = spans[0].Snapshot; + foreach (var issueLocation in issueLocations) + { + if (issueLocation == null) + { + continue; + } + + if (!TryCreateIssueSpan(snapshot, issueLocation, out SnapshotSpan issueSpan)) + { + continue; + } + + if (!spans.IntersectsWith(issueSpan)) + { + continue; + } + + string errorType = GetErrorType(issueLocation.Severity); + var errorTag = new ErrorTag(errorType, issueLocation.Message); + yield return new TagSpan(issueSpan, errorTag); + } + } + + private void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) + { + if (_isClosed) + { + return; + } + + EnsureErrorListService(); + + string latestFilePath = GetCurrentFilePath(); + if (!string.Equals(_currentFilePath, latestFilePath, StringComparison.OrdinalIgnoreCase)) + { + _currentFilePath = latestFilePath; + RaiseTagsChanged(); + } + } + + private void OnViewClosed(object sender, EventArgs e) + { + Dispose(); + } + + public void Dispose() + { + if (_isClosed) + { + return; + } + + _isClosed = true; + + _view.LayoutChanged -= OnLayoutChanged; + _view.Closed -= OnViewClosed; + + if (_errorListService != null) + { + _errorListService.IssueLocationsChanged -= OnIssueLocationsChanged; + } + } + + private void EnsureErrorListService() + { + if (_errorListService != null) + { + return; + } + + if (!XygeniErrorListService.TryGetInstance(out XygeniErrorListService errorListService) || errorListService == null) + { + return; + } + + _errorListService = errorListService; + _errorListService.IssueLocationsChanged += OnIssueLocationsChanged; + RaiseTagsChanged(); + } + + private void OnIssueLocationsChanged(object sender, EventArgs e) + { + if (_isClosed) + { + return; + } + + RaiseTagsChanged(); + } + + private void RaiseTagsChanged() + { + ITextSnapshot snapshot = _buffer.CurrentSnapshot; + var span = new SnapshotSpan(snapshot, 0, snapshot.Length); + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(span)); + } + + private string GetCurrentFilePath() + { + if (_textDocumentFactoryService == null) + { + return null; + } + + ITextBuffer documentBuffer = _view.TextDataModel?.DocumentBuffer ?? _buffer; + if (_textDocumentFactoryService.TryGetTextDocument(documentBuffer, out ITextDocument textDocument)) + { + return textDocument.FilePath; + } + + return null; + } + + private static bool TryCreateIssueSpan(ITextSnapshot snapshot, vs2026_plugin.Services.XygeniIssueLocation issueLocation, out SnapshotSpan issueSpan) + { + issueSpan = default(SnapshotSpan); + + if (snapshot == null || issueLocation == null || snapshot.LineCount == 0) + { + return false; + } + + int startLineNumber = Math.Max(0, issueLocation.BeginLine - 1); + if (startLineNumber >= snapshot.LineCount) + { + return false; + } + + ITextSnapshotLine startLine = snapshot.GetLineFromLineNumber(startLineNumber); + int startColumn = Math.Max(0, issueLocation.BeginColumn - 1); + int startPosition = Math.Min(startLine.End.Position, startLine.Start.Position + startColumn); + + int endLineNumber = issueLocation.EndLine > 0 ? issueLocation.EndLine - 1 : startLineNumber; + endLineNumber = Math.Max(startLineNumber, Math.Min(snapshot.LineCount - 1, endLineNumber)); + + ITextSnapshotLine endLine = snapshot.GetLineFromLineNumber(endLineNumber); + int endPosition; + + if (endLineNumber == startLineNumber) + { + int endColumn = issueLocation.EndColumn > 0 ? issueLocation.EndColumn - 1 : startColumn + 1; + int safeEndColumn = Math.Max(startColumn + 1, endColumn); + endPosition = Math.Min(endLine.End.Position, endLine.Start.Position + safeEndColumn); + } + else + { + if (issueLocation.EndColumn > 0) + { + int endColumn = Math.Max(0, issueLocation.EndColumn - 1); + endPosition = Math.Min(endLine.End.Position, endLine.Start.Position + endColumn); + } + else + { + endPosition = endLine.End.Position; + } + } + + if (endPosition <= startPosition) + { + endPosition = Math.Min(snapshot.Length, startPosition + 1); + } + + if (endPosition <= startPosition) + { + return false; + } + + issueSpan = new SnapshotSpan(snapshot, Span.FromBounds(startPosition, endPosition)); + return issueSpan.Length > 0; + } + + private static string GetErrorType(string severity) + { + switch ((severity ?? string.Empty).Trim().ToLowerInvariant()) + { + case "critical": + case "high": + return PredefinedErrorTypeNames.SyntaxError; + case "medium": + case "low": + return PredefinedErrorTypeNames.Warning; + default: + return PredefinedErrorTypeNames.OtherError; + } + } + } +} diff --git a/Models/IacXygeniIssue.cs b/Models/IacXygeniIssue.cs index f5de7f5..6cc6b0d 100644 --- a/Models/IacXygeniIssue.cs +++ b/Models/IacXygeniIssue.cs @@ -14,12 +14,15 @@ public override string GetIssueDetailsHtml() return $@"
- - - - - - + {Field("Type", Type)} + {Field("Provider", Provider)} + {Where(Branch, null, null)} + {Field("Location", File)} + {Field("Resource", Resource)} + {Field("Found By", Detector)} + + {GetTags()} +
Type{Type}
Detector{Detector}
Resource{Resource}
Provider{Provider}
File{File}
Line{BeginLine + 1}
"; } diff --git a/Models/MisconfXygeniIssue.cs b/Models/MisconfXygeniIssue.cs index 8a1d04e..6d6f798 100644 --- a/Models/MisconfXygeniIssue.cs +++ b/Models/MisconfXygeniIssue.cs @@ -12,11 +12,12 @@ public override string GetIssueDetailsHtml() return $@"
- - - - - + {Field("Explanation", Explanation)} + {Where(CurrentBranch, null, null)} + {Field("Location", File)} + {Field("Found By", Detector)} + {Field("Tool", ToolKind)} + {GetTags()}
Type{Type}
Detector{Detector}
Tool Kind{ToolKind}
File{File}
Line{BeginLine + 1}
"; } diff --git a/Models/ProxySettings.cs b/Models/ProxySettings.cs new file mode 100644 index 0000000..34b503c --- /dev/null +++ b/Models/ProxySettings.cs @@ -0,0 +1,13 @@ +namespace vs2026_plugin.Models +{ + public class ProxySettings + { + public string Protocol { get; set; } + public string Host { get; set; } + public int? Port { get; set; } + public string Authentication { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string NonProxyHosts { get; set; } + } +} diff --git a/Models/SastXygeniIssue.cs b/Models/SastXygeniIssue.cs index 92d38c7..1cb10f2 100644 --- a/Models/SastXygeniIssue.cs +++ b/Models/SastXygeniIssue.cs @@ -16,11 +16,13 @@ public override string GetIssueDetailsHtml() return $@"
- - - - - + {Field("Explanation", Explanation)} + {Field("Type", Type)} + {Where(Branch, null, null)} + {Field("Found At", Url)} + {Field("Location", File + "[" + BeginLine + "]")} + {Field("Found By", Detector)} + {GetTags()}
Explanation{Explanation}
Type{Type}
Location{File}[{BeginLine + 1}]
Found By{Detector}
CWE{Cwe}
"; } diff --git a/Models/SecretsXygeniIssue.cs b/Models/SecretsXygeniIssue.cs index 467d2c7..e8e873f 100644 --- a/Models/SecretsXygeniIssue.cs +++ b/Models/SecretsXygeniIssue.cs @@ -19,11 +19,14 @@ public override string GetIssueDetailsHtml() return $@"
- - - - - + {Field("Type", Type)} + {Field("Secret", Secret)} + {Where(Branch, CommitHash, User)} + {Field("Date", TimeAdded.ToString())} + {Field("Resource", Resource)} + {Field("Location", Url)} + {Field("Found By", Detector)} + {GetTags()}
Type{Type}
Detector{Detector}
Resource{Resource}
File{File}
Line{BeginLine + 1}
"; } diff --git a/Models/VulnXygeniIssue.cs b/Models/VulnXygeniIssue.cs index 77faca7..55f308a 100644 --- a/Models/VulnXygeniIssue.cs +++ b/Models/VulnXygeniIssue.cs @@ -26,11 +26,15 @@ public override string GetIssueDetailsHtml() return $@"
- - - - - + + {Field("Published", PublicationDate)} + {Field("Affecting", !string.IsNullOrEmpty(Group) ? Group + ":" + Name + ":" + Version : (Name + ":" + Version) )} + {Field("Versions", Versions)} + {Field("File", File)} + {Field("Direct Dependency", DirectDependency.ToString())} + {Field("Vector", Vector)} + + {GetTags()}
Type{Type} {RemediableLevel}
Package{Name} ({Version})
Severity{Severity} (Score: {BaseScore})
Published{PublicationDate}
File{File}
"; } diff --git a/Models/XygeniIssue.cs b/Models/XygeniIssue.cs index a1b7567..d1e364a 100644 --- a/Models/XygeniIssue.cs +++ b/Models/XygeniIssue.cs @@ -1,6 +1,9 @@ using System; +using System.Linq; using Markdig; using System.Collections.Generic; +using Microsoft.VisualStudio.Shell; +using System.IO; namespace vs2026_plugin.Models { @@ -35,6 +38,9 @@ public interface IXygeniIssue string GetExplanationHtml(); string GetRemediationTab(); string GetRemediationTabContent(); + string GetTags(); + string Field(string name, string value); + } public abstract class AbstractXygeniIssue : IXygeniIssue @@ -62,6 +68,37 @@ public abstract class AbstractXygeniIssue : IXygeniIssue public const string RemediableAuto = "AUTO"; public const string RemediableManual = "MANUAL"; + // static cache icons as data URIs for WebView2 + public static string BranchIcon; + public static string CommitIcon; + public static string UserIcon; + private static bool _iconsLoaded = false; + + private static string ToDataUri(string filePath) + { + if (!System.IO.File.Exists(filePath)) return ""; + byte[] bytes = System.IO.File.ReadAllBytes(filePath); + string base64 = Convert.ToBase64String(bytes); + return $"data:image/png;base64,{base64}"; + } + + public static void LoadIcons() + { + if (_iconsLoaded) return; + string baseDir = Path.GetDirectoryName(typeof(AbstractXygeniIssue).Assembly.Location); + BranchIcon = ToDataUri(Path.Combine(baseDir, "media", "icons", "branch.png")); + CommitIcon = ToDataUri(Path.Combine(baseDir, "media", "icons", "commit.png")); + UserIcon = ToDataUri(Path.Combine(baseDir, "media", "icons", "user.png")); + _iconsLoaded = true; + } + + private static readonly Dictionary texts = new Dictionary { + {"manual_fix", "Manual Fix"}, + {"potential_reachable", "Potential Reachable"}, + {"in-app-code", "In-App Code"}, + {"generic", "Generic"} + }; + protected AbstractXygeniIssue() { Tags = new List(); @@ -99,13 +136,13 @@ public virtual string GetSubtitleLineHtml() public virtual string GetCodeSnippetHtml() { - if (string.IsNullOrEmpty(Code)) return ""; + if (string.IsNullOrEmpty(Code) || string.IsNullOrEmpty(File)) return ""; var codeLines = Code.Split('\n'); string codeSnippet = ""; for (int i = 0; i < codeLines.Length; i++) { - int lineNumber = BeginLine + i + 1; + int lineNumber = BeginLine + i; string escapedLine = codeLines[i].Replace("<", "<").Replace(">", ">"); codeSnippet += $@" @@ -190,6 +227,32 @@ function formSubmitHandler(e) {{ "; } + + public virtual string GetTags() { + if( Tags is null ) return ""; + return "Tags" + + "
" + string.Join(" ", Tags.Select(tag => $"
{ TagNames(tag)}
")) + "
"; + } + + public virtual string Field(string name, string value) { + return string.IsNullOrEmpty(value) ? "" : $"{name}{value}"; + } + + public virtual string Where(string branch, string commit, string user) { + LoadIcons(); + string where = ""; + if(!string.IsNullOrEmpty(branch)) where += $"Branch {branch}"; + if(!string.IsNullOrEmpty(commit)) where += $"Commit {commit}"; + if(!string.IsNullOrEmpty(user)) where += $"User {user}"; + if(!string.IsNullOrEmpty(where)) where = $"Where{where}"; + return where; + } + + private string TagNames(string tag) { + return texts.ContainsKey(tag.ToLower()) ? texts[tag.ToLower()] : tag; + } } + + } diff --git a/Services/IssueDetailsService.cs b/Services/IssueDetailsService.cs index 66d4f9d..99483ed 100644 --- a/Services/IssueDetailsService.cs +++ b/Services/IssueDetailsService.cs @@ -341,6 +341,7 @@ public string GetThemeColors() public string GetEmptyStateHtml() { + ThreadHelper.ThrowIfNotOnUIThread(); string themeColors = GetThemeColors(); string css = $@" :root {{ @@ -381,6 +382,8 @@ private string GenerateHtml(IXygeniIssue issue) { try { + ThreadHelper.ThrowIfNotOnUIThread(); + // Get current VS theme colors string themeColors = GetThemeColors(); @@ -448,9 +451,34 @@ private string GenerateHtml(IXygeniIssue issue) opacity: 0.6; cursor: not-allowed; }} + .xy-container-chip {{ + display: flex; + align-items: start; + gap: 2px; + flex-direction: row; + flex-wrap: wrap; + }} + + .xy-blue-chip {{ + font-size: 10px !important; + font-weight: 500; + position: relative; + border-radius: 7px !important; + box-sizing: border-box; + background-color: transparent !important; + color: #536DF7; + border: 1px solid #536DF7; + min-height: 22px !important; + padding-left: 2px; + padding-right: 2px; + padding-top: 5px; + margin-right: 2px; + text-wrap: nowrap; + }} "; string severityClass = $"severity-{issue.Severity?.ToLower() ?? "info"}"; + string explanation = issue.Explanation.Length > 30 ? issue.Explanation.Substring(0, 30) + "..." : issue.Explanation; // Construct HTML return $@" @@ -488,7 +516,7 @@ function onMessage(event) {{
{issue.Severity}
-
{issue.Explanation.Substring(0, 30) + "..."}
+
{explanation}
{issue.GetSubtitleLineHtml()} @@ -544,7 +572,7 @@ function onMessage(event) {{
-

No details available

+

No details available {ex.Message}

"; diff --git a/Services/XygeniConfigurationService.cs b/Services/XygeniConfigurationService.cs index 4b38b43..9b0121a 100644 --- a/Services/XygeniConfigurationService.cs +++ b/Services/XygeniConfigurationService.cs @@ -9,6 +9,7 @@ using EnvDTE80; using EnvDTE; using System.Collections.Generic; +using vs2026_plugin.Models; namespace vs2026_plugin.Services { @@ -17,6 +18,13 @@ public class XygeniConfigurationService private const string CollectionPath = "XygeniConfiguration"; private const string ApiUrlKey = "ApiUrl"; private const string TokenKey = "ApiToken"; + private const string ProxyProtocolKey = "ProxyProtocol"; + private const string ProxyHostKey = "ProxyHost"; + private const string ProxyPortKey = "ProxyPort"; + private const string ProxyAuthenticationKey = "ProxyAuthentication"; + private const string ProxyUsernameKey = "ProxyUsername"; + private const string ProxyPasswordKey = "ProxyPassword"; + private const string ProxyNonProxyHostsKey = "ProxyNonProxyHosts"; private const string MetadataFolderKey = ".xygenidata"; private readonly SettingsManager _settingsManager; @@ -77,22 +85,55 @@ public string GetToken() public void SaveUrl(string url) { - var store = _settingsManager.GetWritableSettingsStore(SettingsScope.UserSettings); - if (!store.CollectionExists(CollectionPath)) - { - store.CreateCollection(CollectionPath); - } + var store = GetWritableStore(); store.SetString(CollectionPath, ApiUrlKey, url); } public void SaveToken(string token) { - var store = _settingsManager.GetWritableSettingsStore(SettingsScope.UserSettings); + var store = GetWritableStore(); + store.SetString(CollectionPath, TokenKey, token); + } + + public ProxySettings GetProxySettings() + { + var store = _settingsManager.GetReadOnlySettingsStore(SettingsScope.UserSettings); if (!store.CollectionExists(CollectionPath)) { - store.CreateCollection(CollectionPath); + return new ProxySettings(); } - store.SetString(CollectionPath, TokenKey, token); + + var proxySettings = new ProxySettings + { + Protocol = store.GetString(CollectionPath, ProxyProtocolKey, string.Empty), + Host = store.GetString(CollectionPath, ProxyHostKey, string.Empty), + Authentication = store.GetString(CollectionPath, ProxyAuthenticationKey, string.Empty), + Username = store.GetString(CollectionPath, ProxyUsernameKey, string.Empty), + Password = store.GetString(CollectionPath, ProxyPasswordKey, string.Empty), + NonProxyHosts = store.GetString(CollectionPath, ProxyNonProxyHostsKey, string.Empty) + }; + + var proxyPort = store.GetString(CollectionPath, ProxyPortKey, string.Empty); + if (int.TryParse(proxyPort, out int parsedPort)) + { + proxySettings.Port = parsedPort; + } + + return proxySettings; + } + + public void SaveProxySettings(ProxySettings proxySettings) + { + var settings = proxySettings ?? new ProxySettings(); + var store = GetWritableStore(); + + store.SetString(CollectionPath, ProxyProtocolKey, NormalizeSetting(settings.Protocol)); + store.SetString(CollectionPath, ProxyHostKey, NormalizeSetting(settings.Host)); + store.SetString(CollectionPath, ProxyPortKey, settings.Port.HasValue ? settings.Port.Value.ToString() : string.Empty); + store.SetString(CollectionPath, ProxyAuthenticationKey, NormalizeSetting(settings.Authentication)); + store.SetString(CollectionPath, ProxyUsernameKey, NormalizeSetting(settings.Username)); + store.SetString(CollectionPath, ProxyPasswordKey, settings.Password ?? string.Empty); + store.SetString(CollectionPath, ProxyNonProxyHostsKey, NormalizeSetting(settings.NonProxyHosts)); } public void ClearCache() { @@ -360,6 +401,22 @@ private async Task IsSolutionWithProjectsAsync(Solution solution, Projects return !string.IsNullOrEmpty(solution.FullName) && !solution.IsDirty && projects.Count > 0; } + private WritableSettingsStore GetWritableStore() + { + var store = _settingsManager.GetWritableSettingsStore(SettingsScope.UserSettings); + if (!store.CollectionExists(CollectionPath)) + { + store.CreateCollection(CollectionPath); + } + + return store; + } + + private string NormalizeSetting(string value) + { + return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + } + diff --git a/Services/XygeniErrorListService.cs b/Services/XygeniErrorListService.cs new file mode 100644 index 0000000..99110d3 --- /dev/null +++ b/Services/XygeniErrorListService.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using EnvDTE; +using Microsoft.VisualStudio.Shell; +using vs2026_plugin.Models; + +namespace vs2026_plugin.Services +{ + public class XygeniErrorListService + { + private static XygeniErrorListService _instance; + + private readonly AsyncPackage _package; + private readonly ILogger _logger; + private readonly ErrorListProvider _errorListProvider; + private readonly XygeniIssueService _issueService; + private readonly object _issueLocationGate = new object(); + private List _issueLocations = new List(); + private readonly Dictionary _taskIssueMap = new Dictionary(); + + public event EventHandler IssueLocationsChanged; + + private XygeniErrorListService(AsyncPackage package, ILogger logger) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + _package = package; + _logger = logger; + + _errorListProvider = new ErrorListProvider(_package) + { + ProviderName = "Xygeni Issues" + }; + + _issueService = XygeniIssueService.GetInstance(); + _issueService.IssuesChanged += OnIssuesChanged; + } + + public static XygeniErrorListService GetInstance() + { + if (_instance == null) + { + throw new InvalidOperationException("XygeniErrorListService has not been initialized"); + } + + return _instance; + } + + public static XygeniErrorListService GetInstance(AsyncPackage package = null, ILogger logger = null) + { + if (_instance == null && package != null) + { + _instance = new XygeniErrorListService(package, logger); + } + + return _instance; + } + + public static bool TryGetInstance(out XygeniErrorListService instance) + { + instance = _instance; + return instance != null; + } + + private void OnIssuesChanged(object sender, EventArgs e) + { + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + Refresh(); + }); + } + + public void Refresh() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var issues = _issueService.GetIssues() ?? new List(); + string rootDirectory = GetRootDirectorySafe(); + var issueLocations = new List(); + + _errorListProvider.SuspendRefresh(); + + try + { + _errorListProvider.Tasks.Clear(); + _taskIssueMap.Clear(); + + foreach (var issue in issues) + { + if (issue == null) + { + continue; + } + + var task = new ErrorTask + { + Category = TaskCategory.BuildCompile, + ErrorCategory = GetTaskErrorCategory(issue.Severity), + Text = BuildTaskText(issue), + Document = ResolveIssuePath(issue.File, rootDirectory), + Line = Math.Max(0, issue.BeginLine - 1), + Column = Math.Max(0, issue.BeginColumn - 1) + }; + + _taskIssueMap[task] = issue; + task.Navigate += OnNavigate; + _errorListProvider.Tasks.Add(task); + + issueLocations.Add(CreateIssueLocation(issue, task.Document)); + } + } + finally + { + _errorListProvider.ResumeRefresh(); + } + + UpdateIssueLocations(issueLocations); + } + + private void OnNavigate(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + var errorTask = sender as ErrorTask; + if (errorTask == null || string.IsNullOrEmpty(errorTask.Document)) + { + return; + } + + var dte = ServiceProvider.GlobalProvider.GetService(typeof(DTE)) as DTE; + if (dte == null) + { + return; + } + + if (!File.Exists(errorTask.Document)) + { + return; + } + + var window = dte.ItemOperations.OpenFile(errorTask.Document); + if (window != null) + { + var selection = dte.ActiveDocument?.Selection as TextSelection; + if (selection != null) + { + selection.GotoLine(errorTask.Line + 1, true); + } + } + + // Open issue details panel for the navigated issue + _taskIssueMap.TryGetValue(errorTask, out var issue); + if (issue != null) + { + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await IssueDetailsService.GetInstance().ShowIssueDetailsAsync(issue); + }); + } + } + catch (Exception ex) + { + _logger?.Error(ex, "Error navigating from Xygeni Error List"); + } + } + + + private static TaskErrorCategory GetTaskErrorCategory(string severity) + { + switch ((severity ?? string.Empty).Trim().ToLowerInvariant()) + { + case "critical": + case "high": + return TaskErrorCategory.Error; + case "medium": + case "low": + return TaskErrorCategory.Warning; + default: + return TaskErrorCategory.Message; + } + } + + private static string BuildTaskText(IXygeniIssue issue) + { + string severity = string.IsNullOrWhiteSpace(issue.Severity) ? "info" : issue.Severity; + string type = string.IsNullOrWhiteSpace(issue.Type) ? "Issue" : issue.Type; + string category = string.IsNullOrWhiteSpace(issue.CategoryName) ? "Security" : issue.CategoryName; + return $"[{severity}] {category}: {type}"; + } + + private static string ResolveIssuePath(string issueFilePath, string rootDirectory) + { + if (string.IsNullOrWhiteSpace(issueFilePath)) + { + return string.Empty; + } + + try + { + if (Path.IsPathRooted(issueFilePath)) + { + return Path.GetFullPath(issueFilePath); + } + + if (!string.IsNullOrWhiteSpace(rootDirectory)) + { + return Path.GetFullPath(Path.Combine(rootDirectory, issueFilePath)); + } + } + catch + { + // Keep the original issue path if resolution fails. + } + + return issueFilePath; + } + + public IReadOnlyList GetIssueLocationsForDocument(string documentPath) + { + string normalizedDocumentPath = NormalizePath(documentPath); + if (string.IsNullOrEmpty(normalizedDocumentPath)) + { + return Array.Empty(); + } + + List issueLocationsSnapshot; + lock (_issueLocationGate) + { + issueLocationsSnapshot = new List(_issueLocations); + } + + if (issueLocationsSnapshot.Count == 0) + { + return Array.Empty(); + } + + var matches = new List(); + + foreach (var issueLocation in issueLocationsSnapshot) + { + if (issueLocation == null) + { + continue; + } + + if (IsIssueForCurrentFile(issueLocation.OriginalPath, issueLocation.DocumentPath, normalizedDocumentPath)) + { + matches.Add(issueLocation); + } + } + + return matches; + } + + private void UpdateIssueLocations(List issueLocations) + { + lock (_issueLocationGate) + { + _issueLocations = issueLocations ?? new List(); + } + + IssueLocationsChanged?.Invoke(this, EventArgs.Empty); + } + + private static XygeniIssueLocation CreateIssueLocation(IXygeniIssue issue, string documentPath) + { + int beginLine = issue.BeginLine > 0 ? issue.BeginLine : 1; + int beginColumn = issue.BeginColumn > 0 ? issue.BeginColumn : 1; + int endLine = issue.EndLine >= beginLine ? issue.EndLine : beginLine; + int endColumn = issue.EndColumn > 0 ? issue.EndColumn : beginColumn + 1; + + return new XygeniIssueLocation + { + OriginalPath = issue.File ?? string.Empty, + DocumentPath = NormalizePath(documentPath), + Message = BuildTaskText(issue), + Severity = issue.Severity ?? string.Empty, + BeginLine = beginLine, + EndLine = endLine, + BeginColumn = beginColumn, + EndColumn = endColumn + }; + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + string normalizedPath = path.Replace('/', Path.DirectorySeparatorChar); + return Path.GetFullPath(normalizedPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + catch + { + return path; + } + } + + private static string NormalizeRelativePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + return path.Replace('\\', '/').TrimStart('.', '/'); + } + + private static bool IsIssueForCurrentFile(string issueFilePath, string resolvedIssuePath, string currentFilePath) + { + string normalizedCurrent = NormalizePath(currentFilePath); + if (string.IsNullOrEmpty(normalizedCurrent)) + { + return false; + } + + string normalizedResolved = NormalizePath(resolvedIssuePath); + if (!string.IsNullOrEmpty(normalizedResolved) && + string.Equals(normalizedCurrent, normalizedResolved, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string normalizedIssue = NormalizePath(issueFilePath); + if (!string.IsNullOrEmpty(normalizedIssue) && + !string.IsNullOrEmpty(issueFilePath) && + Path.IsPathRooted(issueFilePath) && + string.Equals(normalizedCurrent, normalizedIssue, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string relativeIssuePath = NormalizeRelativePath(issueFilePath); + if (string.IsNullOrEmpty(relativeIssuePath)) + { + return false; + } + + string normalizedCurrentUnix = normalizedCurrent.Replace('\\', '/'); + if (normalizedCurrentUnix.EndsWith("/" + relativeIssuePath, StringComparison.OrdinalIgnoreCase) || + normalizedCurrentUnix.Equals(relativeIssuePath, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string currentFileName = Path.GetFileName(normalizedCurrent); + string issueFileName = Path.GetFileName(relativeIssuePath); + return string.Equals(currentFileName, issueFileName, StringComparison.OrdinalIgnoreCase); + } + + private static string GetRootDirectorySafe() + { + try + { + return ThreadHelper.JoinableTaskFactory.Run(async delegate + { + return await XygeniConfigurationService.GetInstance().GetRootDirectoryAsync(); + }); + } + catch + { + return string.Empty; + } + } + } +} diff --git a/Services/XygeniInstallerService.cs b/Services/XygeniInstallerService.cs index 889aa2a..f899710 100644 --- a/Services/XygeniInstallerService.cs +++ b/Services/XygeniInstallerService.cs @@ -2,11 +2,13 @@ using System.IO; using System.IO.Compression; using System.Net.Http; +using System.Net; using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; using vs2026_plugin.Services; +using vs2026_plugin.Models; namespace vs2026_plugin.Services { @@ -15,7 +17,6 @@ public class XygeniInstallerService private static XygeniInstallerService _instance; private readonly string _extensionPath; private readonly ILogger _logger; - private readonly HttpClient _httpClient; private const string XygeniGetScannerUrl = "https://get.xygeni.io/latest/scanner/"; private const string XygeniScannerZipName = "xygeni_scanner.zip"; @@ -35,7 +36,6 @@ private XygeniInstallerService(string extensionPath, ILogger logger) { _extensionPath = extensionPath; _logger = logger; - _httpClient = new HttpClient(); CheckScannerInstallation(); } @@ -201,13 +201,14 @@ public async Task IsValidTokenAsync(string xygeniApiUrl, string xygeniToke string testApiUrl = $"{xygeniApiUrl.TrimEnd('/')}/language"; try { + using (var httpClient = CreateHttpClient()) using (var request = new HttpRequestMessage(HttpMethod.Get, testApiUrl)) { if (!string.IsNullOrEmpty(xygeniToken)) { request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {xygeniToken}"); } - var response = await _httpClient.SendAsync(request); + var response = await httpClient.SendAsync(request); return response.StatusCode == System.Net.HttpStatusCode.OK; } } @@ -223,8 +224,11 @@ public async Task IsValidApiUrlAsync(string xygeniApiUrl) string pingUrl = $"{xygeniApiUrl.TrimEnd('/')}/ping"; try { - var response = await _httpClient.GetAsync(pingUrl); - return response.StatusCode == System.Net.HttpStatusCode.OK; + using (var httpClient = CreateHttpClient()) + { + var response = await httpClient.GetAsync(pingUrl); + return response.StatusCode == System.Net.HttpStatusCode.OK; + } } catch (Exception ex) { @@ -235,7 +239,8 @@ public async Task IsValidApiUrlAsync(string xygeniApiUrl) private async Task DownloadFileAsync(string url, string destinationPath) { - using (var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead)) + using (var httpClient = CreateHttpClient()) + using (var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead)) { response.EnsureSuccessStatusCode(); using (var stream = await response.Content.ReadAsStreamAsync()) @@ -281,6 +286,70 @@ private async Task ReadAllTextAsync(string path) } } + private HttpClient CreateHttpClient() + { + var handler = new HttpClientHandler(); + var proxySettings = GetProxySettingsSafe(); + var webProxy = BuildWebProxy(proxySettings); + + if (webProxy != null) + { + handler.Proxy = webProxy; + handler.UseProxy = true; + } + else + { + handler.UseProxy = false; + } + + return new HttpClient(handler, disposeHandler: true); + } + + private ProxySettings GetProxySettingsSafe() + { + try + { + return XygeniConfigurationService.GetInstance().GetProxySettings(); + } + catch + { + return null; + } + } + + private IWebProxy BuildWebProxy(ProxySettings proxySettings) + { + if (proxySettings == null || string.IsNullOrWhiteSpace(proxySettings.Host)) + { + return null; + } + + string protocol = string.IsNullOrWhiteSpace(proxySettings.Protocol) ? "http" : proxySettings.Protocol.Trim(); + string host = proxySettings.Host.Trim(); + string proxyUri = proxySettings.Port.HasValue + ? $"{protocol}://{host}:{proxySettings.Port.Value}" + : $"{protocol}://{host}"; + + var webProxy = new WebProxy(proxyUri); + + if (!string.IsNullOrWhiteSpace(proxySettings.Username)) + { + webProxy.Credentials = new NetworkCredential(proxySettings.Username.Trim(), proxySettings.Password ?? string.Empty); + } + else if (string.Equals(proxySettings.Authentication, "default", StringComparison.OrdinalIgnoreCase)) + { + webProxy.Credentials = CredentialCache.DefaultCredentials; + } + + if (!string.IsNullOrWhiteSpace(proxySettings.NonProxyHosts)) + { + webProxy.BypassList = proxySettings.NonProxyHosts + .Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries); + } + + return webProxy; + } + private void OnChanged() { Changed?.Invoke(this, EventArgs.Empty); diff --git a/Services/XygeniIssueLocation.cs b/Services/XygeniIssueLocation.cs new file mode 100644 index 0000000..d5d28a8 --- /dev/null +++ b/Services/XygeniIssueLocation.cs @@ -0,0 +1,14 @@ +namespace vs2026_plugin.Services +{ + public sealed class XygeniIssueLocation + { + public string OriginalPath { get; set; } + public string DocumentPath { get; set; } + public string Message { get; set; } + public string Severity { get; set; } + public int BeginLine { get; set; } + public int EndLine { get; set; } + public int BeginColumn { get; set; } + public int EndColumn { get; set; } + } +} diff --git a/Services/XygeniIssueService.cs b/Services/XygeniIssueService.cs index 14a9cb5..33ff2d2 100644 --- a/Services/XygeniIssueService.cs +++ b/Services/XygeniIssueService.cs @@ -19,20 +19,38 @@ public class XygeniIssueService { private static XygeniIssueService _instance; private readonly ILogger _logger; + private readonly XygeniScannerService _scannerService; private List _issues = new List(); private bool _isReadingIssues = false; + private bool _pendingReadIssues = false; public event EventHandler IssuesChanged; private XygeniIssueService(ILogger logger) { _logger = logger; - XygeniScannerService.GetInstance().Changed += (s, e) => + _scannerService = XygeniScannerService.GetInstance(); + _scannerService.Changed += (s, e) => { - ReadIssuesAsync(); + OnScannerChanged(); }; } + private void OnScannerChanged() + { + var latestScan = _scannerService.GetScans() + .OrderByDescending(s => s.Timestamp) + .FirstOrDefault(); + + // Refresh issues only when a scan has ended. A "running" update is emitted at scan start. + if (latestScan != null && string.Equals(latestScan.Status, "running", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _ = ReadIssuesAsync(); + } + @@ -78,7 +96,8 @@ public async Task ReadIssuesAsync() string suffix = XygeniCommands.ReportSuffix; if (_isReadingIssues) { - _logger.Log(" Issues are already being read, skipping..."); + _pendingReadIssues = true; + _logger.Log(" Issues are already being read, scheduling a refresh..."); return; } _logger.Log(" Issues report directory: " + workingDir); @@ -100,6 +119,12 @@ public async Task ReadIssuesAsync() { _isReadingIssues = false; _logger.Log("=================================================="); + + if (_pendingReadIssues) + { + _pendingReadIssues = false; + _ = ReadIssuesAsync(); + } } } diff --git a/Services/XygeniScannerService.cs b/Services/XygeniScannerService.cs index c45b853..e979b05 100644 --- a/Services/XygeniScannerService.cs +++ b/Services/XygeniScannerService.cs @@ -9,6 +9,7 @@ using Microsoft.VisualStudio.Shell.Settings; using vs2026_plugin.Services; using vs2026_plugin.Commands; +using vs2026_plugin.Models; namespace vs2026_plugin.Services { @@ -292,7 +293,55 @@ private async Task GetEnvVariables(Dictionary env) env["XYGENI_TOKEN"] = token; } - // Note: Proxy settings are not yet implemented in C# side + ApplyProxyEnvironmentVariables(env, _configurationService.GetProxySettings()); + } + + private void ApplyProxyEnvironmentVariables(Dictionary env, ProxySettings proxySettings) + { + if (proxySettings == null || string.IsNullOrWhiteSpace(proxySettings.Host)) + { + return; + } + + string protocol = string.IsNullOrWhiteSpace(proxySettings.Protocol) ? "http" : proxySettings.Protocol.Trim(); + string host = proxySettings.Host.Trim(); + string portPart = proxySettings.Port.HasValue ? $":{proxySettings.Port.Value}" : string.Empty; + string credentials = string.Empty; + + if (!string.IsNullOrWhiteSpace(proxySettings.Username)) + { + string username = Uri.EscapeDataString(proxySettings.Username.Trim()); + string password = Uri.EscapeDataString(proxySettings.Password ?? string.Empty); + credentials = $"{username}:{password}@"; + } + + string proxyUrl = $"{protocol}://{credentials}{host}{portPart}"; + + env["HTTP_PROXY"] = proxyUrl; + env["HTTPS_PROXY"] = proxyUrl; + + if (!string.IsNullOrWhiteSpace(proxySettings.NonProxyHosts)) + { + env["NO_PROXY"] = proxySettings.NonProxyHosts.Trim(); + } + + SetEnvIfPresent(env, "PROXY_PROTOCOL", proxySettings.Protocol); + SetEnvIfPresent(env, "PROXY_HOST", proxySettings.Host); + SetEnvIfPresent(env, "PROXY_AUTH", proxySettings.Authentication); + SetEnvIfPresent(env, "PROXY_USERNAME", proxySettings.Username); + SetEnvIfPresent(env, "PROXY_PASSWORD", proxySettings.Password); + if (proxySettings.Port.HasValue) + { + env["PROXY_PORT"] = proxySettings.Port.Value.ToString(); + } + } + + private void SetEnvIfPresent(Dictionary env, string key, string value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + env[key] = value.Trim(); + } } private string GetScannerScriptPath(string xygeniScannerPath) diff --git a/UI/Control/XygeniConfigurationControl.xaml b/UI/Control/XygeniConfigurationControl.xaml index 99092a0..a4ee907 100755 --- a/UI/Control/XygeniConfigurationControl.xaml +++ b/UI/Control/XygeniConfigurationControl.xaml @@ -5,10 +5,9 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:vsshell="clr-namespace:Microsoft.VisualStudio.PlatformUI;assembly=Microsoft.VisualStudio.Shell.15.0" mc:Ignorable="d" - d:DesignHeight="300" d:DesignWidth="300"> - + d:DesignHeight="420" d:DesignWidth="350"> + - - - - - - - - - - - - - - + + + + - - + + - - - - - - - @@ -89,11 +44,21 @@ - - + + + + + + diff --git a/UI/Control/XygeniExplorerControl.xaml.cs b/UI/Control/XygeniExplorerControl.xaml.cs index 106a703..dfe5136 100644 --- a/UI/Control/XygeniExplorerControl.xaml.cs +++ b/UI/Control/XygeniExplorerControl.xaml.cs @@ -1,17 +1,16 @@ using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.ComponentModel; using System.IO; -using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; +using System.Windows.Media; using Microsoft.VisualStudio.Shell; using vs2026_plugin.Services; using vs2026_plugin.Models; using vs2026_plugin.Commands; using System.Reflection; +using System.Windows.Media.Imaging; namespace vs2026_plugin.UI.Control @@ -22,6 +21,7 @@ public class TreeNodeData public string IconPath { get; set; } public string DisplayText { get; set; } public object Tag { get; set; } + public bool IsExpanded { get; set; } public ObservableCollection Items { get; } = new ObservableCollection(); @@ -39,24 +39,37 @@ public partial class XygeniExplorerControl : UserControl { private readonly XygeniIssueService _issueService; private readonly XygeniScannerService _scannerService; - private readonly XygeniInstallerService _installerService; private readonly XygeniExplorerViewModel _vm; public XygeniExplorerControl() { InitializeComponent(); + SetRunButtonIcon(); - var issueService = XygeniIssueService.GetInstance(); - var scannerService = XygeniScannerService.GetInstance(); + _issueService = XygeniIssueService.GetInstance(); + _scannerService = XygeniScannerService.GetInstance(); - _vm = new XygeniExplorerViewModel(issueService, scannerService); + _vm = new XygeniExplorerViewModel(_issueService, _scannerService); DataContext = _vm; _vm.IssueSelected += OnIssueSelected; - issueService.IssuesChanged += OnIssuesChanged; - + _issueService.IssuesChanged += OnIssuesChanged; + _scannerService.Changed += OnScannerChanged; + + _vm.Refresh(); + } + + private void SetRunButtonIcon() + { + string baseDir = Path.GetDirectoryName(this.GetType().Assembly.Location); + string iconPath = Path.Combine(baseDir, "media", "icons", "play.png"); + + if (File.Exists(iconPath)) + { + RunScanIcon.Source = new BitmapImage(new Uri(iconPath, UriKind.Absolute)); + } } private async void OnIssuesChanged(object sender, EventArgs e) @@ -65,7 +78,11 @@ private async void OnIssuesChanged(object sender, EventArgs e) _vm.Refresh(); } - + private async void OnScannerChanged(object sender, EventArgs e) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + _vm.Refresh(); + } @@ -79,7 +96,7 @@ private void ExplorerTree_SelectedItemChanged(object sender, RoutedPropertyChang { if (_vm != null) { - if (e.NewValue is TreeViewItem item && item.Header is TreeNodeData nodeData) + if (e.NewValue is TreeNodeData nodeData) { _vm.SelectedItem = nodeData; } @@ -90,6 +107,36 @@ private void ExplorerTree_SelectedItemChanged(object sender, RoutedPropertyChang } } + private void ExplorerTree_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (_vm == null) return; + + var clickedContainer = FindVisualParent(e.OriginalSource as DependencyObject); + if (clickedContainer?.DataContext is TreeNodeData nodeData && + nodeData.Tag is IXygeniIssue) + { + // Force issue-row selection to avoid the category container keeping selection. + clickedContainer.IsSelected = true; + clickedContainer.Focus(); + e.Handled = true; + } + } + + private static T FindVisualParent(DependencyObject child) where T : DependencyObject + { + while (child != null) + { + if (child is T typedParent) + { + return typedParent; + } + + child = VisualTreeHelper.GetParent(child); + } + + return null; + } + private void OnIssueSelected(IXygeniIssue issue) { diff --git a/UI/Control/XygeniExplorerViewModel.cs b/UI/Control/XygeniExplorerViewModel.cs index 4b5f954..4679ad4 100644 --- a/UI/Control/XygeniExplorerViewModel.cs +++ b/UI/Control/XygeniExplorerViewModel.cs @@ -1,7 +1,5 @@ using System.Collections.ObjectModel; -using System.Collections.Generic; using System.ComponentModel; -using System.Windows.Controls; using System.IO; using System.Linq; using System; @@ -15,8 +13,8 @@ namespace vs2026_plugin.UI.Control { public class XygeniExplorerViewModel : INotifyPropertyChanged { - public ObservableCollection RootItems { get; } - = new ObservableCollection(); + public ObservableCollection RootItems { get; } + = new ObservableCollection(); private TreeNodeData _selectedItem; @@ -57,22 +55,27 @@ public void Refresh() RootItems.Clear(); // 1. Scan Executions - var scansRoot = new TreeViewItem { Header = "Scan Executions", IsExpanded = true }; + var scansRoot = new TreeNodeData("Scan Executions") + { + IsExpanded = true + }; foreach (var scan in _scannerService.GetScans() .OrderByDescending(s => s.Timestamp)) { - scansRoot.Items.Add(new TreeViewItem - { - Header = $"[{scan.Timestamp:HH:mm:ss}] {scan.Status} {scan.Summary}", - Tag = scan - }); + scansRoot.Items.Add(new TreeNodeData( + $"[{scan.Timestamp:HH:mm:ss}] {scan.Status} {scan.Summary}", + tag: scan + )); } RootItems.Add(scansRoot); // 2. Issues by Category - var issuesRoot = new TreeViewItem { Header = "Issues by Category", IsExpanded = true }; + var issuesRoot = new TreeNodeData("Issues by Category") + { + IsExpanded = true + }; var categories = _issueService.GetIssues() .GroupBy(i => i.CategoryName) @@ -80,14 +83,11 @@ public void Refresh() foreach (var group in categories) { - var catNode = new TreeViewItem - { - Header = new TreeNodeData( - $"{group.Key} ({group.Count()})", - GetCategoryIcon(group.Key) - ), - Tag = group - }; + var catNode = new TreeNodeData( + $"{group.Key} ({group.Count()})", + GetCategoryIcon(group.Key), + group + ); foreach (var issue in group.OrderBy(i => i.GetSeverityLevel())) { @@ -96,12 +96,7 @@ public void Refresh() GetSeverityIcon(issue.Severity), issue ); - var issueItem = new TreeViewItem - { - Header = issueNodeData, - Tag = issue - }; - catNode.Items.Add(issueItem); + catNode.Items.Add(issueNodeData); } issuesRoot.Items.Add(catNode); @@ -193,7 +188,7 @@ private string GetIconPath(string iconFileName) { if (string.IsNullOrEmpty(iconFileName)) return null; - string baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string baseDir = Path.GetDirectoryName(this.GetType().Assembly.Location); string iconPath = Path.Combine(baseDir, "media", "icons", iconFileName); return iconPath; } diff --git a/source.extension.vsixmanifest b/source.extension.vsixmanifest index 4ada413..b181412 100755 --- a/source.extension.vsixmanifest +++ b/source.extension.vsixmanifest @@ -1,7 +1,7 @@ - + Xygeni Security for Visual Studio Secure your codebase with Secrets, SAST, SCA, IaC & Supply Chain scanning directly within your Visual Studio environment. logo_xy.png diff --git a/vs2026-plugin.csproj b/vs2026-plugin.csproj index 5a0b371..aac5556 100755 --- a/vs2026-plugin.csproj +++ b/vs2026-plugin.csproj @@ -14,7 +14,7 @@ Library Properties vs2026_plugin - vs2026-plugin + xygeni-extension v4.7.2 true true @@ -64,8 +64,11 @@ + + + @@ -73,6 +76,7 @@ + ApplyChangesDialog.xaml @@ -207,4 +211,4 @@ --> - \ No newline at end of file + diff --git a/vs2026_pluginPackage.cs b/vs2026_pluginPackage.cs index 3804549..0074f74 100755 --- a/vs2026_pluginPackage.cs +++ b/vs2026_pluginPackage.cs @@ -6,7 +6,7 @@ using Microsoft.VisualStudio.Shell.Interop; using vs2026_plugin.Services; using System.IO; -using Task = System.Threading.Tasks.Task; +using System.Threading.Tasks; @@ -41,10 +41,13 @@ public sealed class vs2026_pluginPackage : AsyncPackage /// vs2026_pluginPackage GUID string. /// public const string PackageGuidString = "b441d6b9-1770-4351-826d-479748bb2ff9"; + private static readonly Guid XygeniOutputPaneGuid = new Guid("D0E6C712-4B6A-4A73-9095-2BB6E30D42A9"); + private static readonly Guid OutputToolWindowGuid = new Guid("34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3"); public static vs2026_pluginPackage Instance { get; private set; } private IVsOutputWindowPane _outputPane; + private XygeniErrorListService _errorListService; public ILogger Logger { get; private set; } /// @@ -62,19 +65,8 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); // Initialize Output Channel - Guid generalPaneGuid = new Guid("D0E6C712-4B6A-4A73-9095-2BB6E30D42A9"); - - // 1. Get the Output Window service - IVsOutputWindow outWindow = await GetServiceAsync(typeof(SVsOutputWindow)) as IVsOutputWindow; - - IVsOutputWindowPane generalPane; - int hr = outWindow.GetPane(ref generalPaneGuid, out generalPane); - - if (ErrorHandler.Failed(hr) || generalPane == null) - { - outWindow.CreatePane(ref generalPaneGuid, "Xygeni Output", 1, 1); - outWindow.GetPane(ref generalPaneGuid, out generalPane); - } + IVsOutputWindowPane generalPane = await GetOrCreateOutputPaneAsync(); + _outputPane = generalPane; // Initialize Logger Logger = new XygeniOutputLogger(generalPane); @@ -88,6 +80,8 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke XygeniScannerService.GetInstance(Logger); XygeniIssueService.GetInstance(Logger); IssueDetailsService.GetInstance(this, Logger); + _errorListService = XygeniErrorListService.GetInstance(this, Logger); + _errorListService.Refresh(); await Commands.XygeniSettingsCommand.InitializeAsync(this); await Commands.XygeniExplorerCommand.InitializeAsync(this); @@ -96,6 +90,58 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke var initEvents = new InitEvents(this, Logger); initEvents.registerEvents(); } + + public async Task ShowOutputPaneAsync() + { + await JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken); + await ShowOutputToolWindowAsync(); + if (_outputPane == null) + { + _outputPane = await GetOrCreateOutputPaneAsync(); + } + _outputPane?.Activate(); + } + + private async Task ShowOutputToolWindowAsync() + { + await JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken); + + IVsUIShell uiShell = await GetServiceAsync(typeof(SVsUIShell)) as IVsUIShell; + if (uiShell == null) + { + return; + } + + Guid outputWindowGuid = OutputToolWindowGuid; + IVsWindowFrame outputFrame; + int hr = uiShell.FindToolWindow(0, ref outputWindowGuid, out outputFrame); + if (ErrorHandler.Succeeded(hr) && outputFrame != null) + { + ErrorHandler.ThrowOnFailure(outputFrame.Show()); + } + } + + private async Task GetOrCreateOutputPaneAsync() + { + await JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken); + + IVsOutputWindow outWindow = await GetServiceAsync(typeof(SVsOutputWindow)) as IVsOutputWindow; + if (outWindow == null) + { + return null; + } + + Guid paneGuid = XygeniOutputPaneGuid; + IVsOutputWindowPane pane; + int hr = outWindow.GetPane(ref paneGuid, out pane); + if (ErrorHandler.Failed(hr) || pane == null) + { + outWindow.CreatePane(ref paneGuid, "Xygeni Output", 1, 1); + outWindow.GetPane(ref paneGuid, out pane); + } + + return pane; + } // When project/solution is loaded, install scanner and read issues public void OnWorkspaceReady()