FileEntry.vue 26 KB

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