FileEntry.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858
  1. <!--
  2. - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. -
  6. - @license AGPL-3.0-or-later
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -
  21. -->
  22. <template>
  23. <Fragment>
  24. <td class="files-list__row-checkbox">
  25. <NcCheckboxRadioSwitch v-if="active"
  26. :aria-label="t('files', 'Select the row for {displayName}', { displayName })"
  27. :checked="selectedFiles"
  28. :value="fileid"
  29. name="selectedFiles"
  30. @update:checked="onSelectionChange" />
  31. </td>
  32. <!-- Link to file -->
  33. <td class="files-list__row-name">
  34. <!-- Icon or preview -->
  35. <span class="files-list__row-icon" @click="execDefaultAction">
  36. <FolderIcon v-if="source.type === 'folder'" />
  37. <!-- Decorative image, should not be aria documented -->
  38. <span v-else-if="previewUrl && !backgroundFailed"
  39. ref="previewImg"
  40. class="files-list__row-icon-preview"
  41. :style="{ backgroundImage }" />
  42. <span v-else-if="mimeIconUrl"
  43. class="files-list__row-icon-preview files-list__row-icon-preview--mime"
  44. :style="{ backgroundImage: mimeIconUrl }" />
  45. <FileIcon v-else />
  46. <!-- Favorite icon -->
  47. <span v-if="isFavorite"
  48. class="files-list__row-icon-favorite"
  49. :aria-label="t('files', 'Favorite')">
  50. <FavoriteIcon :aria-hidden="true" />
  51. </span>
  52. </span>
  53. <!-- Rename input -->
  54. <form v-show="isRenaming"
  55. v-on-click-outside="stopRenaming"
  56. :aria-hidden="!isRenaming"
  57. :aria-label="t('files', 'Rename file')"
  58. class="files-list__row-rename"
  59. @submit.prevent.stop="onRename">
  60. <NcTextField ref="renameInput"
  61. :aria-label="t('files', 'File name')"
  62. :autofocus="true"
  63. :minlength="1"
  64. :required="true"
  65. :value.sync="newName"
  66. enterkeyhint="done"
  67. @keyup="checkInputValidity"
  68. @keyup.esc="stopRenaming" />
  69. </form>
  70. <a v-show="!isRenaming"
  71. ref="basename"
  72. :aria-hidden="isRenaming"
  73. class="files-list__row-name-link"
  74. v-bind="linkTo"
  75. @click="execDefaultAction">
  76. <!-- File name -->
  77. <span class="files-list__row-name-text">
  78. <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
  79. <span class="files-list__row-name-" v-text="displayName" />
  80. <span class="files-list__row-name-ext" v-text="source.extension" />
  81. </span>
  82. </a>
  83. </td>
  84. <!-- Actions -->
  85. <td v-show="!isRenamingSmallScreen" :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
  86. <!-- Render actions -->
  87. <CustomElementRender v-for="action in enabledRenderActions"
  88. :key="action.id"
  89. :current-view="currentView"
  90. :render="action.renderInline"
  91. :source="source" />
  92. <!-- Menu actions -->
  93. <NcActions v-if="active"
  94. ref="actionsMenu"
  95. :boundaries-element="boundariesElement"
  96. :container="boundariesElement"
  97. :disabled="source._loading"
  98. :force-name="true"
  99. :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
  100. :inline="enabledInlineActions.length"
  101. :open.sync="openedMenu">
  102. <NcActionButton v-for="action in enabledMenuActions"
  103. :key="action.id"
  104. :class="'files-list__row-action-' + action.id"
  105. :close-after-click="true"
  106. @click="onActionClick(action)">
  107. <template #icon>
  108. <NcLoadingIcon v-if="loading === action.id" :size="18" />
  109. <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
  110. </template>
  111. {{ action.displayName([source], currentView) }}
  112. </NcActionButton>
  113. </NcActions>
  114. </td>
  115. <!-- Size -->
  116. <td v-if="isSizeAvailable"
  117. :style="{ opacity: sizeOpacity }"
  118. class="files-list__row-size"
  119. @click="openDetailsIfAvailable">
  120. <span>{{ size }}</span>
  121. </td>
  122. <!-- Mtime -->
  123. <td v-if="isMtimeAvailable"
  124. class="files-list__row-mtime"
  125. @click="openDetailsIfAvailable">
  126. <span>{{ mtime }}</span>
  127. </td>
  128. <!-- View columns -->
  129. <td v-for="column in columns"
  130. :key="column.id"
  131. :class="`files-list__row-${currentView?.id}-${column.id}`"
  132. class="files-list__row-column-custom"
  133. @click="openDetailsIfAvailable">
  134. <CustomElementRender v-if="active"
  135. :current-view="currentView"
  136. :render="column.render"
  137. :source="source" />
  138. </td>
  139. </Fragment>
  140. </template>
  141. <script lang='ts'>
  142. import { debounce } from 'debounce'
  143. import { emit } from '@nextcloud/event-bus'
  144. import { formatFileSize, Permission } from '@nextcloud/files'
  145. import { Fragment } from 'vue-frag'
  146. import { showError, showSuccess } from '@nextcloud/dialogs'
  147. import { translate } from '@nextcloud/l10n'
  148. import { generateUrl } from '@nextcloud/router'
  149. import { vOnClickOutside } from '@vueuse/components'
  150. import axios from '@nextcloud/axios'
  151. import CancelablePromise from 'cancelable-promise'
  152. import FileIcon from 'vue-material-design-icons/File.vue'
  153. import FolderIcon from 'vue-material-design-icons/Folder.vue'
  154. import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
  155. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  156. import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
  157. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  158. import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
  159. import Vue from 'vue'
  160. import type moment from 'moment'
  161. import { ACTION_DETAILS } from '../actions/sidebarAction.ts'
  162. import { getFileActions, DefaultType } from '../services/FileAction.ts'
  163. import { hashCode } from '../utils/hashUtils.ts'
  164. import { isCachedPreview } from '../services/PreviewService.ts'
  165. import { useActionsMenuStore } from '../store/actionsmenu.ts'
  166. import { useFilesStore } from '../store/files.ts'
  167. import { useKeyboardStore } from '../store/keyboard.ts'
  168. import { useSelectionStore } from '../store/selection.ts'
  169. import { useUserConfigStore } from '../store/userconfig.ts'
  170. import { useRenamingStore } from '../store/renaming.ts'
  171. import CustomElementRender from './CustomElementRender.vue'
  172. import CustomSvgIconRender from './CustomSvgIconRender.vue'
  173. import FavoriteIcon from './FavoriteIcon.vue'
  174. import logger from '../logger.js'
  175. // The registered actions list
  176. const actions = getFileActions()
  177. Vue.directive('onClickOutside', vOnClickOutside)
  178. export default Vue.extend({
  179. name: 'FileEntry',
  180. components: {
  181. CustomElementRender,
  182. CustomSvgIconRender,
  183. FavoriteIcon,
  184. FileIcon,
  185. FolderIcon,
  186. Fragment,
  187. NcActionButton,
  188. NcActions,
  189. NcCheckboxRadioSwitch,
  190. NcLoadingIcon,
  191. NcTextField,
  192. },
  193. props: {
  194. active: {
  195. type: Boolean,
  196. default: false,
  197. },
  198. isMtimeAvailable: {
  199. type: Boolean,
  200. default: false,
  201. },
  202. isSizeAvailable: {
  203. type: Boolean,
  204. default: false,
  205. },
  206. source: {
  207. type: Object,
  208. required: true,
  209. },
  210. index: {
  211. type: Number,
  212. required: true,
  213. },
  214. nodes: {
  215. type: Array,
  216. required: true,
  217. },
  218. filesListWidth: {
  219. type: Number,
  220. default: 0,
  221. },
  222. },
  223. setup() {
  224. const actionsMenuStore = useActionsMenuStore()
  225. const filesStore = useFilesStore()
  226. const keyboardStore = useKeyboardStore()
  227. const renamingStore = useRenamingStore()
  228. const selectionStore = useSelectionStore()
  229. const userConfigStore = useUserConfigStore()
  230. return {
  231. actionsMenuStore,
  232. filesStore,
  233. keyboardStore,
  234. renamingStore,
  235. selectionStore,
  236. userConfigStore,
  237. }
  238. },
  239. data() {
  240. return {
  241. backgroundFailed: false,
  242. backgroundImage: '',
  243. boundariesElement: document.querySelector('.app-content > .files-list'),
  244. loading: '',
  245. }
  246. },
  247. computed: {
  248. userConfig() {
  249. return this.userConfigStore.userConfig
  250. },
  251. currentView() {
  252. return this.$navigation.active
  253. },
  254. columns() {
  255. // Hide columns if the list is too small
  256. if (this.filesListWidth < 512) {
  257. return []
  258. }
  259. return this.currentView?.columns || []
  260. },
  261. dir() {
  262. // Remove any trailing slash but leave root slash
  263. return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
  264. },
  265. fileid() {
  266. return this.source?.fileid?.toString?.()
  267. },
  268. displayName() {
  269. const ext = (this.source.extension || '')
  270. const name = (this.source.attributes.displayName
  271. || this.source.basename)
  272. // Strip extension from name if defined
  273. return !ext ? name : name.slice(0, 0 - ext.length)
  274. },
  275. size() {
  276. const size = parseInt(this.source.size, 10) || 0
  277. if (typeof size !== 'number' || size < 0) {
  278. return this.t('files', 'Pending')
  279. }
  280. return formatFileSize(size, true)
  281. },
  282. sizeOpacity() {
  283. // Whatever theme is active, the contrast will pass WCAG AA
  284. // with color main text over main background and an opacity of 0.7
  285. const minOpacity = 0.7
  286. const maxOpacitySize = 10 * 1024 * 1024
  287. const size = parseInt(this.source.size, 10) || 0
  288. if (!size || size < 0) {
  289. return minOpacity
  290. }
  291. return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
  292. },
  293. mtime() {
  294. if (this.source.mtime) {
  295. return moment(this.source.mtime).fromNow()
  296. }
  297. return this.t('files_trashbin', 'A long time ago')
  298. },
  299. mtimeTitle() {
  300. if (this.source.mtime) {
  301. return moment(this.source.mtime).format('LLL')
  302. }
  303. return ''
  304. },
  305. linkTo() {
  306. if (this.enabledDefaultActions.length > 0) {
  307. const action = this.enabledDefaultActions[0]
  308. const displayName = action.displayName([this.source], this.currentView)
  309. return {
  310. title: displayName,
  311. role: 'button',
  312. }
  313. }
  314. if (this.source?.permissions & Permission.READ) {
  315. return {
  316. download: this.source.basename,
  317. href: this.source.source,
  318. title: this.t('files', 'Download file {name}', { name: this.displayName }),
  319. }
  320. }
  321. return {
  322. is: 'span',
  323. }
  324. },
  325. selectedFiles() {
  326. return this.selectionStore.selected
  327. },
  328. isSelected() {
  329. return this.selectedFiles.includes(this.source?.fileid?.toString?.())
  330. },
  331. cropPreviews() {
  332. return this.userConfig.crop_image_previews
  333. },
  334. previewUrl() {
  335. try {
  336. const previewUrl = this.source.attributes.previewUrl
  337. || generateUrl('/core/preview?fileId={fileid}', {
  338. fileid: this.source.fileid,
  339. })
  340. const url = new URL(window.location.origin + previewUrl)
  341. // Request tiny previews
  342. url.searchParams.set('x', '32')
  343. url.searchParams.set('y', '32')
  344. // Handle cropping
  345. url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
  346. return url.href
  347. } catch (e) {
  348. return null
  349. }
  350. },
  351. mimeIconUrl() {
  352. const mimeType = this.source.mime || 'application/octet-stream'
  353. const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType)
  354. if (mimeIconUrl) {
  355. return `url(${mimeIconUrl})`
  356. }
  357. return ''
  358. },
  359. // Sorted actions that are enabled for this node
  360. enabledActions() {
  361. return actions
  362. .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
  363. .sort((a, b) => (a.order || 0) - (b.order || 0))
  364. },
  365. // Enabled action that are displayed inline
  366. enabledInlineActions() {
  367. if (this.filesListWidth < 768) {
  368. return []
  369. }
  370. return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
  371. },
  372. // Enabled action that are displayed inline with a custom render function
  373. enabledRenderActions() {
  374. if (!this.active) {
  375. return []
  376. }
  377. return this.enabledActions.filter(action => typeof action.renderInline === 'function')
  378. },
  379. // Default actions
  380. enabledDefaultActions() {
  381. return this.enabledActions.filter(action => !!action?.default)
  382. },
  383. // Actions shown in the menu
  384. enabledMenuActions() {
  385. return [
  386. // Showing inline first for the NcActions inline prop
  387. ...this.enabledInlineActions,
  388. // Then the rest
  389. ...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
  390. ].filter((value, index, self) => {
  391. // Then we filter duplicates to prevent inline actions to be shown twice
  392. return index === self.findIndex(action => action.id === value.id)
  393. })
  394. },
  395. openedMenu: {
  396. get() {
  397. return this.actionsMenuStore.opened === this.uniqueId
  398. },
  399. set(opened) {
  400. this.actionsMenuStore.opened = opened ? this.uniqueId : null
  401. },
  402. },
  403. uniqueId() {
  404. return hashCode(this.source.source)
  405. },
  406. isFavorite() {
  407. return this.source.attributes.favorite === 1
  408. },
  409. isRenaming() {
  410. return this.renamingStore.renamingNode === this.source
  411. },
  412. isRenamingSmallScreen() {
  413. return this.isRenaming && this.filesListWidth < 512
  414. },
  415. newName: {
  416. get() {
  417. return this.renamingStore.newName
  418. },
  419. set(newName) {
  420. this.renamingStore.newName = newName
  421. },
  422. },
  423. },
  424. watch: {
  425. active(active, before) {
  426. if (active === false && before === true) {
  427. this.resetState()
  428. // When the row is not active anymore
  429. // remove the display from the row to prevent
  430. // keyboard interaction with it.
  431. this.$el.parentNode.style.display = 'none'
  432. return
  433. }
  434. // Restore default tabindex
  435. this.$el.parentNode.style.display = ''
  436. },
  437. /**
  438. * When the source changes, reset the preview
  439. * and fetch the new one.
  440. */
  441. source() {
  442. this.resetState()
  443. this.debounceIfNotCached()
  444. },
  445. /**
  446. * If renaming starts, select the file name
  447. * in the input, without the extension.
  448. */
  449. isRenaming() {
  450. this.startRenaming()
  451. },
  452. },
  453. /**
  454. * The row is mounted once and reused as we scroll.
  455. */
  456. mounted() {
  457. // ⚠ Init the debounce function on mount and
  458. // not when the module is imported to
  459. // avoid sharing between recycled components
  460. this.debounceGetPreview = debounce(function() {
  461. this.fetchAndApplyPreview()
  462. }, 150, false)
  463. // Fetch the preview on init
  464. this.debounceIfNotCached()
  465. // Right click watcher on tr
  466. this.$el.parentNode?.addEventListener?.('contextmenu', this.onRightClick)
  467. },
  468. beforeDestroy() {
  469. this.resetState()
  470. },
  471. methods: {
  472. async debounceIfNotCached() {
  473. if (!this.previewUrl) {
  474. return
  475. }
  476. // Check if we already have this preview cached
  477. const isCached = await isCachedPreview(this.previewUrl)
  478. if (isCached) {
  479. this.backgroundImage = `url(${this.previewUrl})`
  480. this.backgroundFailed = false
  481. return
  482. }
  483. // We don't have this preview cached or it expired, requesting it
  484. this.debounceGetPreview()
  485. },
  486. fetchAndApplyPreview() {
  487. // Ignore if no preview
  488. if (!this.previewUrl) {
  489. return
  490. }
  491. // If any image is being processed, reset it
  492. if (this.previewPromise) {
  493. this.clearImg()
  494. }
  495. // Store the promise to be able to cancel it
  496. this.previewPromise = new CancelablePromise((resolve, reject, onCancel) => {
  497. const img = new Image()
  498. // If active, load the preview with higher priority
  499. img.fetchpriority = this.active ? 'high' : 'auto'
  500. img.onload = () => {
  501. this.backgroundImage = `url(${this.previewUrl})`
  502. this.backgroundFailed = false
  503. resolve(img)
  504. }
  505. img.onerror = () => {
  506. this.backgroundFailed = true
  507. reject(img)
  508. }
  509. img.src = this.previewUrl
  510. // Image loading has been canceled
  511. onCancel(() => {
  512. img.onerror = null
  513. img.onload = null
  514. img.src = ''
  515. })
  516. })
  517. },
  518. resetState() {
  519. // Reset loading state
  520. this.loading = ''
  521. // Reset the preview
  522. this.clearImg()
  523. // Close menu
  524. this.openedMenu = false
  525. },
  526. clearImg() {
  527. this.backgroundImage = ''
  528. this.backgroundFailed = false
  529. if (this.previewPromise) {
  530. this.previewPromise.cancel()
  531. this.previewPromise = null
  532. }
  533. },
  534. async onActionClick(action) {
  535. const displayName = action.displayName([this.source], this.currentView)
  536. try {
  537. // Set the loading marker
  538. this.loading = action.id
  539. Vue.set(this.source, '_loading', true)
  540. const success = await action.exec(this.source, this.currentView, this.dir)
  541. // If the action returns null, we stay silent
  542. if (success === null) {
  543. return
  544. }
  545. if (success) {
  546. showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
  547. return
  548. }
  549. showError(this.t('files', '"{displayName}" action failed', { displayName }))
  550. } catch (e) {
  551. logger.error('Error while executing action', { action, e })
  552. showError(this.t('files', '"{displayName}" action failed', { displayName }))
  553. } finally {
  554. // Reset the loading marker
  555. this.loading = ''
  556. Vue.set(this.source, '_loading', false)
  557. }
  558. },
  559. execDefaultAction(event) {
  560. if (this.enabledDefaultActions.length > 0) {
  561. event.preventDefault()
  562. event.stopPropagation()
  563. // Execute the first default action if any
  564. this.enabledDefaultActions[0].exec(this.source, this.currentView, this.dir)
  565. }
  566. },
  567. openDetailsIfAvailable(event) {
  568. const detailsAction = this.enabledActions.find(action => action.id === ACTION_DETAILS)
  569. if (detailsAction) {
  570. event.preventDefault()
  571. event.stopPropagation()
  572. detailsAction.exec(this.source, this.currentView)
  573. }
  574. },
  575. onSelectionChange(selection) {
  576. const newSelectedIndex = this.index
  577. const lastSelectedIndex = this.selectionStore.lastSelectedIndex
  578. // Get the last selected and select all files in between
  579. if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
  580. const isAlreadySelected = this.selectedFiles.includes(this.fileid)
  581. const start = Math.min(newSelectedIndex, lastSelectedIndex)
  582. const end = Math.max(lastSelectedIndex, newSelectedIndex)
  583. const lastSelection = this.selectionStore.lastSelection
  584. const filesToSelect = this.nodes
  585. .map(file => file.fileid?.toString?.())
  586. .slice(start, end + 1)
  587. // If already selected, update the new selection _without_ the current file
  588. const selection = [...lastSelection, ...filesToSelect]
  589. .filter(fileId => !isAlreadySelected || fileId !== this.fileid)
  590. logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
  591. // Keep previous lastSelectedIndex to be use for further shift selections
  592. this.selectionStore.set(selection)
  593. return
  594. }
  595. logger.debug('Updating selection', { selection })
  596. this.selectionStore.set(selection)
  597. this.selectionStore.setLastIndex(newSelectedIndex)
  598. },
  599. // Open the actions menu on right click
  600. onRightClick(event) {
  601. // If already opened, fallback to default browser
  602. if (this.openedMenu) {
  603. return
  604. }
  605. // If the clicked row is in the selection, open global menu
  606. const isMoreThanOneSelected = this.selectedFiles.length > 1
  607. this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
  608. // Prevent any browser defaults
  609. event.preventDefault()
  610. event.stopPropagation()
  611. },
  612. /**
  613. * Check if the file name is valid and update the
  614. * input validity using browser's native validation.
  615. * @param event the keyup event
  616. */
  617. checkInputValidity(event: KeyboardEvent) {
  618. const input = event?.target as HTMLInputElement
  619. const newName = this.newName.trim?.() || ''
  620. try {
  621. this.isFileNameValid(newName)
  622. input.setCustomValidity('')
  623. input.title = ''
  624. } catch (e) {
  625. input.setCustomValidity(e.message)
  626. input.title = e.message
  627. } finally {
  628. input.reportValidity()
  629. }
  630. },
  631. isFileNameValid(name) {
  632. const trimmedName = name.trim()
  633. if (trimmedName === '.' || trimmedName === '..') {
  634. throw new Error(this.t('files', '"{name}" is an invalid file name.', { name }))
  635. } else if (trimmedName.length === 0) {
  636. throw new Error(this.t('files', 'File name cannot be empty.'))
  637. } else if (trimmedName.indexOf('/') !== -1) {
  638. throw new Error(this.t('files', '"/" is not allowed inside a file name.'))
  639. } else if (trimmedName.match(OC.config.blacklist_files_regex)) {
  640. throw new Error(this.t('files', '"{name}" is not an allowed filetype.', { name }))
  641. } else if (this.checkIfNodeExists(name)) {
  642. throw new Error(this.t('files', '{newName} already exists.', { newName: name }))
  643. }
  644. return true
  645. },
  646. checkIfNodeExists(name) {
  647. return this.nodes.find(node => node.basename === name && node !== this.source)
  648. },
  649. startRenaming() {
  650. this.checkInputValidity()
  651. this.$nextTick(() => {
  652. const extLength = (this.source.extension || '').length
  653. const length = this.source.basename.length - extLength
  654. const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
  655. if (!input) {
  656. logger.error('Could not find the rename input')
  657. return
  658. }
  659. input.setSelectionRange(0, length)
  660. input.focus()
  661. })
  662. },
  663. stopRenaming() {
  664. if (!this.isRenaming) {
  665. return
  666. }
  667. // Reset the renaming store
  668. this.renamingStore.$reset()
  669. },
  670. // Rename and move the file
  671. async onRename() {
  672. const oldName = this.source.basename
  673. const oldSource = this.source.source
  674. const newName = this.newName.trim?.() || ''
  675. if (newName === '') {
  676. showError(this.t('files', 'Name cannot be empty'))
  677. return
  678. }
  679. if (oldName === newName) {
  680. this.stopRenaming()
  681. return
  682. }
  683. // Checking if already exists
  684. if (this.checkIfNodeExists(newName)) {
  685. showError(this.t('files', 'Another entry with the same name already exists'))
  686. return
  687. }
  688. // Set loading state
  689. this.loading = 'renaming'
  690. Vue.set(this.source, '_loading', true)
  691. // Update node
  692. this.source.rename(newName)
  693. try {
  694. await axios({
  695. method: 'MOVE',
  696. url: oldSource,
  697. headers: {
  698. Destination: encodeURI(this.source.source),
  699. },
  700. })
  701. // Success 🎉
  702. emit('files:node:updated', this.source)
  703. emit('files:node:renamed', this.source)
  704. showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
  705. this.stopRenaming()
  706. this.$nextTick(() => {
  707. this.$refs.basename.focus()
  708. })
  709. } catch (error) {
  710. logger.error('Error while renaming file', { error })
  711. this.source.rename(oldName)
  712. this.$refs.renameInput.focus()
  713. // TODO: 409 means current folder does not exist, redirect ?
  714. if (error?.response?.status === 404) {
  715. showError(this.t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
  716. return
  717. } else if (error?.response?.status === 412) {
  718. showError(this.t('files', 'The name "{newName}"" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.dir }))
  719. return
  720. }
  721. // Unknown error
  722. showError(this.t('files', 'Could not rename "{oldName}"', { oldName }))
  723. } finally {
  724. this.loading = false
  725. Vue.set(this.source, '_loading', false)
  726. }
  727. },
  728. t: translate,
  729. formatFileSize,
  730. },
  731. })
  732. </script>
  733. <style scoped lang='scss'>
  734. /* Hover effect on tbody lines only */
  735. tr {
  736. &:hover,
  737. &:focus,
  738. &:active {
  739. background-color: var(--color-background-dark);
  740. }
  741. }
  742. /* Preview not loaded animation effect */
  743. .files-list__row-icon-preview:not([style*='background']) {
  744. background: var(--color-loading-dark);
  745. // animation: preview-gradient-fade 1.2s ease-in-out infinite;
  746. }
  747. </style>
  748. <style>
  749. /* @keyframes preview-gradient-fade {
  750. 0% {
  751. opacity: 1;
  752. }
  753. 50% {
  754. opacity: 0.5;
  755. }
  756. 100% {
  757. opacity: 1;
  758. }
  759. } */
  760. </style>