diff --git a/app/lib/frontend/handlers/experimental.dart b/app/lib/frontend/handlers/experimental.dart index d745713e2b..01c7617f97 100644 --- a/app/lib/frontend/handlers/experimental.dart +++ b/app/lib/frontend/handlers/experimental.dart @@ -14,6 +14,7 @@ const _publicFlags = { final _allFlags = { 'dark-as-default', + 'expose-licence-diff', ..._publicFlags.map((x) => x.name), }; @@ -88,6 +89,8 @@ class ExperimentalFlags { bool get isDarkModeDefault => isEnabled('dark-as-default'); + late final isExposeLicenseDiffEnabled = isEnabled('expose-licence-diff'); + String encodedAsCookie() => _enabled.join(':'); @override diff --git a/app/lib/frontend/templates/package.dart b/app/lib/frontend/templates/package.dart index 912ee47879..b40c8ff015 100644 --- a/app/lib/frontend/templates/package.dart +++ b/app/lib/frontend/templates/package.dart @@ -5,6 +5,8 @@ import 'package:_pub_shared/data/page_data.dart'; import 'package:_pub_shared/search/tags.dart'; import 'package:collection/collection.dart' show IterableExtension; +import 'package:pana/pana.dart'; +import 'package:pub_dev/frontend/request_context.dart'; import 'package:pub_dev/frontend/templates/views/pkg/liked_package_list.dart'; import '../../package/models.dart'; @@ -378,13 +380,99 @@ Tab _installTab(PackagePageData data) { } Tab _licenseTab(PackagePageData data) { - final license = data.hasLicense - ? renderFile(data.asset!, urlResolverFn: data.urlResolverFn) - : d.text('No license file found.'); + final licenses = data.scoreCard.panaReport?.licenses; + final hasEditOpData = + licenses != null && + licenses.isNotEmpty && + licenses.any((l) => l.operations?.isNotEmpty ?? false); + late d.Node content; + if (!data.hasLicense) { + content = d.text('No license file found.'); + } else if (hasEditOpData && + requestContext.experimentalFlags.isExposeLicenseDiffEnabled) { + final text = data.asset!.textContent!; + final opAndLicensePairs = + licenses + .expand((l) => (l.operations ?? []).map((op) => (op, l))) + .toList() + ..sort((a, b) => a.$1.start.compareTo(b.$1.start)); + final nodes = []; + var offset = 0; + for (final (op, _) in opAndLicensePairs) { + if (offset < op.start) { + nodes.add( + d.span( + classes: ['license-op-insert'], + text: text.substring(offset, op.start), + attributes: {'title': 'inserted content'}, + ), + ); + offset = op.start; + } + switch (op.type) { + case TextOpType.delete: + nodes.add( + d.span( + classes: ['license-op-delete', 'license-op-delete-hidden'], + children: [ + d.span( + classes: ['license-op-delete-icon'], + text: '✄', + attributes: {'tabindex': '-1'}, + ), + d.span( + classes: ['license-op-delete-content'], + text: op.content, + ), + ], + attributes: {'title': 'deleted content'}, + ), + ); + break; + case TextOpType.insert: + final end = op.start + op.length; + nodes.add( + d.span( + classes: ['license-op-insert'], + text: text.substring(op.start, end), + attributes: {'title': 'inserted content'}, + ), + ); + offset = end; + break; + case TextOpType.match: + final end = op.start + op.length; + nodes.add( + d.span( + classes: ['license-op-match'], + text: text.substring(op.start, end), + attributes: {'title': 'matched content'}, + ), + ); + offset = end; + break; + } + } + if (offset < text.length) { + nodes.add( + d.span( + classes: ['license-op-insert'], + text: text.substring(offset), + attributes: {'title': 'inserted content'}, + ), + ); + } + content = d.div( + classes: ['highlight'], + child: d.pre(children: nodes), + ); + } else { + content = renderFile(data.asset!, urlResolverFn: data.urlResolverFn); + } return Tab.withContent( id: 'license', title: 'License', - contentNode: d.fragment([d.h2(text: 'License'), license]), + contentNode: d.fragment([d.h2(text: 'License'), content]), isMarkdown: true, ); } diff --git a/pkg/web_app/lib/src/foldable.dart b/pkg/web_app/lib/src/foldable.dart index 1b9c528b84..302a4bbdd1 100644 --- a/pkg/web_app/lib/src/foldable.dart +++ b/pkg/web_app/lib/src/foldable.dart @@ -12,6 +12,7 @@ import 'web_util.dart'; void setupFoldable() { _setEventForFoldable(); _setEventForCheckboxToggle(); + _setEventForLicenseDeleteIcons(); } /// Elements with the `foldable` class provide a folding content: @@ -106,3 +107,15 @@ void _setEventForCheckboxToggle() { }); } } + +/// Setup a toggle event for the delete operation icons in licenses. +void _setEventForLicenseDeleteIcons() { + final icons = document.body! + .querySelectorAll('.license-op-delete-icon') + .toElementList(); + for (final icon in icons) { + icon.onClick.listen((event) { + icon.parentElement!.classList.toggle('license-op-delete-hidden'); + }); + } +} diff --git a/pkg/web_css/lib/src/_pkg.scss b/pkg/web_css/lib/src/_pkg.scss index ca2cba9a2e..b38d01b7b5 100644 --- a/pkg/web_css/lib/src/_pkg.scss +++ b/pkg/web_css/lib/src/_pkg.scss @@ -582,3 +582,25 @@ } } } + +.license-op-delete { + background: var(--pub-license-editop-delete); + + &.license-op-delete-hidden { + .license-op-delete-content { + display: none; + } + } + + .license-op-delete-icon { + cursor: pointer; + } +} + +.license-op-insert { + background: var(--pub-license-editop-insert); +} + +.license-op-match { + background: var(--pub-license-editop-match); +} diff --git a/pkg/web_css/lib/src/_variables.scss b/pkg/web_css/lib/src/_variables.scss index 275a1d19a3..5683eef93e 100644 --- a/pkg/web_css/lib/src/_variables.scss +++ b/pkg/web_css/lib/src/_variables.scss @@ -101,6 +101,11 @@ // Opacity values used to display monochrome icons. --pub-monochrome-opacity-initial: 0.6; --pub-monochrome-opacity-hover: 1.0; + + // Incomplete colors for license text edit ops. + --pub-license-editop-delete: rgba(255, 0, 0, 0.2); + --pub-license-editop-insert: rgba(255, 255, 0, 0.2); + --pub-license-editop-match: rgba(0, 255, 0, 0.2); } /// Variables that are specific to the light theme.