From b5e695eea16dd0e793b710d4351210c708a3457c Mon Sep 17 00:00:00 2001 From: Farhan Attamimi Date: Fri, 7 Dec 2018 10:50:22 -0800 Subject: [PATCH] feat: add GenericSearchResult interface, and re-implement commit and diff search using it (#998) This adds the generic search result interface, which is implemented by all search result types except FileMatches. This makes diff, commit, and repository search results implement the GenericSearchResult interface, and includes the corresponding front end changes to use only the fields in GenericSearchResultInterface when displaying results. --- cmd/frontend/graphqlbackend/file.go | 16 + cmd/frontend/graphqlbackend/markdown.go | 19 + cmd/frontend/graphqlbackend/repository.go | 18 + cmd/frontend/graphqlbackend/schema.go | 60 ++- cmd/frontend/graphqlbackend/schema.graphql | 60 ++- cmd/frontend/graphqlbackend/search_commits.go | 124 ++++++- .../graphqlbackend/search_commits_test.go | 8 +- .../graphqlbackend/search_repositories.go | 3 +- .../graphqlbackend/search_result_match.go | 20 + cmd/frontend/graphqlbackend/textsearch.go | 1 - go.mod | 1 + package.json | 3 + pkg/highlight/highlight.go | 2 +- pkg/highlight/syntect_language_map.go | 346 ++++++++++++++++++ shared/src/components/Markdown.scss | 22 ++ shared/src/components/Markdown.tsx | 9 +- shared/src/components/ResultContainer.scss | 13 + shared/src/components/ResultContainer.tsx | 8 +- shared/src/util/dom.tsx | 9 +- web/src/SourcegraphWebApp.scss | 2 + web/src/components/DecoratedTextLines.tsx | 9 +- web/src/components/SearchResult.scss | 9 + web/src/components/SearchResult.tsx | 95 +++++ web/src/components/SearchResultMatch.scss | 151 ++++++++ web/src/components/SearchResultMatch.tsx | 192 ++++++++++ web/src/discussions/backend.tsx | 37 +- web/src/highlight.ts | 2 + .../blob/discussions/DiscussionsInput.tsx | 2 +- web/src/search/backend.tsx | 118 +++--- web/src/search/results/SearchResultsList.tsx | 23 +- yarn.lock | 21 +- 31 files changed, 1301 insertions(+), 102 deletions(-) create mode 100644 cmd/frontend/graphqlbackend/markdown.go create mode 100644 cmd/frontend/graphqlbackend/search_result_match.go create mode 100644 pkg/highlight/syntect_language_map.go create mode 100644 web/src/components/SearchResult.scss create mode 100644 web/src/components/SearchResult.tsx create mode 100644 web/src/components/SearchResultMatch.scss create mode 100644 web/src/components/SearchResultMatch.tsx diff --git a/cmd/frontend/graphqlbackend/file.go b/cmd/frontend/graphqlbackend/file.go index cc130655a75..031202d5cd1 100644 --- a/cmd/frontend/graphqlbackend/file.go +++ b/cmd/frontend/graphqlbackend/file.go @@ -5,6 +5,7 @@ import ( "encoding/json" "html/template" "path" + "strings" "time" "github.com/sourcegraph/sourcegraph/cmd/frontend/backend" @@ -53,6 +54,21 @@ func (*schemaResolver) RenderMarkdown(args *struct { return markdown.Render(args.Markdown, nil) } +func (*schemaResolver) HighlightCode(ctx context.Context, args *struct { + Code string + FuzzyLanguage string + DisableTimeout bool + IsLightTheme bool +}) (string, error) { + language := highlight.SyntectLanguageMap[strings.ToLower(args.FuzzyLanguage)] + filePath := "file." + language + html, _, err := highlight.Code(ctx, []byte(args.Code), filePath, args.DisableTimeout, args.IsLightTheme) + if err != nil { + return args.Code, err + } + return string(html), nil +} + func (r *gitTreeEntryResolver) Binary(ctx context.Context) (bool, error) { content, err := r.Content(ctx) if err != nil { diff --git a/cmd/frontend/graphqlbackend/markdown.go b/cmd/frontend/graphqlbackend/markdown.go new file mode 100644 index 00000000000..da9534e888b --- /dev/null +++ b/cmd/frontend/graphqlbackend/markdown.go @@ -0,0 +1,19 @@ +package graphqlbackend + +import "github.com/sourcegraph/sourcegraph/pkg/markdown" + +type markdownResolver struct { + text string +} + +func (m *markdownResolver) Text() string { + return m.text +} + +func (m *markdownResolver) HTML() string { + html, err := markdown.Render(m.text, nil) + if err != nil { + return "" + } + return html +} diff --git a/cmd/frontend/graphqlbackend/repository.go b/cmd/frontend/graphqlbackend/repository.go index 14b93461d9a..fb117d1aa84 100644 --- a/cmd/frontend/graphqlbackend/repository.go +++ b/cmd/frontend/graphqlbackend/repository.go @@ -28,6 +28,8 @@ import ( type repositoryResolver struct { repo *types.Repo redirectURL *string + icon string + matches []*searchResultMatchResolver } func repositoryByID(ctx context.Context, id graphql.ID) (*repositoryResolver, error) { @@ -197,6 +199,22 @@ func (r *repositoryResolver) ExternalURLs(ctx context.Context) ([]*externallink. return externallink.Repository(ctx, r.repo) } +func (r *repositoryResolver) Icon() string { + return r.icon +} +func (r *repositoryResolver) Label() (*markdownResolver, error) { + text := "[" + string(r.repo.Name) + "](/" + string(r.repo.Name) + ")" + return &markdownResolver{text: text}, nil +} + +func (r *repositoryResolver) Detail() *markdownResolver { + return &markdownResolver{text: "Repository name match"} +} + +func (r *repositoryResolver) Matches() []*searchResultMatchResolver { + return r.matches +} + func (*schemaResolver) AddPhabricatorRepo(ctx context.Context, args *struct { Callsign string Name *string diff --git a/cmd/frontend/graphqlbackend/schema.go b/cmd/frontend/graphqlbackend/schema.go index d1e54b4e9ec..4603e0ba157 100644 --- a/cmd/frontend/graphqlbackend/schema.go +++ b/cmd/frontend/graphqlbackend/schema.go @@ -762,6 +762,8 @@ type Query { # Renders Markdown to HTML. The returned HTML is already sanitized and # escaped and thus is always safe to render. renderMarkdown(markdown: String!, options: MarkdownOptions): String! + # EXPERIMENTAL: Syntax highlights a code string. + highlightCode(code: String!, fuzzyLanguage: String!, disableTimeout: Boolean!, isLightTheme: Boolean!): String! # Looks up an instance of a type that implements SettingsSubject (i.e., something that has settings). This can # be a site (which has global settings), an organization, or a user. settingsSubject(id: ID!): SettingsSubject @@ -831,6 +833,42 @@ type Search { # A search result. union SearchResult = FileMatch | CommitSearchResult | Repository +# An object representing a markdown string. +type Markdown { + # The raw markdown string. + text: String! + # HTML for the rendered markdown string, or null if there is no HTML representation provided. + # If specified, clients should render this directly. + html: String! +} + +# A search result. Every type of search result, except FileMatch, must implement this interface. +interface GenericSearchResultInterface { + # URL to an icon that is displayed with every search result. + icon: String! + # A markdown string that is rendered prominently. + label: Markdown! + # The URL of the result. + url: String! + # A markdown string that is rendered less prominently. + detail: Markdown! + # A list of matches in this search result. + matches: [SearchResultMatch!]! +} + +# A match in a search result. Matches make up the body content of a search result. +type SearchResultMatch { + # URL for the individual result match. + url: String! + # A markdown string containing the preview contents of the result match. + body: Markdown! + # A list of highlights that specify locations of matches of the query in the body. Each highlight is + # a line number, character offset, and length. Currently, highlights are only displayed on match bodies + # that are code blocks. If the result body is a code block, exclude the markdown code fence lines in + # the line and character count. Leave as an empty list if no highlights are available. + highlights: [Highlight!]! +} + # Search results. type SearchResults { # The results. Inside each SearchResult there may be multiple matches, e.g. @@ -962,7 +1000,17 @@ type Diff { } # A search result that is a Git commit. -type CommitSearchResult { +type CommitSearchResult implements GenericSearchResultInterface { + # Base64 data uri to an icon. + icon: String! + # A markdown string that is rendered prominently. + label: Markdown! + # The URL of the result. + url: String! + # A markdown string of that is rendered less prominently. + detail: Markdown! + # The result previews of the result. + matches: [SearchResultMatch!]! # The commit that matched the search query. commit: GitCommit! # The ref names of the commit. @@ -1088,7 +1136,7 @@ type RepositoryConnection { } # A repository is a Git source control repository that is mirrored from some origin code host. -type Repository implements Node { +type Repository implements Node & GenericSearchResultInterface { # The repository's unique ID. id: ID! # The repository's name, as a path with one or more components. It conventionally consists of @@ -1197,6 +1245,14 @@ type Repository implements Node { redirectURL: String # Whether the viewer has admin privileges on this repository. viewerCanAdminister: Boolean! + # Base64 data uri to an icon. + icon: String! + # A markdown string that is rendered prominently. + label: Markdown! + # A markdown string of that is rendered less prominently. + detail: Markdown! + # The result previews of the result. + matches: [SearchResultMatch!]! } # A URL to a resource on an external service, such as the URL to a repository on its external (origin) code host. diff --git a/cmd/frontend/graphqlbackend/schema.graphql b/cmd/frontend/graphqlbackend/schema.graphql index 54875676051..aa090a65c7a 100755 --- a/cmd/frontend/graphqlbackend/schema.graphql +++ b/cmd/frontend/graphqlbackend/schema.graphql @@ -769,6 +769,8 @@ type Query { # Renders Markdown to HTML. The returned HTML is already sanitized and # escaped and thus is always safe to render. renderMarkdown(markdown: String!, options: MarkdownOptions): String! + # EXPERIMENTAL: Syntax highlights a code string. + highlightCode(code: String!, fuzzyLanguage: String!, disableTimeout: Boolean!, isLightTheme: Boolean!): String! # Looks up an instance of a type that implements SettingsSubject (i.e., something that has settings). This can # be a site (which has global settings), an organization, or a user. settingsSubject(id: ID!): SettingsSubject @@ -838,6 +840,42 @@ type Search { # A search result. union SearchResult = FileMatch | CommitSearchResult | Repository +# An object representing a markdown string. +type Markdown { + # The raw markdown string. + text: String! + # HTML for the rendered markdown string, or null if there is no HTML representation provided. + # If specified, clients should render this directly. + html: String! +} + +# A search result. Every type of search result, except FileMatch, must implement this interface. +interface GenericSearchResultInterface { + # URL to an icon that is displayed with every search result. + icon: String! + # A markdown string that is rendered prominently. + label: Markdown! + # The URL of the result. + url: String! + # A markdown string that is rendered less prominently. + detail: Markdown! + # A list of matches in this search result. + matches: [SearchResultMatch!]! +} + +# A match in a search result. Matches make up the body content of a search result. +type SearchResultMatch { + # URL for the individual result match. + url: String! + # A markdown string containing the preview contents of the result match. + body: Markdown! + # A list of highlights that specify locations of matches of the query in the body. Each highlight is + # a line number, character offset, and length. Currently, highlights are only displayed on match bodies + # that are code blocks. If the result body is a code block, exclude the markdown code fence lines in + # the line and character count. Leave as an empty list if no highlights are available. + highlights: [Highlight!]! +} + # Search results. type SearchResults { # The results. Inside each SearchResult there may be multiple matches, e.g. @@ -969,7 +1007,17 @@ type Diff { } # A search result that is a Git commit. -type CommitSearchResult { +type CommitSearchResult implements GenericSearchResultInterface { + # Base64 data uri to an icon. + icon: String! + # A markdown string that is rendered prominently. + label: Markdown! + # The URL of the result. + url: String! + # A markdown string of that is rendered less prominently. + detail: Markdown! + # The result previews of the result. + matches: [SearchResultMatch!]! # The commit that matched the search query. commit: GitCommit! # The ref names of the commit. @@ -1095,7 +1143,7 @@ type RepositoryConnection { } # A repository is a Git source control repository that is mirrored from some origin code host. -type Repository implements Node { +type Repository implements Node & GenericSearchResultInterface { # The repository's unique ID. id: ID! # The repository's name, as a path with one or more components. It conventionally consists of @@ -1204,6 +1252,14 @@ type Repository implements Node { redirectURL: String # Whether the viewer has admin privileges on this repository. viewerCanAdminister: Boolean! + # Base64 data uri to an icon. + icon: String! + # A markdown string that is rendered prominently. + label: Markdown! + # A markdown string of that is rendered less prominently. + detail: Markdown! + # The result previews of the result. + matches: [SearchResultMatch!]! } # A URL to a resource on an external service, such as the URL to a repository on its external (origin) code host. diff --git a/cmd/frontend/graphqlbackend/search_commits.go b/cmd/frontend/graphqlbackend/search_commits.go index ea3695bf8ee..4a6a053a162 100644 --- a/cmd/frontend/graphqlbackend/search_commits.go +++ b/cmd/frontend/graphqlbackend/search_commits.go @@ -28,6 +28,11 @@ type commitSearchResultResolver struct { sourceRefs []*gitRefResolver messagePreview *highlightedString diffPreview *highlightedString + icon string + label string + url string + detail string + matches []*searchResultMatchResolver } func (r *commitSearchResultResolver) Commit() *gitCommitResolver { return r.commit } @@ -35,6 +40,24 @@ func (r *commitSearchResultResolver) Refs() []*gitRefResolver { retur func (r *commitSearchResultResolver) SourceRefs() []*gitRefResolver { return r.sourceRefs } func (r *commitSearchResultResolver) MessagePreview() *highlightedString { return r.messagePreview } func (r *commitSearchResultResolver) DiffPreview() *highlightedString { return r.diffPreview } +func (r *commitSearchResultResolver) Icon() string { + return r.icon +} +func (r *commitSearchResultResolver) Label() *markdownResolver { + return &markdownResolver{text: r.label} +} + +func (r *commitSearchResultResolver) URL() string { + return r.url +} + +func (r *commitSearchResultResolver) Detail() *markdownResolver { + return &markdownResolver{text: r.detail} +} + +func (r *commitSearchResultResolver) Matches() []*searchResultMatchResolver { + return r.matches +} var mockSearchCommitDiffsInRepo func(ctx context.Context, repoRevs search.RepositoryRevisions, info *search.PatternInfo, query *query.Query) (results []*commitSearchResultResolver, limitHit, timedOut bool, err error) @@ -226,7 +249,8 @@ func searchCommitsInRepo(ctx context.Context, op commitSearchOp) (results []*com results = make([]*commitSearchResultResolver, len(rawResults)) for i, rawResult := range rawResults { commit := rawResult.Commit - results[i] = &commitSearchResultResolver{commit: toGitCommitResolver(repoResolver, &commit)} + commitResolver := toGitCommitResolver(repoResolver, &commit) + results[i] = &commitSearchResultResolver{commit: commitResolver} addRefs := func(dst *[]*gitRefResolver, src []string) { for _, ref := range src { @@ -238,7 +262,8 @@ func searchCommitsInRepo(ctx context.Context, op commitSearchOp) (results []*com } addRefs(&results[i].refs, rawResult.Refs) addRefs(&results[i].sourceRefs, rawResult.SourceRefs) - + var matchBody string + var highlights []*highlightedRange // TODO(sqs): properly combine message: and term values for type:commit searches if !op.diff { var patString string @@ -250,20 +275,111 @@ func searchCommitsInRepo(ctx context.Context, op commitSearchOp) (results []*com pat, err := regexp.Compile(patString) if err == nil { results[i].messagePreview = highlightMatches(pat, []byte(commit.Message)) + highlights = results[i].messagePreview.highlights } } else { results[i].messagePreview = &highlightedString{value: string(commit.Message)} } + matchBody = "```COMMIT_EDITMSG\n" + rawResult.Commit.Message + "\n```" } if rawResult.Diff != nil && op.diff { + highlights = fromVCSHighlights(rawResult.DiffHighlights) results[i].diffPreview = &highlightedString{ value: rawResult.Diff.Raw, - highlights: fromVCSHighlights(rawResult.DiffHighlights), + highlights: highlights, } + matchBody = "```diff\n" + cleanDiffPreview(highlights, rawResult.Diff.Raw) + "```" + } + + commitIcon := "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE3LDEyQzE3LDE0LjQyIDE1LjI4LDE2LjQ0IDEzLDE2LjlWMjFIMTFWMTYuOUM4LjcyLDE2LjQ0IDcsMTQuNDIgNywxMkM3LDkuNTggOC43Miw3LjU2IDExLDcuMVYzSDEzVjcuMUMxNS4yOCw3LjU2IDE3LDkuNTggMTcsMTJNMTIsOUEzLDMgMCAwLDAgOSwxMkEzLDMgMCAwLDAgMTIsMTVBMywzIDAgMCwwIDE1LDEyQTMsMyAwIDAsMCAxMiw5WiIgLz48L3N2Zz4=" + results[i].label = createLabel(rawResult, commitResolver) + if len(rawResult.Commit.ID) > 7 { + results[i].detail = string(rawResult.Commit.ID)[:7] + } else { + results[i].detail = string(rawResult.Commit.ID) + } + results[i].url = commitResolver.URL() + results[i].icon = commitIcon + match := &searchResultMatchResolver{body: matchBody, highlights: highlights, url: commitResolver.URL()} + matches := []*searchResultMatchResolver{match} + results[i].matches = matches + } + + return results, limitHit, timedOut, nil +} + +func cleanDiffPreview(highlights []*highlightedRange, rawDiffResult string) string { + // A map of line number to number of lines that have been ignored before the particular line number. + var lineByCountIgnored = make(map[int]int32) + // The line numbers of lines that were ignored. + var ignoredLineNumbers = make(map[int]bool) + + lines := strings.Split(rawDiffResult, "\n") + var finalLines []string + ignoreUntilAtAt := false + var countIgnored int32 + for i, line := range lines { + // ignore index, ---file, and +++file lines + if ignoreUntilAtAt && !strings.HasPrefix(line, "@@ ") { + ignoredLineNumbers[i] = true + countIgnored++ + continue + } else { + ignoreUntilAtAt = false + } + if strings.HasPrefix(line, "diff ") { + ignoreUntilAtAt = true + lineByCountIgnored[i] = countIgnored + l := strings.Replace(line, "diff --git ", "", 1) + finalLines = append(finalLines, l) + } else { + lineByCountIgnored[i] = countIgnored + finalLines = append(finalLines, line) } } - return results, limitHit, timedOut, nil + + for n := range highlights { + // For each highlight, adjust the line number by the number of lines that were + // ignored in the diff before. + linesIgnored := lineByCountIgnored[int(highlights[n].line)] + if ignoredLineNumbers[int(highlights[n].line)-1] { + // Effectively remove highlights that were on ignored lines by setting + // line to -1. + highlights[n].line = -1 + } + if linesIgnored > 0 { + highlights[n].line = highlights[n].line - linesIgnored + } + } + + return strings.Join(finalLines, "\n") +} + +func createLabel(rawResult *git.LogCommitSearchResult, commitResolver *gitCommitResolver) string { + message := commitSubject(rawResult.Commit.Message) + author := rawResult.Commit.Author.Name + repoName := displayRepoName(commitResolver.Repository().Name()) + repoURL := commitResolver.Repository().URL() + url := commitResolver.URL() + + return fmt.Sprintf("[%s](%s)   [%s](%s)   [%s](%s)", repoName, repoURL, author, url, message, url) +} + +func commitSubject(message string) string { + idx := strings.Index(message, "\n") + if idx != -1 { + return message[:idx] + } + return message +} + +func displayRepoName(repoPath string) string { + parts := strings.Split(repoPath, "/") + if len(parts) >= 3 && strings.Contains(parts[0], ".") { + parts = parts[1:] // remove hostname from repo path (reduce visual noise) + } + return strings.Join(parts, "/") } func highlightMatches(pattern *regexp.Regexp, data []byte) *highlightedString { diff --git a/cmd/frontend/graphqlbackend/search_commits_test.go b/cmd/frontend/graphqlbackend/search_commits_test.go index f8ff1ae8e53..243e92e41c5 100644 --- a/cmd/frontend/graphqlbackend/search_commits_test.go +++ b/cmd/frontend/graphqlbackend/search_commits_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/kylelemons/godebug/pretty" "github.com/sourcegraph/sourcegraph/cmd/frontend/db" "github.com/sourcegraph/sourcegraph/cmd/frontend/internal/pkg/search" "github.com/sourcegraph/sourcegraph/cmd/frontend/internal/pkg/search/query" @@ -68,9 +69,14 @@ func TestSearchCommitsInRepo(t *testing.T) { author: *toSignatureResolver(&git.Signature{}), }, diffPreview: &highlightedString{value: "x", highlights: []*highlightedRange{}}, + icon: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE3LDEyQzE3LDE0LjQyIDE1LjI4LDE2LjQ0IDEzLDE2LjlWMjFIMTFWMTYuOUM4LjcyLDE2LjQ0IDcsMTQuNDIgNywxMkM3LDkuNTggOC43Miw3LjU2IDExLDcuMVYzSDEzVjcuMUMxNS4yOCw3LjU2IDE3LDkuNTggMTcsMTJNMTIsOUEzLDMgMCAwLDAgOSwxMkEzLDMgMCAwLDAgMTIsMTVBMywzIDAgMCwwIDE1LDEyQTMsMyAwIDAsMCAxMiw5WiIgLz48L3N2Zz4=", + label: "[repo](/repo)   [](/repo/-/commit/c1)   [](/repo/-/commit/c1)", + url: "/repo/-/commit/c1", + detail: "c1", + matches: []*searchResultMatchResolver{&searchResultMatchResolver{url: "/repo/-/commit/c1", body: "```diff\nx```", highlights: []*highlightedRange{}}}, }, }; !reflect.DeepEqual(results, want) { - t.Errorf("results\ngot %v\nwant %v", results, want) + t.Errorf("results\ngot %v\nwant %v\ndiff: %v", results, want, pretty.Compare(results, want)) } if limitHit { t.Error("limitHit") diff --git a/cmd/frontend/graphqlbackend/search_repositories.go b/cmd/frontend/graphqlbackend/search_repositories.go index 311e1e32eb0..15ad98a4e76 100644 --- a/cmd/frontend/graphqlbackend/search_repositories.go +++ b/cmd/frontend/graphqlbackend/search_repositories.go @@ -9,6 +9,7 @@ import ( ) var mockSearchRepositories func(args *search.Args) ([]*searchResultResolver, *searchResultsCommon, error) +var repoIcon = "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIKCSB2aWV3Qm94PSIwIDAgNjQgNjQiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDY0IDY0OyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+Cjx0aXRsZT5JY29ucyA0MDA8L3RpdGxlPgo8Zz4KCTxwYXRoIGQ9Ik0yMywyMi40YzEuMywwLDIuNC0xLjEsMi40LTIuNHMtMS4xLTIuNC0yLjQtMi40Yy0xLjMsMC0yLjQsMS4xLTIuNCwyLjRTMjEuNywyMi40LDIzLDIyLjR6Ii8+Cgk8cGF0aCBkPSJNMzUsMjYuNGMxLjMsMCwyLjQtMS4xLDIuNC0yLjRzLTEuMS0yLjQtMi40LTIuNHMtMi40LDEuMS0yLjQsMi40UzMzLjcsMjYuNCwzNSwyNi40eiIvPgoJPHBhdGggZD0iTTIzLDQyLjRjMS4zLDAsMi40LTEuMSwyLjQtMi40cy0xLjEtMi40LTIuNC0yLjRzLTIuNCwxLjEtMi40LDIuNFMyMS43LDQyLjQsMjMsNDIuNHoiLz4KCTxwYXRoIGQ9Ik01MCwxNmgtMS41Yy0wLjMsMC0wLjUsMC4yLTAuNSwwLjV2MzVjMCwwLjMtMC4yLDAuNS0wLjUsMC41aC0yN2MtMC41LDAtMS0wLjItMS40LTAuNmwtMC42LTAuNmMtMC4xLTAuMS0wLjEtMC4yLTAuMS0wLjQKCQljMC0wLjMsMC4yLTAuNSwwLjUtMC41SDQ0YzEuMSwwLDItMC45LDItMlYxMmMwLTEuMS0wLjktMi0yLTJIMTRjLTEuMSwwLTIsMC45LTIsMnYzNi4zYzAsMS4xLDAuNCwyLjEsMS4yLDIuOGwzLjEsMy4xCgkJYzEuMSwxLjEsMi43LDEuOCw0LjIsMS44SDUwYzEuMSwwLDItMC45LDItMlYxOEM1MiwxNi45LDUxLjEsMTYsNTAsMTZ6IE0xOSwyMGMwLTIuMiwxLjgtNCw0LTRjMS40LDAsMi44LDAuOCwzLjUsMgoJCWMxLjEsMS45LDAuNCw0LjMtMS41LDUuNFYzM2MxLTAuNiwyLjMtMC45LDQtMC45YzEsMCwyLTAuNSwyLjgtMS4zQzMyLjUsMzAsMzMsMjkuMSwzMywyOHYtMC42Yy0xLjItMC43LTItMi0yLTMuNQoJCWMwLTIuMiwxLjgtNCw0LTRjMi4yLDAsNCwxLjgsNCw0YzAsMS41LTAuOCwyLjctMiwzLjVoMGMtMC4xLDIuMS0wLjksNC40LTIuNSw2Yy0xLjYsMS42LTMuNCwyLjQtNS41LDIuNWMtMC44LDAtMS40LDAuMS0xLjksMC4zCgkJYy0wLjIsMC4xLTEsMC44LTEuMiwwLjlDMjYuNiwzOCwyNywzOC45LDI3LDQwYzAsMi4yLTEuOCw0LTQsNHMtNC0xLjgtNC00YzAtMS41LDAuOC0yLjcsMi0zLjRWMjMuNEMxOS44LDIyLjcsMTksMjEuNCwxOSwyMHoiLz4KPC9nPgo8L3N2Zz4K" // searchRepositories searches for repositories by name. // @@ -52,7 +53,7 @@ func searchRepositories(ctx context.Context, args *search.Args, limit int32) (re break } if pattern.MatchString(string(repo.Repo.Name)) { - results = append(results, &searchResultResolver{repo: &repositoryResolver{repo: repo.Repo}}) + results = append(results, &searchResultResolver{repo: &repositoryResolver{repo: repo.Repo, icon: repoIcon}}) } } return results, common, nil diff --git a/cmd/frontend/graphqlbackend/search_result_match.go b/cmd/frontend/graphqlbackend/search_result_match.go new file mode 100644 index 00000000000..6b69a6c5faa --- /dev/null +++ b/cmd/frontend/graphqlbackend/search_result_match.go @@ -0,0 +1,20 @@ +package graphqlbackend + +// A resolver for the GraphQL type GenericSearchMatch +type searchResultMatchResolver struct { + url string + body string + highlights []*highlightedRange +} + +func (m *searchResultMatchResolver) URL() string { + return m.url +} + +func (m *searchResultMatchResolver) Body() *markdownResolver { + return &markdownResolver{text: m.body} +} + +func (m *searchResultMatchResolver) Highlights() []*highlightedRange { + return m.highlights +} diff --git a/cmd/frontend/graphqlbackend/textsearch.go b/cmd/frontend/graphqlbackend/textsearch.go index 9ced30381fc..4a6df47a7f3 100644 --- a/cmd/frontend/graphqlbackend/textsearch.go +++ b/cmd/frontend/graphqlbackend/textsearch.go @@ -65,7 +65,6 @@ type fileMatchResolver struct { uri string repo *types.Repo commitID api.CommitID // or empty for default branch - // inputRev is the Git revspec that the user originally requested to search. It is used to // preserve the original revision specifier from the user instead of navigating them to the // absolute commit ID when they select a result. diff --git a/go.mod b/go.mod index fc69ee9d818..21e8521485b 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( github.com/kevinburke/differ v0.0.0-20181006040839-bdfd927653c8 github.com/kevinburke/go-bindata v3.12.0+incompatible github.com/kr/text v0.1.0 + github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 github.com/lib/pq v1.0.0 github.com/lightstep/lightstep-tracer-go v0.15.4 github.com/mattn/goreman v0.2.1-0.20180930133601-738cf1257bd3 diff --git a/package.json b/package.json index c45c2e413f8..4866b7feab6 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,8 @@ "@sourcegraph/codeintellify": "^4.0.0", "@sourcegraph/react-loading-spinner": "0.0.6", "@sqs/jsonc-parser": "^1.0.3", + "@types/he": "^1.1.0", + "@types/sanitize-html": "^1.18.2", "abortable-rx": "^1.0.9", "babel-plugin-istanbul": "^5.0.1", "bootstrap": "^4.1.3", @@ -196,6 +198,7 @@ "downshift": "^2.0.14", "formdata-polyfill": "^3.0.9", "graphiql": "^0.11.11", + "he": "^1.2.0", "highlight.js": "^9.13.1", "intersection-observer": "^0.5.0", "lodash": "^4.17.10", diff --git a/pkg/highlight/highlight.go b/pkg/highlight/highlight.go index 170c896ec80..65ffbbd9eb8 100644 --- a/pkg/highlight/highlight.go +++ b/pkg/highlight/highlight.go @@ -58,7 +58,7 @@ func Code(ctx context.Context, content []byte, filepath string, disableTimeout b themechoice := "Sourcegraph" if isLightTheme { - themechoice = "Solarized (light)" + themechoice = "Sourcegraph (light)" } // Trim a single newline from the end of the file. This means that a file diff --git a/pkg/highlight/syntect_language_map.go b/pkg/highlight/syntect_language_map.go new file mode 100644 index 00000000000..f4cef78f385 --- /dev/null +++ b/pkg/highlight/syntect_language_map.go @@ -0,0 +1,346 @@ +package highlight + +// SyntectLanguageMap is a map that maps language identifiers that may be provided at the end of markdown code fences +// to valid syntect file extensions. +var SyntectLanguageMap = map[string]string{"txt": "txt", + "asa": "asa", + "asp": "asp", + "actionscript": "as", + "as": "as", + "applescript": "applescript", + "script editor": "script editor", + "batchfile": "bat", + "bat": "bat", + "cmd": "cmd", + "build": "build", + "c#": "cs", + "cs": "cs", + "csx": "csx", + "cpp": "cpp", + "cc": "cc", + "cp": "cp", + "cxx": "cxx", + "c++": "c++", + "c": "c", + "h": "h", + "hh": "hh", + "hpp": "hpp", + "hxx": "hxx", + "h++": "h++", + "inl": "inl", + "ipp": "ipp", + "cmakecache.txt": "CMakeCache.txt", + "cmakelists.txt": "CMakeLists.txt", + "cmake": "cmake", + "css": "css", + "css.erb": "css.erb", + "css.liquid": "css.liquid", + "capnp": "capnp", + "cg": "cg", + "clojure": "clj", + "clj": "clj", + "cljs": "cljs", + "cljc": "cljc", + "cljx": "cljx", + "crontab": "crontab", + "d": "d", + "di": "di", + "diff": "diff", + "patch": "patch", + "dockerfile": "Dockerfile", + "erlang": "erl", + "erl": "erl", + "hrl": "hrl", + "emakefile": "emakefile", + "yaws": "yaws", + "fsharp": "fs", + "forth": "frt", + "frt": "frt", + "essl": "essl", + "f.essl": "f.essl", + "v.essl": "v.essl", + "_v.essl": "_v.essl", + "_f.essl": "_f.essl", + "_vs.essl": "_vs.essl", + "_fs.essl": "_fs.essl", + "vs": "vs", + "fs": "fs", + "gs": "gs", + "vsh": "vsh", + "fsh": "fsh", + "gsh": "gsh", + "vshader": "vshader", + "fshader": "fshader", + "gshader": "gshader", + "vert": "vert", + "frag": "frag", + "geom": "geom", + "tesc": "tesc", + "tese": "tese", + "comp": "comp", + "glsl": "glsl", + "attributes": "attributes", + "gitattributes": "gitattributes", + ".gitattributes": "gitattributes", + "commit_editmsg": "COMMIT_EDITMSG", + "merge_msg": "MERGE_MSG", + "tag_editmsg": "TAG_EDITMSG", + "gitconfig": "gitconfig", + ".gitconfig": "gitconfig", + ".gitmodules": "gitmodules", + "exclude": "exclude", + "gitignore": "gitignore", + ".gitignore": "gitignore", + "gitlink": "git", + ".git": "git", + "gitlog": "gitlog", + "git-rebase-todo": "git-rebase-todo", + "go": "go", + "graphviz-dot": "dot", + "dot": "dot", + "gv": "gv", + "groovy": "groovy", + "gvy": "gvy", + "gradle": "gradle", + "fx": "fx", + "fxh": "fxh", + "hlsl": "hlsl", + "hlsli": "hlsli", + "usf": "usf", + "html": "html", + "htm": "htm", + "shtml": "shtml", + "xhtml": "xhtml", + "tmpl": "tmpl", + "tpl": "tpl", + "haskell": "hs", + "hs": "hs", + "literatehaskell": "lhs", + "lhs": "lhs", + "ini": "ini", + "inf": "INF", + "reg": "reg", + "lng": "lng", + "cfg": "cfg", + "url": "url", + ".editorconfig": "editorconfig", + "jsp": "jsp", + "java": "java", + "bsh": "bsh", + "javaproperties": "javaproperties", + "properties": "properties", + "json": "json", + "sublime-settings": "sublime-settings", + "sublime-menu": "sublime-menu", + "sublime-keymap": "sublime-keymap", + "sublime-mousemap": "sublime-mousemap", + "sublime-theme": "sublime-theme", + "sublime-build": "sublime-build", + "sublime-project": "sublime-project", + "sublime-completions": "sublime-completions", + "sublime-commands": "sublime-commands", + "sublime-macro": "sublime-macro", + "sublime-color-scheme": "sublime-color-scheme", + "javascript": "js", + "js": "js", + "jsx": "jsx", + "babel": "babel", + "es6": "es6", + "less": "less", + "bibteX": "bib", + "bib": "bib", + "latex": "ltx", + "tex": "tex", + "ltx": "ltx", + "sty": "sty", + "cls": "cls", + "lisp": "lisp", + "cl": "cl", + "clisp": "clisp", + "l": "l", + "mud": "mud", + "el": "el", + "scm": "scm", + "ss": "ss", + "lsp": "lsp", + "fasl": "fasl", + "lua": "lua", + "proj": "proj", + "targets": "targets", + "msbuild": "msbuild", + "csproj": "csproj", + "vbproj": "vbproj", + "fsproj": "fsproj", + "vcxproj": "vcxproj", + "make": "make", + "gnumakefile": "GNUmakefile", + "makefile": "makefile", + "ocamlmakefile": "OCamlMakefile", + "mak": "mak", + "mk": "mk", + "man": "man", + "md": "md", + "mdown": "mdown", + "markdown": "markdown", + "markdn": "markdn", + "matlab": "matlab", + "pom.xml": "pom.xml", + "mediawiki": "mediawiki", + "wikipedia": "wikipedia", + "wiki": "wiki", + "ninja": "ninja", + "ocaml": "ml", + "ml": "ml", + "mli": "mli", + "ocamllex": "mll", + "mll": "mll", + "ocamlyacc": "mly", + "mly": "mly", + "objective-c++": "mm", + "mm": "mm", + "objective-c": "m", + "m": "m", + "php": "php", + "php3": "php3", + "php4": "php4", + "php5": "php5", + "php7": "php7", + "phps": "phps", + "phpt": "phpt", + "phtml": "phtml", + "pascal": "pas", + "pas": "pas", + "p": "p", + "dpr": "dpr", + "spec": "spec", + "client": "client", + "perl": "pl", + "pm": "pm", + "pod": "pod", + "t": "t", + "pl": "pl", + "postscript": "ps", + "ps": "ps", + "eps": "eps", + "powershell": "ps1", + "ps1": "ps1", + "psm1": "psm1", + "psd1": "psd1", + "python": "py", + "py": "py", + "py3": "py3", + "pyw": "pyw", + "pyi": "pyi", + "pyx": "pyx", + "pyx.in": "pyx.in", + "pxd": "pxd", + "pxd.in": "pxd.in", + "pxi": "pxi", + "pxi.in": "pxi.in", + "rpy": "rpy", + "cpy": "cpy", + "sconstruct": "sconstruct", + "sconscript": "SConscript", + "gyp": "gyp", + "gypi": "gypi", + "snakefile": "snakefile", + "wscript": "wscript", + "r": "r", + "s": "s", + "rprofile": "Rprofile", + "rd": "rd", + "rails": "rails", + "rhtml": "rhtml", + "erb": "erb", + "html.erb": "html.erb", + "js.erb": "js.erb", + "haml": "haml", + "rubyonrails": "rxml", + "rxml": "rxml", + "builder": "builder", + "erbsql": "erbsql", + "sql.erb": "sql.erb", + "re": "re", + "restructuredtext": "rst", + "rst": "rst", + "rest": "rest", + "ruby": "rb", + "rb": "rb", + "appfile": "Appfile", + "appraisals": "Appraisals", + "berksfile": "Berksfile", + "brewfile": "Brewfile", + "capfile": "capfile", + "cgi": "cgi", + "cheffile": "cheffile", + "config.ru": "config.ru", + "deliverfile": "Deliverfile", + "fastfile": "Fastfile", + "fcgi": "fcgi", + "gemfile": "Gemfile", + "gemspec": "gemspec", + "guardfile": "Guardfile", + "irbrc": "irbrc", + "jbuilder": "jbuilder", + "podspec": "podspec", + "prawn": "prawn", + "rabl": "rabl", + "rake": "rake", + "rakefile": "Rakefile", + "rantfile": "Rantfile", + "rbx": "rbx", + "rjs": "rjs", + "ruby.rail": "ruby.rail", + "scanfile": "Scanfile", + "simplecov": "simplecov", + "snapfile": "Snapfile", + "thor": "thor", + "thorfile": "Thorfile", + "vagrantfile": "Vagrantfile", + "rs": "rs", + "sass": "sass", + "scss": "scss", + "sql": "sql", + "ddl": "ddl", + "dml": "dml", + "scala": "scala", + "sbt": "sbt", + "sh": "sh", + "bash": "bash", + "zsh": "zsh", + "fish": "fish", + ".bash_aliases": "bash_aliases", + ".bash_completions": "bash_completions", + ".bash_functions": "bash_functions", + ".bash_login": "bash_login", + ".bash_logout": "bash_logout", + ".bash_profile": "bash_profile", + ".bash_variables": "bash_variables", + ".bashrc": "bashrc", + ".profile": "profile", + ".textmate_init": "textmate_init", + "smalltalk": "st", + "st": "st", + "swift": "swift", + "adp": "adp", + "tcl": "tcl", + "toml": "toml", + "tml": "tml", + "lock": "lock", + "textile": "textile", + "thrift": "thrift", + "typescript": "ts", + "ts": "ts", + "typescriptreact": "tsx", + "tsx": "tsx", + "xml": "xml", + "xsd": "xsd", + "xslt": "xslt", + "tld": "tld", + "dtml": "dtml", + "rss": "rss", + "opml": "opml", + "svg": "svg", + "yaml": "yaml", + "yml": "yml", + "sublime-syntax": "sublime-syntax"} diff --git a/shared/src/components/Markdown.scss b/shared/src/components/Markdown.scss index 1dd9feadfeb..dbf57af26bb 100644 --- a/shared/src/components/Markdown.scss +++ b/shared/src/components/Markdown.scss @@ -70,6 +70,20 @@ img { max-width: 100%; } + + &.search-result-match__markdown { + table, + th, + td { + border: none; + } + + code, + pre { + background-color: transparent; + padding: 0; + } + } } .theme-light { @@ -98,5 +112,13 @@ color: #6a737d; border-left-color: #dfe2e5; } + + &.search-result-match__markdown { + code, + pre { + background-color: transparent; + padding: 0; + } + } } } diff --git a/shared/src/components/Markdown.tsx b/shared/src/components/Markdown.tsx index 23889db0961..5cef494de65 100644 --- a/shared/src/components/Markdown.tsx +++ b/shared/src/components/Markdown.tsx @@ -2,8 +2,15 @@ import * as React from 'react' interface Props { dangerousInnerHTML: string + className?: string + /** A function to attain a reference to the top-level div from a parent component. */ + refFn?: (ref: HTMLElement | null) => void } export const Markdown: React.FunctionComponent = (props: Props) => ( -
+
) diff --git a/shared/src/components/ResultContainer.scss b/shared/src/components/ResultContainer.scss index 83e9d6b6ce8..7f58f0652f3 100644 --- a/shared/src/components/ResultContainer.scss +++ b/shared/src/components/ResultContainer.scss @@ -1,4 +1,5 @@ .result-container { + position: relative; margin-bottom: 0.5rem; border-radius: 2px; @@ -26,11 +27,19 @@ &:hover { background-color: darken($background-color, 0.05); } + + p { + margin-bottom: 0; + } } &__toggle-matches-container { display: flex; } + + .icon-inline__filtered { + filter: contrast(0) saturate(700%) grayscale(100%) sepia(70%) hue-rotate(180deg) brightness(120%); + } } .theme-light { @@ -44,4 +53,8 @@ } } } + + .icon-inline__filtered { + filter: brightness(0%); + } } diff --git a/shared/src/components/ResultContainer.tsx b/shared/src/components/ResultContainer.tsx index 30cb5427d75..e2b28e5365a 100644 --- a/shared/src/components/ResultContainer.tsx +++ b/shared/src/components/ResultContainer.tsx @@ -60,6 +60,7 @@ export interface Props { /** Expand all results */ allExpanded?: boolean + stringIcon?: string } interface State { @@ -90,6 +91,7 @@ export class ResultContainer extends React.PureComponent { public render(): JSX.Element | null { const Icon = this.props.icon + const stringIcon = this.props.stringIcon ? this.props.stringIcon : undefined return (
{ } onClick={this.toggle} > - + {!!stringIcon ? ( + + ) : ( + + )}
{this.props.collapsible ? ( {this.props.title} diff --git a/shared/src/util/dom.tsx b/shared/src/util/dom.tsx index d63efd95293..213bdfa2881 100644 --- a/shared/src/util/dom.tsx +++ b/shared/src/util/dom.tsx @@ -14,7 +14,14 @@ export function highlightNode(node: HTMLElement, start: number, length: number): if (length > node.textContent!.length - start) { return } - + // We want to treat text nodes as walkable so they can be highlighted. Wrap these in a span and + // replace them in the DOM. + if (node.nodeType === Node.TEXT_NODE && node.textContent !== null) { + const sp = document.createElement('span') + sp.innerHTML = node.textContent + node.parentNode!.replaceChild(sp, node) + node = sp + } node.classList.add('annotated-selection-match') highlightNodeHelper(node, 0, start, length) } diff --git a/web/src/SourcegraphWebApp.scss b/web/src/SourcegraphWebApp.scss index 43429e7c0ad..7ade913e408 100644 --- a/web/src/SourcegraphWebApp.scss +++ b/web/src/SourcegraphWebApp.scss @@ -269,5 +269,7 @@ hr { @import './marketing/SurveyPage'; @import './components/SingleValueCard'; @import './repo/blob/discussions/DiscussionsTree'; +@import './components/SearchResult'; +@import './components/SearchResultMatch'; @import './extensions/shared'; diff --git a/web/src/components/DecoratedTextLines.tsx b/web/src/components/DecoratedTextLines.tsx index 28d99dd2d55..e55be412940 100644 --- a/web/src/components/DecoratedTextLines.tsx +++ b/web/src/components/DecoratedTextLines.tsx @@ -3,7 +3,7 @@ import VisibilitySensor from 'react-visibility-sensor' import { LinkOrSpan } from '../../../shared/src/components/LinkOrSpan' import * as GQL from '../../../shared/src/graphql/schema' import { highlightNode } from '../../../shared/src/util/dom' - +import { HighlightRange } from './SearchResult' interface Props { /** * A CSS class name to add to this component's element. @@ -18,7 +18,7 @@ interface Props { /** * The highlights for the lines. */ - highlights?: GQL.IHighlight[] + highlights?: GQL.IHighlight[] | HighlightRange[] /** * A list of classes to apply to 1-indexed line numbers. @@ -33,7 +33,7 @@ interface Props { interface DecoratedLine { value: string - highlights?: GQL.IHighlight[] + highlights?: (GQL.IHighlight | HighlightRange)[] classNames?: string[] url?: string } @@ -97,6 +97,9 @@ export class DecoratedTextLines extends React.PureComponent { const lines: DecoratedLine[] = lineValues.map(line => ({ value: line })) if (props.highlights) { for (const highlight of props.highlights) { + if (highlight.line > lines.length - 1) { + continue + } const line = lines[highlight.line - 1] if (!line.highlights) { line.highlights = [] diff --git a/web/src/components/SearchResult.scss b/web/src/components/SearchResult.scss new file mode 100644 index 00000000000..9e3585ce9a5 --- /dev/null +++ b/web/src/components/SearchResult.scss @@ -0,0 +1,9 @@ +.search-result { + &__title { + display: flex; + align-items: center; + } + &__spacer { + flex: 1; + } +} diff --git a/web/src/components/SearchResult.tsx b/web/src/components/SearchResult.tsx new file mode 100644 index 00000000000..ed6683c6a99 --- /dev/null +++ b/web/src/components/SearchResult.tsx @@ -0,0 +1,95 @@ +import { decode } from 'he' +import _ from 'lodash' +import marked from 'marked' +import FileIcon from 'mdi-react/FileIcon' +import React from 'react' +import { ResultContainer } from '../../../shared/src/components/ResultContainer' +import * as GQL from '../../../shared/src/graphql/schema' +import { SearchResultMatch } from './SearchResultMatch' + +export interface HighlightRange { + /** + * The 0-based line number that this highlight appears in + */ + line: number + /** + * The 0-based character offset to start highlighting at + */ + character: number + /** + * The number of characters to highlight + */ + length: number +} + +interface Props { + result: GQL.GenericSearchResultInterface + isLightTheme: boolean +} + +export class SearchResult extends React.Component { + constructor(props: Props) { + super(props) + } + + private renderTitle = () => ( +
+ + {this.props.result.detail && ( + <> + + + + )} +
+ ) + + private renderBody = () => ( + <> + {this.props.result.matches.map((match, index) => { + const highlightRanges: HighlightRange[] = [] + match.highlights.map(highlight => + highlightRanges.push({ + line: highlight.line, + character: highlight.character, + length: highlight.length, + }) + ) + + return ( + + ) + })} + + ) + + public render(): JSX.Element { + return ( + 0} + defaultExpanded={true} + title={this.renderTitle()} + expandedChildren={this.renderBody()} + /> + ) + } +} diff --git a/web/src/components/SearchResultMatch.scss b/web/src/components/SearchResultMatch.scss new file mode 100644 index 00000000000..78c5e55745b --- /dev/null +++ b/web/src/components/SearchResultMatch.scss @@ -0,0 +1,151 @@ +.search-result-match { + text-decoration: none; // don't use cascading link style + display: flex; + align-items: center; + padding: 0.25rem 0; + overflow-x: auto; + overflow-y: hidden; + background-color: #070a0d; + + &-clickable { + cursor: pointer; + } + + &:hover { + background-color: #151d28; + text-decoration: none; + } + + &:not(:first-child) { + border-top: 1px solid #2a3a51; + } + + pre, + code { + width: 100%; + padding: 0; + margin-bottom: 0; + } + + table, + tr, + td { + display: flex; + flex-grow: 1; + padding: 0; + margin-bottom: 0; + .code { + padding: 0; + } + span:last-child { + width: 100%; + } + } + + tbody { + display: flex; + flex-grow: 1; + flex-direction: column; + } + + &__markdown { + padding: 0.25rem 0.5rem; + display: flex; + flex-direction: column; + flex-grow: 1; + } + + &__code-excerpt { + padding-top: 0; + padding-bottom: 1rem; + padding-left: 0; + padding-right: 0; + + td.line { + display: none; + } + td.code > span:first-child { + padding-left: 1rem; + } + + td.code > span:last-child { + padding-right: 1rem; + } + } + + &__line { + &--hidden { + visibility: hidden; + } + } + + &__loader { + position: absolute; + top: 50%; + transform: translateY(-50%); + left: 50%; + } + + code.language-diff & { + font-style: italic; + tr:first-child { + td.code:first-child { + background-color: red; + } + } + } + + .theme-light & { + background-color: $color-light-bg-1; + border-top: 1px solid $color-light-border; + + &:hover { + background-color: $color-light-bg-4; + text-decoration: none; + } + + pre, + code { + width: 100%; + padding: 0; + margin-bottom: 0; + } + + table, + tr, + td { + display: flex; + flex-grow: 1; + padding: 0; + margin-bottom: 0; + .code { + padding: 0; + } + span:last-child { + width: 100%; + } + } + + tbody { + display: flex; + flex-grow: 1; + flex-direction: column; + } + + &__code-excerpt { + padding-top: 0; + padding-bottom: 1rem; + + td.line { + display: none; + } + td.code > span:first-child { + padding-left: 1rem; + } + + td.code > span:last-child { + padding-right: 1rem; + } + } + } +} diff --git a/web/src/components/SearchResultMatch.tsx b/web/src/components/SearchResultMatch.tsx new file mode 100644 index 00000000000..9f1d0517bd3 --- /dev/null +++ b/web/src/components/SearchResultMatch.tsx @@ -0,0 +1,192 @@ +import { LoadingSpinner } from '@sourcegraph/react-loading-spinner' +import { decode } from 'he' +import _ from 'lodash' +import { range } from 'lodash' +import React from 'react' +import { Link } from 'react-router-dom' +import VisibilitySensor from 'react-visibility-sensor' +import { combineLatest, of, Subject, Subscription } from 'rxjs' +import { catchError, filter, switchMap } from 'rxjs/operators' +import sanitizeHtml from 'sanitize-html' +import { Markdown } from '../../../shared/src/components/Markdown' +import * as GQL from '../../../shared/src/graphql/schema' +import { highlightNode } from '../../../shared/src/util/dom' +import { renderMarkdown } from '../discussions/backend' +import { highlightCode } from '../search/backend' +import { HighlightRange } from './SearchResult' + +interface SearchResultMatchProps { + item: GQL.ISearchResultMatch + highlightRanges: HighlightRange[] + isLightTheme: boolean +} + +interface SearchResultMatchState { + HTML?: string +} + +export class SearchResultMatch extends React.Component { + public state: SearchResultMatchState = {} + private tableContainerElement: HTMLElement | null = null + private visibilitySensorOffset = { bottom: -500 } + + private visibilityChanges = new Subject() + private subscriptions = new Subscription() + private propsChanges = new Subject() + + private getLanguage(): string | undefined { + const matches = /(?:```)([^\s]+)\s/.exec(this.props.item.body.text) + if (!matches) { + return undefined + } + return matches[1] + } + + private bodyIsCode(): boolean { + return this.props.item.body.text.startsWith('```') && this.props.item.body.text.endsWith('```') + } + + public constructor(props: SearchResultMatchProps) { + super(props) + + // Render the match body as markdown, and syntax highlight the response if it's a code block. + // This is a lot of network requests right now, but once extensions can run on the backend we can + // run results through the renderer and syntax highlighter without network requests. + this.subscriptions.add( + combineLatest(this.propsChanges, this.visibilityChanges) + .pipe( + filter(([, isVisible]) => isVisible), + switchMap( + ([props]) => + props.item.body.html + ? of(sanitizeHtml(props.item.body.html)) + : renderMarkdown({ markdown: props.item.body.text }) + ), + switchMap(markdownHTML => { + if (this.bodyIsCode() && markdownHTML.includes('')) { + const lang = this.getLanguage() || 'txt' + const parser = new DOMParser() + // Get content between the outermost code tags. + const codeContent = parser + .parseFromString(markdownHTML, 'text/html') + .querySelector('code')! + .innerHTML.toString() + if (codeContent) { + return highlightCode({ + code: decode(codeContent), + fuzzyLanguage: lang, + disableTimeout: false, + isLightTheme: this.props.isLightTheme, + }).pipe( + switchMap(highlightedStr => { + const highlightedMarkdown = markdownHTML.replace(codeContent, highlightedStr) + return of(highlightedMarkdown) + }), + // Return the rendered markdown if highlighting fails. + catchError(() => of(markdownHTML)) + ) + } + } + return of(markdownHTML) + }), + // Return the raw body if markdown rendering fails, maintaing the text structure. + catchError(() => of('
' + sanitizeHtml(props.item.body.text) + '
')) + ) + .subscribe(str => this.setState({ HTML: str }), error => console.error(error)) + ) + } + + public componentDidMount(): void { + this.propsChanges.next(this.props) + this.highlightNodes() + } + + public componentDidUpdate(): void { + this.highlightNodes() + } + + public componentWillUnmount(): void { + this.subscriptions.unsubscribe() + } + + private highlightNodes(): void { + if (this.tableContainerElement) { + const visibleRows = this.tableContainerElement.querySelectorAll('table tr') + if (visibleRows.length > 0) { + for (const h of this.props.highlightRanges) { + const code = visibleRows[h.line - 1] + if (code) { + highlightNode(code as HTMLElement, h.character, h.length) + } + } + } + } + } + + private onChangeVisibility = (isVisible: boolean): void => { + this.visibilityChanges.next(isVisible) + } + + private getFirstLine(): number { + return Math.max(0, Math.min(...this.props.highlightRanges.map(r => r.line)) - 1) + } + + private getLastLine(): number { + const lastLine = Math.max(...this.props.highlightRanges.map(r => r.line)) + 1 + return this.props.highlightRanges ? Math.min(lastLine, this.props.highlightRanges.length) : lastLine + } + + public render(): JSX.Element { + const firstLine = this.getFirstLine() + let lastLine = this.getLastLine() + if (firstLine === lastLine) { + // Some edge cases yield the same first and last line, causing the visibility sensor to break, so make sure to avoid this. + lastLine++ + } + + return ( + + <> + {this.state.HTML && ( + + + + )} + {!this.state.HTML && ( + <> + + + + {range(firstLine, lastLine).map(i => ( + + {/* create empty space to fill viewport (as if the blob content were already fetched, otherwise we'll overfetch) */} + + + + ))} + +
+ {i} +
+ + )} + +
+ ) + } + + private setTableContainerElement = (ref: HTMLElement | null) => { + this.tableContainerElement = ref + } +} diff --git a/web/src/discussions/backend.tsx b/web/src/discussions/backend.tsx index 8881ee81476..0163fd83640 100644 --- a/web/src/discussions/backend.tsx +++ b/web/src/discussions/backend.tsx @@ -3,6 +3,7 @@ import { map } from 'rxjs/operators' import { gql } from '../../../shared/src/graphql/graphql' import * as GQL from '../../../shared/src/graphql/schema' import { createAggregateError } from '../../../shared/src/util/errors' +import { memoizeObservable } from '../../../shared/src/util/memoizeObservable' import { mutateGraphQL, queryGraphQL } from '../backend/graphql' const discussionCommentFieldsFragment = gql` @@ -271,20 +272,22 @@ export function updateComment(input: GQL.IDiscussionCommentUpdateInput): Observa * * @return Observable that emits the HTML string, which is already sanitized and escaped and thus is always safe to render. */ -export function renderMarkdown(markdown: string, options?: GQL.IMarkdownOptions): Observable { - return queryGraphQL( - gql` - query RenderMarkdown($markdown: String!, $options: MarkdownOptions) { - renderMarkdown(markdown: $markdown, options: $options) - } - `, - { markdown } - ).pipe( - map(({ data, errors }) => { - if (!data || !data.renderMarkdown) { - throw createAggregateError(errors) - } - return data.renderMarkdown - }) - ) -} +export const renderMarkdown = memoizeObservable( + (ctx: { markdown: string; options?: GQL.IMarkdownOptions }): Observable => + queryGraphQL( + gql` + query RenderMarkdown($markdown: String!, $options: MarkdownOptions) { + renderMarkdown(markdown: $markdown, options: $options) + } + `, + ctx + ).pipe( + map(({ data, errors }) => { + if (!data || !data.renderMarkdown) { + throw createAggregateError(errors) + } + return data.renderMarkdown + }) + ), + ctx => `${ctx.markdown}:${ctx.options}` +) diff --git a/web/src/highlight.ts b/web/src/highlight.ts index 3b6b6c0f16c..7698ccde72b 100644 --- a/web/src/highlight.ts +++ b/web/src/highlight.ts @@ -21,3 +21,5 @@ registerLanguage('r', require('highlight.js/lib/languages/r')) registerLanguage('ruby', require('highlight.js/lib/languages/ruby')) registerLanguage('rust', require('highlight.js/lib/languages/rust')) registerLanguage('swift', require('highlight.js/lib/languages/swift')) +registerLanguage('markdown', require('highlight.js/lib/languages/markdown')) +registerLanguage('diff', require('highlight.js/lib/languages/diff')) diff --git a/web/src/repo/blob/discussions/DiscussionsInput.tsx b/web/src/repo/blob/discussions/DiscussionsInput.tsx index 4dd2f1c6eae..893818bf3bd 100644 --- a/web/src/repo/blob/discussions/DiscussionsInput.tsx +++ b/web/src/repo/blob/discussions/DiscussionsInput.tsx @@ -136,7 +136,7 @@ export class DiscussionsInput extends React.PureComponent { mergeMap(([, { textAreaValue }]) => concat( of(state => ({ ...state, previewHTML: undefined, previewLoading: true })), - renderMarkdown(this.trimImplicitTitle(textAreaValue)).pipe( + renderMarkdown({ markdown: this.trimImplicitTitle(textAreaValue) }).pipe( map( (previewHTML): Update => state => ({ ...state, diff --git a/web/src/search/backend.tsx b/web/src/search/backend.tsx index cb069937b23..af191116547 100644 --- a/web/src/search/backend.tsx +++ b/web/src/search/backend.tsx @@ -5,6 +5,7 @@ import { ExtensionsControllerProps } from '../../../shared/src/extensions/contro import { gql } from '../../../shared/src/graphql/graphql' import * as GQL from '../../../shared/src/graphql/schema' import { asError, createAggregateError, ErrorLike } from '../../../shared/src/util/errors' +import { memoizeObservable } from '../../../shared/src/util/memoizeObservable' import { mutateGraphQL, queryGraphQL } from '../backend/graphql' export function search( @@ -48,6 +49,25 @@ export function search( id name url + label { + html + } + icon + detail { + html + } + matches { + url + body { + text + html + } + highlights { + line + character + length + } + } } ... on FileMatch { __typename @@ -77,57 +97,24 @@ export function search( } ... on CommitSearchResult { __typename - refs { - name - displayName - prefix - repository { - name - } + label { + html } - sourceRefs { - name - displayName - prefix - repository { - name - } + url + icon + detail { + html } - messagePreview { - value - highlights { - line - character - length - } - } - diffPreview { - value - highlights { - line - character - length - } - } - commit { - id - repository { - name - url - } - oid - abbreviatedOID - author { - person { - displayName - avatarURL - } - date - } - message + matches { url - tree(path: "") { - canonicalURL + body { + text + html + } + highlights { + line + character + length } } } @@ -439,3 +426,38 @@ export function deleteSavedQuery( }) ) } + +export const highlightCode = memoizeObservable( + (ctx: { + code: string + fuzzyLanguage: string + disableTimeout: boolean + isLightTheme: boolean + }): Observable => + queryGraphQL( + gql` + query highlightCode( + $code: String! + $fuzzyLanguage: String! + $disableTimeout: Boolean! + $isLightTheme: Boolean! + ) { + highlightCode( + code: $code + fuzzyLanguage: $fuzzyLanguage + disableTimeout: $disableTimeout + isLightTheme: $isLightTheme + ) + } + `, + ctx + ).pipe( + map(({ data, errors }) => { + if (!data || !data.highlightCode) { + throw createAggregateError(errors) + } + return data.highlightCode + }) + ), + ctx => `${ctx.code}:${ctx.fuzzyLanguage}:${ctx.disableTimeout}:${ctx.isLightTheme}` +) diff --git a/web/src/search/results/SearchResultsList.tsx b/web/src/search/results/SearchResultsList.tsx index 4813911f8dc..f1c3c3008de 100644 --- a/web/src/search/results/SearchResultsList.tsx +++ b/web/src/search/results/SearchResultsList.tsx @@ -19,10 +19,9 @@ import { SettingsCascadeProps } from '../../../../shared/src/settings/settings' import { ErrorLike, isErrorLike } from '../../../../shared/src/util/errors' import { isDefined } from '../../../../shared/src/util/types' import { ModalContainer } from '../../components/ModalContainer' +import { SearchResult } from '../../components/SearchResult' import { eventLogger } from '../../tracking/eventLogger' import { SavedQueryCreateForm } from '../saved-queries/SavedQueryCreateForm' -import { CommitSearchResult } from './CommitSearchResult' -import { RepositorySearchResult } from './RepositorySearchResult' import { SearchResultsInfoBar } from './SearchResultsInfoBar' const isSearchResults = (val: any): val is GQL.ISearchResults => val && val.__typename === 'SearchResults' @@ -405,10 +404,11 @@ export class SearchResultsList extends React.PureComponent case 'FileMatch': return ( ) - case 'CommitSearchResult': - return ( - - ) } - return undefined + return } /** onBottomHit increments the amount of results to be shown when we have scrolled to the bottom of the list. */ diff --git a/yarn.lock b/yarn.lock index 0b21fe5b6a5..634588e73cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1709,6 +1709,11 @@ resolved "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.0.39.tgz#961fb54db68030890942e6aeffe9f93a957807bd" integrity sha512-vjaS7Q0dVqFp85QhyPSZqDKnTTCemcSHNHFvDdalO1s0Ifz5KuE64jQD5xoUkfdWwF4WpqdJEl7LsWH8rzhKJA== +"@types/he@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@types/he/-/he-1.1.0.tgz#0ddc2ae80f0814f729f0f7e5aa77b191ab4a9598" + integrity sha512-HyiLOiJhclRBPzcbYrNThdi0JOdq7bT4hq9jFBPQk4HGjzkwYVQnMj9IDi7qvYkg9QTly2oZ9kjm4j7d8Ic9eA== + "@types/highlight.js@9.12.3", "@types/highlight.js@^9.12.3": version "9.12.3" resolved "https://registry.npmjs.org/@types/highlight.js/-/highlight.js-9.12.3.tgz#b672cfaac25cbbc634a0fd92c515f66faa18dbca" @@ -1719,6 +1724,13 @@ resolved "https://registry.npmjs.org/@types/history/-/history-4.7.2.tgz#0e670ea254d559241b6eeb3894f8754991e73220" integrity sha512-ui3WwXmjTaY73fOQ3/m3nnajU/Orhi6cEu5rzX+BrAAJxa3eITXZ5ch9suPqtM03OWhAHhPSyBGCN4UKoxO20Q== +"@types/htmlparser2@*": + version "3.7.31" + resolved "https://registry.npmjs.org/@types/htmlparser2/-/htmlparser2-3.7.31.tgz#ae89353691ce37fa2463c3b8b4698f20ef67a59b" + integrity sha512-6Kjy02k+KfJJE2uUiCytS31SXCYnTjKA+G0ydb83DTlMFzorBlezrV2XiKazRO5HSOEvVW3cpzDFPoP9n/9rSA== + dependencies: + "@types/node" "*" + "@types/http-proxy-middleware@*": version "0.17.6" resolved "https://registry.npmjs.org/@types/http-proxy-middleware/-/http-proxy-middleware-0.17.6.tgz#9d1fcb45d8d74b1d4ac24b2cc31553d5389aea38" @@ -1957,6 +1969,13 @@ resolved "https://registry.npmjs.org/@types/retry/-/retry-0.10.2.tgz#bd1740c4ad51966609b058803ee6874577848b37" integrity sha512-LqJkY4VQ7S09XhI7kA3ON71AxauROhSv74639VsNXC9ish4IWHnIi98if+nP1MxQV3RMPqXSCYgpPsDHjlg9UQ== +"@types/sanitize-html@^1.18.2": + version "1.18.2" + resolved "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-1.18.2.tgz#14e9971064d0f29aa4feaa8421122ced9e8346d9" + integrity sha512-WSE/HsqOHfHd1c0vPOOWOWNippsscBU72r5tpWT/+pFL3zBiCPJCp0NO7sQT8V0gU0xjSKpMAve3iMEJrRhUWQ== + dependencies: + "@types/htmlparser2" "*" + "@types/semver@5.5.0": version "5.5.0" resolved "https://registry.npmjs.org/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" @@ -7546,7 +7565,7 @@ he@1.1.1: resolved "https://registry.npmjs.org/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0= -he@^1.1.1: +he@^1.1.1, he@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==