From cd133f70ab3123e46fc681ab3a5026aab90ebbc3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 4 Feb 2026 00:21:09 +0100 Subject: [PATCH] feat(files): add dialog to confirm when about to hide a file Signed-off-by: Ferdinand Thiessen --- apps/files/src/eventbus.d.ts | 2 +- apps/files/src/store/renaming.ts | 80 ++++++++++------ .../src/views/DialogConfirmFileHidden.spec.ts | 94 +++++++++++++++++++ .../src/views/DialogConfirmFileHidden.vue | 76 +++++++++++++++ 4 files changed, 224 insertions(+), 28 deletions(-) create mode 100644 apps/files/src/views/DialogConfirmFileHidden.spec.ts create mode 100644 apps/files/src/views/DialogConfirmFileHidden.vue diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts index fd094af3583..c025564d227 100644 --- a/apps/files/src/eventbus.d.ts +++ b/apps/files/src/eventbus.d.ts @@ -27,7 +27,7 @@ declare module '@nextcloud/event-bus' { 'files:node:updated': INode 'files:node:rename': INode 'files:node:renamed': INode - 'files:node:moved': { INode: INode, oldSource: string } + 'files:node:moved': { node: INode, oldSource: string } 'files:search:updated': { query: string, scope: SearchScope } diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts index 48a44053f4b..9bdd256d731 100644 --- a/apps/files/src/store/renaming.ts +++ b/apps/files/src/store/renaming.ts @@ -2,7 +2,8 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Node } from '@nextcloud/files' + +import type { INode } from '@nextcloud/files' import axios, { isAxiosError } from '@nextcloud/axios' import { emit, subscribe } from '@nextcloud/event-bus' @@ -20,7 +21,7 @@ export const useRenamingStore = defineStore('renaming', () => { /** * The currently renamed node */ - const renamingNode = ref() + const renamingNode = ref() /** * The new name of the currently renamed node */ @@ -43,37 +44,47 @@ export const useRenamingStore = defineStore('renaming', () => { throw new Error('No node is currently being renamed') } + const oldName = renamingNode.value.basename + let newName = newNodeName.value.trim() + if (newName === oldName) { + return false + } + // Only rename once so we use this as some kind of mutex if (isRenaming.value) { return false } isRenaming.value = true + const userConfig = useUserConfigStore() 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 + if (userConfig.userConfig.show_dialog_file_extension) { + const oldExtension = extname(oldName) + const newExtension = extname(newName) + // Check for extension change for files + if (node.type === FileType.File + && oldExtension !== newExtension + && !(await showFileExtensionDialog(oldExtension, newExtension)) + ) { + // user selected to use the old extension + newName = basename(newName, newExtension) + oldExtension + if (oldName === newName) { + return false + } + } + + if (!userConfig.userConfig.show_hidden + && newName.startsWith('.') + && !oldName.startsWith('.') + && !(await showHiddenFileDialog(newName)) + ) { + return false + } } + const oldEncodedSource = node.encodedSource // rename the node node.rename(newName) logger.debug('Moving file to', { destination: node.encodedSource, oldEncodedSource }) @@ -90,7 +101,7 @@ export const useRenamingStore = defineStore('renaming', () => { // 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) { + if (extname(oldName) !== extname(newName)) { node = await fetchNode(node.path) } @@ -144,7 +155,7 @@ export const useRenamingStore = defineStore('renaming', () => { } // Make sure we only register the listeners once - subscribe('files:node:rename', (node: Node) => { + subscribe('files:node:rename', (node: INode) => { renamingNode.value = node newNodeName.value = node.basename }) @@ -166,10 +177,25 @@ export const useRenamingStore = defineStore('renaming', () => { */ async function showFileExtensionDialog(oldExtension: string, newExtension: string): Promise { const { promise, resolve } = Promise.withResolvers() - spawnDialog( + await spawnDialog( defineAsyncComponent(() => import('../views/DialogConfirmFileExtension.vue')), { oldExtension, newExtension }, - (useNewExtension: unknown) => resolve(Boolean(useNewExtension)), + resolve, ) - return await promise + return promise +} + +/** + * Show a dialog asking user for confirmation about renaming a file to a hidden file. + * + * @param filename - The new filename + */ +async function showHiddenFileDialog(filename: string): Promise { + const { promise, resolve } = Promise.withResolvers() + await spawnDialog( + defineAsyncComponent(() => import('../views/DialogConfirmFileHidden.vue')), + { filename }, + resolve, + ) + return promise } diff --git a/apps/files/src/views/DialogConfirmFileHidden.spec.ts b/apps/files/src/views/DialogConfirmFileHidden.spec.ts new file mode 100644 index 00000000000..28cc570ba30 --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileHidden.spec.ts @@ -0,0 +1,94 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createTestingPinia } from '@pinia/testing' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DialogConfirmFileHidden from './DialogConfirmFileHidden.vue' +import { useUserConfigStore } from '../store/userconfig.ts' + +describe('DialogConfirmFileHidden', () => { + beforeEach(cleanup) + + it('renders', async () => { + const component = render(DialogConfirmFileHidden, { + props: { + filename: '.filename.txt', + }, + global: { + plugins: [createTestingPinia({ + createSpy: vi.fn, + })], + }, + }) + + await expect(component.findByRole('dialog', { name: 'Rename file to hidden' })).resolves.not.toThrow() + expect((component.getByRole('checkbox', { name: /Do not show this dialog again/i }) as HTMLInputElement).checked).toBe(false) + await expect(component.findByRole('button', { name: 'Cancel' })).resolves.not.toThrow() + await expect(component.findByRole('button', { name: 'Rename' })).resolves.not.toThrow() + }) + + it('emits false value on cancel', async () => { + const onclose = vi.fn() + const component = render(DialogConfirmFileHidden, { + props: { + filename: '.filename.txt', + }, + listeners: { + close: onclose, + }, + global: { + plugins: [createTestingPinia({ + createSpy: vi.fn, + })], + }, + }) + + await fireEvent.click(component.getByRole('button', { name: 'Cancel' })) + expect(onclose).toHaveBeenCalledOnce() + expect(onclose).toHaveBeenCalledWith(false) + }) + + it('emits true on rename', async () => { + const onclose = vi.fn() + const component = render(DialogConfirmFileHidden, { + props: { + filename: '.filename.txt', + }, + listeners: { + close: onclose, + }, + global: { + plugins: [createTestingPinia({ + createSpy: vi.fn, + })], + }, + }) + + await fireEvent.click(component.getByRole('button', { name: 'Rename' })) + expect(onclose).toHaveBeenCalledOnce() + expect(onclose).toHaveBeenCalledWith(true) + }) + + it('updates user config when checking the checkbox', async () => { + const pinia = createTestingPinia({ + createSpy: vi.fn, + }) + + const component = render(DialogConfirmFileHidden, { + props: { + filename: '.filename.txt', + }, + global: { + plugins: [pinia], + }, + }) + + await fireEvent.click(component.getByRole('checkbox', { name: /Do not show this dialog again/i })) + const store = useUserConfigStore() + expect(store.update).toHaveBeenCalledOnce() + expect(store.update).toHaveBeenCalledWith('show_dialog_file_extension', false) + }) +}) diff --git a/apps/files/src/views/DialogConfirmFileHidden.vue b/apps/files/src/views/DialogConfirmFileHidden.vue new file mode 100644 index 00000000000..5178cb55f7e --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileHidden.vue @@ -0,0 +1,76 @@ + + + + + + +