diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c3fd75..9fffe14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] 2026-03-10 +### Added +- Restore action: Undo a previous decision (include, dismiss, replace) on a completed result, returning it to the pending state +- Keyboard shortcut `U` to restore a file decision +- Add support for `file_snippet` settings in `scanoss.json`, including ranking, snippet hit/line thresholds, file extension handling, and header skipping options +- Add support for `exclude` entries in the scan settings BOM model +- Add ability to change default API URL during compilation: `make build DEFAULT_API_URL=api.something.else` + +### Changed +- Allow component filters without requiring a `purl`, enabling path-only filter entries to be serialized cleanly + +### Fixed +- Fix component replacement displaying incorrect purl and component name when the scanner pre-applies the replacement +- Fix the result view not scrolling into view when navigating with keyboard shortcuts +- Fix action bar buttons not being interactive on completed results that need to be re-evaluated + ## [0.11.0] 2026-01-26 ### Added - Folder actions: Include, dismiss, replace, or skip all files within a folder in a single action @@ -247,3 +263,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [0.9.9]: https://github.com/scanoss/scanoss.cc/compare/v0.9.8...v0.9.9 [0.10.0]: https://github.com/scanoss/scanoss.cc/compare/v0.9.9...v0.10.0 [0.11.0]: https://github.com/scanoss/scanoss.cc/compare/v0.10.0...v0.11.0 +[0.12.0]: https://github.com/scanoss/scanoss.cc/compare/v0.11.0...v0.12.0 diff --git a/Makefile b/Makefile index 642c52a1..ad1e8c92 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ SCRIPTS_DIR = scripts FRONTEND_DIR = frontend ASSETS_DIR = assets APP_BUNDLE = "$(BUILD_DIR)/bin/$(APP_NAME).app" +DEFAULT_API_URL = "https://api.osskb.org" export GOTOOLCHAIN=go1.23.0 @@ -48,11 +49,11 @@ lint-fix: ## Run local instance of Go linting across the code base including aut run: cp_assets ## Runs the application in development mode $(eval APPARGS := $(ARGS)) - @wails dev -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION)" $(if $(strip $(APPARGS)),-appargs "--debug $(APPARGS)") + @wails dev -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION) -X github.com/scanoss/scanoss.cc/internal/config.DefaultAPIURL=$(DEFAULT_API_URL)" $(if $(strip $(APPARGS)),-appargs "--debug $(APPARGS)") run_webkit41: cp_assets ## Runs the application in development mode for Ubuntu 24.04+/Debian 13+ $(eval APPARGS := $(ARGS)) - @wails dev -tags webkit2_41 -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION)" $(if $(strip $(APPARGS)),-appargs "--debug $(APPARGS)") + @wails dev -tags webkit2_41 -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION) -X github.com/scanoss/scanoss.cc/internal/config.DefaultAPIURL=$(DEFAULT_API_URL)" $(if $(strip $(APPARGS)),-appargs "--debug $(APPARGS)") npm: ## Install NPM dependencies for the frontend @echo "Running npm install for frontend..." @@ -66,17 +67,17 @@ cp_assets: ## Copy the necessary assets to the build folder build: clean cp_assets ## Build the application image for the current platform @echo "Building application image..." - @wails build -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION)" + @wails build -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION) -X github.com/scanoss/scanoss.cc/internal/config.DefaultAPIURL=$(DEFAULT_API_URL)" binary: cp_assets ## Build application binary only (no package) @echo "Build application binary only..." - @wails build -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION)" --nopackage + @wails build -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION) -X github.com/scanoss/scanoss.cc/internal/config.DefaultAPIURL=$(DEFAULT_API_URL)" --nopackage build_macos: clean cp_assets ## Build the application image for macOS @echo "Building application image for macOS..." - @wails build -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION)" -platform darwin/universal -o "$(APP_NAME)" + @wails build -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION) -X github.com/scanoss/scanoss.cc/internal/config.DefaultAPIURL=$(DEFAULT_API_URL)" -platform darwin/universal -o "$(APP_NAME)" @echo "Build completed. Result: $(APP_BUNDLE)" build_webkit41: clean cp_assets ## Build the application image for Ubuntu 24.04+/Debian 13+ (webkit 4.1) @echo "Building application image with webkit2_41 tags..." - @wails build -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION)" -tags webkit2_41 + @wails build -ldflags "-X github.com/scanoss/scanoss.cc/backend/entities.AppVersion=$(VERSION) -X github.com/scanoss/scanoss.cc/internal/config.DefaultAPIURL=$(DEFAULT_API_URL)" -tags webkit2_41 diff --git a/backend/entities/component.go b/backend/entities/component.go index cbf1c25b..a4aca573 100644 --- a/backend/entities/component.go +++ b/backend/entities/component.go @@ -89,13 +89,14 @@ const ( Include FilterAction = "include" Remove FilterAction = "remove" Replace FilterAction = "replace" + Restore FilterAction = "restore" ) type ComponentFilterDTO struct { Path string `json:"path,omitempty"` Purl string `json:"purl,omitempty"` Usage string `json:"usage,omitempty"` - Action FilterAction `json:"action" validate:"required,eq=include|eq=remove|eq=replace"` + Action FilterAction `json:"action" validate:"required,eq=include|eq=remove|eq=replace|eq=restore"` Comment string `json:"comment,omitempty"` ReplaceWith string `json:"replace_with,omitempty" validate:"omitempty,valid-purl"` License string `json:"license,omitempty"` diff --git a/backend/entities/keyboard.go b/backend/entities/keyboard.go index 13fa713a..ba7879ea 100644 --- a/backend/entities/keyboard.go +++ b/backend/entities/keyboard.go @@ -51,6 +51,9 @@ const ( ActionReplaceFolder Action = "replaceFolder" ActionReplaceComponent Action = "replaceComponent" + // Restore action (undo decision on completed result) + ActionRestoreFile Action = "restoreFile" + // Skip action (Scan settings) - always opens modal ActionSkipFile Action = "skipFile" ActionSkipFolder Action = "skipFolder" @@ -106,6 +109,7 @@ var AllShortcutActions = []struct { {ActionReplaceFile, "ReplaceFile"}, {ActionReplaceFolder, "ReplaceFolder"}, {ActionReplaceComponent, "ReplaceComponent"}, + {ActionRestoreFile, "RestoreFile"}, {ActionSkipFile, "SkipFile"}, {ActionSkipFolder, "SkipFolder"}, {ActionSkipExtension, "SkipExtension"}, diff --git a/backend/entities/result.go b/backend/entities/result.go index 3b9e8514..5f87a249 100644 --- a/backend/entities/result.go +++ b/backend/entities/result.go @@ -140,6 +140,7 @@ type ResultDTO struct { Comment string `json:"comment,omitempty"` DetectedPurl string `json:"detected_purl,omitempty"` DetectedPurlUrl string `json:"detected_purl_url,omitempty"` + DetectedName string `json:"detected_name,omitempty"` ConcludedPurl string `json:"concluded_purl,omitempty"` ConcludedPurlUrl string `json:"concluded_purl_url,omitempty"` ConcludedName string `json:"concluded_name,omitempty"` diff --git a/backend/entities/scanoss_settings.go b/backend/entities/scanoss_settings.go index f60bf5f9..ee461011 100644 --- a/backend/entities/scanoss_settings.go +++ b/backend/entities/scanoss_settings.go @@ -46,7 +46,8 @@ type SettingsFile struct { } type ScanossSettingsSchema struct { - Skip SkipSettings `json:"skip,omitempty"` + Skip SkipSettings `json:"skip,omitempty"` + FileSnippet FileSnippetSettings `json:"file_snippet,omitempty"` } type SkipSettings struct { @@ -70,15 +71,26 @@ type SizesSkipSettings struct { Max int `json:"max,omitempty"` } +type FileSnippetSettings struct { + RankingEnabled bool `json:"ranking_enabled,omitempty"` + RankingThreshold *int `json:"ranking_threshold,omitempty"` + MinSnippetHits int `json:"min_snippet_hits,omitempty"` + MinSnippetLines int `json:"min_snippet_lines,omitempty"` + HonourFileExts bool `json:"honour_file_exts,omitempty"` + SkipHeaders bool `json:"skip_headers,omitempty"` + SkipHeadersLimit int `json:"skip_headers_limit,omitempty"` +} + type Bom struct { Include []ComponentFilter `json:"include,omitempty"` Remove []ComponentFilter `json:"remove,omitempty"` Replace []ComponentFilter `json:"replace,omitempty"` + Exclude []ComponentFilter `json:"exclude,omitempty"` } type ComponentFilter struct { Path string `json:"path,omitempty"` - Purl string `json:"purl"` + Purl string `json:"purl,omitempty"` Usage ComponentFilterUsage `json:"usage,omitempty"` Comment string `json:"comment,omitempty"` ReplaceWith string `json:"replace_with,omitempty"` @@ -159,11 +171,19 @@ func (cf ComponentFilter) MatchesPath(path string) bool { // MatchesAnyPurl checks if the purl constraint is satisfied. // Empty purl means no constraint (always satisfied). +// For replace entries, also matches if the result's purl equals ReplaceWith, +// since the scanner may have already applied the replacement. func (cf ComponentFilter) MatchesAnyPurl(purls []string) bool { if cf.Purl == "" { return true // No constraint } - return slices.Contains(purls, cf.Purl) + if slices.Contains(purls, cf.Purl) { + return true + } + if cf.ReplaceWith != "" && slices.Contains(purls, cf.ReplaceWith) { + return true + } + return false } // AppliesTo checks if all filter constraints are satisfied by the result. diff --git a/backend/entities/scanoss_settings_test.go b/backend/entities/scanoss_settings_test.go index 6ea81ab4..799c8543 100644 --- a/backend/entities/scanoss_settings_test.go +++ b/backend/entities/scanoss_settings_test.go @@ -129,6 +129,24 @@ func TestComponentFilter_AppliesTo(t *testing.T) { result: Result{Path: "src/file.js", Purl: nil}, expected: false, }, + { + name: "replace filter applies when result purl matches replace_with", + filter: ComponentFilter{Path: "src/file.js", Purl: "pkg:npm/original@1.0.0", ReplaceWith: "pkg:npm/lodash@1.0.0"}, + result: Result{Path: "src/file.js", Purl: &purl}, + expected: true, + }, + { + name: "replace filter applies when result purl matches original purl", + filter: ComponentFilter{Path: "src/file.js", Purl: "pkg:npm/lodash@1.0.0", ReplaceWith: "pkg:npm/other@2.0.0"}, + result: Result{Path: "src/file.js", Purl: &purl}, + expected: true, + }, + { + name: "replace filter does not apply when neither purl nor replace_with match", + filter: ComponentFilter{Path: "src/file.js", Purl: "pkg:npm/original@1.0.0", ReplaceWith: "pkg:npm/other@2.0.0"}, + result: Result{Path: "src/file.js", Purl: &purl}, + expected: false, + }, } for _, tt := range tests { diff --git a/backend/entities/version.go b/backend/entities/version.go index 4f1ab890..07744f3f 100644 --- a/backend/entities/version.go +++ b/backend/entities/version.go @@ -25,10 +25,10 @@ package entities import "time" -// Gets updated build time using -ldflags +// AppVersion Gets updated build time using -ldflags. var AppVersion = "" -// UpdateInfo contains information about an available update +// UpdateInfo contains information about an available update. type UpdateInfo struct { Version string `json:"version"` DownloadURL string `json:"download_url"` diff --git a/backend/mappers/result_mapper_impl.go b/backend/mappers/result_mapper_impl.go index 921a5fd7..be3df654 100644 --- a/backend/mappers/result_mapper_impl.go +++ b/backend/mappers/result_mapper_impl.go @@ -74,13 +74,29 @@ func (m *ResultMapperImpl) MapToResultDTO(result entities.Result) entities.Resul detectedPurl = (*result.Purl)[0] } + // If the scanner already applied the replacement, the detected purl will + // equal the replace_with value. In that case, show the original purl from + // the BOM entry so the display shows "original -> replacement". + scannerPreAppliedReplacement := bomEntry.ReplaceWith != "" && detectedPurl == bomEntry.ReplaceWith && bomEntry.Purl != "" + if scannerPreAppliedReplacement { + detectedPurl = bomEntry.Purl + } + + var detectedName string + if scannerPreAppliedReplacement { + detectedName = m.componentNameFromPurl(bomEntry.Purl) + } else { + detectedName = result.ComponentName + } + dto := entities.ResultDTO{ MatchType: entities.MatchType(result.MatchType), Path: result.Path, DetectedPurl: detectedPurl, - DetectedPurlUrl: m.mapDetectedPurlUrl(result), + DetectedPurlUrl: m.mapPurlUrl(detectedPurl), + DetectedName: detectedName, ConcludedPurl: bomEntry.ReplaceWith, - ConcludedPurlUrl: m.mapConcludedPurlUrl(result), + ConcludedPurlUrl: m.mapPurlUrl(bomEntry.ReplaceWith), ConcludedName: m.mapConcludedName(result), WorkflowState: m.mapWorkflowState(result), FilterConfig: m.mapFilterConfig(result), @@ -91,9 +107,6 @@ func (m *ResultMapperImpl) MapToResultDTO(result entities.Result) entities.Resul return dto } -func (m *ResultMapperImpl) mapConcludedPurl(result entities.Result) string { - return m.scanossSettings.SettingsFile.GetBomEntryFromResult(result).ReplaceWith -} func (m *ResultMapperImpl) MapToResultDTOList(results []entities.Result) []entities.ResultDTO { output := make([]entities.ResultDTO, len(results)) @@ -128,28 +141,6 @@ func (m *ResultMapperImpl) mapFilterConfig(result entities.Result) entities.Filt return m.scanossSettings.SettingsFile.GetResultFilterConfig(result) } -func (m *ResultMapperImpl) mapConcludedPurlUrl(result entities.Result) string { - concludedPurl := m.mapConcludedPurl(result) - if concludedPurl == "" { - return "" - } - - purlObject, err := purlutils.PurlFromString(concludedPurl) - if err != nil { - log.Error().Err(err).Msg("Error parsing concluded purl") - return "" - } - - // Workaround for github purls until purlutils is updated - purlName := purlObject.Name - if purlObject.Type == "github" && purlObject.Namespace != "" { - purlName = fmt.Sprintf("%s/%s", purlObject.Namespace, purlObject.Name) - } - - return m.getProjectURL(concludedPurl, func() (string, error) { - return purlutils.ProjectUrl(purlName, purlObject.Type) - }) -} func (m *ResultMapperImpl) getProjectURL(purl string, compute func() (string, error)) string { if cached, ok := purlCache.Load(purl); ok { @@ -164,16 +155,14 @@ func (m *ResultMapperImpl) getProjectURL(purl string, compute func() (string, er return url } -func (m *ResultMapperImpl) mapDetectedPurlUrl(result entities.Result) string { - if result.Purl == nil || len(*result.Purl) == 0 { +func (m *ResultMapperImpl) mapPurlUrl(purl string) string { + if purl == "" { return "" } - detectedPurl := (*result.Purl)[0] - - purlObject, err := purlutils.PurlFromString(detectedPurl) + purlObject, err := purlutils.PurlFromString(purl) if err != nil { - log.Error().Err(err).Msg("Error parsing detected purl") + log.Error().Err(err).Msg("Error parsing purl") return "" } purlName := purlObject.Name @@ -181,18 +170,22 @@ func (m *ResultMapperImpl) mapDetectedPurlUrl(result entities.Result) string { purlName = fmt.Sprintf("%s/%s", purlObject.Namespace, purlObject.Name) } - return m.getProjectURL(detectedPurl, func() (string, error) { + return m.getProjectURL(purl, func() (string, error) { return purlutils.ProjectUrl(purlName, purlObject.Type) }) } func (m *ResultMapperImpl) mapConcludedName(result entities.Result) string { replacedPurl := m.scanossSettings.SettingsFile.GetBomEntryFromResult(result).ReplaceWith - if replacedPurl == "" { + return m.componentNameFromPurl(replacedPurl) +} + +func (m *ResultMapperImpl) componentNameFromPurl(purl string) string { + if purl == "" { return "" } - purlName, err := purlutils.PurlNameFromString(replacedPurl) + purlName, err := purlutils.PurlNameFromString(purl) if err != nil { log.Error().Err(err).Msg("Error getting component name from purl") return "" diff --git a/backend/repository/mocks/mock_ScanossSettingsRepository.go b/backend/repository/mocks/mock_ScanossSettingsRepository.go index a4473594..60955fbb 100644 --- a/backend/repository/mocks/mock_ScanossSettingsRepository.go +++ b/backend/repository/mocks/mock_ScanossSettingsRepository.go @@ -635,6 +635,52 @@ func (_c *MockScanossSettingsRepository_Read_Call) RunAndReturn(run func() (enti return _c } +// RemoveBomEntry provides a mock function with given fields: entry +func (_m *MockScanossSettingsRepository) RemoveBomEntry(entry entities.ComponentFilter) error { + ret := _m.Called(entry) + + if len(ret) == 0 { + panic("no return value specified for RemoveBomEntry") + } + + var r0 error + if rf, ok := ret.Get(0).(func(entities.ComponentFilter) error); ok { + r0 = rf(entry) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockScanossSettingsRepository_RemoveBomEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveBomEntry' +type MockScanossSettingsRepository_RemoveBomEntry_Call struct { + *mock.Call +} + +// RemoveBomEntry is a helper method to define mock.On call +// - entry entities.ComponentFilter +func (_e *MockScanossSettingsRepository_Expecter) RemoveBomEntry(entry interface{}) *MockScanossSettingsRepository_RemoveBomEntry_Call { + return &MockScanossSettingsRepository_RemoveBomEntry_Call{Call: _e.mock.On("RemoveBomEntry", entry)} +} + +func (_c *MockScanossSettingsRepository_RemoveBomEntry_Call) Run(run func(entry entities.ComponentFilter)) *MockScanossSettingsRepository_RemoveBomEntry_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(entities.ComponentFilter)) + }) + return _c +} + +func (_c *MockScanossSettingsRepository_RemoveBomEntry_Call) Return(_a0 error) *MockScanossSettingsRepository_RemoveBomEntry_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockScanossSettingsRepository_RemoveBomEntry_Call) RunAndReturn(run func(entities.ComponentFilter) error) *MockScanossSettingsRepository_RemoveBomEntry_Call { + _c.Call.Return(run) + return _c +} + // RemoveStagedScanningSkipPattern provides a mock function with given fields: path, pattern func (_m *MockScanossSettingsRepository) RemoveStagedScanningSkipPattern(path string, pattern string) error { ret := _m.Called(path, pattern) diff --git a/backend/repository/scanoss_settings_repository.go b/backend/repository/scanoss_settings_repository.go index 21c3b1a5..f7736e80 100644 --- a/backend/repository/scanoss_settings_repository.go +++ b/backend/repository/scanoss_settings_repository.go @@ -31,6 +31,7 @@ type ScanossSettingsRepository interface { Read() (entities.SettingsFile, error) HasUnsavedChanges() (bool, error) AddBomEntry(newEntry entities.ComponentFilter, filterAction string) error + RemoveBomEntry(entry entities.ComponentFilter) error ClearAllFilters() error GetSettings() *entities.SettingsFile GetDeclaredPurls() []string diff --git a/backend/repository/scanoss_settings_repository_json_impl.go b/backend/repository/scanoss_settings_repository_json_impl.go index 33f5cd52..300f8b24 100644 --- a/backend/repository/scanoss_settings_repository_json_impl.go +++ b/backend/repository/scanoss_settings_repository_json_impl.go @@ -168,6 +168,38 @@ func (r *ScanossSettingsJsonRepository) AddBomEntry(newEntry entities.ComponentF return nil } +func (r *ScanossSettingsJsonRepository) RemoveBomEntry(entry entities.ComponentFilter) error { + r.mutex.Lock() + defer r.mutex.Unlock() + + sf := r.GetSettings() + + // Build a synthetic Result to use AppliesTo matching. + // This correctly handles all cases: file-level restore removing + // component-level (purl-only) BOM entries, folder rules, etc. + result := entities.Result{Path: entry.Path} + if entry.Purl != "" { + purls := []string{entry.Purl} + result.Purl = &purls + } + + removeMatching := func(list []entities.ComponentFilter) []entities.ComponentFilter { + filtered := make([]entities.ComponentFilter, 0, len(list)) + for _, f := range list { + if !f.AppliesTo(result) { + filtered = append(filtered, f) + } + } + return filtered + } + + sf.Bom.Include = removeMatching(sf.Bom.Include) + sf.Bom.Remove = removeMatching(sf.Bom.Remove) + sf.Bom.Replace = removeMatching(sf.Bom.Replace) + + return nil +} + func (r *ScanossSettingsJsonRepository) removeDuplicatesFromAllLists(newEntry entities.ComponentFilter) { sf := r.GetSettings() diff --git a/backend/service/component_service_impl.go b/backend/service/component_service_impl.go index 2beadf24..5aa94d19 100644 --- a/backend/service/component_service_impl.go +++ b/backend/service/component_service_impl.go @@ -151,9 +151,16 @@ func (s *ComponentServiceImpl) applyFilters(dto []entities.ComponentFilterDTO) e ReplaceWith: item.ReplaceWith, License: item.License, } - if err := s.scanossSettingsRepo.AddBomEntry(newFilter, string(item.Action)); err != nil { - log.Error().Err(err).Msg("Error adding bom entry") - errChan <- err + if item.Action == entities.Restore { + if err := s.scanossSettingsRepo.RemoveBomEntry(newFilter); err != nil { + log.Error().Err(err).Msg("Error removing bom entry") + errChan <- err + } + } else { + if err := s.scanossSettingsRepo.AddBomEntry(newFilter, string(item.Action)); err != nil { + log.Error().Err(err).Msg("Error adding bom entry") + errChan <- err + } } }(item) } diff --git a/cmd/root.go b/cmd/root.go index 1c204697..22be7d44 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -83,7 +83,7 @@ func init() { rootCmd.Flags().StringVarP(&scanRoot, "scan-root", "s", "", "Scanned folder root path (optional - default: $WORKDIR)") rootCmd.Flags().StringVar(&scanossSettingsFilePath, "settings", "", "Path to scanoss settings file (optional - default: $WORKDIR/scanoss.json)") rootCmd.Flags().StringVarP(&apiKey, "key", "k", "", "SCANOSS API Key token (optional)") - rootCmd.Flags().StringVarP(&apiUrl, "apiUrl", "u", "", fmt.Sprintf("SCANOSS API URL (optional - default: %s)", config.DEFAULT_API_URL)) + rootCmd.Flags().StringVarP(&apiUrl, "apiUrl", "u", "", fmt.Sprintf("SCANOSS API URL (optional - default: %s)", config.DefaultAPIURL)) rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug mode") rootCmd.Root().CompletionOptions.HiddenDefaultCmd = true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b07e02a..aa8b2480 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2102,9 +2102,10 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" }, "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -5470,10 +5471,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -6887,11 +6889,12 @@ } }, "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -6901,12 +6904,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -7664,9 +7668,11 @@ } }, "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", diff --git a/frontend/src/components/ComponentDetailTooltip.tsx b/frontend/src/components/ComponentDetailTooltip.tsx index 127728bc..2522c3a4 100644 --- a/frontend/src/components/ComponentDetailTooltip.tsx +++ b/frontend/src/components/ComponentDetailTooltip.tsx @@ -60,6 +60,11 @@ function DetectedPurlTooltip({ component, replaced }: { component: entities.Comp const isResultRemoved = result?.filter_config?.action === FilterAction.Remove; const removedStyles = isResultRemoved || replaced ? 'line-through opacity-70 text-muted-foreground' : ''; + const isPreAppliedReplacement = replaced && component.purl?.[0] !== result?.detected_purl; + + const displayName = result?.detected_name || component.component; + const displayUrl = isPreAppliedReplacement ? result?.detected_purl_url : component.url; + return ( @@ -69,7 +74,7 @@ function DetectedPurlTooltip({ component, replaced }: { component: entities.Comp [matchPresentation.accent]: !replaced && !isResultRemoved, })} > - {component.component} + {displayName} {result?.detected_purl} @@ -83,22 +88,22 @@ function DetectedPurlTooltip({ component, replaced }: { component: entities.Comp

