aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/store
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/store')
-rw-r--r--apps/files/src/store/active.ts86
-rw-r--r--apps/files/src/store/dragging.ts6
-rw-r--r--apps/files/src/store/files.ts94
-rw-r--r--apps/files/src/store/filters.ts133
-rw-r--r--apps/files/src/store/index.ts9
-rw-r--r--apps/files/src/store/keyboard.ts1
-rw-r--r--apps/files/src/store/paths.spec.ts166
-rw-r--r--apps/files/src/store/paths.ts106
-rw-r--r--apps/files/src/store/renaming.ts183
-rw-r--r--apps/files/src/store/search.ts153
-rw-r--r--apps/files/src/store/selection.ts2
-rw-r--r--apps/files/src/store/userconfig.ts85
-rw-r--r--apps/files/src/store/viewConfig.ts139
13 files changed, 998 insertions, 165 deletions
diff --git a/apps/files/src/store/active.ts b/apps/files/src/store/active.ts
new file mode 100644
index 00000000000..1303a157b08
--- /dev/null
+++ b/apps/files/src/store/active.ts
@@ -0,0 +1,86 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { FileAction, View, Node, Folder } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { getNavigation } from '@nextcloud/files'
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+import logger from '../logger.ts'
+
+export const useActiveStore = defineStore('active', () => {
+ /**
+ * The currently active action
+ */
+ const activeAction = ref<FileAction>()
+
+ /**
+ * The currently active folder
+ */
+ const activeFolder = ref<Folder>()
+
+ /**
+ * The current active node within the folder
+ */
+ const activeNode = ref<Node>()
+
+ /**
+ * The current active view
+ */
+ const activeView = ref<View>()
+
+ initialize()
+
+ /**
+ * Unset the active node if deleted
+ *
+ * @param node - The node thats deleted
+ * @private
+ */
+ function onDeletedNode(node: Node) {
+ if (activeNode.value && activeNode.value.source === node.source) {
+ activeNode.value = undefined
+ }
+ }
+
+ /**
+ * Callback to update the current active view
+ *
+ * @param view - The new active view
+ * @private
+ */
+ function onChangedView(view: View|null = null) {
+ logger.debug('Setting active view', { view })
+ activeView.value = view ?? undefined
+ activeNode.value = undefined
+ }
+
+ /**
+ * Initalize the store - connect all event listeners.
+ * @private
+ */
+ function initialize() {
+ const navigation = getNavigation()
+
+ // Make sure we only register the listeners once
+ subscribe('files:node:deleted', onDeletedNode)
+
+ onChangedView(navigation.active)
+
+ // Or you can react to changes of the current active view
+ navigation.addEventListener('updateActive', (event) => {
+ onChangedView(event.detail)
+ })
+ }
+
+ return {
+ activeAction,
+ activeFolder,
+ activeNode,
+ activeView,
+ }
+})
diff --git a/apps/files/src/store/dragging.ts b/apps/files/src/store/dragging.ts
index 74de1c4af16..810f662149c 100644
--- a/apps/files/src/store/dragging.ts
+++ b/apps/files/src/store/dragging.ts
@@ -2,9 +2,10 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import type { DragAndDropStore, FileSource } from '../types'
+
import { defineStore } from 'pinia'
import Vue from 'vue'
-import type { DragAndDropStore, FileSource } from '../types'
export const useDragAndDropStore = defineStore('dragging', {
state: () => ({
@@ -13,7 +14,8 @@ export const useDragAndDropStore = defineStore('dragging', {
actions: {
/**
- * Set the selection of fileIds
+ * Set the selection of files being dragged currently
+ * @param selection array of node sources
*/
set(selection = [] as FileSource[]) {
Vue.set(this, 'dragging', selection)
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
index 0b541024018..0bcf4ce9350 100644
--- a/apps/files/src/store/files.ts
+++ b/apps/files/src/store/files.ts
@@ -4,25 +4,15 @@
*/
import type { FilesStore, RootsStore, RootOptions, Service, FilesState, FileSource } from '../types'
-import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { Folder, Node } from '@nextcloud/files'
-import { davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
import { defineStore } from 'pinia'
import { subscribe } from '@nextcloud/event-bus'
import logger from '../logger'
import Vue from 'vue'
-import { client } from '../services/WebdavClient.ts'
-
-const fetchNode = async (node: Node): Promise<Node> => {
- const propfindPayload = davGetDefaultPropfind()
- const result = await client.stat(`${davRootPath}${node.path}`, {
- details: true,
- data: propfindPayload,
- }) as ResponseDataDetailed<FileStat>
- return davResultToNode(result.data)
-}
+import { fetchNode } from '../services/WebdavClient.ts'
+import { usePathsStore } from './paths.ts'
export const useFilesStore = function(...args) {
const store = defineStore('files', {
@@ -34,12 +24,14 @@ export const useFilesStore = function(...args) {
getters: {
/**
* Get a file or folder by its source
+ * @param state
*/
getNode: (state) => (source: FileSource): Node|undefined => state.files[source],
/**
* Get a list of files or folders by their IDs
* Note: does not return undefined values
+ * @param state
*/
getNodes: (state) => (sources: FileSource[]): Node[] => sources
.map(source => state.files[source])
@@ -49,16 +41,58 @@ export const useFilesStore = function(...args) {
* Get files or folders by their file ID
* Multiple nodes can have the same file ID but different sources
* (e.g. in a shared context)
+ * @param state
*/
getNodesById: (state) => (fileId: number): Node[] => Object.values(state.files).filter(node => node.fileid === fileId),
/**
* Get the root folder of a service
+ * @param state
*/
getRoot: (state) => (service: Service): Folder|undefined => state.roots[service],
},
actions: {
+ /**
+ * Get cached directory matching a given path
+ *
+ * @param service - The service (files view)
+ * @param path - The path relative within the service
+ * @return The folder if found
+ */
+ getDirectoryByPath(service: string, path?: string): Folder | undefined {
+ const pathsStore = usePathsStore()
+ let folder: Folder | undefined
+
+ // Get the containing folder from path store
+ if (!path || path === '/') {
+ folder = this.getRoot(service)
+ } else {
+ const source = pathsStore.getPath(service, path)
+ if (source) {
+ folder = this.getNode(source) as Folder | undefined
+ }
+ }
+
+ return folder
+ },
+
+ /**
+ * Get cached child nodes within a given path
+ *
+ * @param service - The service (files view)
+ * @param path - The path relative within the service
+ * @return Array of cached nodes within the path
+ */
+ getNodesByPath(service: string, path?: string): Node[] {
+ const folder = this.getDirectoryByPath(service, path)
+
+ // If we found a cache entry and the cache entry was already loaded (has children) then use it
+ return (folder?._children ?? [])
+ .map((source: string) => this.getNode(source))
+ .filter(Boolean)
+ },
+
updateNodes(nodes: Node[]) {
// Update the store all at once
const files = nodes.reduce((acc, node) => {
@@ -94,6 +128,17 @@ export const useFilesStore = function(...args) {
this.updateNodes([node])
},
+ onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) {
+ if (!node.fileid) {
+ logger.error('Trying to update/set a node without fileid', { node })
+ return
+ }
+
+ // Update the path of the node
+ Vue.delete(this.files, oldSource)
+ this.updateNodes([node])
+ },
+
async onUpdatedNode(node: Node) {
if (!node.fileid) {
logger.error('Trying to update/set a node without fileid', { node })
@@ -103,19 +148,34 @@ export const useFilesStore = function(...args) {
// If we have multiple nodes with the same file ID, we need to update all of them
const nodes = this.getNodesById(node.fileid)
if (nodes.length > 1) {
- await Promise.all(nodes.map(fetchNode)).then(this.updateNodes)
+ await Promise.all(nodes.map(node => fetchNode(node.path))).then(this.updateNodes)
logger.debug(nodes.length + ' nodes updated in store', { fileid: node.fileid })
return
}
// If we have only one node with the file ID, we can update it directly
- if (node.source === nodes[0].source) {
+ if (nodes.length === 1 && node.source === nodes[0].source) {
this.updateNodes([node])
return
}
// Otherwise, it means we receive an event for a node that is not in the store
- fetchNode(node).then(n => this.updateNodes([n]))
+ fetchNode(node.path).then(n => this.updateNodes([n]))
+ },
+
+ // Handlers for legacy sidebar (no real nodes support)
+ onAddFavorite(node: Node) {
+ const ourNode = this.getNode(node.source)
+ if (ourNode) {
+ Vue.set(ourNode.attributes, 'favorite', 1)
+ }
+ },
+
+ onRemoveFavorite(node: Node) {
+ const ourNode = this.getNode(node.source)
+ if (ourNode) {
+ Vue.set(ourNode.attributes, 'favorite', 0)
+ }
},
},
})
@@ -126,6 +186,10 @@ export const useFilesStore = function(...args) {
subscribe('files:node:created', fileStore.onCreatedNode)
subscribe('files:node:deleted', fileStore.onDeletedNode)
subscribe('files:node:updated', fileStore.onUpdatedNode)
+ subscribe('files:node:moved', fileStore.onMovedNode)
+ // legacy sidebar
+ subscribe('files:favorites:added', fileStore.onAddFavorite)
+ subscribe('files:favorites:removed', fileStore.onRemoveFavorite)
fileStore._initialized = true
}
diff --git a/apps/files/src/store/filters.ts b/apps/files/src/store/filters.ts
new file mode 100644
index 00000000000..fd16ec5dc84
--- /dev/null
+++ b/apps/files/src/store/filters.ts
@@ -0,0 +1,133 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip } from '@nextcloud/files'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { getFileListFilters } from '@nextcloud/files'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import logger from '../logger'
+
+/**
+ * Check if the given value is an instance file list filter with mount function
+ * @param value The filter to check
+ */
+function isFileListFilterWithUi(value: IFileListFilter): value is Required<IFileListFilter> {
+ return 'mount' in value
+}
+
+export const useFiltersStore = defineStore('filters', () => {
+ const chips = ref<Record<string, IFileListFilterChip[]>>({})
+ const filters = ref<IFileListFilter[]>([])
+
+ /**
+ * Currently active filter chips
+ */
+ const activeChips = computed<IFileListFilterChip[]>(
+ () => Object.values(chips.value).flat(),
+ )
+
+ /**
+ * Filters sorted by order
+ */
+ const sortedFilters = computed<IFileListFilter[]>(
+ () => filters.value.sort((a, b) => a.order - b.order),
+ )
+
+ /**
+ * All filters that provide a UI for visual controlling the filter state
+ */
+ const filtersWithUI = computed<Required<IFileListFilter>[]>(
+ () => sortedFilters.value.filter(isFileListFilterWithUi),
+ )
+
+ /**
+ * Register a new filter on the store.
+ * This will subscribe the store to the filters events.
+ *
+ * @param filter The filter to add
+ */
+ function addFilter(filter: IFileListFilter) {
+ filter.addEventListener('update:chips', onFilterUpdateChips)
+ filter.addEventListener('update:filter', onFilterUpdate)
+
+ filters.value.push(filter)
+ logger.debug('New file list filter registered', { id: filter.id })
+ }
+
+ /**
+ * Unregister a filter from the store.
+ * This will remove the filter from the store and unsubscribe the store from the filer events.
+ * @param filterId Id of the filter to remove
+ */
+ function removeFilter(filterId: string) {
+ const index = filters.value.findIndex(({ id }) => id === filterId)
+ if (index > -1) {
+ const [filter] = filters.value.splice(index, 1)
+ filter.removeEventListener('update:chips', onFilterUpdateChips)
+ filter.removeEventListener('update:filter', onFilterUpdate)
+ logger.debug('Files list filter unregistered', { id: filterId })
+ }
+ }
+
+ /**
+ * Event handler for filter update events
+ * @private
+ */
+ function onFilterUpdate() {
+ emit('files:filters:changed')
+ }
+
+ /**
+ * Event handler for filter chips updates
+ * @param event The update event
+ * @private
+ */
+ function onFilterUpdateChips(event: FilterUpdateChipsEvent) {
+ const id = (event.target as IFileListFilter).id
+ chips.value = {
+ ...chips.value,
+ [id]: [...event.detail],
+ }
+
+ logger.debug('File list filter chips updated', { filter: id, chips: event.detail })
+ }
+
+ /**
+ * Event handler that resets all filters if the file list view was changed.
+ * @private
+ */
+ function onViewChanged() {
+ logger.debug('Reset all file list filters - view changed')
+
+ for (const filter of filters.value) {
+ if (filter.reset !== undefined) {
+ filter.reset()
+ }
+ }
+ }
+
+ // Initialize the store
+ subscribe('files:navigation:changed', onViewChanged)
+ subscribe('files:filter:added', addFilter)
+ subscribe('files:filter:removed', removeFilter)
+ for (const filter of getFileListFilters()) {
+ addFilter(filter)
+ }
+
+ return {
+ // state
+ chips,
+ filters,
+ filtersWithUI,
+
+ // getters / computed
+ activeChips,
+ sortedFilters,
+
+ // actions / methods
+ addFilter,
+ removeFilter,
+ }
+})
diff --git a/apps/files/src/store/index.ts b/apps/files/src/store/index.ts
index 00676b3bc8e..3ba667ffd2f 100644
--- a/apps/files/src/store/index.ts
+++ b/apps/files/src/store/index.ts
@@ -5,4 +5,11 @@
import { createPinia } from 'pinia'
-export const pinia = createPinia()
+export const getPinia = () => {
+ if (window._nc_files_pinia) {
+ return window._nc_files_pinia
+ }
+
+ window._nc_files_pinia = createPinia()
+ return window._nc_files_pinia
+}
diff --git a/apps/files/src/store/keyboard.ts b/apps/files/src/store/keyboard.ts
index 2b092c89ff8..f2654933895 100644
--- a/apps/files/src/store/keyboard.ts
+++ b/apps/files/src/store/keyboard.ts
@@ -9,6 +9,7 @@ import Vue from 'vue'
* Observe various events and save the current
* special keys states. Useful for checking the
* current status of a key when executing a method.
+ * @param {...any} args
*/
export const useKeyboardStore = function(...args) {
const store = defineStore('keyboard', {
diff --git a/apps/files/src/store/paths.spec.ts b/apps/files/src/store/paths.spec.ts
new file mode 100644
index 00000000000..932e8b1a6a1
--- /dev/null
+++ b/apps/files/src/store/paths.spec.ts
@@ -0,0 +1,166 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, beforeEach, test, expect } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { usePathsStore } from './paths.ts'
+import { emit } from '@nextcloud/event-bus'
+import { File, Folder } from '@nextcloud/files'
+import { useFilesStore } from './files.ts'
+
+describe('Path store', () => {
+
+ let store: ReturnType<typeof usePathsStore>
+ let files: ReturnType<typeof useFilesStore>
+ let root: Folder & { _children?: string[] }
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+
+ root = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/', id: 1 })
+ files = useFilesStore()
+ files.setRoot({ service: 'files', root })
+
+ store = usePathsStore()
+ })
+
+ test('Folder is created', () => {
+ // no defined paths
+ expect(store.paths).toEqual({})
+
+ // create the folder
+ const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node)
+
+ // see that the path is added
+ expect(store.paths).toEqual({ files: { [node.path]: node.source } })
+
+ // see that the node is added
+ expect(root._children).toEqual([node.source])
+ })
+
+ test('File is created', () => {
+ // no defined paths
+ expect(store.paths).toEqual({})
+
+ // create the file
+ const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node)
+
+ // see that there are still no paths
+ expect(store.paths).toEqual({})
+
+ // see that the node is added
+ expect(root._children).toEqual([node.source])
+ })
+
+ test('Existing file is created', () => {
+ // no defined paths
+ expect(store.paths).toEqual({})
+
+ // create the file
+ const node1 = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node1)
+
+ // see that there are still no paths
+ expect(store.paths).toEqual({})
+
+ // see that the node is added
+ expect(root._children).toEqual([node1.source])
+
+ // create the same named file again
+ const node2 = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node2)
+
+ // see that there are still no paths and the children are not duplicated
+ expect(store.paths).toEqual({})
+ expect(root._children).toEqual([node1.source])
+
+ })
+
+ test('Existing folder is created', () => {
+ // no defined paths
+ expect(store.paths).toEqual({})
+
+ // create the file
+ const node1 = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node1)
+
+ // see the path is added
+ expect(store.paths).toEqual({ files: { [node1.path]: node1.source } })
+
+ // see that the node is added
+ expect(root._children).toEqual([node1.source])
+
+ // create the same named file again
+ const node2 = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node2)
+
+ // see that there is still only one paths and the children are not duplicated
+ expect(store.paths).toEqual({ files: { [node1.path]: node1.source } })
+ expect(root._children).toEqual([node1.source])
+ })
+
+ test('Folder is deleted', () => {
+ const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node)
+ // see that the path is added and the children are set-up
+ expect(store.paths).toEqual({ files: { [node.path]: node.source } })
+ expect(root._children).toEqual([node.source])
+
+ emit('files:node:deleted', node)
+ // See the path is removed
+ expect(store.paths).toEqual({ files: {} })
+ // See the child is removed
+ expect(root._children).toEqual([])
+ })
+
+ test('File is deleted', () => {
+ const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node)
+ // see that the children are set-up
+ expect(root._children).toEqual([node.source])
+
+ emit('files:node:deleted', node)
+ // See the child is removed
+ expect(root._children).toEqual([])
+ })
+
+ test('Folder is moved', () => {
+ const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node)
+ // see that the path is added and the children are set-up
+ expect(store.paths).toEqual({ files: { [node.path]: node.source } })
+ expect(root._children).toEqual([node.source])
+
+ const renamedNode = node.clone()
+ renamedNode.rename('new-folder')
+
+ expect(renamedNode.path).toBe('/new-folder')
+ expect(renamedNode.source).toBe('http://example.com/remote.php/dav/files/test/new-folder')
+
+ emit('files:node:moved', { node: renamedNode, oldSource: node.source })
+ // See the path is updated
+ expect(store.paths).toEqual({ files: { [renamedNode.path]: renamedNode.source } })
+ // See the child is updated
+ expect(root._children).toEqual([renamedNode.source])
+ })
+
+ test('File is moved', () => {
+ const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node)
+ // see that the children are set-up
+ expect(root._children).toEqual([node.source])
+ expect(store.paths).toEqual({})
+
+ const renamedNode = node.clone()
+ renamedNode.rename('new-file.txt')
+
+ emit('files:node:moved', { node: renamedNode, oldSource: node.source })
+ // See the child is updated
+ expect(root._children).toEqual([renamedNode.source])
+ expect(store.paths).toEqual({})
+ })
+})
diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts
index 2993cc9d704..4a83cb51c83 100644
--- a/apps/files/src/store/paths.ts
+++ b/apps/files/src/store/paths.ts
@@ -2,9 +2,10 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { FileSource, PathsStore, PathOptions, ServicesState } from '../types'
+import type { FileSource, PathsStore, PathOptions, ServicesState, Service } from '../types'
import { defineStore } from 'pinia'
-import { FileType, Folder, Node, getNavigation } from '@nextcloud/files'
+import { dirname } from '@nextcloud/paths'
+import { File, FileType, Folder, Node, getNavigation } from '@nextcloud/files'
import { subscribe } from '@nextcloud/event-bus'
import Vue from 'vue'
import logger from '../logger'
@@ -41,6 +42,15 @@ export const usePathsStore = function(...args) {
Vue.set(this.paths[payload.service], payload.path, payload.source)
},
+ deletePath(service: Service, path: string) {
+ // skip if service does not exist
+ if (!this.paths[service]) {
+ return
+ }
+
+ Vue.delete(this.paths[service], path)
+ },
+
onCreatedNode(node: Node) {
const service = getNavigation()?.active?.id || 'files'
if (!node.fileid) {
@@ -59,46 +69,94 @@ export const usePathsStore = function(...args) {
// Update parent folder children if exists
// If the folder is the root, get it and update it
- if (node.dirname === '/') {
- const root = files.getRoot(service)
- if (!root._children) {
- Vue.set(root, '_children', [])
+ this.addNodeToParentChildren(node)
+ },
+
+ onDeletedNode(node: Node) {
+ const service = getNavigation()?.active?.id || 'files'
+
+ if (node.type === FileType.Folder) {
+ // Delete the path
+ this.deletePath(
+ service,
+ node.path,
+ )
+ }
+
+ this.deleteNodeFromParentChildren(node)
+ },
+
+ onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) {
+ const service = getNavigation()?.active?.id || 'files'
+
+ // Update the path of the node
+ if (node.type === FileType.Folder) {
+ // Delete the old path if it exists
+ const oldPath = Object.entries(this.paths[service]).find(([, source]) => source === oldSource)
+ if (oldPath?.[0]) {
+ this.deletePath(service, oldPath[0])
}
- root._children.push(node.source)
+
+ // Add the new path
+ this.addPath({
+ service,
+ path: node.path,
+ source: node.source,
+ })
+ }
+
+ // Dummy simple clone of the renamed node from a previous state
+ const oldNode = new File({ source: oldSource, owner: node.owner, mime: node.mime })
+
+ this.deleteNodeFromParentChildren(oldNode)
+ this.addNodeToParentChildren(node)
+ },
+
+ deleteNodeFromParentChildren(node: Node) {
+ const service = getNavigation()?.active?.id || 'files'
+
+ // Update children of a root folder
+ const parentSource = dirname(node.source)
+ const folder = (node.dirname === '/' ? files.getRoot(service) : files.getNode(parentSource)) as Folder & { _children?: string[] }
+ if (folder) {
+ // ensure sources are unique
+ const children = new Set(folder._children ?? [])
+ children.delete(node.source)
+ Vue.set(folder, '_children', [...children.values()])
+ logger.debug('Children updated', { parent: folder, node, children: folder._children })
return
}
- // If the folder doesn't exists yet, it will be
- // fetched later and its children updated anyway.
- if (this.paths[service][node.dirname]) {
- const parentSource = this.paths[service][node.dirname]
- const parentFolder = files.getNode(parentSource) as Folder
- logger.debug('Path already exists, updating children', { parentFolder, node })
+ logger.debug('Parent path does not exists, skipping children update', { node })
+ },
- if (!parentFolder) {
- logger.error('Parent folder not found', { parentSource })
- return
- }
+ addNodeToParentChildren(node: Node) {
+ const service = getNavigation()?.active?.id || 'files'
- if (!parentFolder._children) {
- Vue.set(parentFolder, '_children', [])
- }
- parentFolder._children.push(node.source)
+ // Update children of a root folder
+ const parentSource = dirname(node.source)
+ const folder = (node.dirname === '/' ? files.getRoot(service) : files.getNode(parentSource)) as Folder & { _children?: string[] }
+ if (folder) {
+ // ensure sources are unique
+ const children = new Set(folder._children ?? [])
+ children.add(node.source)
+ Vue.set(folder, '_children', [...children.values()])
+ logger.debug('Children updated', { parent: folder, node, children: folder._children })
return
}
logger.debug('Parent path does not exists, skipping children update', { node })
},
+
},
})
const pathsStore = store(...args)
// Make sure we only register the listeners once
if (!pathsStore._initialized) {
- // TODO: watch folders to update paths?
subscribe('files:node:created', pathsStore.onCreatedNode)
- // subscribe('files:node:deleted', pathsStore.onDeletedNode)
- // subscribe('files:node:moved', pathsStore.onMovedNode)
+ subscribe('files:node:deleted', pathsStore.onDeletedNode)
+ subscribe('files:node:moved', pathsStore.onMovedNode)
pathsStore._initialized = true
}
diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts
index 3782b75e3a4..fc61be3bd3b 100644
--- a/apps/files/src/store/renaming.ts
+++ b/apps/files/src/store/renaming.ts
@@ -2,29 +2,174 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { defineStore } from 'pinia'
-import { subscribe } from '@nextcloud/event-bus'
import type { Node } from '@nextcloud/files'
-import type { RenamingStore } from '../types'
-
-export const useRenamingStore = function(...args) {
- const store = defineStore('renaming', {
- state: () => ({
- renamingNode: undefined,
- newName: '',
- } as RenamingStore),
- })
- const renamingStore = store(...args)
+import axios, { isAxiosError } from '@nextcloud/axios'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { FileType, NodeStatus } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { spawnDialog } from '@nextcloud/vue/functions/dialog'
+import { basename, dirname, extname } from 'path'
+import { defineStore } from 'pinia'
+import logger from '../logger'
+import Vue, { defineAsyncComponent, ref } from 'vue'
+import { useUserConfigStore } from './userconfig'
+import { fetchNode } from '../services/WebdavClient'
+
+export const useRenamingStore = defineStore('renaming', () => {
+ /**
+ * The currently renamed node
+ */
+ const renamingNode = ref<Node>()
+ /**
+ * The new name of the currently renamed node
+ */
+ const newNodeName = ref('')
+
+ /**
+ * Internal flag to only allow calling `rename` once.
+ */
+ const isRenaming = ref(false)
+
+ /**
+ * Execute the renaming.
+ * This will rename the node set as `renamingNode` to the configured new name `newName`.
+ *
+ * @return true if success, false if skipped (e.g. new and old name are the same)
+ * @throws Error if renaming fails, details are set in the error message
+ */
+ async function rename(): Promise<boolean> {
+ if (renamingNode.value === undefined) {
+ throw new Error('No node is currently being renamed')
+ }
+
+ // Only rename once so we use this as some kind of mutex
+ if (isRenaming.value) {
+ return false
+ }
+ isRenaming.value = true
+
+ let node = renamingNode.value
+ Vue.set(node, 'status', NodeStatus.LOADING)
+
+ const userConfig = useUserConfigStore()
+
+ let newName = newNodeName.value.trim()
+ const oldName = node.basename
+ const oldExtension = extname(oldName)
+ const newExtension = extname(newName)
+ // Check for extension change for files
+ if (node.type === FileType.File
+ && oldExtension !== newExtension
+ && userConfig.userConfig.show_dialog_file_extension
+ && !(await showFileExtensionDialog(oldExtension, newExtension))
+ ) {
+ // user selected to use the old extension
+ newName = basename(newName, newExtension) + oldExtension
+ }
+
+ const oldEncodedSource = node.encodedSource
+ try {
+ if (oldName === newName) {
+ return false
+ }
+
+ // rename the node
+ node.rename(newName)
+ logger.debug('Moving file to', { destination: node.encodedSource, oldEncodedSource })
+ // create MOVE request
+ await axios({
+ method: 'MOVE',
+ url: oldEncodedSource,
+ headers: {
+ Destination: node.encodedSource,
+ Overwrite: 'F',
+ },
+ })
+
+ // Update mime type if extension changed
+ // as other related informations might have changed
+ // on the backend but it is really hard to know on the front
+ if (oldExtension !== newExtension) {
+ node = await fetchNode(node.path)
+ }
+
+ // Success 🎉
+ emit('files:node:updated', node)
+ emit('files:node:renamed', node)
+ emit('files:node:moved', {
+ node,
+ oldSource: `${dirname(node.source)}/${oldName}`,
+ })
+
+ // Reset the state not changed
+ if (renamingNode.value === node) {
+ $reset()
+ }
+
+ return true
+ } catch (error) {
+ logger.error('Error while renaming file', { error })
+ // Rename back as it failed
+ node.rename(oldName)
+ if (isAxiosError(error)) {
+ // TODO: 409 means current folder does not exist, redirect ?
+ if (error?.response?.status === 404) {
+ throw new Error(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
+ } else if (error?.response?.status === 412) {
+ throw new Error(t(
+ 'files',
+ 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.',
+ {
+ newName,
+ dir: basename(renamingNode.value!.dirname),
+ },
+ ))
+ }
+ }
+ // Unknown error
+ throw new Error(t('files', 'Could not rename "{oldName}"', { oldName }))
+ } finally {
+ Vue.set(node, 'status', undefined)
+ isRenaming.value = false
+ }
+ }
+
+ /**
+ * Reset the store state
+ */
+ function $reset(): void {
+ newNodeName.value = ''
+ renamingNode.value = undefined
+ }
// Make sure we only register the listeners once
- if (!renamingStore._initialized) {
- subscribe('files:node:rename', function(node: Node) {
- renamingStore.renamingNode = node
- renamingStore.newName = node.basename
- })
- renamingStore._initialized = true
+ subscribe('files:node:rename', (node: Node) => {
+ renamingNode.value = node
+ newNodeName.value = node.basename
+ })
+
+ return {
+ $reset,
+
+ newNodeName,
+ rename,
+ renamingNode,
}
+})
- return renamingStore
+/**
+ * Show a dialog asking user for confirmation about changing the file extension.
+ *
+ * @param oldExtension the old file name extension
+ * @param newExtension the new file name extension
+ */
+async function showFileExtensionDialog(oldExtension: string, newExtension: string): Promise<boolean> {
+ const { promise, resolve } = Promise.withResolvers<boolean>()
+ spawnDialog(
+ defineAsyncComponent(() => import('../views/DialogConfirmFileExtension.vue')),
+ { oldExtension, newExtension },
+ (useNewExtension: unknown) => resolve(Boolean(useNewExtension)),
+ )
+ return await promise
}
diff --git a/apps/files/src/store/search.ts b/apps/files/src/store/search.ts
new file mode 100644
index 00000000000..43e01f35b92
--- /dev/null
+++ b/apps/files/src/store/search.ts
@@ -0,0 +1,153 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { View } from '@nextcloud/files'
+import type RouterService from '../services/RouterService.ts'
+import type { SearchScope } from '../types.ts'
+
+import { emit, subscribe } from '@nextcloud/event-bus'
+import debounce from 'debounce'
+import { defineStore } from 'pinia'
+import { ref, watch } from 'vue'
+import { VIEW_ID } from '../views/search.ts'
+import logger from '../logger.ts'
+
+export const useSearchStore = defineStore('search', () => {
+ /**
+ * The current search query
+ */
+ const query = ref('')
+
+ /**
+ * Scope of the search.
+ * Scopes:
+ * - filter: only filter current file list
+ * - globally: search everywhere
+ */
+ const scope = ref<SearchScope>('filter')
+
+ // reset the base if query is cleared
+ watch(scope, updateSearch)
+
+ watch(query, (old, current) => {
+ // skip if only whitespaces changed
+ if (old.trim() === current.trim()) {
+ return
+ }
+
+ updateSearch()
+ })
+
+ // initialize the search store
+ initialize()
+
+ /**
+ * Debounced update of the current route
+ * @private
+ */
+ const updateRouter = debounce((isSearch: boolean) => {
+ const router = window.OCP.Files.Router as RouterService
+ router.goToRoute(
+ undefined,
+ {
+ view: VIEW_ID,
+ },
+ {
+ query: query.value,
+ },
+ isSearch,
+ )
+ })
+
+ /**
+ * Handle updating the filter if needed.
+ * Also update the search view by updating the current route if needed.
+ *
+ * @private
+ */
+ function updateSearch() {
+ // emit the search event to update the filter
+ emit('files:search:updated', { query: query.value, scope: scope.value })
+ const router = window.OCP.Files.Router as RouterService
+
+ // if we are on the search view and the query was unset or scope was set to 'filter' we need to move back to the files view
+ if (router.params.view === VIEW_ID && (query.value === '' || scope.value === 'filter')) {
+ scope.value = 'filter'
+ return router.goToRoute(
+ undefined,
+ {
+ view: 'files',
+ },
+ {
+ ...router.query,
+ query: undefined,
+ },
+ )
+ }
+
+ // for the filter scope we do not need to adjust the current route anymore
+ // also if the query is empty we do not need to do anything
+ if (scope.value === 'filter' || !query.value) {
+ return
+ }
+
+ const isSearch = router.params.view === VIEW_ID
+
+ logger.debug('Update route for updated search query', { query: query.value, isSearch })
+ updateRouter(isSearch)
+ }
+
+ /**
+ * Event handler that resets the store if the file list view was changed.
+ *
+ * @param view - The new view that is active
+ * @private
+ */
+ function onViewChanged(view: View) {
+ if (view.id !== VIEW_ID) {
+ query.value = ''
+ scope.value = 'filter'
+ }
+ }
+
+ /**
+ * Initialize the store from the router if needed
+ */
+ function initialize() {
+ subscribe('files:navigation:changed', onViewChanged)
+
+ const router = window.OCP.Files.Router as RouterService
+ // if we initially load the search view (e.g. hard page refresh)
+ // then we need to initialize the store from the router
+ if (router.params.view === VIEW_ID) {
+ query.value = [router.query.query].flat()[0] ?? ''
+
+ if (query.value) {
+ scope.value = 'globally'
+ logger.debug('Directly navigated to search view', { query: query.value })
+ } else {
+ // we do not have any query so we need to move to the files list
+ logger.info('Directly navigated to search view without any query, redirect to files view.')
+ router.goToRoute(
+ undefined,
+ {
+ ...router.params,
+ view: 'files',
+ },
+ {
+ ...router.query,
+ query: undefined,
+ },
+ true,
+ )
+ }
+ }
+ }
+
+ return {
+ query,
+ scope,
+ }
+})
diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts
index c8c5c6d7de3..fa35d953406 100644
--- a/apps/files/src/store/selection.ts
+++ b/apps/files/src/store/selection.ts
@@ -16,6 +16,7 @@ export const useSelectionStore = defineStore('selection', {
actions: {
/**
* Set the selection of fileIds
+ * @param selection
*/
set(selection = [] as FileSource[]) {
Vue.set(this, 'selected', [...new Set(selection)])
@@ -23,6 +24,7 @@ export const useSelectionStore = defineStore('selection', {
/**
* Set the last selected index
+ * @param lastSelectedIndex
*/
setLastIndex(lastSelectedIndex = null as number | null) {
// Update the last selection if we provided a new selection starting point
diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts
index 4faa63a068a..48fe01d5134 100644
--- a/apps/files/src/store/userconfig.ts
+++ b/apps/files/src/store/userconfig.ts
@@ -2,58 +2,61 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { UserConfig, UserConfigStore } from '../types'
-import { defineStore } from 'pinia'
+import type { UserConfig } from '../types'
+import { getCurrentUser } from '@nextcloud/auth'
import { emit, subscribe } from '@nextcloud/event-bus'
-import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+import { ref, set } from 'vue'
import axios from '@nextcloud/axios'
-import Vue from 'vue'
-const userConfig = loadState<UserConfig>('files', 'config', {
- show_hidden: false,
+const initialUserConfig = loadState<UserConfig>('files', 'config', {
crop_image_previews: true,
+ default_view: 'files',
+ grid_view: false,
+ show_files_extensions: true,
+ show_hidden: false,
+ show_mime_column: true,
sort_favorites_first: true,
sort_folders_first: true,
- grid_view: false,
-})
-export const useUserConfigStore = function(...args) {
- const store = defineStore('userconfig', {
- state: () => ({
- userConfig,
- } as UserConfigStore),
+ show_dialog_deletion: false,
+ show_dialog_file_extension: true,
+})
- actions: {
- /**
- * Update the user config local store
- */
- onUpdate(key: string, value: boolean) {
- Vue.set(this.userConfig, key, value)
- },
+export const useUserConfigStore = defineStore('userconfig', () => {
+ const userConfig = ref<UserConfig>({ ...initialUserConfig })
- /**
- * Update the user config local store AND on server side
- */
- async update(key: string, value: boolean) {
- await axios.put(generateUrl('/apps/files/api/v1/config/' + key), {
- value,
- })
+ /**
+ * Update the user config local store
+ * @param key The config key
+ * @param value The new value
+ */
+ function onUpdate(key: string, value: boolean): void {
+ set(userConfig.value, key, value)
+ }
- emit('files:config:updated', { key, value })
- },
- },
- })
+ /**
+ * Update the user config local store AND on server side
+ * @param key The config key
+ * @param value The new value
+ */
+ async function update(key: string, value: boolean): Promise<void> {
+ // only update if a user is logged in (not the case for public shares)
+ if (getCurrentUser() !== null) {
+ await axios.put(generateUrl('/apps/files/api/v1/config/{key}', { key }), {
+ value,
+ })
+ }
+ emit('files:config:updated', { key, value })
+ }
- const userConfigStore = store(...args)
+ // Register the event listener
+ subscribe('files:config:updated', ({ key, value }) => onUpdate(key, value))
- // Make sure we only register the listeners once
- if (!userConfigStore._initialized) {
- subscribe('files:config:updated', function({ key, value }: { key: string, value: boolean }) {
- userConfigStore.onUpdate(key, value)
- })
- userConfigStore._initialized = true
+ return {
+ userConfig,
+ update,
}
-
- return userConfigStore
-}
+})
diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts
index eed17cd1b17..a902cedb6fa 100644
--- a/apps/files/src/store/viewConfig.ts
+++ b/apps/files/src/store/viewConfig.ts
@@ -2,82 +2,95 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { defineStore } from 'pinia'
+import type { ViewConfigs, ViewId, ViewConfig } from '../types'
+
+import { getCurrentUser } from '@nextcloud/auth'
import { emit, subscribe } from '@nextcloud/event-bus'
-import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+import { ref, set } from 'vue'
import axios from '@nextcloud/axios'
-import Vue from 'vue'
-import type { ViewConfigs, ViewConfigStore, ViewId, ViewConfig } from '../types'
+const initialViewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs
+
+export const useViewConfigStore = defineStore('viewconfig', () => {
-const viewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs
+ const viewConfigs = ref({ ...initialViewConfig })
-export const useViewConfigStore = function(...args) {
- const store = defineStore('viewconfig', {
- state: () => ({
- viewConfig,
- } as ViewConfigStore),
+ /**
+ * Get the config for a specific view
+ * @param viewid Id of the view to fet the config for
+ */
+ function getConfig(viewid: ViewId): ViewConfig {
+ return viewConfigs.value[viewid] || {}
+ }
- getters: {
- getConfig: (state) => (view: ViewId): ViewConfig => state.viewConfig[view] || {},
- },
+ /**
+ * Update the view config local store
+ * @param viewId The id of the view to update
+ * @param key The config key to update
+ * @param value The new value
+ */
+ function onUpdate(viewId: ViewId, key: string, value: string | number | boolean): void {
+ if (!(viewId in viewConfigs.value)) {
+ set(viewConfigs.value, viewId, {})
+ }
+ set(viewConfigs.value[viewId], key, value)
+ }
- actions: {
- /**
- * Update the view config local store
- */
- onUpdate(view: ViewId, key: string, value: string | number | boolean) {
- if (!this.viewConfig[view]) {
- Vue.set(this.viewConfig, view, {})
- }
- Vue.set(this.viewConfig[view], key, value)
- },
+ /**
+ * Update the view config local store AND on server side
+ * @param view Id of the view to update
+ * @param key Config key to update
+ * @param value New value
+ */
+ async function update(view: ViewId, key: string, value: string | number | boolean): Promise<void> {
+ if (getCurrentUser() !== null) {
+ await axios.put(generateUrl('/apps/files/api/v1/views'), {
+ value,
+ view,
+ key,
+ })
+ }
- /**
- * Update the view config local store AND on server side
- */
- async update(view: ViewId, key: string, value: string | number | boolean) {
- axios.put(generateUrl(`/apps/files/api/v1/views/${view}/${key}`), {
- value,
- })
+ emit('files:view-config:updated', { view, key, value })
+ }
- emit('files:viewconfig:updated', { view, key, value })
- },
+ /**
+ * Set the sorting key AND sort by ASC
+ * The key param must be a valid key of a File object
+ * If not found, will be searched within the File attributes
+ * @param key Key to sort by
+ * @param view View to set the sorting key for
+ */
+ function setSortingBy(key = 'basename', view = 'files'): void {
+ // Save new config
+ update(view, 'sorting_mode', key)
+ update(view, 'sorting_direction', 'asc')
+ }
- /**
- * Set the sorting key AND sort by ASC
- * The key param must be a valid key of a File object
- * If not found, will be searched within the File attributes
- */
- setSortingBy(key = 'basename', view = 'files') {
- // Save new config
- this.update(view, 'sorting_mode', key)
- this.update(view, 'sorting_direction', 'asc')
- },
+ /**
+ * Toggle the sorting direction
+ * @param viewId id of the view to set the sorting order for
+ */
+ function toggleSortingDirection(viewId = 'files'): void {
+ const config = viewConfigs.value[viewId] || { sorting_direction: 'asc' }
+ const newDirection = config.sorting_direction === 'asc' ? 'desc' : 'asc'
- /**
- * Toggle the sorting direction
- */
- toggleSortingDirection(view = 'files') {
- const config = this.getConfig(view) || { sorting_direction: 'asc' }
- const newDirection = config.sorting_direction === 'asc' ? 'desc' : 'asc'
+ // Save new config
+ update(viewId, 'sorting_direction', newDirection)
+ }
- // Save new config
- this.update(view, 'sorting_direction', newDirection)
- },
- },
- })
+ // Initialize event listener
+ subscribe('files:view-config:updated', ({ view, key, value }) => onUpdate(view, key, value))
- const viewConfigStore = store(...args)
+ return {
+ viewConfigs,
- // Make sure we only register the listeners once
- if (!viewConfigStore._initialized) {
- subscribe('files:viewconfig:updated', function({ view, key, value }: { view: ViewId, key: string, value: boolean }) {
- viewConfigStore.onUpdate(view, key, value)
- })
- viewConfigStore._initialized = true
+ getConfig,
+ setSortingBy,
+ toggleSortingDirection,
+ update,
}
-
- return viewConfigStore
-}
+})