Skip to content

Commit

Permalink
feat(files): allow to ignore warning to change file type
Browse files Browse the repository at this point in the history
* Missing pieces of #46528
  * Add checkbox to not show this dialog again
  * Add user config as suggested by designers in files settings to
    reenable or diable this behavior.
  * Fix behavior of dialog: It says "keep .ext" but it does not keep the
    extension but cancels the operation. From the button label the user
    expects that the operation is continued but with the old extension.
  * Added more test coverage by adding component tests.

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Feb 22, 2025
1 parent 6cd3046 commit d114981
Show file tree
Hide file tree
Showing 10 changed files with 452 additions and 179 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
9 changes: 4 additions & 5 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
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)

Check warning on line 37 in apps/files/src/store/renaming.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @return type
* @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
1 change: 1 addition & 0 deletions apps/files/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface PathOptions {
export interface UserConfig {
[key: string]: boolean|undefined

show_dialog_file_extension: boolean,
show_hidden: boolean
crop_image_previews: boolean
sort_favorites_first: boolean
Expand Down
Loading

0 comments on commit d114981

Please sign in to comment.