feat(files): add dialog to confirm when about to hide a file

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-02-04 00:21:09 +01:00
parent d07441443d
commit cd133f70ab
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
4 changed files with 224 additions and 28 deletions

View file

@ -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 }

View file

@ -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<Node>()
const renamingNode = ref<INode>()
/**
* 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<boolean> {
const { promise, resolve } = Promise.withResolvers<boolean>()
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<boolean> {
const { promise, resolve } = Promise.withResolvers<boolean>()
await spawnDialog(
defineAsyncComponent(() => import('../views/DialogConfirmFileHidden.vue')),
{ filename },
resolve,
)
return promise
}

View file

@ -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)
})
})

View file

@ -0,0 +1,76 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import { useUserConfigStore } from '../store/userconfig.ts'
const props = defineProps<{
filename: string
}>()
const emit = defineEmits<{
(e: 'close', v: boolean): void
}>()
const userConfigStore = useUserConfigStore()
const dontShowAgain = computed({
get: () => !userConfigStore.userConfig.show_dialog_file_extension,
set: (value: boolean) => userConfigStore.update('show_dialog_file_extension', !value),
})
/** Open state of the dialog */
const open = ref(true)
/**
* Close the dialog and emit the response
*
* @param value User selected response
*/
function closeDialog(value: boolean) {
emit('close', value)
open.value = false
}
</script>
<template>
<NcDialog
no-close
:open="open"
:name="t('files', 'Rename file to hidden')"
size="small">
<div>
<p>
{{ t('files', 'Prefixing a filename with a dot may render the file hidden.') }}
{{ t('files', 'Are you sure you want to rename the file to "{filename}"?', { filename: props.filename }) }}
</p>
<NcCheckboxRadioSwitch
v-model="dontShowAgain"
:class="$style.dialogConfirmFileHidden__checkbox"
type="switch">
{{ t('files', 'Do not show this dialog again.') }}
</NcCheckboxRadioSwitch>
</div>
<template #actions>
<NcButton variant="secondary" @click="closeDialog(false)">
{{ t('files', 'Cancel') }}
</NcButton>
<NcButton variant="primary" @click="closeDialog(true)">
{{ t('files', 'Rename') }}
</NcButton>
</template>
</NcDialog>
</template>
<style module>
.dialogConfirmFileHidden__checkbox {
margin-top: 1rem;
}
</style>