Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. <!--
  2. - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  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. <NcAppContent data-cy-files-content>
  24. <div class="files-list__header">
  25. <!-- Current folder breadcrumbs -->
  26. <BreadCrumbs :path="dir" @reload="fetchContent">
  27. <template #actions>
  28. <!-- Sharing button -->
  29. <NcButton v-if="canShare && filesListWidth >= 512"
  30. :aria-label="shareButtonLabel"
  31. :class="{ 'files-list__header-share-button--shared': shareButtonType }"
  32. :title="shareButtonLabel"
  33. class="files-list__header-share-button"
  34. type="tertiary"
  35. @click="openSharingSidebar">
  36. <template #icon>
  37. <LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" />
  38. <ShareVariantIcon v-else :size="20" />
  39. </template>
  40. </NcButton>
  41. <!-- Disabled upload button -->
  42. <NcButton v-if="!canUpload || isQuotaExceeded"
  43. :aria-label="cantUploadLabel"
  44. :title="cantUploadLabel"
  45. class="files-list__header-upload-button--disabled"
  46. :disabled="true"
  47. type="secondary">
  48. <template #icon>
  49. <PlusIcon :size="20" />
  50. </template>
  51. {{ t('files', 'Add') }}
  52. </NcButton>
  53. <!-- Uploader -->
  54. <UploadPicker v-else-if="currentFolder"
  55. :content="dirContents"
  56. :destination="currentFolder"
  57. :multiple="true"
  58. class="files-list__header-upload-button"
  59. @failed="onUploadFail"
  60. @uploaded="onUpload" />
  61. </template>
  62. </BreadCrumbs>
  63. <NcButton v-if="filesListWidth >= 512"
  64. :aria-label="gridViewButtonLabel"
  65. :title="gridViewButtonLabel"
  66. class="files-list__header-grid-button"
  67. type="tertiary"
  68. @click="toggleGridView">
  69. <template #icon>
  70. <ListViewIcon v-if="userConfig.grid_view" />
  71. <ViewGridIcon v-else />
  72. </template>
  73. </NcButton>
  74. <!-- Secondary loading indicator -->
  75. <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
  76. </div>
  77. <!-- Drag and drop notice -->
  78. <DragAndDropNotice v-if="!loading && canUpload"
  79. :current-folder="currentFolder" />
  80. <!-- Initial loading -->
  81. <NcLoadingIcon v-if="loading && !isRefreshing"
  82. class="files-list__loading-icon"
  83. :size="38"
  84. :name="t('files', 'Loading current folder')" />
  85. <!-- Empty content placeholder -->
  86. <NcEmptyContent v-else-if="!loading && isEmptyDir"
  87. :name="currentView?.emptyTitle || t('files', 'No files in here')"
  88. :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')"
  89. data-cy-files-content-empty>
  90. <template #action>
  91. <NcButton v-if="dir !== '/'"
  92. :aria-label="t('files', 'Go to the previous folder')"
  93. type="primary"
  94. :to="toPreviousDir">
  95. {{ t('files', 'Go back') }}
  96. </NcButton>
  97. </template>
  98. <template #icon>
  99. <NcIconSvgWrapper :svg="currentView.icon" />
  100. </template>
  101. </NcEmptyContent>
  102. <!-- File list -->
  103. <FilesListVirtual v-else
  104. ref="filesListVirtual"
  105. :current-folder="currentFolder"
  106. :current-view="currentView"
  107. :nodes="dirContentsSorted" />
  108. </NcAppContent>
  109. </template>
  110. <script lang="ts">
  111. import type { Route } from 'vue-router'
  112. import type { Upload } from '@nextcloud/upload'
  113. import type { UserConfig } from '../types.ts'
  114. import type { View, ContentsWithRoot } from '@nextcloud/files'
  115. import { emit } from '@nextcloud/event-bus'
  116. import { Folder, Node, Permission } from '@nextcloud/files'
  117. import { getCapabilities } from '@nextcloud/capabilities'
  118. import { join, dirname } from 'path'
  119. import { orderBy } from 'natural-orderby'
  120. import { Parser } from 'xml2js'
  121. import { showError } from '@nextcloud/dialogs'
  122. import { translate, translatePlural } from '@nextcloud/l10n'
  123. import { Type } from '@nextcloud/sharing'
  124. import { UploadPicker } from '@nextcloud/upload'
  125. import { defineComponent } from 'vue'
  126. import LinkIcon from 'vue-material-design-icons/Link.vue'
  127. import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue'
  128. import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
  129. import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
  130. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  131. import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
  132. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  133. import PlusIcon from 'vue-material-design-icons/Plus.vue'
  134. import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue'
  135. import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
  136. import { action as sidebarAction } from '../actions/sidebarAction.ts'
  137. import { useFilesStore } from '../store/files.ts'
  138. import { usePathsStore } from '../store/paths.ts'
  139. import { useSelectionStore } from '../store/selection.ts'
  140. import { useUploaderStore } from '../store/uploader.ts'
  141. import { useUserConfigStore } from '../store/userconfig.ts'
  142. import { useViewConfigStore } from '../store/viewConfig.ts'
  143. import BreadCrumbs from '../components/BreadCrumbs.vue'
  144. import FilesListVirtual from '../components/FilesListVirtual.vue'
  145. import filesListWidthMixin from '../mixins/filesListWidth.ts'
  146. import filesSortingMixin from '../mixins/filesSorting.ts'
  147. import logger from '../logger.js'
  148. import DragAndDropNotice from '../components/DragAndDropNotice.vue'
  149. const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined
  150. export default defineComponent({
  151. name: 'FilesList',
  152. components: {
  153. BreadCrumbs,
  154. DragAndDropNotice,
  155. FilesListVirtual,
  156. LinkIcon,
  157. ListViewIcon,
  158. NcAppContent,
  159. NcButton,
  160. NcEmptyContent,
  161. NcIconSvgWrapper,
  162. NcLoadingIcon,
  163. PlusIcon,
  164. ShareVariantIcon,
  165. UploadPicker,
  166. ViewGridIcon,
  167. },
  168. mixins: [
  169. filesListWidthMixin,
  170. filesSortingMixin,
  171. ],
  172. setup() {
  173. const filesStore = useFilesStore()
  174. const pathsStore = usePathsStore()
  175. const selectionStore = useSelectionStore()
  176. const uploaderStore = useUploaderStore()
  177. const userConfigStore = useUserConfigStore()
  178. const viewConfigStore = useViewConfigStore()
  179. return {
  180. filesStore,
  181. pathsStore,
  182. selectionStore,
  183. uploaderStore,
  184. userConfigStore,
  185. viewConfigStore,
  186. }
  187. },
  188. data() {
  189. return {
  190. loading: true,
  191. promise: null,
  192. Type,
  193. }
  194. },
  195. computed: {
  196. userConfig(): UserConfig {
  197. return this.userConfigStore.userConfig
  198. },
  199. currentView(): View {
  200. return (this.$navigation.active
  201. || this.$navigation.views.find(view => view.id === 'files')) as View
  202. },
  203. /**
  204. * The current directory query.
  205. */
  206. dir(): string {
  207. // Remove any trailing slash but leave root slash
  208. return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
  209. },
  210. /**
  211. * The current folder.
  212. */
  213. currentFolder(): Folder|undefined {
  214. if (!this.currentView?.id) {
  215. return
  216. }
  217. if (this.dir === '/') {
  218. return this.filesStore.getRoot(this.currentView.id)
  219. }
  220. const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
  221. return this.filesStore.getNode(fileId)
  222. },
  223. /**
  224. * Directory content sorting parameters
  225. * Provided by an extra computed property for caching
  226. */
  227. sortingParameters() {
  228. const identifiers = [
  229. // 1: Sort favorites first if enabled
  230. ...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []),
  231. // 2: Sort folders first if sorting by name
  232. ...(this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : []),
  233. // 3: Use sorting mode if NOT basename (to be able to use displayName too)
  234. ...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []),
  235. // 4: Use displayName if available, fallback to name
  236. v => v.attributes?.displayName || v.basename,
  237. // 5: Finally, use basename if all previous sorting methods failed
  238. v => v.basename,
  239. ]
  240. const orders = [
  241. // (for 1): always sort favorites before normal files
  242. ...(this.userConfig.sort_favorites_first ? ['asc'] : []),
  243. // (for 2): always sort folders before files
  244. ...(this.sortingMode === 'basename' ? ['asc'] : []),
  245. // (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower
  246. ...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []),
  247. // (also for 3 so make sure not to conflict with 2 and 3)
  248. ...(this.sortingMode !== 'mtime' && this.sortingMode !== 'basename' ? [this.isAscSorting ? 'asc' : 'desc'] : []),
  249. // for 4: use configured sorting direction
  250. this.isAscSorting ? 'asc' : 'desc',
  251. // for 5: use configured sorting direction
  252. this.isAscSorting ? 'asc' : 'desc',
  253. ]
  254. return [identifiers, orders] as const
  255. },
  256. /**
  257. * The current directory contents.
  258. */
  259. dirContentsSorted(): Node[] {
  260. if (!this.currentView) {
  261. return []
  262. }
  263. const customColumn = (this.currentView?.columns || [])
  264. .find(column => column.id === this.sortingMode)
  265. // Custom column must provide their own sorting methods
  266. if (customColumn?.sort && typeof customColumn.sort === 'function') {
  267. const results = [...this.dirContents].sort(customColumn.sort)
  268. return this.isAscSorting ? results : results.reverse()
  269. }
  270. return orderBy(
  271. [...this.dirContents],
  272. ...this.sortingParameters,
  273. )
  274. },
  275. dirContents(): Node[] {
  276. const showHidden = this.userConfigStore?.userConfig.show_hidden
  277. return (this.currentFolder?._children || [])
  278. .map(this.getNode)
  279. .filter(file => {
  280. if (!showHidden) {
  281. return file?.attributes?.hidden !== true && !file?.basename.startsWith('.')
  282. }
  283. return true
  284. })
  285. },
  286. /**
  287. * The current directory is empty.
  288. */
  289. isEmptyDir(): boolean {
  290. return this.dirContents.length === 0
  291. },
  292. /**
  293. * We are refreshing the current directory.
  294. * But we already have a cached version of it
  295. * that is not empty.
  296. */
  297. isRefreshing(): boolean {
  298. return this.currentFolder !== undefined
  299. && !this.isEmptyDir
  300. && this.loading
  301. },
  302. /**
  303. * Route to the previous directory.
  304. */
  305. toPreviousDir(): Route {
  306. const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
  307. return { ...this.$route, query: { dir } }
  308. },
  309. shareAttributes(): number[]|undefined {
  310. if (!this.currentFolder?.attributes?.['share-types']) {
  311. return undefined
  312. }
  313. return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[]
  314. },
  315. shareButtonLabel() {
  316. if (!this.shareAttributes) {
  317. return this.t('files', 'Share')
  318. }
  319. if (this.shareButtonType === Type.SHARE_TYPE_LINK) {
  320. return this.t('files', 'Shared by link')
  321. }
  322. return this.t('files', 'Shared')
  323. },
  324. shareButtonType(): Type|null {
  325. if (!this.shareAttributes) {
  326. return null
  327. }
  328. // If all types are links, show the link icon
  329. if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) {
  330. return Type.SHARE_TYPE_LINK
  331. }
  332. return Type.SHARE_TYPE_USER
  333. },
  334. gridViewButtonLabel() {
  335. return this.userConfig.grid_view
  336. ? this.t('files', 'Switch to list view')
  337. : this.t('files', 'Switch to grid view')
  338. },
  339. /**
  340. * Check if the current folder has create permissions
  341. */
  342. canUpload() {
  343. return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
  344. },
  345. isQuotaExceeded() {
  346. return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
  347. },
  348. cantUploadLabel() {
  349. if (this.isQuotaExceeded) {
  350. return this.t('files', 'Your have used your space quota and cannot upload files anymore')
  351. }
  352. return this.t('files', 'You don’t have permission to upload or create files here')
  353. },
  354. /**
  355. * Check if current folder has share permissions
  356. */
  357. canShare() {
  358. return isSharingEnabled
  359. && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0
  360. },
  361. },
  362. watch: {
  363. currentView(newView, oldView) {
  364. if (newView?.id === oldView?.id) {
  365. return
  366. }
  367. logger.debug('View changed', { newView, oldView })
  368. this.selectionStore.reset()
  369. this.fetchContent()
  370. },
  371. dir(newDir, oldDir) {
  372. logger.debug('Directory changed', { newDir, oldDir })
  373. // TODO: preserve selection on browsing?
  374. this.selectionStore.reset()
  375. this.fetchContent()
  376. // Scroll to top, force virtual scroller to re-render
  377. if (this.$refs?.filesListVirtual?.$el) {
  378. this.$refs.filesListVirtual.$el.scrollTop = 0
  379. }
  380. },
  381. dirContents(contents) {
  382. logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents })
  383. emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents })
  384. },
  385. },
  386. mounted() {
  387. this.fetchContent()
  388. },
  389. methods: {
  390. async fetchContent() {
  391. this.loading = true
  392. const dir = this.dir
  393. const currentView = this.currentView
  394. if (!currentView) {
  395. logger.debug('The current view doesn\'t exists or is not ready.', { currentView })
  396. return
  397. }
  398. // If we have a cancellable promise ongoing, cancel it
  399. if (typeof this.promise?.cancel === 'function') {
  400. this.promise.cancel()
  401. logger.debug('Cancelled previous ongoing fetch')
  402. }
  403. // Fetch the current dir contents
  404. this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot>
  405. try {
  406. const { folder, contents } = await this.promise
  407. logger.debug('Fetched contents', { dir, folder, contents })
  408. // Update store
  409. this.filesStore.updateNodes(contents)
  410. // Define current directory children
  411. // TODO: make it more official
  412. this.$set(folder, '_children', contents.map(node => node.fileid))
  413. // If we're in the root dir, define the root
  414. if (dir === '/') {
  415. this.filesStore.setRoot({ service: currentView.id, root: folder })
  416. } else {
  417. // Otherwise, add the folder to the store
  418. if (folder.fileid) {
  419. this.filesStore.updateNodes([folder])
  420. this.pathsStore.addPath({ service: currentView.id, fileid: folder.fileid, path: dir })
  421. } else {
  422. // If we're here, the view API messed up
  423. logger.error('Invalid root folder returned', { dir, folder, currentView })
  424. }
  425. }
  426. // Update paths store
  427. const folders = contents.filter(node => node.type === 'folder')
  428. folders.forEach(node => {
  429. this.pathsStore.addPath({ service: currentView.id, fileid: node.fileid, path: join(dir, node.basename) })
  430. })
  431. } catch (error) {
  432. logger.error('Error while fetching content', { error })
  433. } finally {
  434. this.loading = false
  435. }
  436. },
  437. /**
  438. * Get a cached note from the store
  439. *
  440. * @param {number} fileId the file id to get
  441. * @return {Folder|File}
  442. */
  443. getNode(fileId) {
  444. return this.filesStore.getNode(fileId)
  445. },
  446. /**
  447. * The upload manager have finished handling the queue
  448. * @param {Upload} upload the uploaded data
  449. */
  450. onUpload(upload: Upload) {
  451. // Let's only refresh the current Folder
  452. // Navigating to a different folder will refresh it anyway
  453. const destinationSource = dirname(upload.source)
  454. const needsRefresh = destinationSource === this.currentFolder?.source
  455. // TODO: fetch uploaded files data only
  456. // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid
  457. if (needsRefresh) {
  458. // fetchContent will cancel the previous ongoing promise
  459. this.fetchContent()
  460. }
  461. },
  462. async onUploadFail(upload: Upload) {
  463. const status = upload.response?.status || 0
  464. // Check known status codes
  465. if (status === 507) {
  466. showError(this.t('files', 'Not enough free space'))
  467. return
  468. } else if (status === 404 || status === 409) {
  469. showError(this.t('files', 'Target folder does not exist any more'))
  470. return
  471. } else if (status === 403) {
  472. showError(this.t('files', 'Operation is blocked by access control'))
  473. return
  474. } else if (status !== 0) {
  475. showError(this.t('files', 'Error when assembling chunks, status code {status}', { status }))
  476. return
  477. }
  478. // Else we try to parse the response error message
  479. try {
  480. const parser = new Parser({ trim: true, explicitRoot: false })
  481. const response = await parser.parseStringPromise(upload.response?.data)
  482. const message = response['s:message'][0] as string
  483. if (typeof message === 'string' && message.trim() !== '') {
  484. // Unfortunatly, the server message is not translated
  485. showError(this.t('files', 'Error during upload: {message}', { message }))
  486. return
  487. }
  488. } catch (error) {}
  489. showError(this.t('files', 'Unknown error during upload'))
  490. },
  491. openSharingSidebar() {
  492. if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
  493. window.OCA.Files.Sidebar.setActiveTab('sharing')
  494. }
  495. sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path)
  496. },
  497. toggleGridView() {
  498. this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
  499. },
  500. t: translate,
  501. n: translatePlural,
  502. },
  503. })
  504. </script>
  505. <style scoped lang="scss">
  506. .app-content {
  507. // Virtual list needs to be full height and is scrollable
  508. display: flex;
  509. overflow: hidden;
  510. flex-direction: column;
  511. max-height: 100%;
  512. position: relative;
  513. }
  514. $margin: 4px;
  515. $navigationToggleSize: 50px;
  516. .files-list {
  517. &__header {
  518. display: flex;
  519. align-items: center;
  520. // Do not grow or shrink (vertically)
  521. flex: 0 0;
  522. // Align with the navigation toggle icon
  523. margin: $margin $margin $margin $navigationToggleSize;
  524. max-width: 100%;
  525. > * {
  526. // Do not grow or shrink (horizontally)
  527. // Only the breadcrumbs shrinks
  528. flex: 0 0;
  529. }
  530. &-share-button {
  531. opacity: .3;
  532. &--shared {
  533. opacity: 1;
  534. }
  535. }
  536. }
  537. &__refresh-icon {
  538. flex: 0 0 44px;
  539. width: 44px;
  540. height: 44px;
  541. }
  542. &__loading-icon {
  543. margin: auto;
  544. }
  545. }
  546. </style>