Merge pull request #58041 from nextcloud/fix/renaming

feat(files): warn when file would be hidden
This commit is contained in:
Andy Scherzinger 2026-02-05 18:27:26 +01:00 committed by GitHub
commit 29e1ac3c02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 354 additions and 49 deletions

View file

@ -40,7 +40,6 @@
ref="name"
:basename="basename"
:extension="extension"
:nodes="nodes"
:source="source"
@auxclick.native="execDefaultAction"
@click.native="execDefaultAction" />

View file

@ -15,9 +15,9 @@
ref="renameInput"
v-model="newName"
:label="renameLabel"
:autofocus="true"
autofocus
required
:minlength="1"
:required="true"
enterkeyhint="done"
@keyup.esc="stopRenaming" />
</form>
@ -45,6 +45,7 @@ import type { PropType } from 'vue'
import { showError } from '@nextcloud/dialogs'
import { FileType, NodeStatus } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { basename } from '@nextcloud/paths'
import { defineComponent, inject } from 'vue'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
@ -78,20 +79,10 @@ export default defineComponent({
required: true,
},
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
source: {
type: Object as PropType<Node>,
required: true,
},
gridMode: {
type: Boolean,
default: false,
},
},
setup() {
@ -153,8 +144,8 @@ export default defineComponent({
if (this.defaultFileAction) {
const displayName = this.defaultFileAction.displayName({
nodes: [this.source],
view: this.activeView,
folder: this.activeFolder,
view: this.activeView!,
folder: this.activeFolder!,
contents: [],
})
return {
@ -215,7 +206,8 @@ export default defineComponent({
methods: {
checkIfNodeExists(name: string) {
return this.nodes.find((node) => node.basename === name && node !== this.source)
const sources: string[] = (this.activeFolder as { _children?: string[] })?._children || []
return sources.some((sourceName) => basename(sourceName) === name)
},
startRenaming() {

View file

@ -42,8 +42,6 @@
ref="name"
:basename="basename"
:extension="extension"
:grid-mode="true"
:nodes="nodes"
:source="source"
@auxclick.native="execDefaultAction"
@click.native="execDefaultAction" />

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>

2
dist/4723-4723.js vendored Normal file
View file

@ -0,0 +1,2 @@
"use strict";(globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[]).push([[4723],{35412(t,e,n){n.d(e,{A:()=>a});var o=n(71354),i=n.n(o),l=n(76314),s=n.n(l)()(i());s.push([t.id,"\n._dialogConfirmFileHidden__checkbox_CoKAM {\n\tmargin-top: 1rem;\n}\n","",{version:3,sources:["webpack://./apps/files/src/views/DialogConfirmFileHidden.vue"],names:[],mappings:";AAwEA;CACA,gBAAA;AACA",sourcesContent:["\x3c!--\n - SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors\n - SPDX-License-Identifier: AGPL-3.0-or-later\n--\x3e\n\n<script setup lang=\"ts\">\nimport { t } from '@nextcloud/l10n'\nimport { computed, ref } from 'vue'\nimport NcButton from '@nextcloud/vue/components/NcButton'\nimport NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'\nimport NcDialog from '@nextcloud/vue/components/NcDialog'\nimport { useUserConfigStore } from '../store/userconfig.ts'\n\nconst props = defineProps<{\n\tfilename: string\n}>()\n\nconst emit = defineEmits<{\n\t(e: 'close', v: boolean): void\n}>()\n\nconst userConfigStore = useUserConfigStore()\nconst dontShowAgain = computed({\n\tget: () => !userConfigStore.userConfig.show_dialog_file_extension,\n\tset: (value: boolean) => userConfigStore.update('show_dialog_file_extension', !value),\n})\n\n/** Open state of the dialog */\nconst open = ref(true)\n\n/**\n * Close the dialog and emit the response\n *\n * @param value User selected response\n */\nfunction closeDialog(value: boolean) {\n\temit('close', value)\n\topen.value = false\n}\n<\/script>\n\n<template>\n\t<NcDialog\n\t\tno-close\n\t\t:open=\"open\"\n\t\t:name=\"t('files', 'Rename file to hidden')\"\n\t\tsize=\"small\">\n\t\t<div>\n\t\t\t<p>\n\t\t\t\t{{ t('files', 'Prefixing a filename with a dot may render the file hidden.') }}\n\t\t\t\t{{ t('files', 'Are you sure you want to rename the file to \"{filename}\"?', { filename: props.filename }) }}\n\t\t\t</p>\n\n\t\t\t<NcCheckboxRadioSwitch\n\t\t\t\tv-model=\"dontShowAgain\"\n\t\t\t\t:class=\"$style.dialogConfirmFileHidden__checkbox\"\n\t\t\t\ttype=\"switch\">\n\t\t\t\t{{ t('files', 'Do not show this dialog again.') }}\n\t\t\t</NcCheckboxRadioSwitch>\n\t\t</div>\n\t\t<template #actions>\n\t\t\t<NcButton variant=\"secondary\" @click=\"closeDialog(false)\">\n\t\t\t\t{{ t('files', 'Cancel') }}\n\t\t\t</NcButton>\n\t\t\t<NcButton variant=\"primary\" @click=\"closeDialog(true)\">\n\t\t\t\t{{ t('files', 'Rename') }}\n\t\t\t</NcButton>\n\t\t</template>\n\t</NcDialog>\n</template>\n\n<style module>\n.dialogConfirmFileHidden__checkbox {\n\tmargin-top: 1rem;\n}\n</style>\n"],sourceRoot:""}]),s.locals={dialogConfirmFileHidden__checkbox:"_dialogConfirmFileHidden__checkbox_CoKAM"};const a=s},54723(t,e,n){n.r(e),n.d(e,{default:()=>k});var o=n(85471),i=n(53334),l=n(74095),s=n(32073),a=n(94219),c=n(69379);const r=(0,o.pM)({__name:"DialogConfirmFileHidden",props:{filename:null},emits:["close"],setup(t,{emit:e}){const n=t,r=(0,c.O)(),d=(0,o.EW)({get:()=>!r.userConfig.show_dialog_file_extension,set:t=>r.update("show_dialog_file_extension",!t)}),u=(0,o.KR)(!0);return{__sfc:!0,props:n,emit:e,userConfigStore:r,dontShowAgain:d,open:u,closeDialog:function(t){e("close",t),u.value=!1},t:i.t,NcButton:l.A,NcCheckboxRadioSwitch:s.A,NcDialog:a.A}}});var d=n(85072),u=n.n(d),f=n(97825),m=n.n(f),p=n(77659),g=n.n(p),h=n(55056),_=n.n(h),v=n(10540),C=n.n(v),x=n(41113),A=n.n(x),w=n(35412),b={};b.styleTagTransform=A(),b.setAttributes=_(),b.insert=g().bind(null,"head"),b.domAPI=m(),b.insertStyleElement=C(),u()(w.A,b);const y=w.A&&w.A.locals?w.A.locals:void 0,k=(0,n(14486).A)(r,function(){var t=this,e=t._self._c,n=t._self._setupProxy;return e(n.NcDialog,{attrs:{"no-close":"",open:n.open,name:n.t("files","Rename file to hidden"),size:"small"},scopedSlots:t._u([{key:"actions",fn:function(){return[e(n.NcButton,{attrs:{variant:"secondary"},on:{click:function(t){return n.closeDialog(!1)}}},[t._v("\n\t\t\t"+t._s(n.t("files","Cancel"))+"\n\t\t")]),t._v(" "),e(n.NcButton,{attrs:{variant:"primary"},on:{click:function(t){return n.closeDialog(!0)}}},[t._v("\n\t\t\t"+t._s(n.t("files","Rename"))+"\n\t\t")])]},proxy:!0}])},[e("div",[e("p",[t._v("\n\t\t\t"+t._s(n.t("files","Prefixing a filename with a dot may render the file hidden."))+"\n\t\t\t"+t._s(n.t("files",'Are you sure you want to rename the file to "{filename}"?',{filename:n.props.filename}))+"\n\t\t")]),t._v(" "),e(n.NcCheckboxRadioSwitch,{class:t.$style.dialogConfirmFileHidden__checkbox,attrs:{type:"switch"},model:{value:n.dontShowAgain,callback:function(t){n.dontShowAgain=t},expression:"dontShowAgain"}},[t._v("\n\t\t\t"+t._s(n.t("files","Do not show this dialog again."))+"\n\t\t")])],1)])},[],!1,function(t){this.$style=y.locals||y},null,null).exports}}]);
//# sourceMappingURL=4723-4723.js.map?v=934c36402dc782526ac6

116
dist/4723-4723.js.license vendored Normal file
View file

@ -0,0 +1,116 @@
SPDX-License-Identifier: MIT
SPDX-License-Identifier: ISC
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-License-Identifier: BSD-3-Clause
SPDX-License-Identifier: AGPL-3.0-or-later
SPDX-License-Identifier: (MPL-2.0 OR Apache-2.0)
SPDX-FileCopyrightText: escape-html developers
SPDX-FileCopyrightText: Tobias Koppers @sokra
SPDX-FileCopyrightText: T. Jameson Little <t.jameson.little@gmail.com>
SPDX-FileCopyrightText: Roman Shtylman <shtylman@gmail.com>
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: Matt Zabriskie
SPDX-FileCopyrightText: Guillaume Chau <guillaume.b.chau@gmail.com>
SPDX-FileCopyrightText: Guillaume Chau
SPDX-FileCopyrightText: GitHub Inc.
SPDX-FileCopyrightText: Feross Aboukhadijeh
SPDX-FileCopyrightText: Evan You
SPDX-FileCopyrightText: Eduardo San Martin Morote
SPDX-FileCopyrightText: Dr.-Ing. Mario Heiderich, Cure53 <mario@cure53.de> (https://cure53.de/)
SPDX-FileCopyrightText: David Clark
SPDX-FileCopyrightText: Christoph Wurst
SPDX-FileCopyrightText: Anthony Fu <https://github.com/antfu>
SPDX-FileCopyrightText: Anthony Fu <anthonyfu117@hotmail.com>
This file is generated from multiple sources. Included packages:
- @nextcloud/auth
- version: 2.5.3
- license: GPL-3.0-or-later
- @nextcloud/axios
- version: 2.5.2
- license: GPL-3.0-or-later
- @nextcloud/browser-storage
- version: 0.5.0
- license: GPL-3.0-or-later
- semver
- version: 7.7.2
- license: ISC
- @nextcloud/event-bus
- version: 3.3.3
- license: GPL-3.0-or-later
- @nextcloud/initial-state
- version: 3.0.0
- license: GPL-3.0-or-later
- @nextcloud/l10n
- version: 3.4.1
- license: GPL-3.0-or-later
- @nextcloud/logger
- version: 3.0.3
- license: GPL-3.0-or-later
- @nextcloud/router
- version: 3.1.0
- license: GPL-3.0-or-later
- @nextcloud/vue
- version: 8.35.3
- license: AGPL-3.0-or-later
- @vue/devtools-api
- version: 6.6.4
- license: MIT
- @vueuse/core
- version: 11.3.0
- license: MIT
- @vueuse/shared
- version: 11.3.0
- license: MIT
- axios
- version: 1.12.2
- license: MIT
- base64-js
- version: 1.5.1
- license: MIT
- css-loader
- version: 7.1.2
- license: MIT
- dompurify
- version: 3.3.1
- license: (MPL-2.0 OR Apache-2.0)
- escape-html
- version: 1.0.3
- license: MIT
- floating-vue
- version: 1.0.0-beta.19
- license: MIT
- focus-trap
- version: 7.8.0
- license: MIT
- ieee754
- version: 1.2.1
- license: BSD-3-Clause
- buffer
- version: 6.0.3
- license: MIT
- pinia
- version: 2.3.1
- license: MIT
- process
- version: 0.11.10
- license: MIT
- style-loader
- version: 4.0.0
- license: MIT
- tabbable
- version: 6.4.0
- license: MIT
- vue-demi
- version: 0.14.10
- license: MIT
- vue-loader
- version: 15.11.1
- license: MIT
- vue
- version: 2.7.16
- license: MIT
- nextcloud
- version: 1.0.0
- license: AGPL-3.0-or-later

1
dist/4723-4723.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/4723-4723.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
4723-4723.js.license

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long