feat(files): add search scope toggle and logic

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-06-24 15:04:34 +02:00
parent 2c65bd2f4b
commit 32dfd34099
No known key found for this signature in database
GPG key ID: 45FAE7268762B400
4 changed files with 149 additions and 56 deletions

View file

@ -0,0 +1,122 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { mdiMagnify, mdiSearchWeb } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts'
import { useNavigation } from '../composables/useNavigation.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useFilesStore } from '../store/files.ts'
import { useSearchStore } from '../store/search.ts'
import { VIEW_ID } from '../views/search.ts'
const { currentView } = useNavigation(true)
const { directory } = useRouteParameters()
const filesStore = useFilesStore()
const searchStore = useSearchStore()
/**
* When the route is changed from search view to something different
* we need to clear the search box.
*/
onBeforeNavigation((to, from, next) => {
if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) {
// we are leaving the search view so unset the query
searchStore.query = ''
searchStore.scope = 'filter'
} else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) {
// fix the query if the user refreshed the view
if (searchStore.query && !to.query.query) {
// @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3)
return next({
...to,
query: {
...to.query,
query: searchStore.query,
},
})
}
}
next()
})
/**
* Are we currently on the search view.
* Needed to disable the action menu (we cannot change the search mode there)
*/
const isSearchView = computed(() => currentView.value.id === VIEW_ID)
/**
* Local search is only possible on real DAV resources within the files root
*/
const canSearchLocally = computed(() => {
if (searchStore.base) {
return true
}
const folder = filesStore.getDirectoryByPath(currentView.value.id, directory.value)
return folder?.isDavResource && folder?.root?.startsWith('/files/')
})
/**
* Different searchbox label depending if filtering or searching
*/
const searchLabel = computed(() => {
if (searchStore.scope === 'globally') {
return t('files', 'Search globally by filename …')
} else if (searchStore.scope === 'locally') {
return t('files', 'Search here by filename …')
}
return t('files', 'Filter file names …')
})
/**
* Update the search value and set the base if needed
* @param value - The new value
*/
function onUpdateSearch(value: string) {
if (searchStore.scope === 'locally' && currentView.value.id !== VIEW_ID) {
searchStore.base = filesStore.getDirectoryByPath(currentView.value.id, directory.value)
}
searchStore.query = value
}
</script>
<template>
<NcAppNavigationSearch :label="searchLabel" :model-value="searchStore.query" @update:modelValue="onUpdateSearch">
<template #actions>
<NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView">
<template #icon>
<NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" />
</template>
<NcActionButton close-after-click @click="searchStore.scope = 'filter'">
<template #icon>
<NcIconSvgWrapper :path="mdiMagnify" />
</template>
{{ t('files', 'Filter in current view') }}
</NcActionButton>
<NcActionButton v-if="canSearchLocally" close-after-click @click="searchStore.scope = 'locally'">
<template #icon>
<NcIconSvgWrapper :path="mdiMagnify" />
</template>
{{ t('files', 'Search from this location') }}
</NcActionButton>
<NcActionButton close-after-click @click="searchStore.scope = 'globally'">
<template #icon>
<NcIconSvgWrapper :path="mdiSearchWeb" />
</template>
{{ t('files', 'Search globally') }}
</NcActionButton>
</NcActions>
</template>
</NcAppNavigationSearch>
</template>

View file

@ -0,0 +1,20 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { NavigationGuard } from 'vue-router'
import { onUnmounted } from 'vue'
import { useRouter } from 'vue-router/composables'
/**
* Helper until we use Vue-Router v4 (Vue3).
*
* @param fn - The navigation guard
*/
export function onBeforeNavigation(fn: NavigationGuard) {
const router = useRouter()
const remove = router.beforeResolve(fn)
onUnmounted(remove)
}

View file

@ -1,47 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerFileListFilter, unregisterFileListFilter } from '@nextcloud/files'
import { watchThrottled } from '@vueuse/core'
import { onMounted, onUnmounted, ref } from 'vue'
import { FilenameFilter } from '../filters/FilenameFilter'
/**
* This is for the `Navigation` component to provide a filename filter
*/
export function useFilenameFilter() {
const searchQuery = ref('')
const filenameFilter = new FilenameFilter()
/**
* Updating the search query ref from the filter
* @param event The update:query event
*/
function updateQuery(event: CustomEvent) {
if (event.type === 'update:query') {
searchQuery.value = event.detail
event.stopPropagation()
}
}
onMounted(() => {
filenameFilter.addEventListener('update:query', updateQuery)
registerFileListFilter(filenameFilter)
})
onUnmounted(() => {
filenameFilter.removeEventListener('update:query', updateQuery)
unregisterFileListFilter(filenameFilter.id)
})
// Update the query on the filter, but throttle to max. every 800ms
// This will debounce the filter refresh
watchThrottled(searchQuery, () => {
filenameFilter.updateQuery(searchQuery.value)
}, { throttle: 800 })
return {
searchQuery,
}
}

View file

@ -7,7 +7,7 @@
class="files-navigation"
:aria-label="t('files', 'Files')">
<template #search>
<NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter file names …')" />
<FilesNavigationSearch />
</template>
<template #default>
<NcAppNavigationList class="files-navigation__list"
@ -39,24 +39,24 @@
</template>
<script lang="ts">
import { getNavigation, type View } from '@nextcloud/files'
import type { View } from '@nextcloud/files'
import type { ViewConfig } from '../types.ts'
import { defineComponent } from 'vue'
import { emit, subscribe } from '@nextcloud/event-bus'
import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
import { getNavigation } from '@nextcloud/files'
import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import IconCog from 'vue-material-design-icons/Cog.vue'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
import FilesNavigationItem from '../components/FilesNavigationItem.vue'
import FilesNavigationSearch from '../components/FilesNavigationSearch.vue'
import { useNavigation } from '../composables/useNavigation'
import { useFilenameFilter } from '../composables/useFilenameFilter'
import { useFiltersStore } from '../store/filters.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import logger from '../logger.ts'
@ -75,12 +75,12 @@ export default defineComponent({
components: {
IconCog,
FilesNavigationItem,
FilesNavigationSearch,
NavigationQuota,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationList,
NcAppNavigationSearch,
SettingsModal,
},
@ -88,11 +88,9 @@ export default defineComponent({
const filtersStore = useFiltersStore()
const viewConfigStore = useViewConfigStore()
const { currentView, views } = useNavigation()
const { searchQuery } = useFilenameFilter()
return {
currentView,
searchQuery,
t,
views,