diff --git a/client/extension-api-types/src/textDocument.d.ts b/client/extension-api-types/src/textDocument.d.ts index 3f49b27f4a46..79104da0399a 100644 --- a/client/extension-api-types/src/textDocument.d.ts +++ b/client/extension-api-types/src/textDocument.d.ts @@ -12,3 +12,17 @@ export interface TextDocumentDecoration /** The range that the decoration applies to. */ range: Range } + +export interface InsightDecoration + extends Pick> { + /** The range that the decoration applies to. */ + range: Range + + /** The raw html to render in line. */ + content: JSX.Element + + /** The JSX Element to render in the popover */ + popover: JSX.Element + + trigger?: 'hover' | 'click' +} diff --git a/client/shared/src/api/extension/api/decorations.ts b/client/shared/src/api/extension/api/decorations.ts index 387f8dad3ae2..bc02612c45ee 100644 --- a/client/shared/src/api/extension/api/decorations.ts +++ b/client/shared/src/api/extension/api/decorations.ts @@ -8,7 +8,7 @@ import { } from 'sourcegraph' import { hasProperty } from '@sourcegraph/common' -import { TextDocumentDecoration } from '@sourcegraph/extension-api-types' +import { InsightDecoration, TextDocumentDecoration } from '@sourcegraph/extension-api-types' // LINE AND COLUMN DECORATIONS @@ -46,15 +46,17 @@ export function decorationAttachmentStyleForTheme( return { ...base, ...overrides } } -export type DecorationMapByLine = ReadonlyMap +export type DecorationMapByLine = ReadonlyMap /** * @returns Map from 1-based line number to non-empty array of TextDocumentDecoration for that line * * @todo this does not handle decorations that span multiple lines */ -export const groupDecorationsByLine = (decorations: TextDocumentDecoration[] | null): DecorationMapByLine => { - const grouped = new Map() +export const groupDecorationsByLine = ( + decorations: (TextDocumentDecoration | InsightDecoration)[] | null +): DecorationMapByLine => { + const grouped = new Map() for (const decoration of decorations || []) { const lineNumber = decoration.range.start.line + 1 const decorationsForLine = grouped.get(lineNumber) diff --git a/client/web/src/insights/components/InsightDecorationContent.module.scss b/client/web/src/insights/components/InsightDecorationContent.module.scss new file mode 100644 index 000000000000..a6208cbb0781 --- /dev/null +++ b/client/web/src/insights/components/InsightDecorationContent.module.scss @@ -0,0 +1,5 @@ +.insight-decoration-content { + background-color: var(--primary-4); + padding: 1px 3px; + cursor: pointer; +} diff --git a/client/web/src/insights/components/InsightDecorationContent.tsx b/client/web/src/insights/components/InsightDecorationContent.tsx new file mode 100644 index 000000000000..59524f8f1069 --- /dev/null +++ b/client/web/src/insights/components/InsightDecorationContent.tsx @@ -0,0 +1,11 @@ +import { forwardRef, PropsWithChildren } from 'react' + +import styles from './InsightDecorationContent.module.scss' + +export const InsightDecorationContent = forwardRef>(({ children }, ref) => ( + + {children} + +)) + +InsightDecorationContent.displayName = 'InsightDecorationContent' diff --git a/client/web/src/insights/components/InsightDecorationPopover.module.scss b/client/web/src/insights/components/InsightDecorationPopover.module.scss new file mode 100644 index 000000000000..d8741b4ac104 --- /dev/null +++ b/client/web/src/insights/components/InsightDecorationPopover.module.scss @@ -0,0 +1,29 @@ +.insight-decoration-popover { + background-color: var(--color-bg-1); + padding: 12px; + border-radius: 4px; + border: solid 1px var(--border-color); + box-shadow: var(--dropdown-shadow); +} + +.insight-decoration-line-ref { + color: var(--text-muted); + font-size: 0.625rem; + font-weight: 400; +} + +.insight-decoration-link { + font-size: 0.75rem; + font-weight: 400; + cursor: pointer; +} + +.insight-decoration-row { + padding-left: 16px; + margin-left: 4px; + border-left: solid 2px var(--border-color-2); +} + +.insight-decoration-section { + margin-bottom: 8px; +} diff --git a/client/web/src/insights/components/InsightDecorationPopover.tsx b/client/web/src/insights/components/InsightDecorationPopover.tsx new file mode 100644 index 000000000000..967ff42c46dd --- /dev/null +++ b/client/web/src/insights/components/InsightDecorationPopover.tsx @@ -0,0 +1,48 @@ +import { FC } from 'react' + +import classNames from 'classnames' +import OpenInNewIcon from 'mdi-react/OpenInNewIcon' + +import { Link } from '@sourcegraph/wildcard' + +import styles from './InsightDecorationPopover.module.scss' + +interface TokenInsight { + id: string + name: string + url: string +} + +interface Token { + token: string + insights: TokenInsight[] +} + +interface InsightDecorationPopoverProps { + tokens: Token[] +} + +export const InsightDecorationPopover: FC = ({ tokens }) => ( +
+ {tokens.map(token => ( +
+
+ {'{}'} + + {token.token} + +
+
+ Insights referencing this line ({token.insights.length}) +
+ {token.insights.map(insight => ( +
+ + {insight.name} + +
+ ))} +
+ ))} +
+) diff --git a/client/web/src/insights/hooks/useCodeInsightsData.ts b/client/web/src/insights/hooks/useCodeInsightsData.ts new file mode 100644 index 000000000000..a12f9b8663b1 --- /dev/null +++ b/client/web/src/insights/hooks/useCodeInsightsData.ts @@ -0,0 +1,37 @@ +import { QueryResult } from '@apollo/client' + +import { gql, useQuery } from '@sourcegraph/http-client' + +import { GetRelatedInsightsInlineResult } from '../../graphql-operations' + +const RELATED_INSIGHTS_INLINE_QUERY = gql` + query GetRelatedInsightsInline($input: RelatedInsightsInput!) { + relatedInsightsInline(input: $input) { + viewId + title + lineNumbers + text + } + } +` + +interface UseCodeInsightsDataInput { + file: string + revision: string + repo: string +} + +export const useCodeInsightsData = ({ + file, + revision, + repo, +}: UseCodeInsightsDataInput): QueryResult => + useQuery(RELATED_INSIGHTS_INLINE_QUERY, { + variables: { + input: { + file, + revision, + repo, + }, + }, + }) diff --git a/client/web/src/repo/blob/Blob.tsx b/client/web/src/repo/blob/Blob.tsx index 639db78fd874..f3abbc21bbcd 100644 --- a/client/web/src/repo/blob/Blob.tsx +++ b/client/web/src/repo/blob/Blob.tsx @@ -55,7 +55,7 @@ import { addLineRangeQueryParameter, formatSearchParameters, } from '@sourcegraph/common' -import { TextDocumentDecoration } from '@sourcegraph/extension-api-types' +import { InsightDecoration, TextDocumentDecoration } from '@sourcegraph/extension-api-types' import { ActionItemAction } from '@sourcegraph/shared/src/actions/ActionItem' import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' import { FlatExtensionHostAPI } from '@sourcegraph/shared/src/api/contract' @@ -87,6 +87,9 @@ import { Code, useObservable } from '@sourcegraph/wildcard' import { getHover, getDocumentHighlights } from '../../backend/features' import { WebHoverOverlay } from '../../components/shared' import { StatusBar } from '../../extensions/components/StatusBar' +import { InsightDecorationContent } from '../../insights/components/InsightDecorationContent' +import { InsightDecorationPopover } from '../../insights/components/InsightDecorationPopover' +import { useCodeInsightsData } from '../../insights/hooks/useCodeInsightsData' import { enableExtensionsDecorationsColumnViewFromSettings } from '../../util/settings' import { HoverThresholdProps } from '../RepoContainer' @@ -177,6 +180,62 @@ const domFunctions = { const STATUS_BAR_HORIZONTAL_GAP_VAR = '--blob-status-bar-horizontal-gap' const STATUS_BAR_VERTICAL_GAP_VAR = '--blob-status-bar-vertical-gap' +// BEGIN STATIC CODE INSIGHTS TEST DATA +const insightTokens = [ + { + token: 'AuthURLPrefix', + insights: [ + { + id: 'foo', + name: 'Track Middleware', + url: '/insights/foo', + }, + { + id: 'bar', + name: 'API Middleware', + url: '/insights/bar', + }, + { + id: 'baz', + name: 'API Tracking', + url: '/insights/baz', + }, + ], + }, + { + token: '"/.auth"', + insights: [ + { + id: 'foo', + name: 'Auth Middleware', + url: '/insights/foo', + }, + { + id: 'bar', + name: 'Auth APIs', + url: '/insights/bar', + }, + ], + }, +] + +const decoration: InsightDecoration = { + range: { + start: { line: 2, character: 0 }, + end: { line: 2, character: 0 }, + }, + content: ( + + <> + Referenced in 5 insights 📈 + + + ), + popover: , + trigger: 'click', +} +// END STATIC CODE INSIGHTS TEST DATA + /** * Renders a code view augmented by Sourcegraph extensions * @@ -307,8 +366,111 @@ export const Blob: React.FunctionComponent> = }, [blobInfo, nextBlobInfoChange, viewerUpdates]) const [decorationsOrError, setDecorationsOrError] = useState< - [TextDocumentDecorationType, TextDocumentDecoration[]][] | Error | undefined + [TextDocumentDecorationType, (TextDocumentDecoration | InsightDecoration)[]][] | Error | undefined >() + const [insightDecorations, setInsightsDecorations] = useState(new Map()) + + const { data } = useCodeInsightsData({ + file: blobInfo.filePath, + repo: blobInfo.repoName, + revision: blobInfo.revision, + }) + + /** + * + [ + { + "viewId": "2CVBE12Ou8AG21gyJJj97Qf34S7", + "title": "CodeInsightsQueryDefaults usage", + "lineNumbers": [ + 42, + 41 + ], + "text": [ + "CodeInsightsQueryDefaults", + "CodeInsightsQueryDefaults" + ] + } +] + */ + + let insightDecorationsData: InsightDecoration[] = useMemo(() => [], []) + const dataLoaded = useRef(false) + + if (data && !dataLoaded.current) { + dataLoaded.current = true + const { relatedInsightsInline } = data + const linesWithInsightData: { [key: number]: any[] } = {} + + for (const relatedInsight of relatedInsightsInline) { + for (const [index, line] of relatedInsight.lineNumbers.entries()) { + const lineData = linesWithInsightData[line] + + if (!lineData) { + linesWithInsightData[line] = [ + { + token: relatedInsight.text[index], + insights: [ + { + id: relatedInsight.viewId, + name: relatedInsight.title, + url: `/insights/insight/${relatedInsight.viewId}`, + }, + ], + }, + ] + } else { + const token = lineData.find(tokenData => tokenData.token === relatedInsight.text[index]) + + if (!token) { + lineData.push({ + token: relatedInsight.text[index], + insights: [ + { + id: relatedInsight.viewId, + name: relatedInsight.title, + url: `/insights/insight/${relatedInsight.viewId}`, + }, + ], + }) + } else { + token.insights.push({ + id: relatedInsight.viewId, + name: relatedInsight.title, + url: `/insights/insight/${relatedInsight.viewId}`, + }) + } + } + } + } + + insightDecorationsData = Object.entries(linesWithInsightData).reduce((accumulator, lineData) => { + const [line, lineInsights] = lineData + const lineInt = parseInt(line, 10) + + const decoration: InsightDecoration = { + range: { + start: { line: lineInt, character: 0 }, + end: { line: lineInt, character: 0 }, + }, + content: ( + + <> + Referenced in {lineInsights.length} insights 📈 + + + ), + popover: , + trigger: 'click', + } + + return [...accumulator, decoration] + }, [] as InsightDecoration[]) + } + + useEffect(() => setInsightsDecorations(groupDecorationsByLine(insightDecorationsData)), [insightDecorationsData]) + // TODO: Update this to an API call + // useEffect(() => setInsightsDecorations(groupDecorationsByLine([decoration])), []) const popoverCloses = useMemo(() => new Subject(), []) const nextPopoverClose = useCallback((click: void) => popoverCloses.next(click), [popoverCloses]) @@ -868,6 +1030,22 @@ export const Blob: React.FunctionComponent> = ) }) .toArray()} + {iterate(insightDecorations) + .map(([line, items]) => { + const portalID = toPortalID(line) + return ( + + ) + }) + .toArray()} {!props.disableStatusBar && ( getCodeElementFromLineNumber: (codeView: HTMLElement, line: number) => HTMLTableCellElement | null } @@ -135,38 +136,57 @@ const LineDecorator = React.memo( // Render decoration attachments into portal return ReactDOM.createPortal( - decorations?.filter(property('after', isDefined)).map((decoration, index) => { - const attachment = decoration.after - const style = decorationAttachmentStyleForTheme(attachment, isLightTheme) - - return ( - - { + // The original decoration path + if (isDefined(decoration.after)) { + const attachment = decoration.after + const style = decorationAttachmentStyleForTheme(attachment, isLightTheme) + + return ( + - - - - ) + + + + + ) + } + + // Content is only available on Insight decorations + if ('content' in decoration && isDefined(decoration.content)) { + return ( + + {decoration.content} + + ) + } + + return null }), portalNode ) diff --git a/cmd/frontend/graphqlbackend/insights.go b/cmd/frontend/graphqlbackend/insights.go index 446cba93cad4..4fb46d241ac6 100644 --- a/cmd/frontend/graphqlbackend/insights.go +++ b/cmd/frontend/graphqlbackend/insights.go @@ -474,12 +474,13 @@ type RelatedInsightsRepoInput struct { } type RelatedInsightsInlineResolver interface { - ViewID() string + ViewID() graphql.ID Title() string LineNumbers() []int32 + Text() []string } type RelatedInsightsResolver interface { - ViewID() string + ViewID() graphql.ID Title() string } diff --git a/cmd/frontend/graphqlbackend/insights.graphql b/cmd/frontend/graphqlbackend/insights.graphql index 0a5078fc7018..22e91d3b87ff 100644 --- a/cmd/frontend/graphqlbackend/insights.graphql +++ b/cmd/frontend/graphqlbackend/insights.graphql @@ -265,7 +265,7 @@ type RelatedInsightInline { """ Insight view id """ - viewId: String! + viewId: ID! """ Insight title """ @@ -274,6 +274,10 @@ type RelatedInsightInline { An array of line numbers where matches are found """ lineNumbers: [Int!]! + """ + The text that was matched + """ + text: [String!]! } """ @@ -283,7 +287,7 @@ type RelatedInsight { """ Insight view id """ - viewId: String! + viewId: ID! """ Insight title """ diff --git a/enterprise/internal/insights/query/streaming/decoder.go b/enterprise/internal/insights/query/streaming/decoder.go index ea40bdcbb55f..465f1d9893e8 100644 --- a/enterprise/internal/insights/query/streaming/decoder.go +++ b/enterprise/internal/insights/query/streaming/decoder.go @@ -123,8 +123,6 @@ func MetadataDecoder() (streamhttp.FrontendStreamDecoder, *MetadataResult) { OnMatches: func(matches []streamhttp.EventMatch) { for _, match := range matches { switch match := match.(type) { - // Right now we only care about inline matches. - // Should be extended when we care about repo and file results. case *streamhttp.EventContentMatch: mr.Matches = append(mr.Matches, &SearchMatch{LineMatches: match.LineMatches}) case *streamhttp.EventPathMatch: diff --git a/enterprise/internal/insights/resolvers/related_insights_resolvers.go b/enterprise/internal/insights/resolvers/related_insights_resolvers.go index 7df4416fa05e..1c5ca52c9dcc 100644 --- a/enterprise/internal/insights/resolvers/related_insights_resolvers.go +++ b/enterprise/internal/insights/resolvers/related_insights_resolvers.go @@ -5,12 +5,15 @@ import ( "sort" "strings" + "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/relay" "github.com/sourcegraph/log" "github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend" "github.com/sourcegraph/sourcegraph/enterprise/internal/insights/query/querybuilder" "github.com/sourcegraph/sourcegraph/enterprise/internal/insights/query/streaming" "github.com/sourcegraph/sourcegraph/enterprise/internal/insights/store" + "github.com/sourcegraph/sourcegraph/enterprise/internal/insights/types" "github.com/sourcegraph/sourcegraph/lib/errors" ) @@ -29,6 +32,7 @@ func (r *Resolver) RelatedInsightsInline(ctx context.Context, args graphqlbacken if err != nil { return nil, errors.Wrap(err, "GetAll") } + allSeries = limitSeries(allSeries) seriesMatches := map[string]*relatedInsightInlineMetadata{} for _, series := range allSeries { @@ -54,10 +58,19 @@ func (r *Resolver) RelatedInsightsInline(ctx context.Context, args graphqlbacken for _, match := range mr.Matches { for _, lineMatch := range match.LineMatches { + lowBound := lineMatch.OffsetAndLengths[0][0] + highBound := lowBound + lineMatch.OffsetAndLengths[0][1] + text := lineMatch.Line[lowBound:highBound] + if seriesMatches[series.UniqueID] == nil { - seriesMatches[series.UniqueID] = &relatedInsightInlineMetadata{title: series.Title, lineNumbers: []int32{lineMatch.LineNumber}} - } else if !containsInt(seriesMatches[series.UniqueID].lineNumbers, lineMatch.LineNumber) { + seriesMatches[series.UniqueID] = &relatedInsightInlineMetadata{ + title: series.Title, + lineNumbers: []int32{lineMatch.LineNumber}, + text: []string{text}, + } + } else { seriesMatches[series.UniqueID].lineNumbers = append(seriesMatches[series.UniqueID].lineNumbers, lineMatch.LineNumber) + seriesMatches[series.UniqueID].text = append(seriesMatches[series.UniqueID].text, text) } } } @@ -65,10 +78,12 @@ func (r *Resolver) RelatedInsightsInline(ctx context.Context, args graphqlbacken var resolvers []graphqlbackend.RelatedInsightsInlineResolver for insightId, metadata := range seriesMatches { - sort.SliceStable(metadata.lineNumbers, func(i, j int) bool { - return metadata.lineNumbers[i] < metadata.lineNumbers[j] + resolvers = append(resolvers, &relatedInsightsInlineResolver{ + viewID: insightId, + title: metadata.title, + lineNumbers: metadata.lineNumbers, + text: metadata.text, }) - resolvers = append(resolvers, &relatedInsightsInlineResolver{viewID: insightId, title: metadata.title, lineNumbers: metadata.lineNumbers}) } return resolvers, nil } @@ -76,18 +91,20 @@ func (r *Resolver) RelatedInsightsInline(ctx context.Context, args graphqlbacken type relatedInsightInlineMetadata struct { title string lineNumbers []int32 + text []string } type relatedInsightsInlineResolver struct { viewID string title string lineNumbers []int32 + text []string baseInsightResolver } -func (r *relatedInsightsInlineResolver) ViewID() string { - return r.viewID +func (r *relatedInsightsInlineResolver) ViewID() graphql.ID { + return relay.MarshalID("insight_view", r.viewID) } func (r *relatedInsightsInlineResolver) Title() string { @@ -98,6 +115,10 @@ func (r *relatedInsightsInlineResolver) LineNumbers() []int32 { return r.lineNumbers } +func (r *relatedInsightsInlineResolver) Text() []string { + return r.text +} + func (r *Resolver) RelatedInsightsForFile(ctx context.Context, args graphqlbackend.RelatedInsightsArgs) ([]graphqlbackend.RelatedInsightsResolver, error) { validator := PermissionsValidatorFromBase(&r.baseInsightResolver) validator.loadUserContext(ctx) @@ -111,6 +132,7 @@ func (r *Resolver) RelatedInsightsForFile(ctx context.Context, args graphqlbacke if err != nil { return nil, errors.Wrap(err, "GetAll") } + allSeries = limitSeries(allSeries) var resolvers []graphqlbackend.RelatedInsightsResolver matchedInsightViews := map[string]bool{} @@ -164,6 +186,7 @@ func (r *Resolver) RelatedInsightsForRepo(ctx context.Context, args graphqlbacke if err != nil { return nil, errors.Wrap(err, "GetAll") } + allSeries = limitSeries(allSeries) var resolvers []graphqlbackend.RelatedInsightsResolver matchedInsightViews := map[string]bool{} @@ -211,19 +234,21 @@ type relatedInsightsResolver struct { baseInsightResolver } -func (r *relatedInsightsResolver) ViewID() string { - return r.viewID +func (r *relatedInsightsResolver) ViewID() graphql.ID { + return relay.MarshalID("insight_view", r.viewID) } func (r *relatedInsightsResolver) Title() string { return r.title } -func containsInt(array []int32, findElement int32) bool { - for _, currentElement := range array { - if findElement == currentElement { - return true - } - } - return false +// Limiting the number of series/queries to 50 will have no impact on the vast majority of customers. +// However, our own test environments have hundreds of insights and we need to limit these queries in some way +// so that the endpoints do not time out. +// This returns the 50 most recent series. +func limitSeries(series []types.InsightViewSeries) []types.InsightViewSeries { + sort.SliceStable(series, func(i, j int) bool { + return series[i].CreatedAt.After(series[j].CreatedAt) + }) + return series[:minInt(50, int32(len(series)))] } diff --git a/package.json b/package.json index 6316e8475a6b..e926c2deb35a 100644 --- a/package.json +++ b/package.json @@ -385,6 +385,7 @@ "@sourcegraph/extension-api-classes": "^1.1.0", "@stripe/react-stripe-js": "^1.8.0-0", "@stripe/stripe-js": "^1.29.0", + "@tippyjs/react": "^4.2.6", "@visx/annotation": "^2.10.0", "@visx/axis": "^2.10.0", "@visx/glyph": "^2.10.0", diff --git a/yarn.lock b/yarn.lock index 1d88e48e6a6f..8abd02f0d395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3363,6 +3363,11 @@ qs "^6.7.0" url-parse "^1.4.7" +"@popperjs/core@^2.9.0": + version "2.11.5" + resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" + integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== + "@radix-ui/popper@0.1.0": version "0.1.0" resolved "https://registry.npmjs.org/@radix-ui/popper/-/popper-0.1.0.tgz#c387a38f31b7799e1ea0d2bb1ca0c91c2931b063" @@ -5233,6 +5238,13 @@ dependencies: "@babel/runtime" "^7.12.5" +"@tippyjs/react@^4.2.6": + version "4.2.6" + resolved "https://registry.npmjs.org/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71" + integrity sha512-91RicDR+H7oDSyPycI13q3b7o4O60wa2oRbjlz2fyRLmHImc4vyDwuUP8NtZaN0VARJY5hybvDYrFzhY9+Lbyw== + dependencies: + tippy.js "^6.3.1" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -23621,6 +23633,13 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3: resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tippy.js@^6.3.1: + version "6.3.7" + resolved "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c" + integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ== + dependencies: + "@popperjs/core" "^2.9.0" + title-case@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982"