diff --git a/README.md b/README.md index 3e6980d..982b315 100644 --- a/README.md +++ b/README.md @@ -1,189 +1,109 @@ -# ide-visualstudio-plugin +# Conviso Platform for Visual Studio -Visual Studio 2022 extension for Conviso Platform integration. +Analyze code, review security vulnerabilities, receive AI-powered remediation guidance, and interact with Conviso AI without leaving Visual Studio. -## What this repository is +## 🔐 What can you do with Conviso Platform for Visual Studio? -This repository is the Visual Studio port of the existing Conviso IDE plugin ecosystem. -It is no longer just a blank scaffold: the extension already exposes commands, tool windows, settings persistence, GraphQL-backed views, and broker-backed chat actions. +- **Analyze code and receive AI-powered security recommendations.** + Select code in the editor to identify potential security issues, understand their risks, and receive practical remediation guidance. -What is still true is that the port has not been validated end to end on a Windows + Visual Studio environment yet. Because of that, the main risk today is not missing code structure, but unclear operator guidance and unknown runtime gaps. +- **Apply AI-assisted code fixes.** + When an AI response includes a suggested code block, apply it directly to the current editor selection after reviewing and confirming the change. -## Current scope +- **Review vulnerabilities from Conviso Platform.** + Filter vulnerabilities by asset for the configured company, inspect their details, request an AI-generated fix, and update vulnerability status from Visual Studio. -Implemented in the repository: +- **Track projects, security requirements, and activities.** + Browse projects and their requirements, inspect related activities, and update project or activity status. -- VSIX project and solution structure for Visual Studio 2022 -- `AsyncPackage` bootstrap and command registration -- `Conviso Settings` tool window -- `Conviso Chat` tool window with: - - broker connection - - free-form chat - - attach selection - - analyze selected code - - check similar issues across the workspace - - apply suggested fix from the latest assistant response - - mark response as helpful -- `Conviso Vulnerabilities` tool window with: - - company filter - - asset filter - - vulnerability list - - vulnerability details - - generate-fix action - - vulnerability status update -- `Conviso Requirements` tool window with: - - projects list - - requirement list per project - - activities list per requirement - - project status update - - activity status update -- `Conviso Pipeline Breaks` tool window with list and details -- GraphQL diagnostics routed to the Visual Studio ActivityLog -- Secrets stored with Windows user protection through the settings service +- **Investigate pipeline breaks.** + Review security gate executions and inspect their status, source, asset, trigger, and failure reasons. -Known gaps and risks: +- **Chat with Conviso AI using IDE context.** + Attach a code selection, analyze selected code, include workspace files when checking for similar issues, apply a suggested fix, and mark useful responses. -- no validated Windows test pass yet -- build and installation flow has not been documented until now -- broker compatibility still depends on the target environment -- local AST scanner parity with the VS Code extension is not present -- there is no automated validation in this repository for the VSIX runtime behavior +> The current Visual Studio extension does not include a standalone local repository scanner or a separate Repository Vulnerabilities view. -## Quick start +--- -### Prerequisites +## 🔑 Getting access -- Windows -- Visual Studio 2022 -- access to a Conviso API token -- access to a broker endpoint and API key when chat features are used +You need a Conviso Platform account and a valid Platform API token. -### Install and open +If you do not have an account, sign up or start a free trial: -For end users and QA: +👉 https://www.convisoappsec.com/ -1. Install the provided `.vsix` package. -2. Open Visual Studio 2022. -3. Open a repository or project with editable source files. +--- -For maintainers preparing a test build: +## 🛠️ Installation and configuration -1. Open `Conviso.Platform.VisualStudio.sln` in Visual Studio 2022. -2. Restore packages if Visual Studio prompts for it. -3. Build the solution in `Debug` or `Release`. -4. Start the extension in the Experimental Instance with `F5`, or distribute the generated `.vsix` on Windows. +### Install the extension -Notes: +1. Go to **Extensions > Manage Extensions > Online**. +2. Search for **Conviso Platform**. +3. Install. -- The project targets `.NET Framework 4.7.2`. -- This repository can be edited on macOS, but the VSIX cannot be validated end to end outside Windows + Visual Studio 2022. +### Configure Conviso Platform -## How to use the extension +1. In Visual Studio, open **Tools > Conviso Settings**. +2. On the **Platform API** tab, enter your **API Token**. +3. Open the **Scope** tab and choose the company to use. +4. Select **Save Settings**. -After installation, open the commands from the Visual Studio `Tools` menu: +You can use **Test API** and **Test Chat** to validate the credentials. The API token is stored using Windows user-level data protection. -- `Conviso Chat` -- `Conviso Vulnerabilities` -- `Conviso Requirements` -- `Conviso Pipeline Breaks` -- `Conviso Settings` -- `Analyze + Suggest Fix` -- `Attach Selection to Chat` -- `Check Similar Issues` +--- -### 1. Configure settings first +## 🚀 How to use -Open `Tools > Conviso Settings` and fill: +Open extension features from Visual Studio's **Tools** menu: -- `API Base URL` -- `API Token` -- `Company ID` -- `Requirements Scope ID` -- `Broker Endpoint` -- `Broker API Key` +- **Conviso Chat** — opens **AI Autonomous AppSec** for security questions, code analysis, workspace similarity checks, and AI-assisted fixes. +- **Conviso Vulnerabilities** — reviews and manages vulnerabilities from Conviso Platform. +- **Conviso Requirements** — browses projects, requirements, and related activities. +- **Conviso Pipeline Breaks** — investigates security gate executions and their failure reasons. +- **Conviso Settings** — configures the API token and company. -Useful behavior already implemented: +The **Tools** menu also provides direct editor actions: -- `Load Companies` fetches accessible companies from the API -- `Use Selected Company` copies the selected company into `Company ID` and `Requirements Scope ID` -- `Save Settings` persists text values and stores secrets with Windows user protection -- `Test API` performs a GraphQL round-trip -- `Test Broker` attempts a WebSocket connection -- `Use Defaults` restores the default API base URL and broker endpoint +- **Analyze + Suggest Fix** +- **Attach Selection to Chat** +- **Check Similar Issues** -### 2. Use chat features +### Analyze selected code -Open `Tools > Conviso Chat`. +1. Open a source file and select the relevant code. +2. Choose **Tools > Analyze + Suggest Fix**, or open **Conviso Chat** and use **Analyze + Suggest Fix**. +3. Review the response in **AI Autonomous AppSec**. +4. If the response contains a fenced code block, select the destination code and choose **Apply Suggested Fix**. -The chat window supports: +The extension always asks for confirmation before replacing the current selection. -- `Connect Chat` -- `Send Message` -- `Attach Selection` -- `Analyze + Suggest Fix` -- `Check Similar Issues` -- `Apply Suggested Fix` -- `Mark Helpful` +### Use selection and workspace context -Expected workflow: +- **Attach Selection** adds the current code selection to subsequent chat messages. +- **Clear** removes the attached selection. +- **Check Similar Issues** collects supported workspace files and asks Conviso AI to identify similar patterns. +- **Clear Chat** removes the current transcript. -1. Open a file and select the code you want to analyze. -2. Use `Attach Selection` if you want the selected code added as context to the chat session. -3. Use `Analyze + Suggest Fix` to send the current selection for analysis. -4. Use `Check Similar Issues` to scan the workspace for similar patterns. -5. If the assistant returns a fenced code block, use `Apply Suggested Fix` to replace the current selection after confirmation. +### Manage vulnerabilities -Important behavior: +1. Open **Tools > Conviso Vulnerabilities**. +2. Optionally filter by asset. +3. Select a vulnerability to inspect its title, description, severity, status, and asset. +4. Use **Generate Fix** to request an AI-assisted remediation result. +5. Enter a permitted status and use **Update Status** to update the finding in Conviso Platform. -- `Analyze + Suggest Fix`, `Attach Selection to Chat`, and `Check Similar Issues` are also exposed as top-level menu commands and will open the chat window automatically. -- `Apply Suggested Fix` only works when there is a current editor selection and the assistant response contains a fenced code block. +### Review requirements and pipeline breaks -### 3. Use data views +- In **Conviso Requirements**, select a project to view its details and requirements, then select a requirement to inspect its activities. Project and activity status can be updated from their detail panels. +- In **Conviso Pipeline Breaks**, select an execution to inspect its status, date, trigger, source, asset, and severity-based failure reasons. -`Conviso Vulnerabilities` +--- -- filter by company and asset -- refresh the list -- inspect details -- generate a fix suggestion -- update vulnerability status +## 💡 Pro tip -`Conviso Requirements` +Use Conviso Platform throughout development: attach the smallest relevant code selection for focused AI guidance, check for similar patterns across the workspace, and review Platform findings before committing your changes. -- load projects -- inspect project details -- inspect requirements for a selected project -- inspect activities for a selected requirement -- update project status -- update activity status - -`Conviso Pipeline Breaks` - -- load the list of pipeline break executions -- inspect execution details for the selected row - -## Recommended validation flow - -Use the checklist in [docs/manual-validation.md](/Users/welias/welias_files/dev/ide-visualstudio-plugin/docs/manual-validation.md) before calling this port production-ready. - -## Troubleshooting - -- If API requests fail, inspect the Visual Studio ActivityLog. GraphQL request and response details are logged there. -- If company discovery fails, verify `API Base URL` and `API Token` first. -- If chat does not connect, verify the broker endpoint resolves to `/ws` and that the broker API key is valid. -- If `Apply Suggested Fix` is disabled or fails, confirm that: - - the assistant returned a fenced code block - - the target file is open - - the intended replacement region is selected - -## Repository layout - -- `src/Conviso.Platform.VisualStudio`: Visual Studio VSIX project -- `docs/architecture.md`: port architecture and runtime boundaries -- `docs/manual-validation.md`: manual test checklist for Windows validation - -## References - -- `../platform-ide-plugins/docs/ide-adapters.md` -- `../platform-ide-plugins/docs/protocol.md` -- `../ide-vscode-plugin/docs/architecture.md` +Build secure software without leaving **Visual Studio** using **Conviso Platform**. diff --git a/docs/README-internal.md b/docs/README-internal.md new file mode 100644 index 0000000..bcc8b21 --- /dev/null +++ b/docs/README-internal.md @@ -0,0 +1,186 @@ +# ide-visualstudio-plugin + +Visual Studio 2022 extension for Conviso Platform integration. + +## What this repository is + +This repository is the Visual Studio port of the existing Conviso IDE plugin ecosystem. +It is no longer just a blank scaffold: the extension already exposes commands, tool windows, settings persistence, GraphQL-backed views, and broker-backed chat actions. + +What is still true is that the port has not been validated end to end on a Windows + Visual Studio environment yet. Because of that, the main risk today is not missing code structure, but unclear operator guidance and unknown runtime gaps. + +## Current scope + +Implemented in the repository: + +- VSIX project and solution structure for Visual Studio 2022 +- `AsyncPackage` bootstrap and command registration +- `Conviso Settings` tool window +- `Conviso Chat` tool window with: + - broker connection + - free-form chat + - attach selection + - analyze selected code + - check similar issues across the workspace + - apply suggested fix from the latest assistant response + - mark response as helpful +- `Conviso Vulnerabilities` tool window with: + - globally configured company + - asset filter + - vulnerability list + - vulnerability details + - generate-fix action + - vulnerability status update +- `Conviso Requirements` tool window with: + - projects list + - requirement list per project + - activities list per requirement + - project status update + - activity status update +- `Conviso Pipeline Breaks` tool window with list and details +- GraphQL diagnostics routed to the Visual Studio ActivityLog +- Secrets stored with Windows user protection through the settings service + +Known gaps and risks: + +- no validated Windows test pass yet +- build and installation flow has not been documented until now +- broker compatibility still depends on the target environment +- local AST scanner parity with the VS Code extension is not present +- there is no automated validation in this repository for the VSIX runtime behavior + +## Quick start + +### Prerequisites + +- Windows +- Visual Studio 2022 +- access to a Conviso API token +- access to a broker endpoint and API key when chat features are used + +### Install and open + +For end users and QA: + +1. Install the provided `.vsix` package. +2. Open Visual Studio 2022. +3. Open a repository or project with editable source files. + +For maintainers preparing a test build: + +1. Open `Conviso.Platform.VisualStudio.sln` in Visual Studio 2022. +2. Restore packages if Visual Studio prompts for it. +3. Build the solution in `Debug` or `Release`. +4. Start the extension in the Experimental Instance with `F5`, or distribute the generated `.vsix` on Windows. + +Notes: + +- The project targets `.NET Framework 4.7.2`. +- This repository can be edited on macOS, but the VSIX cannot be validated end to end outside Windows + Visual Studio 2022. + +## How to use the extension + +After installation, open the commands from the Visual Studio `Tools` menu: + +- `Conviso Chat` +- `Conviso Vulnerabilities` +- `Conviso Requirements` +- `Conviso Pipeline Breaks` +- `Conviso Settings` +- `Analyze + Suggest Fix` +- `Attach Selection to Chat` +- `Check Similar Issues` + +### 1. Configure settings first + +Open `Tools > Conviso Settings` and fill: + +- `API Token` +- `Company` on the `Scope` tab + +Useful behavior already implemented: + +- opening `Scope` fetches accessible companies from the API +- the first accessible company is selected automatically on first configuration +- changing `Company` persists the global company immediately +- `Save Settings` stores the API token with Windows user protection +- `Test API` performs a GraphQL round-trip +- `Test Broker` attempts a WebSocket connection + +### 2. Use chat features + +Open `Tools > Conviso Chat`. + +The chat window supports: + +- `Send Message` +- `Attach Selection` +- `Analyze + Suggest Fix` +- `Check Similar Issues` +- `Apply Suggested Fix` +- `Mark Helpful` + +The broker connection is established automatically when a chat action needs it. A centered `Thinking...` indicator uses a permanently reserved line and is empty when no response is pending. + +Expected workflow: + +1. Open a file and select the code you want to analyze. +2. Use `Attach Selection` if you want the selected code added as context to the chat session. +3. Use `Analyze + Suggest Fix` to send the current selection for analysis. +4. Use `Check Similar Issues` to scan the workspace for similar patterns. +5. If the assistant returns a fenced code block, use `Apply Suggested Fix` to replace the current selection after confirmation. + +Important behavior: + +- `Analyze + Suggest Fix`, `Attach Selection to Chat`, and `Check Similar Issues` are also exposed as top-level menu commands and will open the chat window automatically. +- `Apply Suggested Fix` only works when there is a current editor selection and the assistant response contains a fenced code block. + +### 3. Use data views + +`Conviso Vulnerabilities` + +- use the globally configured company and filter by asset +- refresh the list +- inspect details +- generate a fix suggestion +- update vulnerability status + +`Conviso Requirements` + +- load projects +- inspect project details +- inspect requirements for a selected project +- inspect activities for a selected requirement +- update project status +- update activity status + +`Conviso Pipeline Breaks` + +- load the list of pipeline break executions +- inspect execution details for the selected row + +## Recommended validation flow + +Use the checklist in `docs/manual-validation.md` before calling this port production-ready. + +## Troubleshooting + +- If API requests fail, inspect the Visual Studio ActivityLog. GraphQL request and response details are logged there. +- If company discovery fails, verify `API Base URL` and `API Token` first. +- If chat does not connect, verify the broker endpoint resolves to `/ws` and that the broker API key is valid. +- If `Apply Suggested Fix` is disabled or fails, confirm that: + - the assistant returned a fenced code block + - the target file is open + - the intended replacement region is selected + +## Repository layout + +- `src/Conviso.Platform.VisualStudio`: Visual Studio VSIX project +- `docs/architecture.md`: port architecture and runtime boundaries +- `docs/manual-validation.md`: manual test checklist for Windows validation + +## References + +- `../platform-ide-plugins/docs/ide-adapters.md` +- `../platform-ide-plugins/docs/protocol.md` +- `../ide-vscode-plugin/docs/architecture.md` diff --git a/docs/manual-validation.md b/docs/manual-validation.md index 6bb9c02..cb882ed 100644 --- a/docs/manual-validation.md +++ b/docs/manual-validation.md @@ -48,50 +48,44 @@ Expected result: 1. Open `Tools > Conviso Settings`. 2. Verify all fields render: - - API Base URL - API Token - - Company ID - - Requirements Scope ID - - Accessible companies - - Broker Endpoint - - Broker API Key -3. Click `Use Defaults`. -4. Enter valid API and broker credentials. -5. Click `Save Settings`. -6. Click `Test API`. -7. Click `Load Companies`. -8. Select a company and click `Use Selected Company`. -9. Click `Save Settings` again. -10. Click `Test Broker`. + - Company +3. Enter a valid API token. +4. Click `Save Settings`. +5. Click `Test API`. +6. Open the `Scope` tab. +7. Confirm that accessible companies load automatically. +8. If this is the first configuration, confirm that the first company is selected automatically. +9. Select another company, close the settings window, reopen it, and return to `Scope`. +10. Click `Test Chat`. Expected result: - status messages change coherently after each action - accessible companies load for a valid token -- selected company fills both `Company ID` and `Requirements Scope ID` +- the selected company is saved immediately and restored when settings are reopened - API test succeeds or returns a clear permission error -- broker test succeeds or returns a clear connection error +- chat test succeeds or returns a clear connection error ## Chat window 1. Open `Tools > Conviso Chat`. -2. Click `Connect Chat`. -3. Send a plain message. +2. Send a plain message without connecting manually. +3. Confirm that `Thinking...` appears centered between the transcript and message input while the response is pending, then changes to an empty reserved line. 4. Open a code file, select a snippet, and click `Attach Selection`. 5. Click `Analyze + Suggest Fix`. 6. Click `Check Similar Issues`. 7. If a response contains a fenced code block, select the target code and click `Apply Suggested Fix`. 8. Click `Mark Helpful`. -9. Click `Disconnect`. Expected result: -- connection status changes to connected +- the chat connects automatically when an action needs the broker - transcript shows user and assistant messages +- the transient `Thinking...` indicator is empty when no response is pending without shifting the input - attach-selection updates the context summary - analyze and similarity actions append responses instead of failing silently - apply-fix asks for confirmation and replaces only the current selection -- disconnect updates the status and transcript ## Menu command shortcuts @@ -108,18 +102,22 @@ Expected result: ## Vulnerabilities window 1. Open `Tools > Conviso Vulnerabilities`. -2. Confirm the company filter loads. -3. Change the company filter. -4. Change the asset filter. -5. Click `Refresh`. -6. Select a vulnerability row. -7. Review the details panel. -8. Click `Generate Fix`. -9. Enter a new status and click `Update Status`. +2. Confirm that data uses the company configured in `Conviso Settings > Scope`. +3. Type part of a vulnerability title in `Filter by title...`. +4. Confirm results change on each keystroke without a loading request. +5. Clear the text filter and confirm all loaded vulnerabilities return. +6. Change the asset filter. +7. Click `Refresh`. +8. Select a vulnerability row. +9. Review the details panel. +10. Click `Generate Fix`. +11. Enter a new status and click `Update Status`. Expected result: - list loads without UI crashes +- the title is the first column and horizontal scrolling is available when columns overflow +- title filtering is case-insensitive, supports no-match results, and does not discard loaded items - filters do not break the view - selecting a row loads details - generate-fix returns a meaningful result or a clear backend error @@ -129,17 +127,22 @@ Expected result: 1. Open `Tools > Conviso Requirements`. 2. Click `Refresh`. -3. Select a project. -4. Verify project details load. -5. Select a requirement. -6. Verify requirement details and activities load. -7. Select an activity. -8. Change project status and click `Update Project`. -9. Change activity status and click `Update Activity`. +3. Filter projects by label, then clear the filter. +4. Select a project. +5. Verify project details load. +6. Filter requirements by label, then clear the filter. +7. Select a requirement. +8. Verify requirement details and activities load. +9. Filter activities by title, then clear the filter. +10. Select an activity. +11. Change project status and click `Update Project`. +12. Change activity status and click `Update Activity`. Expected result: - projects load +- project, requirement, and activity lists show horizontal scrollbars when their columns overflow +- each text filter updates only its local loaded list and preserves the full list when cleared - selecting a project populates requirements - selecting a requirement populates activities - detail sidebars update correctly @@ -149,11 +152,14 @@ Expected result: 1. Open `Tools > Conviso Pipeline Breaks`. 2. Click `Refresh`. -3. Select an item. +3. Filter the list by part of an asset name, then clear the filter. +4. Select an item. Expected result: - list loads +- the list shows a horizontal scrollbar when its columns overflow +- asset filtering updates immediately without an additional API request - selecting a row loads execution details - empty-state or setup-state messages remain readable diff --git a/src/Conviso.Platform.VisualStudio/AssemblyInfo.cs b/src/Conviso.Platform.VisualStudio/AssemblyInfo.cs index 0f26709..21bed72 100644 --- a/src/Conviso.Platform.VisualStudio/AssemblyInfo.cs +++ b/src/Conviso.Platform.VisualStudio/AssemblyInfo.cs @@ -5,6 +5,6 @@ [assembly: AssemblyDescription("Conviso Platform Visual Studio extension")] [assembly: AssemblyCompany("Conviso")] [assembly: AssemblyProduct("Conviso Platform Visual Studio")] -[assembly: AssemblyVersion("0.1.4.0")] -[assembly: AssemblyFileVersion("0.1.4.0")] +[assembly: AssemblyVersion("0.2.1.0")] +[assembly: AssemblyFileVersion("0.2.1.0")] [assembly: ComVisible(false)] diff --git a/src/Conviso.Platform.VisualStudio/Commands/AnalyzeSecurityAndSuggestFixCommand.cs b/src/Conviso.Platform.VisualStudio/Commands/AnalyzeSecurityAndSuggestFixCommand.cs index 85a47a4..cb99148 100644 --- a/src/Conviso.Platform.VisualStudio/Commands/AnalyzeSecurityAndSuggestFixCommand.cs +++ b/src/Conviso.Platform.VisualStudio/Commands/AnalyzeSecurityAndSuggestFixCommand.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel.Design; +using System.Threading; using System.Threading.Tasks; using Conviso.Platform.VisualStudio.ToolWindows; using Microsoft.VisualStudio.Shell; @@ -9,6 +10,7 @@ namespace Conviso.Platform.VisualStudio.Commands internal sealed class AnalyzeSecurityAndSuggestFixCommand { private readonly AsyncPackage package; + private int isExecuting; private AnalyzeSecurityAndSuggestFixCommand(AsyncPackage package, OleMenuCommandService commandService) { @@ -25,12 +27,32 @@ public static async Task InitializeAsync(AsyncPackage package) private void Execute(object? sender, EventArgs e) { - ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + if (Interlocked.Exchange(ref isExecuting, 1) != 0) { - ToolWindowPane window = await package.ShowToolWindowAsync(typeof(ChatToolWindow), 0, true, package.DisposalToken); - if (window?.Content is ChatToolWindowControl control) + return; + } + + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + try + { + ToolWindowPane window = await package.ShowToolWindowAsync(typeof(ChatToolWindow), 0, true, package.DisposalToken); + if (window?.Content is ChatToolWindowControl control) + { + await control.RunAnalyzeSecurityAndSuggestFixAsync(); + } + } + catch (OperationCanceledException) when (package.DisposalToken.IsCancellationRequested) + { + + } + catch (Exception error) + { + Infrastructure.DiagnosticsLogger.LogError("Analyze + Suggest Fix failed: " + error); + } + finally { - await control.RunAnalyzeSecurityAndSuggestFixAsync(); + Volatile.Write(ref isExecuting, 0); } }); } diff --git a/src/Conviso.Platform.VisualStudio/Commands/AttachSelectionToChatCommand.cs b/src/Conviso.Platform.VisualStudio/Commands/AttachSelectionToChatCommand.cs index 3a50a92..56050d3 100644 --- a/src/Conviso.Platform.VisualStudio/Commands/AttachSelectionToChatCommand.cs +++ b/src/Conviso.Platform.VisualStudio/Commands/AttachSelectionToChatCommand.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel.Design; +using System.Threading; using System.Threading.Tasks; using Conviso.Platform.VisualStudio.ToolWindows; using Microsoft.VisualStudio.Shell; @@ -9,6 +10,7 @@ namespace Conviso.Platform.VisualStudio.Commands internal sealed class AttachSelectionToChatCommand { private readonly AsyncPackage package; + private int isExecuting; private AttachSelectionToChatCommand(AsyncPackage package, OleMenuCommandService commandService) { @@ -25,12 +27,32 @@ public static async Task InitializeAsync(AsyncPackage package) private void Execute(object? sender, EventArgs e) { - ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + if (Interlocked.Exchange(ref isExecuting, 1) != 0) { - ToolWindowPane window = await package.ShowToolWindowAsync(typeof(ChatToolWindow), 0, true, package.DisposalToken); - if (window?.Content is ChatToolWindowControl control) + return; + } + + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + try + { + ToolWindowPane window = await package.ShowToolWindowAsync(typeof(ChatToolWindow), 0, true, package.DisposalToken); + if (window?.Content is ChatToolWindowControl control) + { + await control.RunAttachSelectionAsync(); + } + } + catch (OperationCanceledException) when (package.DisposalToken.IsCancellationRequested) + { + + } + catch (Exception error) + { + Infrastructure.DiagnosticsLogger.LogError("Attach Selection failed: " + error); + } + finally { - await control.RunAttachSelectionAsync(); + Volatile.Write(ref isExecuting, 0); } }); } diff --git a/src/Conviso.Platform.VisualStudio/Commands/CheckSimilarIssuesCommand.cs b/src/Conviso.Platform.VisualStudio/Commands/CheckSimilarIssuesCommand.cs index 0be9bce..5e94ee8 100644 --- a/src/Conviso.Platform.VisualStudio/Commands/CheckSimilarIssuesCommand.cs +++ b/src/Conviso.Platform.VisualStudio/Commands/CheckSimilarIssuesCommand.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel.Design; +using System.Threading; using System.Threading.Tasks; using Conviso.Platform.VisualStudio.ToolWindows; using Microsoft.VisualStudio.Shell; @@ -9,6 +10,7 @@ namespace Conviso.Platform.VisualStudio.Commands internal sealed class CheckSimilarIssuesCommand { private readonly AsyncPackage package; + private int isExecuting; private CheckSimilarIssuesCommand(AsyncPackage package, OleMenuCommandService commandService) { @@ -25,12 +27,32 @@ public static async Task InitializeAsync(AsyncPackage package) private void Execute(object? sender, EventArgs e) { - ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + if (Interlocked.Exchange(ref isExecuting, 1) != 0) { - ToolWindowPane window = await package.ShowToolWindowAsync(typeof(ChatToolWindow), 0, true, package.DisposalToken); - if (window?.Content is ChatToolWindowControl control) + return; + } + + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + try + { + ToolWindowPane window = await package.ShowToolWindowAsync(typeof(ChatToolWindow), 0, true, package.DisposalToken); + if (window?.Content is ChatToolWindowControl control) + { + await control.RunCheckSimilarIssuesAsync(); + } + } + catch (OperationCanceledException) when (package.DisposalToken.IsCancellationRequested) + { + + } + catch (Exception error) + { + Infrastructure.DiagnosticsLogger.LogError("Check Similar Issues failed: " + error); + } + finally { - await control.RunCheckSimilarIssuesAsync(); + Volatile.Write(ref isExecuting, 0); } }); } diff --git a/src/Conviso.Platform.VisualStudio/Commands/OpenToolWindowCommandBase.cs b/src/Conviso.Platform.VisualStudio/Commands/OpenToolWindowCommandBase.cs index b532c4b..616403b 100644 --- a/src/Conviso.Platform.VisualStudio/Commands/OpenToolWindowCommandBase.cs +++ b/src/Conviso.Platform.VisualStudio/Commands/OpenToolWindowCommandBase.cs @@ -32,10 +32,21 @@ private void Execute(object? sender, EventArgs e) { ThreadHelper.JoinableTaskFactory.RunAsync(async delegate { - ToolWindowPane window = await package.ShowToolWindowAsync(typeof(TToolWindow), 0, true, package.DisposalToken); - if (window == null) + try { - throw new InvalidOperationException($"Unable to open tool window {typeof(TToolWindow).Name}."); + ToolWindowPane window = await package.ShowToolWindowAsync(typeof(TToolWindow), 0, true, package.DisposalToken); + if (window == null) + { + throw new InvalidOperationException($"Unable to open tool window {typeof(TToolWindow).Name}."); + } + } + catch (OperationCanceledException) when (package.DisposalToken.IsCancellationRequested) + { + + } + catch (Exception error) + { + Infrastructure.DiagnosticsLogger.LogError($"Unable to open {typeof(TToolWindow).Name}: {error}"); } }); } diff --git a/src/Conviso.Platform.VisualStudio/Configuration/ConvisoOptions.cs b/src/Conviso.Platform.VisualStudio/Configuration/ConvisoOptions.cs index 210b00d..6a14dbb 100644 --- a/src/Conviso.Platform.VisualStudio/Configuration/ConvisoOptions.cs +++ b/src/Conviso.Platform.VisualStudio/Configuration/ConvisoOptions.cs @@ -3,12 +3,11 @@ namespace Conviso.Platform.VisualStudio.Configuration internal static class ConvisoOptions { public const string DefaultApiBaseUrl = "https://api.convisoappsec.com"; - public const string DefaultBrokerEndpoint = "wss://conviso.docker.localhost/ws"; + public const string DefaultBrokerEndpoint = "wss://securecode.convisoappsec.com/ws"; public const string CollectionPath = "ConvisoPlatform"; public const string ApiBaseUrlKey = "Conviso.ApiBaseUrl"; public const string CompanyIdKey = "Conviso.CompanyId"; - public const string RequirementsScopeIdKey = "Conviso.RequirementsScopeId"; public const string BrokerEndpointKey = "Conviso.BrokerEndpoint"; public const string ApiTokenKey = "Conviso.ApiToken"; public const string BrokerApiKeyKey = "Conviso.BrokerApiKey"; diff --git a/src/Conviso.Platform.VisualStudio/Conviso.Platform.VisualStudio.csproj b/src/Conviso.Platform.VisualStudio/Conviso.Platform.VisualStudio.csproj index 566de14..8031ede 100644 --- a/src/Conviso.Platform.VisualStudio/Conviso.Platform.VisualStudio.csproj +++ b/src/Conviso.Platform.VisualStudio/Conviso.Platform.VisualStudio.csproj @@ -24,9 +24,20 @@ - + + + + + + + + + @@ -36,6 +47,9 @@ Designer + + true + diff --git a/src/Conviso.Platform.VisualStudio/Conviso.Platform.VisualStudioPackage.vsct b/src/Conviso.Platform.VisualStudio/Conviso.Platform.VisualStudioPackage.vsct index 4dcc7a0..b768838 100644 --- a/src/Conviso.Platform.VisualStudio/Conviso.Platform.VisualStudioPackage.vsct +++ b/src/Conviso.Platform.VisualStudio/Conviso.Platform.VisualStudioPackage.vsct @@ -6,7 +6,7 @@ - + diff --git a/src/Conviso.Platform.VisualStudio/ConvisoPlatformPackage.cs b/src/Conviso.Platform.VisualStudio/ConvisoPlatformPackage.cs index 6ab3dd7..3fb0342 100644 --- a/src/Conviso.Platform.VisualStudio/ConvisoPlatformPackage.cs +++ b/src/Conviso.Platform.VisualStudio/ConvisoPlatformPackage.cs @@ -17,6 +17,7 @@ namespace Conviso.Platform.VisualStudio [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] [InstalledProductRegistration("Conviso Platform", "Conviso Platform integration for Visual Studio", "0.1.4")] [ProvideMenuResource("Menus.ctmenu", 1)] + [ProvideBindingPath] [ProvideToolWindow(typeof(ChatToolWindow))] [ProvideToolWindow(typeof(VulnerabilitiesToolWindow))] [ProvideToolWindow(typeof(RequirementsToolWindow))] diff --git a/src/Conviso.Platform.VisualStudio/Infrastructure/AsyncDelegateCommand.cs b/src/Conviso.Platform.VisualStudio/Infrastructure/AsyncDelegateCommand.cs index dcc2988..9656c2c 100644 --- a/src/Conviso.Platform.VisualStudio/Infrastructure/AsyncDelegateCommand.cs +++ b/src/Conviso.Platform.VisualStudio/Infrastructure/AsyncDelegateCommand.cs @@ -36,6 +36,12 @@ public async void Execute(object? parameter) RaiseCanExecuteChanged(); await execute(); } + catch (Exception error) + { + // ICommand.Execute is async void by contract. Never let an exception + // escape to WPF's dispatcher, because Visual Studio hosts us in-proc. + DiagnosticsLogger.LogError("Command failed: " + error); + } finally { isExecuting = false; diff --git a/src/Conviso.Platform.VisualStudio/Models/ChatTranscriptItem.cs b/src/Conviso.Platform.VisualStudio/Models/ChatTranscriptItem.cs index ccbeb12..2298913 100644 --- a/src/Conviso.Platform.VisualStudio/Models/ChatTranscriptItem.cs +++ b/src/Conviso.Platform.VisualStudio/Models/ChatTranscriptItem.cs @@ -1,15 +1,35 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + namespace Conviso.Platform.VisualStudio.Models { - public sealed class ChatTranscriptItem + public sealed class ChatTranscriptItem : INotifyPropertyChanged { + private string content; + public ChatTranscriptItem(string role, string content) { Role = role; - Content = content; + this.content = content; } + public event PropertyChangedEventHandler? PropertyChanged; + public string Role { get; } - public string Content { get; } + public string Content + { + get => content; + set + { + if (content == value) + { + return; + } + + content = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Content))); + } + } } } diff --git a/src/Conviso.Platform.VisualStudio/Resources/pluginIcon.png b/src/Conviso.Platform.VisualStudio/Resources/pluginIcon.png new file mode 100644 index 0000000..8e1a9b4 Binary files /dev/null and b/src/Conviso.Platform.VisualStudio/Resources/pluginIcon.png differ diff --git a/src/Conviso.Platform.VisualStudio/Services/Broker/BrokerClient.cs b/src/Conviso.Platform.VisualStudio/Services/Broker/BrokerClient.cs index 414a26a..e30a051 100644 --- a/src/Conviso.Platform.VisualStudio/Services/Broker/BrokerClient.cs +++ b/src/Conviso.Platform.VisualStudio/Services/Broker/BrokerClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -14,6 +15,9 @@ internal sealed class BrokerClient : IBrokerClient private ClientWebSocket? socket; private CancellationTokenSource? receiveLoopCancellation; private TaskCompletionSource? authenticationCompletionSource; + private readonly object exclusiveRequestsLock = new object(); + private readonly HashSet exclusiveRequestIds = new HashSet(StringComparer.Ordinal); + private event Action? InternalEventReceived; public event Action? EventReceived; @@ -35,7 +39,11 @@ public async Task ConnectAsync(BrokerConnectionOptions options, CancellationToke await socket.ConnectAsync(new Uri(endpoint), cancellationToken); receiveLoopCancellation = new CancellationTokenSource(); - _ = Task.Run(() => ReceiveLoopAsync(socket, receiveLoopCancellation.Token), receiveLoopCancellation.Token); + ClientWebSocket activeSocket = socket; + CancellationToken receiveToken = receiveLoopCancellation.Token; + _ = Task.Run( + () => RunReceiveLoopSafelyAsync(activeSocket, receiveToken), + receiveToken); string authRequestId = CreateRequestId("auth"); await SendMessageAsync( @@ -111,6 +119,7 @@ public async Task RequestAutoFixAsync(string findingId, Cancellat string requestId = CreateRequestId("req"); var responseCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + BrokerEvent? completedEvent = null; void HandleEvent(BrokerEvent brokerEvent) { @@ -119,13 +128,14 @@ void HandleEvent(BrokerEvent brokerEvent) return; } - if (brokerEvent.Type == "autofix_complete" || brokerEvent.Type == "autofix_suggested") + if (brokerEvent.Type == "analysis_complete") { + completedEvent = brokerEvent; responseCompletionSource.TrySetResult(brokerEvent); return; } - if (brokerEvent.Type == "autofix_error" || brokerEvent.Type == "error") + if (brokerEvent.Type == "analysis_error" || brokerEvent.Type == "error") { responseCompletionSource.TrySetException(new InvalidOperationException( string.IsNullOrWhiteSpace(brokerEvent.Content) @@ -134,7 +144,11 @@ void HandleEvent(BrokerEvent brokerEvent) } } - EventReceived += HandleEvent; + InternalEventReceived += HandleEvent; + lock (exclusiveRequestsLock) + { + exclusiveRequestIds.Add(requestId); + } try { @@ -142,11 +156,16 @@ await SendMessageAsync( socket, new { - type = "request_autofix", + type = "analyze_code", request_id = requestId, payload = new { - finding_id = findingId, + code = string.Join( + "\n", + "Generate a secure remediation for the following Conviso Platform vulnerability.", + "Explain the risk and provide the corrected code in a fenced code block when possible.", + "Vulnerability ID: " + findingId), + language = "text", }, }, cancellationToken); @@ -163,7 +182,13 @@ await SendMessageAsync( try { BrokerEvent response = await responseCompletionSource.Task; - return ExtractAutoFixResult(response.RawPayload); + AutoFixResult result = ExtractAutoFixResult(response.RawPayload); + if (string.IsNullOrWhiteSpace(result.PrUrl) && string.IsNullOrWhiteSpace(result.Summary)) + { + result.Summary = response.Content; + } + + return result; } catch (TaskCanceledException) when (timeoutCancellation.IsCancellationRequested) { @@ -173,7 +198,26 @@ await SendMessageAsync( } finally { - EventReceived -= HandleEvent; + lock (exclusiveRequestsLock) + { + exclusiveRequestIds.Remove(requestId); + } + + InternalEventReceived -= HandleEvent; + + // Autofix chunks stay isolated to avoid flooding the chat UI, but + // the completed response is published once so users can review it. + if (completedEvent != null) + { + try + { + EventReceived?.Invoke(completedEvent); + } + catch + { + // A UI subscriber must not turn a completed fix into a failed request. + } + } } } @@ -264,18 +308,34 @@ public async Task DisconnectAsync(CancellationToken cancellationToken) receiveLoopCancellation?.Dispose(); receiveLoopCancellation = null; - if (socket == null) + ClientWebSocket? activeSocket = socket; + socket = null; + if (activeSocket == null) { return; } - if (socket.State == WebSocketState.Open) + try { - await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "disconnect", cancellationToken); + if (activeSocket.State == WebSocketState.Open) + { + await activeSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "disconnect", cancellationToken); + } + } + catch (WebSocketException) when (activeSocket.State == WebSocketState.Aborted || + activeSocket.State == WebSocketState.Closed) + { + // An aborted socket is already disconnected; cleanup is enough. + } + catch (InvalidOperationException) when (activeSocket.State == WebSocketState.Aborted || + activeSocket.State == WebSocketState.Closed) + { + // CloseAsync cannot be used after the receive loop aborts the socket. + } + finally + { + activeSocket.Dispose(); } - - socket.Dispose(); - socket = null; } private async Task ReceiveLoopAsync(ClientWebSocket activeSocket, CancellationToken cancellationToken) @@ -305,6 +365,23 @@ private async Task ReceiveLoopAsync(ClientWebSocket activeSocket, CancellationTo } } + private async Task RunReceiveLoopSafelyAsync(ClientWebSocket activeSocket, CancellationToken cancellationToken) + { + try + { + await ReceiveLoopAsync(activeSocket, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + + } + catch (Exception error) + { + authenticationCompletionSource?.TrySetException(error); + Infrastructure.DiagnosticsLogger.LogError("Chat receive loop stopped: " + error); + } + } + private void ProcessIncomingMessage(string raw) { BrokerEvent brokerEvent = ParseEvent(raw); @@ -338,7 +415,18 @@ private void ProcessIncomingMessage(string raw) return; } - EventReceived?.Invoke(brokerEvent); + InternalEventReceived?.Invoke(brokerEvent); + + bool isExclusiveRequest; + lock (exclusiveRequestsLock) + { + isExclusiveRequest = exclusiveRequestIds.Contains(brokerEvent.RequestId); + } + + if (!isExclusiveRequest) + { + EventReceived?.Invoke(brokerEvent); + } } private static BrokerEvent ParseEvent(string raw) diff --git a/src/Conviso.Platform.VisualStudio/Services/Editor/EditorContextService.cs b/src/Conviso.Platform.VisualStudio/Services/Editor/EditorContextService.cs index a591f30..be740e7 100644 --- a/src/Conviso.Platform.VisualStudio/Services/Editor/EditorContextService.cs +++ b/src/Conviso.Platform.VisualStudio/Services/Editor/EditorContextService.cs @@ -14,6 +14,7 @@ internal sealed class EditorContextService : IEditorContextService { private const int MaxWorkspaceFiles = 20; private const int MaxWorkspaceCharacters = 50000; + private const int MaxEditorContextCharacters = 12000; private readonly AsyncPackage package; public EditorContextService(AsyncPackage package) @@ -50,21 +51,35 @@ public EditorContextService(AsyncPackage package) return null; } - EditPoint startPoint = textDocument.StartPoint.CreateEditPoint(); - string documentText = startPoint.GetText(textDocument.EndPoint); - string selectionText = string.Empty; - if (textDocument.Selection != null && !textDocument.Selection.IsEmpty) + string documentText = string.Empty; + try { - selectionText = textDocument.Selection.Text ?? string.Empty; + TextSelection? selection = textDocument.Selection; + if (selection != null && !selection.IsEmpty) + { + selectionText = Truncate(selection.Text?.Trim() ?? string.Empty, MaxEditorContextCharacters); + } + else + { + EditPoint startPoint = textDocument.StartPoint.CreateEditPoint(); + documentText = Truncate( + startPoint.GetText(textDocument.EndPoint).Trim(), + MaxEditorContextCharacters); + } + } + catch (Exception error) + { + Infrastructure.DiagnosticsLogger.LogError("Unable to capture active editor context: " + error); + return null; } return new EditorContextSnapshot { FilePath = filePath, Language = language, - SelectionText = selectionText.Trim(), - DocumentText = documentText.Trim(), + SelectionText = selectionText, + DocumentText = documentText, }; } @@ -92,6 +107,17 @@ public EditorContextService(AsyncPackage package) } string extension = Path.GetExtension(reference.FilePath ?? string.Empty); + return await Task.Run( + () => CollectWorkspaceContext(rootPath, extension, reference.FilePath ?? string.Empty, cancellationToken), + cancellationToken); + } + + private static WorkspaceContextSnapshot CollectWorkspaceContext( + string rootPath, + string extension, + string referenceFilePath, + CancellationToken cancellationToken) + { var result = new WorkspaceContextSnapshot { RootPath = rootPath, @@ -102,7 +128,7 @@ public EditorContextService(AsyncPackage package) { cancellationToken.ThrowIfCancellationRequested(); - if (string.Equals(filePath, reference.FilePath, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(filePath, referenceFilePath, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/src/Conviso.Platform.VisualStudio/Services/Platform/GraphQlDocuments.cs b/src/Conviso.Platform.VisualStudio/Services/Platform/GraphQlDocuments.cs index 1b95a4c..1d2b3cd 100644 --- a/src/Conviso.Platform.VisualStudio/Services/Platform/GraphQlDocuments.cs +++ b/src/Conviso.Platform.VisualStudio/Services/Platform/GraphQlDocuments.cs @@ -32,13 +32,11 @@ query Issues( $pagination: PaginationInput! $filters: IssuesFiltersInput $companyId: ID! - $sortOptions: [IssueSortOptionInput!] ) { issues( pagination: $pagination filters: $filters companyId: $companyId - sortOptions: $sortOptions ) { collection { id diff --git a/src/Conviso.Platform.VisualStudio/Services/Platform/PlatformApiClient.cs b/src/Conviso.Platform.VisualStudio/Services/Platform/PlatformApiClient.cs index 90294fc..6a99fd4 100644 --- a/src/Conviso.Platform.VisualStudio/Services/Platform/PlatformApiClient.cs +++ b/src/Conviso.Platform.VisualStudio/Services/Platform/PlatformApiClient.cs @@ -21,9 +21,8 @@ public PlatformApiClient(ISettingsService settingsService) public async Task QueryAsync(string graphqlDocument, string variablesJson, CancellationToken cancellationToken) { - string apiBaseUrl = settingsService.GetString(ConvisoOptions.ApiBaseUrlKey, ConvisoOptions.DefaultApiBaseUrl); string apiToken = settingsService.GetSecret(ConvisoOptions.ApiTokenKey, string.Empty); - string endpoint = $"{apiBaseUrl.TrimEnd('/')}/graphql"; + string endpoint = ConvisoOptions.DefaultApiBaseUrl + "/graphql"; using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); request.Headers.Add("Accept", "application/json"); diff --git a/src/Conviso.Platform.VisualStudio/Services/Platform/PlatformFacade.cs b/src/Conviso.Platform.VisualStudio/Services/Platform/PlatformFacade.cs index 7281421..54b6b92 100644 --- a/src/Conviso.Platform.VisualStudio/Services/Platform/PlatformFacade.cs +++ b/src/Conviso.Platform.VisualStudio/Services/Platform/PlatformFacade.cs @@ -23,7 +23,7 @@ public async Task> GetAccessibleCompanies { string json = await apiClient.QueryAsync( GraphQlDocuments.SetupCompanies, - SerializeVariables(new { page = 1, limit = 100 }), + SerializeVariables(new { page = 1, limit = 200 }), cancellationToken); using JsonDocument document = JsonDocument.Parse(json); @@ -351,18 +351,10 @@ public async Task> GetVulnerabilitiesAsync(s pagination = new { page = 1, - limit = 20, + perPage = 20, }, filters, companyId = effectiveCompanyId, - sortOptions = new[] - { - new - { - field = "updatedAt", - direction = "DESC", - }, - }, }); string json = await apiClient.QueryAsync(GraphQlDocuments.IssuesList, variables, cancellationToken); diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindow.cs b/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindow.cs index 538c2ba..61d8c13 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindow.cs +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindow.cs @@ -6,7 +6,7 @@ internal sealed class ChatToolWindow : ToolWindowPane { public ChatToolWindow() : base(null) { - Caption = "Conviso Platform"; + Caption = "AI Autonomous AppSec"; Content = new ChatToolWindowControl(ConvisoPlatformPackage.Instance!.ToolWindowContext); } } diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindowControl.xaml b/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindowControl.xaml index d8a26a5..42d877c 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindowControl.xaml +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindowControl.xaml @@ -1,76 +1,172 @@ - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vs="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0" + Background="{DynamicResource {x:Static vs:VsBrushes.ToolWindowBackgroundKey}}" + Foreground="{DynamicResource {x:Static vs:VsBrushes.ToolWindowTextKey}}"> + + + - - - - - - - + + + + + + + - - + - + - - - + + + - + - + - + + + + + + + + + + + + + + + + + + + Margin="0,0,0,12"> + + + + + + + + diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindowControl.xaml.cs b/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindowControl.xaml.cs index c69dd1d..5bd7b2c 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindowControl.xaml.cs +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/ChatToolWindowControl.xaml.cs @@ -1,13 +1,22 @@ using System.Threading.Tasks; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; using Conviso.Platform.VisualStudio.Infrastructure; +using Conviso.Platform.VisualStudio.Models; using Conviso.Platform.VisualStudio.ViewModels; namespace Conviso.Platform.VisualStudio.ToolWindows { public partial class ChatToolWindowControl : UserControl { + private const double MinimumContentHeight = 480; + private const double VerticalContentMargin = 24; private readonly ChatToolWindowViewModel viewModel; + private readonly DispatcherTimer transcriptScrollTimer; public ChatToolWindowControl(ToolWindowContext context) { @@ -18,6 +27,89 @@ public ChatToolWindowControl(ToolWindowContext context) context.EditorContextService, context.PatchService); DataContext = viewModel; + transcriptScrollTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = System.TimeSpan.FromMilliseconds(75), + }; + transcriptScrollTimer.Tick += OnTranscriptScrollTimerTick; + viewModel.Transcript.CollectionChanged += OnTranscriptChanged; + } + + private void OnTranscriptChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (ChatTranscriptItem item in e.OldItems) + { + item.PropertyChanged -= OnTranscriptItemPropertyChanged; + } + } + + if (e.NewItems != null) + { + foreach (ChatTranscriptItem item in e.NewItems) + { + item.PropertyChanged += OnTranscriptItemPropertyChanged; + } + } + + ScheduleTranscriptScroll(); + + viewModel.ClearChatCommand.RaiseCanExecuteChanged(); + } + + private void ScrollTranscriptToEnd() + { + FindVisualChild(TranscriptList)?.ScrollToEnd(); + } + + private void OnTranscriptItemPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ChatTranscriptItem.Content)) + { + ScheduleTranscriptScroll(); + } + } + + private void ScheduleTranscriptScroll() + { + transcriptScrollTimer.Stop(); + transcriptScrollTimer.Start(); + } + + private void OnTranscriptScrollTimerTick(object sender, System.EventArgs e) + { + transcriptScrollTimer.Stop(); + ScrollTranscriptToEnd(); + } + + private void OnWindowScrollViewerSizeChanged(object sender, SizeChangedEventArgs e) + { + // Above the minimum, the star-sized transcript follows the viewport. + // Below it, the content remains fixed and the outer viewer starts scrolling. + double availableContentHeight = System.Math.Max(0, e.NewSize.Height - VerticalContentMargin); + LayoutRoot.Height = System.Math.Max(MinimumContentHeight, availableContentHeight); + } + + private static T? FindVisualChild(DependencyObject parent) where T : DependencyObject + { + int childrenCount = VisualTreeHelper.GetChildrenCount(parent); + for (int index = 0; index < childrenCount; index++) + { + DependencyObject child = VisualTreeHelper.GetChild(parent, index); + if (child is T match) + { + return match; + } + + T? nestedMatch = FindVisualChild(child); + if (nestedMatch != null) + { + return nestedMatch; + } + } + + return null; } public Task RunAnalyzeSecurityAndSuggestFixAsync() diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/PipelineBreaksToolWindowControl.xaml b/src/Conviso.Platform.VisualStudio/ToolWindows/PipelineBreaksToolWindowControl.xaml index c6ca3d4..f72c801 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/PipelineBreaksToolWindowControl.xaml +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/PipelineBreaksToolWindowControl.xaml @@ -1,40 +1,49 @@ - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vs="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0" + Background="{DynamicResource {x:Static vs:VsBrushes.ToolWindowBackgroundKey}}" + Foreground="{DynamicResource {x:Static vs:VsBrushes.ToolWindowTextKey}}"> + + + - - - - - - + + + + + + + - + - + + @@ -46,6 +55,7 @@ - + + diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/PipelineBreaksToolWindowControl.xaml.cs b/src/Conviso.Platform.VisualStudio/ToolWindows/PipelineBreaksToolWindowControl.xaml.cs index 2108820..fac8ef9 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/PipelineBreaksToolWindowControl.xaml.cs +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/PipelineBreaksToolWindowControl.xaml.cs @@ -6,6 +6,8 @@ namespace Conviso.Platform.VisualStudio.ToolWindows { public partial class PipelineBreaksToolWindowControl : UserControl { + private const double MinimumContentHeight = 520; + private const double VerticalContentMargin = 24; private readonly PipelineBreaksToolWindowViewModel viewModel; public PipelineBreaksToolWindowControl(ToolWindowContext context) @@ -13,7 +15,20 @@ public PipelineBreaksToolWindowControl(ToolWindowContext context) InitializeComponent(); viewModel = new PipelineBreaksToolWindowViewModel(context.PlatformFacade); DataContext = viewModel; - Loaded += async (_, __) => await viewModel.RefreshAsync(); + Loaded += OnLoaded; + } + + private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e) + { + Loaded -= OnLoaded; + try { await viewModel.RefreshAsync(); } + catch (System.Exception error) { DiagnosticsLogger.LogError("Unable to load pipeline breaks: " + error); } + } + + private void OnWindowScrollViewerSizeChanged(object sender, System.Windows.SizeChangedEventArgs e) + { + double availableContentHeight = System.Math.Max(0, e.NewSize.Height - VerticalContentMargin); + LayoutRoot.Height = System.Math.Max(MinimumContentHeight, availableContentHeight); } } } diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/RequirementsToolWindowControl.xaml b/src/Conviso.Platform.VisualStudio/ToolWindows/RequirementsToolWindowControl.xaml index 56d52a9..c63bc7c 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/RequirementsToolWindowControl.xaml +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/RequirementsToolWindowControl.xaml @@ -1,21 +1,20 @@ - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vs="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0" + Background="{DynamicResource {x:Static vs:VsBrushes.ToolWindowBackgroundKey}}" + Foreground="{DynamicResource {x:Static vs:VsBrushes.ToolWindowTextKey}}"> + + - - - - - + @@ -25,17 +24,28 @@ - + + + + + + + + - + - + + @@ -46,36 +56,58 @@ - + + + + + + + + - + - + + - + + + + + + + + - + - + + - + @@ -90,12 +122,14 @@ - - + @@ -106,7 +140,7 @@ - + @@ -117,7 +151,9 @@ - diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/RequirementsToolWindowControl.xaml.cs b/src/Conviso.Platform.VisualStudio/ToolWindows/RequirementsToolWindowControl.xaml.cs index 0accca1..6498705 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/RequirementsToolWindowControl.xaml.cs +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/RequirementsToolWindowControl.xaml.cs @@ -13,7 +13,14 @@ public RequirementsToolWindowControl(ToolWindowContext context) InitializeComponent(); viewModel = new RequirementsToolWindowViewModel(context.PlatformFacade); DataContext = viewModel; - Loaded += async (_, __) => await viewModel.RefreshAsync(); + Loaded += OnLoaded; + } + + private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e) + { + Loaded -= OnLoaded; + try { await viewModel.RefreshAsync(); } + catch (System.Exception error) { DiagnosticsLogger.LogError("Unable to load requirements: " + error); } } } } diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/SettingsToolWindowControl.xaml b/src/Conviso.Platform.VisualStudio/ToolWindows/SettingsToolWindowControl.xaml index 6ded019..d9daa43 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/SettingsToolWindowControl.xaml +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/SettingsToolWindowControl.xaml @@ -1,64 +1,25 @@ - - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vs="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0" + Background="{DynamicResource {x:Static vs:VsBrushes.ToolWindowBackgroundKey}}" + Foreground="{DynamicResource {x:Static vs:VsBrushes.ToolWindowTextKey}}"> + + - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/SettingsToolWindowControl.xaml.cs b/src/Conviso.Platform.VisualStudio/ToolWindows/SettingsToolWindowControl.xaml.cs index 0b0c0e2..dc0455d 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/SettingsToolWindowControl.xaml.cs +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/SettingsToolWindowControl.xaml.cs @@ -6,10 +6,31 @@ namespace Conviso.Platform.VisualStudio.ToolWindows { public partial class SettingsToolWindowControl : UserControl { + private readonly SettingsToolWindowViewModel viewModel; + public SettingsToolWindowControl(ToolWindowContext context) { InitializeComponent(); - DataContext = new SettingsToolWindowViewModel(context.SettingsService, context.PlatformFacade); + viewModel = new SettingsToolWindowViewModel(context.SettingsService, context.PlatformFacade); + DataContext = viewModel; + ApiTokenPasswordBox.Password = viewModel.ApiToken; + SettingsTabs.SelectionChanged += OnSettingsTabSelectionChanged; + } + + private void OnApiTokenPasswordChanged(object sender, System.Windows.RoutedEventArgs e) + { + if (DataContext is SettingsToolWindowViewModel currentViewModel) + { + currentViewModel.ApiToken = ApiTokenPasswordBox.Password; + } + } + + private async void OnSettingsTabSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (e.Source == SettingsTabs && ScopeTab.IsSelected) + { + await viewModel.LoadCompaniesAsync(); + } } } } diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/ToolWindowStyles.xaml b/src/Conviso.Platform.VisualStudio/ToolWindows/ToolWindowStyles.xaml new file mode 100644 index 0000000..b4fc3c1 --- /dev/null +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/ToolWindowStyles.xaml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/VulnerabilitiesToolWindowControl.xaml b/src/Conviso.Platform.VisualStudio/ToolWindows/VulnerabilitiesToolWindowControl.xaml index 512935d..37e2792 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/VulnerabilitiesToolWindowControl.xaml +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/VulnerabilitiesToolWindowControl.xaml @@ -1,54 +1,76 @@ - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vs="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0" + Background="{DynamicResource {x:Static vs:VsBrushes.ToolWindowBackgroundKey}}" + Foreground="{DynamicResource {x:Static vs:VsBrushes.ToolWindowTextKey}}"> + + + - - - - - - - - + + + + + + + + + + - - + + + - - + - + + + diff --git a/src/Conviso.Platform.VisualStudio/ToolWindows/VulnerabilitiesToolWindowControl.xaml.cs b/src/Conviso.Platform.VisualStudio/ToolWindows/VulnerabilitiesToolWindowControl.xaml.cs index 6ee0d3a..b5f8ced 100644 --- a/src/Conviso.Platform.VisualStudio/ToolWindows/VulnerabilitiesToolWindowControl.xaml.cs +++ b/src/Conviso.Platform.VisualStudio/ToolWindows/VulnerabilitiesToolWindowControl.xaml.cs @@ -6,6 +6,8 @@ namespace Conviso.Platform.VisualStudio.ToolWindows { public partial class VulnerabilitiesToolWindowControl : UserControl { + private const double MinimumContentHeight = 600; + private const double VerticalContentMargin = 24; private readonly VulnerabilitiesToolWindowViewModel viewModel; public VulnerabilitiesToolWindowControl(ToolWindowContext context) @@ -13,7 +15,20 @@ public VulnerabilitiesToolWindowControl(ToolWindowContext context) InitializeComponent(); viewModel = new VulnerabilitiesToolWindowViewModel(context.PlatformFacade, context.SettingsService, context.BrokerClient); DataContext = viewModel; - Loaded += async (_, __) => await viewModel.RefreshAsync(); + Loaded += OnLoaded; + } + + private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e) + { + Loaded -= OnLoaded; + try { await viewModel.InitializeAsync(); } + catch (System.Exception error) { DiagnosticsLogger.LogError("Unable to load vulnerabilities: " + error); } + } + + private void OnWindowScrollViewerSizeChanged(object sender, System.Windows.SizeChangedEventArgs e) + { + double availableContentHeight = System.Math.Max(0, e.NewSize.Height - VerticalContentMargin); + LayoutRoot.Height = System.Math.Max(MinimumContentHeight, availableContentHeight); } } } diff --git a/src/Conviso.Platform.VisualStudio/ViewModels/ChatToolWindowViewModel.cs b/src/Conviso.Platform.VisualStudio/ViewModels/ChatToolWindowViewModel.cs index 3b32fba..d6f7db7 100644 --- a/src/Conviso.Platform.VisualStudio/ViewModels/ChatToolWindowViewModel.cs +++ b/src/Conviso.Platform.VisualStudio/ViewModels/ChatToolWindowViewModel.cs @@ -21,12 +21,14 @@ internal sealed class ChatToolWindowViewModel : ObservableObject private readonly IBrokerClient brokerClient; private readonly IEditorContextService editorContextService; private readonly IPatchService patchService; + private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1, 1); private readonly Dictionary requestExtractorIdMap = new Dictionary(); private EditorContextSnapshot? attachedContext; private string? latestCompletedRequestId; private string status = "Ready"; private string message = string.Empty; private string attachedContextSummary = "No attached selection."; + private string thinkingStatus = string.Empty; public ChatToolWindowViewModel( ISettingsService settingsService, @@ -39,15 +41,14 @@ public ChatToolWindowViewModel( this.editorContextService = editorContextService; this.patchService = patchService; Transcript = new ObservableCollection(); - ConnectCommand = new AsyncDelegateCommand(ConnectAsync); SendCommand = new AsyncDelegateCommand(SendAsync, () => !string.IsNullOrWhiteSpace(Message)); - DisconnectCommand = new AsyncDelegateCommand(DisconnectAsync); AttachSelectionCommand = new AsyncDelegateCommand(AttachSelectionAsync); AnalyzeSelectionCommand = new AsyncDelegateCommand(AnalyzeSecurityAndSuggestFixAsync); CheckSimilarIssuesCommand = new AsyncDelegateCommand(CheckSimilarIssuesAsync); ApplySuggestedFixCommand = new AsyncDelegateCommand(ApplySuggestedFixAsync, CanApplySuggestedFix); MarkResponseHelpfulCommand = new AsyncDelegateCommand(MarkResponseHelpfulAsync, CanMarkResponseHelpful); ClearAttachedContextCommand = new AsyncDelegateCommand(ClearAttachedContextAsync, () => attachedContext != null); + ClearChatCommand = new AsyncDelegateCommand(ClearChatAsync, () => Transcript.Count > 0); brokerClient.EventReceived += OnBrokerEventReceived; } @@ -69,12 +70,14 @@ public string Message } } - public AsyncDelegateCommand ConnectCommand { get; } + public string ThinkingStatus + { + get => thinkingStatus; + set => SetProperty(ref thinkingStatus, value); + } public AsyncDelegateCommand SendCommand { get; } - public AsyncDelegateCommand DisconnectCommand { get; } - public AsyncDelegateCommand AttachSelectionCommand { get; } public AsyncDelegateCommand AnalyzeSelectionCommand { get; } @@ -87,6 +90,8 @@ public string Message public AsyncDelegateCommand ClearAttachedContextCommand { get; } + public AsyncDelegateCommand ClearChatCommand { get; } + public ObservableCollection Transcript { get; } public string AttachedContextSummary @@ -95,19 +100,6 @@ public string AttachedContextSummary set => SetProperty(ref attachedContextSummary, value); } - private async Task ConnectAsync() - { - try - { - await EnsureConnectedAsync(); - } - catch (System.Exception error) - { - Status = "Connection failed"; - Transcript.Add(new ChatTranscriptItem("system", "Connection failed: " + error.Message)); - } - } - private async Task SendAsync() { string currentMessage = Message.Trim(); @@ -118,6 +110,7 @@ private async Task SendAsync() try { + ThinkingStatus = "Thinking..."; await EnsureConnectedAsync(); Transcript.Add(new ChatTranscriptItem("user", currentMessage)); var context = attachedContext ?? await editorContextService.GetActiveContextAsync(CancellationToken.None); @@ -129,6 +122,7 @@ private async Task SendAsync() } catch (System.Exception error) { + ThinkingStatus = string.Empty; Status = "Send failed"; Transcript.Add(new ChatTranscriptItem("system", "Failed to send: " + error.Message)); } @@ -174,6 +168,7 @@ public async Task AnalyzeSecurityAndSuggestFixAsync() string userMessage = "Analyze the selected code and suggest a fix."; string request = context.SelectionText; + ThinkingStatus = "Thinking..."; await EnsureConnectedAsync(); Transcript.Add(new ChatTranscriptItem("user", userMessage)); await brokerClient.SendChatMessageAsync(new ChatMessage("user", request, context.Language), CancellationToken.None); @@ -181,6 +176,7 @@ public async Task AnalyzeSecurityAndSuggestFixAsync() } catch (System.Exception error) { + ThinkingStatus = string.Empty; Status = "Analyze failed"; Transcript.Add(new ChatTranscriptItem("system", "Failed to analyze selection: " + error.Message)); } @@ -210,6 +206,7 @@ public async Task CheckSimilarIssuesAsync() string request = BuildSimilarIssuesMessage(context, workspaceContext!); string userMessage = "Check whether the selected issue appears elsewhere in the workspace (" + workspaceFilesCount + " files scanned)."; + ThinkingStatus = "Thinking..."; await EnsureConnectedAsync(); Transcript.Add(new ChatTranscriptItem("user", userMessage)); await brokerClient.SendChatMessageAsync(new ChatMessage("user", request, context.Language), CancellationToken.None); @@ -217,6 +214,7 @@ public async Task CheckSimilarIssuesAsync() } catch (System.Exception error) { + ThinkingStatus = string.Empty; Status = "Similarity scan failed"; Transcript.Add(new ChatTranscriptItem("system", "Failed to check similar issues: " + error.Message)); } @@ -294,6 +292,7 @@ public async Task MarkResponseHelpfulAsync() return; } + await EnsureConnectedAsync(); await brokerClient.UpdateExtractorAcceptedAsync(extractorId, CancellationToken.None); Status = "Response marked as helpful"; Transcript.Add(new ChatTranscriptItem("system", "Assistant response marked as helpful.")); @@ -316,23 +315,16 @@ private Task ClearAttachedContextAsync() return Task.CompletedTask; } - private async Task DisconnectAsync() + private Task ClearChatAsync() { - try - { - await brokerClient.DisconnectAsync(CancellationToken.None); - Status = "Disconnected"; - Transcript.Add(new ChatTranscriptItem("system", "Disconnected from broker.")); - ApplySuggestedFixCommand.RaiseCanExecuteChanged(); - MarkResponseHelpfulCommand.RaiseCanExecuteChanged(); - } - catch (System.Exception error) - { - Status = "Disconnect failed"; - Transcript.Add(new ChatTranscriptItem("system", "Disconnect failed: " + error.Message)); - ApplySuggestedFixCommand.RaiseCanExecuteChanged(); - MarkResponseHelpfulCommand.RaiseCanExecuteChanged(); - } + Transcript.Clear(); + requestExtractorIdMap.Clear(); + latestCompletedRequestId = null; + Status = "Chat cleared"; + ClearChatCommand.RaiseCanExecuteChanged(); + ApplySuggestedFixCommand.RaiseCanExecuteChanged(); + MarkResponseHelpfulCommand.RaiseCanExecuteChanged(); + return Task.CompletedTask; } private void OnBrokerEventReceived(BrokerEvent brokerEvent) @@ -341,11 +333,11 @@ void UpdateUi() { if (brokerEvent.Type == "analysis_chunk") { + ThinkingStatus = string.Empty; var last = Transcript.LastOrDefault(); if (last != null && last.Role == "assistant") { - Transcript.Remove(last); - Transcript.Add(new ChatTranscriptItem("assistant", last.Content + brokerEvent.Content)); + last.Content += brokerEvent.Content; } else { @@ -360,6 +352,7 @@ void UpdateUi() if (brokerEvent.Type == "analysis_complete") { + ThinkingStatus = string.Empty; latestCompletedRequestId = brokerEvent.RequestId; TrackExtractorId(brokerEvent); if (!string.IsNullOrWhiteSpace(brokerEvent.Content)) @@ -379,6 +372,7 @@ void UpdateUi() if (brokerEvent.Type == "analysis_error" || brokerEvent.Type == "error") { + ThinkingStatus = string.Empty; Transcript.Add(new ChatTranscriptItem("system", brokerEvent.Content)); Status = "Error from broker"; ApplySuggestedFixCommand.RaiseCanExecuteChanged(); @@ -386,6 +380,7 @@ void UpdateUi() return; } + ThinkingStatus = string.Empty; Transcript.Add(new ChatTranscriptItem("assistant", brokerEvent.Content)); Status = "Message received"; ApplySuggestedFixCommand.RaiseCanExecuteChanged(); @@ -398,10 +393,17 @@ void UpdateUi() return; } - ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async delegate { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - UpdateUi(); + try + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + UpdateUi(); + } + catch (System.Exception error) + { + DiagnosticsLogger.LogError("Unable to update chat UI from broker event: " + error); + } }); } @@ -468,6 +470,7 @@ private async Task TryAcceptLatestResponseAsync(string successMessage) return; } + await EnsureConnectedAsync(); await brokerClient.UpdateExtractorAcceptedAsync(extractorId, CancellationToken.None); Transcript.Add(new ChatTranscriptItem("system", successMessage)); } @@ -528,16 +531,26 @@ private async Task EnsureConnectedAsync() return; } - Status = "Connecting..."; - await brokerClient.ConnectAsync( - new BrokerConnectionOptions + await connectionLock.WaitAsync(); + try + { + if (brokerClient.IsConnected) { - Endpoint = settingsService.GetString(ConvisoOptions.BrokerEndpointKey, ConvisoOptions.DefaultBrokerEndpoint), - ApiKey = settingsService.GetSecret(ConvisoOptions.BrokerApiKeyKey, string.Empty), - }, - CancellationToken.None); - Status = "Connected"; - Transcript.Add(new ChatTranscriptItem("system", "Connected to broker.")); + return; + } + + await brokerClient.ConnectAsync( + new BrokerConnectionOptions + { + Endpoint = ConvisoOptions.DefaultBrokerEndpoint, + ApiKey = settingsService.GetSecret(ConvisoOptions.ApiTokenKey, string.Empty), + }, + CancellationToken.None); + } + finally + { + connectionLock.Release(); + } } private static string BuildBrokerMessage(string message, EditorContextSnapshot? context) diff --git a/src/Conviso.Platform.VisualStudio/ViewModels/PipelineBreaksToolWindowViewModel.cs b/src/Conviso.Platform.VisualStudio/ViewModels/PipelineBreaksToolWindowViewModel.cs index deb22d2..722a59b 100644 --- a/src/Conviso.Platform.VisualStudio/ViewModels/PipelineBreaksToolWindowViewModel.cs +++ b/src/Conviso.Platform.VisualStudio/ViewModels/PipelineBreaksToolWindowViewModel.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; +using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using System.Windows.Data; using Conviso.Platform.VisualStudio.Infrastructure; using Conviso.Platform.VisualStudio.Models; using Conviso.Platform.VisualStudio.Services.Platform; @@ -19,22 +21,43 @@ internal sealed class PipelineBreaksToolWindowViewModel : ObservableObject private string detailAssetName = string.Empty; private string detailReasonText = string.Empty; private string detailCommandStatus = "Select a pipeline break"; + private string itemFilter = string.Empty; public PipelineBreaksToolWindowViewModel(IPlatformFacade platformFacade) { this.platformFacade = platformFacade; Items = new ObservableCollection(); + ItemsView = CollectionViewSource.GetDefaultView(Items); + ItemsView.Filter = FilterItem; RefreshCommand = new AsyncDelegateCommand(RefreshAsync); } public ObservableCollection Items { get; } + public ICollectionView ItemsView { get; } + public string Status { get => status; set => SetProperty(ref status, value); } + public string ItemFilter + { + get => itemFilter; + set + { + if (SetProperty(ref itemFilter, value)) + { + ItemsView.Refresh(); + if (SelectedItem != null && !ItemsView.Contains(SelectedItem)) + { + SelectedItem = null; + } + } + } + } + public PipelineBreakSummary? SelectedItem { get => selectedItem; @@ -69,12 +92,19 @@ public async Task RefreshAsync() Items.Clear(); SelectedItem = null; ResetDetails(); - foreach (PipelineBreakSummary item in await platformFacade.GetPipelineBreaksAsync(CancellationToken.None)) + try { - Items.Add(item); + foreach (PipelineBreakSummary item in await platformFacade.GetPipelineBreaksAsync(CancellationToken.None)) + { + Items.Add(item); + } + Status = $"Loaded {Items.Count} item(s)"; + } + catch (System.Exception error) + { + Status = "Unable to load pipeline breaks: " + error.Message; + DiagnosticsLogger.LogError("Unable to load pipeline breaks: " + error); } - - Status = $"Loaded {Items.Count} item(s)"; } private async Task LoadDetailsAsync(PipelineBreakSummary? item) @@ -86,7 +116,17 @@ private async Task LoadDetailsAsync(PipelineBreakSummary? item) } DetailCommandStatus = "Loading pipeline break details..."; - PipelineBreakDetails details = await platformFacade.GetPipelineBreakDetailsAsync(item.Id, CancellationToken.None); + PipelineBreakDetails details; + try + { + details = await platformFacade.GetPipelineBreakDetailsAsync(item.Id, CancellationToken.None); + } + catch (System.Exception error) + { + DetailCommandStatus = "Unable to load details: " + error.Message; + DiagnosticsLogger.LogError("Unable to load pipeline break details: " + error); + return; + } DetailStatus = details.Status; DetailExecutionDate = details.ExecutionDate; DetailTriggeredBy = details.TriggeredBy; @@ -106,5 +146,15 @@ private void ResetDetails() DetailReasonText = string.Empty; DetailCommandStatus = "Select a pipeline break"; } + + private bool FilterItem(object item) + { + if (item is not PipelineBreakSummary pipelineBreak || string.IsNullOrWhiteSpace(ItemFilter)) + { + return true; + } + + return (pipelineBreak.AssetName ?? string.Empty).IndexOf(ItemFilter.Trim(), System.StringComparison.OrdinalIgnoreCase) >= 0; + } } } diff --git a/src/Conviso.Platform.VisualStudio/ViewModels/RequirementsToolWindowViewModel.cs b/src/Conviso.Platform.VisualStudio/ViewModels/RequirementsToolWindowViewModel.cs index 70899fd..507db93 100644 --- a/src/Conviso.Platform.VisualStudio/ViewModels/RequirementsToolWindowViewModel.cs +++ b/src/Conviso.Platform.VisualStudio/ViewModels/RequirementsToolWindowViewModel.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; +using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using System.Windows.Data; using Conviso.Platform.VisualStudio.Infrastructure; using Conviso.Platform.VisualStudio.Models; using Conviso.Platform.VisualStudio.Services.Platform; @@ -35,6 +37,9 @@ internal sealed class RequirementsToolWindowViewModel : ObservableObject private string activityUpdatedAt = string.Empty; private string newActivityStatus = string.Empty; private string activityCommandStatus = "Select an activity"; + private string projectFilter = string.Empty; + private string requirementFilter = string.Empty; + private string activityFilter = string.Empty; public RequirementsToolWindowViewModel(IPlatformFacade platformFacade) { @@ -42,6 +47,12 @@ public RequirementsToolWindowViewModel(IPlatformFacade platformFacade) Projects = new ObservableCollection(); Requirements = new ObservableCollection(); Activities = new ObservableCollection(); + ProjectsView = CollectionViewSource.GetDefaultView(Projects); + RequirementsView = CollectionViewSource.GetDefaultView(Requirements); + ActivitiesView = CollectionViewSource.GetDefaultView(Activities); + ProjectsView.Filter = FilterProject; + RequirementsView.Filter = FilterRequirement; + ActivitiesView.Filter = FilterActivity; RefreshCommand = new AsyncDelegateCommand(RefreshAsync); UpdateProjectStatusCommand = new AsyncDelegateCommand(UpdateProjectStatusAsync, () => selectedProject != null && !string.IsNullOrWhiteSpace(NewProjectStatus)); UpdateActivityStatusCommand = new AsyncDelegateCommand(UpdateActivityStatusAsync, () => selectedActivity != null && !string.IsNullOrWhiteSpace(NewActivityStatus)); @@ -53,6 +64,12 @@ public RequirementsToolWindowViewModel(IPlatformFacade platformFacade) public ObservableCollection Activities { get; } + public ICollectionView ProjectsView { get; } + + public ICollectionView RequirementsView { get; } + + public ICollectionView ActivitiesView { get; } + public string Status { get => status; @@ -66,7 +83,7 @@ public ProjectSummary? SelectedProject { if (SetProperty(ref selectedProject, value)) { - _ = LoadProjectAsync(value); + _ = LoadProjectSafelyAsync(value); } } } @@ -78,7 +95,7 @@ public RequirementSummary? SelectedRequirement { if (SetProperty(ref selectedRequirement, value)) { - _ = LoadRequirementAsync(value); + _ = LoadRequirementSafelyAsync(value); } } } @@ -163,6 +180,81 @@ public string NewActivityStatus public AsyncDelegateCommand UpdateActivityStatusCommand { get; } + private async Task LoadProjectSafelyAsync(ProjectSummary? project) + { + try + { + await LoadProjectAsync(project); + } + catch (System.Exception error) + { + ProjectCommandStatus = "Unable to load project: " + error.Message; + Status = ProjectCommandStatus; + DiagnosticsLogger.LogError("Unable to load project: " + error); + } + } + + public string ProjectFilter + { + get => projectFilter; + set + { + if (SetProperty(ref projectFilter, value)) + { + ProjectsView.Refresh(); + if (SelectedProject != null && !ProjectsView.Contains(SelectedProject)) + { + SelectedProject = null; + } + } + } + } + + public string RequirementFilter + { + get => requirementFilter; + set + { + if (SetProperty(ref requirementFilter, value)) + { + RequirementsView.Refresh(); + if (SelectedRequirement != null && !RequirementsView.Contains(SelectedRequirement)) + { + SelectedRequirement = null; + } + } + } + } + + public string ActivityFilter + { + get => activityFilter; + set + { + if (SetProperty(ref activityFilter, value)) + { + ActivitiesView.Refresh(); + if (SelectedActivity != null && !ActivitiesView.Contains(SelectedActivity)) + { + SelectedActivity = null; + } + } + } + } + + private async Task LoadRequirementSafelyAsync(RequirementSummary? requirement) + { + try + { + await LoadRequirementAsync(requirement); + } + catch (System.Exception error) + { + Status = "Unable to load requirement: " + error.Message; + DiagnosticsLogger.LogError("Unable to load requirement: " + error); + } + } + public async Task RefreshAsync() { Status = "Loading projects..."; @@ -176,12 +268,20 @@ public async Task RefreshAsync() ResetRequirementDetails(); ResetActivityDetails(); - foreach (ProjectSummary item in await platformFacade.GetProjectsAsync(CancellationToken.None)) + try { - Projects.Add(item); - } + foreach (ProjectSummary item in await platformFacade.GetProjectsAsync(CancellationToken.None)) + { + Projects.Add(item); + } - Status = $"Loaded {Projects.Count} project(s)"; + Status = $"Loaded {Projects.Count} project(s)"; + } + catch (System.Exception error) + { + Status = "Unable to load projects: " + error.Message; + DiagnosticsLogger.LogError("Unable to load projects: " + error); + } } private async Task LoadProjectAsync(ProjectSummary? project) @@ -201,7 +301,17 @@ private async Task LoadProjectAsync(ProjectSummary? project) } ProjectCommandStatus = "Loading project details..."; - ProjectDetails details = await platformFacade.GetProjectDetailsAsync(project.Id, CancellationToken.None); + ProjectDetails details; + try + { + details = await platformFacade.GetProjectDetailsAsync(project.Id, CancellationToken.None); + } + catch (System.Exception error) + { + ProjectCommandStatus = "Unable to load project: " + error.Message; + DiagnosticsLogger.LogError("Unable to load project details: " + error); + return; + } ProjectLabel = details.Label; ProjectStatus = details.Status; ProjectType = details.ProjectTypeLabel; @@ -328,5 +438,26 @@ private void ResetActivityDetails() NewActivityStatus = string.Empty; ActivityCommandStatus = "Select an activity"; } + + private bool FilterProject(object item) + { + return item is not ProjectSummary project || + string.IsNullOrWhiteSpace(ProjectFilter) || + (project.Label ?? string.Empty).IndexOf(ProjectFilter.Trim(), System.StringComparison.OrdinalIgnoreCase) >= 0; + } + + private bool FilterRequirement(object item) + { + return item is not RequirementSummary requirement || + string.IsNullOrWhiteSpace(RequirementFilter) || + (requirement.Label ?? string.Empty).IndexOf(RequirementFilter.Trim(), System.StringComparison.OrdinalIgnoreCase) >= 0; + } + + private bool FilterActivity(object item) + { + return item is not ProjectActivitySummary activity || + string.IsNullOrWhiteSpace(ActivityFilter) || + (activity.Title ?? string.Empty).IndexOf(ActivityFilter.Trim(), System.StringComparison.OrdinalIgnoreCase) >= 0; + } } } diff --git a/src/Conviso.Platform.VisualStudio/ViewModels/SettingsToolWindowViewModel.cs b/src/Conviso.Platform.VisualStudio/ViewModels/SettingsToolWindowViewModel.cs index 0ada4d2..f533f23 100644 --- a/src/Conviso.Platform.VisualStudio/ViewModels/SettingsToolWindowViewModel.cs +++ b/src/Conviso.Platform.VisualStudio/ViewModels/SettingsToolWindowViewModel.cs @@ -19,36 +19,29 @@ internal sealed class SettingsToolWindowViewModel : ObservableObject private readonly IPlatformFacade platformFacade; private string apiBaseUrl; private string apiToken; - private string companyId; - private string requirementsScopeId; private string brokerEndpoint; private string brokerApiKey; private AccessibleCompanyOption? selectedCompany; + private bool isLoadingCompanies; + private bool isInitializingCompanySelection; private string status = "Ready"; public SettingsToolWindowViewModel(ISettingsService settingsService, IPlatformFacade platformFacade) { this.settingsService = settingsService; this.platformFacade = platformFacade; - apiBaseUrl = settingsService.GetString(ConvisoOptions.ApiBaseUrlKey, ConvisoOptions.DefaultApiBaseUrl); + apiBaseUrl = ConvisoOptions.DefaultApiBaseUrl; apiToken = settingsService.GetSecret(ConvisoOptions.ApiTokenKey, string.Empty); - companyId = settingsService.GetString(ConvisoOptions.CompanyIdKey, string.Empty); - requirementsScopeId = settingsService.GetString(ConvisoOptions.RequirementsScopeIdKey, string.Empty); - brokerEndpoint = settingsService.GetString(ConvisoOptions.BrokerEndpointKey, ConvisoOptions.DefaultBrokerEndpoint); - brokerApiKey = settingsService.GetSecret(ConvisoOptions.BrokerApiKeyKey, string.Empty); + brokerEndpoint = ConvisoOptions.DefaultBrokerEndpoint; + brokerApiKey = apiToken; Companies = new ObservableCollection(); SaveCommand = new AsyncDelegateCommand(SaveAsync); TestApiCommand = new AsyncDelegateCommand(TestApiAsync); TestBrokerCommand = new AsyncDelegateCommand(TestBrokerAsync); - ResetDefaultsCommand = new AsyncDelegateCommand(ResetDefaultsAsync); - LoadCompaniesCommand = new AsyncDelegateCommand(LoadCompaniesAsync); - ApplySelectedCompanyCommand = new AsyncDelegateCommand(ApplySelectedCompanyAsync, () => SelectedCompany != null); } public string ApiBaseUrl { get => apiBaseUrl; set => SetProperty(ref apiBaseUrl, value); } public string ApiToken { get => apiToken; set => SetProperty(ref apiToken, value); } - public string CompanyId { get => companyId; set => SetProperty(ref companyId, value); } - public string RequirementsScopeId { get => requirementsScopeId; set => SetProperty(ref requirementsScopeId, value); } public string BrokerEndpoint { get => brokerEndpoint; set => SetProperty(ref brokerEndpoint, value); } public string BrokerApiKey { get => brokerApiKey; set => SetProperty(ref brokerApiKey, value); } public string Status { get => status; set => SetProperty(ref status, value); } @@ -62,7 +55,13 @@ public AccessibleCompanyOption? SelectedCompany { if (SetProperty(ref selectedCompany, value)) { - ApplySelectedCompanyCommand.RaiseCanExecuteChanged(); + if (!isInitializingCompanySelection && + value != null && + !string.IsNullOrWhiteSpace(value.Id)) + { + settingsService.SetString(ConvisoOptions.CompanyIdKey, value.Id); + Status = $"Company {value.DisplayLabel} selected and saved."; + } } } } @@ -73,44 +72,25 @@ public AccessibleCompanyOption? SelectedCompany public AsyncDelegateCommand TestBrokerCommand { get; } - public AsyncDelegateCommand ResetDefaultsCommand { get; } - - public AsyncDelegateCommand LoadCompaniesCommand { get; } - - public AsyncDelegateCommand ApplySelectedCompanyCommand { get; } - private Task SaveAsync() { - string normalizedApiBaseUrl = NormalizeApiBaseUrl(ApiBaseUrl); - string normalizedBrokerEndpoint = NormalizeBrokerEndpoint(BrokerEndpoint); - string normalizedCompanyId = CompanyId.Trim(); - string normalizedRequirementsScopeId = string.IsNullOrWhiteSpace(RequirementsScopeId) - ? normalizedCompanyId - : RequirementsScopeId.Trim(); + string normalizedApiBaseUrl = ConvisoOptions.DefaultApiBaseUrl; + string normalizedBrokerEndpoint = ConvisoOptions.DefaultBrokerEndpoint; string normalizedApiToken = ApiToken.Trim(); - string normalizedBrokerApiKey = string.IsNullOrWhiteSpace(BrokerApiKey) - ? normalizedApiToken - : BrokerApiKey.Trim(); + string normalizedBrokerApiKey = normalizedApiToken; ApiBaseUrl = normalizedApiBaseUrl; BrokerEndpoint = normalizedBrokerEndpoint; - CompanyId = normalizedCompanyId; - RequirementsScopeId = normalizedRequirementsScopeId; BrokerApiKey = normalizedBrokerApiKey; - settingsService.SetString(ConvisoOptions.ApiBaseUrlKey, normalizedApiBaseUrl); settingsService.SetSecret(ConvisoOptions.ApiTokenKey, normalizedApiToken); - settingsService.SetString(ConvisoOptions.CompanyIdKey, normalizedCompanyId); - settingsService.SetString(ConvisoOptions.RequirementsScopeIdKey, normalizedRequirementsScopeId); - settingsService.SetString(ConvisoOptions.BrokerEndpointKey, normalizedBrokerEndpoint); - settingsService.SetSecret(ConvisoOptions.BrokerApiKeyKey, normalizedBrokerApiKey); Status = "Settings saved. Secrets are stored with Windows user protection."; return Task.CompletedTask; } private async Task TestApiAsync() { - string normalizedApiBaseUrl = NormalizeApiBaseUrl(ApiBaseUrl); + string normalizedApiBaseUrl = ConvisoOptions.DefaultApiBaseUrl; string normalizedApiToken = ApiToken.Trim(); using var httpClient = new HttpClient(); using var request = new HttpRequestMessage(HttpMethod.Post, normalizedApiBaseUrl.TrimEnd('/') + "/graphql"); @@ -156,8 +136,8 @@ private async Task TestBrokerAsync() await brokerClient.ConnectAsync( new BrokerConnectionOptions { - Endpoint = NormalizeBrokerEndpoint(BrokerEndpoint), - ApiKey = BrokerApiKey.Trim(), + Endpoint = ConvisoOptions.DefaultBrokerEndpoint, + ApiKey = ApiToken.Trim(), }, CancellationToken.None); Status = "Broker connection successful."; @@ -172,56 +152,62 @@ await brokerClient.ConnectAsync( } } - private Task ResetDefaultsAsync() + public async Task LoadCompaniesAsync() { - ApiBaseUrl = ConvisoOptions.DefaultApiBaseUrl; - BrokerEndpoint = ConvisoOptions.DefaultBrokerEndpoint; - if (string.IsNullOrWhiteSpace(RequirementsScopeId)) + if (isLoadingCompanies) { - RequirementsScopeId = CompanyId.Trim(); + return; } - Status = "Defaults restored for API and broker endpoint."; - return Task.CompletedTask; - } + string savedApiToken = settingsService.GetSecret(ConvisoOptions.ApiTokenKey, string.Empty); + if (string.IsNullOrWhiteSpace(savedApiToken)) + { + Companies.Clear(); + SelectedCompany = null; + Status = "Save an API token before selecting a company."; + return; + } - private async Task LoadCompaniesAsync() - { try { - await SaveAsync(); + isLoadingCompanies = true; Status = "Loading accessible companies..."; var companies = await platformFacade.GetAccessibleCompaniesAsync(CancellationToken.None); Companies.Clear(); - foreach (AccessibleCompanyOption company in companies) + foreach (AccessibleCompanyOption company in companies.OrderBy(item => item.Label)) { Companies.Add(company); } - string currentCompanyId = CompanyId.Trim(); - SelectedCompany = Companies.FirstOrDefault(item => item.Id == currentCompanyId) ?? Companies.FirstOrDefault(); + string currentCompanyId = settingsService.GetString(ConvisoOptions.CompanyIdKey, string.Empty); + AccessibleCompanyOption? companyToSelect = + Companies.FirstOrDefault(item => item.Id == currentCompanyId) ?? + (string.IsNullOrWhiteSpace(currentCompanyId) ? Companies.FirstOrDefault() : null); + + isInitializingCompanySelection = true; + SelectedCompany = companyToSelect; + isInitializingCompanySelection = false; + + if (companyToSelect != null && string.IsNullOrWhiteSpace(currentCompanyId)) + { + settingsService.SetString(ConvisoOptions.CompanyIdKey, companyToSelect.Id); + } + Status = Companies.Count == 0 ? "No accessible companies returned for this API key." - : $"Loaded {Companies.Count} accessible compan{(Companies.Count == 1 ? "y" : "ies")}."; + : companyToSelect == null + ? "The saved company is no longer accessible. Select another company." + : $"Company {companyToSelect.DisplayLabel} selected."; } catch (Exception error) { Status = "Company discovery failed: " + error.Message; } - } - - private Task ApplySelectedCompanyAsync() - { - if (SelectedCompany == null) + finally { - Status = "Select a company first."; - return Task.CompletedTask; + isInitializingCompanySelection = false; + isLoadingCompanies = false; } - - CompanyId = SelectedCompany.Id; - RequirementsScopeId = SelectedCompany.Id; - Status = $"Selected company {SelectedCompany.DisplayLabel}. Save settings to persist it."; - return Task.CompletedTask; } private static string NormalizeApiBaseUrl(string value) diff --git a/src/Conviso.Platform.VisualStudio/ViewModels/VulnerabilitiesToolWindowViewModel.cs b/src/Conviso.Platform.VisualStudio/ViewModels/VulnerabilitiesToolWindowViewModel.cs index b4cf2cc..50003ab 100644 --- a/src/Conviso.Platform.VisualStudio/ViewModels/VulnerabilitiesToolWindowViewModel.cs +++ b/src/Conviso.Platform.VisualStudio/ViewModels/VulnerabilitiesToolWindowViewModel.cs @@ -1,7 +1,9 @@ using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Windows.Data; using Conviso.Platform.VisualStudio.Configuration; using Conviso.Platform.VisualStudio.Infrastructure; using Conviso.Platform.VisualStudio.Models; @@ -15,10 +17,11 @@ internal sealed class VulnerabilitiesToolWindowViewModel : ObservableObject private readonly IPlatformFacade platformFacade; private readonly ISettingsService settingsService; private readonly IBrokerClient brokerClient; - private AccessibleCompanyOption? selectedCompany; + private string companyId = string.Empty; private AssetOption? selectedAsset; private VulnerabilitySummary? selectedItem; private string status = "Ready"; + private string itemFilter = string.Empty; public VulnerabilitiesToolWindowViewModel( IPlatformFacade platformFacade, @@ -29,16 +32,16 @@ public VulnerabilitiesToolWindowViewModel( this.settingsService = settingsService; this.brokerClient = brokerClient; Items = new ObservableCollection(); - Companies = new ObservableCollection(); + ItemsView = CollectionViewSource.GetDefaultView(Items); + ItemsView.Filter = FilterItem; Assets = new ObservableCollection(); Details = new VulnerabilityDetailsViewModel(platformFacade, settingsService, brokerClient); RefreshCommand = new AsyncDelegateCommand(RefreshAsync); - _ = LoadFilterOptionsAsync(); } public ObservableCollection Items { get; } - public ObservableCollection Companies { get; } + public ICollectionView ItemsView { get; } public ObservableCollection Assets { get; } @@ -57,20 +60,7 @@ public VulnerabilitySummary? SelectedItem { if (SetProperty(ref selectedItem, value)) { - _ = Details.LoadAsync(value); - } - } - } - - public AccessibleCompanyOption? SelectedCompany - { - get => selectedCompany; - set - { - if (SetProperty(ref selectedCompany, value)) - { - PersistSelectedCompany(value); - _ = LoadAssetsForSelectedCompanyAsync(); + _ = LoadDetailsSafelyAsync(value); } } } @@ -87,42 +77,70 @@ public async Task RefreshAsync() { Status = "Loading vulnerabilities..."; Items.Clear(); - foreach (var item in await platformFacade.GetVulnerabilitiesAsync( - SelectedCompany?.Id, - string.IsNullOrWhiteSpace(SelectedAsset?.Id) ? null : SelectedAsset?.Id, - CancellationToken.None)) + try { - Items.Add(item); + string currentCompanyId = settingsService.GetString(ConvisoOptions.CompanyIdKey, string.Empty); + if (companyId != currentCompanyId) + { + companyId = currentCompanyId; + await LoadAssetsForSelectedCompanyAsync(); + } + + foreach (var item in await platformFacade.GetVulnerabilitiesAsync( + companyId, + string.IsNullOrWhiteSpace(SelectedAsset?.Id) ? null : SelectedAsset?.Id, + CancellationToken.None)) + { + Items.Add(item); + } + + Status = $"Loaded {Items.Count} item(s)"; + } + catch (System.Exception error) + { + Status = "Unable to load vulnerabilities: " + error.Message; + DiagnosticsLogger.LogError("Unable to load vulnerabilities: " + error); } + } - Status = $"Loaded {Items.Count} item(s)"; + public Task InitializeAsync() + { + return LoadFilterOptionsAsync(); } - private async Task LoadFilterOptionsAsync() + public string ItemFilter { - try + get => itemFilter; + set { - var companies = await platformFacade.GetAccessibleCompaniesAsync(CancellationToken.None); - Companies.Clear(); - foreach (AccessibleCompanyOption company in companies.OrderBy(item => item.Label)) + if (SetProperty(ref itemFilter, value)) { - Companies.Add(company); + ItemsView.Refresh(); + if (SelectedItem != null && !ItemsView.Contains(SelectedItem)) + { + SelectedItem = null; + } } + } + } - string currentCompanyId = settingsService.GetString(ConvisoOptions.CompanyIdKey, string.Empty); - SelectedCompany = Companies.FirstOrDefault(item => item.Id == currentCompanyId) ?? Companies.FirstOrDefault(); + private async Task LoadDetailsSafelyAsync(VulnerabilitySummary? item) + { + try + { + await Details.LoadAsync(item); } - catch + catch (System.Exception error) { - string currentCompanyId = settingsService.GetString(ConvisoOptions.CompanyIdKey, string.Empty); - if (!string.IsNullOrWhiteSpace(currentCompanyId)) - { - Companies.Clear(); - Companies.Add(new AccessibleCompanyOption { Id = currentCompanyId, Label = currentCompanyId }); - SelectedCompany = Companies[0]; - } + Status = "Unable to load vulnerability details: " + error.Message; + DiagnosticsLogger.LogError("Unable to load vulnerability details: " + error); } + } + private async Task LoadFilterOptionsAsync() + { + companyId = settingsService.GetString(ConvisoOptions.CompanyIdKey, string.Empty); + await LoadAssetsForSelectedCompanyAsync(); await RefreshAsync(); } @@ -132,14 +150,14 @@ private async Task LoadAssetsForSelectedCompanyAsync() Assets.Add(new AssetOption { Id = string.Empty, Name = "All assets" }); SelectedAsset = Assets[0]; - if (SelectedCompany == null) + if (string.IsNullOrWhiteSpace(companyId)) { return; } try { - var assets = await platformFacade.GetAssetsAsync(SelectedCompany.Id, CancellationToken.None); + var assets = await platformFacade.GetAssetsAsync(companyId, CancellationToken.None); foreach (AssetOption asset in assets.OrderBy(item => item.Name)) { Assets.Add(asset); @@ -151,15 +169,15 @@ private async Task LoadAssetsForSelectedCompanyAsync() } } - private void PersistSelectedCompany(AccessibleCompanyOption? company) + private bool FilterItem(object item) { - if (company == null || string.IsNullOrWhiteSpace(company.Id)) + if (item is not VulnerabilitySummary vulnerability || string.IsNullOrWhiteSpace(ItemFilter)) { - return; + return true; } - settingsService.SetString(ConvisoOptions.CompanyIdKey, company.Id); - settingsService.SetString(ConvisoOptions.RequirementsScopeIdKey, company.Id); + return (vulnerability.Title ?? string.Empty).IndexOf(ItemFilter.Trim(), System.StringComparison.OrdinalIgnoreCase) >= 0; } + } } diff --git a/src/Conviso.Platform.VisualStudio/ViewModels/VulnerabilityDetailsViewModel.cs b/src/Conviso.Platform.VisualStudio/ViewModels/VulnerabilityDetailsViewModel.cs index 760ece5..72a6cf3 100644 --- a/src/Conviso.Platform.VisualStudio/ViewModels/VulnerabilityDetailsViewModel.cs +++ b/src/Conviso.Platform.VisualStudio/ViewModels/VulnerabilityDetailsViewModel.cs @@ -112,22 +112,38 @@ private async Task GenerateFixAsync() return; } - CommandStatus = "Connecting to broker..."; - await EnsureBrokerConnectedAsync(); + try + { + CommandStatus = "Connecting to AI service..."; + await EnsureBrokerConnectedAsync(); + + CommandStatus = "Generating fix guidance..."; + AutoFixResult result = await brokerClient.RequestAutoFixAsync(selectedSummary.Id, CancellationToken.None); - CommandStatus = "Submitting autofix request..."; - AutoFixResult result = await brokerClient.RequestAutoFixAsync(selectedSummary.Id, CancellationToken.None); + if (!string.IsNullOrWhiteSpace(result.PrUrl)) + { + Process.Start(new ProcessStartInfo(result.PrUrl) { UseShellExecute = true }); + CommandStatus = "Fix generated. Pull request opened in browser."; + return; + } - if (!string.IsNullOrWhiteSpace(result.PrUrl)) + CommandStatus = string.IsNullOrWhiteSpace(result.Summary) + ? "Fix request completed without guidance." + : TruncateStatus(result.Summary!, 2000); + } + catch (System.Exception error) { - Process.Start(new ProcessStartInfo(result.PrUrl) { UseShellExecute = true }); - CommandStatus = "Fix generated. Pull request opened in browser."; - return; + CommandStatus = "Generate Fix failed: " + error.Message; + DiagnosticsLogger.LogError("Generate Fix failed: " + error); } + } - CommandStatus = string.IsNullOrWhiteSpace(result.Summary) - ? "AutoFix request sent. Track execution status in Conviso Platform." - : result.Summary!; + private static string TruncateStatus(string value, int maxLength) + { + string normalized = value.Trim(); + return normalized.Length <= maxLength + ? normalized + : normalized.Substring(0, maxLength) + "\n\n[response truncated]"; } private async Task EnsureBrokerConnectedAsync() @@ -140,8 +156,8 @@ private async Task EnsureBrokerConnectedAsync() await brokerClient.ConnectAsync( new BrokerConnectionOptions { - Endpoint = settingsService.GetString(ConvisoOptions.BrokerEndpointKey, ConvisoOptions.DefaultBrokerEndpoint), - ApiKey = settingsService.GetSecret(ConvisoOptions.BrokerApiKeyKey, string.Empty), + Endpoint = ConvisoOptions.DefaultBrokerEndpoint, + ApiKey = settingsService.GetSecret(ConvisoOptions.ApiTokenKey, string.Empty), }, CancellationToken.None); } diff --git a/src/Conviso.Platform.VisualStudio/source.extension.vsixmanifest b/src/Conviso.Platform.VisualStudio/source.extension.vsixmanifest index c5db6ff..ccee98f 100644 --- a/src/Conviso.Platform.VisualStudio/source.extension.vsixmanifest +++ b/src/Conviso.Platform.VisualStudio/source.extension.vsixmanifest @@ -1,9 +1,10 @@ - + Conviso Platform Conviso Platform integration for Visual Studio. + Resources\pluginIcon.png