Skip to content

Commit

Permalink
Merge pull request #50979 from nextcloud/feat/ignore-warning-files
Browse files Browse the repository at this point in the history
feat(files): allow to ignore warning to change file type
  • Loading branch information
susnux authored Feb 25, 2025
2 parents 0427d5d + 4896c51 commit 373107b
Show file tree
Hide file tree
Showing 18 changed files with 580 additions and 186 deletions.
6 changes: 6 additions & 0 deletions apps/files/lib/Service/UserConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class UserConfig {
'default' => true,
'allowed' => [true, false],
],
[
// Whether to show the "confirm file extension change" warning
'key' => 'show_dialog_file_extension',
'default' => true,
'allowed' => [true, false],
],
[
// Whether to show the hidden files or not in the files list
'key' => 'show_hidden',
Expand Down
13 changes: 7 additions & 6 deletions apps/files/src/components/FileEntry/FileEntryName.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
<component :is="linkTo.is"
v-else
ref="basename"
:aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
v-bind="linkTo.params">
Expand Down Expand Up @@ -117,11 +116,11 @@ export default defineComponent({
return this.isRenaming && this.filesListWidth < 512
},
newName: {
get() {
return this.renamingStore.newName
get(): string {
return this.renamingStore.newNodeName
},
set(newName) {
this.renamingStore.newName = newName
set(newName: string) {
this.renamingStore.newNodeName = newName
},
},

Expand Down Expand Up @@ -249,7 +248,9 @@ export default defineComponent({
try {
const status = await this.renamingStore.rename()
if (status) {
showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
showSuccess(
t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }),
)
this.$nextTick(() => {
const nameContainer = this.$refs.basename as HTMLElement | undefined
nameContainer?.focus()
Expand Down
1 change: 1 addition & 0 deletions apps/files/src/eventbus.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ declare module '@nextcloud/event-bus' {
'files:node:created': Node
'files:node:deleted': Node
'files:node:updated': Node
'files:node:rename': Node
'files:node:renamed': Node
'files:node:moved': { node: Node, oldSource: string }

Expand Down
307 changes: 144 additions & 163 deletions apps/files/src/store/renaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,184 +3,165 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { RenamingStore } from '../types'

import axios, { isAxiosError } from '@nextcloud/axios'
import { emit, subscribe } from '@nextcloud/event-bus'
import { FileType, NodeStatus } from '@nextcloud/files'
import { DialogBuilder } from '@nextcloud/dialogs'
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 from 'vue'
import IconCancel from '@mdi/svg/svg/cancel.svg?raw'
import IconCheck from '@mdi/svg/svg/check.svg?raw'

let isDialogVisible = false

const showWarningDialog = (oldExtension: string, newExtension: string): Promise<boolean> => {
if (isDialogVisible) {
return Promise.resolve(false)
}

isDialogVisible = true

let message

if (!oldExtension && newExtension) {
message = t(
'files',
'Adding the file extension "{new}" may render the file unreadable.',
{ new: newExtension },
)
} else if (!newExtension) {
message = t(
'files',
'Removing the file extension "{old}" may render the file unreadable.',
{ old: oldExtension },
)
} else {
message = t(
'files',
'Changing the file extension from "{old}" to "{new}" may render the file unreadable.',
{ old: oldExtension, new: newExtension },
)
}

return new Promise((resolve) => {
const dialog = new DialogBuilder()
.setName(t('files', 'Change file extension'))
.setText(message)
.setButtons([
{
label: t('files', 'Keep {oldextension}', { oldextension: oldExtension }),
icon: IconCancel,
type: 'secondary',
callback: () => {
isDialogVisible = false
resolve(false)
},
import Vue, { defineAsyncComponent, ref } from 'vue'
import { useUserConfigStore } from './userconfig'

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

const 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',
},
{
label: newExtension.length ? t('files', 'Use {newextension}', { newextension: newExtension }) : t('files', 'Remove extension'),
icon: IconCheck,
type: 'primary',
callback: () => {
isDialogVisible = false
resolve(true)
},
},
])
.build()

dialog.show().then(() => {
dialog.hide()
})
})
}

export const useRenamingStore = function(...args) {
const store = defineStore('renaming', {
state: () => ({
renamingNode: undefined,
newName: '',
} as RenamingStore),

actions: {
/**
* 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 rename(): Promise<boolean> {
if (this.renamingNode === undefined) {
throw new Error('No node is currently being renamed')
}

const newName = this.newName.trim?.() || ''
const oldName = this.renamingNode.basename
const oldEncodedSource = this.renamingNode.encodedSource

// Check for extension change for files
const oldExtension = extname(oldName)
const newExtension = extname(newName)
if (oldExtension !== newExtension && this.renamingNode.type === FileType.File) {
const proceed = await showWarningDialog(oldExtension, newExtension)
if (!proceed) {
return false
}
})

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

if (oldName === newName) {
return false
}
/**
* Reset the store state
*/
function $reset(): void {
newNodeName.value = ''
renamingNode.value = undefined
}

const node = this.renamingNode
Vue.set(node, 'status', NodeStatus.LOADING)

try {
// rename the node
this.renamingNode.rename(newName)
logger.debug('Moving file to', { destination: this.renamingNode.encodedSource, oldEncodedSource })
// create MOVE request
await axios({
method: 'MOVE',
url: oldEncodedSource,
headers: {
Destination: this.renamingNode.encodedSource,
Overwrite: 'F',
},
})

// Success 🎉
emit('files:node:updated', this.renamingNode as Node)
emit('files:node:renamed', this.renamingNode as Node)
emit('files:node:moved', {
node: this.renamingNode as Node,
oldSource: `${dirname(this.renamingNode.source)}/${oldName}`,
})
this.$reset()
return true
} catch (error) {
logger.error('Error while renaming file', { error })
// Rename back as it failed
this.renamingNode.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(this.renamingNode.dirname),
},
))
}
}
// Unknown error
throw new Error(t('files', 'Could not rename "{oldName}"', { oldName }))
} finally {
Vue.set(node, 'status', undefined)
}
},
},
// Make sure we only register the listeners once
subscribe('files:node:rename', (node: Node) => {
renamingNode.value = node
newNodeName.value = node.basename
})

const renamingStore = store(...args)
return {
$reset,

// 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
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
}
2 changes: 2 additions & 0 deletions apps/files/src/store/userconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const initialUserConfig = loadState<UserConfig>('files', 'config', {
sort_favorites_first: true,
sort_folders_first: true,
grid_view: false,

show_dialog_file_extension: true,
})

export const useUserConfigStore = defineStore('userconfig', () => {
Expand Down
Loading

0 comments on commit 373107b

Please sign in to comment.