You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

FilesList.vue 8.0KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. <!--
  2. - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
  3. -
  4. - @author Gary Kim <gary@garykim.dev>
  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 v-show="!currentView?.legacy"
  24. :class="{'app-content--hidden': currentView?.legacy}"
  25. data-cy-files-content>
  26. <div class="files-list__header">
  27. <!-- Current folder breadcrumbs -->
  28. <BreadCrumbs :path="dir" />
  29. <!-- Secondary loading indicator -->
  30. <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
  31. </div>
  32. <!-- Initial loading -->
  33. <NcLoadingIcon v-if="loading && !isRefreshing"
  34. class="files-list__loading-icon"
  35. :size="38"
  36. :title="t('files', 'Loading current folder')" />
  37. <!-- Empty content placeholder -->
  38. <NcEmptyContent v-else-if="!loading && isEmptyDir"
  39. :title="t('files', 'No files in here')"
  40. :description="t('files', 'No files or folders have been deleted yet')"
  41. data-cy-files-content-empty>
  42. <template #action>
  43. <NcButton v-if="dir !== '/'"
  44. aria-label="t('files', 'Go to the previous folder')"
  45. type="primary"
  46. :to="toPreviousDir">
  47. {{ t('files', 'Go back') }}
  48. </NcButton>
  49. </template>
  50. <template #icon>
  51. <TrashCan />
  52. </template>
  53. </NcEmptyContent>
  54. <!-- File list -->
  55. <FilesListVirtual v-else :nodes="dirContents" />
  56. </NcAppContent>
  57. </template>
  58. <script lang="ts">
  59. import { Folder } from '@nextcloud/files'
  60. import { translate } from '@nextcloud/l10n'
  61. import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
  62. import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
  63. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  64. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  65. import TrashCan from 'vue-material-design-icons/TrashCan.vue'
  66. import BreadCrumbs from '../components/BreadCrumbs.vue'
  67. import logger from '../logger.js'
  68. import Navigation from '../services/Navigation'
  69. import FilesListVirtual from '../components/FilesListVirtual.vue'
  70. import { ContentsWithRoot } from '../services/Navigation'
  71. import { join } from 'path'
  72. export default {
  73. name: 'FilesList',
  74. components: {
  75. BreadCrumbs,
  76. FilesListVirtual,
  77. NcAppContent,
  78. NcButton,
  79. NcEmptyContent,
  80. NcLoadingIcon,
  81. TrashCan,
  82. },
  83. props: {
  84. // eslint-disable-next-line vue/prop-name-casing
  85. Navigation: {
  86. type: Navigation,
  87. required: true,
  88. },
  89. },
  90. data() {
  91. return {
  92. loading: true,
  93. promise: null,
  94. }
  95. },
  96. computed: {
  97. currentViewId() {
  98. return this.$route.params.view || 'files'
  99. },
  100. /** @return {Navigation} */
  101. currentView() {
  102. return this.views.find(view => view.id === this.currentViewId)
  103. },
  104. /** @return {Navigation[]} */
  105. views() {
  106. return this.Navigation.views
  107. },
  108. /**
  109. * The current directory query.
  110. * @return {string}
  111. */
  112. dir() {
  113. // Remove any trailing slash but leave root slash
  114. return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
  115. },
  116. /**
  117. * The current folder.
  118. * @return {Folder|undefined}
  119. */
  120. currentFolder() {
  121. if (this.dir === '/') {
  122. return this.$store.getters['files/getRoot'](this.currentViewId)
  123. }
  124. const fileId = this.$store.getters['paths/getPath'](this.currentViewId, this.dir)
  125. return this.$store.getters['files/getNode'](fileId)
  126. },
  127. /**
  128. * The current directory contents.
  129. * @return {Node[]}
  130. */
  131. dirContents() {
  132. return (this.currentFolder?.children || []).map(this.getNode)
  133. },
  134. /**
  135. * The current directory is empty.
  136. */
  137. isEmptyDir() {
  138. return this.dirContents.length === 0
  139. },
  140. /**
  141. * We are refreshing the current directory.
  142. * But we already have a cached version of it
  143. * that is not empty.
  144. */
  145. isRefreshing() {
  146. return this.currentFolder !== undefined
  147. && !this.isEmptyDir
  148. && this.loading
  149. },
  150. /**
  151. * Route to the previous directory.
  152. */
  153. toPreviousDir() {
  154. const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
  155. return { ...this.$route, query: { dir } }
  156. },
  157. },
  158. watch: {
  159. currentView(newView, oldView) {
  160. if (newView?.id === oldView?.id) {
  161. return
  162. }
  163. logger.debug('View changed', { newView, oldView })
  164. this.$store.dispatch('selection/reset')
  165. this.fetchContent()
  166. },
  167. dir(newDir, oldDir) {
  168. logger.debug('Directory changed', { newDir, oldDir })
  169. // TODO: preserve selection on browsing?
  170. this.$store.dispatch('selection/reset')
  171. this.fetchContent()
  172. },
  173. paths(paths) {
  174. logger.debug('Paths changed', { paths })
  175. },
  176. currentFolder(currentFolder) {
  177. logger.debug('currentFolder changed', { currentFolder })
  178. },
  179. },
  180. methods: {
  181. async fetchContent() {
  182. if (this.currentView?.legacy) {
  183. return
  184. }
  185. this.loading = true
  186. const dir = this.dir
  187. const currentView = this.currentView
  188. // If we have a cancellable promise ongoing, cancel it
  189. if (typeof this.promise?.cancel === 'function') {
  190. this.promise.cancel()
  191. logger.debug('Cancelled previous ongoing fetch')
  192. }
  193. // Fetch the current dir contents
  194. /** @type {Promise<ContentsWithRoot>} */
  195. this.promise = currentView.getContents(dir)
  196. try {
  197. const { folder, contents } = await this.promise
  198. logger.debug('Fetched contents', { dir, folder, contents })
  199. // Update store
  200. this.$store.dispatch('files/addNodes', contents)
  201. // Define current directory children
  202. folder.children = contents.map(node => node.attributes.fileid)
  203. // If we're in the root dir, define the root
  204. if (dir === '/') {
  205. console.debug('files', 'Setting root', { service: currentView.id, folder })
  206. this.$store.dispatch('files/setRoot', { service: currentView.id, root: folder })
  207. } else
  208. // Otherwise, add the folder to the store
  209. if (folder.attributes.fileid) {
  210. this.$store.dispatch('files/addNodes', [folder])
  211. this.$store.dispatch('paths/addPath', { service: currentView.id, fileid: folder.attributes.fileid, path: dir })
  212. } else {
  213. // If we're here, the view API messed up
  214. logger.error('Invalid root folder returned', { dir, folder, currentView })
  215. }
  216. // Update paths store
  217. const folders = contents.filter(node => node.type === 'folder')
  218. folders.forEach(node => {
  219. this.$store.dispatch('paths/addPath', { service: currentView.id, fileid: node.attributes.fileid, path: join(dir, node.basename) })
  220. })
  221. } catch (error) {
  222. logger.error('Error while fetching content', { error })
  223. } finally {
  224. this.loading = false
  225. }
  226. },
  227. /**
  228. * Get a cached note from the store
  229. *
  230. * @param {number} fileId the file id to get
  231. * @return {Folder|File}
  232. */
  233. getNode(fileId) {
  234. return this.$store.getters['files/getNode'](fileId)
  235. },
  236. t: translate,
  237. },
  238. }
  239. </script>
  240. <style scoped lang="scss">
  241. .app-content {
  242. // Virtual list needs to be full height and is scrollable
  243. display: flex;
  244. overflow: hidden;
  245. flex-direction: column;
  246. max-height: 100%;
  247. // TODO: remove after all legacy views are migrated
  248. // Hides the legacy app-content if shown view is not legacy
  249. &:not(&--hidden)::v-deep + #app-content {
  250. display: none;
  251. }
  252. }
  253. $margin: 4px;
  254. $navigationToggleSize: 50px;
  255. .files-list {
  256. &__header {
  257. display: flex;
  258. align-content: center;
  259. // Do not grow or shrink (vertically)
  260. flex: 0 0;
  261. // Align with the navigation toggle icon
  262. margin: $margin $margin $margin $navigationToggleSize;
  263. > * {
  264. // Do not grow or shrink (horizontally)
  265. // Only the breadcrumbs shrinks
  266. flex: 0 0;
  267. }
  268. }
  269. &__refresh-icon {
  270. flex: 0 0 44px;
  271. width: 44px;
  272. height: 44px;
  273. }
  274. &__loading-icon {
  275. margin: auto;
  276. }
  277. }
  278. </style>