Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f1999b2
feat: update html tree view with UI changes for ga
andrewrobinsonhodges-snyk Jun 1, 2026
2b7d77d
Merge branch 'main' into feat/html-tree-view-ga
andrewrobinsonhodges-snyk Jun 3, 2026
2ad3358
fix: hide chevrons when there are no child items in the tree view
andrewrobinsonhodges-snyk Jun 8, 2026
894ec55
refactor: change tooltips and child node text to match prototype
andrewrobinsonhodges-snyk Jun 8, 2026
dd3c544
fix: cli path becomes disabled when manage binaries is toggled on
tarsnyk Jun 8, 2026
c7acca9
fix: change tooltip to reflect the reason for scans not running
andrewrobinsonhodges-snyk Jun 9, 2026
f122693
fix: tree view and settings page severity filters are now kept in sync
andrewrobinsonhodges-snyk Jun 10, 2026
9e20447
fix: restore missing chevrons to file icons in tree view
andrewrobinsonhodges-snyk Jun 11, 2026
655cbe9
Merge branch 'main' into feat/html-tree-view-ga
andrewrobinsonhodges-snyk Jun 17, 2026
d8d6fff
chore: add generated file
andrewrobinsonhodges-snyk Jun 18, 2026
4a2b751
fix: hide autofix messages when autofix is not available [IDE-1880] (…
andrewrobinsonhodges-snyk Jun 18, 2026
ea4e213
fix: update html test code regex
andrewrobinsonhodges-snyk Jun 18, 2026
d13e711
Merge branch 'feat/html-tree-view-ga' of github.com:snyk/snyk-ls into…
andrewrobinsonhodges-snyk Jun 18, 2026
4e16b3b
Merge branch 'main' into feat/html-tree-view-ga
andrewrobinsonhodges-snyk Jun 19, 2026
501c3e8
fix: race condition when accessing context
andrewrobinsonhodges-snyk Jun 22, 2026
577da98
Merge branch 'main' into feat/html-tree-view-ga
andrewrobinsonhodges-snyk Jun 22, 2026
132d1ac
Merge branch 'main' into feat-tars-first-test-change
tarsnyk Jun 22, 2026
0a15345
fix: spelling error in comments and regenerated static html
tarsnyk Jun 22, 2026
c9085e6
Merge branch 'feat/html-tree-view-ga' into feat-tars-first-test-change
tarsnyk Jun 22, 2026
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
6 changes: 6 additions & 0 deletions application/server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,12 @@ func applySeverityFilter(conf configuration.Configuration, engine workflow.Engin
return
}

// This is the user-global settings path: it sets the workspace default only.
// Per-folder severity overrides are written independently via the per-folder
// folderConfigs channel (processFolderConfigs) for the specific folder being
// edited, and via the workspace-wide tree toggle (toggleTreeFilter). Keeping
// this path global-only is what stops the global default and per-folder
// overrides from moving in lockstep. (IDE-1996)
oldValue := config.GetFilterSeverity(conf)
modified := config.SetSeverityFilterOnConfig(conf, sf, logger)
if !modified {
Expand Down
2 changes: 1 addition & 1 deletion domain/ide/command/command_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func CreateFromCommandData(
case types.GetTreeView:
return &getTreeViewCommand{command: commandData, engine: engine, scanStateFunc: scanStateFunc}, nil
case types.ToggleTreeFilter:
return &toggleTreeFilter{command: commandData, engine: engine}, nil
return &toggleTreeFilter{command: commandData, engine: engine, notifier: notifier, configResolver: configResolver}, nil
case types.SetNodeExpanded:
return &setNodeExpanded{command: commandData, expandState: treeview.GlobalExpandState()}, nil
case types.ShowScanErrorDetails:
Expand Down
79 changes: 62 additions & 17 deletions domain/ide/command/toggle_tree_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@ import (
"github.com/snyk/go-application-framework/pkg/workflow"

"github.com/snyk/snyk-ls/application/config"
noti "github.com/snyk/snyk-ls/internal/notification"
"github.com/snyk/snyk-ls/internal/types"
"github.com/snyk/snyk-ls/internal/util"
)

// toggleTreeFilter handles the snyk.toggleTreeFilter command. It updates the
// severity filter or issue view options in config, then triggers a config change
// which re-emits the tree view via $/snyk.treeView notification.
// which re-emits the tree view via $/snyk.treeView notification, and emits a
// $/snyk.configuration notification so an open settings window reflects the new
// filter values (IDE-1866).
type toggleTreeFilter struct {
command types.CommandData
engine workflow.Engine
command types.CommandData
engine workflow.Engine
notifier noti.Notifier
configResolver types.ConfigResolverInterface
}

func (cmd *toggleTreeFilter) Command() types.CommandData {
Expand Down Expand Up @@ -77,40 +81,81 @@ func (cmd *toggleTreeFilter) Execute(_ context.Context) (any, error) {
go ws.HandleConfigChange()
}

// Emit the current configuration so an open settings window reflects the new
// filter values without being reopened. This is the same $/snyk.configuration
// notification a settings change already sends back; it is a one-way state
// push (the settings view applies it, it does not echo a change), so it does
// not create a toggle<->settings loop.
cmd.notifyConfigurationChanged()

return nil, nil
}

func (cmd *toggleTreeFilter) applySeverityFilter(value string, enabled bool) error {
current := config.GetFilterSeverity(cmd.engine.GetConfiguration())
var settingName string
switch value {
case "critical":
current.Critical = enabled
settingName = types.SettingSeverityFilterCritical
case "high":
current.High = enabled
settingName = types.SettingSeverityFilterHigh
case "medium":
current.Medium = enabled
settingName = types.SettingSeverityFilterMedium
case "low":
current.Low = enabled
settingName = types.SettingSeverityFilterLow
default:
return fmt.Errorf("unknown severity value %q", value)
}
config.SetSeverityFilterOnConfig(cmd.engine.GetConfiguration(), util.Ptr(current), cmd.engine.GetLogger())
cmd.writeFilterToAllFolders(settingName, enabled)
return nil
}

func (cmd *toggleTreeFilter) applyIssueViewFilter(value string, enabled bool) error {
// toggleTreeFilter intentionally writes global IVO: the filter panel is a
// workspace-wide concept. Per-folder tree info nodes read folder-level IVO
// (see BuildTree / FolderData.IssueViewOptions) — these are separate concerns by design.
current := config.GetIssueViewOptions(cmd.engine.GetConfiguration())
var settingName string
switch value {
case "openIssues":
current.OpenIssues = enabled
settingName = types.SettingIssueViewOpenIssues
case "ignoredIssues":
current.IgnoredIssues = enabled
settingName = types.SettingIssueViewIgnoredIssues
default:
return fmt.Errorf("unknown issue view value %q", value)
}
config.SetIssueViewOptionsOnConfig(cmd.engine.GetConfiguration(), util.Ptr(current), cmd.engine.GetLogger())
cmd.writeFilterToAllFolders(settingName, enabled)
return nil
}

// writeFilterToAllFolders writes a single folder-scoped filter setting to every
// open folder, leaving each folder's OTHER filter values untouched. The toolbar
// is workspace-wide, so a toggle applies the toggled severity to all folders
// (e.g. clicking a "mixed" button enables just that severity everywhere) — it
// must not rewrite the other severities, which can legitimately differ per
// folder. Writing per-folder only (not user-global) also keeps the toggle from
// moving the global default in lockstep; the per-folder value is authoritative
// for filtering, outranking LDX-Sync remote defaults. (IDE-1866 / IDE-1996)
func (cmd *toggleTreeFilter) writeFilterToAllFolders(settingName string, enabled bool) {
conf := cmd.engine.GetConfiguration()
for _, f := range cmd.workspaceFolders() {
types.SetUserFolder(conf, f.Path(), settingName, enabled)
}
}

// workspaceFolders returns the current workspace folders (nil if no workspace).
func (cmd *toggleTreeFilter) workspaceFolders() []types.Folder {
ws := config.GetWorkspace(cmd.engine.GetConfiguration())
if ws == nil {
return nil
}
return ws.Folders()
}

// notifyConfigurationChanged sends the current configuration to the IDE via the
// $/snyk.configuration notification so an open settings window can reflect the
// updated per-folder filter values. featureFlagService is nil here (the same as
// the settings-side sendFolderConfigUpdateIfNeeded), since only the filter
// values are relevant to this push.
func (cmd *toggleTreeFilter) notifyConfigurationChanged() {
if cmd.notifier == nil {
return
}
lspConfig := BuildLspConfiguration(cmd.engine.GetConfiguration(), cmd.engine, cmd.engine.GetLogger(), nil, cmd.configResolver)
cmd.notifier.Send(lspConfig)
}
128 changes: 116 additions & 12 deletions domain/ide/command/toggle_tree_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,75 @@ package command
import (
"testing"

"github.com/snyk/go-application-framework/pkg/workflow"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/snyk/snyk-ls/application/config"
"github.com/snyk/snyk-ls/domain/ide/workspace"
"github.com/snyk/snyk-ls/domain/scanstates"
"github.com/snyk/snyk-ls/domain/snyk/persistence"
"github.com/snyk/snyk-ls/domain/snyk/scanner"
"github.com/snyk/snyk-ls/infrastructure/featureflag"
"github.com/snyk/snyk-ls/internal/notification"
"github.com/snyk/snyk-ls/internal/observability/performance"
"github.com/snyk/snyk-ls/internal/testutil"
"github.com/snyk/snyk-ls/internal/types"
"github.com/snyk/snyk-ls/internal/util"
)

// setupToggleWorkspaceFolder registers a workspace with a single folder so the
// toggle command (which writes per-folder, workspace-wide) has a folder to act
// on. Returns the folder path for reading the resolved per-folder filters back.
func setupToggleWorkspaceFolder(t *testing.T, engine workflow.Engine) types.FilePath {
t.Helper()
sc := &scanner.TestScanner{}
scanNotifier := scanner.NewMockScanNotifier()
scanPersister := persistence.NewGitPersistenceProvider(engine.GetLogger(), engine.GetConfiguration())
scanStateAggregator := scanstates.NewNoopStateAggregator()
resolver := testutil.DefaultConfigResolver(engine)
folderPath := types.PathKey("dummy")
w := workspace.New(engine.GetConfiguration(), engine.GetLogger(), performance.NewInstrumentor(), sc, nil, scanNotifier, notification.NewMockNotifier(), scanPersister, scanStateAggregator, featureflag.NewFakeService(), resolver, engine)
folder := workspace.NewFolder(engine.GetConfiguration(), engine.GetLogger(), folderPath, "dummy", sc, nil, scanNotifier, notification.NewMockNotifier(), scanPersister, scanStateAggregator, featureflag.NewFakeService(), resolver, engine)
w.AddFolder(folder)
config.SetWorkspace(engine.GetConfiguration(), w)
return folderPath
}

// setupToggleWorkspaceFolders registers a workspace with one folder per given path.
func setupToggleWorkspaceFolders(t *testing.T, engine workflow.Engine, paths ...types.FilePath) {
t.Helper()
sc := &scanner.TestScanner{}
scanNotifier := scanner.NewMockScanNotifier()
scanPersister := persistence.NewGitPersistenceProvider(engine.GetLogger(), engine.GetConfiguration())
scanStateAggregator := scanstates.NewNoopStateAggregator()
resolver := testutil.DefaultConfigResolver(engine)
w := workspace.New(engine.GetConfiguration(), engine.GetLogger(), performance.NewInstrumentor(), sc, nil, scanNotifier, notification.NewMockNotifier(), scanPersister, scanStateAggregator, featureflag.NewFakeService(), resolver, engine)
for _, p := range paths {
folder := workspace.NewFolder(engine.GetConfiguration(), engine.GetLogger(), p, string(p), sc, nil, scanNotifier, notification.NewMockNotifier(), scanPersister, scanStateAggregator, featureflag.NewFakeService(), resolver, engine)
w.AddFolder(folder)
}
config.SetWorkspace(engine.GetConfiguration(), w)
}

func folderSeverityFilter(t *testing.T, engine workflow.Engine, folderPath types.FilePath) types.SeverityFilter {
t.Helper()
resolver := testutil.DefaultConfigResolver(engine)
fc := config.GetFolderConfigFromEngine(engine, resolver, folderPath, engine.GetLogger())
return resolver.FilterSeverityForFolder(fc)
}

func folderIssueViewOptions(t *testing.T, engine workflow.Engine, folderPath types.FilePath) types.IssueViewOptions {
t.Helper()
resolver := testutil.DefaultConfigResolver(engine)
fc := config.GetFolderConfigFromEngine(engine, resolver, folderPath, engine.GetLogger())
return resolver.IssueViewOptionsForFolder(fc)
}

func TestToggleTreeFilter_Execute_SeverityHigh_Disabled(t *testing.T) {
engine := testutil.UnitTest(t)
config.SetSeverityFilterOnConfig(engine.GetConfiguration(), util.Ptr(types.NewSeverityFilter(true, true, true, true)), engine.GetLogger())
folderPath := setupToggleWorkspaceFolder(t, engine)
types.SetSeverityFilterForFolder(engine.GetConfiguration(), folderPath, util.Ptr(types.NewSeverityFilter(true, true, true, true)))

cmd := &toggleTreeFilter{
command: types.CommandData{
Expand All @@ -44,16 +101,17 @@ func TestToggleTreeFilter_Execute_SeverityHigh_Disabled(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, result, "toggleTreeFilter should return nil; tree HTML is pushed via notification")

filter := config.GetFilterSeverity(engine.GetConfiguration())
filter := folderSeverityFilter(t, engine, folderPath)
assert.True(t, filter.Critical)
assert.False(t, filter.High, "high should be disabled")
assert.False(t, filter.High, "high should be disabled for the folder")
assert.True(t, filter.Medium)
assert.True(t, filter.Low)
}

func TestToggleTreeFilter_Execute_SeverityMedium_Enabled(t *testing.T) {
engine := testutil.UnitTest(t)
config.SetSeverityFilterOnConfig(engine.GetConfiguration(), util.Ptr(types.NewSeverityFilter(true, true, false, true)), engine.GetLogger())
folderPath := setupToggleWorkspaceFolder(t, engine)
types.SetSeverityFilterForFolder(engine.GetConfiguration(), folderPath, util.Ptr(types.NewSeverityFilter(true, true, false, true)))

cmd := &toggleTreeFilter{
command: types.CommandData{
Expand All @@ -67,13 +125,14 @@ func TestToggleTreeFilter_Execute_SeverityMedium_Enabled(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, result, "toggleTreeFilter should return nil; tree HTML is pushed via notification")

filter := config.GetFilterSeverity(engine.GetConfiguration())
assert.True(t, filter.Medium, "medium should be enabled")
filter := folderSeverityFilter(t, engine, folderPath)
assert.True(t, filter.Medium, "medium should be enabled for the folder")
}

func TestToggleTreeFilter_Execute_IssueViewOpenIssues_Disabled(t *testing.T) {
engine := testutil.UnitTest(t)
config.SetIssueViewOptionsOnConfig(engine.GetConfiguration(), util.Ptr(types.NewIssueViewOptions(true, true)), engine.GetLogger())
folderPath := setupToggleWorkspaceFolder(t, engine)
types.SetIssueViewOptionsForFolder(engine.GetConfiguration(), folderPath, util.Ptr(types.NewIssueViewOptions(true, true)))

cmd := &toggleTreeFilter{
command: types.CommandData{
Expand All @@ -87,14 +146,15 @@ func TestToggleTreeFilter_Execute_IssueViewOpenIssues_Disabled(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, result, "toggleTreeFilter should return nil; tree HTML is pushed via notification")

options := config.GetIssueViewOptions(engine.GetConfiguration())
assert.False(t, options.OpenIssues, "open issues should be disabled")
options := folderIssueViewOptions(t, engine, folderPath)
assert.False(t, options.OpenIssues, "open issues should be disabled for the folder")
assert.True(t, options.IgnoredIssues)
}

func TestToggleTreeFilter_Execute_IssueViewIgnoredIssues_Enabled(t *testing.T) {
engine := testutil.UnitTest(t)
config.SetIssueViewOptionsOnConfig(engine.GetConfiguration(), util.Ptr(types.NewIssueViewOptions(true, false)), engine.GetLogger())
folderPath := setupToggleWorkspaceFolder(t, engine)
types.SetIssueViewOptionsForFolder(engine.GetConfiguration(), folderPath, util.Ptr(types.NewIssueViewOptions(true, false)))

cmd := &toggleTreeFilter{
command: types.CommandData{
Expand All @@ -108,8 +168,52 @@ func TestToggleTreeFilter_Execute_IssueViewIgnoredIssues_Enabled(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, result, "toggleTreeFilter should return nil; tree HTML is pushed via notification")

options := config.GetIssueViewOptions(engine.GetConfiguration())
assert.True(t, options.IgnoredIssues, "ignored issues should be enabled")
options := folderIssueViewOptions(t, engine, folderPath)
assert.True(t, options.IgnoredIssues, "ignored issues should be enabled for the folder")
}

func TestToggleTreeFilter_MixedFolders_TogglesOnlyClickedSeverity(t *testing.T) {
// Two open folders with different severity filters. Clicking one severity in
// the workspace-wide toolbar must set ONLY that severity on every folder and
// leave each folder's other (legitimately differing) severities untouched.
engine := testutil.UnitTest(t)
pathA := types.PathKey("folderA")
pathB := types.PathKey("folderB")
setupToggleWorkspaceFolders(t, engine, pathA, pathB)
types.SetSeverityFilterForFolder(engine.GetConfiguration(), pathA, util.Ptr(types.NewSeverityFilter(true, false, true, false)))
types.SetSeverityFilterForFolder(engine.GetConfiguration(), pathB, util.Ptr(types.NewSeverityFilter(false, false, false, true)))

cmd := &toggleTreeFilter{
command: types.CommandData{
CommandId: types.ToggleTreeFilter,
Arguments: []any{"severity", "high", true},
},
engine: engine,
}
_, err := cmd.Execute(t.Context())
require.NoError(t, err)

// Only High flips to true; Critical/Medium/Low keep each folder's own values.
assert.Equal(t, types.NewSeverityFilter(true, true, true, false), folderSeverityFilter(t, engine, pathA), "folder A: only High changed")
assert.Equal(t, types.NewSeverityFilter(false, true, false, true), folderSeverityFilter(t, engine, pathB), "folder B: only High changed")
}

func TestToggleTreeFilter_PerFolderValueOutranksGlobal(t *testing.T) {
// The bug: severity filters were written user-global, but folder-scoped issue
// filtering resolves folder value > remote > user-global, so an LDX-Sync
// remote/folder default shadowed the user's choice. Writing per-folder
// (UserFolderKey) must outrank the user-global value for the folder.
engine := testutil.UnitTest(t)
folderPath := setupToggleWorkspaceFolder(t, engine)

// User-global says critical is enabled.
config.SetSeverityFilterOnConfig(engine.GetConfiguration(), util.Ptr(types.NewSeverityFilter(true, true, true, true)), engine.GetLogger())
// Per-folder says critical is disabled.
types.SetSeverityFilterForFolder(engine.GetConfiguration(), folderPath, util.Ptr(types.NewSeverityFilter(false, true, true, true)))

filter := folderSeverityFilter(t, engine, folderPath)
assert.False(t, filter.Critical, "per-folder value must outrank the user-global value for the folder")
assert.True(t, filter.High)
}

func TestToggleTreeFilter_Execute_MissingArgs_ReturnsError(t *testing.T) {
Expand Down
Loading
Loading