mirror of
https://github.com/nextcloud/server.git
synced 2026-02-03 20:41:22 -05:00
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:
parent
d07441443d
commit
cd133f70ab
4 changed files with 224 additions and 28 deletions
2
apps/files/src/eventbus.d.ts
vendored
2
apps/files/src/eventbus.d.ts
vendored
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
94
apps/files/src/views/DialogConfirmFileHidden.spec.ts
Normal file
94
apps/files/src/views/DialogConfirmFileHidden.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
76
apps/files/src/views/DialogConfirmFileHidden.vue
Normal file
76
apps/files/src/views/DialogConfirmFileHidden.vue
Normal 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>
|
||||
Loading…
Reference in a new issue