Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(files): allow to ignore warning to change file type #50979

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)

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
Loading
Loading