├── .commitlintrc.yml ├── .gitattributes ├── .github ├── CODEOWNERS └── workflows │ ├── build.yml │ └── static-analysis.yml ├── .gitignore ├── .mailmap ├── .reuse └── dep5 ├── LICENSE ├── LICENSES └── Apache-2.0.txt ├── NOTICE ├── README.md ├── assets └── screenshot.png ├── build.gradle.kts ├── detekt.yml ├── gradle.properties ├── gradle ├── gradle-daemon-jvm.properties ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renovate.json ├── settings.gradle.kts └── src └── main ├── composeResources └── drawable │ ├── advisor.svg │ ├── analyzer.svg │ ├── app-icon │ ├── icon.icns │ ├── icon.ico │ └── icon.png │ ├── evaluator.svg │ ├── ort-black.png │ ├── ort-white.png │ └── scanner.svg └── kotlin ├── Main.kt ├── composables ├── CaptionedColumn.kt ├── CaptionedText.kt ├── CircularProgressBox.kt ├── Datetime.kt ├── DirectoryChooser.kt ├── ErrorCard.kt ├── Expandable.kt ├── ExpandableText.kt ├── Extensions.kt ├── FileDialog.kt ├── FilterButton.kt ├── FilterPanel.kt ├── FilterTextField.kt ├── IconText.kt ├── Link.kt ├── ListScreenAppBar.kt ├── ListScreenContent.kt ├── ListScreenList.kt ├── Preview.kt ├── ScreenAppBar.kt ├── SeverityIcon.kt ├── SidePanel.kt ├── SingleLineText.kt ├── StyledCard.kt ├── Tooltip.kt ├── TwoColumnTable.kt └── tree │ ├── Tree.kt │ └── TreeState.kt ├── lifecycle ├── MoleculeViewModel.kt └── ViewModel.kt ├── model ├── DependencyReference.kt ├── FilterData.kt ├── OrtApi.kt ├── OrtModel.kt ├── OrtModelInfo.kt ├── ResolutionStatus.kt ├── ResolvedIssue.kt ├── ResolvedRuleViolation.kt ├── ResolvedVulnerability.kt └── Tool.kt ├── navigation ├── BackstackEntry.kt ├── NavController.kt ├── NavHost.kt └── Screen.kt ├── state └── DialogState.kt ├── theme ├── Color.kt ├── Shape.kt ├── Theme.kt └── Typography.kt ├── ui ├── App.kt ├── MainScreen.kt ├── Menu.kt ├── MenuItem.kt ├── TopBar.kt ├── WorkbenchController.kt ├── dependencies │ ├── Dependencies.kt │ ├── DependenciesState.kt │ └── DependenciesViewModel.kt ├── issues │ ├── Issues.kt │ ├── IssuesState.kt │ └── IssuesViewModel.kt ├── packagedetails │ ├── PackageDetails.kt │ ├── PackageDetailsState.kt │ └── PackageDetailsViewModel.kt ├── packages │ ├── Packages.kt │ ├── PackagesState.kt │ └── PackagesViewModel.kt ├── settings │ ├── Settings.kt │ ├── SettingsTab.kt │ └── SettingsViewModel.kt ├── summary │ ├── AdvisorInfoCard.kt │ ├── AnalyzerInfoCard.kt │ ├── EmptyToolInfoCard.kt │ ├── EvaluatorInfoCard.kt │ ├── ResultFileInfoCard.kt │ ├── ScannerInfoCard.kt │ ├── Summary.kt │ ├── SummaryState.kt │ ├── SummaryViewModel.kt │ └── ToolInfoCard.kt ├── violations │ ├── Violations.kt │ ├── ViolationsState.kt │ └── ViolationsViewModel.kt └── vulnerabilities │ ├── Vulnerabilities.kt │ ├── VulnerabilitiesState.kt │ └── VulnerabilitiesViewModel.kt └── utils ├── Extensions.kt ├── FilterMatchers.kt ├── OrtIcon.kt ├── SpdxExpressionStringComparator.kt └── Util.kt /.commitlintrc.yml: -------------------------------------------------------------------------------- 1 | # Commitlint configuration. 2 | # See: https://github.com/conventional-changelog/commitlint/blob/master/docs/reference-rules.md 3 | --- 4 | parserPreset: 5 | parserOpts: 6 | headerPattern: '^(\w*)(?:\((.*)\))?!?: (.*)$' 7 | breakingHeaderPattern: '^(\w*)(?:\((.*)\))?!: (.*)$' 8 | headerCorrespondence: ['type', 'scope', 'subject'] 9 | noteKeywords: ['BREAKING CHANGE', 'BREAKING-CHANGE', '\[\d+\]:', 'Signed-off-by:'] 10 | revertPattern: '/^(?:Revert|revert:)\s"?([\s\S]+?)"?\s*This reverts commit (\w*)\./i' 11 | revertCorrespondence: ['header', 'hash'] 12 | rules: 13 | body-leading-blank: 14 | - 2 15 | - always 16 | body-max-line-length: 17 | - 2 18 | - always 19 | - 75 20 | footer-leading-blank: 21 | - 2 22 | - always 23 | header-max-length: 24 | - 2 25 | - always 26 | - 75 27 | scope-case: 28 | - 2 29 | - always 30 | - - camel-case 31 | - kebab-case 32 | - lower-case 33 | - pascal-case 34 | - snake-case 35 | - upper-case 36 | subject-case: 37 | - 1 38 | - always 39 | - - pascal-case 40 | - sentence-case 41 | - start-case 42 | - upper-case 43 | subject-empty: 44 | - 2 45 | - never 46 | subject-full-stop: 47 | - 2 48 | - never 49 | - . 50 | type-case: 51 | - 2 52 | - always 53 | - lower-case 54 | type-empty: 55 | - 2 56 | - never 57 | type-enum: 58 | - 2 59 | - always 60 | - - build 61 | - chore 62 | - ci 63 | - deps 64 | - docs 65 | - feat 66 | - fix 67 | - perf 68 | - refactor 69 | - revert 70 | - style 71 | - test 72 | signed-off-by: 73 | - 2 74 | - always 75 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Always use Unix line endings for Unix shell scripts. 2 | gradlew text eol=lf 3 | *.sh text eol=lf 4 | 5 | # Always use Windows line endings for Windows batch scripts. 6 | *.bat text eol=crlf 7 | *.cmd text eol=crlf 8 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Note that patterns are not cumulative. So order is important as only the 2 | # last matching pattern is used, see 3 | # https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/creating-a-repository-on-github/about-code-owners#codeowners-syntax 4 | 5 | * @oss-review-toolkit/kotlin-devs 6 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-24.04 14 | env: 15 | GRADLE_OPTS: -Dorg.gradle.daemon=false 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v4 19 | - name: Setup Gradle 20 | uses: gradle/actions/setup-gradle@v4 21 | - name: Build the JAR 22 | run: ./gradlew --stacktrace jar 23 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | commit-lint: 13 | runs-on: ubuntu-24.04 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - uses: wagoid/commitlint-github-action@v6 19 | with: 20 | configFile: .commitlintrc.yml 21 | detekt: 22 | runs-on: ubuntu-24.04 23 | env: 24 | GRADLE_OPTS: -Dorg.gradle.daemon=false 25 | steps: 26 | - name: Checkout Repository 27 | uses: actions/checkout@v4 28 | - name: Setup Gradle 29 | uses: gradle/actions/setup-gradle@v4 30 | - name: Run Detekt 31 | run: ./gradlew --stacktrace detekt 32 | - name: Upload SARIF File 33 | uses: github/codeql-action/upload-sarif@v3 34 | if: ${{ always() }} # Upload even if the previous step failed. 35 | with: 36 | sarif_file: build/reports/detekt/detekt.sarif 37 | reuse: 38 | runs-on: ubuntu-24.04 39 | steps: 40 | - name: Checkout Repository 41 | uses: actions/checkout@v4 42 | - name: Setup Python 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: "3.13" 46 | - name: Check REUSE Compliance 47 | run: | 48 | pip install --user reuse 49 | ~/.local/bin/reuse lint 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle/ 2 | .idea/ 3 | .kotlin/ 4 | build/ 5 | 6 | # MacOS 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Marcel Bochtler 2 | Martin Nonnenmacher 3 | Martin Nonnenmacher 4 | Sebastian Schuberth 5 | Sebastian Schuberth 6 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: ORT Workbench 3 | Source: https://github.com/oss-review-toolkit/ort-workbench 4 | 5 | Files: .* 6 | Copyright: 2021 The ORT Workbench Project Authors (see ) 7 | License: Apache-2.0 8 | 9 | Files: *.json 10 | Copyright: 2022 The ORT Workbench Project Authors (see ) 11 | License: Apache-2.0 12 | 13 | Files: *.kts 14 | Copyright: 2021 The ORT Workbench Project Authors (see ) 15 | License: Apache-2.0 16 | 17 | Files: *.md 18 | Copyright: 2021 The ORT Workbench Project Authors (see ) 19 | License: Apache-2.0 20 | 21 | Files: *.properties 22 | Copyright: 2021 The ORT Workbench Project Authors (see ) 23 | License: Apache-2.0 24 | 25 | Files: *.yml 26 | Copyright: 2021 The ORT Workbench Project Authors (see ) 27 | License: Apache-2.0 28 | 29 | Files: assets/* 30 | Copyright: 2022 The ORT Workbench Project Authors (see ) 31 | License: Apache-2.0 32 | 33 | Files: gradle/*.toml 34 | Copyright: 2022 The ORT Workbench Project Authors (see ) 35 | License: Apache-2.0 36 | 37 | Files: NOTICE 38 | Copyright: 2022 The ORT Workbench Project Authors (see ) 39 | License: Apache-2.0 40 | 41 | Files: src/* 42 | Copyright: 2021 The ORT Workbench Project Authors (see ) 43 | License: Apache-2.0 44 | 45 | Files: src/main/resources/material/* 46 | Copyright: 2016 Google LLC 47 | License: Apache-2.0 48 | 49 | # Third-party files 50 | 51 | Files: gradlew* 52 | Copyright: 2007-2023 The original author or authors 53 | License: Apache-2.0 54 | 55 | Files: gradle/wrapper/* 56 | Copyright: 2007-2023 The original author or authors 57 | License: Apache-2.0 58 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | The ORT Workbench Project 2 | 3 | Copyright (C) 2021-2024 Martin Nonnenmacher 4 | Copyright (C) 2022 Bosch.IO GmbH 5 | Copyright (C) 2022-2023 Sebastian Schuberth 6 | Copyright (C) 2024 Double Open Oy 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ORT Workbench 2 | 3 | [![Build](https://github.com/oss-review-toolkit/ort-workbench/actions/workflows/build.yml/badge.svg)](https://github.com/oss-review-toolkit/ort-workbench/actions/workflows/build.yml) 4 | [![Static Analysis](https://github.com/oss-review-toolkit/ort-workbench/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/oss-review-toolkit/ort-workbench/actions/workflows/static-analysis.yml) 5 | 6 | A basic workbench for [ORT](https://oss-review-toolkit.org) result files, implemented with 7 | [Compose Desktop](https://www.jetbrains.com/lp/compose-mpp/). 8 | 9 | ![Screenshot](assets/screenshot.png) 10 | 11 | ## Getting Started 12 | 13 | The are currently no binary builds published, to run the workbench clone the repository and execute: 14 | 15 | ```shell 16 | ./gradlew run 17 | ``` 18 | 19 | ## Roadmap 20 | 21 | See [Milestones](https://github.com/oss-review-toolkit/ort-workbench/milestones?direction=asc&sort=title&state=open) for 22 | planned features. 23 | 24 | # License 25 | 26 | Copyright (C) 2021-2024 [The ORT Workbench Project Authors](./NOTICE). 27 | 28 | This project is published under the [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0.html) license, see the 29 | [LICENSE](./LICENSE) file in the root of this project for license details. 30 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oss-review-toolkit/ort-workbench/af2297a65cb6686bc47b8ee69ae844165a917aa7/assets/screenshot.png -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask 2 | 3 | import io.gitlab.arturbosch.detekt.Detekt 4 | 5 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 6 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 7 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 8 | 9 | val javaLanguageVersion: String by project 10 | 11 | plugins { 12 | alias(libs.plugins.compose) 13 | alias(libs.plugins.compose.compiler) 14 | alias(libs.plugins.detekt) 15 | alias(libs.plugins.kotlin) 16 | alias(libs.plugins.versions) 17 | } 18 | 19 | group = "org.ossreviewtoolkit.workbench" 20 | version = "1.0.0" 21 | 22 | repositories { 23 | google() 24 | mavenCentral() 25 | 26 | exclusiveContent { 27 | forRepository { 28 | maven("https://androidx.dev/storage/compose-compiler/repository") 29 | } 30 | 31 | filter { 32 | includeGroup("androidx.compose.compiler") 33 | } 34 | } 35 | 36 | exclusiveContent { 37 | forRepository { 38 | maven("https://repo.gradle.org/gradle/libs-releases/") 39 | } 40 | 41 | filter { 42 | includeGroup("org.gradle") 43 | } 44 | } 45 | } 46 | 47 | dependencies { 48 | implementation(compose.components.resources) 49 | implementation(compose.desktop.currentOs) 50 | implementation(compose.materialIconsExtended) 51 | implementation(libs.bundles.ort) 52 | implementation(libs.bundles.richtext) 53 | implementation(libs.dataTableMaterial) 54 | implementation(libs.fileKit) 55 | implementation(libs.jacksonModuleKotlin) 56 | implementation(libs.kotlinxCoroutinesSwing) 57 | implementation(libs.log4jApiKotlin) 58 | implementation(libs.log4jApiToSlf4j) 59 | implementation(libs.logbackClassic) 60 | implementation(libs.moleculeRuntime) 61 | implementation(platform(libs.ortPackageConfigurationProviders)) 62 | 63 | detektPlugins(libs.detektFormatting) 64 | detektPlugins(libs.ortDetektRules) 65 | } 66 | 67 | tasks.named("dependencyUpdates") { 68 | gradleReleaseChannel = "current" 69 | outputFormatter = "json" 70 | 71 | val nonFinalQualifiers = listOf( 72 | "alpha", "b", "beta", "cr", "dev", "ea", "eap", "m", "milestone", "pr", "preview", "rc", "\\d{14}" 73 | ).joinToString("|", "(", ")") 74 | 75 | val nonFinalQualifiersRegex = Regex(".*[.-]$nonFinalQualifiers[.\\d-+]*", RegexOption.IGNORE_CASE) 76 | 77 | rejectVersionIf { 78 | candidate.version.matches(nonFinalQualifiersRegex) 79 | } 80 | } 81 | 82 | java { 83 | toolchain { 84 | languageVersion = JavaLanguageVersion.of(javaLanguageVersion) 85 | vendor = JvmVendorSpec.ADOPTIUM 86 | } 87 | } 88 | 89 | val maxKotlinJvmTarget = runCatching { JvmTarget.fromTarget(javaLanguageVersion) } 90 | .getOrDefault(enumValues().max()) 91 | 92 | tasks.withType { 93 | val customCompilerArgs = listOf( 94 | "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi" 95 | ) 96 | 97 | compilerOptions { 98 | allWarningsAsErrors = true 99 | freeCompilerArgs.addAll(customCompilerArgs) 100 | jvmTarget = maxKotlinJvmTarget 101 | } 102 | } 103 | 104 | detekt { 105 | config.from(files("detekt.yml")) 106 | buildUponDefaultConfig = true 107 | basePath = rootProject.projectDir.path 108 | source.from(fileTree(".") { include("*.gradle.kts") }) 109 | } 110 | 111 | tasks.withType().configureEach { 112 | jvmTarget = maxKotlinJvmTarget.target 113 | 114 | reports { 115 | xml.required = false 116 | html.required = false 117 | txt.required = false 118 | sarif.required = true 119 | } 120 | } 121 | 122 | tasks.test { 123 | useJUnitPlatform() 124 | } 125 | 126 | compose { 127 | desktop { 128 | application { 129 | mainClass = "org.ossreviewtoolkit.workbench.MainKt" 130 | 131 | nativeDistributions { 132 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 133 | packageName = "ort-workbench" 134 | packageVersion = "1.0.0" 135 | 136 | val iconsRoot = project.file("src/main/resources/app-icon") 137 | 138 | macOS { 139 | iconFile = iconsRoot.resolve("icon.icns") 140 | jvmArgs("-Dapple.awt.application.appearance=system") 141 | } 142 | 143 | windows { 144 | iconFile = iconsRoot.resolve("icon.ico") 145 | } 146 | 147 | linux { 148 | iconFile = iconsRoot.resolve("icon.png") 149 | } 150 | } 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /detekt.yml: -------------------------------------------------------------------------------- 1 | complexity: 2 | CyclomaticComplexMethod: 3 | threshold: 20 4 | LongMethod: 5 | active: true 6 | threshold: 80 7 | LongParameterList: 8 | active: true 9 | functionThreshold: 15 10 | constructorThreshold: 11 11 | ignoreDefaultParameters: true 12 | TooManyFunctions: 13 | active: false 14 | 15 | formatting: 16 | ChainWrapping: 17 | active: false 18 | CommentWrapping: 19 | active: false 20 | ImportOrdering: 21 | active: false 22 | Indentation: 23 | active: false 24 | MaximumLineLength: 25 | active: false 26 | NoWildcardImports: 27 | active: false 28 | 29 | naming: 30 | FunctionNaming: 31 | active: true 32 | ignoreAnnotated: ['Composable'] 33 | InvalidPackageDeclaration: 34 | active: false 35 | MatchingDeclarationName: 36 | active: false 37 | 38 | style: 39 | ForbiddenComment: 40 | active: false 41 | MagicNumber: 42 | active: true 43 | ignoreAnnotated: ['Composable', 'Preview'] 44 | UnusedPrivateMember: 45 | active: true 46 | ignoreAnnotated: ['Preview'] 47 | WildcardImport: 48 | excludeImports: 49 | - 'androidx.compose.material.icons.automirrored.filled.*' 50 | - 'androidx.compose.material.icons.filled.*' 51 | - 'org.ossreviewtoolkit.workbench.ort_workbench.generated.resources.*' 52 | 53 | ORT: 54 | OrtImportOrder: 55 | active: true 56 | OrtPackageNaming: 57 | active: false 58 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.caching = true 2 | org.gradle.configuration-cache = true 3 | org.gradle.kotlin.dsl.allWarningsAsErrors = true 4 | org.gradle.parallel = true 5 | 6 | # Keep this aligned with `toolchainVersion` in `gradle/gradle-daemon-jvm.properties`. 7 | javaLanguageVersion = 21 8 | 9 | kotlin.code.style = official 10 | -------------------------------------------------------------------------------- /gradle/gradle-daemon-jvm.properties: -------------------------------------------------------------------------------- 1 | # Keep this aligned with `javaLanguageVersion` in `gradle.properties`. 2 | toolchainVersion = 21 3 | 4 | toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect 5 | toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect 6 | toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect 7 | toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect 8 | toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect 9 | toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect 10 | toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect 11 | toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect 12 | toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect 13 | toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect 14 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | detektPlugin = "1.23.8" 3 | composePlugin = "1.8.1" 4 | kotlinPlugin = "2.1.21" 5 | versionsPlugin = "0.52.0" 6 | 7 | dataTableMaterial = "0.8.1" 8 | fileKit = "0.8.8" 9 | jackson = "2.19.0" 10 | kotlinxCoroutines = "1.10.2" 11 | log4jApi = "2.24.3" 12 | log4jApiKotlin = "1.5.0" 13 | logbackImpl = "1.5.18" 14 | moleculeRuntime = "2.1.0" 15 | ort = "60.0.0" 16 | richtext = "0.20.0" 17 | 18 | [plugins] 19 | detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detektPlugin" } 20 | compose = { id = "org.jetbrains.compose", version.ref = "composePlugin" } 21 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinPlugin" } 22 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinPlugin" } 23 | versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } 24 | 25 | [libraries] 26 | dataTableMaterial = { module = "com.seanproctor:data-table-material", version.ref = "dataTableMaterial" } 27 | detektFormatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektPlugin" } 28 | fileKit = { module = "io.github.vinceglb:filekit-core", version.ref = "fileKit" } 29 | jacksonModuleKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } 30 | kotlinxCoroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" } 31 | log4jApiKotlin = { module = "org.apache.logging.log4j:log4j-api-kotlin", version.ref = "log4jApiKotlin" } 32 | log4jApiToSlf4j = { module = "org.apache.logging.log4j:log4j-to-slf4j", version.ref = "log4jApi" } 33 | logbackClassic = { module = "ch.qos.logback:logback-classic", version.ref = "logbackImpl" } 34 | moleculeRuntime = { module = "app.cash.molecule:molecule-runtime", version.ref = "moleculeRuntime" } 35 | ortAnalyzer = { module = "org.ossreviewtoolkit:analyzer", version.ref = "ort" } 36 | ortDetektRules = { module = "org.ossreviewtoolkit:detekt-rules", version.ref = "ort" } 37 | ortModel = { module = "org.ossreviewtoolkit:model", version.ref = "ort" } 38 | ortPackageConfigurationProviders = { module = "org.ossreviewtoolkit.plugins:package-configuration-providers", version.ref = "ort" } 39 | ortReporter = { module = "org.ossreviewtoolkit:reporter", version.ref = "ort" } 40 | richtextCommonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" } 41 | richtextUiMaterial = { module = "com.halilibo.compose-richtext:richtext-ui-material", version.ref = "richtext" } 42 | 43 | [bundles] 44 | ort = ["ortAnalyzer", "ortModel", "ortReporter"] 45 | richtext = ["richtextCommonmark", "richtextUiMaterial"] 46 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oss-review-toolkit/ort-workbench/af2297a65cb6686bc47b8ee69ae844165a917aa7/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":semanticCommits", 6 | ":semanticCommitScopeDisabled", 7 | ":semanticCommitTypeAll(deps)" 8 | ], 9 | "dependencyDashboard": false, 10 | "ignoreDeps": [ 11 | "org.jetbrains.kotlin.jvm" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | gradlePluginPortal() 5 | mavenCentral() 6 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 7 | } 8 | } 9 | 10 | plugins { 11 | // Gradle cannot access the version catalog from here, so hard-code the dependency. 12 | id("org.gradle.toolchains.foojay-resolver-convention").version("1.0.0") 13 | } 14 | 15 | rootProject.name = "ort-workbench" 16 | -------------------------------------------------------------------------------- /src/main/composeResources/drawable/advisor.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 3 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /src/main/composeResources/drawable/analyzer.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 3 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /src/main/composeResources/drawable/app-icon/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oss-review-toolkit/ort-workbench/af2297a65cb6686bc47b8ee69ae844165a917aa7/src/main/composeResources/drawable/app-icon/icon.icns -------------------------------------------------------------------------------- /src/main/composeResources/drawable/app-icon/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oss-review-toolkit/ort-workbench/af2297a65cb6686bc47b8ee69ae844165a917aa7/src/main/composeResources/drawable/app-icon/icon.ico -------------------------------------------------------------------------------- /src/main/composeResources/drawable/app-icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oss-review-toolkit/ort-workbench/af2297a65cb6686bc47b8ee69ae844165a917aa7/src/main/composeResources/drawable/app-icon/icon.png -------------------------------------------------------------------------------- /src/main/composeResources/drawable/ort-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oss-review-toolkit/ort-workbench/af2297a65cb6686bc47b8ee69ae844165a917aa7/src/main/composeResources/drawable/ort-black.png -------------------------------------------------------------------------------- /src/main/composeResources/drawable/ort-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oss-review-toolkit/ort-workbench/af2297a65cb6686bc47b8ee69ae844165a917aa7/src/main/composeResources/drawable/ort-white.png -------------------------------------------------------------------------------- /src/main/kotlin/Main.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench 2 | 3 | import androidx.compose.ui.graphics.painter.BitmapPainter 4 | import androidx.compose.ui.unit.DpSize 5 | import androidx.compose.ui.unit.dp 6 | import androidx.compose.ui.window.WindowState 7 | import androidx.compose.ui.window.singleWindowApplication 8 | 9 | import java.net.URI 10 | 11 | import org.jetbrains.compose.resources.ExperimentalResourceApi 12 | import org.jetbrains.compose.resources.decodeToImageBitmap 13 | 14 | import org.ossreviewtoolkit.workbench.ort_workbench.generated.resources.Res 15 | import org.ossreviewtoolkit.workbench.ui.App 16 | import org.ossreviewtoolkit.workbench.ui.WorkbenchController 17 | 18 | @OptIn(ExperimentalResourceApi::class) 19 | fun main() { 20 | val workbenchController = WorkbenchController() 21 | 22 | // See https://github.com/JetBrains/compose-multiplatform/issues/2369. 23 | val iconBytes = URI.create(Res.getUri("drawable/app-icon/icon.png")).toURL().readBytes() 24 | val icon = BitmapPainter(iconBytes.decodeToImageBitmap()) 25 | 26 | singleWindowApplication( 27 | title = "ORT Workbench", 28 | state = WindowState( 29 | size = DpSize(1440.dp, 810.dp) 30 | ), 31 | icon = icon 32 | ) { 33 | App(workbenchController) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/CaptionedColumn.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.ColumnScope 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | 10 | @Composable 11 | fun CaptionedColumn(caption: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { 12 | Column(modifier = modifier) { 13 | Text( 14 | text = caption, 15 | style = MaterialTheme.typography.caption, 16 | color = MaterialTheme.colors.primary 17 | ) 18 | content() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/CaptionedText.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | 7 | @Composable 8 | fun CaptionedText(caption: String, text: String, modifier: Modifier = Modifier) { 9 | CaptionedColumn(caption, modifier = modifier) { Text(text) } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/CircularProgressBox.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material.CircularProgressIndicator 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | 15 | @Composable 16 | fun CircularProgressBox() { 17 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 18 | CircularProgressIndicator() 19 | } 20 | } 21 | 22 | @Composable 23 | fun CircularProgressBox(current: Int, target: Int, label: String) { 24 | val progress = remember(current, target) { 25 | if (target != 0) { 26 | current / target.toFloat() 27 | } else { 28 | 0f 29 | } 30 | } 31 | 32 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 33 | Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { 34 | CircularProgressIndicator(progress = progress) 35 | Text("Processed $current of $target $label...") 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/Datetime.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | 7 | import java.time.Instant 8 | import java.time.LocalDateTime 9 | import java.time.ZoneId 10 | import java.time.format.DateTimeFormatter 11 | import java.time.format.FormatStyle 12 | 13 | /** 14 | * Display the provided [instant] as localized date time using the provided [formatStyle]. 15 | */ 16 | @Composable 17 | fun Datetime(instant: Instant, formatStyle: FormatStyle = FormatStyle.MEDIUM) { 18 | Text(rememberFormattedDatetime(instant, formatStyle)) 19 | } 20 | 21 | /** 22 | * Format and remember the provided [instant] as a localized date time using the provided [formatStyle]. 23 | */ 24 | @Composable 25 | fun rememberFormattedDatetime(instant: Instant, formatStyle: FormatStyle = FormatStyle.MEDIUM): String { 26 | val formatter = remember(formatStyle) { DateTimeFormatter.ofLocalizedDateTime(formatStyle) } 27 | return remember(instant) { 28 | val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) 29 | formatter.format(localDateTime) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/DirectoryChooser.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | 6 | import java.io.File 7 | 8 | import javax.swing.JFileChooser 9 | import javax.swing.UIManager 10 | 11 | import kotlinx.coroutines.DelicateCoroutinesApi 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.GlobalScope 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.swing.Swing 16 | 17 | @OptIn(DelicateCoroutinesApi::class) 18 | @Composable 19 | fun DirectoryChooser(currentDirectory: File? = null, onResult: (result: File?) -> Unit) { 20 | DisposableEffect(Unit) { 21 | val job = GlobalScope.launch(Dispatchers.Swing) { 22 | UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) 23 | val fileChooser = JFileChooser() 24 | fileChooser.currentDirectory = currentDirectory 25 | fileChooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY 26 | if (fileChooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { 27 | onResult(fileChooser.selectedFile) 28 | } else { 29 | onResult(null) 30 | } 31 | } 32 | 33 | onDispose { 34 | job.cancel() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/ErrorCard.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.material.Card 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.Composable 8 | 9 | @Composable 10 | fun ErrorCard(message: String) { 11 | Card(backgroundColor = MaterialTheme.colors.error) { 12 | Text(text = message) 13 | } 14 | } 15 | 16 | @Composable 17 | @Preview 18 | fun ErrorCardPreview() { 19 | Preview { 20 | ErrorCard("Some error message.") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/Expandable.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.animateContentSize 5 | import androidx.compose.animation.core.MutableTransitionState 6 | import androidx.compose.animation.expandVertically 7 | import androidx.compose.animation.shrinkVertically 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.ColumnScope 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.material.Icon 12 | import androidx.compose.material.IconButton 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.filled.* 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | 20 | @Composable 21 | fun Expandable( 22 | header: @Composable ColumnScope.(Boolean) -> Unit, 23 | startExpanded: Boolean = false, 24 | expandedContent: @Composable () -> Unit 25 | ) { 26 | val expanded = remember { MutableTransitionState(startExpanded) } 27 | 28 | Row { 29 | Column(modifier = Modifier.weight(1f).align(Alignment.Bottom).animateContentSize()) { 30 | header(expanded.currentState) 31 | } 32 | 33 | IconButton( 34 | onClick = { expanded.targetState = !expanded.currentState }, 35 | modifier = Modifier.align(Alignment.Top) 36 | ) { 37 | val image = if (expanded.currentState) Icons.Default.ExpandLess else Icons.Default.ExpandMore 38 | Icon(image, "expand") 39 | } 40 | } 41 | 42 | AnimatedVisibility( 43 | visibleState = expanded, 44 | enter = expandVertically(), 45 | exit = shrinkVertically() 46 | ) { 47 | expandedContent() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/ExpandableText.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.animation.animateContentSize 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.wrapContentHeight 8 | import androidx.compose.material.Icon 9 | import androidx.compose.material.Text 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.* 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.saveable.rememberSaveable 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.text.font.FontFamily 19 | import androidx.compose.ui.unit.Dp 20 | import androidx.compose.ui.unit.dp 21 | 22 | import com.halilibo.richtext.markdown.Markdown 23 | import com.halilibo.richtext.ui.material.RichText 24 | 25 | @Composable 26 | fun ExpandableText(text: String, unexpandedHeight: Dp = 20.dp, fontFamily: FontFamily? = null) { 27 | var expanded by rememberSaveable { mutableStateOf(false) } 28 | 29 | val modifier = if (expanded) Modifier.wrapContentHeight() else Modifier.height(unexpandedHeight) 30 | Row(modifier = modifier.animateContentSize()) { 31 | Text(text, modifier = Modifier.weight(1f), fontFamily = fontFamily) 32 | 33 | val image = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore 34 | Icon(image, "expand", modifier = Modifier.clickable { expanded = !expanded }) 35 | } 36 | } 37 | 38 | @Composable 39 | fun ExpandableMarkdown(text: String, unexpandedHeight: Dp = 20.dp) { 40 | var expanded by rememberSaveable { mutableStateOf(false) } 41 | 42 | val modifier = if (expanded) Modifier.wrapContentHeight() else Modifier.height(unexpandedHeight) 43 | Row(modifier = modifier.animateContentSize()) { 44 | RichText(modifier = Modifier.weight(1f)) { 45 | Markdown(text) 46 | } 47 | 48 | val image = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore 49 | Icon(image, "expand", modifier = Modifier.clickable { expanded = !expanded }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/Extensions.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | 6 | import org.ossreviewtoolkit.utils.common.titlecase 7 | 8 | @Composable 9 | fun Modifier.conditional(condition: Boolean, modifier: @Composable Modifier.() -> Modifier) = 10 | if (condition) then(modifier(Modifier)) else this 11 | 12 | fun String.enumcase() = replace("_", " ").titlecase() 13 | 14 | fun Any?.toStringOrDash() = this?.toString()?.takeIf { it.isNotEmpty() } ?: "-" 15 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/FileDialog.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | import io.github.vinceglb.filekit.core.FileKit 6 | import io.github.vinceglb.filekit.core.PickerMode 7 | import io.github.vinceglb.filekit.core.PickerType 8 | 9 | import java.nio.file.Path 10 | 11 | import kotlinx.coroutines.runBlocking 12 | 13 | @Composable 14 | fun FileDialog( 15 | title: String, 16 | isLoad: Boolean, 17 | fileExtensionFilter: List = emptyList(), 18 | onResult: (result: Path?) -> Unit 19 | ) { 20 | require(isLoad) 21 | 22 | val fileType = PickerType.File(fileExtensionFilter) 23 | val pickedFile = runBlocking { FileKit.pickFile(fileType, PickerMode.Single, title) } 24 | 25 | pickedFile?.run { onResult(file.toPath()) } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/FilterButton.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.material.ContentAlpha 6 | import androidx.compose.material.DropdownMenu 7 | import androidx.compose.material.DropdownMenuItem 8 | import androidx.compose.material.LocalContentAlpha 9 | import androidx.compose.material.OutlinedButton 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.CompositionLocalProvider 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.saveable.rememberSaveable 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.text.font.FontStyle 19 | import androidx.compose.ui.text.style.TextOverflow 20 | 21 | import org.ossreviewtoolkit.workbench.model.FilterData 22 | 23 | @Composable 24 | fun FilterButton( 25 | data: FilterData, 26 | label: String, 27 | onFilterChange: (T?) -> Unit, 28 | convert: (T) -> String = { it.toString() } 29 | ) { 30 | var expanded by rememberSaveable { mutableStateOf(false) } 31 | 32 | Box { 33 | OutlinedButton(onClick = { expanded = !expanded }, modifier = Modifier.fillMaxWidth()) { 34 | if (data.selectedItem == null) { 35 | Text(label, overflow = TextOverflow.Ellipsis) 36 | } else { 37 | Text("$label: ${convert(data.selectedItem)}", overflow = TextOverflow.Ellipsis) 38 | } 39 | } 40 | 41 | DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { 42 | DropdownMenuItem(onClick = { 43 | expanded = false 44 | onFilterChange(null) 45 | }) { 46 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 47 | Text("No filter", fontStyle = FontStyle.Italic, overflow = TextOverflow.Ellipsis) 48 | } 49 | } 50 | 51 | data.options.forEach { item -> 52 | DropdownMenuItem(onClick = { 53 | expanded = false 54 | onFilterChange(item) 55 | }) { 56 | Text(convert(item), overflow = TextOverflow.Ellipsis) 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/FilterPanel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.ColumnScope 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | 13 | @Composable 14 | fun FilterPanel(visible: Boolean, content: @Composable ColumnScope.() -> Unit) { 15 | SidePanel(visible = visible) { 16 | Column(modifier = Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { 17 | Text("Filters", style = MaterialTheme.typography.h4) 18 | content() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/FilterTextField.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.widthIn 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.material.ContentAlpha 7 | import androidx.compose.material.Icon 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Text 10 | import androidx.compose.material.TextField 11 | import androidx.compose.material.TextFieldDefaults 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.* 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.runtime.mutableStateOf 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.runtime.setValue 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.graphics.vector.ImageVector 22 | import androidx.compose.ui.unit.dp 23 | 24 | @Composable 25 | fun FilterTextField( 26 | filterText: String, 27 | label: String = "Filter", 28 | image: ImageVector = Icons.Default.Filter, 29 | modifier: Modifier = Modifier, 30 | onFilterChange: (String) -> Unit 31 | ) { 32 | var localFilterText by remember(filterText) { mutableStateOf(filterText) } 33 | 34 | TextField( 35 | value = localFilterText, 36 | onValueChange = { 37 | localFilterText = it 38 | onFilterChange(it) 39 | }, 40 | placeholder = { Text(label) }, 41 | singleLine = true, 42 | leadingIcon = { Icon(image, label) }, 43 | shape = RoundedCornerShape(10.dp), 44 | colors = TextFieldDefaults.textFieldColors( 45 | focusedIndicatorColor = Color.Transparent, 46 | unfocusedIndicatorColor = Color.Transparent, 47 | textColor = MaterialTheme.colors.onPrimary.copy(ContentAlpha.high), 48 | leadingIconColor = MaterialTheme.colors.onPrimary.copy(ContentAlpha.medium), 49 | placeholderColor = MaterialTheme.colors.onPrimary.copy(ContentAlpha.medium) 50 | ), 51 | modifier = modifier.widthIn(max = 300.dp) 52 | ) 53 | } 54 | 55 | @Composable 56 | @Preview 57 | private fun FilterTextFieldPreview() { 58 | FilterTextField("Filter text", onFilterChange = {}) 59 | } 60 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/IconText.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.material.Icon 6 | import androidx.compose.material.Text 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.* 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.scale 13 | import androidx.compose.ui.graphics.painter.Painter 14 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 15 | 16 | private const val ICON_SCALE = 0.6f 17 | 18 | @Composable 19 | fun IconText( 20 | icon: Painter, 21 | text: String, 22 | contentDescription: String? = null 23 | ) { 24 | Row(verticalAlignment = Alignment.CenterVertically) { 25 | Icon( 26 | painter = icon, 27 | contentDescription = contentDescription, 28 | modifier = Modifier.scale(ICON_SCALE) 29 | ) 30 | Text(text) 31 | } 32 | } 33 | 34 | @Composable 35 | @Preview 36 | private fun IconTextPreview() { 37 | Preview { 38 | IconText( 39 | icon = rememberVectorPainter(Icons.Default.BugReport), 40 | text = "Issues" 41 | ) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/Link.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.TooltipArea 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.material.ContentAlpha 12 | import androidx.compose.material.Icon 13 | import androidx.compose.material.LocalContentAlpha 14 | import androidx.compose.material.MaterialTheme 15 | import androidx.compose.material.Text 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.automirrored.filled.* 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.CompositionLocalProvider 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.painter.Painter 23 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 24 | import androidx.compose.ui.unit.dp 25 | 26 | import java.io.File 27 | 28 | import org.ossreviewtoolkit.workbench.utils.browseDirectory 29 | import org.ossreviewtoolkit.workbench.utils.editFile 30 | import org.ossreviewtoolkit.workbench.utils.openUrlInBrowser 31 | 32 | @OptIn(ExperimentalFoundationApi::class) 33 | @Composable 34 | fun Link(text: String, tooltip: String? = null, icon: Painter? = null, enabled: Boolean = true, onClick: () -> Unit) { 35 | if (tooltip != null) { 36 | TooltipArea(tooltip = { Tooltip(tooltip) }) { 37 | LinkContent(text, icon, enabled, onClick) 38 | } 39 | } else { 40 | LinkContent(text, icon, enabled, onClick) 41 | } 42 | } 43 | 44 | @Composable 45 | private fun LinkContent(text: String, icon: Painter?, enabled: Boolean, onClick: () -> Unit) { 46 | Row( 47 | modifier = Modifier.clickable { if (enabled) onClick() }, 48 | horizontalArrangement = Arrangement.spacedBy(5.dp), 49 | verticalAlignment = Alignment.CenterVertically 50 | ) { 51 | CompositionLocalProvider(LocalContentAlpha provides if (enabled) ContentAlpha.high else ContentAlpha.medium) { 52 | Text(text) 53 | } 54 | 55 | if (icon != null) { 56 | Icon( 57 | icon, 58 | contentDescription = "link", 59 | tint = MaterialTheme.colors.primary, 60 | modifier = Modifier.size(16.dp) 61 | ) 62 | } 63 | } 64 | } 65 | 66 | @Composable 67 | @Preview 68 | private fun LinkPreview() { 69 | Preview { 70 | Column { 71 | Link("Enabled link") {} 72 | Link("Disabled link", enabled = false) {} 73 | } 74 | } 75 | } 76 | 77 | @Composable 78 | fun BrowseDirectoryLink(text: String, file: File) { 79 | Link(text, tooltip = file.path, icon = rememberVectorPainter(Icons.AutoMirrored.Default.OpenInNew)) { 80 | browseDirectory(file) 81 | } 82 | } 83 | 84 | @Composable 85 | fun EditFileLink(text: String, file: File) { 86 | Link(text, tooltip = file.path, icon = rememberVectorPainter(Icons.AutoMirrored.Default.OpenInNew)) { 87 | editFile(file) 88 | } 89 | } 90 | 91 | @Composable 92 | fun WebLink(text: String, url: String) { 93 | Link(text, tooltip = url, icon = rememberVectorPainter(Icons.AutoMirrored.Default.OpenInNew)) { 94 | openUrlInBrowser(url) 95 | } 96 | } 97 | 98 | @Composable 99 | @Preview 100 | private fun WebLinkPreview() { 101 | Preview { 102 | WebLink("Web Link", "") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/ListScreenAppBar.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material.Icon 8 | import androidx.compose.material.IconButton 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.* 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | 15 | @Composable 16 | fun ListScreenAppBar( 17 | filterText: String, 18 | onUpdateFilterText: (text: String) -> Unit, 19 | onToggleFilter: () -> Unit 20 | ) { 21 | ScreenAppBar( 22 | title = {}, 23 | actions = { 24 | Row(modifier = Modifier.padding(vertical = 5.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { 25 | FilterTextField(filterText, onFilterChange = onUpdateFilterText) 26 | 27 | IconButton(onClick = onToggleFilter) { 28 | Icon( 29 | Icons.Default.FilterList, 30 | contentDescription = "Filter", 31 | modifier = Modifier.size(32.dp) 32 | ) 33 | } 34 | } 35 | } 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/ListScreenContent.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.BoxScope 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.RowScope 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | 15 | @Composable 16 | fun ListScreenContent( 17 | filterText: String, 18 | onUpdateFilterText: (text: String) -> Unit, 19 | list: @Composable BoxScope.() -> Unit, 20 | filterPanel: @Composable RowScope.(showFilterPanel: Boolean) -> Unit 21 | ) { 22 | var showFilterPanel by remember { mutableStateOf(false) } 23 | 24 | Column { 25 | ListScreenAppBar( 26 | filterText = filterText, 27 | onUpdateFilterText = onUpdateFilterText, 28 | onToggleFilter = { showFilterPanel = !showFilterPanel } 29 | ) 30 | 31 | Row { 32 | Box(modifier = Modifier.weight(1f)) { 33 | list() 34 | } 35 | 36 | filterPanel(showFilterPanel) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/ListScreenList.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.VerticalScrollbar 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.fillMaxHeight 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.foundation.lazy.LazyItemScope 12 | import androidx.compose.foundation.lazy.rememberLazyListState 13 | import androidx.compose.foundation.rememberScrollbarAdapter 14 | import androidx.compose.material.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.unit.dp 19 | 20 | @Composable 21 | fun ListScreenList( 22 | items: List, 23 | itemsEmptyText: String, 24 | item: @Composable LazyItemScope.(item: ITEM) -> Unit 25 | ) { 26 | if (items.isEmpty()) { 27 | Text(itemsEmptyText, modifier = Modifier.padding(15.dp)) 28 | } else { 29 | Box(modifier = Modifier.fillMaxSize()) { 30 | val listState = rememberLazyListState() 31 | 32 | LazyColumn( 33 | contentPadding = PaddingValues(15.dp), 34 | verticalArrangement = Arrangement.spacedBy(10.dp), 35 | state = listState 36 | ) { 37 | items(items.size, key = { it }) { index -> 38 | item(items[index]) 39 | } 40 | } 41 | 42 | VerticalScrollbar( 43 | modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), 44 | adapter = rememberScrollbarAdapter(scrollState = listState) 45 | ) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/Preview.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.unit.dp 8 | 9 | import org.ossreviewtoolkit.workbench.model.WorkbenchTheme 10 | import org.ossreviewtoolkit.workbench.theme.OrtWorkbenchTheme 11 | 12 | @Composable 13 | fun Preview(content: @Composable () -> Unit) { 14 | OrtWorkbenchTheme(WorkbenchTheme.AUTO) { 15 | Box(modifier = Modifier.padding(10.dp)) { 16 | content() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/ScreenAppBar.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.TopAppBar 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.zIndex 9 | 10 | @Composable 11 | fun ScreenAppBar( 12 | title: @Composable () -> Unit, 13 | navigationIcon: @Composable (() -> Unit)? = null, 14 | actions: @Composable RowScope.() -> Unit = {} 15 | ) { 16 | TopAppBar( 17 | modifier = Modifier.zIndex(1f), 18 | backgroundColor = MaterialTheme.colors.primary, 19 | title = title, 20 | navigationIcon = navigationIcon, 21 | actions = actions 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/SeverityIcon.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material.Icon 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.* 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.Dp 12 | import androidx.compose.ui.unit.dp 13 | 14 | import org.ossreviewtoolkit.model.Severity 15 | import org.ossreviewtoolkit.workbench.theme.Error 16 | import org.ossreviewtoolkit.workbench.theme.Hint 17 | import org.ossreviewtoolkit.workbench.theme.LightGray 18 | import org.ossreviewtoolkit.workbench.theme.Warning 19 | 20 | @Composable 21 | fun SeverityIcon(severity: Severity, resolved: Boolean = false, size: Dp = 24.dp) { 22 | val icon = when (severity) { 23 | Severity.HINT -> Icons.Default.Info 24 | Severity.WARNING -> Icons.Default.Warning 25 | Severity.ERROR -> Icons.Default.Error 26 | } 27 | 28 | val tint = if (resolved) { 29 | LightGray 30 | } else { 31 | when (severity) { 32 | Severity.HINT -> Hint 33 | Severity.WARNING -> Warning 34 | Severity.ERROR -> Error 35 | } 36 | } 37 | 38 | Icon( 39 | icon, 40 | contentDescription = severity.name, 41 | tint = tint, 42 | modifier = Modifier.size(size) 43 | ) 44 | } 45 | 46 | @Composable 47 | @Preview 48 | private fun SeverityIconPreview() { 49 | Preview { 50 | Row { 51 | SeverityIcon(Severity.HINT) 52 | SeverityIcon(Severity.WARNING) 53 | SeverityIcon(Severity.ERROR) 54 | SeverityIcon(Severity.HINT, resolved = true) 55 | SeverityIcon(Severity.WARNING, resolved = true) 56 | SeverityIcon(Severity.ERROR, resolved = true) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/SidePanel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.layout.fillMaxHeight 5 | import androidx.compose.foundation.layout.width 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Surface 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | 12 | @Composable 13 | fun SidePanel(visible: Boolean, content: @Composable () -> Unit) { 14 | AnimatedVisibility(visible = visible) { 15 | Surface( 16 | modifier = Modifier.width(500.dp).fillMaxHeight(), 17 | color = MaterialTheme.colors.surface, 18 | elevation = 8.dp 19 | ) { 20 | content() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/SingleLineText.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.TooltipArea 5 | import androidx.compose.material.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.text.style.TextOverflow 8 | 9 | /** 10 | * Display the [text] on a single line using [TextOverflow.Ellipsis] on overflow. The text is also shown in a [Tooltip] 11 | * on mouse over. 12 | */ 13 | @OptIn(ExperimentalFoundationApi::class) 14 | @Composable 15 | fun SingleLineText(text: String) { 16 | TooltipArea(tooltip = { Tooltip(text) }) { 17 | Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/StyledCard.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.ColumnScope 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.size 10 | import androidx.compose.material.Card 11 | import androidx.compose.material.Icon 12 | import androidx.compose.material.MaterialTheme 13 | import androidx.compose.material.Surface 14 | import androidx.compose.material.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.painter.Painter 19 | import androidx.compose.ui.unit.dp 20 | 21 | @Composable 22 | fun StyledCard( 23 | titleIcon: Painter? = null, 24 | title: String, 25 | content: @Composable (ColumnScope.() -> Unit) 26 | ) { 27 | Card(modifier = Modifier.fillMaxWidth(), elevation = 8.dp) { 28 | Column { 29 | Surface(color = MaterialTheme.colors.primaryVariant, modifier = Modifier.fillMaxWidth()) { 30 | Row( 31 | modifier = Modifier.padding(10.dp), 32 | verticalAlignment = Alignment.CenterVertically, 33 | horizontalArrangement = Arrangement.spacedBy(5.dp) 34 | ) { 35 | titleIcon?.run { Icon(titleIcon, contentDescription = title, modifier = Modifier.size(24.dp)) } 36 | Text(title, style = MaterialTheme.typography.h4) 37 | } 38 | } 39 | 40 | Column(modifier = Modifier.padding(15.dp)) { 41 | content() 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/Tooltip.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.core.MutableTransitionState 5 | import androidx.compose.animation.fadeIn 6 | import androidx.compose.animation.fadeOut 7 | import androidx.compose.desktop.ui.tooling.preview.Preview 8 | import androidx.compose.foundation.background 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material.MaterialTheme 14 | import androidx.compose.material.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.clip 20 | import androidx.compose.ui.unit.dp 21 | 22 | @Composable 23 | fun Tooltip(text: String) { 24 | val visible = remember { MutableTransitionState(false).apply { targetState = true } } 25 | 26 | AnimatedVisibility( 27 | visibleState = visible, 28 | enter = fadeIn(), 29 | exit = fadeOut() 30 | ) { 31 | Box(modifier = Modifier.clip(RoundedCornerShape(size = 8.dp)), contentAlignment = Alignment.Center) { 32 | Row(modifier = Modifier.background(MaterialTheme.colors.primaryVariant).padding(8.dp)) { 33 | Text(text, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onPrimary) 34 | } 35 | } 36 | } 37 | } 38 | 39 | @Composable 40 | @Preview 41 | private fun TooltipPreview() { 42 | Preview { 43 | Tooltip("Some tooltip") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/TwoColumnTable.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.dp 7 | 8 | import com.seanproctor.datatable.DataColumn 9 | import com.seanproctor.datatable.material.DataTable 10 | 11 | /** 12 | * A [DataTable] with two columns. 13 | */ 14 | @Composable 15 | fun TwoColumnTable(headers: Pair, data: Map) { 16 | DataTable( 17 | modifier = Modifier.fillMaxWidth(), 18 | headerHeight = 36.dp, 19 | rowHeight = 24.dp, 20 | columns = listOf( 21 | DataColumn { SingleLineText(headers.first) }, 22 | DataColumn { SingleLineText(headers.second) } 23 | ) 24 | ) { 25 | data.forEach { (key, value) -> 26 | row { 27 | cell { SingleLineText(key) } 28 | cell { SingleLineText(value) } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/tree/Tree.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables.tree 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.size 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.foundation.lazy.LazyListState 12 | import androidx.compose.foundation.lazy.rememberLazyListState 13 | import androidx.compose.material.Icon 14 | import androidx.compose.material.Text 15 | import androidx.compose.material.icons.Icons 16 | import androidx.compose.material.icons.filled.* 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.LaunchedEffect 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.vector.ImageVector 22 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 23 | import androidx.compose.ui.input.key.Key 24 | import androidx.compose.ui.input.key.KeyEvent 25 | import androidx.compose.ui.input.key.KeyEventType 26 | import androidx.compose.ui.input.key.key 27 | import androidx.compose.ui.input.key.onKeyEvent 28 | import androidx.compose.ui.input.key.type 29 | import androidx.compose.ui.text.font.FontWeight 30 | import androidx.compose.ui.unit.Dp 31 | import androidx.compose.ui.unit.dp 32 | 33 | import org.ossreviewtoolkit.workbench.composables.Preview 34 | 35 | @Composable 36 | fun Tree( 37 | modifier: Modifier = Modifier, 38 | roots: List>, 39 | startExpanded: Boolean = false, 40 | listState: LazyListState = rememberLazyListState(), 41 | indentation: Dp = 5.dp, 42 | expandIcon: ImageVector = Icons.Default.ChevronRight, 43 | expandedIcon: ImageVector = Icons.Default.ExpandMore, 44 | iconSize: Dp = 12.dp, 45 | itemContent: @Composable (item: TreeItem, isSelected: Boolean) -> Unit = { item, isSelected -> 46 | DefaultItemContent(item, isSelected) 47 | } 48 | ) { 49 | Tree( 50 | modifier = modifier, 51 | state = TreeState(roots, startExpanded), 52 | listState = listState, 53 | indentation = indentation, 54 | expandIcon = expandIcon, 55 | expandedIcon = expandedIcon, 56 | iconSize = iconSize, 57 | itemContent = itemContent 58 | ) 59 | } 60 | 61 | @Composable 62 | fun Tree( 63 | modifier: Modifier = Modifier, 64 | state: TreeState, 65 | listState: LazyListState = rememberLazyListState(), 66 | indentation: Dp = 5.dp, 67 | expandIcon: ImageVector = Icons.Default.ChevronRight, 68 | expandedIcon: ImageVector = Icons.Default.ExpandMore, 69 | iconSize: Dp = 12.dp, 70 | itemContent: @Composable (item: TreeItem, isSelected: Boolean) -> Unit = { item, isSelected -> 71 | DefaultItemContent(item, isSelected) 72 | } 73 | ) { 74 | fun handleKeyEvent(event: KeyEvent) = 75 | when (event.type) { 76 | KeyEventType.KeyUp -> { 77 | when (event.key) { 78 | Key.DirectionDown -> { 79 | state.selectNextItem() 80 | true 81 | } 82 | 83 | Key.DirectionUp -> { 84 | state.selectPreviousItem() 85 | true 86 | } 87 | 88 | Key.DirectionLeft -> { 89 | state.collapseSelectedItem() 90 | true 91 | } 92 | 93 | Key.DirectionRight -> { 94 | state.expandSelectedItem() 95 | true 96 | } 97 | 98 | else -> false 99 | } 100 | } 101 | 102 | else -> false 103 | } 104 | 105 | if (state.isItemAutoSelected) { 106 | val selectedItemIndex = state.visibleItems.indexOf(state.selectedItem) 107 | if (selectedItemIndex >= 0) { 108 | LaunchedEffect(selectedItemIndex) { 109 | listState.animateScrollToItem(selectedItemIndex) 110 | } 111 | } 112 | } 113 | 114 | LazyColumn( 115 | state = listState, 116 | modifier = modifier.onKeyEvent(::handleKeyEvent), 117 | verticalArrangement = Arrangement.spacedBy(5.dp) 118 | ) { 119 | items(state.visibleItems.size, key = { state.visibleItems[it].key }) { 120 | val item = state.visibleItems[it] 121 | Row( 122 | modifier = Modifier.padding(start = (indentation * item.level)), 123 | verticalAlignment = Alignment.CenterVertically 124 | ) { 125 | val icon = when { 126 | item.node.children.isNotEmpty() -> if (item.expanded) expandedIcon else expandIcon 127 | else -> null 128 | } 129 | 130 | if (icon != null) { 131 | Icon( 132 | painter = rememberVectorPainter(icon), 133 | contentDescription = null, 134 | modifier = Modifier.size(iconSize).clickable { state.toggleExpanded(item) } 135 | ) 136 | } 137 | 138 | Box( 139 | modifier = Modifier.padding(start = if (icon == null) iconSize else 0.dp) 140 | .clickable { state.selectItem(item, isAutoSelected = false) } 141 | ) { 142 | itemContent(item, item.index == state.selectedItem?.index) 143 | } 144 | } 145 | } 146 | } 147 | } 148 | 149 | @Composable 150 | private fun DefaultItemContent(item: TreeItem, isSelected: Boolean) { 151 | Text(text = item.node.value.toString(), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) 152 | } 153 | 154 | @Composable 155 | @Preview 156 | fun TreePreview() { 157 | val roots = listOf( 158 | TreeNode( 159 | value = "Root", 160 | key = "Root", 161 | children = listOf( 162 | TreeNode( 163 | value = "Child 1", 164 | key = "Child 1", 165 | children = listOf( 166 | TreeNode(value = "Child 1.1", key = "Child 1.1"), 167 | TreeNode( 168 | value = "Child 1.2", 169 | key = "Child 1.2", 170 | children = listOf( 171 | TreeNode(value = "Child 1.2.1", key = "Child 1.2.1") 172 | ) 173 | ), 174 | TreeNode(value = "Child 1.3", key = "Child 1.3") 175 | ) 176 | ), 177 | TreeNode( 178 | value = "Child 2", 179 | key = "Child 2", 180 | children = listOf( 181 | TreeNode(value = "Child 2.1", key = "Child 2.1"), 182 | TreeNode(value = "Child 2.2", key = "Child 2.2") 183 | ) 184 | ) 185 | ) 186 | ), 187 | TreeNode( 188 | value = "Root 2", 189 | key = "Root 2", 190 | children = listOf( 191 | TreeNode(value = "Child 1", key = "Child 1"), 192 | TreeNode(value = "Child 2", key = "Child 2") 193 | ) 194 | ) 195 | ) 196 | 197 | Preview { 198 | Tree(roots = roots, startExpanded = true) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/main/kotlin/composables/tree/TreeState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.composables.tree 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | 7 | class TreeState(private var roots: List>, startExpanded: Boolean = false) { 8 | var items = buildItems(roots, startExpanded) 9 | 10 | var visibleItems by mutableStateOf(emptyList>()) 11 | private set 12 | 13 | var selectedItem: TreeItem? by mutableStateOf(null) 14 | private set 15 | 16 | var isItemAutoSelected by mutableStateOf(false) 17 | private set 18 | 19 | init { 20 | updateVisibleItems() 21 | } 22 | 23 | private fun updateVisibleItems() { 24 | var expandedLevel = 0 25 | 26 | visibleItems = items.filter { item -> 27 | if (item.level < expandedLevel) expandedLevel = item.level 28 | if (item.level <= expandedLevel) { 29 | if (item.expanded) expandedLevel = item.level + 1 30 | true 31 | } else { 32 | false 33 | } 34 | } 35 | } 36 | 37 | fun selectItem(itemIndex: Int, isAutoSelected: Boolean) { 38 | items.getOrNull(itemIndex)?.let { selectItem(it, isAutoSelected = isAutoSelected) } 39 | } 40 | 41 | fun selectItem(item: TreeItem, isAutoSelected: Boolean) { 42 | if (item == selectedItem && !isAutoSelected) { 43 | selectedItem = null 44 | isItemAutoSelected = false 45 | } else { 46 | selectedItem = item 47 | isItemAutoSelected = isAutoSelected 48 | } 49 | } 50 | 51 | fun selectNextItem() { 52 | if (selectedItem != null) { 53 | val newIndex = visibleItems.indexOf(selectedItem) + 1 54 | 55 | selectedItem = if (newIndex < visibleItems.size) { 56 | visibleItems[newIndex] 57 | } else { 58 | visibleItems.first() 59 | } 60 | } else if (visibleItems.isNotEmpty()) { 61 | selectedItem = visibleItems[0] 62 | } 63 | } 64 | 65 | fun selectPreviousItem() { 66 | if (selectedItem != null) { 67 | val newIndex = visibleItems.indexOf(selectedItem) - 1 68 | 69 | selectedItem = if (newIndex < 0) { 70 | visibleItems.last() 71 | } else { 72 | visibleItems[newIndex] 73 | } 74 | } else if (visibleItems.isNotEmpty()) { 75 | selectedItem = visibleItems.last() 76 | } 77 | } 78 | 79 | fun toggleExpanded(item: TreeItem) { 80 | item.expanded = !item.expanded 81 | updateVisibleItems() 82 | } 83 | 84 | fun expandSelectedItem() { 85 | selectedItem?.let { 86 | if (!it.expanded) toggleExpanded(it) 87 | } 88 | } 89 | 90 | fun collapseSelectedItem() { 91 | selectedItem?.let { 92 | if (it.expanded) toggleExpanded(it) 93 | } 94 | } 95 | 96 | private fun getItemParent(item: TreeItem): TreeItem? { 97 | var index = item.index - 1 98 | while (index >= 0) { 99 | if (items[index].level < item.level) return items[index] 100 | index-- 101 | } 102 | 103 | return null 104 | } 105 | 106 | fun expandItem(itemIndex: Int) { 107 | var currentItem = items.getOrNull(itemIndex) 108 | 109 | do { 110 | currentItem = currentItem?.let { getItemParent(it) } 111 | currentItem?.expanded = true 112 | } while (currentItem != null && currentItem.level >= 0) 113 | 114 | updateVisibleItems() 115 | } 116 | 117 | fun updateNodes(roots: List>) { 118 | this.roots = roots 119 | val newItems = buildItems(roots, startExpanded = false) 120 | val expandedByKey = items.toMutableList().associate { it.key to it.expanded } 121 | 122 | newItems.forEach { newItem -> 123 | if (expandedByKey[newItem.key] == true) newItem.expanded = true 124 | } 125 | 126 | items = newItems 127 | selectedItem?.let { item -> 128 | selectedItem = items.find { it.key == item.key } 129 | } 130 | updateVisibleItems() 131 | } 132 | } 133 | 134 | private fun buildItems(roots: List>, startExpanded: Boolean): List> { 135 | var index = 0 136 | return buildList { 137 | fun addItem(level: Int, node: TreeNode, parentKeys: List = emptyList()) { 138 | val newParentKeys = parentKeys + node.key 139 | val key = newParentKeys.joinToString(separator = "|") 140 | 141 | add( 142 | TreeItem( 143 | index = index, 144 | level = level, 145 | node = node, 146 | key = key, 147 | expanded = startExpanded 148 | ) 149 | ) 150 | index++ 151 | 152 | node.children.forEach { addItem(level = level + 1, it, newParentKeys) } 153 | } 154 | 155 | roots.forEach { addItem(level = 0, node = it) } 156 | } 157 | } 158 | 159 | class TreeNode( 160 | val value: VALUE, 161 | val key: String, 162 | val children: List> = emptyList() 163 | ) 164 | 165 | class TreeItem( 166 | val index: Int, 167 | val level: Int, 168 | val node: TreeNode, 169 | val key: String, 170 | expanded: Boolean 171 | ) { 172 | var expanded by mutableStateOf(expanded) 173 | } 174 | -------------------------------------------------------------------------------- /src/main/kotlin/lifecycle/MoleculeViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.lifecycle 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | import app.cash.molecule.RecompositionMode 6 | import app.cash.molecule.launchMolecule 7 | 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.MutableSharedFlow 11 | import kotlinx.coroutines.flow.StateFlow 12 | 13 | import org.jetbrains.skiko.MainUIDispatcher 14 | 15 | /** 16 | * A [ViewModel] that manages a Molecule presenter, based on the official 17 | * [sample](https://github.com/cashapp/molecule/blob/trunk/sample-viewmodel). 18 | */ 19 | abstract class MoleculeViewModel : ViewModel() { 20 | /** 21 | * The [CoroutineScope] used to [launch][launchMolecule] molecule. 22 | */ 23 | private val moleculeScope = CoroutineScope(scope.coroutineContext + MainUIDispatcher) 24 | 25 | /** 26 | * A flow of UI events to handle. The capacity is large enough to handle simultaneous UI events, but small enough to 27 | * surface issues if they get backed up for some reason. 28 | */ 29 | private val events = MutableSharedFlow(extraBufferCapacity = 20) 30 | 31 | /** 32 | * A [StateFlow] that emits new [MODEL]s on updates. 33 | */ 34 | val model: StateFlow by lazy(LazyThreadSafetyMode.NONE) { 35 | moleculeScope.launchMolecule(mode = RecompositionMode.Immediate) { 36 | composeModel(events) 37 | } 38 | } 39 | 40 | /** 41 | * Handle the provided [event]. This might result in a new [model] being emitted. Events will be emitted to the 42 | * Molecule presenter function in [composeModel]. 43 | */ 44 | fun take(event: EVENT) { 45 | if (!events.tryEmit(event)) { 46 | error("Event buffer overflow.") 47 | } 48 | } 49 | 50 | /** 51 | * Implementations of this function are supposed to call the Molecule presenter function. This function gets 52 | * [launched][launchMolecule] in Molecule and recompositions will cause a new [model] to be emitted. 53 | */ 54 | @Composable 55 | protected abstract fun composeModel(events: Flow): MODEL 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/lifecycle/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.lifecycle 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.MainCoroutineDispatcher 6 | import kotlinx.coroutines.SupervisorJob 7 | import kotlinx.coroutines.cancel 8 | 9 | /** 10 | * The [ViewModel] manages the UI state, similar to view models in Android. It is supposed to exist outside the Compose 11 | * scope so that it can preserve state independent of recompositions. 12 | */ 13 | open class ViewModel { 14 | /** 15 | * A [CoroutineScope] similar to the one used in Android view models. It uses a [SupervisorJob] to ensure that jobs 16 | * in this scope can fail independently of each other, combined with an 17 | * [immediate][MainCoroutineDispatcher.immediate] dispatcher. 18 | */ 19 | val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) 20 | 21 | /** 22 | * Close this [ViewModel] by cancelling its coroutine [scope]. After calling this function this [ViewModel] should 23 | * not be used anymore. 24 | */ 25 | fun close() { 26 | scope.cancel() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/model/DependencyReference.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.model 2 | 3 | import org.ossreviewtoolkit.model.Identifier 4 | 5 | data class DependencyReference( 6 | val project: Identifier, 7 | val isExcluded: Boolean, 8 | val scopes: List 9 | ) 10 | 11 | data class ScopeReference( 12 | val scope: String, 13 | val isExcluded: Boolean 14 | ) 15 | -------------------------------------------------------------------------------- /src/main/kotlin/model/FilterData.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.model 2 | 3 | data class FilterData( 4 | val options: List = emptyList(), 5 | val selectedItem: ITEM? = null 6 | ) { 7 | /** 8 | * Return a new [FilterData] instance with the provided options and the currently [selectedItem]. If the provided 9 | * [options] do not contain the [selectedItem] it is set to null. 10 | */ 11 | fun updateOptions(options: List) = FilterData(options, selectedItem.takeIf { it in options }) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/model/OrtModelInfo.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.model 2 | 3 | /** 4 | * A holder for a summary of a loaded ORT result file to be used in the overview of loaded files. 5 | */ 6 | data class OrtModelInfo( 7 | /** A name for the ORT result, derived from the ORT result file and its content. */ 8 | val name: String, 9 | 10 | /** The absolute path of the loaded ORT result file. */ 11 | val filePath: String, 12 | 13 | /** The size of the loaded ORT result file. */ 14 | val fileSize: Long, 15 | 16 | /** The count of projects by package manager. */ 17 | val projectsByPackageManager: Map 18 | ) 19 | -------------------------------------------------------------------------------- /src/main/kotlin/model/ResolutionStatus.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.model 2 | 3 | enum class ResolutionStatus { 4 | RESOLVED, 5 | UNRESOLVED 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/model/ResolvedIssue.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.model 2 | 3 | import java.time.Instant 4 | 5 | import org.ossreviewtoolkit.model.Identifier 6 | import org.ossreviewtoolkit.model.Issue 7 | import org.ossreviewtoolkit.model.Severity 8 | import org.ossreviewtoolkit.model.config.IssueResolution 9 | 10 | data class ResolvedIssue( 11 | val id: Identifier, 12 | val tool: Tool, 13 | val resolutions: List, 14 | val timestamp: Instant, 15 | val source: String, 16 | val message: String, 17 | val severity: Severity = Severity.ERROR 18 | ) { 19 | constructor(id: Identifier, tool: Tool, resolutions: List, issue: Issue) : this( 20 | id, 21 | tool, 22 | resolutions, 23 | issue.timestamp, 24 | issue.source, 25 | issue.message, 26 | issue.severity 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/model/ResolvedRuleViolation.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.model 2 | 3 | import org.ossreviewtoolkit.model.Identifier 4 | import org.ossreviewtoolkit.model.LicenseSource 5 | import org.ossreviewtoolkit.model.RuleViolation 6 | import org.ossreviewtoolkit.model.Severity 7 | import org.ossreviewtoolkit.model.config.RuleViolationResolution 8 | import org.ossreviewtoolkit.utils.spdx.SpdxSingleLicenseExpression 9 | 10 | data class ResolvedRuleViolation( 11 | val pkg: Identifier?, 12 | val rule: String, 13 | val license: SpdxSingleLicenseExpression?, 14 | val licenseSource: LicenseSource?, 15 | val severity: Severity, 16 | val message: String, 17 | val howToFix: String, 18 | val resolutions: List, 19 | ) { 20 | constructor( 21 | resolutions: List, 22 | violation: RuleViolation 23 | ) : this( 24 | violation.pkg, 25 | violation.rule, 26 | violation.license, 27 | violation.licenseSource, 28 | violation.severity, 29 | violation.message, 30 | violation.howToFix, 31 | resolutions 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/model/ResolvedVulnerability.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.model 2 | 3 | import org.ossreviewtoolkit.model.Identifier 4 | import org.ossreviewtoolkit.model.config.VulnerabilityResolution 5 | import org.ossreviewtoolkit.model.vulnerabilities.Vulnerability 6 | import org.ossreviewtoolkit.model.vulnerabilities.VulnerabilityReference 7 | 8 | data class ResolvedVulnerability( 9 | val pkg: Identifier, 10 | val resolutions: List, 11 | val advisor: String, 12 | val id: String, 13 | val references: List 14 | ) { 15 | constructor( 16 | pkg: Identifier, 17 | resolutions: List, 18 | advisor: String, 19 | vulnerability: Vulnerability 20 | ) : this(pkg, resolutions, advisor, vulnerability.id, vulnerability.references) 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/model/Tool.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.model 2 | 3 | /** 4 | * A list of ORT tools. 5 | */ 6 | enum class Tool { 7 | ANALYZER, 8 | ADVISOR, 9 | SCANNER 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/navigation/BackstackEntry.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.navigation 2 | 3 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 4 | 5 | /** 6 | * An entry on the navigation backstack that contains the [screen] configuration and the associated [viewModel]. 7 | */ 8 | class BackstackEntry( 9 | val screen: Screen<*>, 10 | val viewModel: ViewModel 11 | ) 12 | 13 | /** 14 | * Get the [ViewModel] of type [VM]. 15 | */ 16 | inline fun BackstackEntry.viewModel(): VM = viewModel as VM 17 | -------------------------------------------------------------------------------- /src/main/kotlin/navigation/NavController.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.navigation 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 7 | 8 | /** 9 | * The [NavController] manages the navigation backstack. 10 | */ 11 | class NavController( 12 | /** 13 | * The initial [Screen] to navigate to. 14 | */ 15 | vararg initialScreens: Screen<*> 16 | ) { 17 | private val backstack = ArrayDeque() 18 | 19 | private val _backstackEntry = MutableStateFlow(null) 20 | 21 | /** 22 | * A [StateFlow] that gets updated with the [BackstackEntry] currently at the top of the backstack. 23 | */ 24 | val backstackEntry: StateFlow = _backstackEntry 25 | 26 | init { 27 | initialScreens.forEach { 28 | navigate(it, launchSingleTop = false) 29 | } 30 | } 31 | 32 | /** 33 | * Navigate to the provided [screen]. If [launchSingleTop] is true and there is already an equal screen in the 34 | * backstack, this screen will be moved to the top of the backstack instead of creating a new one. 35 | */ 36 | fun navigate(screen: Screen<*>, launchSingleTop: Boolean = true) { 37 | val reuseEntry = if (launchSingleTop) backstack.find { it.screen == screen } else null 38 | 39 | if (reuseEntry != null) { 40 | backstack.remove(reuseEntry) 41 | backstack.addLast(reuseEntry) 42 | } else { 43 | val viewModel = screen.createViewModel() 44 | val newEntry = BackstackEntry(screen, viewModel) 45 | backstack.addLast(newEntry) 46 | } 47 | 48 | _backstackEntry.value = backstack.last() 49 | } 50 | 51 | /** 52 | * Remove the topmost item from the backstack. 53 | */ 54 | fun back() { 55 | backstack.removeLastOrNull()?.let { entry -> 56 | entry.viewModel.close() 57 | _backstackEntry.value = backstack.lastOrNull() 58 | } 59 | } 60 | 61 | /** 62 | * Call this function when removing this [NavController]. This will [close][ViewModel.close] all [ViewModel]s in the 63 | * backstack to ensure that all resources are released. 64 | */ 65 | fun close() { 66 | backstack.forEach { it.viewModel.close() } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/kotlin/navigation/NavHost.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.navigation 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | 7 | @Composable 8 | fun NavHost( 9 | navigationController: NavController, 10 | content: @Composable (backstackEntry: BackstackEntry) -> Unit 11 | ) { 12 | val backstackEntry by navigationController.backstackEntry.collectAsState() 13 | 14 | backstackEntry?.let { content(it) } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/navigation/Screen.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.navigation 2 | 3 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 4 | 5 | /** 6 | * An interface of screens that can be navigated to. 7 | */ 8 | interface Screen { 9 | /** 10 | * A factory function to create the [ViewModel] for this [Screen]. 11 | */ 12 | fun createViewModel(): VM 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/state/DialogState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.state 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | 7 | import kotlinx.coroutines.CompletableDeferred 8 | 9 | class DialogState { 10 | private var onResult: CompletableDeferred? by mutableStateOf(null) 11 | 12 | val isAwaiting get() = onResult != null 13 | 14 | suspend fun awaitResult(): T { 15 | onResult = CompletableDeferred() 16 | val result = onResult!!.await() 17 | onResult = null 18 | return result 19 | } 20 | 21 | fun onResult(result: T) = onResult?.complete(result) 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val LightBlue = Color(color = 0xFF63A4FF) 6 | val Blue = Color(color = 0xFF1976D2) 7 | val DarkBlue = Color(color = 0xFF004BA0) 8 | 9 | val LightTeal = Color(color = 0xFFB2FEF7) 10 | val Teal = Color(color = 0xFF80CBC4) 11 | val DarkTeal = Color(color = 0xFF4F9A94) 12 | 13 | val DarkGray = Color(color = 0xFF121212) 14 | val Gray = Color(color = 0XFF202020) 15 | val LightGray = Color(color = 0xFF828282) 16 | val VeryLightGray = Color(color = 0xFFF2F2F2) 17 | 18 | val Red = Color(color = 0xFFB00020) 19 | val Yellow = Color(color = 0xFFFAD400) 20 | 21 | val Hint = Blue 22 | val Warning = Yellow 23 | val Error = Red 24 | -------------------------------------------------------------------------------- /src/main/kotlin/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.ExperimentalMaterialApi 5 | import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.darkColors 8 | import androidx.compose.material.lightColors 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.CompositionLocalProvider 11 | import androidx.compose.ui.graphics.Color 12 | 13 | import org.ossreviewtoolkit.workbench.model.WorkbenchTheme 14 | 15 | private val DarkColorPalette = darkColors( 16 | primary = Blue, 17 | primaryVariant = DarkBlue, 18 | secondary = Teal, 19 | secondaryVariant = DarkTeal, 20 | background = Gray, 21 | surface = DarkGray, 22 | error = Red, 23 | onPrimary = Color.White, 24 | onSecondary = Color.White, 25 | onBackground = Color.White, 26 | onSurface = Color.White, 27 | onError = Color.White 28 | ) 29 | 30 | private val LightColorPalette = lightColors( 31 | primary = LightBlue, 32 | primaryVariant = Blue, 33 | secondary = LightTeal, 34 | secondaryVariant = Teal, 35 | background = VeryLightGray, 36 | surface = Color.White, 37 | error = Red, 38 | onPrimary = Color.White, 39 | onSecondary = Color.White, 40 | onBackground = Color.Black, 41 | onSurface = Color.Black, 42 | onError = Color.White 43 | ) 44 | 45 | @OptIn(ExperimentalMaterialApi::class) 46 | @Composable 47 | fun OrtWorkbenchTheme(theme: WorkbenchTheme, content: @Composable () -> Unit) { 48 | val colors = if (theme == WorkbenchTheme.DARK || (theme == WorkbenchTheme.AUTO && isSystemInDarkTheme())) { 49 | DarkColorPalette 50 | } else { 51 | LightColorPalette 52 | } 53 | 54 | CompositionLocalProvider( 55 | LocalMinimumInteractiveComponentEnforcement provides false 56 | ) { 57 | MaterialTheme( 58 | colors = colors, 59 | typography = Typography, 60 | shapes = Shapes, 61 | content = content 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/kotlin/theme/Typography.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MagicNumber") 2 | 3 | package org.ossreviewtoolkit.workbench.theme 4 | 5 | import androidx.compose.desktop.ui.tooling.preview.Preview 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.material.Text 8 | import androidx.compose.material.Typography 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.text.TextStyle 11 | import androidx.compose.ui.text.font.FontFamily 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.unit.sp 14 | 15 | import org.ossreviewtoolkit.workbench.composables.Preview 16 | 17 | val Typography = Typography( 18 | h1 = TextStyle( 19 | fontWeight = FontWeight.Light, 20 | fontSize = 48.sp, 21 | letterSpacing = (-1.5).sp 22 | ), 23 | h2 = TextStyle( 24 | fontWeight = FontWeight.Light, 25 | fontSize = 32.sp, 26 | letterSpacing = (-0.5).sp 27 | ), 28 | h3 = TextStyle( 29 | fontWeight = FontWeight.Normal, 30 | fontSize = 24.sp, 31 | letterSpacing = 0.sp 32 | ), 33 | h4 = TextStyle( 34 | fontWeight = FontWeight.Normal, 35 | fontSize = 20.sp, 36 | letterSpacing = 0.25.sp 37 | ), 38 | h5 = TextStyle( 39 | fontWeight = FontWeight.Normal, 40 | fontSize = 16.sp, 41 | letterSpacing = 0.sp 42 | ), 43 | h6 = TextStyle( 44 | fontWeight = FontWeight.Medium, 45 | fontSize = 14.sp, 46 | letterSpacing = 0.15.sp 47 | ), 48 | subtitle1 = TextStyle( 49 | fontWeight = FontWeight.Normal, 50 | fontSize = 13.sp, 51 | letterSpacing = 0.15.sp 52 | ), 53 | subtitle2 = TextStyle( 54 | fontWeight = FontWeight.Medium, 55 | fontSize = 12.sp, 56 | letterSpacing = 0.1.sp 57 | ), 58 | body1 = TextStyle( 59 | fontWeight = FontWeight.Normal, 60 | fontSize = 13.sp, 61 | letterSpacing = 0.5.sp 62 | ), 63 | body2 = TextStyle( 64 | fontFamily = FontFamily.Default, 65 | fontWeight = FontWeight.Normal, 66 | fontSize = 12.sp, 67 | letterSpacing = 0.25.sp 68 | ), 69 | button = TextStyle( 70 | fontWeight = FontWeight.Medium, 71 | fontSize = 12.sp, 72 | letterSpacing = 1.25.sp 73 | ), 74 | caption = TextStyle( 75 | fontWeight = FontWeight.Normal, 76 | fontSize = 11.sp, 77 | letterSpacing = 0.4.sp 78 | ), 79 | overline = TextStyle( 80 | fontWeight = FontWeight.Normal, 81 | fontSize = 10.sp, 82 | letterSpacing = 1.5.sp 83 | ) 84 | ) 85 | 86 | @Composable 87 | @Preview 88 | private fun TypographyPreview() { 89 | Preview { 90 | Column { 91 | Text("Header 1", style = Typography.h1) 92 | Text("Header 2", style = Typography.h2) 93 | Text("Header 3", style = Typography.h3) 94 | Text("Header 4", style = Typography.h4) 95 | Text("Header 5", style = Typography.h5) 96 | Text("Header 6", style = Typography.h6) 97 | Text("Subtitle 1", style = Typography.subtitle1) 98 | Text("Subtitle 2", style = Typography.subtitle2) 99 | Text("Body 1", style = Typography.body1) 100 | Text("Body 2", style = Typography.body2) 101 | Text("Button", style = Typography.button) 102 | Text("Caption", style = Typography.caption) 103 | Text("Overline", style = Typography.overline) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/MainScreen.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui 2 | 3 | import org.ossreviewtoolkit.model.Identifier 4 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 5 | import org.ossreviewtoolkit.workbench.model.OrtModel 6 | import org.ossreviewtoolkit.workbench.navigation.Screen 7 | import org.ossreviewtoolkit.workbench.ui.dependencies.DependenciesViewModel 8 | import org.ossreviewtoolkit.workbench.ui.issues.IssuesViewModel 9 | import org.ossreviewtoolkit.workbench.ui.packagedetails.PackageDetailsViewModel 10 | import org.ossreviewtoolkit.workbench.ui.packages.PackagesViewModel 11 | import org.ossreviewtoolkit.workbench.ui.settings.SettingsViewModel 12 | import org.ossreviewtoolkit.workbench.ui.summary.SummaryViewModel 13 | import org.ossreviewtoolkit.workbench.ui.violations.ViolationsViewModel 14 | import org.ossreviewtoolkit.workbench.ui.vulnerabilities.VulnerabilitiesViewModel 15 | 16 | sealed class MainScreen(val name: String, val menuItem: MenuItem? = null) : Screen { 17 | data class Dependencies(private val ortModel: OrtModel) : 18 | MainScreen("Dependencies", MenuItem.DEPENDENCIES) { 19 | override fun createViewModel() = DependenciesViewModel(ortModel) 20 | } 21 | 22 | data class Issues(private val ortModel: OrtModel) : MainScreen("Issues", MenuItem.ISSUES) { 23 | override fun createViewModel() = IssuesViewModel(ortModel) 24 | } 25 | 26 | data class PackageDetails(private val ortModel: OrtModel, private val pkgId: Identifier) : 27 | MainScreen("Package Details") { 28 | override fun createViewModel() = PackageDetailsViewModel(ortModel, pkgId) 29 | } 30 | 31 | data class Packages(private val ortModel: OrtModel) : MainScreen("Packages", MenuItem.PACKAGES) { 32 | override fun createViewModel() = PackagesViewModel(ortModel) 33 | } 34 | 35 | data class Settings(private val controller: WorkbenchController) : 36 | MainScreen("Settings", MenuItem.SETTINGS) { 37 | override fun createViewModel() = SettingsViewModel(controller) 38 | } 39 | 40 | data class Summary(private val ortModel: OrtModel) : MainScreen("Summary", MenuItem.SUMMARY) { 41 | override fun createViewModel() = SummaryViewModel(ortModel) 42 | } 43 | 44 | data class RuleViolations(private val ortModel: OrtModel) : 45 | MainScreen("Rule Violations", MenuItem.RULE_VIOLATIONS) { 46 | override fun createViewModel() = ViolationsViewModel(ortModel) 47 | } 48 | 49 | data class Vulnerabilities(private val ortModel: OrtModel) : 50 | MainScreen("Vulnerabilities", MenuItem.VULNERABILITIES) { 51 | override fun createViewModel() = VulnerabilitiesViewModel(ortModel) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/Menu.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.TooltipArea 6 | import androidx.compose.foundation.TooltipPlacement 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.Column 12 | import androidx.compose.foundation.layout.Row 13 | import androidx.compose.foundation.layout.fillMaxHeight 14 | import androidx.compose.foundation.layout.fillMaxWidth 15 | import androidx.compose.foundation.layout.padding 16 | import androidx.compose.foundation.layout.width 17 | import androidx.compose.material.ContentAlpha 18 | import androidx.compose.material.Divider 19 | import androidx.compose.material.Icon 20 | import androidx.compose.material.LocalContentAlpha 21 | import androidx.compose.material.MaterialTheme 22 | import androidx.compose.material.Surface 23 | import androidx.compose.material.Switch 24 | import androidx.compose.material.Text 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.runtime.CompositionLocalProvider 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.text.font.FontWeight 30 | import androidx.compose.ui.text.style.TextAlign 31 | import androidx.compose.ui.unit.dp 32 | import androidx.compose.ui.zIndex 33 | 34 | import org.ossreviewtoolkit.utils.ort.ORT_VERSION 35 | import org.ossreviewtoolkit.workbench.composables.Preview 36 | import org.ossreviewtoolkit.workbench.composables.Tooltip 37 | import org.ossreviewtoolkit.workbench.composables.conditional 38 | import org.ossreviewtoolkit.workbench.composables.enumcase 39 | import org.ossreviewtoolkit.workbench.model.OrtApiState 40 | 41 | @OptIn(ExperimentalFoundationApi::class) 42 | @Composable 43 | fun Menu( 44 | currentItem: MenuItem?, 45 | apiState: OrtApiState, 46 | useOnlyResolvedConfiguration: Boolean, 47 | onSwitchUseOnlyResolvedConfiguration: () -> Unit, 48 | onSelectMenuItem: (MenuItem) -> Unit 49 | ) { 50 | Surface(modifier = Modifier.fillMaxHeight().width(200.dp).zIndex(zIndex = 3f), elevation = 8.dp) { 51 | Column( 52 | modifier = Modifier.padding(vertical = 20.dp) 53 | ) { 54 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 55 | MenuRow(MenuItem.SUMMARY, currentItem, apiState) { onSelectMenuItem(MenuItem.SUMMARY) } 56 | MenuRow(MenuItem.PACKAGES, currentItem, apiState) { onSelectMenuItem(MenuItem.PACKAGES) } 57 | MenuRow(MenuItem.DEPENDENCIES, currentItem, apiState) { onSelectMenuItem(MenuItem.DEPENDENCIES) } 58 | MenuRow(MenuItem.ISSUES, currentItem, apiState) { onSelectMenuItem(MenuItem.ISSUES) } 59 | MenuRow(MenuItem.RULE_VIOLATIONS, currentItem, apiState) { onSelectMenuItem(MenuItem.RULE_VIOLATIONS) } 60 | MenuRow(MenuItem.VULNERABILITIES, currentItem, apiState) { onSelectMenuItem(MenuItem.VULNERABILITIES) } 61 | 62 | Box(modifier = Modifier.weight(1f)) 63 | 64 | Divider() 65 | 66 | Row( 67 | modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 20.dp), 68 | verticalAlignment = Alignment.CenterVertically 69 | ) { 70 | Text( 71 | text = "Use only resolved configuration", 72 | style = MaterialTheme.typography.h6, 73 | modifier = Modifier.weight(1f) 74 | ) 75 | 76 | TooltipArea( 77 | tooltipPlacement = TooltipPlacement.ComponentRect( 78 | anchor = Alignment.TopCenter, 79 | alignment = Alignment.TopCenter 80 | ), 81 | tooltip = { 82 | Tooltip( 83 | """ 84 | Use only the resolved configuration from the ORT result and ignore the local ORT 85 | config directory. This can be disabled to test local configuration changes, but be 86 | aware that this can lead to inconsistent results when the ORT result was created 87 | with different configuration. 88 | Please note that this setting currently only affects package configurations and 89 | resolutions. 90 | """.trimIndent() 91 | ) 92 | } 93 | ) { 94 | Switch( 95 | checked = useOnlyResolvedConfiguration, 96 | onCheckedChange = { onSwitchUseOnlyResolvedConfiguration() } 97 | ) 98 | } 99 | } 100 | 101 | Divider() 102 | 103 | MenuRow(MenuItem.SETTINGS, currentItem, apiState) { onSelectMenuItem(MenuItem.SETTINGS) } 104 | 105 | Divider() 106 | 107 | Text( 108 | "ORT version $ORT_VERSION", 109 | modifier = Modifier.fillMaxWidth().padding(top = 15.dp), 110 | textAlign = TextAlign.Center, 111 | style = MaterialTheme.typography.caption 112 | ) 113 | } 114 | } 115 | } 116 | } 117 | 118 | @Composable 119 | fun MenuRow(item: MenuItem, currentItem: MenuItem?, apiState: OrtApiState, onSelect: () -> Unit) { 120 | val isEnabled = item == MenuItem.SUMMARY || apiState == OrtApiState.READY 121 | 122 | if (isEnabled) { 123 | val isCurrent = item == currentItem 124 | 125 | Row( 126 | modifier = Modifier.clickable { onSelect() } 127 | .conditional(isCurrent) { background(MaterialTheme.colors.background) } 128 | .fillMaxWidth() 129 | .padding(vertical = 8.dp, horizontal = 20.dp), 130 | horizontalArrangement = Arrangement.spacedBy(5.dp), 131 | verticalAlignment = Alignment.CenterVertically, 132 | ) { 133 | Icon(item.icon, item.name) 134 | 135 | Text( 136 | text = item.name.enumcase(), 137 | style = MaterialTheme.typography.h6, 138 | fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal 139 | ) 140 | } 141 | } 142 | } 143 | 144 | @Composable 145 | @Preview 146 | private fun MenuPreview() { 147 | Preview { 148 | Menu(currentItem = MenuItem.SUMMARY, OrtApiState.READY, true, {}, {}) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/MenuItem.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.* 5 | import androidx.compose.ui.graphics.vector.ImageVector 6 | 7 | enum class MenuItem(val icon: ImageVector) { 8 | SUMMARY(Icons.Default.Assessment), 9 | PACKAGES(Icons.Default.Inventory), 10 | DEPENDENCIES(Icons.Default.AccountTree), 11 | ISSUES(Icons.Default.BugReport), 12 | RULE_VIOLATIONS(Icons.Default.Gavel), 13 | VULNERABILITIES(Icons.Default.LockOpen), 14 | SETTINGS(Icons.Default.Settings) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/TopBar.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.foundation.layout.width 12 | import androidx.compose.material.Divider 13 | import androidx.compose.material.DropdownMenu 14 | import androidx.compose.material.DropdownMenuItem 15 | import androidx.compose.material.Icon 16 | import androidx.compose.material.IconButton 17 | import androidx.compose.material.LocalContentColor 18 | import androidx.compose.material.MaterialTheme 19 | import androidx.compose.material.Text 20 | import androidx.compose.material.TextButton 21 | import androidx.compose.material.TopAppBar 22 | import androidx.compose.material.icons.Icons 23 | import androidx.compose.material.icons.filled.* 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.CompositionLocalProvider 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.runtime.mutableStateOf 28 | import androidx.compose.runtime.saveable.rememberSaveable 29 | import androidx.compose.runtime.setValue 30 | import androidx.compose.ui.Alignment 31 | import androidx.compose.ui.Modifier 32 | import androidx.compose.ui.layout.ContentScale 33 | import androidx.compose.ui.text.font.FontStyle 34 | import androidx.compose.ui.text.font.FontWeight 35 | import androidx.compose.ui.text.style.TextOverflow 36 | import androidx.compose.ui.unit.dp 37 | import androidx.compose.ui.zIndex 38 | 39 | import org.jetbrains.compose.resources.imageResource 40 | 41 | import org.ossreviewtoolkit.workbench.composables.conditional 42 | import org.ossreviewtoolkit.workbench.model.OrtModelInfo 43 | import org.ossreviewtoolkit.workbench.ort_workbench.generated.resources.Res 44 | import org.ossreviewtoolkit.workbench.ort_workbench.generated.resources.ort_white 45 | 46 | @Composable 47 | fun TopBar( 48 | selectedOrtModel: OrtModelInfo?, 49 | ortModelInfos: List, 50 | onLoadFile: () -> Unit, 51 | onSelectModel: (OrtModelInfo) -> Unit, 52 | onCloseModel: (OrtModelInfo) -> Unit 53 | ) { 54 | TopAppBar(modifier = Modifier.zIndex(zIndex = 5f), backgroundColor = MaterialTheme.colors.primaryVariant) { 55 | Image( 56 | imageResource(Res.drawable.ort_white), 57 | contentDescription = "OSS Review Toolkit", 58 | contentScale = ContentScale.FillHeight, 59 | modifier = Modifier.padding(vertical = 10.dp).width(200.dp) 60 | ) 61 | 62 | Box(modifier = Modifier.weight(1f)) 63 | 64 | OrtModelSelector( 65 | selectedOrtModel, 66 | ortModelInfos, 67 | onLoadFile = onLoadFile, 68 | onSelectModel = onSelectModel, 69 | onCloseModel = onCloseModel 70 | ) 71 | } 72 | } 73 | 74 | @Composable 75 | fun OrtModelSelector( 76 | selectedOrtModel: OrtModelInfo?, 77 | ortModelInfos: List, 78 | onLoadFile: () -> Unit, 79 | onSelectModel: (OrtModelInfo) -> Unit, 80 | onCloseModel: (OrtModelInfo) -> Unit 81 | ) { 82 | var expanded by rememberSaveable { mutableStateOf(false) } 83 | 84 | Box { 85 | TextButton(onClick = { expanded = !expanded }) { 86 | CompositionLocalProvider(LocalContentColor provides MaterialTheme.colors.onPrimary) { 87 | Text(selectedOrtModel?.name ?: "") 88 | 89 | val image = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore 90 | Icon(image, if (expanded) "expand" else "collapse") 91 | } 92 | } 93 | 94 | DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, modifier = Modifier.width(600.dp)) { 95 | DropdownMenuItem(onClick = { 96 | expanded = false 97 | onLoadFile() 98 | }) { 99 | Text("Open ORT result file") 100 | } 101 | 102 | ortModelInfos.forEach { modelInfo -> 103 | Divider() 104 | 105 | DropdownMenuItem( 106 | onClick = { 107 | expanded = false 108 | onSelectModel(modelInfo) 109 | }, 110 | modifier = Modifier.fillMaxWidth() 111 | .conditional(modelInfo == selectedOrtModel) { background(MaterialTheme.colors.background) } 112 | ) { 113 | val projects = modelInfo.projectsByPackageManager.entries.joinToString { "${it.value} ${it.key}" } 114 | 115 | Row(modifier = Modifier.padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { 116 | Column(modifier = Modifier.weight(1f)) { 117 | Text(modelInfo.name, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis) 118 | 119 | Text(modelInfo.filePath, overflow = TextOverflow.Ellipsis) 120 | 121 | Text("Projects: $projects", fontStyle = FontStyle.Italic) 122 | } 123 | 124 | IconButton(onClick = { onCloseModel(modelInfo) }) { 125 | Icon( 126 | Icons.Default.Close, 127 | contentDescription = "close", 128 | modifier = Modifier.size(16.dp) 129 | ) 130 | } 131 | } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/WorkbenchController.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui 2 | 3 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory 4 | import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator 5 | import com.fasterxml.jackson.dataformat.yaml.YAMLMapper 6 | import com.fasterxml.jackson.module.kotlin.readValue 7 | import com.fasterxml.jackson.module.kotlin.registerKotlinModule 8 | 9 | import java.io.File 10 | import java.nio.file.Path 11 | 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.NonCancellable 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.StateFlow 17 | import kotlinx.coroutines.launch 18 | import kotlinx.coroutines.sync.Mutex 19 | import kotlinx.coroutines.sync.withLock 20 | import kotlinx.coroutines.withContext 21 | 22 | import org.ossreviewtoolkit.utils.common.safeMkdirs 23 | import org.ossreviewtoolkit.utils.ort.ortDataDirectory 24 | import org.ossreviewtoolkit.workbench.model.OrtModel 25 | import org.ossreviewtoolkit.workbench.model.OrtModelInfo 26 | import org.ossreviewtoolkit.workbench.model.WorkbenchSettings 27 | import org.ossreviewtoolkit.workbench.state.DialogState 28 | 29 | private const val ORT_WORKBENCH_CONFIG_DIRNAME = "workbench" 30 | private const val ORT_WORKBENCH_CONFIG_FILENAME = "settings.yml" 31 | 32 | class WorkbenchController { 33 | companion object { 34 | private val settingsMapper = 35 | YAMLMapper(YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)).apply { 36 | registerKotlinModule() 37 | } 38 | } 39 | 40 | private val scope = CoroutineScope(Dispatchers.Default) 41 | private val mutex = Mutex() 42 | 43 | private val settingsFile = 44 | ortDataDirectory.resolve(ORT_WORKBENCH_CONFIG_DIRNAME).resolve(ORT_WORKBENCH_CONFIG_FILENAME) 45 | 46 | private val _settings = MutableStateFlow(WorkbenchSettings.default()) 47 | val settings: StateFlow = _settings 48 | 49 | private val _error = MutableStateFlow(null) 50 | val error: StateFlow = _error 51 | 52 | private val _ortModels = MutableStateFlow(emptyList()) 53 | val ortModels: StateFlow> = _ortModels 54 | 55 | private val _ortModel = MutableStateFlow(null) 56 | val ortModel: StateFlow = _ortModel 57 | 58 | val openResultDialog = DialogState() 59 | 60 | init { 61 | scope.launch { loadSettings() } 62 | } 63 | 64 | suspend fun openOrtResult(file: File) { 65 | val newOrtModel = OrtModel(settings) 66 | val matchingModel = ortModels.value.find { it.info.value?.filePath == file.absolutePath } 67 | 68 | if (matchingModel != null) { 69 | // If the file was already loaded do not load it again but switch the selected model. 70 | _ortModel.value = matchingModel 71 | } else { 72 | _ortModels.value = mutex.withLock { _ortModels.value + newOrtModel } 73 | _ortModel.value = newOrtModel 74 | newOrtModel.loadOrtResult(file) 75 | } 76 | } 77 | 78 | suspend fun updateSettings(settings: WorkbenchSettings) { 79 | mutex.withLock { 80 | withContext(Dispatchers.IO + NonCancellable) { 81 | _settings.value = settings 82 | saveSettings(settings) 83 | } 84 | } 85 | } 86 | 87 | private fun loadSettings() { 88 | val settings = 89 | settingsFile.takeIf { it.isFile }?.let { settingsMapper.readValue(it) } ?: WorkbenchSettings.default() 90 | 91 | _settings.value = settings 92 | 93 | if (!settingsFile.exists()) { 94 | saveSettings(settings) 95 | } 96 | } 97 | 98 | private fun saveSettings(settings: WorkbenchSettings) { 99 | runCatching { 100 | settingsFile.parentFile.safeMkdirs() 101 | settingsMapper.writeValue(settingsFile, settings) 102 | }.onFailure { 103 | _error.value = "Could not save settings at ${settingsFile.absolutePath}: ${it.message}" 104 | } 105 | } 106 | 107 | fun selectOrtModel(ortModelInfo: OrtModelInfo) { 108 | scope.launch { 109 | ortModels.value.find { it.info.value == ortModelInfo }?.let { 110 | _ortModel.value = it 111 | } 112 | } 113 | } 114 | 115 | fun closeOrtModel(ortModelInfo: OrtModelInfo) { 116 | scope.launch { 117 | ortModels.value.find { it.info.value == ortModelInfo }?.let { 118 | _ortModels.value = _ortModels.value - it 119 | if (ortModel.value?.info?.value == ortModelInfo) { 120 | _ortModel.value = _ortModels.value.firstOrNull() 121 | } 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/dependencies/DependenciesState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.dependencies 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateListOf 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | 8 | import org.ossreviewtoolkit.model.CuratedPackage 9 | import org.ossreviewtoolkit.model.Identifier 10 | import org.ossreviewtoolkit.model.Issue 11 | import org.ossreviewtoolkit.model.Package 12 | import org.ossreviewtoolkit.model.PackageLinkage 13 | import org.ossreviewtoolkit.model.Project 14 | import org.ossreviewtoolkit.model.Scope 15 | import org.ossreviewtoolkit.model.licenses.ResolvedLicenseInfo 16 | import org.ossreviewtoolkit.workbench.composables.tree.TreeNode 17 | import org.ossreviewtoolkit.workbench.composables.tree.TreeState 18 | 19 | sealed interface DependenciesState { 20 | data object Loading : DependenciesState 21 | 22 | data class Error(val message: String) : DependenciesState 23 | 24 | class Success(rootNodes: List>) : DependenciesState { 25 | var search by mutableStateOf("") 26 | private set 27 | 28 | var searchCurrentHit by mutableStateOf(-1) 29 | private set 30 | 31 | private val _searchHits = mutableStateListOf() 32 | val searchHits: List = _searchHits 33 | 34 | val treeState: TreeState = TreeState(rootNodes) 35 | 36 | fun selectNextSearchHit() { 37 | if (searchHits.isEmpty()) searchCurrentHit = -1 else searchCurrentHit++ 38 | 39 | if (searchCurrentHit >= searchHits.size) searchCurrentHit = 0 40 | 41 | if (searchCurrentHit > -1) { 42 | treeState.expandItem(searchHits[searchCurrentHit]) 43 | treeState.selectItem(searchHits[searchCurrentHit], isAutoSelected = true) 44 | } 45 | } 46 | 47 | fun selectPreviousSearchHit() { 48 | if (searchHits.isEmpty()) searchCurrentHit = -1 else searchCurrentHit-- 49 | 50 | if (searchCurrentHit < 0) searchCurrentHit = searchHits.size - 1 51 | 52 | if (searchCurrentHit > -1) { 53 | treeState.expandItem(searchCurrentHit) 54 | treeState.selectItem(searchHits[searchCurrentHit], isAutoSelected = true) 55 | } 56 | } 57 | 58 | fun updateSearch(search: String) { 59 | this.search = search 60 | searchCurrentHit = 0 61 | _searchHits.clear() 62 | 63 | if (search.isNotBlank()) { 64 | val trimmedSearch = search.trim() 65 | 66 | _searchHits += treeState.items.mapIndexedNotNull { index, item -> 67 | if (item.node.value.name.contains(trimmedSearch)) index else null 68 | } 69 | } 70 | 71 | searchCurrentHit = if (_searchHits.isEmpty()) -1 else 0 72 | 73 | if (searchCurrentHit >= 0) { 74 | val item = treeState.items[searchHits[searchCurrentHit]] 75 | treeState.expandItem(item.index) 76 | treeState.selectItem(item, isAutoSelected = true) 77 | } 78 | } 79 | } 80 | } 81 | 82 | sealed class DependencyTreeItem { 83 | abstract val name: String 84 | } 85 | 86 | class DependencyTreeProject( 87 | val project: Project, 88 | val linkage: PackageLinkage, 89 | val issues: List, 90 | val resolvedLicense: ResolvedLicenseInfo 91 | ) : DependencyTreeItem() { 92 | override val name = project.id.toCoordinates() 93 | } 94 | 95 | class DependencyTreeScope(val project: Project, val scope: Scope) : DependencyTreeItem() { 96 | override val name = scope.name 97 | } 98 | 99 | class DependencyTreePackage( 100 | val id: Identifier, 101 | val uncuratedPackage: Package, 102 | val curatedPackage: CuratedPackage, 103 | val linkage: PackageLinkage, 104 | val issues: List, 105 | val resolvedLicense: ResolvedLicenseInfo? 106 | ) : DependencyTreeItem() { 107 | override val name = id.toCoordinates() 108 | } 109 | 110 | class DependencyTreeError( 111 | val id: Identifier, 112 | val message: String 113 | ) : DependencyTreeItem() { 114 | override val name = id.toCoordinates() 115 | } 116 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/dependencies/DependenciesViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.dependencies 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.launch 8 | import kotlinx.coroutines.withContext 9 | 10 | import org.ossreviewtoolkit.model.PackageLinkage 11 | import org.ossreviewtoolkit.model.PackageReference 12 | import org.ossreviewtoolkit.model.Project 13 | import org.ossreviewtoolkit.model.Scope 14 | import org.ossreviewtoolkit.workbench.composables.tree.TreeNode 15 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 16 | import org.ossreviewtoolkit.workbench.model.OrtApi 17 | import org.ossreviewtoolkit.workbench.model.OrtModel 18 | 19 | class DependenciesViewModel(private val ortModel: OrtModel) : ViewModel() { 20 | private val defaultScope = CoroutineScope(Dispatchers.Default) 21 | 22 | private val _state = MutableStateFlow(DependenciesState.Loading) 23 | val state: StateFlow = _state 24 | 25 | init { 26 | defaultScope.launch { 27 | ortModel.api.collect { api -> 28 | val dependencyNodes = createDependencyNodes(api) 29 | 30 | // Switch back to the UI scope because DependenciesState contains mutable states which must be created 31 | // in the UI scope. 32 | withContext(scope.coroutineContext) { 33 | val oldState = state.value 34 | if (oldState is DependenciesState.Success) { 35 | oldState.treeState.updateNodes(dependencyNodes) 36 | } else { 37 | _state.value = DependenciesState.Success(dependencyNodes) 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | private fun createDependencyNodes(api: OrtApi): List> { 45 | val resolvedLicenses = api.getProjectAndPackageIdentifiers().associateWith { 46 | api.getResolvedLicense(it).filterExcluded() 47 | } 48 | 49 | fun PackageReference.toTreeNode(): TreeNode { 50 | val children = dependencies.map { it.toTreeNode() } 51 | 52 | return api.getProject(id)?.let { project -> 53 | TreeNode( 54 | value = DependencyTreeProject( 55 | project = project, 56 | linkage = linkage, 57 | issues = issues, 58 | resolvedLicense = resolvedLicenses.getValue(id) 59 | ), 60 | key = project.id.toCoordinates(), 61 | children = children 62 | ) 63 | } ?: api.getCuratedPackage(id)?.let { curatedPackage -> 64 | val uncuratedPackage = checkNotNull(api.getUncuratedPackageOrProject(id)) { 65 | "There must be an uncurated package if there is a curated one." 66 | } 67 | 68 | TreeNode( 69 | value = DependencyTreePackage( 70 | id = id, 71 | uncuratedPackage = uncuratedPackage, 72 | curatedPackage = curatedPackage, 73 | linkage = linkage, 74 | issues = issues, 75 | resolvedLicense = resolvedLicenses.getValue(id) 76 | ), 77 | key = id.toCoordinates(), 78 | children = children 79 | ) 80 | } ?: TreeNode( 81 | value = DependencyTreeError( 82 | id = id, 83 | message = "Could not find package or project for id '${id.toCoordinates()}'." 84 | ), 85 | key = id.toCoordinates() 86 | ) 87 | } 88 | 89 | fun Scope.toTreeNode(project: Project): TreeNode { 90 | val children = dependencies.map { it.toTreeNode() } 91 | 92 | return TreeNode( 93 | value = DependencyTreeScope( 94 | project = project, 95 | scope = this 96 | ), 97 | key = name, 98 | children = children 99 | ) 100 | } 101 | 102 | fun Project.toTreeNode(): TreeNode { 103 | val children = scopes.map { it.toTreeNode(this) } 104 | 105 | return TreeNode( 106 | value = DependencyTreeProject( 107 | project = this, 108 | linkage = PackageLinkage.PROJECT_STATIC, 109 | issues = emptyList(), 110 | resolvedLicense = resolvedLicenses.getValue(id) 111 | ), 112 | key = id.toCoordinates(), 113 | children = children 114 | ) 115 | } 116 | 117 | return api.getProjects().map { it.toTreeNode() } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/issues/Issues.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.issues 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material.Card 11 | import androidx.compose.material.Divider 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.text.font.FontFamily 19 | import androidx.compose.ui.text.font.FontWeight 20 | import androidx.compose.ui.unit.dp 21 | 22 | import java.time.Instant 23 | 24 | import org.ossreviewtoolkit.model.Identifier 25 | import org.ossreviewtoolkit.model.Severity 26 | import org.ossreviewtoolkit.model.config.IssueResolution 27 | import org.ossreviewtoolkit.model.config.IssueResolutionReason 28 | import org.ossreviewtoolkit.utils.common.titlecase 29 | import org.ossreviewtoolkit.workbench.composables.CircularProgressBox 30 | import org.ossreviewtoolkit.workbench.composables.ExpandableText 31 | import org.ossreviewtoolkit.workbench.composables.FilterButton 32 | import org.ossreviewtoolkit.workbench.composables.FilterPanel 33 | import org.ossreviewtoolkit.workbench.composables.ListScreenContent 34 | import org.ossreviewtoolkit.workbench.composables.ListScreenList 35 | import org.ossreviewtoolkit.workbench.composables.Preview 36 | import org.ossreviewtoolkit.workbench.composables.SeverityIcon 37 | import org.ossreviewtoolkit.workbench.model.ResolutionStatus 38 | import org.ossreviewtoolkit.workbench.model.ResolvedIssue 39 | import org.ossreviewtoolkit.workbench.model.Tool 40 | 41 | @Composable 42 | fun Issues(viewModel: IssuesViewModel) { 43 | val stateState = viewModel.state.collectAsState() 44 | 45 | when (val state = stateState.value) { 46 | is IssuesState.Loading -> CircularProgressBox() 47 | 48 | is IssuesState.Success -> { 49 | ListScreenContent( 50 | filterText = state.filter.text, 51 | onUpdateFilterText = viewModel::updateTextFilter, 52 | list = { 53 | ListScreenList( 54 | items = state.issues, 55 | itemsEmptyText = "No issues found.", 56 | item = { IssueCard(it) } 57 | ) 58 | }, 59 | filterPanel = { showFilterPanel -> 60 | IssuesFilterPanel( 61 | visible = showFilterPanel, 62 | state = state, 63 | onUpdateIdentifierFilter = viewModel::updateIdentifierFilter, 64 | onUpdateResolutionStatusFilter = viewModel::updateResolutionStatusFilter, 65 | onUpdateSeverityFilter = viewModel::updateSeverityFilter, 66 | onUpdateSourceFilter = viewModel::updateSourceFilter, 67 | onUpdateToolFilter = viewModel::updateToolFilter 68 | ) 69 | } 70 | ) 71 | } 72 | } 73 | } 74 | 75 | @Composable 76 | fun IssueCard(issue: ResolvedIssue) { 77 | Card(modifier = Modifier.fillMaxWidth(), elevation = 8.dp) { 78 | Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { 79 | Row( 80 | verticalAlignment = Alignment.CenterVertically, 81 | horizontalArrangement = Arrangement.spacedBy(5.dp) 82 | ) { 83 | SeverityIcon(issue.severity, resolved = issue.resolutions.isNotEmpty()) 84 | Text(issue.id.toCoordinates()) 85 | Box(modifier = Modifier.weight(1f)) 86 | Text("Source: ${issue.source}") 87 | } 88 | 89 | if (issue.resolutions.isNotEmpty()) Divider() 90 | 91 | issue.resolutions.forEach { resolution -> 92 | Text("Resolved: ${resolution.reason}", fontWeight = FontWeight.Bold) 93 | ExpandableText(resolution.comment) 94 | Divider() 95 | } 96 | 97 | ExpandableText(issue.message, fontFamily = FontFamily.Monospace) 98 | } 99 | } 100 | } 101 | 102 | @Composable 103 | @Preview 104 | private fun IssueCardPreview() { 105 | val issue = ResolvedIssue( 106 | id = Identifier("Maven:org.example:package:1.0.0-beta"), 107 | tool = Tool.ANALYZER, 108 | resolutions = listOf( 109 | IssueResolution("", IssueResolutionReason.BUILD_TOOL_ISSUE, "Some long explanation. ".repeat(20)), 110 | IssueResolution("", IssueResolutionReason.BUILD_TOOL_ISSUE, "Some long explanation. ".repeat(20)) 111 | ), 112 | timestamp = Instant.now(), 113 | source = "Maven", 114 | message = "Some long error message. ".repeat(20), 115 | severity = Severity.WARNING 116 | ) 117 | 118 | Preview { 119 | IssueCard(issue) 120 | } 121 | } 122 | 123 | @Composable 124 | fun IssuesFilterPanel( 125 | visible: Boolean, 126 | state: IssuesState.Success, 127 | onUpdateIdentifierFilter: (identifier: Identifier?) -> Unit, 128 | onUpdateResolutionStatusFilter: (status: ResolutionStatus?) -> Unit, 129 | onUpdateSeverityFilter: (severity: Severity?) -> Unit, 130 | onUpdateSourceFilter: (source: String?) -> Unit, 131 | onUpdateToolFilter: (tool: Tool?) -> Unit 132 | ) { 133 | FilterPanel(visible = visible) { 134 | FilterButton( 135 | data = state.filter.severity, 136 | label = "Severity", 137 | onFilterChange = onUpdateSeverityFilter, 138 | convert = { it.name.titlecase() } 139 | ) 140 | 141 | FilterButton(data = state.filter.source, label = "Source", onFilterChange = onUpdateSourceFilter) 142 | 143 | FilterButton( 144 | data = state.filter.tool, 145 | label = "Tool", 146 | onFilterChange = onUpdateToolFilter, 147 | convert = { it.name.titlecase() } 148 | ) 149 | 150 | FilterButton( 151 | data = state.filter.identifier, 152 | label = "Package", 153 | onFilterChange = onUpdateIdentifierFilter, 154 | convert = { it.toCoordinates() } 155 | ) 156 | 157 | FilterButton( 158 | data = state.filter.resolutionStatus, 159 | label = "Resolution", 160 | onFilterChange = onUpdateResolutionStatusFilter, 161 | convert = { it.name.titlecase() } 162 | ) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/issues/IssuesState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.issues 2 | 3 | import org.ossreviewtoolkit.workbench.model.ResolvedIssue 4 | 5 | sealed interface IssuesState { 6 | data object Loading : IssuesState 7 | 8 | data class Success( 9 | val issues: List, 10 | val filter: IssuesFilter 11 | ) : IssuesState 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/issues/IssuesViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.issues 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.combine 8 | import kotlinx.coroutines.launch 9 | 10 | import org.ossreviewtoolkit.model.Identifier 11 | import org.ossreviewtoolkit.model.Severity 12 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 13 | import org.ossreviewtoolkit.workbench.model.FilterData 14 | import org.ossreviewtoolkit.workbench.model.OrtModel 15 | import org.ossreviewtoolkit.workbench.model.ResolutionStatus 16 | import org.ossreviewtoolkit.workbench.model.ResolvedIssue 17 | import org.ossreviewtoolkit.workbench.model.Tool 18 | import org.ossreviewtoolkit.workbench.utils.matchResolutionStatus 19 | import org.ossreviewtoolkit.workbench.utils.matchString 20 | import org.ossreviewtoolkit.workbench.utils.matchStringContains 21 | import org.ossreviewtoolkit.workbench.utils.matchValue 22 | 23 | class IssuesViewModel(private val ortModel: OrtModel) : ViewModel() { 24 | private val defaultScope = CoroutineScope(Dispatchers.Default) 25 | 26 | private val issues = MutableStateFlow?>(null) 27 | private val filter = MutableStateFlow(IssuesFilter()) 28 | 29 | private val _state = MutableStateFlow(IssuesState.Loading) 30 | val state: StateFlow = _state 31 | 32 | init { 33 | defaultScope.launch { ortModel.api.collect { issues.value = it.getResolvedIssues() } } 34 | 35 | scope.launch { issues.collect { if (it != null) initFilter(it) } } 36 | 37 | scope.launch { 38 | combine(filter, issues) { filter, issues -> 39 | if (issues != null) { 40 | IssuesState.Success( 41 | issues = issues.filter(filter::check), 42 | filter = filter 43 | ) 44 | } else { 45 | IssuesState.Loading 46 | } 47 | }.collect { _state.value = it } 48 | } 49 | } 50 | 51 | private fun initFilter(issues: List) { 52 | filter.value = filter.value.updateOptions( 53 | identifiers = issues.mapTo(sortedSetOf()) { it.id }.toList(), 54 | sources = issues.mapTo(sortedSetOf()) { it.source }.toList() 55 | ) 56 | } 57 | 58 | fun updateTextFilter(text: String) { 59 | filter.value = filter.value.copy(text = text) 60 | } 61 | 62 | fun updateIdentifierFilter(identifier: Identifier?) { 63 | filter.value = filter.value.copy(identifier = filter.value.identifier.copy(selectedItem = identifier)) 64 | } 65 | 66 | fun updateResolutionStatusFilter(resolutionStatus: ResolutionStatus?) { 67 | filter.value = filter.value.copy( 68 | resolutionStatus = filter.value.resolutionStatus.copy(selectedItem = resolutionStatus) 69 | ) 70 | } 71 | 72 | fun updateSeverityFilter(severity: Severity?) { 73 | filter.value = filter.value.copy(severity = filter.value.severity.copy(selectedItem = severity)) 74 | } 75 | 76 | fun updateSourceFilter(source: String?) { 77 | filter.value = filter.value.copy(source = filter.value.source.copy(selectedItem = source)) 78 | } 79 | 80 | fun updateToolFilter(tool: Tool?) { 81 | filter.value = filter.value.copy(tool = filter.value.tool.copy(selectedItem = tool)) 82 | } 83 | } 84 | 85 | data class IssuesFilter( 86 | val identifier: FilterData = FilterData(), 87 | val resolutionStatus: FilterData = FilterData(), 88 | val severity: FilterData = FilterData(), 89 | val source: FilterData = FilterData(), 90 | val text: String = "", 91 | val tool: FilterData = FilterData() 92 | ) { 93 | fun check(issue: ResolvedIssue) = 94 | matchValue(identifier.selectedItem, issue.id) 95 | && matchResolutionStatus(resolutionStatus.selectedItem, issue.resolutions) 96 | && matchValue(severity.selectedItem, issue.severity) 97 | && matchString(source.selectedItem, issue.source) 98 | && matchStringContains(text, issue.id.toCoordinates(), issue.source, issue.message) 99 | && matchValue(tool.selectedItem, issue.tool) 100 | 101 | @OptIn(ExperimentalStdlibApi::class) 102 | fun updateOptions( 103 | identifiers: List, 104 | sources: List 105 | ) = IssuesFilter( 106 | identifier = identifier.updateOptions(identifiers), 107 | resolutionStatus = resolutionStatus.updateOptions(ResolutionStatus.entries), 108 | severity = severity.updateOptions(Severity.entries), 109 | source = source.updateOptions(sources), 110 | text = text, 111 | tool = tool.updateOptions(Tool.entries) 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/packagedetails/PackageDetailsState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.packagedetails 2 | 3 | import org.ossreviewtoolkit.model.CuratedPackage 4 | import org.ossreviewtoolkit.model.Identifier 5 | import org.ossreviewtoolkit.model.Project 6 | import org.ossreviewtoolkit.model.licenses.ResolvedLicenseInfo 7 | 8 | data class PackageInfo( 9 | val id: Identifier, 10 | val pkg: CuratedPackage?, 11 | val project: Project?, 12 | val license: ResolvedLicenseInfo 13 | ) 14 | 15 | data class PackageDetailsState( 16 | val packageInfo: PackageInfo? 17 | ) 18 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/packagedetails/PackageDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.packagedetails 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.remember 7 | 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | import org.ossreviewtoolkit.model.Identifier 11 | import org.ossreviewtoolkit.workbench.lifecycle.MoleculeViewModel 12 | import org.ossreviewtoolkit.workbench.model.OrtModel 13 | 14 | class PackageDetailsViewModel(private val ortModel: OrtModel, private val pkgId: Identifier) : 15 | MoleculeViewModel() { 16 | 17 | @Composable 18 | override fun composeModel(events: Flow): PackageDetailsState { 19 | return PackageDetailsPresenter(ortModel, pkgId) 20 | } 21 | } 22 | 23 | @Composable 24 | fun PackageDetailsPresenter( 25 | ortModel: OrtModel, 26 | pkgId: Identifier 27 | ): PackageDetailsState { 28 | val api by ortModel.api.collectAsState() 29 | val packageInfo = remember(pkgId) { 30 | val pkg = api.getCuratedPackageOrProject(pkgId) 31 | val project = api.getProject(pkgId) 32 | 33 | PackageInfo( 34 | id = pkgId, 35 | pkg = pkg, 36 | project = project, 37 | license = api.getResolvedLicense(pkgId) 38 | ) 39 | } 40 | 41 | return PackageDetailsState(packageInfo) 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/packages/PackagesState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.packages 2 | 3 | sealed interface PackagesState { 4 | data class Loading( 5 | val processedPackages: Int, 6 | val totalPackages: Int 7 | ) : PackagesState 8 | 9 | data class Success( 10 | val packages: List, 11 | val filter: PackagesFilter 12 | ) : PackagesState 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/settings/SettingsTab.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.settings 2 | 3 | enum class SettingsTab(val title: String) { 4 | WORKBENCH("Workbench"), 5 | CONFIG_FILES("Configuration Files") 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/AdvisorInfoCard.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.runtime.Composable 5 | 6 | import com.halilibo.richtext.markdown.Markdown 7 | import com.halilibo.richtext.ui.material.RichText 8 | 9 | import java.time.Instant 10 | 11 | import kotlin.time.Duration.Companion.minutes 12 | 13 | import org.ossreviewtoolkit.reporter.IssueStatistics 14 | import org.ossreviewtoolkit.workbench.composables.Preview 15 | import org.ossreviewtoolkit.workbench.model.AdviceProviderStats 16 | import org.ossreviewtoolkit.workbench.model.AdvisorStats 17 | import org.ossreviewtoolkit.workbench.utils.OrtIcon 18 | 19 | @Composable 20 | fun AdvisorInfoCard(info: AdvisorInfo?, startExpanded: Boolean = false) { 21 | if (info == null) { 22 | EmptyToolInfoCard(icon = OrtIcon.ADVISOR, toolName = "Advisor") 23 | } else { 24 | ToolInfoCard( 25 | icon = OrtIcon.ADVISOR, 26 | toolName = "Advisor", 27 | info = info, 28 | startExpanded = startExpanded 29 | ) { 30 | info.advisorStats.adviceProviderStats.forEach { (provider, stats) -> 31 | val markdown = """ 32 | * Requested vulnerabilities for ${stats.requestedPackageCount} packages from $provider. Found 33 | ${stats.packageWithVulnerabilityCount} vulnerable package(s) with a total of 34 | ${stats.totalVulnerabilityCount} vulnerabilities. 35 | """.trimIndent() 36 | 37 | RichText { Markdown(markdown) } 38 | } 39 | } 40 | } 41 | } 42 | 43 | @Composable 44 | @Preview 45 | private fun AdvisorInfoCardPreview() { 46 | Preview { 47 | AdvisorInfoCard( 48 | AdvisorInfo( 49 | startTime = Instant.now(), 50 | duration = 1000.minutes, 51 | issueStats = IssueStatistics(0, 1, 2, 3), 52 | serializedConfig = "allow_dynamic_versions: false\nskip_excluded: false", 53 | environment = mapOf( 54 | "ORT Version" to "2.0.0", 55 | "Java Version" to "17.0.5", 56 | "OS" to "Windows 10" 57 | ), 58 | environmentVariables = mapOf( 59 | "TERM" to "xterm", 60 | "JAVA_HOME" to "C:/Program Files/Eclipse Adoptium/jdk-17.0.5.8-hotspot/" 61 | ), 62 | toolVersions = mapOf( 63 | "NPM" to "9.4.0" 64 | ), 65 | advisorStats = AdvisorStats( 66 | adviceProviderStats = mapOf( 67 | "OSV" to AdviceProviderStats(1, 2, 3), 68 | "VulnerableCode" to AdviceProviderStats(4, 5, 6) 69 | ) 70 | ) 71 | ), 72 | startExpanded = true 73 | ) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/AnalyzerInfoCard.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.runtime.Composable 5 | 6 | import com.halilibo.richtext.markdown.Markdown 7 | import com.halilibo.richtext.ui.material.RichText 8 | 9 | import java.time.Instant 10 | 11 | import kotlin.time.Duration.Companion.minutes 12 | 13 | import org.ossreviewtoolkit.reporter.IssueStatistics 14 | import org.ossreviewtoolkit.workbench.composables.Preview 15 | import org.ossreviewtoolkit.workbench.model.PackageManagerStats 16 | import org.ossreviewtoolkit.workbench.model.ProjectStats 17 | import org.ossreviewtoolkit.workbench.utils.OrtIcon 18 | 19 | @Composable 20 | fun AnalyzerInfoCard(info: AnalyzerInfo, startExpanded: Boolean = false) { 21 | ToolInfoCard( 22 | icon = OrtIcon.ANALYZER, 23 | toolName = "Analyzer", 24 | info = info, 25 | startExpanded = startExpanded 26 | ) { 27 | info.projectStats.packageManagerStats.forEach { (type, stats) -> 28 | RichText { 29 | Markdown( 30 | "* Analyzed ${stats.projectCount} $type project(s) with ${stats.dependencyCount} dependencies." 31 | ) 32 | } 33 | } 34 | } 35 | } 36 | 37 | @Composable 38 | @Preview 39 | private fun AnalyzerInfoCardPreview() { 40 | Preview { 41 | AnalyzerInfoCard( 42 | AnalyzerInfo( 43 | startTime = Instant.now(), 44 | duration = 1000.minutes, 45 | issueStats = IssueStatistics(0, 1, 2, 3), 46 | serializedConfig = "allow_dynamic_versions: false\nskip_excluded: false", 47 | environment = mapOf( 48 | "ORT Version" to "2.0.0", 49 | "Java Version" to "17.0.5", 50 | "OS" to "Windows 10" 51 | ), 52 | environmentVariables = mapOf( 53 | "TERM" to "xterm", 54 | "JAVA_HOME" to "C:/Program Files/Eclipse Adoptium/jdk-17.0.5.8-hotspot/" 55 | ), 56 | toolVersions = mapOf( 57 | "NPM" to "9.4.0" 58 | ), 59 | projectStats = ProjectStats( 60 | packageManagerStats = mapOf( 61 | "Gradle" to PackageManagerStats(1, 2), 62 | "Maven" to PackageManagerStats(3, 4), 63 | "NPM" to PackageManagerStats(5, 6) 64 | ) 65 | ) 66 | ), 67 | startExpanded = true 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/EmptyToolInfoCard.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.material.Text 5 | import androidx.compose.runtime.Composable 6 | 7 | import org.jetbrains.compose.resources.painterResource 8 | 9 | import org.ossreviewtoolkit.workbench.composables.Preview 10 | import org.ossreviewtoolkit.workbench.composables.StyledCard 11 | import org.ossreviewtoolkit.workbench.utils.OrtIcon 12 | 13 | @Composable 14 | fun EmptyToolInfoCard(icon: OrtIcon, toolName: String) { 15 | StyledCard( 16 | titleIcon = painterResource(icon.resource), 17 | title = toolName 18 | ) { 19 | Text("The $toolName was not executed.") 20 | } 21 | } 22 | 23 | @Composable 24 | @Preview 25 | private fun EmptyToolInfoCardPreview() { 26 | Preview { 27 | EmptyToolInfoCard(icon = OrtIcon.ANALYZER, toolName = "Analyzer") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/EvaluatorInfoCard.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.runtime.Composable 5 | 6 | import com.halilibo.richtext.markdown.Markdown 7 | import com.halilibo.richtext.ui.material.RichText 8 | 9 | import java.time.Instant 10 | 11 | import kotlin.time.Duration.Companion.minutes 12 | 13 | import org.ossreviewtoolkit.model.LicenseSource 14 | import org.ossreviewtoolkit.reporter.IssueStatistics 15 | import org.ossreviewtoolkit.workbench.composables.Preview 16 | import org.ossreviewtoolkit.workbench.model.EvaluatorStats 17 | import org.ossreviewtoolkit.workbench.utils.OrtIcon 18 | 19 | @Composable 20 | fun EvaluatorInfoCard(info: EvaluatorInfo?, startExpanded: Boolean = false) { 21 | if (info == null) { 22 | EmptyToolInfoCard(icon = OrtIcon.EVALUATOR, toolName = "Evaluator") 23 | } else { 24 | ToolInfoCard( 25 | icon = OrtIcon.EVALUATOR, 26 | toolName = "Evaluator", 27 | info = info, 28 | startExpanded = startExpanded 29 | ) { 30 | val markdown = """ 31 | * Found a total of ${info.evaluatorStats.ruleViolationCount} rule violation(s) of which 32 | ${info.evaluatorStats.ruleViolationCountByLicenseSource[LicenseSource.CONCLUDED] ?: 0} were triggered 33 | by concluded licenses, 34 | ${info.evaluatorStats.ruleViolationCountByLicenseSource[LicenseSource.DECLARED] ?: 0} by declared 35 | licenses, and ${info.evaluatorStats.ruleViolationCountByLicenseSource[LicenseSource.DETECTED] ?: 0} by 36 | detected licenses. 37 | * Found ${info.evaluatorStats.packageWithRuleViolationCount} different package(s) with rule violations. 38 | * A total of ${info.evaluatorStats.ruleThatTriggeredViolationCount} different rule(s) were violated. 39 | """.trimIndent() 40 | 41 | RichText { Markdown(markdown) } 42 | } 43 | } 44 | } 45 | 46 | @Composable 47 | @Preview 48 | private fun EvaluatorInfoCardPreview() { 49 | Preview { 50 | EvaluatorInfoCard( 51 | EvaluatorInfo( 52 | startTime = Instant.now(), 53 | duration = 1000.minutes, 54 | issueStats = IssueStatistics(0, 1, 2, 3), 55 | evaluatorStats = EvaluatorStats( 56 | ruleViolationCount = 1, 57 | ruleViolationCountByLicenseSource = mapOf( 58 | LicenseSource.CONCLUDED to 2, 59 | LicenseSource.DECLARED to 3, 60 | LicenseSource.DETECTED to 4 61 | ), 62 | packageWithRuleViolationCount = 5, 63 | ruleThatTriggeredViolationCount = 6 64 | ) 65 | ), 66 | startExpanded = true 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/ResultFileInfoCard.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.Text 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.* 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 17 | import androidx.compose.ui.unit.dp 18 | 19 | import com.halilibo.richtext.markdown.Markdown 20 | import com.halilibo.richtext.ui.material.RichText 21 | 22 | import com.seanproctor.datatable.DataColumn 23 | import com.seanproctor.datatable.material.DataTable 24 | 25 | import java.time.Instant 26 | 27 | import org.ossreviewtoolkit.model.VcsInfo 28 | import org.ossreviewtoolkit.model.VcsType 29 | import org.ossreviewtoolkit.workbench.composables.Preview 30 | import org.ossreviewtoolkit.workbench.composables.SingleLineText 31 | import org.ossreviewtoolkit.workbench.composables.StyledCard 32 | import org.ossreviewtoolkit.workbench.composables.TwoColumnTable 33 | import org.ossreviewtoolkit.workbench.composables.rememberFormattedDatetime 34 | 35 | private const val KIBI = 1024 36 | private const val MEBI = KIBI * KIBI 37 | 38 | @Composable 39 | fun ResultFileInfoCard(info: ResultFileInfo) { 40 | StyledCard( 41 | titleIcon = rememberVectorPainter(Icons.Default.FilePresent), 42 | title = "ORT Result File" 43 | ) { 44 | Row(modifier = Modifier.padding(vertical = 15.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { 45 | Column(modifier = Modifier.weight(0.75f), horizontalAlignment = Alignment.CenterHorizontally) { 46 | Text("File Info", style = MaterialTheme.typography.h6) 47 | 48 | TwoColumnTable( 49 | headers = "Property" to "Value", 50 | data = mapOf( 51 | "Path" to info.absolutePath, 52 | "Size" to "%.2f MiB".format(info.size.toFloat() / MEBI), 53 | "Modified" to rememberFormattedDatetime(info.timestamp) 54 | ) 55 | ) 56 | } 57 | 58 | Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { 59 | Text("Repository Configuration", style = MaterialTheme.typography.h6) 60 | 61 | val stats = info.repositoryConfigurationStats 62 | 63 | val markdown = """ 64 | * Defines ${stats.pathExcludeCount} path exclude(s) and ${stats.scopeExcludeCount} scope exclude(s). 65 | * Contains resolutions for ${stats.issueResolutionCount} issues, 66 | ${stats.vulnerabilityResolutionCount} vulnerabilities, and ${stats.ruleViolationResolutionCount} 67 | rule violations. 68 | * Contains ${stats.packageCurationCount} package curation(s) and ${stats.packageConfigurationCount} 69 | package configuration(s). 70 | * Contains ${stats.licenseFindingCurationCount} license finding curation(s) for the project's own 71 | source code. 72 | * Contains ${stats.licenseChoiceCount} license choice(s). 73 | """.trimIndent() 74 | 75 | RichText(modifier = Modifier.padding(top = 8.dp)) { Markdown(markdown) } 76 | } 77 | } 78 | 79 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 80 | Text("VCS Info", style = MaterialTheme.typography.h6) 81 | 82 | DataTable( 83 | modifier = Modifier.fillMaxWidth(), 84 | headerHeight = 36.dp, 85 | rowHeight = 24.dp, 86 | columns = listOf( 87 | DataColumn { SingleLineText("Path") }, 88 | DataColumn { SingleLineText("URL") }, 89 | DataColumn { SingleLineText("Revision") }, 90 | DataColumn { SingleLineText("Sparse Checkout Path") } 91 | ) 92 | ) { 93 | row { 94 | cell { SingleLineText("/") } 95 | cell { SingleLineText(info.vcs.url) } 96 | cell { SingleLineText(info.vcs.revision) } 97 | cell { SingleLineText(info.vcs.path) } 98 | } 99 | 100 | info.nestedRepositories.forEach { (path, vcs) -> 101 | row { 102 | cell { SingleLineText("/$path") } 103 | cell { SingleLineText(vcs.url) } 104 | cell { SingleLineText(vcs.revision) } 105 | cell { SingleLineText(vcs.path) } 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | 113 | @Composable 114 | @Preview 115 | private fun ResultFileInfoCardPreview() { 116 | Preview { 117 | ResultFileInfoCard( 118 | ResultFileInfo( 119 | absolutePath = "/some/path", 120 | size = 1024 * 1024, 121 | timestamp = Instant.now(), 122 | vcs = VcsInfo( 123 | type = VcsType.GIT, 124 | url = "https://example.org/repo.git", 125 | revision = "main", 126 | path = "path" 127 | ), 128 | nestedRepositories = mapOf( 129 | "submodule1" to VcsInfo( 130 | type = VcsType.GIT, 131 | url = "https://example.org/submodule1.git", 132 | revision = "main" 133 | ), 134 | "submodule2" to VcsInfo( 135 | type = VcsType.GIT, 136 | url = "https://example.org/submodule2.git", 137 | revision = "main" 138 | ) 139 | ), 140 | repositoryConfigurationStats = RepositoryConfigurationStats(1, 2, 3, 4, 5, 6, 7, 8, 9) 141 | ) 142 | ) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/ScannerInfoCard.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.runtime.Composable 5 | 6 | import com.halilibo.richtext.markdown.Markdown 7 | import com.halilibo.richtext.ui.material.RichText 8 | 9 | import java.time.Instant 10 | 11 | import kotlin.time.Duration.Companion.minutes 12 | 13 | import org.ossreviewtoolkit.model.ScannerDetails 14 | import org.ossreviewtoolkit.reporter.IssueStatistics 15 | import org.ossreviewtoolkit.workbench.composables.Preview 16 | import org.ossreviewtoolkit.workbench.model.ScannerStats 17 | import org.ossreviewtoolkit.workbench.model.ScannerWrapperStats 18 | import org.ossreviewtoolkit.workbench.utils.OrtIcon 19 | 20 | @Composable 21 | fun ScannerInfoCard(info: ScannerInfo?, startExpanded: Boolean = false) { 22 | if (info == null) { 23 | EmptyToolInfoCard(icon = OrtIcon.SCANNER, toolName = "Scanner") 24 | } else { 25 | ToolInfoCard( 26 | icon = OrtIcon.SCANNER, 27 | toolName = "Scanner", 28 | info = info, 29 | startExpanded = startExpanded 30 | ) { 31 | info.scannerStats.scannerWrapperStats.forEach { (scanner, stats) -> 32 | val markdown = with(stats) { 33 | """ 34 | * Scanned $scannedPackageCount package(s) with ${scanner.name} ${scanner.version}. 35 | Detected $detectedLicenseCount licenses and $detectedCopyrightCount copyrights in 36 | $scannedSourceArtifactCount source artifacts and $scannedRepositoryCount source code 37 | repositories. 38 | """.trimIndent() 39 | } 40 | 41 | RichText { Markdown(markdown) } 42 | } 43 | } 44 | } 45 | } 46 | 47 | @Composable 48 | @Preview 49 | private fun ScannerInfoCardPreview() { 50 | Preview { 51 | ScannerInfoCard( 52 | ScannerInfo( 53 | startTime = Instant.now(), 54 | duration = 1000.minutes, 55 | issueStats = IssueStatistics(0, 1, 2, 3), 56 | serializedConfig = "create_missing_archives: false\nskip_concluded: false", 57 | environment = mapOf( 58 | "ORT Version" to "2.0.0", 59 | "Java Version" to "17.0.5", 60 | "OS" to "Windows 10" 61 | ), 62 | environmentVariables = mapOf( 63 | "TERM" to "xterm", 64 | "JAVA_HOME" to "C:/Program Files/Eclipse Adoptium/jdk-17.0.5.8-hotspot/" 65 | ), 66 | toolVersions = mapOf( 67 | "NPM" to "9.4.0" 68 | ), 69 | scannerStats = ScannerStats( 70 | scannerWrapperStats = mapOf( 71 | ScannerDetails(name = "ScanCode", version = "32.0.6", configuration = "") to 72 | ScannerWrapperStats(1, 2, 3, 4, 5), 73 | ScannerDetails(name = "ScanCode", version = "32.0.7", configuration = "") to 74 | ScannerWrapperStats(6, 7, 8, 9, 10) 75 | ) 76 | ) 77 | ), 78 | startExpanded = true 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/Summary.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import androidx.compose.foundation.VerticalScrollbar 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxHeight 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.rememberScrollState 12 | import androidx.compose.foundation.rememberScrollbarAdapter 13 | import androidx.compose.foundation.verticalScroll 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.collectAsState 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.unit.dp 19 | 20 | import org.ossreviewtoolkit.workbench.composables.CircularProgressBox 21 | 22 | @Composable 23 | fun Summary(viewModel: SummaryViewModel) { 24 | val stateState = viewModel.state.collectAsState() 25 | 26 | when (val state = stateState.value) { 27 | is SummaryState.Loading -> CircularProgressBox() 28 | 29 | is SummaryState.Success -> { 30 | val scrollState = rememberScrollState() 31 | 32 | Box(modifier = Modifier.fillMaxSize()) { 33 | Column(modifier = Modifier.fillMaxWidth().verticalScroll(scrollState)) { 34 | Column( 35 | modifier = Modifier.fillMaxWidth().padding(15.dp), 36 | verticalArrangement = Arrangement.spacedBy(15.dp) 37 | ) { 38 | ResultFileInfoCard(state.resultFileInfo) 39 | AnalyzerInfoCard(state.analyzerInfo) 40 | AdvisorInfoCard(state.advisorInfo) 41 | ScannerInfoCard(state.scannerInfo) 42 | EvaluatorInfoCard(state.evaluatorInfo) 43 | } 44 | } 45 | 46 | VerticalScrollbar( 47 | modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), 48 | adapter = rememberScrollbarAdapter(scrollState = scrollState) 49 | ) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/SummaryState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import java.time.Instant 4 | 5 | import kotlin.time.Duration 6 | 7 | import org.ossreviewtoolkit.model.VcsInfo 8 | import org.ossreviewtoolkit.reporter.IssueStatistics 9 | import org.ossreviewtoolkit.workbench.model.AdvisorStats 10 | import org.ossreviewtoolkit.workbench.model.EvaluatorStats 11 | import org.ossreviewtoolkit.workbench.model.ProjectStats 12 | import org.ossreviewtoolkit.workbench.model.ScannerStats 13 | 14 | sealed interface SummaryState { 15 | data object Loading : SummaryState 16 | 17 | data class Success( 18 | val resultFileInfo: ResultFileInfo = ResultFileInfo.EMPTY, 19 | val analyzerInfo: AnalyzerInfo = AnalyzerInfo.EMPTY, 20 | val advisorInfo: AdvisorInfo? = null, 21 | val scannerInfo: ScannerInfo? = null, 22 | val evaluatorInfo: EvaluatorInfo? = null 23 | ) : SummaryState 24 | } 25 | 26 | data class ResultFileInfo( 27 | val absolutePath: String, 28 | val size: Long, 29 | val timestamp: Instant, 30 | val vcs: VcsInfo, 31 | val nestedRepositories: Map, 32 | val repositoryConfigurationStats: RepositoryConfigurationStats 33 | ) { 34 | companion object { 35 | val EMPTY = ResultFileInfo("", 0L, Instant.EPOCH, VcsInfo.EMPTY, emptyMap(), RepositoryConfigurationStats.EMPTY) 36 | } 37 | } 38 | 39 | data class RepositoryConfigurationStats( 40 | val pathExcludeCount: Int, 41 | val scopeExcludeCount: Int, 42 | val issueResolutionCount: Int, 43 | val vulnerabilityResolutionCount: Int, 44 | val ruleViolationResolutionCount: Int, 45 | val packageCurationCount: Int, 46 | val licenseFindingCurationCount: Int, 47 | val packageConfigurationCount: Int, 48 | val licenseChoiceCount: Int 49 | ) { 50 | companion object { 51 | val EMPTY = RepositoryConfigurationStats(0, 0, 0, 0, 0, 0, 0, 0, 0) 52 | } 53 | } 54 | 55 | interface ToolInfo { 56 | val startTime: Instant 57 | val duration: Duration 58 | val issueStats: IssueStatistics 59 | val serializedConfig: String? 60 | val environment: Map? 61 | val environmentVariables: Map? 62 | val toolVersions: Map? 63 | } 64 | 65 | data class AnalyzerInfo( 66 | override val startTime: Instant, 67 | override val duration: Duration, 68 | override val issueStats: IssueStatistics, 69 | override val serializedConfig: String, 70 | override val environment: Map, 71 | override val environmentVariables: Map, 72 | override val toolVersions: Map, 73 | val projectStats: ProjectStats 74 | ) : ToolInfo { 75 | companion object { 76 | val EMPTY = AnalyzerInfo( 77 | startTime = Instant.EPOCH, 78 | duration = Duration.ZERO, 79 | projectStats = ProjectStats.EMPTY, 80 | issueStats = EMPTY_STATS, 81 | serializedConfig = "", 82 | environment = emptyMap(), 83 | environmentVariables = emptyMap(), 84 | toolVersions = emptyMap() 85 | ) 86 | } 87 | } 88 | 89 | data class AdvisorInfo( 90 | override val startTime: Instant, 91 | override val duration: Duration, 92 | override val issueStats: IssueStatistics, 93 | override val serializedConfig: String, 94 | override val environment: Map, 95 | override val environmentVariables: Map, 96 | override val toolVersions: Map, 97 | val advisorStats: AdvisorStats 98 | ) : ToolInfo 99 | 100 | data class ScannerInfo( 101 | override val startTime: Instant, 102 | override val duration: Duration, 103 | override val issueStats: IssueStatistics, 104 | override val serializedConfig: String, 105 | override val environment: Map, 106 | override val environmentVariables: Map, 107 | override val toolVersions: Map, 108 | val scannerStats: ScannerStats 109 | ) : ToolInfo 110 | 111 | data class EvaluatorInfo( 112 | override val startTime: Instant, 113 | override val duration: Duration, 114 | override val issueStats: IssueStatistics, 115 | override val serializedConfig: String? = null, 116 | override val environment: Map? = null, 117 | override val environmentVariables: Map? = null, 118 | override val toolVersions: Map? = null, 119 | val evaluatorStats: EvaluatorStats 120 | ) : ToolInfo 121 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/summary/SummaryViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.summary 2 | 3 | import java.io.File 4 | import java.time.Duration 5 | import java.time.Instant 6 | 7 | import kotlin.time.toKotlinDuration 8 | 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.flow.combine 14 | import kotlinx.coroutines.launch 15 | 16 | import org.ossreviewtoolkit.model.config.RepositoryConfiguration 17 | import org.ossreviewtoolkit.model.yamlMapper 18 | import org.ossreviewtoolkit.reporter.IssueStatistics 19 | import org.ossreviewtoolkit.utils.ort.Environment 20 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 21 | import org.ossreviewtoolkit.workbench.model.OrtApi 22 | import org.ossreviewtoolkit.workbench.model.OrtModel 23 | import org.ossreviewtoolkit.workbench.utils.removeYamlPrefix 24 | 25 | class SummaryViewModel(private val ortModel: OrtModel) : ViewModel() { 26 | private val defaultScope = CoroutineScope(Dispatchers.Default) 27 | 28 | private val _state = MutableStateFlow(SummaryState.Loading) 29 | val state: StateFlow = _state 30 | 31 | init { 32 | defaultScope.launch { 33 | combine(ortModel.ortResultFile, ortModel.api) { resultFile, api -> 34 | SummaryState.Success( 35 | resultFileInfo = createResultFileInfo(resultFile, api), 36 | analyzerInfo = createAnalyzerInfo(api), 37 | advisorInfo = createAdvisorInfo(api), 38 | scannerInfo = createScannerInfo(api), 39 | evaluatorInfo = createEvaluatorInfo(api) 40 | ) 41 | }.collect { 42 | _state.value = it 43 | } 44 | } 45 | } 46 | } 47 | 48 | private fun createResultFileInfo(file: File?, api: OrtApi): ResultFileInfo = 49 | file?.let { 50 | ResultFileInfo( 51 | absolutePath = it.absolutePath, 52 | size = it.length(), 53 | timestamp = Instant.ofEpochMilli(it.lastModified()), 54 | vcs = api.getRepository().vcs, 55 | nestedRepositories = api.getRepository().nestedRepositories, 56 | repositoryConfigurationStats = api.getRepository().config.toStats() 57 | ) 58 | } ?: ResultFileInfo.EMPTY 59 | 60 | private fun RepositoryConfiguration.toStats() = 61 | RepositoryConfigurationStats( 62 | pathExcludeCount = excludes.paths.size, 63 | scopeExcludeCount = excludes.scopes.size, 64 | issueResolutionCount = resolutions.issues.size, 65 | vulnerabilityResolutionCount = resolutions.vulnerabilities.size, 66 | ruleViolationResolutionCount = resolutions.ruleViolations.size, 67 | packageCurationCount = curations.packages.size, 68 | licenseFindingCurationCount = curations.licenseFindings.size, 69 | packageConfigurationCount = packageConfigurations.size, 70 | licenseChoiceCount = licenseChoices.packageLicenseChoices.size + licenseChoices.repositoryLicenseChoices.size 71 | ) 72 | 73 | private fun createAnalyzerInfo(api: OrtApi): AnalyzerInfo = 74 | api.getAnalyzerRun()?.let { analyzerRun -> 75 | AnalyzerInfo( 76 | startTime = analyzerRun.startTime, 77 | duration = Duration.between(analyzerRun.startTime, analyzerRun.endTime).toKotlinDuration(), 78 | issueStats = api.getAnalyzerIssueStats(), 79 | serializedConfig = yamlMapper.writeValueAsString(analyzerRun.config).removeYamlPrefix(), 80 | environment = analyzerRun.environment.toMap(), 81 | environmentVariables = analyzerRun.environment.variables, 82 | toolVersions = analyzerRun.environment.toolVersions, 83 | projectStats = api.getProjectStats() 84 | ) 85 | } ?: AnalyzerInfo.EMPTY 86 | 87 | private fun createAdvisorInfo(api: OrtApi): AdvisorInfo? = 88 | api.getAdvisorRun()?.let { advisorRun -> 89 | AdvisorInfo( 90 | startTime = advisorRun.startTime, 91 | duration = Duration.between(advisorRun.startTime, advisorRun.endTime).toKotlinDuration(), 92 | issueStats = api.getAdvisorIssueStats(), 93 | serializedConfig = yamlMapper.writeValueAsString(advisorRun.config).removeYamlPrefix(), 94 | environment = advisorRun.environment.toMap(), 95 | environmentVariables = advisorRun.environment.variables, 96 | toolVersions = advisorRun.environment.toolVersions, 97 | advisorStats = api.getAdvisorStats() 98 | ) 99 | } 100 | 101 | private fun createScannerInfo(api: OrtApi): ScannerInfo? = 102 | api.getScannerRun()?.let { scannerRun -> 103 | ScannerInfo( 104 | startTime = scannerRun.startTime, 105 | duration = Duration.between(scannerRun.startTime, scannerRun.endTime).toKotlinDuration(), 106 | issueStats = api.getScannerIssueStats(), 107 | serializedConfig = yamlMapper.writeValueAsString(scannerRun.config).removeYamlPrefix(), 108 | environment = scannerRun.environment.toMap(), 109 | environmentVariables = scannerRun.environment.variables, 110 | toolVersions = scannerRun.environment.toolVersions, 111 | scannerStats = api.getScannerStats() 112 | ) 113 | } 114 | 115 | private fun createEvaluatorInfo(api: OrtApi): EvaluatorInfo? = 116 | api.getEvaluatorRun()?.let { evaluatorRun -> 117 | EvaluatorInfo( 118 | startTime = evaluatorRun.startTime, 119 | duration = Duration.between(evaluatorRun.startTime, evaluatorRun.endTime).toKotlinDuration(), 120 | issueStats = api.getRuleViolationStats(), 121 | evaluatorStats = api.getEvaluatorStats() 122 | ) 123 | } 124 | 125 | private fun Environment.toMap() = mapOf( 126 | "ORT version" to ortVersion, 127 | "Java version" to javaVersion, 128 | "OS" to os, 129 | "Processors" to processors.toString(), 130 | "Max memory" to maxMemory.toString() 131 | ) 132 | 133 | val EMPTY_STATS = IssueStatistics(0, 0, 0, 0) 134 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/violations/Violations.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.violations 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material.Card 11 | import androidx.compose.material.Divider 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.text.font.FontWeight 19 | import androidx.compose.ui.unit.dp 20 | 21 | import org.ossreviewtoolkit.model.Identifier 22 | import org.ossreviewtoolkit.model.LicenseSource 23 | import org.ossreviewtoolkit.model.Severity 24 | import org.ossreviewtoolkit.model.config.RuleViolationResolution 25 | import org.ossreviewtoolkit.model.config.RuleViolationResolutionReason 26 | import org.ossreviewtoolkit.utils.common.titlecase 27 | import org.ossreviewtoolkit.utils.spdx.SpdxLicenseIdExpression 28 | import org.ossreviewtoolkit.utils.spdx.SpdxSingleLicenseExpression 29 | import org.ossreviewtoolkit.workbench.composables.CircularProgressBox 30 | import org.ossreviewtoolkit.workbench.composables.ExpandableMarkdown 31 | import org.ossreviewtoolkit.workbench.composables.ExpandableText 32 | import org.ossreviewtoolkit.workbench.composables.FilterButton 33 | import org.ossreviewtoolkit.workbench.composables.FilterPanel 34 | import org.ossreviewtoolkit.workbench.composables.ListScreenContent 35 | import org.ossreviewtoolkit.workbench.composables.ListScreenList 36 | import org.ossreviewtoolkit.workbench.composables.Preview 37 | import org.ossreviewtoolkit.workbench.composables.SeverityIcon 38 | import org.ossreviewtoolkit.workbench.model.ResolutionStatus 39 | import org.ossreviewtoolkit.workbench.model.ResolvedRuleViolation 40 | 41 | @Composable 42 | @Preview 43 | fun Violations(viewModel: ViolationsViewModel) { 44 | val stateState = viewModel.state.collectAsState() 45 | 46 | when (val state = stateState.value) { 47 | is ViolationsState.Loading -> CircularProgressBox() 48 | 49 | is ViolationsState.Success -> { 50 | ListScreenContent( 51 | filterText = state.filter.text, 52 | onUpdateFilterText = viewModel::updateTextFilter, 53 | list = { 54 | ListScreenList( 55 | items = state.violations, 56 | itemsEmptyText = "No violations found.", 57 | item = { ViolationCard(it) } 58 | ) 59 | }, 60 | filterPanel = { showFilterPanel -> 61 | ViolationsFilterPanel( 62 | visible = showFilterPanel, 63 | state = state, 64 | onUpdateIdentifierFilter = viewModel::updateIdentifierFilter, 65 | onUpdateLicenseFilter = viewModel::updateLicenseFilter, 66 | onUpdateLicenseSourceFilter = viewModel::updateLicenseSourceFilter, 67 | onUpdateResolutionStatusFilter = viewModel::updateResolutionStatusFilter, 68 | onUpdateRuleFilter = viewModel::updateRuleFilter, 69 | onUpdateSeverityFilter = viewModel::updateSeverityFilter 70 | ) 71 | } 72 | ) 73 | } 74 | } 75 | } 76 | 77 | @Composable 78 | fun ViolationCard(violation: ResolvedRuleViolation) { 79 | Card(modifier = Modifier.fillMaxWidth(), elevation = 8.dp) { 80 | Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { 81 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { 82 | SeverityIcon(violation.severity, resolved = violation.resolutions.isNotEmpty()) 83 | Text(violation.rule) 84 | violation.pkg?.let { Text(it.toCoordinates()) } 85 | Box(modifier = Modifier.weight(1f)) 86 | violation.license?.let { Text(it.toString()) } 87 | violation.licenseSource?.let { Text("Source: ${it.name}") } 88 | } 89 | 90 | if (violation.resolutions.isNotEmpty()) Divider() 91 | 92 | violation.resolutions.forEach { resolution -> 93 | Text("Resolved: ${resolution.reason}", fontWeight = FontWeight.Bold) 94 | ExpandableText(resolution.comment) 95 | Divider() 96 | } 97 | 98 | if (violation.message.isNotBlank()) ExpandableText(violation.message) 99 | if (violation.howToFix.isNotBlank()) ExpandableMarkdown(violation.howToFix) 100 | } 101 | } 102 | } 103 | 104 | @Composable 105 | @Preview 106 | private fun ViolationCardPreview() { 107 | val violation = ResolvedRuleViolation( 108 | pkg = Identifier("Maven:com.example:package:1.0.0-beta"), 109 | rule = "RULE_NAME", 110 | license = SpdxLicenseIdExpression("Apache-2.0"), 111 | licenseSource = LicenseSource.DECLARED, 112 | severity = Severity.WARNING, 113 | message = "Some long message. ".repeat(20), 114 | howToFix = """ 115 | # HOW TO FIX 116 | 117 | * A 118 | * **Markdown** 119 | * *String* 120 | """.trimIndent(), 121 | resolutions = listOf( 122 | RuleViolationResolution( 123 | "", 124 | RuleViolationResolutionReason.CANT_FIX_EXCEPTION, 125 | "Some long comment. ".repeat(20) 126 | ) 127 | ) 128 | ) 129 | 130 | Preview { 131 | ViolationCard(violation) 132 | } 133 | } 134 | 135 | @Composable 136 | fun ViolationsFilterPanel( 137 | visible: Boolean, 138 | state: ViolationsState.Success, 139 | onUpdateIdentifierFilter: (identifier: Identifier?) -> Unit, 140 | onUpdateLicenseFilter: (license: SpdxSingleLicenseExpression?) -> Unit, 141 | onUpdateLicenseSourceFilter: (licenseSource: LicenseSource?) -> Unit, 142 | onUpdateResolutionStatusFilter: (resolutionStatus: ResolutionStatus?) -> Unit, 143 | onUpdateRuleFilter: (rule: String?) -> Unit, 144 | onUpdateSeverityFilter: (severity: Severity?) -> Unit 145 | ) { 146 | FilterPanel(visible = visible) { 147 | FilterButton( 148 | data = state.filter.severity, 149 | label = "Severity", 150 | onFilterChange = onUpdateSeverityFilter, 151 | convert = { it.name.titlecase() } 152 | ) 153 | 154 | FilterButton(data = state.filter.license, label = "License", onFilterChange = onUpdateLicenseFilter) 155 | 156 | FilterButton( 157 | data = state.filter.licenseSource, 158 | label = "License Source", 159 | onFilterChange = onUpdateLicenseSourceFilter, 160 | convert = { it.name.titlecase() } 161 | ) 162 | 163 | FilterButton(data = state.filter.rule, label = "Rule", onFilterChange = onUpdateRuleFilter) 164 | 165 | FilterButton( 166 | data = state.filter.identifier, 167 | label = "Package", 168 | onFilterChange = onUpdateIdentifierFilter, 169 | convert = { it.toCoordinates() } 170 | ) 171 | 172 | FilterButton( 173 | data = state.filter.resolutionStatus, 174 | label = "Resolution", 175 | onFilterChange = onUpdateResolutionStatusFilter, 176 | convert = { it.name.titlecase() } 177 | ) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/violations/ViolationsState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.violations 2 | 3 | import org.ossreviewtoolkit.workbench.model.ResolvedRuleViolation 4 | 5 | sealed interface ViolationsState { 6 | data object Loading : ViolationsState 7 | 8 | data class Success( 9 | val violations: List, 10 | val filter: ViolationsFilter 11 | ) : ViolationsState 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/violations/ViolationsViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.violations 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.combine 8 | import kotlinx.coroutines.launch 9 | 10 | import org.ossreviewtoolkit.model.Identifier 11 | import org.ossreviewtoolkit.model.LicenseSource 12 | import org.ossreviewtoolkit.model.Severity 13 | import org.ossreviewtoolkit.utils.spdx.SpdxSingleLicenseExpression 14 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 15 | import org.ossreviewtoolkit.workbench.model.FilterData 16 | import org.ossreviewtoolkit.workbench.model.OrtModel 17 | import org.ossreviewtoolkit.workbench.model.ResolutionStatus 18 | import org.ossreviewtoolkit.workbench.model.ResolvedRuleViolation 19 | import org.ossreviewtoolkit.workbench.utils.SpdxExpressionStringComparator 20 | import org.ossreviewtoolkit.workbench.utils.matchResolutionStatus 21 | import org.ossreviewtoolkit.workbench.utils.matchString 22 | import org.ossreviewtoolkit.workbench.utils.matchStringContains 23 | import org.ossreviewtoolkit.workbench.utils.matchValue 24 | 25 | class ViolationsViewModel(private val ortModel: OrtModel) : ViewModel() { 26 | private val defaultScope = CoroutineScope(Dispatchers.Default) 27 | 28 | private val violations = MutableStateFlow?>(null) 29 | private val filter = MutableStateFlow(ViolationsFilter()) 30 | 31 | private val _state = MutableStateFlow(ViolationsState.Loading) 32 | val state: StateFlow = _state 33 | 34 | init { 35 | defaultScope.launch { ortModel.api.collect { violations.value = it.getViolations() } } 36 | 37 | scope.launch { violations.collect { if (it != null) initFilter(it) } } 38 | 39 | scope.launch { 40 | combine(filter, violations) { filter, violations -> 41 | if (violations != null) { 42 | ViolationsState.Success( 43 | violations = violations.filter(filter::check), 44 | filter = filter 45 | ) 46 | } else { 47 | ViolationsState.Loading 48 | } 49 | }.collect { _state.value = it } 50 | } 51 | } 52 | 53 | private fun initFilter(violations: List) { 54 | filter.value = filter.value.updateOptions( 55 | identifiers = violations.mapNotNullTo(sortedSetOf()) { it.pkg }.toList(), 56 | licenses = violations.mapNotNullTo(sortedSetOf(SpdxExpressionStringComparator())) { 57 | it.license 58 | }.toList(), 59 | rules = violations.mapTo(sortedSetOf()) { it.rule }.toList() 60 | ) 61 | } 62 | 63 | fun updateTextFilter(text: String) { 64 | filter.value = filter.value.copy(text = text) 65 | } 66 | 67 | fun updateIdentifierFilter(identifier: Identifier?) { 68 | filter.value = filter.value.copy(identifier = filter.value.identifier.copy(selectedItem = identifier)) 69 | } 70 | 71 | fun updateLicenseFilter(license: SpdxSingleLicenseExpression?) { 72 | filter.value = filter.value.copy(license = filter.value.license.copy(selectedItem = license)) 73 | } 74 | 75 | fun updateLicenseSourceFilter(licenseSource: LicenseSource?) { 76 | filter.value = filter.value.copy(licenseSource = filter.value.licenseSource.copy(selectedItem = licenseSource)) 77 | } 78 | 79 | fun updateResolutionStatusFilter(resolutionStatus: ResolutionStatus?) { 80 | filter.value = filter.value.copy( 81 | resolutionStatus = filter.value.resolutionStatus.copy(selectedItem = resolutionStatus) 82 | ) 83 | } 84 | 85 | fun updateRuleFilter(rule: String?) { 86 | filter.value = filter.value.copy(rule = filter.value.rule.copy(selectedItem = rule)) 87 | } 88 | 89 | fun updateSeverityFilter(severity: Severity?) { 90 | filter.value = filter.value.copy(severity = filter.value.severity.copy(selectedItem = severity)) 91 | } 92 | } 93 | 94 | data class ViolationsFilter( 95 | val identifier: FilterData = FilterData(), 96 | val license: FilterData = FilterData(), 97 | val licenseSource: FilterData = FilterData(), 98 | val resolutionStatus: FilterData = FilterData(), 99 | val rule: FilterData = FilterData(), 100 | val severity: FilterData = FilterData(), 101 | val text: String = "" 102 | ) { 103 | fun check(violation: ResolvedRuleViolation) = 104 | matchValue(identifier.selectedItem, violation.pkg) 105 | && matchValue(license.selectedItem, violation.license) 106 | && matchValue(licenseSource.selectedItem, violation.licenseSource) 107 | && matchResolutionStatus(resolutionStatus.selectedItem, violation.resolutions) 108 | && matchString(rule.selectedItem, violation.rule) 109 | && matchValue(severity.selectedItem, violation.severity) 110 | && matchStringContains( 111 | text, 112 | listOfNotNull( 113 | violation.pkg?.toCoordinates(), 114 | violation.rule, 115 | violation.license?.toString(), 116 | violation.licenseSource?.name, 117 | violation.message, 118 | violation.howToFix 119 | ) 120 | ) 121 | 122 | @OptIn(ExperimentalStdlibApi::class) 123 | fun updateOptions( 124 | identifiers: List, 125 | licenses: List, 126 | rules: List 127 | ) = ViolationsFilter( 128 | identifier = identifier.updateOptions(identifiers), 129 | license = license.updateOptions(licenses), 130 | licenseSource = licenseSource.updateOptions(LicenseSource.entries), 131 | resolutionStatus = resolutionStatus.updateOptions(ResolutionStatus.entries), 132 | rule = rule.updateOptions(rules), 133 | severity = severity.updateOptions(Severity.entries), 134 | text = text 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/vulnerabilities/VulnerabilitiesState.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.vulnerabilities 2 | 3 | import org.ossreviewtoolkit.workbench.model.ResolvedVulnerability 4 | 5 | sealed interface VulnerabilitiesState { 6 | data object Loading : VulnerabilitiesState 7 | 8 | data class Success( 9 | val vulnerabilities: List, 10 | val filter: VulnerabilitiesFilter 11 | ) : VulnerabilitiesState 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/vulnerabilities/VulnerabilitiesViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.ui.vulnerabilities 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.collect 8 | import kotlinx.coroutines.flow.combine 9 | import kotlinx.coroutines.launch 10 | 11 | import org.ossreviewtoolkit.model.Identifier 12 | import org.ossreviewtoolkit.workbench.lifecycle.ViewModel 13 | import org.ossreviewtoolkit.workbench.model.FilterData 14 | import org.ossreviewtoolkit.workbench.model.OrtModel 15 | import org.ossreviewtoolkit.workbench.model.ResolutionStatus 16 | import org.ossreviewtoolkit.workbench.model.ResolvedVulnerability 17 | import org.ossreviewtoolkit.workbench.utils.matchResolutionStatus 18 | import org.ossreviewtoolkit.workbench.utils.matchString 19 | import org.ossreviewtoolkit.workbench.utils.matchStringContains 20 | import org.ossreviewtoolkit.workbench.utils.matchValue 21 | 22 | class VulnerabilitiesViewModel(private val ortModel: OrtModel) : ViewModel() { 23 | private val defaultScope = CoroutineScope(Dispatchers.Default) 24 | 25 | private val vulnerabilities = MutableStateFlow?>(null) 26 | private val filter = MutableStateFlow(VulnerabilitiesFilter()) 27 | 28 | private val _state = MutableStateFlow(VulnerabilitiesState.Loading) 29 | val state: StateFlow = _state 30 | 31 | init { 32 | defaultScope.launch { ortModel.api.collect { api -> vulnerabilities.value = api.getVulnerabilities() } } 33 | 34 | scope.launch { vulnerabilities.collect { if (it != null) initFilter(it) } } 35 | 36 | scope.launch { 37 | combine(filter, vulnerabilities) { filter, vulnerabilities -> 38 | if (vulnerabilities != null) { 39 | VulnerabilitiesState.Success( 40 | vulnerabilities = vulnerabilities.filter(filter::check), 41 | filter = filter 42 | ) 43 | } else { 44 | VulnerabilitiesState.Loading 45 | } 46 | }.collect { _state.value = it } 47 | } 48 | } 49 | 50 | private fun initFilter(vulnerabilities: List) { 51 | filter.value = filter.value.updateOptions( 52 | advisors = vulnerabilities.mapTo(sortedSetOf()) { it.advisor }.toList(), 53 | identifiers = vulnerabilities.mapTo(sortedSetOf()) { it.pkg }.toList(), 54 | scoringSystems = vulnerabilities.flatMapTo(sortedSetOf()) { vulnerability -> 55 | vulnerability.references.mapNotNull { it.scoringSystem } 56 | }.toList(), 57 | severities = vulnerabilities.flatMapTo(sortedSetOf()) { vulnerability -> 58 | vulnerability.references.mapNotNull { it.severity } 59 | }.toList() 60 | ) 61 | } 62 | 63 | fun updateTextFilter(text: String) { 64 | filter.value = filter.value.copy(text = text) 65 | } 66 | 67 | fun updateAdvisorsFilter(advisor: String?) { 68 | filter.value = filter.value.copy(advisor = filter.value.advisor.copy(selectedItem = advisor)) 69 | } 70 | 71 | fun updateIdentifiersFilter(identifier: Identifier?) { 72 | filter.value = filter.value.copy(identifier = filter.value.identifier.copy(selectedItem = identifier)) 73 | } 74 | 75 | fun updateResolutionStatusFilter(resolutionStatus: ResolutionStatus?) { 76 | filter.value = filter.value.copy( 77 | resolutionStatus = filter.value.resolutionStatus.copy(selectedItem = resolutionStatus) 78 | ) 79 | } 80 | 81 | fun updateScoringSystemsFilter(scoringSystem: String?) { 82 | filter.value = filter.value.copy(scoringSystem = filter.value.scoringSystem.copy(selectedItem = scoringSystem)) 83 | } 84 | 85 | fun updateSeveritiesFilter(severity: String?) { 86 | filter.value = filter.value.copy(severity = filter.value.severity.copy(selectedItem = severity)) 87 | } 88 | } 89 | 90 | data class VulnerabilitiesFilter( 91 | val advisor: FilterData = FilterData(), 92 | val identifier: FilterData = FilterData(), 93 | val resolutionStatus: FilterData = FilterData(), 94 | val scoringSystem: FilterData = FilterData(), 95 | val severity: FilterData = FilterData(), 96 | val text: String = "" 97 | ) { 98 | fun check(vulnerability: ResolvedVulnerability) = 99 | matchString(advisor.selectedItem, vulnerability.advisor) 100 | && matchValue(identifier.selectedItem, vulnerability.pkg) 101 | && matchResolutionStatus(resolutionStatus.selectedItem, vulnerability.resolutions) 102 | && matchString(scoringSystem.selectedItem, vulnerability.references.mapNotNull { it.scoringSystem }) 103 | && matchString(severity.selectedItem, vulnerability.references.mapNotNull { it.severity }) 104 | && matchStringContains(text, vulnerability.pkg.toCoordinates(), vulnerability.id, vulnerability.advisor) 105 | 106 | @OptIn(ExperimentalStdlibApi::class) 107 | fun updateOptions( 108 | advisors: List, 109 | identifiers: List, 110 | scoringSystems: List, 111 | severities: List 112 | ) = VulnerabilitiesFilter( 113 | advisor = advisor.updateOptions(advisors), 114 | identifier = identifier.updateOptions(identifiers), 115 | resolutionStatus = resolutionStatus.updateOptions(ResolutionStatus.entries), 116 | scoringSystem = scoringSystem.updateOptions(scoringSystems), 117 | severity = severity.updateOptions(severities), 118 | text = text 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /src/main/kotlin/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.utils 2 | 3 | /** 4 | * Return the value corresponding to the given [key] or `0` if the key is not present. 5 | */ 6 | fun Map.getOrZero(key: K) = getOrDefault(key, 0) 7 | 8 | /** 9 | * Remove the first line that reads "---" when serializing YAML with Jackson. 10 | */ 11 | // TODO: Create a dedicated YAML mapper with `.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)` instead. 12 | fun String.removeYamlPrefix() = removePrefix("---\n") 13 | -------------------------------------------------------------------------------- /src/main/kotlin/utils/FilterMatchers.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.utils 2 | 3 | import org.ossreviewtoolkit.workbench.model.ResolutionStatus 4 | import org.ossreviewtoolkit.workbench.model.ResolvedIssue 5 | import org.ossreviewtoolkit.workbench.model.ResolvedRuleViolation 6 | import org.ossreviewtoolkit.workbench.model.ResolvedVulnerability 7 | import org.ossreviewtoolkit.workbench.ui.packages.ExclusionStatus 8 | import org.ossreviewtoolkit.workbench.ui.packages.IssueStatus 9 | import org.ossreviewtoolkit.workbench.ui.packages.ViolationStatus 10 | import org.ossreviewtoolkit.workbench.ui.packages.VulnerabilityStatus 11 | 12 | fun matchExclusionStatus(filter: ExclusionStatus?, value: Boolean) = 13 | filter == null 14 | || filter == ExclusionStatus.EXCLUDED && value 15 | || filter == ExclusionStatus.INCLUDED && !value 16 | 17 | fun matchIssueStatus(filter: IssueStatus?, value: List) = 18 | filter == null 19 | || filter == IssueStatus.HAS_ISSUES && value.isNotEmpty() 20 | || filter == IssueStatus.NO_ISSUES && value.isEmpty() 21 | 22 | fun matchResolutionStatus(filter: ResolutionStatus?, value: List) = 23 | filter == null 24 | || filter == ResolutionStatus.RESOLVED && value.isNotEmpty() 25 | || filter == ResolutionStatus.UNRESOLVED && value.isEmpty() 26 | 27 | fun matchViolationStatus(filter: ViolationStatus?, value: List) = 28 | filter == null 29 | || filter == ViolationStatus.HAS_VIOLATIONS && value.isNotEmpty() 30 | || filter == ViolationStatus.NO_VIOLATIONS && value.isEmpty() 31 | 32 | fun matchVulnerabilityStatus(filter: VulnerabilityStatus?, value: List) = 33 | filter == null 34 | || filter == VulnerabilityStatus.HAS_VULNERABILITY && value.isNotEmpty() 35 | || filter == VulnerabilityStatus.NO_VULNERABILITY && value.isEmpty() 36 | 37 | fun matchString(filter: String?, vararg values: String) = filter.isNullOrEmpty() || filter in values 38 | 39 | fun matchString(filter: String?, values: Collection) = filter.isNullOrEmpty() || filter in values 40 | 41 | fun matchStringContains(filter: String?, vararg values: String) = 42 | filter.isNullOrEmpty() || values.any { it.contains(filter) } 43 | 44 | fun matchStringContains(filter: String?, values: List) = 45 | filter.isNullOrEmpty() || values.any { it.contains(filter) } 46 | 47 | fun matchValue(filter: T?, value: T) = filter == null || filter == value 48 | 49 | fun matchAnyValue(filter: T?, value: Collection) = filter == null || value.any { it == filter } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/utils/OrtIcon.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.utils 2 | 3 | import org.jetbrains.compose.resources.DrawableResource 4 | 5 | import org.ossreviewtoolkit.workbench.ort_workbench.generated.resources.* 6 | 7 | enum class OrtIcon(val resource: DrawableResource) { 8 | ADVISOR(Res.drawable.advisor), 9 | ANALYZER(Res.drawable.analyzer), 10 | EVALUATOR(Res.drawable.evaluator), 11 | SCANNER(Res.drawable.scanner) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/utils/SpdxExpressionStringComparator.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.utils 2 | 3 | import org.ossreviewtoolkit.utils.spdx.SpdxExpression 4 | 5 | class SpdxExpressionStringComparator : Comparator { 6 | override fun compare(left: SpdxExpression?, right: SpdxExpression?): Int = 7 | compareValues(left?.toString(), right?.toString()) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/utils/Util.kt: -------------------------------------------------------------------------------- 1 | package org.ossreviewtoolkit.workbench.utils 2 | 3 | import java.awt.Desktop 4 | import java.io.File 5 | import java.net.URI 6 | 7 | fun browseDirectory(file: File) { 8 | Desktop.getDesktop().apply { 9 | runCatching { 10 | if (isSupported(Desktop.Action.BROWSE_FILE_DIR)) { 11 | browseFileDirectory(file) 12 | } else if (isSupported(Desktop.Action.OPEN)) { 13 | open(file) 14 | } 15 | }.onFailure { 16 | // TODO: Propagate error. 17 | } 18 | } 19 | } 20 | 21 | fun editFile(file: File) { 22 | Desktop.getDesktop().apply { 23 | runCatching { 24 | if (isSupported(Desktop.Action.EDIT)) { 25 | edit(file) 26 | } 27 | }.recoverCatching { 28 | if (isSupported(Desktop.Action.OPEN)) { 29 | open(file) 30 | } 31 | }.onFailure { 32 | // TODO: Propagate error. 33 | } 34 | } 35 | } 36 | 37 | fun openUrlInBrowser(url: String) { 38 | runCatching { 39 | val uri = URI(url) 40 | Desktop.getDesktop().apply { 41 | if (isSupported(Desktop.Action.BROWSE)) { 42 | browse(uri) 43 | } 44 | } 45 | } 46 | } 47 | --------------------------------------------------------------------------------