Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
1
Merge Requests
1
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
nexedi
gitlab-ce
Commits
b40cd08a
Commit
b40cd08a
authored
Mar 21, 2022
by
Jacques Erasmus
Committed by
Kushal Pandya
Mar 21, 2022
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Improve the performance of highlight.js
Improved the performance by highlighting in chunks
parent
f0c53b2b
Changes
13
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
440 additions
and
234 deletions
+440
-234
app/assets/javascripts/blob/line_highlighter.js
app/assets/javascripts/blob/line_highlighter.js
+2
-0
app/assets/javascripts/vue_shared/components/line_numbers.vue
...assets/javascripts/vue_shared/components/line_numbers.vue
+0
-31
app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
.../vue_shared/components/source_viewer/components/chunk.vue
+103
-0
app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
...shared/components/source_viewer/components/chunk_line.vue
+44
-0
app/assets/javascripts/vue_shared/components/source_viewer/constants.js
...ascripts/vue_shared/components/source_viewer/constants.js
+2
-0
app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
...pts/vue_shared/components/source_viewer/source_viewer.vue
+114
-51
app/assets/javascripts/vue_shared/components/source_viewer/utils.js
.../javascripts/vue_shared/components/source_viewer/utils.js
+0
-28
spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
...atures/projects/blobs/blob_line_permalink_updater_spec.rb
+4
-4
spec/frontend/vue_shared/components/line_numbers_spec.js
spec/frontend/vue_shared/components/line_numbers_spec.js
+0
-37
spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
...ed/components/source_viewer/components/chunk_line_spec.js
+47
-0
spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
..._shared/components/source_viewer/components/chunk_spec.js
+82
-0
spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
...vue_shared/components/source_viewer/source_viewer_spec.js
+42
-57
spec/frontend/vue_shared/components/source_viewer/utils_spec.js
...rontend/vue_shared/components/source_viewer/utils_spec.js
+0
-26
No files found.
app/assets/javascripts/blob/line_highlighter.js
View file @
b40cd08a
...
...
@@ -37,6 +37,7 @@ const LineHighlighter = function (options = {}) {
options
.
fileHolderSelector
=
options
.
fileHolderSelector
||
'
.file-holder
'
;
options
.
scrollFileHolder
=
options
.
scrollFileHolder
||
false
;
options
.
hash
=
options
.
hash
||
window
.
location
.
hash
;
options
.
scrollBehavior
=
options
.
scrollBehavior
||
'
smooth
'
;
this
.
options
=
options
;
this
.
_hash
=
options
.
hash
;
...
...
@@ -74,6 +75,7 @@ LineHighlighter.prototype.highlightHash = function (newHash) {
// Scroll to the first highlighted line on initial load
// Add an offset of -100 for some context
offset
:
-
100
,
behavior
:
this
.
options
.
scrollBehavior
,
});
}
}
...
...
app/assets/javascripts/vue_shared/components/line_numbers.vue
deleted
100644 → 0
View file @
f0c53b2b
<
script
>
import
{
GlIcon
,
GlLink
}
from
'
@gitlab/ui
'
;
export
default
{
components
:
{
GlIcon
,
GlLink
,
},
props
:
{
lines
:
{
type
:
Number
,
required
:
true
,
},
},
};
</
script
>
<
template
>
<div
class=
"line-numbers"
>
<gl-link
v-for=
"line in lines"
:id=
"`L$
{line}`"
:key="line"
class="diff-line-num gl-shadow-none!"
:to="`#LC${line}`"
:data-line-number="line"
>
<gl-icon
:size=
"12"
name=
"link"
/>
{{
line
}}
</gl-link>
</div>
</
template
>
app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
0 → 100644
View file @
b40cd08a
<
script
>
import
{
GlIntersectionObserver
,
GlSafeHtmlDirective
}
from
'
@gitlab/ui
'
;
import
ChunkLine
from
'
./chunk_line.vue
'
;
/*
* We only highlight the chunk that is currently visible to the user.
* By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
*
* Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
* so by making text transparent and rendering raw (non-highlighted) text,
* the browser spends less resources on painting content that is not immediately relevant.
*
* Why use transparent text as opposed to hiding content entirely?
* 1. If content is hidden entirely, native find text (⌘ + F) won't work.
* 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
*/
export
default
{
components
:
{
ChunkLine
,
GlIntersectionObserver
,
},
directives
:
{
SafeHtml
:
GlSafeHtmlDirective
,
},
props
:
{
chunkIndex
:
{
type
:
Number
,
required
:
false
,
default
:
0
,
},
isHighlighted
:
{
type
:
Boolean
,
required
:
true
,
},
content
:
{
type
:
String
,
required
:
true
,
},
startingFrom
:
{
type
:
Number
,
required
:
false
,
default
:
0
,
},
totalLines
:
{
type
:
Number
,
required
:
false
,
default
:
0
,
},
language
:
{
type
:
String
,
required
:
false
,
default
:
null
,
},
},
computed
:
{
lines
()
{
return
this
.
content
.
split
(
'
\n
'
);
},
},
methods
:
{
handleChunkAppear
()
{
if
(
!
this
.
isHighlighted
)
{
this
.
$emit
(
'
appear
'
,
this
.
chunkIndex
);
}
},
},
};
</
script
>
<
template
>
<div>
<gl-intersection-observer
@
appear=
"handleChunkAppear"
>
<div
v-if=
"isHighlighted"
>
<chunk-line
v-for=
"(line, index) in lines"
:key=
"index"
:number=
"startingFrom + index + 1"
:content=
"line"
:language=
"language"
/>
</div>
<div
v-else
class=
"gl-display-flex"
>
<div
class=
"gl-display-flex gl-flex-direction-column"
>
<a
v-for=
"(n, index) in totalLines"
:id=
"`L$
{startingFrom + index + 1}`"
:key="index"
class="gl-ml-5 gl-text-transparent"
:href="`#L${startingFrom + index + 1}`"
:data-line-number="startingFrom + index + 1"
data-testid="line-number"
>
{{
startingFrom
+
index
+
1
}}
</a>
</div>
<div
v-safe-html=
"content"
class=
"gl-white-space-pre-wrap! gl-text-transparent"
data-testid=
"content"
></div>
</div>
</gl-intersection-observer>
</div>
</
template
>
app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
0 → 100644
View file @
b40cd08a
<
script
>
import
{
GlLink
,
GlSafeHtmlDirective
}
from
'
@gitlab/ui
'
;
export
default
{
components
:
{
GlLink
,
},
directives
:
{
SafeHtml
:
GlSafeHtmlDirective
,
},
props
:
{
number
:
{
type
:
Number
,
required
:
true
,
},
content
:
{
type
:
String
,
required
:
true
,
},
language
:
{
type
:
String
,
required
:
true
,
},
},
};
</
script
>
<
template
>
<div
class=
"gl-display-flex"
>
<div
class=
"line-numbers gl-pt-0! gl-pb-0! gl-absolute gl-z-index-3"
>
<gl-link
:id=
"`L$
{number}`"
class="file-line-num diff-line-num gl-user-select-none"
:to="`#L${number}`"
:data-line-number="number"
>
{{
number
}}
</gl-link>
</div>
<pre
class=
"code highlight gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11!"
><code><span
:id=
"`LC$
{number}`" v-safe-html="content" :lang="language" class="line" data-testid="content">
</span></code></pre>
</div>
</
template
>
app/assets/javascripts/vue_shared/components/source_viewer/constants.js
View file @
b40cd08a
...
...
@@ -109,3 +109,5 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
xquery
:
'
xquery
'
,
yaml
:
'
yaml
'
,
};
export
const
LINES_PER_CHUNK
=
70
;
app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
View file @
b40cd08a
<
script
>
import
{
GlSafeHtmlDirective
,
GlLoadingIcon
}
from
'
@gitlab/ui
'
;
import
LineNumbers
from
'
~/vue_shared/components/line_numbers.vue
'
;
import
{
sanitize
}
from
'
~/lib/dompurify
'
;
import
{
ROUGE_TO_HLJS_LANGUAGE_MAP
}
from
'
./constants
'
;
import
{
wrapLines
}
from
'
./utils
'
;
const
LINE_SELECT_CLASS_NAME
=
'
hll
'
;
import
LineHighlighter
from
'
~/blob/line_highlighter
'
;
import
{
ROUGE_TO_HLJS_LANGUAGE_MAP
,
LINES_PER_CHUNK
}
from
'
./constants
'
;
import
Chunk
from
'
./components/chunk.vue
'
;
/*
* This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
* we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
*
* The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
* Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
* it does not trigger a repaint on a parent element that wraps all 1000 lines.
*/
export
default
{
components
:
{
LineNumbers
,
GlLoadingIcon
,
Chunk
,
},
directives
:
{
SafeHtml
:
GlSafeHtmlDirective
,
...
...
@@ -27,46 +32,92 @@ export default {
content
:
this
.
blob
.
rawTextBlob
,
language
:
ROUGE_TO_HLJS_LANGUAGE_MAP
[
this
.
blob
.
language
],
hljs
:
null
,
firstChunk
:
null
,
chunks
:
{},
isLoading
:
true
,
isLineSelected
:
false
,
lineHighlighter
:
null
,
};
},
computed
:
{
splitContent
()
{
return
this
.
content
.
split
(
'
\n
'
);
},
lineNumbers
()
{
return
this
.
content
.
split
(
'
\n
'
)
.
length
;
return
this
.
splitContent
.
length
;
},
highlightedContent
()
{
let
highlightedContent
;
let
{
language
}
=
this
;
},
async
created
()
{
this
.
generateFirstChunk
();
this
.
hljs
=
await
this
.
loadHighlightJS
();
if
(
this
.
hljs
)
{
if
(
!
language
)
{
const
hljsHighlightAuto
=
this
.
hljs
.
highlightAuto
(
this
.
content
);
if
(
this
.
language
)
{
this
.
languageDefinition
=
await
this
.
loadLanguage
();
}
highlightedContent
=
hljsHighlightAuto
.
value
;
language
=
hljsHighlightAuto
.
language
;
}
else
if
(
this
.
languageDefinition
)
{
highlightedContent
=
this
.
hljs
.
highlight
(
this
.
content
,
{
language
:
this
.
language
}).
value
;
}
// Highlight the first chunk as soon as highlight.js is available
this
.
highlightChunk
(
null
,
true
);
window
.
requestIdleCallback
(
async
()
=>
{
// Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
this
.
generateRemainingChunks
();
this
.
isLoading
=
false
;
await
this
.
$nextTick
();
this
.
lineHighlighter
=
new
LineHighlighter
({
scrollBehavior
:
'
auto
'
});
});
},
methods
:
{
generateFirstChunk
()
{
const
lines
=
this
.
splitContent
.
splice
(
0
,
LINES_PER_CHUNK
);
this
.
firstChunk
=
this
.
createChunk
(
lines
);
},
generateRemainingChunks
()
{
const
result
=
{};
for
(
let
i
=
0
;
i
<
this
.
splitContent
.
length
;
i
+=
LINES_PER_CHUNK
)
{
const
chunkIndex
=
Math
.
floor
(
i
/
LINES_PER_CHUNK
);
const
lines
=
this
.
splitContent
.
slice
(
i
,
i
+
LINES_PER_CHUNK
);
result
[
chunkIndex
]
=
this
.
createChunk
(
lines
,
i
+
LINES_PER_CHUNK
);
}
return
wrapLines
(
highlightedContent
,
language
)
;
this
.
chunks
=
result
;
},
},
watch
:
{
highlightedContent
()
{
this
.
$nextTick
(()
=>
this
.
selectLine
());
createChunk
(
lines
,
startingFrom
=
0
)
{
return
{
content
:
lines
.
join
(
'
\n
'
),
startingFrom
,
totalLines
:
lines
.
length
,
language
:
this
.
language
,
isHighlighted
:
false
,
};
},
$route
()
{
highlightChunk
(
index
,
isFirstChunk
)
{
const
chunk
=
isFirstChunk
?
this
.
firstChunk
:
this
.
chunks
[
index
];
if
(
chunk
.
isHighlighted
)
{
return
;
}
const
{
highlightedContent
,
language
}
=
this
.
highlight
(
chunk
.
content
,
this
.
language
);
Object
.
assign
(
chunk
,
{
language
,
content
:
highlightedContent
,
isHighlighted
:
true
});
this
.
selectLine
();
},
},
async
mounted
()
{
this
.
hljs
=
await
this
.
loadHighlightJS
();
highlight
(
content
,
language
)
{
let
detectedLanguage
=
language
;
let
highlightedContent
;
if
(
this
.
hljs
)
{
if
(
!
detectedLanguage
)
{
const
hljsHighlightAuto
=
this
.
hljs
.
highlightAuto
(
content
);
highlightedContent
=
hljsHighlightAuto
.
value
;
detectedLanguage
=
hljsHighlightAuto
.
language
;
}
else
if
(
this
.
languageDefinition
)
{
highlightedContent
=
this
.
hljs
.
highlight
(
content
,
{
language
:
this
.
language
}).
value
;
}
}
if
(
this
.
language
)
{
this
.
languageDefinition
=
await
this
.
loadLanguage
();
}
},
methods
:
{
return
{
highlightedContent
,
language
:
detectedLanguage
};
},
loadHighlightJS
()
{
// If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
return
!
this
.
language
?
import
(
'
highlight.js/lib/common
'
)
:
import
(
'
highlight.js/lib/core
'
);
...
...
@@ -83,21 +134,14 @@ export default {
return
languageDefinition
;
},
selectLine
()
{
const
hash
=
sanitize
(
this
.
$route
.
hash
);
const
lineToSelect
=
hash
&&
this
.
$el
.
querySelector
(
hash
);
if
(
!
lineToSelect
)
{
async
selectLine
()
{
if
(
this
.
isLineSelected
||
!
this
.
lineHighlighter
)
{
return
;
}
if
(
this
.
$options
.
currentlySelectedLine
)
{
this
.
$options
.
currentlySelectedLine
.
classList
.
remove
(
LINE_SELECT_CLASS_NAME
);
}
lineToSelect
.
classList
.
add
(
LINE_SELECT_CLASS_NAME
);
this
.
$options
.
currentlySelectedLine
=
lineToSelect
;
lineToSelect
.
scrollIntoView
({
behavior
:
'
smooth
'
,
block
:
'
center
'
});
this
.
isLineSelected
=
true
;
await
this
.
$nextTick
();
this
.
lineHighlighter
.
highlightHash
(
this
.
$route
.
hash
);
},
},
userColorScheme
:
window
.
gon
.
user_color_scheme
,
...
...
@@ -105,16 +149,35 @@ export default {
};
</
script
>
<
template
>
<gl-loading-icon
v-if=
"!highlightedContent"
size=
"sm"
class=
"gl-my-5"
/>
<div
v-else
class=
"file-content code js-syntax-highlight blob-content gl-display-flex"
class=
"file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
:class=
"$options.userColorScheme"
data-type=
"simple"
data-qa-selector=
"blob_viewer_file_content"
>
<line-numbers
:lines=
"lineNumbers"
/>
<pre
class=
"code highlight gl-pb-0!"
><code
v-safe-html=
"highlightedContent"
></code>
</pre>
<chunk
v-if=
"firstChunk"
:lines=
"firstChunk.lines"
:total-lines=
"firstChunk.totalLines"
:content=
"firstChunk.content"
:starting-from=
"firstChunk.startingFrom"
:is-highlighted=
"firstChunk.isHighlighted"
:language=
"firstChunk.language"
/>
<gl-loading-icon
v-if=
"isLoading"
size=
"sm"
class=
"gl-my-5"
/>
<chunk
v-for=
"(chunk, key, index) in chunks"
v-else
:key=
"key"
:lines=
"chunk.lines"
:content=
"chunk.content"
:total-lines=
"chunk.totalLines"
:starting-from=
"chunk.startingFrom"
:is-highlighted=
"chunk.isHighlighted"
:chunk-index=
"index"
:language=
"chunk.language"
@
appear=
"highlightChunk"
/>
</div>
</
template
>
app/assets/javascripts/vue_shared/components/source_viewer/utils.js
deleted
100644 → 0
View file @
f0c53b2b
export
const
wrapLines
=
(
content
,
language
)
=>
{
const
isValidLanguage
=
/^
[
a-z
\d\-
_
]
+$/
.
test
(
language
);
// To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_).
return
(
content
&&
content
.
split
(
'
\n
'
)
.
map
((
line
,
i
)
=>
{
let
formattedLine
;
const
attributes
=
`id="LC
${
i
+
1
}
" lang="
${
isValidLanguage
?
language
:
''
}
"`
;
if
(
line
.
includes
(
'
<span class="hljs
'
)
&&
!
line
.
includes
(
'
</span>
'
))
{
/**
* In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span
*
* example (before): <span class="hljs-code">```bash
* example (after): <span id="LC67" class="hljs-code">```bash
*/
formattedLine
=
line
.
replace
(
/
(?=
class="hljs
)
/
,
`
${
attributes
}
`
);
}
else
{
formattedLine
=
`<span
${
attributes
}
class="line">
${
line
}
</span>`
;
}
return
formattedLine
;
})
.
join
(
'
\n
'
)
);
};
spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
View file @
b40cd08a
...
...
@@ -39,7 +39,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find
(
'#L3'
).
click
find
(
"#L5"
).
click
expect
(
find
(
'.js-data-file-blob-permalink-url'
)[
'href'
]).
to
eq
(
get_absolute_url
(
project_blob_path
(
project
,
tree_join
(
sha
,
path
),
anchor:
"L
C
5"
)))
expect
(
find
(
'.js-data-file-blob-permalink-url'
)[
'href'
]).
to
eq
(
get_absolute_url
(
project_blob_path
(
project
,
tree_join
(
sha
,
path
),
anchor:
"L5"
)))
end
it
'with initial fragment hash, changes fragment hash if line number clicked'
do
...
...
@@ -50,7 +50,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find
(
'#L3'
).
click
find
(
"#L5"
).
click
expect
(
find
(
'.js-data-file-blob-permalink-url'
)[
'href'
]).
to
eq
(
get_absolute_url
(
project_blob_path
(
project
,
tree_join
(
sha
,
path
),
anchor:
"L
C
5"
)))
expect
(
find
(
'.js-data-file-blob-permalink-url'
)[
'href'
]).
to
eq
(
get_absolute_url
(
project_blob_path
(
project
,
tree_join
(
sha
,
path
),
anchor:
"L5"
)))
end
end
...
...
@@ -75,7 +75,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find
(
'#L3'
).
click
find
(
"#L5"
).
click
expect
(
find
(
'.js-blob-blame-link'
)[
'href'
]).
to
eq
(
get_absolute_url
(
project_blame_path
(
project
,
tree_join
(
'master'
,
path
),
anchor:
"L
C
5"
)))
expect
(
find
(
'.js-blob-blame-link'
)[
'href'
]).
to
eq
(
get_absolute_url
(
project_blame_path
(
project
,
tree_join
(
'master'
,
path
),
anchor:
"L5"
)))
end
it
'with initial fragment hash, changes fragment hash if line number clicked'
do
...
...
@@ -86,7 +86,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find
(
'#L3'
).
click
find
(
"#L5"
).
click
expect
(
find
(
'.js-blob-blame-link'
)[
'href'
]).
to
eq
(
get_absolute_url
(
project_blame_path
(
project
,
tree_join
(
'master'
,
path
),
anchor:
"L
C
5"
)))
expect
(
find
(
'.js-blob-blame-link'
)[
'href'
]).
to
eq
(
get_absolute_url
(
project_blame_path
(
project
,
tree_join
(
'master'
,
path
),
anchor:
"L5"
)))
end
end
end
...
...
spec/frontend/vue_shared/components/line_numbers_spec.js
deleted
100644 → 0
View file @
f0c53b2b
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
{
GlIcon
,
GlLink
}
from
'
@gitlab/ui
'
;
import
LineNumbers
from
'
~/vue_shared/components/line_numbers.vue
'
;
describe
(
'
Line Numbers component
'
,
()
=>
{
let
wrapper
;
const
lines
=
10
;
const
createComponent
=
()
=>
{
wrapper
=
shallowMount
(
LineNumbers
,
{
propsData
:
{
lines
}
});
};
const
findGlIcon
=
()
=>
wrapper
.
findComponent
(
GlIcon
);
const
findLineNumbers
=
()
=>
wrapper
.
findAllComponents
(
GlLink
);
const
findFirstLineNumber
=
()
=>
findLineNumbers
().
at
(
0
);
beforeEach
(()
=>
createComponent
());
afterEach
(()
=>
wrapper
.
destroy
());
describe
(
'
rendering
'
,
()
=>
{
it
(
'
renders Line Numbers
'
,
()
=>
{
expect
(
findLineNumbers
().
length
).
toBe
(
lines
);
expect
(
findFirstLineNumber
().
attributes
()).
toMatchObject
({
id
:
'
L1
'
,
to
:
'
#LC1
'
,
});
});
it
(
'
renders a link icon
'
,
()
=>
{
expect
(
findGlIcon
().
props
()).
toMatchObject
({
size
:
12
,
name
:
'
link
'
,
});
});
});
});
spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
0 → 100644
View file @
b40cd08a
import
{
GlLink
}
from
'
@gitlab/ui
'
;
import
{
shallowMountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
import
ChunkLine
from
'
~/vue_shared/components/source_viewer/components/chunk_line.vue
'
;
const
DEFAULT_PROPS
=
{
number
:
2
,
content
:
'
// Line content
'
,
language
:
'
javascript
'
,
};
describe
(
'
Chunk Line component
'
,
()
=>
{
let
wrapper
;
const
createComponent
=
(
props
=
{})
=>
{
wrapper
=
shallowMountExtended
(
ChunkLine
,
{
propsData
:
{
...
DEFAULT_PROPS
,
...
props
}
});
};
const
findLink
=
()
=>
wrapper
.
findComponent
(
GlLink
);
const
findContent
=
()
=>
wrapper
.
findByTestId
(
'
content
'
);
beforeEach
(()
=>
{
createComponent
();
});
afterEach
(()
=>
wrapper
.
destroy
());
describe
(
'
rendering
'
,
()
=>
{
it
(
'
renders a line number
'
,
()
=>
{
expect
(
findLink
().
attributes
()).
toMatchObject
({
'
data-line-number
'
:
`
${
DEFAULT_PROPS
.
number
}
`
,
to
:
`#L
${
DEFAULT_PROPS
.
number
}
`
,
id
:
`L
${
DEFAULT_PROPS
.
number
}
`
,
});
expect
(
findLink
().
text
()).
toBe
(
DEFAULT_PROPS
.
number
.
toString
());
});
it
(
'
renders content
'
,
()
=>
{
expect
(
findContent
().
attributes
()).
toMatchObject
({
id
:
`LC
${
DEFAULT_PROPS
.
number
}
`
,
lang
:
DEFAULT_PROPS
.
language
,
});
expect
(
findContent
().
text
()).
toBe
(
DEFAULT_PROPS
.
content
);
});
});
});
spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
0 → 100644
View file @
b40cd08a
import
{
GlIntersectionObserver
}
from
'
@gitlab/ui
'
;
import
{
shallowMountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
import
Chunk
from
'
~/vue_shared/components/source_viewer/components/chunk.vue
'
;
import
ChunkLine
from
'
~/vue_shared/components/source_viewer/components/chunk_line.vue
'
;
const
DEFAULT_PROPS
=
{
chunkIndex
:
2
,
isHighlighted
:
false
,
content
:
'
// Line 1 content
\n
// Line 2 content
'
,
startingFrom
:
140
,
totalLines
:
50
,
language
:
'
javascript
'
,
};
describe
(
'
Chunk component
'
,
()
=>
{
let
wrapper
;
const
createComponent
=
(
props
=
{})
=>
{
wrapper
=
shallowMountExtended
(
Chunk
,
{
propsData
:
{
...
DEFAULT_PROPS
,
...
props
}
});
};
const
findIntersectionObserver
=
()
=>
wrapper
.
findComponent
(
GlIntersectionObserver
);
const
findChunkLines
=
()
=>
wrapper
.
findAllComponents
(
ChunkLine
);
const
findLineNumbers
=
()
=>
wrapper
.
findAllByTestId
(
'
line-number
'
);
const
findContent
=
()
=>
wrapper
.
findByTestId
(
'
content
'
);
beforeEach
(()
=>
{
createComponent
();
});
afterEach
(()
=>
wrapper
.
destroy
());
describe
(
'
Intersection observer
'
,
()
=>
{
it
(
'
renders an Intersection observer component
'
,
()
=>
{
expect
(
findIntersectionObserver
().
exists
()).
toBe
(
true
);
});
it
(
'
emits an appear event when intersection-observer appears
'
,
()
=>
{
findIntersectionObserver
().
vm
.
$emit
(
'
appear
'
);
expect
(
wrapper
.
emitted
(
'
appear
'
)).
toEqual
([[
DEFAULT_PROPS
.
chunkIndex
]]);
});
it
(
'
does not emit an appear event is isHighlighted is true
'
,
()
=>
{
createComponent
({
isHighlighted
:
true
});
findIntersectionObserver
().
vm
.
$emit
(
'
appear
'
);
expect
(
wrapper
.
emitted
(
'
appear
'
)).
toEqual
(
undefined
);
});
});
describe
(
'
rendering
'
,
()
=>
{
it
(
'
does not render a Chunk Line component if isHighlighted is false
'
,
()
=>
{
expect
(
findChunkLines
().
length
).
toBe
(
0
);
});
it
(
'
renders simplified line numbers and content if isHighlighted is false
'
,
()
=>
{
expect
(
findLineNumbers
().
length
).
toBe
(
DEFAULT_PROPS
.
totalLines
);
expect
(
findLineNumbers
().
at
(
0
).
attributes
()).
toMatchObject
({
'
data-line-number
'
:
`
${
DEFAULT_PROPS
.
startingFrom
+
1
}
`
,
href
:
`#L
${
DEFAULT_PROPS
.
startingFrom
+
1
}
`
,
id
:
`L
${
DEFAULT_PROPS
.
startingFrom
+
1
}
`
,
});
expect
(
findContent
().
text
()).
toBe
(
DEFAULT_PROPS
.
content
);
});
it
(
'
renders Chunk Line components if isHighlighted is true
'
,
()
=>
{
const
splitContent
=
DEFAULT_PROPS
.
content
.
split
(
'
\n
'
);
createComponent
({
isHighlighted
:
true
});
expect
(
findChunkLines
().
length
).
toBe
(
splitContent
.
length
);
expect
(
findChunkLines
().
at
(
0
).
props
()).
toMatchObject
({
number
:
DEFAULT_PROPS
.
startingFrom
+
1
,
content
:
splitContent
[
0
],
language
:
DEFAULT_PROPS
.
language
,
});
});
});
});
spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
View file @
b40cd08a
import
hljs
from
'
highlight.js/lib/core
'
;
import
{
GlLoadingIcon
}
from
'
@gitlab/ui
'
;
import
Vue
,
{
nextTick
}
from
'
vue
'
;
import
Vue
from
'
vue
'
;
import
VueRouter
from
'
vue-router
'
;
import
{
shallowMountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
import
SourceViewer
from
'
~/vue_shared/components/source_viewer/source_viewer.vue
'
;
import
Chunk
from
'
~/vue_shared/components/source_viewer/components/chunk.vue
'
;
import
{
ROUGE_TO_HLJS_LANGUAGE_MAP
}
from
'
~/vue_shared/components/source_viewer/constants
'
;
import
LineNumbers
from
'
~/vue_shared/components/line_numbers.vue
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
*
as
sourceViewerUtils
from
'
~/vue_shared/components/source_viewer/utils
'
;
import
LineHighlighter
from
'
~/blob/line_highlighter
'
;
jest
.
mock
(
'
~/blob/line_highlighter
'
);
jest
.
mock
(
'
highlight.js/lib/core
'
);
Vue
.
use
(
VueRouter
);
const
router
=
new
VueRouter
();
const
generateContent
=
(
content
,
totalLines
=
1
)
=>
{
let
generatedContent
=
''
;
for
(
let
i
=
0
;
i
<
totalLines
;
i
+=
1
)
{
generatedContent
+=
`Line:
${
i
+
1
}
=
${
content
}
\n`
;
}
return
generatedContent
;
};
const
execImmediately
=
(
callback
)
=>
callback
();
describe
(
'
Source Viewer component
'
,
()
=>
{
let
wrapper
;
const
language
=
'
docker
'
;
const
mappedLanguage
=
ROUGE_TO_HLJS_LANGUAGE_MAP
[
language
];
const
content
=
`// Some source code`
;
const
chunk1
=
generateContent
(
'
// Some source code 1
'
,
70
);
const
chunk2
=
generateContent
(
'
// Some source code 2
'
,
70
);
const
content
=
chunk1
+
chunk2
;
const
DEFAULT_BLOB_DATA
=
{
language
,
rawTextBlob
:
content
};
const
highlightedContent
=
`<span data-testid='test-highlighted' id='LC1'>
${
content
}
</span><span id='LC2'></span>`
;
...
...
@@ -29,15 +41,12 @@ describe('Source Viewer component', () => {
await
waitForPromises
();
};
const
findLoadingIcon
=
()
=>
wrapper
.
findComponent
(
GlLoadingIcon
);
const
findLineNumbers
=
()
=>
wrapper
.
findComponent
(
LineNumbers
);
const
findHighlightedContent
=
()
=>
wrapper
.
findByTestId
(
'
test-highlighted
'
);
const
findFirstLine
=
()
=>
wrapper
.
find
(
'
#LC1
'
);
const
findChunks
=
()
=>
wrapper
.
findAllComponents
(
Chunk
);
beforeEach
(()
=>
{
hljs
.
highlight
.
mockImplementation
(()
=>
({
value
:
highlightedContent
}));
hljs
.
highlightAuto
.
mockImplementation
(()
=>
({
value
:
highlightedContent
}));
jest
.
spyOn
(
sourceViewerUtils
,
'
wrapLines
'
);
jest
.
spyOn
(
window
,
'
requestIdleCallback
'
).
mockImplementation
(
execImmediately
);
return
createComponent
();
});
...
...
@@ -45,6 +54,8 @@ describe('Source Viewer component', () => {
afterEach
(()
=>
wrapper
.
destroy
());
describe
(
'
highlight.js
'
,
()
=>
{
beforeEach
(()
=>
createComponent
({
language
:
mappedLanguage
}));
it
(
'
registers the language definition
'
,
async
()
=>
{
const
languageDefinition
=
await
import
(
`highlight.js/lib/languages/
${
mappedLanguage
}
`
);
...
...
@@ -54,72 +65,46 @@ describe('Source Viewer component', () => {
);
});
it
(
'
highlights the
content
'
,
()
=>
{
expect
(
hljs
.
highlight
).
toHaveBeenCalledWith
(
c
ontent
,
{
language
:
mappedLanguage
});
it
(
'
highlights the
first chunk
'
,
()
=>
{
expect
(
hljs
.
highlight
).
toHaveBeenCalledWith
(
c
hunk1
.
trim
()
,
{
language
:
mappedLanguage
});
});
describe
(
'
auto-detects if a language cannot be loaded
'
,
()
=>
{
beforeEach
(()
=>
createComponent
({
language
:
'
some_unknown_language
'
}));
it
(
'
highlights the content with auto-detection
'
,
()
=>
{
expect
(
hljs
.
highlightAuto
).
toHaveBeenCalledWith
(
c
ontent
);
expect
(
hljs
.
highlightAuto
).
toHaveBeenCalledWith
(
c
hunk1
.
trim
()
);
});
});
});
describe
(
'
rendering
'
,
()
=>
{
it
(
'
renders a loading icon if no highlighted content is available yet
'
,
async
()
=>
{
hljs
.
highlight
.
mockImplementation
(()
=>
({
value
:
null
}));
await
createComponent
();
expect
(
findLoadingIcon
().
exists
()).
toBe
(
true
);
});
it
(
'
renders the first chunk
'
,
async
()
=>
{
const
firstChunk
=
findChunks
().
at
(
0
);
it
(
'
calls the wrapLines helper method with highlightedContent and mappedLanguage
'
,
()
=>
{
expect
(
sourceViewerUtils
.
wrapLines
).
toHaveBeenCalledWith
(
highlightedContent
,
mappedLanguage
);
});
it
(
'
renders Line Numbers
'
,
()
=>
{
expect
(
findLineNumbers
().
props
(
'
lines
'
)).
toBe
(
1
);
});
expect
(
firstChunk
.
props
(
'
content
'
)).
toContain
(
chunk1
);
it
(
'
renders the highlighted content
'
,
()
=>
{
expect
(
findHighlightedContent
().
exists
()).
toBe
(
true
);
});
});
describe
(
'
selecting a line
'
,
()
=>
{
let
firstLine
;
let
firstLineElement
;
beforeEach
(()
=>
{
firstLine
=
findFirstLine
();
firstLineElement
=
firstLine
.
element
;
jest
.
spyOn
(
firstLineElement
,
'
scrollIntoView
'
);
jest
.
spyOn
(
firstLineElement
.
classList
,
'
add
'
);
jest
.
spyOn
(
firstLineElement
.
classList
,
'
remove
'
);
expect
(
firstChunk
.
props
()).
toMatchObject
({
totalLines
:
70
,
startingFrom
:
0
,
});
});
it
(
'
adds the highlight (hll) class
'
,
async
()
=>
{
wrapper
.
vm
.
$router
.
push
(
'
#LC1
'
);
await
nextTick
();
it
(
'
renders the second chunk
'
,
async
()
=>
{
const
secondChunk
=
findChunks
().
at
(
1
);
expect
(
firstLineElement
.
classList
.
add
).
toHaveBeenCalledWith
(
'
hll
'
);
});
expect
(
secondChunk
.
props
(
'
content
'
)).
toContain
(
chunk2
.
trim
());
it
(
'
removes the highlight (hll) class from a previously highlighted line
'
,
async
()
=>
{
wrapper
.
vm
.
$router
.
push
(
'
#LC2
'
);
await
nextTick
();
expect
(
firstLineElement
.
classList
.
remove
).
toHaveBeenCalledWith
(
'
hll
'
);
expect
(
secondChunk
.
props
()).
toMatchObject
({
totalLines
:
70
,
startingFrom
:
70
,
});
});
});
it
(
'
scrolls the line into view
'
,
()
=>
{
expect
(
firstLineElement
.
scrollIntoView
).
toHaveBeenCalledWith
({
behavior
:
'
smooth
'
,
block
:
'
center
'
,
});
describe
(
'
LineHighlighter
'
,
()
=>
{
it
(
'
instantiates the lineHighlighter class
'
,
async
()
=>
{
expect
(
LineHighlighter
).
toHaveBeenCalledWith
({
scrollBehavior
:
'
auto
'
});
});
});
});
spec/frontend/vue_shared/components/source_viewer/utils_spec.js
deleted
100644 → 0
View file @
f0c53b2b
import
{
wrapLines
}
from
'
~/vue_shared/components/source_viewer/utils
'
;
describe
(
'
Wrap lines
'
,
()
=>
{
it
.
each
`
content | language | output
${
'
line 1
'
}
|
${
'
javascript
'
}
|
${
'
<span id="LC1" lang="javascript" class="line">line 1</span>
'
}
${
'
line 1
\n
line 2
'
}
|
${
'
html
'
}
|
${
`<span id="LC1" lang="html" class="line">line 1</span>\n<span id="LC2" lang="html" class="line">line 2</span>`
}
${
'
<span class="hljs-code">line 1
\n
line 2</span>
'
}
|
${
'
html
'
}
|
${
`<span id="LC1" lang="html" class="hljs-code">line 1\n<span id="LC2" lang="html" class="line">line 2</span></span>`
}
${
'
<span class="hljs-code">```bash
'
}
|
${
'
bash
'
}
|
${
'
<span id="LC1" lang="bash" class="hljs-code">```bash
'
}
${
'
<span class="hljs-code">```bash
'
}
|
${
'
valid-language1
'
}
|
${
'
<span id="LC1" lang="valid-language1" class="hljs-code">```bash
'
}
${
'
<span class="hljs-code">```bash
'
}
|
${
'
valid_language2
'
}
|
${
'
<span id="LC1" lang="valid_language2" class="hljs-code">```bash
'
}
`
(
'
returns lines wrapped in spans containing line numbers
'
,
({
content
,
language
,
output
})
=>
{
expect
(
wrapLines
(
content
,
language
)).
toBe
(
output
);
});
it
.
each
`
language
${
'
invalidLanguage>
'
}
${
'
"invalidLanguage"
'
}
${
'
<invalidLanguage
'
}
`
(
'
returns lines safely without XSS language is not valid
'
,
({
language
})
=>
{
expect
(
wrapLines
(
'
<span class="hljs-code">```bash
'
,
language
)).
toBe
(
'
<span id="LC1" lang="" class="hljs-code">```bash
'
,
);
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment