Browse Source
Merge pull request #42094 from nextcloud/refactor-global-search-to-unified-search
Merge pull request #42094 from nextcloud/refactor-global-search-to-unified-search
Rename "global search" to "unified search"pull/42064/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1414 additions and 1416 deletions
-
169core/src/components/GlobalSearch/SearchResult.vue
-
14core/src/components/UnifiedSearch/CustomDateRangeModal.vue
-
259core/src/components/UnifiedSearch/LegacySearchResult.vue
-
0core/src/components/UnifiedSearch/SearchFilterChip.vue
-
286core/src/components/UnifiedSearch/SearchResult.vue
-
0core/src/components/UnifiedSearch/SearchableList.vue
-
14core/src/legacy-unified-search.js
-
43core/src/services/LegacyUnifiedSearchService.js
-
43core/src/services/UnifiedSearchService.js
-
4core/src/unified-search.js
-
96core/src/views/GlobalSearch.vue
-
863core/src/views/LegacyUnifiedSearch.vue
-
853core/src/views/UnifiedSearch.vue
-
45core/src/views/UnifiedSearchModal.vue
-
1core/templates/layout.user.php
-
3dist/core-global-search.js
-
21dist/core-global-search.js.LICENSE.txt
-
1dist/core-global-search.js.map
-
3dist/core-legacy-unified-search.js
-
46dist/core-legacy-unified-search.js.LICENSE.txt
-
1dist/core-legacy-unified-search.js.map
-
4dist/core-unified-search.js
-
29dist/core-unified-search.js.LICENSE.txt
-
2dist/core-unified-search.js.map
-
4dist/files_sharing-personal-settings.js
-
2dist/files_sharing-personal-settings.js.map
-
4dist/settings-vue-settings-admin-security.js
-
2dist/settings-vue-settings-admin-security.js.map
-
4dist/sharebymail-vue-settings-admin-sharebymail.js
-
2dist/sharebymail-vue-settings-admin-sharebymail.js.map
-
4dist/updatenotification-updatenotification.js
-
2dist/updatenotification-updatenotification.js.map
-
4lib/private/TemplateLayout.php
-
2webpack.modules.js
@ -1,169 +0,0 @@ |
|||
<template> |
|||
<NcListItem class="result-items__item" |
|||
:name="title" |
|||
:bold="false" |
|||
:href="resourceUrl" |
|||
target="_self"> |
|||
<template #icon> |
|||
<div aria-hidden="true" |
|||
class="result-items__item-icon" |
|||
:class="{ |
|||
'result-items__item-icon--rounded': rounded, |
|||
'result-items__item-icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl), |
|||
'result-items__item-icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl), |
|||
[icon]: !isValidIconOrPreviewUrl(icon), |
|||
}" |
|||
:style="{ |
|||
backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '', |
|||
}"> |
|||
<img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError" |
|||
:src="thumbnailUrl" |
|||
@error="thumbnailErrorHandler"> |
|||
</div> |
|||
</template> |
|||
<template #subname> |
|||
{{ subline }} |
|||
</template> |
|||
</NcListItem> |
|||
</template> |
|||
|
|||
<script> |
|||
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js' |
|||
|
|||
export default { |
|||
name: 'SearchResult', |
|||
components: { |
|||
NcListItem, |
|||
}, |
|||
props: { |
|||
thumbnailUrl: { |
|||
type: String, |
|||
default: null, |
|||
}, |
|||
title: { |
|||
type: String, |
|||
required: true, |
|||
}, |
|||
subline: { |
|||
type: String, |
|||
default: null, |
|||
}, |
|||
resourceUrl: { |
|||
type: String, |
|||
default: null, |
|||
}, |
|||
icon: { |
|||
type: String, |
|||
default: '', |
|||
}, |
|||
rounded: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
query: { |
|||
type: String, |
|||
default: '', |
|||
}, |
|||
|
|||
/** |
|||
* Only used for the first result as a visual feedback |
|||
* so we can keep the search input focused but pressing |
|||
* enter still opens the first result |
|||
*/ |
|||
focused: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
}, |
|||
data() { |
|||
return { |
|||
thumbnailHasError: false, |
|||
} |
|||
}, |
|||
watch: { |
|||
thumbnailUrl() { |
|||
this.thumbnailHasError = false |
|||
}, |
|||
}, |
|||
methods: { |
|||
isValidIconOrPreviewUrl(url) { |
|||
return /^https?:\/\//.test(url) || url.startsWith('/') |
|||
}, |
|||
thumbnailErrorHandler() { |
|||
this.thumbnailHasError = true |
|||
}, |
|||
}, |
|||
} |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
@use "sass:math"; |
|||
$clickable-area: 44px; |
|||
$margin: 10px; |
|||
|
|||
.result-items { |
|||
&__item { |
|||
|
|||
::v-deep a { |
|||
border-radius: 12px; |
|||
border: 2px solid transparent; |
|||
border-radius: var(--border-radius-large) !important; |
|||
|
|||
&--focused { |
|||
background-color: var(--color-background-hover); |
|||
} |
|||
|
|||
&:active, |
|||
&:hover, |
|||
&:focus { |
|||
background-color: var(--color-background-hover); |
|||
border: 2px solid var(--color-border-maxcontrast); |
|||
} |
|||
|
|||
* { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
} |
|||
|
|||
&-icon { |
|||
overflow: hidden; |
|||
width: $clickable-area; |
|||
height: $clickable-area; |
|||
border-radius: var(--border-radius); |
|||
background-repeat: no-repeat; |
|||
background-position: center center; |
|||
background-size: 32px; |
|||
|
|||
&--rounded { |
|||
border-radius: math.div($clickable-area, 2); |
|||
} |
|||
|
|||
&--no-preview { |
|||
background-size: 32px; |
|||
} |
|||
|
|||
&--with-thumbnail { |
|||
background-size: cover; |
|||
} |
|||
|
|||
&--with-thumbnail:not(&--rounded) { |
|||
// compensate for border |
|||
max-width: $clickable-area - 2px; |
|||
max-height: $clickable-area - 2px; |
|||
border: 1px solid var(--color-border); |
|||
} |
|||
|
|||
img { |
|||
// Make sure to keep ratio |
|||
width: 100%; |
|||
height: 100%; |
|||
|
|||
object-fit: cover; |
|||
object-position: center; |
|||
} |
|||
} |
|||
|
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,259 @@ |
|||
<!-- |
|||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> |
|||
- |
|||
- @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
- |
|||
- @license GNU AGPL version 3 or any later version |
|||
- |
|||
- This program is free software: you can redistribute it and/or modify |
|||
- it under the terms of the GNU Affero General Public License as |
|||
- published by the Free Software Foundation, either version 3 of the |
|||
- License, or (at your option) any later version. |
|||
- |
|||
- This program is distributed in the hope that it will be useful, |
|||
- but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
- GNU Affero General Public License for more details. |
|||
- |
|||
- You should have received a copy of the GNU Affero General Public License |
|||
- along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
- |
|||
--> |
|||
<template> |
|||
<a :href="resourceUrl || '#'" |
|||
class="unified-search__result" |
|||
:class="{ |
|||
'unified-search__result--focused': focused, |
|||
}" |
|||
@click="reEmitEvent" |
|||
@focus="reEmitEvent"> |
|||
|
|||
<!-- Icon describing the result --> |
|||
<div class="unified-search__result-icon" |
|||
:class="{ |
|||
'unified-search__result-icon--rounded': rounded, |
|||
'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded, |
|||
'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded, |
|||
[icon]: !loaded && !isIconUrl, |
|||
}" |
|||
:style="{ |
|||
backgroundImage: isIconUrl ? `url(${icon})` : '', |
|||
}"> |
|||
|
|||
<img v-if="hasValidThumbnail" |
|||
v-show="loaded" |
|||
:src="thumbnailUrl" |
|||
alt="" |
|||
@error="onError" |
|||
@load="onLoad"> |
|||
</div> |
|||
|
|||
<!-- Title and sub-title --> |
|||
<span class="unified-search__result-content"> |
|||
<span class="unified-search__result-line-one" :title="title"> |
|||
<NcHighlight :text="title" :search="query" /> |
|||
</span> |
|||
<span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span> |
|||
</span> |
|||
</a> |
|||
</template> |
|||
|
|||
<script> |
|||
import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js' |
|||
|
|||
export default { |
|||
name: 'LegacySearchResult', |
|||
|
|||
components: { |
|||
NcHighlight, |
|||
}, |
|||
|
|||
props: { |
|||
thumbnailUrl: { |
|||
type: String, |
|||
default: null, |
|||
}, |
|||
title: { |
|||
type: String, |
|||
required: true, |
|||
}, |
|||
subline: { |
|||
type: String, |
|||
default: null, |
|||
}, |
|||
resourceUrl: { |
|||
type: String, |
|||
default: null, |
|||
}, |
|||
icon: { |
|||
type: String, |
|||
default: '', |
|||
}, |
|||
rounded: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
query: { |
|||
type: String, |
|||
default: '', |
|||
}, |
|||
|
|||
/** |
|||
* Only used for the first result as a visual feedback |
|||
* so we can keep the search input focused but pressing |
|||
* enter still opens the first result |
|||
*/ |
|||
focused: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
}, |
|||
|
|||
data() { |
|||
return { |
|||
hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '', |
|||
loaded: false, |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
isIconUrl() { |
|||
// If we're facing an absolute url |
|||
if (this.icon.startsWith('/')) { |
|||
return true |
|||
} |
|||
|
|||
// Otherwise, let's check if this is a valid url |
|||
try { |
|||
// eslint-disable-next-line no-new |
|||
new URL(this.icon) |
|||
} catch { |
|||
return false |
|||
} |
|||
return true |
|||
}, |
|||
}, |
|||
|
|||
watch: { |
|||
// Make sure to reset state on change even when vue recycle the component |
|||
thumbnailUrl() { |
|||
this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== '' |
|||
this.loaded = false |
|||
}, |
|||
}, |
|||
|
|||
methods: { |
|||
reEmitEvent(e) { |
|||
this.$emit(e.type, e) |
|||
}, |
|||
|
|||
/** |
|||
* If the image fails to load, fallback to iconClass |
|||
*/ |
|||
onError() { |
|||
this.hasValidThumbnail = false |
|||
}, |
|||
|
|||
onLoad() { |
|||
this.loaded = true |
|||
}, |
|||
}, |
|||
} |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
@use "sass:math"; |
|||
|
|||
$clickable-area: 44px; |
|||
$margin: 10px; |
|||
|
|||
.unified-search__result { |
|||
display: flex; |
|||
align-items: center; |
|||
height: $clickable-area; |
|||
padding: $margin; |
|||
border: 2px solid transparent; |
|||
border-radius: var(--border-radius-large) !important; |
|||
|
|||
&--focused { |
|||
background-color: var(--color-background-hover); |
|||
} |
|||
|
|||
&:active, |
|||
&:hover, |
|||
&:focus { |
|||
background-color: var(--color-background-hover); |
|||
border: 2px solid var(--color-border-maxcontrast); |
|||
} |
|||
|
|||
* { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
&-icon { |
|||
overflow: hidden; |
|||
width: $clickable-area; |
|||
height: $clickable-area; |
|||
border-radius: var(--border-radius); |
|||
background-repeat: no-repeat; |
|||
background-position: center center; |
|||
background-size: 32px; |
|||
&--rounded { |
|||
border-radius: math.div($clickable-area, 2); |
|||
} |
|||
&--no-preview { |
|||
background-size: 32px; |
|||
} |
|||
&--with-thumbnail { |
|||
background-size: cover; |
|||
} |
|||
&--with-thumbnail:not(&--rounded) { |
|||
// compensate for border |
|||
max-width: $clickable-area - 2px; |
|||
max-height: $clickable-area - 2px; |
|||
border: 1px solid var(--color-border); |
|||
} |
|||
|
|||
img { |
|||
// Make sure to keep ratio |
|||
width: 100%; |
|||
height: 100%; |
|||
|
|||
object-fit: cover; |
|||
object-position: center; |
|||
} |
|||
} |
|||
|
|||
&-icon, |
|||
&-actions { |
|||
flex: 0 0 $clickable-area; |
|||
} |
|||
|
|||
&-content { |
|||
display: flex; |
|||
align-items: center; |
|||
flex: 1 1 100%; |
|||
flex-wrap: wrap; |
|||
// Set to minimum and gro from it |
|||
min-width: 0; |
|||
padding-left: $margin; |
|||
} |
|||
|
|||
&-line-one, |
|||
&-line-two { |
|||
overflow: hidden; |
|||
flex: 1 1 100%; |
|||
margin: 1px 0; |
|||
white-space: nowrap; |
|||
text-overflow: ellipsis; |
|||
// Use the same color as the `a` |
|||
color: inherit; |
|||
font-size: inherit; |
|||
} |
|||
&-line-two { |
|||
opacity: .7; |
|||
font-size: var(--default-font-size); |
|||
} |
|||
} |
|||
|
|||
</style> |
|||
@ -1,96 +0,0 @@ |
|||
<!-- |
|||
- @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com> |
|||
- |
|||
- @author Fon E. Noel NFEBE <fenn25.fn@gmail.com> |
|||
- |
|||
- @license GNU AGPL version 3 or any later version |
|||
- |
|||
- This program is free software: you can redistribute it and/or modify |
|||
- it under the terms of the GNU Affero General Public License as |
|||
- published by the Free Software Foundation, either version 3 of the |
|||
- License, or (at your option) any later version. |
|||
- |
|||
- This program is distributed in the hope that it will be useful, |
|||
- but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
- GNU Affero General Public License for more details. |
|||
- |
|||
- You should have received a copy of the GNU Affero General Public License |
|||
- along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
- |
|||
--> |
|||
<template> |
|||
<div class="header-menu"> |
|||
<NcButton class="global-search__button" :aria-label="t('core', 'Unified search')" @click="toggleGlobalSearch"> |
|||
<template #icon> |
|||
<Magnify class="global-search__trigger" :size="22" /> |
|||
</template> |
|||
</NcButton> |
|||
<GlobalSearchModal :class="'global-search-modal'" :is-visible="showGlobalSearch" @update:isVisible="handleModalVisibilityChange" /> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' |
|||
import Magnify from 'vue-material-design-icons/Magnify.vue' |
|||
import GlobalSearchModal from './GlobalSearchModal.vue' |
|||
|
|||
export default { |
|||
name: 'GlobalSearch', |
|||
components: { |
|||
NcButton, |
|||
Magnify, |
|||
GlobalSearchModal, |
|||
}, |
|||
data() { |
|||
return { |
|||
showGlobalSearch: false, |
|||
} |
|||
}, |
|||
mounted() { |
|||
console.debug('Global search initialized!') |
|||
}, |
|||
methods: { |
|||
toggleGlobalSearch() { |
|||
this.showGlobalSearch = !this.showGlobalSearch |
|||
}, |
|||
handleModalVisibilityChange(newVisibilityVal) { |
|||
this.showGlobalSearch = newVisibilityVal |
|||
}, |
|||
}, |
|||
} |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
.header-menu { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
.global-search__button { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
width: var(--header-height); |
|||
// height: var(--header-height); |
|||
margin: 0; |
|||
padding: 0; |
|||
cursor: pointer; |
|||
opacity: .85; |
|||
background-color: transparent; |
|||
border: none; |
|||
filter: none !important; |
|||
color: var(--color-primary-text) !important; |
|||
|
|||
&:hover { |
|||
background-color: transparent !important; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.global-search-modal { |
|||
::v-deep .modal-container { |
|||
height: 80%; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,863 @@ |
|||
<!-- |
|||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> |
|||
- |
|||
- @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
- |
|||
- @license GNU AGPL version 3 or any later version |
|||
- |
|||
- This program is free software: you can redistribute it and/or modify |
|||
- it under the terms of the GNU Affero General Public License as |
|||
- published by the Free Software Foundation, either version 3 of the |
|||
- License, or (at your option) any later version. |
|||
- |
|||
- This program is distributed in the hope that it will be useful, |
|||
- but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
- GNU Affero General Public License for more details. |
|||
- |
|||
- You should have received a copy of the GNU Affero General Public License |
|||
- along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
- |
|||
--> |
|||
<template> |
|||
<NcHeaderMenu id="unified-search" |
|||
class="unified-search" |
|||
:exclude-click-outside-selectors="['.popover']" |
|||
:open.sync="open" |
|||
:aria-label="ariaLabel" |
|||
@open="onOpen" |
|||
@close="onClose"> |
|||
<!-- Header icon --> |
|||
<template #trigger> |
|||
<Magnify class="unified-search__trigger" |
|||
:size="22/* fit better next to other 20px icons */" /> |
|||
</template> |
|||
|
|||
<!-- Search form & filters wrapper --> |
|||
<div class="unified-search__input-wrapper"> |
|||
<div class="unified-search__input-row"> |
|||
<NcTextField ref="input" |
|||
:value.sync="query" |
|||
trailing-button-icon="close" |
|||
:label="ariaLabel" |
|||
:trailing-button-label="t('core','Reset search')" |
|||
:show-trailing-button="query !== ''" |
|||
aria-describedby="unified-search-desc" |
|||
class="unified-search__form-input" |
|||
:class="{'unified-search__form-input--with-reset': !!query}" |
|||
:placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })" |
|||
@trailing-button-click="onReset" |
|||
@input="onInputDebounced" /> |
|||
<p id="unified-search-desc" class="hidden-visually"> |
|||
{{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }} |
|||
</p> |
|||
|
|||
<!-- Search filters --> |
|||
<NcActions v-if="availableFilters.length > 1" |
|||
class="unified-search__filters" |
|||
placement="bottom-end" |
|||
container=".unified-search__input-wrapper"> |
|||
<!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 --> |
|||
<NcActionButton v-for="filter in availableFilters" |
|||
:key="filter" |
|||
icon="icon-filter" |
|||
@click.stop="onClickFilter(`in:${filter}`)"> |
|||
{{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }} |
|||
</NcActionButton> |
|||
</NcActions> |
|||
</div> |
|||
</div> |
|||
|
|||
<template v-if="!hasResults"> |
|||
<!-- Loading placeholders --> |
|||
<SearchResultPlaceholders v-if="isLoading" /> |
|||
|
|||
<NcEmptyContent v-else-if="isValidQuery" |
|||
:title="validQueryTitle"> |
|||
<template #icon> |
|||
<Magnify /> |
|||
</template> |
|||
</NcEmptyContent> |
|||
|
|||
<NcEmptyContent v-else-if="!isLoading || isShortQuery" |
|||
:title="t('core', 'Start typing to search')" |
|||
:description="shortQueryDescription"> |
|||
<template #icon> |
|||
<Magnify /> |
|||
</template> |
|||
</NcEmptyContent> |
|||
</template> |
|||
|
|||
<!-- Grouped search results --> |
|||
<template v-for="({list, type}, typesIndex) in orderedResults" v-else> |
|||
<h2 :key="type" class="unified-search__results-header"> |
|||
{{ typesMap[type] }} |
|||
</h2> |
|||
<ul :key="type" |
|||
class="unified-search__results" |
|||
:class="`unified-search__results-${type}`" |
|||
:aria-label="typesMap[type]"> |
|||
<!-- Search results --> |
|||
<li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl"> |
|||
<SearchResult v-bind="result" |
|||
:query="query" |
|||
:focused="focused === 0 && typesIndex === 0 && index === 0" |
|||
@focus="setFocusedIndex" /> |
|||
</li> |
|||
|
|||
<!-- Load more button --> |
|||
<li> |
|||
<SearchResult v-if="!reached[type]" |
|||
class="unified-search__result-more" |
|||
:title="loading[type] |
|||
? t('core', 'Loading more results …') |
|||
: t('core', 'Load more results')" |
|||
:icon-class="loading[type] ? 'icon-loading-small' : ''" |
|||
@click.prevent.stop="loadMore(type)" |
|||
@focus="setFocusedIndex" /> |
|||
</li> |
|||
</ul> |
|||
</template> |
|||
</NcHeaderMenu> |
|||
</template> |
|||
|
|||
<script> |
|||
import debounce from 'debounce' |
|||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' |
|||
import { showError } from '@nextcloud/dialogs' |
|||
|
|||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' |
|||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' |
|||
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' |
|||
import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' |
|||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' |
|||
|
|||
import Magnify from 'vue-material-design-icons/Magnify.vue' |
|||
|
|||
import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue' |
|||
import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue' |
|||
|
|||
import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js' |
|||
|
|||
const REQUEST_FAILED = 0 |
|||
const REQUEST_OK = 1 |
|||
const REQUEST_CANCELED = 2 |
|||
|
|||
export default { |
|||
name: 'LegacyUnifiedSearch', |
|||
|
|||
components: { |
|||
Magnify, |
|||
NcActionButton, |
|||
NcActions, |
|||
NcEmptyContent, |
|||
NcHeaderMenu, |
|||
SearchResult, |
|||
SearchResultPlaceholders, |
|||
NcTextField, |
|||
}, |
|||
|
|||
data() { |
|||
return { |
|||
types: [], |
|||
|
|||
// Cursors per types |
|||
cursors: {}, |
|||
// Various search limits per types |
|||
limits: {}, |
|||
// Loading types |
|||
loading: {}, |
|||
// Reached search types |
|||
reached: {}, |
|||
// Pending cancellable requests |
|||
requests: [], |
|||
// List of all results |
|||
results: {}, |
|||
|
|||
query: '', |
|||
focused: null, |
|||
triggered: false, |
|||
|
|||
defaultLimit, |
|||
minSearchLength, |
|||
enableLiveSearch, |
|||
|
|||
open: false, |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
typesIDs() { |
|||
return this.types.map(type => type.id) |
|||
}, |
|||
typesNames() { |
|||
return this.types.map(type => type.name) |
|||
}, |
|||
typesMap() { |
|||
return this.types.reduce((prev, curr) => { |
|||
prev[curr.id] = curr.name |
|||
return prev |
|||
}, {}) |
|||
}, |
|||
|
|||
ariaLabel() { |
|||
return t('core', 'Search') |
|||
}, |
|||
|
|||
/** |
|||
* Is there any result to display |
|||
* |
|||
* @return {boolean} |
|||
*/ |
|||
hasResults() { |
|||
return Object.keys(this.results).length !== 0 |
|||
}, |
|||
|
|||
/** |
|||
* Return ordered results |
|||
* |
|||
* @return {Array} |
|||
*/ |
|||
orderedResults() { |
|||
return this.typesIDs |
|||
.filter(type => type in this.results) |
|||
.map(type => ({ |
|||
type, |
|||
list: this.results[type], |
|||
})) |
|||
}, |
|||
|
|||
/** |
|||
* Available filters |
|||
* We only show filters that are available on the results |
|||
* |
|||
* @return {string[]} |
|||
*/ |
|||
availableFilters() { |
|||
return Object.keys(this.results) |
|||
}, |
|||
|
|||
/** |
|||
* Applied filters |
|||
* |
|||
* @return {string[]} |
|||
*/ |
|||
usedFiltersIn() { |
|||
let match |
|||
const filters = [] |
|||
while ((match = regexFilterIn.exec(this.query)) !== null) { |
|||
filters.push(match[2]) |
|||
} |
|||
return filters |
|||
}, |
|||
|
|||
/** |
|||
* Applied anti filters |
|||
* |
|||
* @return {string[]} |
|||
*/ |
|||
usedFiltersNot() { |
|||
let match |
|||
const filters = [] |
|||
while ((match = regexFilterNot.exec(this.query)) !== null) { |
|||
filters.push(match[2]) |
|||
} |
|||
return filters |
|||
}, |
|||
|
|||
/** |
|||
* Valid query empty content title |
|||
* |
|||
* @return {string} |
|||
*/ |
|||
validQueryTitle() { |
|||
return this.triggered |
|||
? t('core', 'No results for {query}', { query: this.query }) |
|||
: t('core', 'Press Enter to start searching') |
|||
}, |
|||
|
|||
/** |
|||
* Short query empty content description |
|||
* |
|||
* @return {string} |
|||
*/ |
|||
shortQueryDescription() { |
|||
if (!this.isShortQuery) { |
|||
return '' |
|||
} |
|||
|
|||
return n('core', |
|||
'Please enter {minSearchLength} character or more to search', |
|||
'Please enter {minSearchLength} characters or more to search', |
|||
this.minSearchLength, |
|||
{ minSearchLength: this.minSearchLength }) |
|||
}, |
|||
|
|||
/** |
|||
* Is the current search too short |
|||
* |
|||
* @return {boolean} |
|||
*/ |
|||
isShortQuery() { |
|||
return this.query && this.query.trim().length < minSearchLength |
|||
}, |
|||
|
|||
/** |
|||
* Is the current search valid |
|||
* |
|||
* @return {boolean} |
|||
*/ |
|||
isValidQuery() { |
|||
return this.query && this.query.trim() !== '' && !this.isShortQuery |
|||
}, |
|||
|
|||
/** |
|||
* Have we reached the end of all types searches |
|||
* |
|||
* @return {boolean} |
|||
*/ |
|||
isDoneSearching() { |
|||
return Object.values(this.reached).every(state => state === false) |
|||
}, |
|||
|
|||
/** |
|||
* Is there any search in progress |
|||
* |
|||
* @return {boolean} |
|||
*/ |
|||
isLoading() { |
|||
return Object.values(this.loading).some(state => state === true) |
|||
}, |
|||
}, |
|||
|
|||
async created() { |
|||
this.types = await getTypes() |
|||
this.logger.debug('Unified Search initialized with the following providers', this.types) |
|||
}, |
|||
|
|||
beforeDestroy() { |
|||
unsubscribe('files:navigation:changed', this.onNavigationChange) |
|||
}, |
|||
|
|||
mounted() { |
|||
// subscribe in mounted, as onNavigationChange relys on $el |
|||
subscribe('files:navigation:changed', this.onNavigationChange) |
|||
|
|||
if (OCP.Accessibility.disableKeyboardShortcuts()) { |
|||
return |
|||
} |
|||
|
|||
document.addEventListener('keydown', (event) => { |
|||
// if not already opened, allows us to trigger default browser on second keydown |
|||
if (event.ctrlKey && event.code === 'KeyF' && !this.open) { |
|||
event.preventDefault() |
|||
this.open = true |
|||
} else if (event.ctrlKey && event.key === 'f' && this.open) { |
|||
// User wants to use the native browser search, so we close ours again |
|||
this.open = false |
|||
} |
|||
|
|||
// https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus |
|||
if (this.open) { |
|||
// If arrow down, focus next result |
|||
if (event.key === 'ArrowDown') { |
|||
this.focusNext(event) |
|||
} |
|||
|
|||
// If arrow up, focus prev result |
|||
if (event.key === 'ArrowUp') { |
|||
this.focusPrev(event) |
|||
} |
|||
} |
|||
}) |
|||
}, |
|||
|
|||
methods: { |
|||
async onOpen() { |
|||
// Update types list in the background |
|||
this.types = await getTypes() |
|||
}, |
|||
onClose() { |
|||
emit('nextcloud:unified-search.close') |
|||
}, |
|||
|
|||
onNavigationChange() { |
|||
this.$el?.querySelector?.('form[role="search"]')?.reset?.() |
|||
}, |
|||
|
|||
/** |
|||
* Reset the search state |
|||
*/ |
|||
onReset() { |
|||
emit('nextcloud:unified-search.reset') |
|||
this.logger.debug('Search reset') |
|||
this.query = '' |
|||
this.resetState() |
|||
this.focusInput() |
|||
}, |
|||
async resetState() { |
|||
this.cursors = {} |
|||
this.limits = {} |
|||
this.reached = {} |
|||
this.results = {} |
|||
this.focused = null |
|||
this.triggered = false |
|||
await this.cancelPendingRequests() |
|||
}, |
|||
|
|||
/** |
|||
* Cancel any ongoing searches |
|||
*/ |
|||
async cancelPendingRequests() { |
|||
// Cloning so we can keep processing other requests |
|||
const requests = this.requests.slice(0) |
|||
this.requests = [] |
|||
|
|||
// Cancel all pending requests |
|||
await Promise.all(requests.map(cancel => cancel())) |
|||
}, |
|||
|
|||
/** |
|||
* Focus the search input on next tick |
|||
*/ |
|||
focusInput() { |
|||
this.$nextTick(() => { |
|||
this.$refs.input.focus() |
|||
this.$refs.input.select() |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* If we have results already, open first one |
|||
* If not, trigger the search again |
|||
*/ |
|||
onInputEnter() { |
|||
if (this.hasResults) { |
|||
const results = this.getResultsList() |
|||
results[0].click() |
|||
return |
|||
} |
|||
this.onInput() |
|||
}, |
|||
|
|||
/** |
|||
* Start searching on input |
|||
*/ |
|||
async onInput() { |
|||
// emit the search query |
|||
emit('nextcloud:unified-search.search', { query: this.query }) |
|||
|
|||
// Do not search if not long enough |
|||
if (this.query.trim() === '' || this.isShortQuery) { |
|||
for (const type of this.typesIDs) { |
|||
this.$delete(this.results, type) |
|||
} |
|||
return |
|||
} |
|||
|
|||
let types = this.typesIDs |
|||
let query = this.query |
|||
|
|||
// Filter out types |
|||
if (this.usedFiltersNot.length > 0) { |
|||
types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1) |
|||
} |
|||
|
|||
// Only use those filters if any and check if they are valid |
|||
if (this.usedFiltersIn.length > 0) { |
|||
types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1) |
|||
} |
|||
|
|||
// Remove any filters from the query |
|||
query = query.replace(regexFilterIn, '').replace(regexFilterNot, '') |
|||
|
|||
// Reset search if the query changed |
|||
await this.resetState() |
|||
this.triggered = true |
|||
|
|||
if (!types.length) { |
|||
// no results since no types were selected |
|||
this.logger.error('No types to search in') |
|||
return |
|||
} |
|||
|
|||
this.$set(this.loading, 'all', true) |
|||
this.logger.debug(`Searching ${query} in`, types) |
|||
|
|||
Promise.all(types.map(async type => { |
|||
try { |
|||
// Init cancellable request |
|||
const { request, cancel } = search({ type, query }) |
|||
this.requests.push(cancel) |
|||
|
|||
// Fetch results |
|||
const { data } = await request() |
|||
|
|||
// Process results |
|||
if (data.ocs.data.entries.length > 0) { |
|||
this.$set(this.results, type, data.ocs.data.entries) |
|||
} else { |
|||
this.$delete(this.results, type) |
|||
} |
|||
|
|||
// Save cursor if any |
|||
if (data.ocs.data.cursor) { |
|||
this.$set(this.cursors, type, data.ocs.data.cursor) |
|||
} else if (!data.ocs.data.isPaginated) { |
|||
// If no cursor and no pagination, we save the default amount |
|||
// provided by server's initial state `defaultLimit` |
|||
this.$set(this.limits, type, this.defaultLimit) |
|||
} |
|||
|
|||
// Check if we reached end of pagination |
|||
if (data.ocs.data.entries.length < this.defaultLimit) { |
|||
this.$set(this.reached, type, true) |
|||
} |
|||
|
|||
// If none already focused, focus the first rendered result |
|||
if (this.focused === null) { |
|||
this.focused = 0 |
|||
} |
|||
return REQUEST_OK |
|||
} catch (error) { |
|||
this.$delete(this.results, type) |
|||
|
|||
// If this is not a cancelled throw |
|||
if (error.response && error.response.status) { |
|||
this.logger.error(`Error searching for ${this.typesMap[type]}`, error) |
|||
showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] })) |
|||
return REQUEST_FAILED |
|||
} |
|||
return REQUEST_CANCELED |
|||
} |
|||
})).then(results => { |
|||
// Do not declare loading finished if the request have been cancelled |
|||
// This means another search was triggered and we're therefore still loading |
|||
if (results.some(result => result === REQUEST_CANCELED)) { |
|||
return |
|||
} |
|||
// We finished all searches |
|||
this.loading = {} |
|||
}) |
|||
}, |
|||
onInputDebounced: enableLiveSearch |
|||
? debounce(function(e) { |
|||
this.onInput(e) |
|||
}, 500) |
|||
: function() { |
|||
this.triggered = false |
|||
}, |
|||
|
|||
/** |
|||
* Load more results for the provided type |
|||
* |
|||
* @param {string} type type |
|||
*/ |
|||
async loadMore(type) { |
|||
// If already loading, ignore |
|||
if (this.loading[type]) { |
|||
return |
|||
} |
|||
|
|||
if (this.cursors[type]) { |
|||
// Init cancellable request |
|||
const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] }) |
|||
this.requests.push(cancel) |
|||
|
|||
// Fetch results |
|||
const { data } = await request() |
|||
|
|||
// Save cursor if any |
|||
if (data.ocs.data.cursor) { |
|||
this.$set(this.cursors, type, data.ocs.data.cursor) |
|||
} |
|||
|
|||
// Process results |
|||
if (data.ocs.data.entries.length > 0) { |
|||
this.results[type].push(...data.ocs.data.entries) |
|||
} |
|||
|
|||
// Check if we reached end of pagination |
|||
if (data.ocs.data.entries.length < this.defaultLimit) { |
|||
this.$set(this.reached, type, true) |
|||
} |
|||
} else { |
|||
// If no cursor, we might have all the results already, |
|||
// let's fake pagination and show the next xxx entries |
|||
if (this.limits[type] && this.limits[type] >= 0) { |
|||
this.limits[type] += this.defaultLimit |
|||
|
|||
// Check if we reached end of pagination |
|||
if (this.limits[type] >= this.results[type].length) { |
|||
this.$set(this.reached, type, true) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Focus result after render |
|||
if (this.focused !== null) { |
|||
this.$nextTick(() => { |
|||
this.focusIndex(this.focused) |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Return a subset of the array if the search provider |
|||
* doesn't supports pagination |
|||
* |
|||
* @param {Array} list the results |
|||
* @param {string} type the type |
|||
* @return {Array} |
|||
*/ |
|||
limitIfAny(list, type) { |
|||
if (type in this.limits) { |
|||
return list.slice(0, this.limits[type]) |
|||
} |
|||
return list |
|||
}, |
|||
|
|||
getResultsList() { |
|||
return this.$el.querySelectorAll('.unified-search__results .unified-search__result') |
|||
}, |
|||
|
|||
/** |
|||
* Focus the first result if any |
|||
* |
|||
* @param {Event} event the keydown event |
|||
*/ |
|||
focusFirst(event) { |
|||
const results = this.getResultsList() |
|||
if (results && results.length > 0) { |
|||
if (event) { |
|||
event.preventDefault() |
|||
} |
|||
this.focused = 0 |
|||
this.focusIndex(this.focused) |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Focus the next result if any |
|||
* |
|||
* @param {Event} event the keydown event |
|||
*/ |
|||
focusNext(event) { |
|||
if (this.focused === null) { |
|||
this.focusFirst(event) |
|||
return |
|||
} |
|||
|
|||
const results = this.getResultsList() |
|||
// If we're not focusing the last, focus the next one |
|||
if (results && results.length > 0 && this.focused + 1 < results.length) { |
|||
event.preventDefault() |
|||
this.focused++ |
|||
this.focusIndex(this.focused) |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Focus the previous result if any |
|||
* |
|||
* @param {Event} event the keydown event |
|||
*/ |
|||
focusPrev(event) { |
|||
if (this.focused === null) { |
|||
this.focusFirst(event) |
|||
return |
|||
} |
|||
|
|||
const results = this.getResultsList() |
|||
// If we're not focusing the first, focus the previous one |
|||
if (results && results.length > 0 && this.focused > 0) { |
|||
event.preventDefault() |
|||
this.focused-- |
|||
this.focusIndex(this.focused) |
|||
} |
|||
|
|||
}, |
|||
|
|||
/** |
|||
* Focus the specified result index if it exists |
|||
* |
|||
* @param {number} index the result index |
|||
*/ |
|||
focusIndex(index) { |
|||
const results = this.getResultsList() |
|||
if (results && results[index]) { |
|||
results[index].focus() |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Set the current focused element based on the target |
|||
* |
|||
* @param {Event} event the focus event |
|||
*/ |
|||
setFocusedIndex(event) { |
|||
const entry = event.target |
|||
const results = this.getResultsList() |
|||
const index = [...results].findIndex(search => search === entry) |
|||
if (index > -1) { |
|||
// let's not use focusIndex as the entry is already focused |
|||
this.focused = index |
|||
} |
|||
}, |
|||
|
|||
onClickFilter(filter) { |
|||
this.query = `${this.query} ${filter}` |
|||
.replace(/ {2}/g, ' ') |
|||
.trim() |
|||
this.onInput() |
|||
}, |
|||
}, |
|||
} |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
@use "sass:math"; |
|||
|
|||
$margin: 10px; |
|||
$input-height: 34px; |
|||
$input-padding: 10px; |
|||
|
|||
.unified-search { |
|||
&__input-wrapper { |
|||
position: sticky; |
|||
// above search results |
|||
z-index: 2; |
|||
top: 0; |
|||
display: inline-flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
width: 100%; |
|||
background-color: var(--color-main-background); |
|||
|
|||
label[for="unified-search__input"] { |
|||
align-self: flex-start; |
|||
font-weight: bold; |
|||
font-size: 19px; |
|||
margin-left: 13px; |
|||
} |
|||
} |
|||
|
|||
&__form-input { |
|||
margin: 0 !important; |
|||
&:focus, |
|||
&:focus-visible, |
|||
&:active { |
|||
border-color: 2px solid var(--color-main-text) !important; |
|||
box-shadow: 0 0 0 2px var(--color-main-background) !important; |
|||
} |
|||
} |
|||
|
|||
&__input-row { |
|||
display: flex; |
|||
width: 100%; |
|||
align-items: center; |
|||
} |
|||
|
|||
&__filters { |
|||
margin: $margin 0 $margin math.div($margin, 2); |
|||
padding-top: 5px; |
|||
ul { |
|||
display: inline-flex; |
|||
justify-content: space-between; |
|||
} |
|||
} |
|||
|
|||
&__form { |
|||
position: relative; |
|||
width: 100%; |
|||
margin: $margin 0; |
|||
|
|||
// Loading spinner |
|||
&::after { |
|||
right: $input-padding; |
|||
left: auto; |
|||
} |
|||
|
|||
&-input, |
|||
&-reset { |
|||
margin: math.div($input-padding, 2); |
|||
} |
|||
|
|||
&-input { |
|||
width: 100%; |
|||
height: $input-height; |
|||
padding: $input-padding; |
|||
|
|||
&, |
|||
&[placeholder], |
|||
&::placeholder { |
|||
overflow: hidden; |
|||
white-space: nowrap; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
// Hide webkit clear search |
|||
&::-webkit-search-decoration, |
|||
&::-webkit-search-cancel-button, |
|||
&::-webkit-search-results-button, |
|||
&::-webkit-search-results-decoration { |
|||
-webkit-appearance: none; |
|||
} |
|||
} |
|||
|
|||
&-reset, &-submit { |
|||
position: absolute; |
|||
top: 0; |
|||
right: 4px; |
|||
width: $input-height - $input-padding; |
|||
height: $input-height - $input-padding; |
|||
min-height: 30px; |
|||
padding: 0; |
|||
opacity: .5; |
|||
border: none; |
|||
background-color: transparent; |
|||
margin-right: 0; |
|||
|
|||
&:hover, |
|||
&:focus, |
|||
&:active { |
|||
opacity: 1; |
|||
} |
|||
} |
|||
|
|||
&-submit { |
|||
right: 28px; |
|||
} |
|||
} |
|||
|
|||
&__results { |
|||
&-header { |
|||
display: block; |
|||
margin: $margin; |
|||
margin-bottom: $margin - 4px; |
|||
margin-left: 13px; |
|||
color: var(--color-primary-element); |
|||
font-size: 19px; |
|||
font-weight: bold; |
|||
} |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 4px; |
|||
} |
|||
|
|||
.unified-search__result-more::v-deep { |
|||
color: var(--color-text-maxcontrast); |
|||
} |
|||
|
|||
.empty-content { |
|||
margin: 10vh 0; |
|||
|
|||
::v-deep .empty-content__title { |
|||
font-weight: normal; |
|||
font-size: var(--default-font-size); |
|||
text-align: center; |
|||
} |
|||
} |
|||
} |
|||
|
|||
</style> |
|||
3
dist/core-global-search.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -1,21 +0,0 @@ |
|||
/** |
|||
* @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com> |
|||
* |
|||
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.com> |
|||
* |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
* |
|||
*/ |
|||
1
dist/core-global-search.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
3
dist/core-legacy-unified-search.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,46 @@ |
|||
/** |
|||
* @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @author Christoph Wurst <christoph@winzerhof-wurst.at> |
|||
* @author Daniel Calviño Sánchez <danxuliu@gmail.com> |
|||
* @author Joas Schilling <coding@schilljs.com> |
|||
* @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
* |
|||
*/ |
|||
|
|||
/** |
|||
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
* |
|||
*/ |
|||
1
dist/core-legacy-unified-search.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
4
dist/core-unified-search.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
dist/core-unified-search.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
4
dist/files_sharing-personal-settings.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
dist/files_sharing-personal-settings.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
4
dist/settings-vue-settings-admin-security.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
dist/settings-vue-settings-admin-security.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
4
dist/sharebymail-vue-settings-admin-sharebymail.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
dist/sharebymail-vue-settings-admin-sharebymail.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
4
dist/updatenotification-updatenotification.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
dist/updatenotification-updatenotification.js.map
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue