Display search result fixes (#1901)

Fix syntax highlighting on non highlighted fields. Fix ellipsis on new lines in code syntax highlighting. Fix ellipsis on content start or end in non code fields.

Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
Matthias Thieroff
2021-12-21 15:10:08 +01:00
committed by GitHub
parent 5b700dc0c7
commit bc86ed4474
8 changed files with 455 additions and 82 deletions

View File

@@ -0,0 +1,6 @@
- type: fixed
description: Syntax highlighting on non highlighted fields ([#1901](https://github.com/scm-manager/scm-manager/pull/1901))
- type: fixed
description: Ellipsis on new lines in code syntax highlighting ([#1901](https://github.com/scm-manager/scm-manager/pull/1901))
- type: fixed
description: Ellipsis on content start or end in non code fields ([#1901](https://github.com/scm-manager/scm-manager/pull/1901))

View File

@@ -1,73 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Hit } from "@scm-manager/ui-types";
export const javaHit: Hit = {
score: 2.5,
fields: {
content: {
highlighted: true,
fragments: [
"import org.slf4j.LoggerFactory;\n\nimport java.util.Date;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * Jwt implementation of {@link <|[[--AccessTokenBuilder--]]|>}.\n * \n * @author Sebastian Sdorra\n * @since 2.0.0\n */\npublic final class <|[[--JwtAccessTokenBuilder--]]|> implements <|[[--AccessTokenBuilder--]]|> {\n\n /**\n * the logger for <|[[--JwtAccessTokenBuilder--]]|>\n */\n private static final Logger LOG = LoggerFactory.getLogger(<|[[--JwtAccessTokenBuilder.class--]]|>);\n \n private final KeyGenerator keyGenerator; \n private final SecureKeyResolver keyResolver; \n \n private String subject;\n private String issuer;\n",
" private final Map<String,Object> custom = Maps.newHashMap();\n \n <|[[--JwtAccessTokenBuilder--]]|>(KeyGenerator keyGenerator, SecureKeyResolver keyResolver) {\n this.keyGenerator = keyGenerator;\n this.keyResolver = keyResolver;\n }\n\n @Override\n public <|[[--JwtAccessTokenBuilder--]]|> subject(String subject) {\n",
' public <|[[--JwtAccessTokenBuilder--]]|> custom(String key, Object value) {\n Preconditions.checkArgument(!Strings.isNullOrEmpty(key), "null or empty value not allowed");\n Preconditions.checkArgument(value != null, "null or empty value not allowed");\n'
],
matchesContentStart: false,
matchesContentEnd: false
}
},
_links: {}
};
export const bashHit: Hit = {
score: 2.5,
fields: {
content: {
highlighted: true,
fragments: [
'# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n#\n\n<|[[--getent--]]|> group scm >/dev/null || groupadd -r scm\n<|[[--getent--]]|> passwd scm >/dev/null || \\\n useradd -r -g scm -M \\\n -s /sbin/nologin -d /var/lib/scm \\\n -c "user for the scm-server process" scm\nexit 0\n\n'
],
matchesContentStart: false,
matchesContentEnd: true
}
},
_links: {}
};
export const markdownHit: Hit = {
score: 2.5,
fields: {
content: {
highlighted: true,
fragments: [
"---\ntitle: SCM-Manager v2 Test <|[[--Cases--]]|>\n---\n\nDescribes the expected behaviour for SCMM v2 REST Resources using manual tests.\n\nThe following states general test <|[[--cases--]]|> per HTTP Method and en expected return code as well as exemplary curl calls.\nResource-specifics are stated \n\n## Test <|[[--Cases--]]|>\n\n### GET\n\n- Collection Resource (e.g. `/users`)\n - Without parameters -> 200\n - Parameters\n - `?pageSize=1` -> Only one embedded element, pageTotal reflects the correct number of pages, `last` link points to last page.\n - `?pageSize=1&page=1` -> `next` link points to page 0 ; `prev` link points to page 2\n - `?sortBy=admin` -> Sorted by `admin` field of embedded objects\n - `?sortBy=admin&desc=true` -> Invert sorting\n- Individual Resource (e.g. `/users/scmadmin`)\n - Exists -> 200\n",
"\n### DELETE\n\n- existing -> 204\n- not existing -> 204\n- without permission -> 401\n\n## Exemplary calls & Resource specific test <|[[--cases--]]|>\n\nIn order to extend those tests to other Resources, have a look at the rest docs. Note that the Content Type is specific to each resource as well.\n"
],
matchesContentStart: true,
matchesContentEnd: false
}
},
_links: {}
};

File diff suppressed because one or more lines are too long

View File

@@ -90255,6 +90255,278 @@ In order to extend those tests to other Resources, have a look at the rest docs.
</pre>
`;
exports[`Storyshots TextHitField Non Content Search 1`] = `
<pre>
&lt;?xml version="1.0"?&gt;
&lt;project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"&gt;
&lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;
&lt;parent&gt;
&lt;artifactId&gt;scm-clients&lt;/artifactId&gt;
&lt;groupId&gt;sonia.scm.clients&lt;/groupId&gt;
&lt;version&gt;2.0.0-SNAPSHOT&lt;/version&gt;
&lt;/parent&gt;
&lt;artifactId&gt;scm-cli-client&lt;/artifactId&gt;
&lt;version&gt;2.0.0-SNAPSHOT&lt;/version&gt;
&lt;name&gt;scm-cli-client&lt;/name&gt;
&lt;dependencies&gt;
&lt;!-- fix javadoc --&gt;
&lt;dependency&gt;
&lt;groupId&gt;javax.servlet&lt;/groupId&gt;
&lt;artifactId&gt;javax.servlet-api&lt;/artifactId&gt;
&lt;version&gt;\${servlet.version}&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;javax.transaction&lt;/groupId&gt;
&lt;artifactId&gt;jta&lt;/artifactId&gt;
&lt;version&gt;1.1&lt;/version&gt;
&lt;scope&gt;provided&lt;/scope&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;sonia.scm.clients&lt;/groupId&gt;
&lt;artifactId&gt;scm-client-impl&lt;/artifactId&gt;
&lt;version&gt;2.0.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;args4j&lt;/groupId&gt;
&lt;artifactId&gt;args4j&lt;/artifactId&gt;
&lt;version&gt;2.0.29&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;ch.qos.logback&lt;/groupId&gt;
&lt;artifactId&gt;logback-classic&lt;/artifactId&gt;
&lt;version&gt;\${logback.version}&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.freemarker&lt;/groupId&gt;
&lt;artifactId&gt;freemarker&lt;/artifactId&gt;
&lt;version&gt;2.3.21&lt;/version&gt;
&lt;/dependency&gt;
&lt;/dependencies&gt;
&lt;build&gt;
&lt;plugins&gt;
&lt;plugin&gt;
&lt;groupId&gt;com.mycila.maven-license-plugin&lt;/groupId&gt;
&lt;artifactId&gt;maven-license-plugin&lt;/artifactId&gt;
&lt;version&gt;1.9.0&lt;/version&gt;
&lt;configuration&gt;
&lt;header&gt;http://download.scm-manager.org/licenses/mvn-license.txt&lt;/header&gt;
&lt;includes&gt;
&lt;include&gt;src/**&lt;/include&gt;
&lt;include&gt;**/test/**&lt;/include&gt;
&lt;/includes&gt;
&lt;excludes&gt;
&lt;exclude&gt;target/**&lt;/exclude&gt;
&lt;exclude&gt;.hg/**&lt;/exclude&gt;
&lt;exclude&gt;**/*.ftl&lt;/exclude&gt;
&lt;/excludes&gt;
&lt;strictCheck&gt;true&lt;/strictCheck&gt;
&lt;/configuration&gt;
&lt;/plugin&gt;
&lt;plugin&gt;
&lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;
&lt;artifactId&gt;maven-assembly-plugin&lt;/artifactId&gt;
&lt;version&gt;2.3&lt;/version&gt;
&lt;configuration&gt;
&lt;archive&gt;
&lt;manifest&gt;
&lt;mainClass&gt;sonia.scm.cli.App&lt;/mainClass&gt;
&lt;/manifest&gt;
&lt;/archive&gt;
&lt;descriptorRefs&gt;
&lt;descriptorRef&gt;jar-with-dependencies&lt;/descriptorRef&gt;
&lt;/descriptorRefs&gt;
&lt;/configuration&gt;
&lt;executions&gt;
&lt;execution&gt;
&lt;phase&gt;package&lt;/phase&gt;
&lt;goals&gt;
&lt;goal&gt;single&lt;/goal&gt;
&lt;/goals&gt;
&lt;/execution&gt;
&lt;/executions&gt;
&lt;/plugin&gt;
&lt;/plugins&gt;
&lt;/build&gt;
&lt;profiles&gt;
&lt;profile&gt;
&lt;id&gt;it&lt;/id&gt;
&lt;build&gt;
&lt;plugins&gt;
&lt;plugin&gt;
&lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;
&lt;artifactId&gt;maven-dependency-plugin&lt;/artifactId&gt;
&lt;version&gt;2.4&lt;/version&gt;
&lt;executions&gt;
&lt;execution&gt;
&lt;phase&gt;package&lt;/phase&gt;
&lt;goals&gt;
&lt;goal&gt;copy&lt;/goal&gt;
&lt;/goals&gt;
&lt;configuration&gt;
&lt;artifactItems&gt;
&lt;artifactItem&gt;
&lt;groupId&gt;sonia.scm&lt;/groupId&gt;
&lt;artifactId&gt;scm-webapp&lt;/artifactId&gt;
&lt;version&gt;\${project.version}&lt;/version&gt;
&lt;type&gt;war&lt;/type&gt;
&lt;outputDirectory&gt;\${project.build.directory}/webapp&lt;/outputDirectory&gt;
&lt;destFileName&gt;scm-webapp.war&lt;/destFileName&gt;
&lt;/artifactItem&gt;
&lt;/artifactItems&gt;
&lt;/configuration&gt;
&lt;/execution&gt;
&lt;/executions&gt;
&lt;/plugin&gt;
&lt;plugin&gt;
&lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;
&lt;artifactId&gt;maven-failsafe-plugin&lt;/artifactId&gt;
&lt;version&gt;2.12&lt;/version&gt;
&lt;configuration&gt;
&lt;systemPropertyVariables&gt;
&lt;scm.version&gt;\${project.version}&lt;/scm.version&gt;
&lt;/systemPropertyVariables&gt;
&lt;/configuration&gt;
&lt;executions&gt;
&lt;execution&gt;
&lt;id&gt;integration-test&lt;/id&gt;
&lt;goals&gt;
&lt;goal&gt;integration-test&lt;/goal&gt;
&lt;/goals&gt;
&lt;/execution&gt;
&lt;execution&gt;
&lt;id&gt;verify&lt;/id&gt;
&lt;goals&gt;
&lt;goal&gt;verify&lt;/goal&gt;
&lt;/goals&gt;
&lt;/execution&gt;
&lt;/executions&gt;
&lt;/plugin&gt;
&lt;plugin&gt;
&lt;groupId&gt;org.eclipse.jetty&lt;/groupId&gt;
&lt;artifactId&gt;jetty-maven-plugin&lt;/artifactId&gt;
&lt;version&gt;\${jetty.maven.version}&lt;/version&gt;
&lt;configuration&gt;
&lt;stopPort&gt;8085&lt;/stopPort&gt;
&lt;stopKey&gt;STOP&lt;/stopKey&gt;
&lt;systemProperties&gt;
&lt;systemProperty&gt;
&lt;name&gt;scm.home&lt;/name&gt;
&lt;value&gt;target/scm-it&lt;/value&gt;
&lt;/systemProperty&gt;
&lt;systemProperty&gt;
&lt;name&gt;file.encoding&lt;/name&gt;
&lt;value&gt;UTF-8&lt;/value&gt;
&lt;/systemProperty&gt;
&lt;/systemProperties&gt;
&lt;httpConnector&gt;
&lt;port&gt;8081&lt;/port&gt;
&lt;/httpConnector&gt;
&lt;webApp&gt;
&lt;contextPath&gt;/scm&lt;/contextPath&gt;
&lt;/webApp&gt;
&lt;war&gt;\${project.build.directory}/webapp/scm-webapp.war&lt;/war&gt;
&lt;scanIntervalSeconds&gt;0&lt;/scanIntervalSeconds&gt;
&lt;daemon&gt;true&lt;/daemon&gt;
&lt;/configuration&gt;
&lt;executions&gt;
&lt;execution&gt;
&lt;id&gt;start-jetty&lt;/id&gt;
&lt;phase&gt;pre-integration-test&lt;/phase&gt;
&lt;goals&gt;
&lt;goal&gt;deploy-war&lt;/goal&gt;
&lt;/goals&gt;
&lt;/execution&gt;
&lt;execution&gt;
&lt;id&gt;stop-jetty&lt;/id&gt;
&lt;phase&gt;post-integration-test&lt;/phase&gt;
&lt;goals&gt;
&lt;goal&gt;stop&lt;/goal&gt;
&lt;/goals&gt;
&lt;/execution&gt;
&lt;/executions&gt;
&lt;/plugin&gt;
&lt;/plugins&gt;
&lt;/build&gt;
&lt;/profile&gt;
&lt;/profiles&gt;
&lt;/project&gt;
</pre>
`;
exports[`Storyshots TextHitField Truncate 1`] = `
<pre>
The Hitchhiker's Guide to the Galaxy (sometimes referred to as **HG2G**, **HHGTTG** or **H2G2**) is a comedy science fiction ser...
</pre>
`;
exports[`Storyshots TextHitField Truncate Keep Whole Line 1`] = `
<pre>
&lt;?xml version="1.0"?&gt;
&lt;project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"&gt;
&lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;
&lt;parent&gt;
&lt;artifactId&gt;scm-clients&lt;/artifactId&gt;
&lt;groupId&gt;sonia.scm.clients&lt;/groupId&gt;
&lt;version&gt;2.0.0-SNAPSHOT&lt;/version&gt;
&lt;/parent&gt;
&lt;artifactId&gt;scm-cli-client&lt;/artifactId&gt;
&lt;version&gt;2.0.0-SNAPSHOT&lt;/version&gt;
&lt;name&gt;scm-cli-client&lt;/name&gt;
&lt;dependencies&gt;
&lt;!-- fix javadoc --&gt;
&lt;dependency&gt;
&lt;groupId&gt;javax.servlet&lt;/groupId&gt;
&lt;artifactId&gt;javax.servlet-api&lt;/artifactId&gt;
&lt;version&gt;\${servlet.version}&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;javax.transaction&lt;/groupId&gt;
&lt;artifactId&gt;jta&lt;/artifactId&gt;
&lt;version&gt;1.1&lt;/version&gt;
&lt;scope&gt;provided&lt;/scope&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;sonia.scm.clients&lt;/groupId&gt;
&lt;artifactId&gt;scm-client-impl&lt;/artifactId&gt;
&lt;version&gt;2.0.0-SNAPSHOT&lt;/version&gt;
...
</pre>
`;
exports[`Storyshots TextHitField Unknown SyntaxHighlighting 1`] = `
<pre>
...

View File

@@ -23,7 +23,7 @@
*/
import React from "react";
import { storiesOf } from "@storybook/react";
import { bashHit, javaHit, markdownHit } from "../__resources__/ContentSearchHit";
import { bashHit, filenameXmlHit, javaHit, markdownHit, pullRequestHit } from "../__resources__/SearchHit";
import TextHitField from "./TextHitField";
storiesOf("TextHitField", module)
@@ -51,4 +51,19 @@ storiesOf("TextHitField", module)
<pre>
<TextHitField hit={bashHit} field={"content"} syntaxHighlightingLanguage="__unknown__" />
</pre>
))
.add("Non Content Search", () => (
<pre>
<TextHitField hit={filenameXmlHit} field={"content"} syntaxHighlightingLanguage="xml" />
</pre>
))
.add("Truncate", () => (
<pre>
<TextHitField hit={pullRequestHit} field={"description"} truncateValueAt={128} />
</pre>
))
.add("Truncate Keep Whole Line", () => (
<pre>
<TextHitField hit={filenameXmlHit} field={"content"} syntaxHighlightingLanguage="xml" truncateValueAt={1024} />
</pre>
));

View File

@@ -40,11 +40,7 @@ const HighlightedTextField: FC<HighlightedTextFieldProps> = ({ field, syntaxHigh
{field.fragments.map((fragment, i) => (
<React.Fragment key={fragment}>
{field.matchesContentStart ? null : separator}
{syntaxHighlightingLanguage ? (
<SyntaxHighlightedFragment value={fragment} language={syntaxHighlightingLanguage} />
) : (
<HighlightedFragment value={fragment} />
)}
<FieldFragment fragment={fragment} syntaxHighlightingLanguage={syntaxHighlightingLanguage} />
{i + 1 >= field.fragments.length && !field.matchesContentEnd ? separator : null}
</React.Fragment>
))}
@@ -52,6 +48,18 @@ const HighlightedTextField: FC<HighlightedTextFieldProps> = ({ field, syntaxHigh
);
};
type FieldFragmentProps = {
fragment: string;
syntaxHighlightingLanguage?: string;
};
const FieldFragment: FC<FieldFragmentProps> = ({ fragment, syntaxHighlightingLanguage }) => {
if (syntaxHighlightingLanguage) {
return <SyntaxHighlightedFragment value={fragment} language={syntaxHighlightingLanguage} />;
}
return <HighlightedFragment value={fragment} />;
};
type Props = {
hit: Hit;
field: string;
@@ -59,6 +67,20 @@ type Props = {
syntaxHighlightingLanguage?: string;
};
function truncate(value: string, truncateValueAt: number = 0, syntaxHighlightingLanguage?: string): string {
if (truncateValueAt > 0 && value.length > truncateValueAt) {
if (syntaxHighlightingLanguage) {
let nextLineBreak = value.indexOf("\n", truncateValueAt);
if (nextLineBreak >= 0 && nextLineBreak < value.length - 1) {
value = value.substring(0, nextLineBreak) + "\n...";
}
} else {
value = value.substring(0, truncateValueAt) + "...";
}
}
return value;
}
const TextHitField: FC<Props> = ({
hit,
field: fieldName,
@@ -75,8 +97,9 @@ const TextHitField: FC<Props> = ({
let value = field.value;
if (value === "") {
return <>{children}</>;
} else if (typeof value === "string" && truncateValueAt > 0 && value.length > truncateValueAt) {
value = value.substring(0, truncateValueAt) + "...";
} else if (typeof value === "string") {
const v = truncate(value, truncateValueAt, syntaxHighlightingLanguage);
return <FieldFragment fragment={v} syntaxHighlightingLanguage={syntaxHighlightingLanguage} />;
}
return <>{value}</>;
}

View File

@@ -75,7 +75,7 @@ public final class LuceneHighlighter {
if (fieldAnalyzer == Indexed.Analyzer.CODE) {
return keepWholeLine(value, fragments);
}
return Arrays.stream(fragments).map(ContentFragment::new)
return Arrays.stream(fragments).map(f -> createContentFragment(value, f))
.toArray(ContentFragment[]::new);
}
@@ -122,4 +122,9 @@ public final class LuceneHighlighter {
return new ContentFragment(snippet + content.substring(index + raw.length(), end) + (matchesContentEnd ? "" : "\n"), matchesContentStart, matchesContentEnd);
}
private ContentFragment createContentFragment(String content, String fragment) {
String raw = fragment.replace(PRE_TAG, "").replace(POST_TAG, "");
return new ContentFragment(fragment, content.startsWith(raw), content.endsWith(raw));
}
}

View File

@@ -123,6 +123,24 @@ class LuceneHighlighterTest {
assertThat(contentFragments[0].isMatchesContentEnd()).isTrue();
}
@Test
void shouldMatchContentStartWithDefaultAnalyzer() throws InvalidTokenOffsetsException, IOException {
ContentFragment[] contentFragments = highlight("GameOfLife.java", "gameoflife");
assertThat(contentFragments).hasSize(1);
assertThat(contentFragments[0].isMatchesContentStart()).isTrue();
assertThat(contentFragments[0].isMatchesContentEnd()).isFalse();
}
@Test
void shouldMatchContentEndWithDefaultAnalyzer() throws InvalidTokenOffsetsException, IOException {
ContentFragment[] contentFragments = highlight("Button.tsx", "default");
assertThat(contentFragments).hasSize(1);
assertThat(contentFragments[0].isMatchesContentStart()).isFalse();
assertThat(contentFragments[0].isMatchesContentEnd()).isTrue();
}
@Nested
class IsHighlightableTests {
@@ -162,6 +180,16 @@ class LuceneHighlighterTest {
}
private ContentFragment[] highlight(String resource, String search) throws IOException, InvalidTokenOffsetsException {
StandardAnalyzer analyzer = new StandardAnalyzer();
Query query = new TermQuery(new Term("content", search));
String content = content(resource);
LuceneHighlighter highlighter = new LuceneHighlighter(analyzer, query);
return highlighter.highlight("content", Indexed.Analyzer.DEFAULT, content);
}
private ContentFragment[] highlightCode(String resource, String search) throws IOException, InvalidTokenOffsetsException {
NonNaturalLanguageAnalyzer analyzer = new NonNaturalLanguageAnalyzer();
Query query = new TermQuery(new Term("content", search));