Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
013315c
feat: add comprehensive configuration dialog with all settings [IDE-1…
bastiandoetsch Nov 21, 2025
a63d424
fix: populate all settings in constructSettingsFromConfig [IDE-1455]
bastiandoetsch Nov 21, 2025
775092f
refactor: remove auto-populated system fields from config dialog [IDE…
bastiandoetsch Nov 21, 2025
37bffef
feat: improve configuration dialog UX and add risk score threshold [I…
bastiandoetsch Nov 21, 2025
884d858
refactor: reduce code duplication and improve maintainability [IDE-1455]
bastiandoetsch Nov 21, 2025
eb2d1cd
fix: properly implement configuration smoke test with LSP server [IDE…
bastiandoetsch Nov 21, 2025
a67eb6b
refactor: improve smoke test to verify actual HTML content from confi…
bastiandoetsch Nov 21, 2025
1374398
fix: smoke test now verifies actual HTML content from command respons…
bastiandoetsch Nov 21, 2025
4e84b1d
docs: add comprehensive configuration dialog integration guide [IDE-1…
bastiandoetsch Nov 21, 2025
b42a85f
refactor: remove unnecessary window/showDocument callback [IDE-1455]
bastiandoetsch Nov 21, 2025
35cdceb
test: update configuration command test to verify response instead of…
bastiandoetsch Nov 21, 2025
55f7084
refactor: simplify response to return HTML string directly [IDE-1455]
bastiandoetsch Nov 21, 2025
4d525c7
docs: enhance configuration saving documentation with complete flow d…
bastiandoetsch Nov 21, 2025
add202a
docs: fix sequence diagrams to match current implementation [IDE-1455]
bastiandoetsch Nov 21, 2025
9e7f603
feat: enhance configuration dialog with org auto-select and field too…
nick-y-snyk Nov 28, 2025
94678c5
chore: preview of html
nick-y-snyk Nov 28, 2025
5130461
Merge branch 'main' into feat/IDE-1455_configuration-dialog
nick-y-snyk Nov 28, 2025
f15edf4
feat: auto save instead of a button
nick-y-snyk Dec 1, 2025
de08489
fix: remove trustedFolders from settings page. Merge folder configs
nick-y-snyk Dec 1, 2025
3720f77
Merge branch 'main' into feat/IDE-1455_configuration-dialog
rrama Dec 2, 2025
1447d43
feat: filter out folder configs which are not part of the workspace
nick-y-snyk Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,23 @@ Right now the language server supports the following actions:
- command: `snyk.generateIssueDescription`
- args:
- `issueId` string
- `Configuration Dialog` Opens the configuration dialog with all Snyk settings.
- command: `snyk.workspace.configuration`
- args: empty
- returns: HTML string containing the configuration dialog
- example:
```html
<html>
<head>
<title>Snyk Configuration</title>
...
</head>
<body>
<!-- Configuration form with all settings -->
</body>
</html>
```
- See [Configuration Dialog Integration Guide](docs/configuration-dialog.md) for full integration details.

## Installation

Expand Down
116 changes: 107 additions & 9 deletions application/server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"github.com/snyk/snyk-ls/application/di"
"github.com/snyk/snyk-ls/domain/ide/command"
"github.com/snyk/snyk-ls/infrastructure/analytics"
"github.com/snyk/snyk-ls/internal/product"
"github.com/snyk/snyk-ls/internal/types"
"github.com/snyk/snyk-ls/internal/util"
)
Expand Down Expand Up @@ -216,24 +217,67 @@
c.SetSnykOpenBrowserActionsEnabled(enable)
}

func updateFolderConfig(c *config.Config, settings types.Settings, logger *zerolog.Logger, triggerSource analytics.TriggerSource) {

Check failure on line 220 in application/server/configuration.go

View workflow job for this annotation

GitHub Actions / lint

cyclomatic complexity 20 of func `updateFolderConfig` is high (> 15) (gocyclo)
notifier := di.Notifier()
var folderConfigs []types.FolderConfig
needsToSendUpdateToClient := false

for _, folderConfig := range settings.FolderConfigs {
path := folderConfig.FolderPath
// MERGE: Process all folders from both incoming settings AND storage
// This prevents data loss when IDE sends an incomplete list

// Create a map of incoming configs for quick lookup
incomingMap := make(map[types.FilePath]types.FolderConfig)
for _, fc := range settings.FolderConfigs {
incomingMap[fc.FolderPath] = fc
}

// Get all paths that need processing (union of incoming + stored)
allPaths := make(map[types.FilePath]bool)

// Add incoming paths
for path := range incomingMap {
allPaths[path] = true
}

// Add stored paths from all workspace folders
workspace := c.Workspace()
if workspace != nil {
for _, folder := range workspace.Folders() {
allPaths[folder.Path()] = true
}
}

// Process each folder
for path := range allPaths {
storedConfig := c.FolderConfig(path)
// Never trust the IDE for what the FFs and SAST settings are
folderConfig.FeatureFlags = storedConfig.FeatureFlags
folderConfig.SastSettings = storedConfig.SastSettings

// Start with stored config as base, then merge incoming changes
var folderConfig types.FolderConfig
if storedConfig != nil {
folderConfig = *storedConfig
} else {
// New folder - initialize with defaults
folderConfig = types.FolderConfig{
FolderPath: path,
}
}

// Merge incoming config if present
if incoming, hasIncoming := incomingMap[path]; hasIncoming {
mergeFolderConfig(&folderConfig, incoming)
}

// Never trust the IDE for what the FFs and SAST settings are - always use stored values
if storedConfig != nil {
folderConfig.FeatureFlags = storedConfig.FeatureFlags
folderConfig.SastSettings = storedConfig.SastSettings
}

// Folder config might be new or changed, so (re)resolve the org before saving it.
// We should also check that the folder's org is still valid if the globally set org has changed.
// Also, if the config hasn't been migrated yet, we need to perform the initial migration.
needsMigration := !storedConfig.OrgMigratedFromGlobalConfig
orgSettingsChanged := !folderConfigsOrgSettingsEqual(folderConfig, storedConfig)
needsMigration := storedConfig != nil && !storedConfig.OrgMigratedFromGlobalConfig
orgSettingsChanged := storedConfig != nil && !folderConfigsOrgSettingsEqual(folderConfig, storedConfig)

if needsMigration || orgSettingsChanged {
updateFolderConfigOrg(c, storedConfig, &folderConfig)
Expand All @@ -245,15 +289,18 @@

di.FeatureFlagService().PopulateFolderConfig(&folderConfig)

if !cmp.Equal(folderConfig, *storedConfig) {
configChanged := storedConfig == nil || !cmp.Equal(folderConfig, *storedConfig)
if configChanged {
needsToSendUpdateToClient = true
err := c.UpdateFolderConfig(&folderConfig)
if err != nil {
notifier.SendShowMessage(sglsp.MTError, err.Error())
}
}

sendFolderConfigAnalytics(c, path, triggerSource, *storedConfig, folderConfig)
if storedConfig != nil {
sendFolderConfigAnalytics(c, path, triggerSource, *storedConfig, folderConfig)
}

folderConfigs = append(folderConfigs, folderConfig)
}
Expand All @@ -263,6 +310,57 @@
}
}

// mergeFolderConfig merges incoming config fields into the base config
// Only non-zero/non-empty values from incoming are applied
func mergeFolderConfig(base *types.FolderConfig, incoming types.FolderConfig) {
// Always update FolderPath
if incoming.FolderPath != "" {
base.FolderPath = incoming.FolderPath
}

// Merge fields that were explicitly sent
if incoming.BaseBranch != "" {
base.BaseBranch = incoming.BaseBranch
}
if len(incoming.LocalBranches) > 0 {
base.LocalBranches = incoming.LocalBranches
}
if len(incoming.AdditionalParameters) > 0 {
base.AdditionalParameters = incoming.AdditionalParameters
}
if incoming.ReferenceFolderPath != "" {
base.ReferenceFolderPath = incoming.ReferenceFolderPath
}
if incoming.PreferredOrg != "" {
base.PreferredOrg = incoming.PreferredOrg
}
if incoming.AutoDeterminedOrg != "" {
base.AutoDeterminedOrg = incoming.AutoDeterminedOrg
}

// Boolean fields - need to check if they were explicitly set
// For now, always copy them (Go zero value is false, which is valid)
base.OrgSetByUser = incoming.OrgSetByUser
base.OrgMigratedFromGlobalConfig = incoming.OrgMigratedFromGlobalConfig

// Numeric fields
if incoming.RiskScoreThreshold != 0 {
base.RiskScoreThreshold = incoming.RiskScoreThreshold
}

// ScanCommandConfig - merge if present
if incoming.ScanCommandConfig != nil {
if base.ScanCommandConfig == nil {
base.ScanCommandConfig = make(map[product.Product]types.ScanCommandConfig)
}
for prod, config := range incoming.ScanCommandConfig {
base.ScanCommandConfig[prod] = config
}
}

// Note: FeatureFlags and SastSettings are NOT merged here - they are handled separately
}

func sendFolderConfigAnalytics(c *config.Config, path types.FilePath, triggerSource analytics.TriggerSource, oldStoredConfig, newStoredConfig types.FolderConfig) {
// FolderPath change
if oldStoredConfig.FolderPath != newStoredConfig.FolderPath {
Expand Down
209 changes: 209 additions & 0 deletions application/server/configuration_smoke_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* Β© 2024-2025 Snyk Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package server

import (
"strings"
"testing"

sglsp "github.com/sourcegraph/go-lsp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/snyk/snyk-ls/application/di"
"github.com/snyk/snyk-ls/internal/testutil"
"github.com/snyk/snyk-ls/internal/types"
)

// Test_SmokeConfigurationDialog verifies that the configuration dialog:
// 1. Can be triggered via workspace/executeCommand
// 2. Returns response with URI and HTML content
// 3. Generated HTML includes ALL settings fields from types.Settings
// 4. Generated HTML includes ALL sub-fields from FolderConfig
// 5. Includes authentication and logout triggers
func Test_SmokeConfigurationDialog(t *testing.T) {
c := testutil.SmokeTest(t, "")
testutil.CreateDummyProgressListener(t)

// Setup server with LSP client
loc, _ := setupServer(t, c)
di.Init()

// Execute the configuration command via LSP
response, err := loc.Client.Call(t.Context(), "workspace/executeCommand", sglsp.ExecuteCommandParams{
Command: types.WorkspaceConfigurationCommand,
Arguments: []any{},
})
require.NoError(t, err, "Configuration command should execute successfully")

// Unmarshal the result - should be an HTML string
var html string
err = response.UnmarshalResult(&html)
require.NoError(t, err, "Should unmarshal result")
require.NotEmpty(t, html, "HTML content should not be empty")

// Now verify the HTML content that was returned by the command
t.Run("Verify HTML Content from Command Response", func(t *testing.T) {
// Verify VISIBLE settings in simplified UI are present in HTML
t.Run("Global Settings Fields", func(t *testing.T) {
t.Helper()
// Core authentication settings
assertFieldPresent(t, html, "token", "Token field")
assertFieldPresent(t, html, "endpoint", "Endpoint field")
assertFieldPresent(t, html, "authenticationMethod", "AuthenticationMethod field")
assertFieldPresent(t, html, "insecure", "Insecure field")

// Product activation settings (Scan Configuration section)
assertFieldPresent(t, html, "activateSnykOpenSource", "ActivateSnykOpenSource field")
assertFieldPresent(t, html, "activateSnykCode", "ActivateSnykCode field")
assertFieldPresent(t, html, "activateSnykIac", "ActivateSnykIac field")
assertFieldPresent(t, html, "activateSnykCodeSecurity", "ActivateSnykCodeSecurity field")
assertFieldPresent(t, html, "activateSnykCodeQuality", "ActivateSnykCodeQuality field")
assertFieldPresent(t, html, "scanningMode", "ScanningMode field")
assertFieldPresent(t, html, "organization", "Organization field")

// Filter and display settings
assertFieldPresent(t, html, "filterSeverity", "FilterSeverity field")
assertFieldPresent(t, html, "issueViewOptions", "IssueViewOptions field")
assertFieldPresent(t, html, "enableDeltaFindings", "EnableDeltaFindings field")

// Advanced settings (legacy additional params only)
assertFieldPresent(t, html, "additionalParams", "AdditionalParams field")
})

t.Run("Folder-Specific Settings Fields", func(t *testing.T) {
// Verify folder configs section exists
assert.Contains(t, html, "Folder Settings", "Folder Settings section should be present")

// Folder-specific fields in simplified UI
// Only visible fields: additionalParameters, riskScoreThreshold, orgSetByUser, preferredOrg, scan config
if strings.Contains(html, "folderPath") {
// If folders are present, verify their VISIBLE fields
assertFieldPresent(t, html, "folderPath", "FolderPath field")
assertFieldPresent(t, html, "additionalParameters", "AdditionalParameters field")
assertFieldPresent(t, html, "riskScoreThreshold", "RiskScoreThreshold field")
assertFieldPresent(t, html, "orgSetByUser", "OrgSetByUser field")
assertFieldPresent(t, html, "preferredOrg", "PreferredOrg field")

// Scan command config fields (pre/post scan commands per product - in hidden section)
assertFieldPresent(t, html, "scanConfig_oss_preScanCommand", "ScanConfig OSS PreScanCommand field")
assertFieldPresent(t, html, "scanConfig_oss_postScanCommand", "ScanConfig OSS PostScanCommand field")
assertFieldPresent(t, html, "scanConfig_code_preScanCommand", "ScanConfig Code PreScanCommand field")
assertFieldPresent(t, html, "scanConfig_iac_preScanCommand", "ScanConfig IaC PreScanCommand field")
}
})

t.Run("Authentication and Logout Triggers", func(t *testing.T) {
// Verify authentication and logout buttons are present
assert.Contains(t, html, "Authenticate", "Authentication button should be present")
assert.Contains(t, html, "authenticate-btn", "Authentication button ID should be present")
assert.Contains(t, html, "Logout", "Logout button should be present")
assert.Contains(t, html, "logout-btn", "Logout button ID should be present")

// Verify IDE function placeholders for injection
assert.Contains(t, html, "${ideLogin}", "ideLogin placeholder should be present")
assert.Contains(t, html, "${ideSaveConfig}", "ideSaveConfig placeholder should be present")
assert.Contains(t, html, "${ideLogout}", "ideLogout placeholder should be present")
})

t.Run("Endpoint Validation Logic", func(t *testing.T) {
// Verify endpoint validation is present in JavaScript
assert.Contains(t, html, "validateEndpoint", "Endpoint validation function should be present")
assert.Contains(t, html, "endpoint-error", "Endpoint error element should be present")

// Verify regex patterns for Snyk API endpoints (accounting for escaping in JavaScript)
// The regex patterns will be escaped in the JavaScript, so we check for the domain parts
assert.Contains(t, html, "snyk.io", "Snyk API domain should be present in validation")
assert.Contains(t, html, "snykgov.io", "Snyk Gov API domain should be present in validation")
})

t.Run("Form Data Collection", func(t *testing.T) {
// Verify form data collection function exists
assert.Contains(t, html, "collectData", "collectData function should be present")
assert.Contains(t, html, "configForm", "config form ID should be present")

// Verify save functionality
assert.Contains(t, html, "save-config-btn", "Save button should be present")
assert.Contains(t, html, "Save Configuration", "Save button text should be present")
})

t.Run("IE7 Compatibility", func(t *testing.T) {
// Verify no ES6+ syntax is used
assert.NotContains(t, html, "=>", "Should not contain arrow functions")
assert.NotContains(t, html, "let ", "Should not use 'let' keyword")
assert.NotContains(t, html, "const ", "Should not use 'const' keyword")
assert.Contains(t, html, "var ", "Should use 'var' keyword")

// Verify IE7-compatible event handling
assert.Contains(t, html, "attachEvent", "Should have attachEvent for IE7 compatibility")
})

t.Run("Value Population", func(t *testing.T) {
// Verify that values from config are populated in the HTML
// The HTML should contain value attributes for input fields
assert.Contains(t, html, "value=\"", "HTML should contain populated values")

// Verify endpoint field has a value attribute (will be from config)
assert.Regexp(t, `id="endpoint"[^>]*value="[^"]*"`, html, "Endpoint field should have a value")

// Verify token field has a value attribute
assert.Regexp(t, `id="token"[^>]*value="[^"]*"`, html, "Token field should have a value")

// Verify organization field has a value attribute
assert.Regexp(t, `id="organization"[^>]*value="[^"]*"`, html, "Organization field should have a value")
})

t.Run("Security - Password Masking", func(t *testing.T) {
// Verify token field is of type password
assert.Contains(t, html, "type=\"password\"", "Token field should be password type")
})
})
}

// assertFieldPresent checks if a field name/id is present in the HTML
func assertFieldPresent(t *testing.T, html, fieldName, description string) {
t.Helper()

// Check for various ways a field might be present in HTML:
// 1. As a name attribute: name="fieldName"
// 2. As an id attribute: id="fieldName"
// 3. As a folder-specific field: folder_0_fieldName
fieldPatterns := []string{
"name=\"" + fieldName + "\"",
"id=\"" + fieldName + "\"",
"name=\"folder_0_" + fieldName + "\"",
"id=\"folder_0_" + fieldName + "\"",
"folder_{{$index}}_" + fieldName,
}

found := false
for _, pattern := range fieldPatterns {
if strings.Contains(html, pattern) {
found = true
break
}
}

if !found {
// Also check if it's mentioned as a label or in comments
if strings.Contains(strings.ToLower(html), strings.ToLower(fieldName)) {
found = true
}
}

assert.True(t, found, "%s must be present in configuration HTML (field: %s)", description, fieldName)
}
1 change: 1 addition & 0 deletions application/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ func initializeHandler(c *config.Config, srv *jrpc2.Server) handler.Func {
types.GenerateIssueDescriptionCommand,
types.ReportAnalyticsCommand,
types.ExecuteMCPToolCall,
types.WorkspaceConfigurationCommand,
},
},
},
Expand Down
Loading
Loading