PURL

{result?.detected_purl}

- {component.version && ( + {!isPreAppliedReplacement && component.version && (

VERSION

{component.version}

)} - {component.licenses?.length ? ( + {!isPreAppliedReplacement && component.licenses?.length ? (

LICENSE

{component.licenses?.[0].name}

) : null} - {component.url && ( + {displayUrl && (

URL

- {component.url} + {displayUrl}
)} diff --git a/frontend/src/components/FilterComponentActions.tsx b/frontend/src/components/FilterComponentActions.tsx index ccc4fcab..d81093b6 100644 --- a/frontend/src/components/FilterComponentActions.tsx +++ b/frontend/src/components/FilterComponentActions.tsx @@ -21,7 +21,7 @@ * SOFTWARE. */ -import { Check, EyeOff, PackageMinus, Replace } from 'lucide-react'; +import { Check, EyeOff, PackageMinus, Replace, Undo2 } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import useKeyboardShortcut from '@/hooks/useKeyboardShortcut'; @@ -33,7 +33,19 @@ import { KEYBOARD_SHORTCUTS } from '@/lib/shortcuts'; import { FilterAction } from '@/modules/components/domain'; import useComponentFilterStore, { OnFilterComponentArgs } from '@/modules/components/stores/useComponentFilterStore'; +import useResultsStore from '@/modules/results/stores/useResultsStore'; + import { entities } from '../../wailsjs/go/models'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from './ui/alert-dialog'; import FilterActionModal from './FilterActionModal'; import SkipActionModal from './SkipActionModal'; import { @@ -66,6 +78,9 @@ export default function FilterComponentActions() { const [skipModalOpen, setSkipModalOpen] = useState(false); const [skipModalInitialSelection, setSkipModalInitialSelection] = useState('file'); + // Restore confirmation dialog state + const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); + const handleFilterComponent = withErrorHandling({ asyncFn: async (args: OnFilterComponentArgs) => { await onFilterComponent(args); @@ -83,25 +98,25 @@ export default function FilterComponentActions() { // Creates handler that opens filter modal with given action and selection const createModalActionHandler = useCallback( (action: FilterAction, selection: FilterInitialSelection) => () => { - if (!selectedResult || isCompletedResult) return; + if (!selectedResult) return; setFilterModalAction(action); setFilterModalInitialSelection(selection); setFilterModalOpen(true); }, - [selectedResult, isCompletedResult] + [selectedResult] ); // Creates handler that applies filter action directly to the current file (no modal) const createDirectActionHandler = useCallback( (action: FilterAction) => () => { - if (!selectedResult || isCompletedResult) return; + if (!selectedResult) return; handleFilterComponent({ action, filterBy: 'by_file', purl: selectedResult.detected_purl ?? '', }); }, - [selectedResult, isCompletedResult, handleFilterComponent] + [selectedResult, handleFilterComponent] ); // Creates handler that opens skip modal with given selection @@ -114,6 +129,39 @@ export default function FilterComponentActions() { [selectedResult] ); + const confirmRestore = useCallback(() => { + if (!selectedResult) return; + handleFilterComponent({ + action: FilterAction.Restore, + filterBy: 'by_file', + purl: selectedResult.detected_purl ?? '', + }); + setRestoreDialogOpen(false); + }, [selectedResult, handleFilterComponent]); + + const restoreDescription = useMemo(() => { + if (!selectedResult?.filter_config) return ''; + const { selectedResults } = useResultsStore.getState(); + + if (selectedResults.length > 1) { + return `You are about to restore ${selectedResults.length} selected files to pending.`; + } + + const filterType = selectedResult.filter_config.type; + const purl = selectedResult.detected_purl ?? ''; + + if (filterType === 'by_purl') { + return `You are about to restore all files matched to component "${purl}" to pending.`; + } + + if (filterType === 'by_folder') { + return `You are about to restore all files in the matching folder rule to pending.`; + } + + // by_file + return `You are about to restore file "${selectedResult.path}" to pending.`; + }, [selectedResult]); + // Generate all handlers const handlers = useMemo( () => ({ @@ -136,13 +184,20 @@ export default function FilterComponentActions() { skipFile: createModalSkipHandler('file'), skipFolder: createModalSkipHandler('folder'), skipExtension: createModalSkipHandler('extension'), + + // Restore: opens confirmation dialog before executing + restoreFile: () => { + if (!selectedResult) return; + setRestoreDialogOpen(true); + }, }), - [createDirectActionHandler, createModalActionHandler, createModalSkipHandler] + [selectedResult, createDirectActionHandler, createModalActionHandler, createModalSkipHandler] ); // === Keyboard shortcuts === - const filterEnabled = !isCompletedResult && !!selectedResult; + const filterEnabled = !!selectedResult; const skipEnabled = !!selectedResult; + const restoreEnabled = isCompletedResult && !!selectedResult; // Include useKeyboardShortcut(KEYBOARD_SHORTCUTS.includeFile.keys, handlers.includeFile, { enabled: filterEnabled }); @@ -164,6 +219,9 @@ export default function FilterComponentActions() { useKeyboardShortcut(KEYBOARD_SHORTCUTS.skipFolder.keys, handlers.skipFolder, { enabled: skipEnabled }); useKeyboardShortcut(KEYBOARD_SHORTCUTS.skipExtension.keys, handlers.skipExtension, { enabled: skipEnabled }); + // Restore + useKeyboardShortcut(KEYBOARD_SHORTCUTS.restoreFile.keys, handlers.restoreFile, { enabled: restoreEnabled }); + // === Menu bar events === useMenuEvents({ // Include @@ -182,9 +240,11 @@ export default function FilterComponentActions() { [entities.Action.SkipFile]: handlers.skipFile, [entities.Action.SkipFolder]: handlers.skipFolder, [entities.Action.SkipExtension]: handlers.skipExtension, + // Restore + [entities.Action.RestoreFile]: handlers.restoreFile, }); - const isDisabled = isCompletedResult || !selectedResult; + const isDisabled = !selectedResult; return ( <> @@ -193,7 +253,7 @@ export default function FilterComponentActions() { Include @@ -218,7 +278,7 @@ export default function FilterComponentActions() { Dismiss @@ -243,7 +303,7 @@ export default function FilterComponentActions() { Replace @@ -271,7 +331,7 @@ export default function FilterComponentActions() { Skip @@ -291,6 +351,20 @@ export default function FilterComponentActions() { + + {/* Restore - only visible for completed results */} + {isCompletedResult && ( + <> + + + + )} {/* Filter Action Modal */} @@ -315,6 +389,20 @@ export default function FilterComponentActions() { initialSelection={skipModalInitialSelection} /> )} + + {/* Restore Confirmation Dialog */} + + + + Restore to pending + {restoreDescription} + + + Cancel + Restore + + + ); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7d0318cf..bb0f00b6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -24,7 +24,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import clsx from 'clsx'; import { Braces, File } from 'lucide-react'; -import { ReactNode, useRef } from 'react'; +import { ReactNode, useEffect, useRef } from 'react'; import { entities } from 'wailsjs/go/models'; import ResultSearchBar from '@/components/ResultSearchBar'; @@ -164,6 +164,8 @@ interface ResultSectionProps { function ResultSection({ title, results, onSelect, selectionType, isLoading }: ResultSectionProps) { const parentRef = useRef(null); const ITEM_HEIGHT = 32; + const lastSelectedIndex = useResultsStore((state) => state.lastSelectedIndex); + const lastSelectionType = useResultsStore((state) => state.lastSelectionType); const virtualizer = useVirtualizer({ count: results.length, @@ -172,6 +174,12 @@ function ResultSection({ title, results, onSelect, selectionType, isLoading }: R overscan: 5, }); + useEffect(() => { + if (lastSelectionType === selectionType && lastSelectedIndex >= 0 && lastSelectedIndex < results.length) { + virtualizer.scrollToIndex(lastSelectedIndex, { align: 'auto' }); + } + }, [lastSelectedIndex, lastSelectionType, selectionType, results.length, virtualizer]); + return (
diff --git a/frontend/src/lib/shortcuts.ts b/frontend/src/lib/shortcuts.ts index 262413d1..f92ad1fb 100644 --- a/frontend/src/lib/shortcuts.ts +++ b/frontend/src/lib/shortcuts.ts @@ -121,6 +121,13 @@ export const KEYBOARD_SHORTCUTS: Record = { keys: 'shift+r', }, + // Restore (undo decision on completed result) + [entities.Action.RestoreFile]: { + name: 'Restore file', + description: 'Restore file to pending (undo decision)', + keys: 'u', + }, + // Skip (Scan settings) - always opens modal [entities.Action.SkipFile]: { name: 'Skip file', diff --git a/frontend/src/modules/components/domain/index.ts b/frontend/src/modules/components/domain/index.ts index 09c92a0f..9966490e 100644 --- a/frontend/src/modules/components/domain/index.ts +++ b/frontend/src/modules/components/domain/index.ts @@ -26,6 +26,7 @@ export enum FilterAction { Include = 'include', Remove = 'remove', Replace = 'replace', + Restore = 'restore', } export type FilterBy = 'path' | 'purl'; @@ -35,6 +36,7 @@ export const filterActionLabelMap: Record = { [FilterAction.Include]: 'Include', [FilterAction.Remove]: 'Dismiss', [FilterAction.Replace]: 'Replace', + [FilterAction.Restore]: 'Restore', }; export interface OnAddFilterArgs { diff --git a/frontend/src/modules/results/domain/index.tsx b/frontend/src/modules/results/domain/index.tsx index e5d8b4ea..1ee19f90 100644 --- a/frontend/src/modules/results/domain/index.tsx +++ b/frontend/src/modules/results/domain/index.tsx @@ -107,4 +107,10 @@ export const stateInfoPresentation: Record stateInfoSidebarIndicatorStyles: 'bg-gray-600', stateInfoTextStyles: 'text-gray-600', }, + [FilterAction.Restore]: { + label: 'Restored', + stateInfoContainerStyles: 'border-l-4 border-blue-600 border-l-blue-600 bg-blue-950', + stateInfoSidebarIndicatorStyles: 'bg-blue-600', + stateInfoTextStyles: 'text-blue-600', + }, }; diff --git a/frontend/src/modules/results/stores/useResultsStore.ts b/frontend/src/modules/results/stores/useResultsStore.ts index 66cb7c36..7a0d651b 100644 --- a/frontend/src/modules/results/stores/useResultsStore.ts +++ b/frontend/src/modules/results/stores/useResultsStore.ts @@ -101,8 +101,18 @@ const useResultsStore = create()( set({ pendingResults, completedResults }); + // Sync selectedResults with fresh data so stale objects don't persist + if (selectedResults.length) { + const allFreshResults = [...pendingResults, ...completedResults]; + const updatedSelected = selectedResults + .map((sel) => allFreshResults.find((r) => r.path === sel.path)) + .filter((r): r is entities.ResultDTO => r !== undefined); + set({ selectedResults: updatedSelected }); + } + // When the app first loads or if changing the query, select the first result - if (!selectedResults.length) { + const currentSelectedResults = get().selectedResults; + if (!currentSelectedResults.length) { const hasPendingResults = pendingResults.length > 0; const hasCompletedResults = completedResults.length > 0; const firstSelectedResult = hasPendingResults ? pendingResults[0] : hasCompletedResults ? completedResults[0] : null; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index a8d12ef2..b3e2d65b 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -18,6 +18,7 @@ export namespace entities { ReplaceFile = "replaceFile", ReplaceFolder = "replaceFolder", ReplaceComponent = "replaceComponent", + RestoreFile = "restoreFile", SkipFile = "skipFile", SkipFolder = "skipFolder", SkipExtension = "skipExtension", @@ -514,6 +515,7 @@ export namespace entities { comment?: string; detected_purl?: string; detected_purl_url?: string; + detected_name?: string; concluded_purl?: string; concluded_purl_url?: string; concluded_name?: string; @@ -531,6 +533,7 @@ export namespace entities { this.comment = source["comment"]; this.detected_purl = source["detected_purl"]; this.detected_purl_url = source["detected_purl_url"]; + this.detected_name = source["detected_name"]; this.concluded_purl = source["concluded_purl"]; this.concluded_purl_url = source["concluded_purl_url"]; this.concluded_name = source["concluded_name"]; diff --git a/internal/config/config.go b/internal/config/config.go index 513a42bd..7c5db67a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,7 +45,6 @@ var ( ) const ( - DEFAULT_API_URL = "https://api.osskb.org" DEFAULT_RESULTS_FILE = "results.json" DEFAULT_SCANOSS_SETTINGS_FILE = "scanoss.json" DEFAULT_CONFIG_FILE_NAME = "scanoss-cc-settings" @@ -55,6 +54,10 @@ const ( SCANOSS_PREMIUM_API_URL = "https://api.scanoss.com" ) +// DefaultAPIURL Build-time overridable default. Can be set with: +// go build -ldflags "-X 'github.com/scanoss/scanoss.cc/internal/config.DefaultAPIURL=https://...'" +var DefaultAPIURL = "https://api.osskb.org" + type Config struct { apiToken string apiUrl string @@ -324,7 +327,7 @@ func (c *Config) initializeConfigFile(cfgFile string) error { viper.AddConfigPath(c.GetDefaultConfigFolder()) // Default values - viper.SetDefault("apiurl", DEFAULT_API_URL) + viper.SetDefault("apiurl", DefaultAPIURL) viper.SetDefault("apitoken", "") if err := viper.ReadInConfig(); err != nil { diff --git a/package-lock.json b/package-lock.json index bbd6f7e0..5e682c97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,4 +4,3 @@ "requires": true, "packages": {} } - diff --git a/scanoss.json b/scanoss.json index f057b81f..a8c5cc3a 100755 --- a/scanoss.json +++ b/scanoss.json @@ -1,5 +1,10 @@ { "settings": { + "file_snippet": { + "ranking_enabled": true, + "skip_headers": true, + "skip_headers_limit": 100 + }, "skip": { "patterns": { "scanning": [ @@ -86,10 +91,6 @@ "purl": "pkg:github/bhelpful/momentmeal", "replace_with": "pkg:github/shadcn-ui/ui" }, - { - "purl": "pkg:github/genesysgo/shadow-nft-standard", - "replace_with": "pkg:github/shadcn-ui/ui" - }, { "purl": "pkg:github/yournextstore/yournextstore", "replace_with": "pkg:github/shadcn-ui/ui"