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.
This commit is contained in:
Farhan Attamimi 2018-12-07 10:50:22 -08:00 committed by GitHub
parent f4e5b5a63e
commit b5e695eea1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1301 additions and 102 deletions

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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 {

View File

@ -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")

View File

@ -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

View File

@ -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
}

View File

@ -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.

1
go.mod
View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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"}

View File

@ -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;
}
}
}
}

View File

@ -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: Props) => (
<div className="markdown" dangerouslySetInnerHTML={{ __html: props.dangerousInnerHTML }} />
<div
ref={props.refFn}
className={`markdown ${props.className}`}
dangerouslySetInnerHTML={{ __html: props.dangerousInnerHTML }}
/>
)

View File

@ -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%);
}
}

View File

@ -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<Props, State> {
public render(): JSX.Element | null {
const Icon = this.props.icon
const stringIcon = this.props.stringIcon ? this.props.stringIcon : undefined
return (
<div className="result-container">
<div
@ -99,7 +101,11 @@ export class ResultContainer extends React.PureComponent<Props, State> {
}
onClick={this.toggle}
>
<Icon className="icon-inline" />
{!!stringIcon ? (
<img src={stringIcon} className="icon-inline icon-inline__filtered" />
) : (
<Icon className="icon-inline" />
)}
<div className={`result-container__header-title ${this.props.titleClassName || ''}`}>
{this.props.collapsible ? (
<span onClick={blockExpandAndCollapse}>{this.props.title}</span>

View File

@ -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)
}

View File

@ -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';

View File

@ -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<Props, State> {
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 = []

View File

@ -0,0 +1,9 @@
.search-result {
&__title {
display: flex;
align-items: center;
}
&__spacer {
flex: 1;
}
}

View File

@ -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<Props> {
constructor(props: Props) {
super(props)
}
private renderTitle = () => (
<div className="search-result__title">
<span
dangerouslySetInnerHTML={{
__html: this.props.result.label.html
? decode(this.props.result.label.html)
: marked(this.props.result.label.text, { gfm: true, breaks: true, sanitize: true }),
}}
/>
{this.props.result.detail && (
<>
<span className="search-result__spacer" />
<small
dangerouslySetInnerHTML={{
__html: this.props.result.detail.html
? decode(this.props.result.detail.html)
: marked(this.props.result.detail.text, { gfm: true, breaks: true, sanitize: true }),
}}
/>
</>
)}
</div>
)
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 (
<SearchResultMatch
key={`item.url#${index}`}
item={match}
highlightRanges={highlightRanges}
isLightTheme={this.props.isLightTheme}
/>
)
})}
</>
)
public render(): JSX.Element {
return (
<ResultContainer
stringIcon={this.props.result.icon}
icon={FileIcon}
collapsible={this.props.result && this.props.result.matches.length > 0}
defaultExpanded={true}
title={this.renderTitle()}
expandedChildren={this.renderBody()}
/>
)
}
}

View File

@ -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;
}
}
}
}

View File

@ -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<SearchResultMatchProps, SearchResultMatchState> {
public state: SearchResultMatchState = {}
private tableContainerElement: HTMLElement | null = null
private visibilitySensorOffset = { bottom: -500 }
private visibilityChanges = new Subject<boolean>()
private subscriptions = new Subscription()
private propsChanges = new Subject<SearchResultMatchProps>()
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('<code') && markdownHTML.includes('</code>')) {
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('<pre>' + sanitizeHtml(props.item.body.text) + '</pre>'))
)
.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 (
<VisibilitySensor
active={true}
onChange={this.onChangeVisibility}
partialVisibility={true}
offset={this.visibilitySensorOffset}
>
<>
{this.state.HTML && (
<Link key={this.props.item.url} to={this.props.item.url} className="search-result-match">
<Markdown
refFn={this.setTableContainerElement}
className={`search-result-match__markdown ${
this.bodyIsCode() ? 'search-result-match__code-excerpt' : ''
}`}
dangerousInnerHTML={this.state.HTML}
/>
</Link>
)}
{!this.state.HTML && (
<>
<LoadingSpinner className="icon-inline search-result-match__loader" />
<table>
<tbody>
{range(firstLine, lastLine).map(i => (
<tr key={`this.props.item.url#${i}`}>
{/* create empty space to fill viewport (as if the blob content were already fetched, otherwise we'll overfetch) */}
<td className="line search-result-match__line--hidden">
<code>{i}</code>
</td>
<td className="code"> </td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
</VisibilitySensor>
)
}
private setTableContainerElement = (ref: HTMLElement | null) => {
this.tableContainerElement = ref
}
}

View File

@ -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<string> {
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<string> =>
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}`
)

View File

@ -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'))

View File

@ -136,7 +136,7 @@ export class DiscussionsInput extends React.PureComponent<Props, State> {
mergeMap(([, { textAreaValue }]) =>
concat(
of<Update>(state => ({ ...state, previewHTML: undefined, previewLoading: true })),
renderMarkdown(this.trimImplicitTitle(textAreaValue)).pipe(
renderMarkdown({ markdown: this.trimImplicitTitle(textAreaValue) }).pipe(
map(
(previewHTML): Update => state => ({
...state,

View File

@ -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<string> =>
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}`
)

View File

@ -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<SearchResultsListProp
)
}
private renderResult(result: GQL.SearchResult, expanded: boolean): JSX.Element | undefined {
private renderResult(
result: GQL.GenericSearchResultInterface | GQL.IFileMatch,
expanded: boolean
): JSX.Element | undefined {
switch (result.__typename) {
case 'Repository':
return <RepositorySearchResult key={'repo:' + result.id} result={result} onSelect={this.logEvent} />
case 'FileMatch':
return (
<FileMatch
@ -423,19 +423,8 @@ export class SearchResultsList extends React.PureComponent<SearchResultsListProp
fetchHighlightedFileLines={this.props.fetchHighlightedFileLines}
/>
)
case 'CommitSearchResult':
return (
<CommitSearchResult
key={'commit:' + result.commit.id}
location={this.props.location}
result={result}
onSelect={this.logEvent}
expanded={expanded}
allExpanded={this.props.allExpanded}
/>
)
}
return undefined
return <SearchResult key={result.url} result={result} isLightTheme={this.props.isLightTheme} />
}
/** onBottomHit increments the amount of results to be shown when we have scrolled to the bottom of the list. */

View File

@ -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==