Merge pull request #39808 from nextcloud/feat/f2v/files

This commit is contained in:
John Molakvoæ 2023-08-17 20:00:15 +02:00 committed by GitHub
commit a8fc62f0b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
129 changed files with 1956 additions and 2591 deletions

View file

@ -1591,126 +1591,6 @@ trigger:
- pull_request
- push
---
kind: pipeline
name: acceptance-app-files
steps:
- name: submodules
image: ghcr.io/nextcloud/continuous-integration-alpine-git:latest
commands:
- git submodule update --init
- name: acceptance-app-files
image: ghcr.io/nextcloud/continuous-integration-acceptance-php8.0:latest
commands:
- tests/acceptance/run-local.sh --timeout-multiplier 10 --nextcloud-server-domain acceptance-app-files --selenium-server selenium:4444 allow-git-repository-modifications features/app-files.feature
services:
- name: selenium
image: ghcr.io/nextcloud/continuous-integration-selenium:3.141.59
environment:
# Reduce default log level for Selenium server (INFO) as it is too
# verbose.
JAVA_OPTS: -Dselenium.LOGGER.level=WARNING
trigger:
branch:
- master
- stable*
event:
- pull_request
- push
---
kind: pipeline
name: acceptance-app-files-sharing
steps:
- name: submodules
image: ghcr.io/nextcloud/continuous-integration-alpine-git:latest
commands:
- git submodule update --init
- name: acceptance-app-files-sharing
image: ghcr.io/nextcloud/continuous-integration-acceptance-php8.0:latest
commands:
- tests/acceptance/run-local.sh --timeout-multiplier 10 --nextcloud-server-domain acceptance-app-files-sharing --selenium-server selenium:4444 allow-git-repository-modifications features/app-files-sharing.feature
services:
- name: selenium
image: ghcr.io/nextcloud/continuous-integration-selenium:3.141.59
environment:
# Reduce default log level for Selenium server (INFO) as it is too
# verbose.
JAVA_OPTS: -Dselenium.LOGGER.level=WARNING
trigger:
branch:
- master
- stable*
event:
- pull_request
- push
---
kind: pipeline
name: acceptance-app-files-sharing-link
steps:
- name: submodules
image: ghcr.io/nextcloud/continuous-integration-alpine-git:latest
commands:
- git submodule update --init
- name: acceptance-app-files-sharing-link
image: ghcr.io/nextcloud/continuous-integration-acceptance-php8.0:latest
commands:
- tests/acceptance/run-local.sh --timeout-multiplier 10 --nextcloud-server-domain acceptance-app-files-sharing-link --selenium-server selenium:4444 allow-git-repository-modifications features/app-files-sharing-link.feature
services:
- name: selenium
image: ghcr.io/nextcloud/continuous-integration-selenium:3.141.59
environment:
# Reduce default log level for Selenium server (INFO) as it is too
# verbose.
JAVA_OPTS: -Dselenium.LOGGER.level=WARNING
trigger:
branch:
- master
- stable*
event:
- pull_request
- push
---
kind: pipeline
name: acceptance-app-files-tags
steps:
- name: submodules
image: ghcr.io/nextcloud/continuous-integration-alpine-git:latest
commands:
- git submodule update --init
- name: acceptance-app-files-tags
image: ghcr.io/nextcloud/continuous-integration-acceptance-php8.0:latest
commands:
- tests/acceptance/run-local.sh --timeout-multiplier 10 --nextcloud-server-domain acceptance-app-files-tags --selenium-server selenium:4444 allow-git-repository-modifications features/app-files-tags.feature
services:
- name: selenium
image: ghcr.io/nextcloud/continuous-integration-selenium:3.141.59
environment:
# Reduce default log level for Selenium server (INFO) as it is too
# verbose.
JAVA_OPTS: -Dselenium.LOGGER.level=WARNING
trigger:
branch:
- master
- stable*
event:
- pull_request
- push
---
kind: pipeline
name: acceptance-header

View file

@ -24,3 +24,6 @@ import '@testing-library/jest-dom'
// Mock `window.location` with Jest spies and extend expect
import 'jest-location-mock'
// Mock `window.fetch` with Jest
import 'jest-fetch-mock'

View file

@ -139,16 +139,14 @@ $application->registerRoutes(
'verb' => 'GET'
],
[
'name' => 'view#index',
'name' => 'view#indexView',
'url' => '/{view}',
'verb' => 'GET',
'postfix' => 'view',
],
[
'name' => 'view#index',
'name' => 'view#indexViewFileid',
'url' => '/{view}/{fileid}',
'verb' => 'GET',
'postfix' => 'fileid',
],
],
'ocs' => [

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["files.scss","../../../core/css/functions.scss"],"names":[],"mappings":"AAWA,SAEC,YACA,YACA,qBACA,WAED,oEACA,8BACA,kDAEC,+CAED,0BACC,oDAGD,mBACC,kBACA,aACA,SACA,4CACC,iBACA,YACA,SACA,oDACA,8CACA,aACA,iBAIF,gBACC,aAGD,OACC,iBACA,YACA,aACA,aACA,mBAGD,6EAGC,yBACA,gCAID,kBACC,kBACA,WACA,gBACA,cACA,sBAEA,6CACC,aAGD,wBACC,wBACA,gBAEA,SAEA,WACA,cACA,0DAMD,wBACC,cACA,WAEA,mGAEC,8CAEA,6KACC,oCAKF,8DACC,oBAKH,yBACC,aAID,uCACC,cACA,WAGD,wBAGC,yBAEA,qBAGD,6FACC,+DAGD,iCACC,yDAGD,kFACC,0CAGD,4EACC,+DAID,gBC9EC,yCDiFD,iBCjFC,yCDoFD,oBCpFC,0CDuFD,qGCvFC,wCD6FD,0BC7FC,yCDgGD,2BChGC,2CDmGD,mBCnGC,yCDsGD,2BCtGC,4CDyGD,2BCzGC,0CD4GD,4BC5GC,4CDgHD,4CACC,WAGD,iCACC,WACA,YACA,eACA,SACA,eAGD,wCACC,aAGD,0CACC,WAGD,2BACC,YAED,4KAKC,+CAED,wMAKC,oDAGD,qCAEA,yDACC,oCAED,kCACC,iCACA,8BACA,4BACA,yBACA,mBAED,wGAIC,UACA,oCAGD,oBACC,oCAED,uBACC,6BAED,sBACC,cACA,aACA,YACA,sBACA,2BACA,sBACA,oCACC,kBAGF,kCACC,qBACA,mBAED,2BACC,eACA,iBAGD,uCACC,cAGD,yBACC,WACA,WACA,gBACA,qBACA,2BACA,WAED,wJAIC,kBAED,2CACC,eAED,4EAEC,mBAGD,kBAEC,4CACA,gBACA,mBAED,SACC,eACA,kBACA,+BACA,4BAED,qBACC,kBACA,aACA,UAGD,uBACC,kBACA,YAGD,0BACC,gBAED,uCACC,iBAED,8EAEC,2BACA,sBACA,kBAEA,gBAGD,qMAQC,gBACA,qPACC,MAIF,2BACC,0DACA,iBAGD,sDACC,iBAGD,+BACC,kBACA,aAED,kCACC,aAGD,0DAGC,WACA,kBAED,kDAEC,aACA,kBACA,2BACA,sBACA,YACA,iBACA,UAED,qCAEC,QACA,eACA,eACA,YAGA,8DACC,WAED,mEACC,WAGF,6BACC,qBACA,WACA,YACA,wBACA,2BACA,4BACA,gBACA,eACA,mCACA,eACA,kBACA,UAED,oCACC,eAID,2CACC,qCAGD,iDACC,qBACA,4BACA,YAED,uBACC,iBACA,kBACA,SAGD,6IACA,8FAEA,wCACC,kBACA,gBACA,uBACA,YAKA,kBACC,YACA,4BACC,QACA,YACA,aACA,gBACA,mBACA,uBACA,YACA,WACA,mBAID,+BACC,iBACA,aACA,uBACA,mBACA,kCACA,gBAEA,iDACC,iBACA,iBACA,wCACA,iCACA,oCACA,uBACA,mBACA,gBACA,uBACA,iBACA,kBAEA,uDACC,iBACA,sBAID,mEACC,gBAOL,iJAEC,wBAGD,mCACC,iCACA,8BACA,4BACA,yBAED,4BACC,WAGD,2CACC,uBACA,gBACA,kBACA,mBAKD,8BACC,kBACA,mBAEA,iBACA,OACA,SACA,YACA,cAEA,iBACA,eAEA,iBACA,oCACA,uBACA,mBAGD,mBACC,UAID,6DACC,WACA,eAID,iRAIC,UAID,0EACC,WAMA,wEACC,aAGD,oGACC,+CACA,wCACA,wBACA,yDACA,aAIF,oGAEC,mBAGD,+BACC,kBACA,WACA,eACA,gBACA,wJAGD,wFAEC,kBACA,UACA,YAGD,yCACC,qBACA,WAED,8CACC,kBACA,cACA,SACA,WACA,iBACA,kBACA,wDAEC,8CACA,8CACA,oBAEA,WACA,YACA,aACA,qBACA,uBAGF,8DACC,+CAGD,iDAGA,aACC,WAGD,iCACC,kBAID,mDAEC,gBAID,oCACC,qBACA,0BAGD,8EACC,0BAOA,kCACC,eAGD,sEACC,eAGD,sCACC,gBAIF,aACC,YACA,WACA,2BAKA,0EACC,wCAKF,iBACI,kBACA,qBACA,sBAEJ,wBACI,aAEJ,mBACC,eACA,iBACA,iBAGD,0BACC,aAED,uBACC,kBACA,2BACA,mBAGD,8CACC,gBAIA,8BACC,eACA,iBACA,iBACA,WACA,2CACC,kBACA,0FAGC,kBACA,cACA,SACA,UACA,WACA,gBAED,mDACC,qBACA,sBAGF,0CACC,iBACA,oBACA,kBACA,mBAGA,oGACC,WAID,qIAEC,WAED,uDACC,WACA,0HACC,WAIH,wEACC,UAED,oCACC,+CACA,wCAGF,uGACC,WAED,wDACC,UAKF,4EACC,qBACA,eACA,gBACA,uBACA,sBACA,gBAGD,2CACC,yBAGD,yCACC,UAGD,kNAKC,UAGD,qCACC,gBAGD,0FAEC,WAGD,mDACC,eAGD,SACC,oCAGA,aAED,wCACC,WAEA,mBAKD,sBACC,aAED,2DAIC,+BAED,YACC,mBACA,mBACA,iBAED,wBACC,UAED,YACC,qBAGD,iBACC,WACA,aAED,6BACC,kBACA,mBACA,YAGA,gBAED,yBACC,kBAED,MACC,WACA,kBACA,MACA,OACA,QACA,SACA,8CACA,sCACA,wBACA,WACA,yBACA,8BACA,4BACA,6BACA,iCAED,kBACC,UAGD,aACC,gBACA,SACA,sBACA,eACA,gBACA,aAGA,oBACC,qBAKF,gBACC,sBACA,wBACA,gBACA,YACA,UACA,SACA,0DACA,WACA,yBACA,sBACA,qBACA,iBACA,aACA,MACA,kBAKE,0IACC,sBACA,qBACA,aACA,YACA,WACA,YACA,mBACA,uBAED,oFACC,aAQJ,0DACC,OAGD,6KAIC,qBACA,sBACA,0BAMA,sDACC,sBAED,yDACC,uDAIF,iJAGC,aAGD,oJAGC,WACA,YAGD,gCACC,kBACA,YACA,SACA,oDACA,8CACA,aACA,iBAGD,YACC,mBAEA,uBACC,mCAIF,0DAEC,oCAED,qBACC,oCACA,4BACC,2BAIF,cACC,iBACA,kBACA,gBACA,6BACA,cACA,gBACA,YAEA,2BACC,aAGD,kCACC,UACA,kBACA,iBAIF,uBACC,oBACA,YACA,gBACA,+BACA,UACA,YACA,wBACA,sBAEA,6BACC,YAKA,oEACC,0BAIF,kCACC,WACA,mCAWA,kDACC,cACA,4CACA,0DACA,qDACC,WACA,YAMH,+CACC,aACA,+CACA,6BACA,aACA,cAGA,+DACC,cACA,kBACA,aACA,mCAEA,0fAKC,+BAEA,oxDAGC,+CAKH,kDACC,eACA,mBAGC,8EACC,YACA,eACA,kBACA,MAvDQ,MAwDR,OAxDQ,MAyDR,QAxDO,KAyDP,MACA,OACA,WAEA,yFACC,4BACA,6BACA,wBACA,SACA,mCACA,4BACA,2BAKA,wGACC,UACA,UACA,YAKH,uEACC,WACA,SACA,MACA,YAEA,YACA,gBAEA,kBAGD,iEACC,YACA,mCAIA,gBAKA,0BAEA,2EACC,aACA,YACA,iBACA,kBACA,iBACA,UAEA,0FACC,qBACA,kBACA,gBACA,uBACA,mBAED,kFACC,WACA,OACA,eAED,iFACC,WACA,OACA,eAID,sFACC,aAKF,8EACC,aAGD,8EACC,eACA,iBACA,aACA,mBACA,kBACA,QAEA,sFACC,QAxJK,KAyJL,WACA,YACA,aACA,mBACA,uBAGA,wGACC,aAQH,2GACC,yBAEA,6HACC,YACA,kBAIF,6GACC,yBAGD,6GACC,yBAIF,gEACC,iBACA,mCAEA,+EACC,WACA,cACA,YAMH,kHAEC,aAGD,sIAEC,kBACA,SACA,UACA,aACA,WAEA,kJACC,WACA,YACA,oBACA,QAzNO,KA0NP,kKACC,SACA,MA5NM,KA6NN,OA7NM,KAmOT,+DACC,OACA,YACA,aAGA,yFACC,gBACA,uBAMJ,+FACC,cAID,+CACC,aAEA,qEACC,qBACA,cAEA,aAEA,wEACC,iBAEA,iKAEC,aAGD,8EACI,cAQR,aACC,0DACA,YACA,SACA,aACA,WACA,YACA,mCACA,iCACA,YACA,gBAEA,uEAGC,UAGD,oEAEC,mEASF,cACC,eACA,MAOC,uGACC,gBAID,4EACC,WAKF,0BACC,kBACA,QACA,MAKF,gBACC,aAGD,8BACC,gBACA,sBACA,kBACA,kBACA,aACA,eACA,mBAEA,iCACC,WACA,eAGD,6DACC,aACA,YACA","file":"files.css"}
{"version":3,"sourceRoot":"","sources":["files.scss"],"names":[],"mappings":"AAWA,SAEC,YACA,YACA,qBACA,WAED,oEACA,8BACA,kDAEC,+CAED,0BACC,oDAGD,mBACC,kBACA,aACA,SACA,4CACC,iBACA,YACA,SACA,oDACA,8CACA,aACA,iBAIF,gBACC,aAGD,OACC,iBACA,YACA,aACA,aACA,mBAGD,6EAGC,yBACA,gCAID,kBACC,kBACA,WACA,gBACA,cACA,sBAEA,6CACC,aAGD,wBACC,wBACA,gBAEA,SAEA,WACA,cACA,0DAMD,wBACC,cACA,WAEA,mGAEC,8CAEA,6KACC,oCAKF,8DACC,oBAKH,yBACC,aAID,uCACC,cACA,WAGD,wBAGC,yBAEA,qBAGD,6FACC,+DAGD,iCACC,yDAGD,kFACC,0CAGD,4EACC,+DAID,iCACC,WACA,YACA,eACA,SACA,eAGD,wCACC,aAGD,0CACC,WAGD,2BACC,YAED,4KAKC,+CAED,wMAKC,oDAGD,qCAEA,yDACC,oCAED,kCACC,iCACA,8BACA,4BACA,yBACA,mBAED,wGAIC,UACA,oCAGD,oBACC,oCAED,uBACC,6BAED,sBACC,cACA,aACA,YACA,sBACA,2BACA,sBACA,oCACC,kBAGF,kCACC,qBACA,mBAED,2BACC,eACA,iBAGD,uCACC,cAGD,yBACC,WACA,WACA,gBACA,qBACA,2BACA,WAED,wJAIC,kBAED,2CACC,eAED,4EAEC,mBAGD,kBAEC,4CACA,gBACA,mBAED,SACC,eACA,kBACA,+BACA,4BAED,qBACC,kBACA,aACA,UAGD,uBACC,kBACA,YAGD,0BACC,gBAED,uCACC,iBAED,8EAEC,2BACA,sBACA,kBAEA,gBAGD,qMAQC,gBACA,qPACC,MAIF,2BACC,0DACA,iBAGD,sDACC,iBAGD,+BACC,kBACA,aAED,kCACC,aAGD,0DAGC,WACA,kBAED,kDAEC,aACA,kBACA,2BACA,sBACA,YACA,iBACA,UAED,qCAEC,QACA,eACA,eACA,YAGA,8DACC,WAED,mEACC,WAGF,6BACC,qBACA,WACA,YACA,wBACA,2BACA,4BACA,gBACA,eACA,mCACA,eACA,kBACA,UAED,oCACC,eAID,2CACC,qCAGD,iDACC,qBACA,4BACA,YAED,uBACC,iBACA,kBACA,SAGD,6IACA,8FAEA,wCACC,kBACA,gBACA,uBACA,YAKA,kBACC,YACA,4BACC,QACA,YACA,aACA,gBACA,mBACA,uBACA,YACA,WACA,mBAID,+BACC,iBACA,aACA,uBACA,mBACA,kCACA,gBAEA,iDACC,iBACA,iBACA,wCACA,iCACA,oCACA,uBACA,mBACA,gBACA,uBACA,iBACA,kBAEA,uDACC,iBACA,sBAID,mEACC,gBAOL,iJAEC,wBAGD,mCACC,iCACA,8BACA,4BACA,yBAED,4BACC,WAGD,2CACC,uBACA,gBACA,kBACA,mBAKD,8BACC,kBACA,mBAEA,iBACA,OACA,SACA,YACA,cAEA,iBACA,eAEA,iBACA,oCACA,uBACA,mBAGD,mBACC,UAID,6DACC,WACA,eAID,iRAIC,UAID,0EACC,WAMA,wEACC,aAGD,oGACC,+CACA,wCACA,wBACA,yDACA,aAIF,oGAEC,mBAGD,+BACC,kBACA,WACA,eACA,gBACA,wJAGD,wFAEC,kBACA,UACA,YAGD,yCACC,qBACA,WAED,8CACC,kBACA,cACA,SACA,WACA,iBACA,kBACA,wDAEC,8CACA,8CACA,oBAEA,WACA,YACA,aACA,qBACA,uBAGF,8DACC,+CAGD,iDAGA,aACC,WAGD,iCACC,kBAID,mDAEC,gBAID,oCACC,qBACA,0BAGD,8EACC,0BAOA,kCACC,eAGD,sEACC,eAGD,sCACC,gBAIF,aACC,YACA,WACA,2BAKA,0EACC,wCAKF,iBACI,kBACA,qBACA,sBAEJ,wBACI,aAEJ,mBACC,eACA,iBACA,iBAGD,0BACC,aAED,uBACC,kBACA,2BACA,mBAGD,8CACC,gBAIA,8BACC,eACA,iBACA,iBACA,WACA,2CACC,kBACA,0FAGC,kBACA,cACA,SACA,UACA,WACA,gBAED,mDACC,qBACA,sBAGF,0CACC,iBACA,oBACA,kBACA,mBAGA,oGACC,WAID,qIAEC,WAED,uDACC,WACA,0HACC,WAIH,wEACC,UAED,oCACC,+CACA,wCAGF,uGACC,WAED,wDACC,UAKF,4EACC,qBACA,eACA,gBACA,uBACA,sBACA,gBAGD,2CACC,yBAGD,yCACC,UAGD,kNAKC,UAGD,qCACC,gBAGD,0FAEC,WAGD,mDACC,eAGD,SACC,oCAGA,aAED,wCACC,WAEA,mBAKD,sBACC,aAED,2DAIC,+BAED,YACC,mBACA,mBACA,iBAED,wBACC,UAED,YACC,qBAGD,iBACC,WACA,aAED,6BACC,kBACA,mBACA,YAGA,gBAED,yBACC,kBAED,MACC,WACA,kBACA,MACA,OACA,QACA,SACA,8CACA,sCACA,wBACA,WACA,yBACA,8BACA,4BACA,6BACA,iCAED,kBACC,UAGD,aACC,gBACA,SACA,sBACA,eACA,gBACA,aAGA,oBACC,qBAKF,gBACC,sBACA,wBACA,gBACA,YACA,UACA,SACA,0DACA,WACA,yBACA,sBACA,qBACA,iBACA,aACA,MACA,kBAKE,0IACC,sBACA,qBACA,aACA,YACA,WACA,YACA,mBACA,uBAED,oFACC,aAQJ,0DACC,OAGD,6KAIC,qBACA,sBACA,0BAMA,sDACC,sBAED,yDACC,uDAIF,iJAGC,aAGD,oJAGC,WACA,YAGD,gCACC,kBACA,YACA,SACA,oDACA,8CACA,aACA,iBAGD,YACC,mBAEA,uBACC,mCAIF,0DAEC,oCAED,qBACC,oCACA,4BACC,2BAIF,cACC,iBACA,kBACA,gBACA,6BACA,cACA,gBACA,YAEA,2BACC,aAGD,kCACC,UACA,kBACA,iBAIF,uBACC,oBACA,YACA,gBACA,+BACA,UACA,YACA,wBACA,sBAEA,6BACC,YAKA,oEACC,0BAIF,kCACC,WACA,mCAWA,kDACC,cACA,4CACA,0DACA,qDACC,WACA,YAMH,+CACC,aACA,+CACA,6BACA,aACA,cAGA,+DACC,cACA,kBACA,aACA,mCAEA,0fAKC,+BAEA,oxDAGC,+CAKH,kDACC,eACA,mBAGC,8EACC,YACA,eACA,kBACA,MAvDQ,MAwDR,OAxDQ,MAyDR,QAxDO,KAyDP,MACA,OACA,WAEA,yFACC,4BACA,6BACA,wBACA,SACA,mCACA,4BACA,2BAKA,wGACC,UACA,UACA,YAKH,uEACC,WACA,SACA,MACA,YAEA,YACA,gBAEA,kBAGD,iEACC,YACA,mCAIA,gBAKA,0BAEA,2EACC,aACA,YACA,iBACA,kBACA,iBACA,UAEA,0FACC,qBACA,kBACA,gBACA,uBACA,mBAED,kFACC,WACA,OACA,eAED,iFACC,WACA,OACA,eAID,sFACC,aAKF,8EACC,aAGD,8EACC,eACA,iBACA,aACA,mBACA,kBACA,QAEA,sFACC,QAxJK,KAyJL,WACA,YACA,aACA,mBACA,uBAGA,wGACC,aAQH,2GACC,yBAEA,6HACC,YACA,kBAIF,6GACC,yBAGD,6GACC,yBAIF,gEACC,iBACA,mCAEA,+EACC,WACA,cACA,YAMH,kHAEC,aAGD,sIAEC,kBACA,SACA,UACA,aACA,WAEA,kJACC,WACA,YACA,oBACA,QAzNO,KA0NP,kKACC,SACA,MA5NM,KA6NN,OA7NM,KAmOT,+DACC,OACA,YACA,aAGA,yFACC,gBACA,uBAMJ,+FACC,cAID,+CACC,aAEA,qEACC,qBACA,cAEA,aAEA,wEACC,iBAEA,iKAEC,aAGD,8EACI,cAQR,aACC,0DACA,YACA,SACA,aACA,WACA,YACA,mCACA,iCACA,YACA,gBAEA,uEAGC,UAGD,oEAEC,mEASF,cACC,eACA,MAOC,uGACC,gBAID,4EACC,WAKF,0BACC,kBACA,QACA,MAKF,gBACC,aAGD,8BACC,gBACA,sBACA,kBACA,kBACA,aACA,eACA,mBAEA,iCACC,WACA,eAGD,6DACC,aACA,YACA","file":"files.css"}

View file

@ -140,44 +140,6 @@
background-color: var(--color-primary-element-light) !important;
}
/* icons for sidebar */
.nav-icon-files {
@include icon-color('folder', 'files', variables.$color-black);
}
.nav-icon-recent {
@include icon-color('recent', 'files', variables.$color-black);
}
.nav-icon-favorites {
@include icon-color('starred', 'actions', variables.$color-black, 2, true);
}
.nav-icon-sharinginOld,
.nav-icon-sharingoutOld,
.nav-icon-pendingsharesOld,
.nav-icon-shareoverviewOld {
@include icon-color('share', 'files', variables.$color-black);
}
.nav-icon-sharinglinksOld {
@include icon-color('public', 'files', variables.$color-black);
}
.nav-icon-extstoragemounts {
@include icon-color('external', 'files', variables.$color-black);
}
.nav-icon-trashbin {
@include icon-color('delete', 'files', variables.$color-black);
}
.nav-icon-trashbin-starred {
@include icon-color('delete', 'files', #ff0000);
}
.nav-icon-deletedsharesOld {
@include icon-color('unshare', 'files', variables.$color-black);
}
.nav-icon-favorites-starred {
@include icon-color('starred', 'actions', variables.$color-yellow, 2, true);
}
#app-navigation .nav-files a.nav-icon-files {
width: auto;
}
/* button needs overrides due to navigation styles */
#app-navigation .nav-files a.new {
width: 40px;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="mdi-folder" viewBox="0 0 24 24"><path d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" /></svg>

After

Width:  |  Height:  |  Size: 188 B

View file

@ -1 +1 @@
<svg width="32" height="32" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="m3 4c-0.5 0-1 0.5-1 1v22c0 0.52 0.48 1 1 1h26c0.52 0 1-0.482 1-1v-18c0-0.5-0.5-1-1-1h-13l-4-4z" fill="#fff"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" id="mdi-folder" viewBox="0 0 24 24"><path d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" fill="#fff" /></svg>

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 200 B

View file

@ -1 +0,0 @@
<svg width="24px" height="24px" fill="#000000" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H1c-.55 0-1 .45-1 1s.45 1 1 1h22c.55 0 1-.45 1-1s-.45-1-1-1h-3zM5 6h14c.55 0 1 .45 1 1v8c0 .55-.45 1-1 1H5c-.55 0-1-.45-1-1V7c0-.55.45-1 1-1z"/></svg>

Before

Width:  |  Height:  |  Size: 344 B

View file

@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewbox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M6.5 1L6 2H3c-.554 0-1 .446-1 1v1h12V3c0-.554-.446-1-1-1h-3l-.5-1zM3 5l.875 9c.06.55.573 1 1.125 1h6c.552 0 1.064-.45 1.125-1L13 5z"/></svg>

Before

Width:  |  Height:  |  Size: 247 B

View file

@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewbox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8 1 2.4 2.44-3.46 3.5 2.12 2.12 3.5-3.5 2.44 2.44v-7zm-5.25 1.5c-0.68 0-1.25 0.57-1.25 1.25v9.5c0 0.68 0.57 1.25 1.25 1.25h9.5c0.68 0 1.25-0.57 1.25-1.25v-4.25l-1.5-1.5v5.5h-9v-9h5.5l-1.5-1.5z"/></svg>

Before

Width:  |  Height:  |  Size: 309 B

View file

@ -1 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m1.5 2c-0.25 0-0.5 0.25-0.5 0.5v11c0 0.26 0.24 0.5 0.5 0.5h13c0.26 0 0.5-0.241 0.5-0.5v-9c0-0.25-0.25-0.5-0.5-0.5h-6.5l-2-2z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" id="mdi-folder" viewBox="0 0 24 24"><path d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" /></svg>

Before

Width:  |  Height:  |  Size: 240 B

After

Width:  |  Height:  |  Size: 188 B

View file

@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m9.2363 2.166-3.1816 3.1836c-0.7071 0.7072-1.0378 1.6182-0.9883 2.457 0.05 0.8389 0.4333 1.5841 0.9883 2.1387l1.4121-1.416c-0.5672-0.5672-0.5444-1.2192 2e-3 -1.7656l3.1812-3.1817c0.52536-0.52536 1.2507-0.52318 1.772-2e-3 0.48245 0.5556 0.52732 1.2382-4e-3 1.7695l-0.82 0.8203c0.555 0.785 0.645 1.3663 0.593 2.2344l1.641-1.6406c1.2374-1.2374 1.2371-3.3645 0-4.6016-1.236-1.2361-3.342-1.2113-4.5957 4e-3zm0.7071 3.8848-1.4141 1.418h4e-3c0.55 0.55 0.50736 1.2582-4e-3 1.7695l-3.1816 3.1817c-0.696 0.59192-1.2985 0.47105-1.7696 0-0.62636-0.62636-0.5-1.2681 0-1.768l0.85-0.8473c-0.556-0.7835-0.6484-1.365-0.5976-2.2324l-1.666 1.666c-1.2393 1.2393-1.2357 3.36 0 4.5957 1.2353 1.2353 3.362 1.2356 4.5976 0l3.1817-3.182c0.7086-0.7083 1.0396-1.6184 0.9906-2.4586-0.048-0.8401-0.432-1.5864-0.9887-2.1407z"/></svg>

Before

Width:  |  Height:  |  Size: 910 B

View file

@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewbox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="7" fill="none" stroke="#000" stroke-width="2"/><path d="m8 3.5-1 5 3.5 2-2-2z" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/></svg>

Before

Width:  |  Height:  |  Size: 289 B

View file

@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><circle cx="3.5" cy="8" r="2.5"/><circle cx="12.5" cy="12.5" r="2.5"/><circle cx="12.5" cy="3.5" r="2.5"/><path d="m3.5 8 9 4.5m-9-4.5 9-4.5" fill="none" stroke="#000" stroke-width="2"/></svg>

Before

Width:  |  Height:  |  Size: 290 B

View file

@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8 0.5 2.2 5.3 5.8 0.45-4.5 3.75 1.5 5.5-5-3.1-5 3.1 1.5-5.5-4.5-3.75 5.8-0.45z"/></svg>

Before

Width:  |  Height:  |  Size: 195 B

View file

@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m12.5 1a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0 0.003906 0.12891l-4.9023 2.4512a2.5 2.5 0 0 0-1.6016-0.58008 2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 0.30469-0.021484l3.4395-1.7246-1.25-0.625a2.5 2.5 0 0 0 0.0058594-0.12891 2.5 2.5 0 0 0-0.0039062-0.12891l4.9023-2.4512a2.5 2.5 0 0 0 1.6016 0.58008 2.5 2.5 0 0 0 0.26562-0.013672l1.5625-0.7832a2.5 2.5 0 0 0 0.67188-1.7031 2.5 2.5 0 0 0-2.5-2.5zm0.25391 9.0156-3.7246 1.8672 0.97656 0.48828a2.5 2.5 0 0 0-0.005859 0.12891 2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5-2.5 2.5 2.5 0 0 0-2.2461-2.4844z"/><rect transform="rotate(-26.63)" x="-1.0586" y="11.891" width="11.687" height="2.0029" ry="0" style="paint-order:normal"/></svg>

Before

Width:  |  Height:  |  Size: 795 B

View file

@ -133,7 +133,6 @@ class Application extends App implements IBootstrap {
$context->injectFn([Listener::class, 'register']);
$context->injectFn(Closure::fromCallable([$this, 'registerSearchProvider']));
$this->registerTemplates();
$context->injectFn(Closure::fromCallable([$this, 'registerNavigation']));
$this->registerHooks();
}
@ -152,18 +151,6 @@ class Application extends App implements IBootstrap {
$templateManager->registerTemplate('application/vnd.oasis.opendocument.spreadsheet', 'core/templates/filetemplates/template.ods');
}
private function registerNavigation(IL10N $l10n): void {
\OCA\Files\App::getNavigationManager()->add(function () use ($l10n) {
return [
'id' => 'files',
'appname' => 'files',
'script' => 'list.php',
'order' => 0,
'name' => $l10n->t('All files')
];
});
}
private function registerHooks(): void {
Util::connectHook('\OCP\Config', 'js', '\OCA\Files\App', 'extendJsConfig');
}

View file

@ -388,6 +388,7 @@ class ApiController extends Controller {
/**
* @NoAdminRequired
* @NoCSRFRequired
* @PublicPage
*
* Get the service-worker Javascript for previews
*

View file

@ -153,17 +153,36 @@ class ViewController extends Controller {
*
* @param string $fileid
* @return TemplateResponse|RedirectResponse
* @throws NotFoundException
*/
public function showFile(string $fileid = null, int $openfile = 1): Response {
public function showFile(string $fileid = null): Response {
if (!$fileid) {
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index'));
}
// This is the entry point from the `/f/{fileid}` URL which is hardcoded in the server.
try {
return $this->redirectToFile($fileid, $openfile !== 0);
return $this->redirectToFile((int) $fileid);
} catch (NotFoundException $e) {
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true]));
}
}
/**
* @NoCSRFRequired
* @NoAdminRequired
* @UseSession
*
* @param string $dir
* @param string $view
* @param string $fileid
* @param bool $fileNotFound
* @return TemplateResponse|RedirectResponse
*/
public function indexView($dir = '', $view = '', $fileid = null, $fileNotFound = false) {
return $this->index($dir, $view, $fileid, $fileNotFound);
}
/**
* @NoCSRFRequired
* @NoAdminRequired
@ -173,22 +192,30 @@ class ViewController extends Controller {
* @param string $view
* @param string $fileid
* @param bool $fileNotFound
* @param string $openfile - the openfile URL parameter if it was present in the initial request
* @return TemplateResponse|RedirectResponse
* @throws NotFoundException
*/
public function index($dir = '', $view = '', $fileid = null, $fileNotFound = false, $openfile = null) {
public function indexViewFileid($dir = '', $view = '', $fileid = null, $fileNotFound = false) {
return $this->index($dir, $view, $fileid, $fileNotFound);
}
if ($fileid !== null && $dir === '') {
/**
* @NoCSRFRequired
* @NoAdminRequired
* @UseSession
*
* @param string $dir
* @param string $view
* @param string $fileid
* @param bool $fileNotFound
* @return TemplateResponse|RedirectResponse
*/
public function index($dir = '', $view = '', $fileid = null, $fileNotFound = false) {
if ($fileid !== null && $view !== 'trashbin') {
try {
return $this->redirectToFile($fileid);
} catch (NotFoundException $e) {
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true]));
}
return $this->redirectToFileIfInTrashbin((int) $fileid);
} catch (NotFoundException $e) {}
}
$nav = new \OCP\Template('files', 'appnavigation', '');
// Load the files we need
\OCP\Util::addStyle('files', 'merged');
\OCP\Util::addScript('files', 'merged-index', 'files');
@ -203,17 +230,6 @@ class ViewController extends Controller {
$favElements['folders'] = [];
}
$navItems = \OCA\Files\App::getNavigationManager()->getAll();
// parse every menu and add the expanded user value
foreach ($navItems as $key => $item) {
$navItems[$key]['expanded'] = $this->config->getUserValue($userId, 'files', 'show_' . $item['id'], '0') === '1';
}
$nav->assign('navigationItems', $navItems);
$contentItems = [];
try {
// If view is files, we use the directory, otherwise we use the root storage
$storageInfo = $this->getStorageInfo(($view === 'files' && $dir) ? $dir : '/');
@ -222,7 +238,6 @@ class ViewController extends Controller {
}
$this->initialState->provideInitialState('storageStats', $storageInfo);
$this->initialState->provideInitialState('navigation', $navItems);
$this->initialState->provideInitialState('config', $this->userConfig->getConfigs());
$this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs());
$this->initialState->provideInitialState('favoriteFolders', $favElements['folders'] ?? []);
@ -231,34 +246,9 @@ class ViewController extends Controller {
$filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true);
$this->initialState->provideInitialState('filesSortingConfig', $filesSortingConfig);
// render the container content for every navigation item
foreach ($navItems as $item) {
$content = '';
if (isset($item['script'])) {
$content = $this->renderScript($item['appname'], $item['script']);
}
// parse submenus
if (isset($item['sublist'])) {
foreach ($item['sublist'] as $subitem) {
$subcontent = '';
if (isset($subitem['script'])) {
$subcontent = $this->renderScript($subitem['appname'], $subitem['script']);
}
$contentItems[$subitem['id']] = [
'id' => $subitem['id'],
'content' => $subcontent
];
}
}
$contentItems[$item['id']] = [
'id' => $item['id'],
'content' => $content
];
}
$this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent());
$event = new LoadAdditionalScriptsEvent();
$this->eventDispatcher->dispatchTyped($event);
$this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent());
$this->eventDispatcher->dispatchTyped(new LoadSidebar());
// Load Viewer scripts
if (class_exists(LoadViewer::class)) {
@ -268,23 +258,9 @@ class ViewController extends Controller {
$this->initialState->provideInitialState('templates_path', $this->templateManager->hasTemplateDirectory() ? $this->templateManager->getTemplatePath() : false);
$this->initialState->provideInitialState('templates', $this->templateManager->listCreators());
$params = [];
$params['usedSpacePercent'] = (int) $storageInfo['relative'];
$params['owner'] = $storageInfo['owner'] ?? '';
$params['ownerDisplayName'] = $storageInfo['ownerDisplayName'] ?? '';
$params['isPublic'] = false;
$params['allowShareWithLink'] = $this->shareManager->shareApiAllowLinks() ? 'yes' : 'no';
$params['defaultFileSorting'] = $filesSortingConfig['files']['mode'] ?? 'basename';
$params['defaultFileSortingDirection'] = $filesSortingConfig['files']['direction'] ?? 'asc';
$params['showgridview'] = $this->config->getUserValue($userId, 'files', 'show_grid', false);
$showHidden = (bool) $this->config->getUserValue($userId, 'files', 'show_hidden', false);
$params['showHiddenFiles'] = $showHidden ? 1 : 0;
$cropImagePreviews = (bool) $this->config->getUserValue($userId, 'files', 'crop_image_previews', true);
$params['cropImagePreviews'] = $cropImagePreviews ? 1 : 0;
$params['fileNotFound'] = $fileNotFound ? 1 : 0;
$params['appNavigation'] = $nav;
$params['appContents'] = $contentItems;
$params['hiddenFields'] = $event->getHiddenFields();
$params = [
'fileNotFound' => $fileNotFound ? 1 : 0
];
$response = new TemplateResponse(
Application::APP_ID,
@ -293,21 +269,23 @@ class ViewController extends Controller {
);
$policy = new ContentSecurityPolicy();
$policy->addAllowedFrameDomain('\'self\'');
// Allow preview service worker
$policy->addAllowedWorkerSrcDomain('\'self\'');
$response->setContentSecurityPolicy($policy);
$this->provideInitialState($dir, $openfile);
$this->provideInitialState($dir, $fileid);
return $response;
}
/**
* Add openFileInfo in initialState if $openfile is set.
* Add openFileInfo in initialState.
* @param string $dir - the ?dir= URL param
* @param string $openfile - the ?openfile= URL param
* @param string $fileid - the fileid URL param
* @return void
*/
private function provideInitialState(string $dir, ?string $openfile): void {
if ($openfile === null) {
private function provideInitialState(string $dir, ?string $fileid): void {
if ($fileid === null) {
return;
}
@ -319,7 +297,7 @@ class ViewController extends Controller {
$uid = $user->getUID();
$userFolder = $this->rootFolder->getUserFolder($uid);
$nodes = $userFolder->getById((int) $openfile);
$nodes = $userFolder->getById((int) $fileid);
$node = array_shift($nodes);
if ($node === null) {
@ -351,44 +329,70 @@ class ViewController extends Controller {
}
/**
* Redirects to the file list and highlight the given file id
* Redirects to the trashbin file list and highlight the given file id
*
* @param string $fileId file id to show
* @param bool $setOpenfile - whether or not to set the openfile URL parameter
* @param int $fileId file id to show
* @return RedirectResponse redirect response or not found response
* @throws \OCP\Files\NotFoundException
* @throws NotFoundException
*/
private function redirectToFile($fileId, bool $setOpenfile = false) {
private function redirectToFileIfInTrashbin($fileId): RedirectResponse {
$uid = $this->userSession->getUser()->getUID();
$baseFolder = $this->rootFolder->getUserFolder($uid);
$files = $baseFolder->getById($fileId);
$nodes = $baseFolder->getById($fileId);
$params = [];
if (empty($files) && $this->appManager->isEnabledForUser('files_trashbin')) {
if (empty($nodes) && $this->appManager->isEnabledForUser('files_trashbin')) {
/** @var Folder */
$baseFolder = $this->rootFolder->get($uid . '/files_trashbin/files/');
$files = $baseFolder->getById($fileId);
$nodes = $baseFolder->getById($fileId);
$params['view'] = 'trashbin';
}
if (!empty($files)) {
$file = current($files);
if ($file instanceof Folder) {
if (!empty($nodes)) {
$node = current($nodes);
$params['fileid'] = $fileId;
if ($node instanceof Folder) {
// set the full path to enter the folder
$params['dir'] = $baseFolder->getRelativePath($node->getPath());
} else {
// set parent path as dir
$params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath());
}
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params));
}
}
throw new NotFoundException();
}
/**
* Redirects to the file list and highlight the given file id
*
* @param int $fileId file id to show
* @return RedirectResponse redirect response or not found response
* @throws NotFoundException
*/
private function redirectToFile(int $fileId) {
$uid = $this->userSession->getUser()->getUID();
$baseFolder = $this->rootFolder->getUserFolder($uid);
$nodes = $baseFolder->getById($fileId);
$params = [];
try {
$this->redirectToFileIfInTrashbin($fileId);
} catch (NotFoundException $e) {}
if (!empty($nodes)) {
$node = current($nodes);
$params['fileid'] = $fileId;
if ($node instanceof Folder) {
// set the full path to enter the folder
$params['dir'] = $baseFolder->getRelativePath($file->getPath());
$params['dir'] = $baseFolder->getRelativePath($node->getPath());
} else {
// set parent path as dir
$params['dir'] = $baseFolder->getRelativePath($file->getParent()->getPath());
// and scroll to the entry
$params['scrollto'] = $file->getName();
if ($setOpenfile) {
// forward the openfile URL parameter.
$params['openfile'] = $fileId;
}
$params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath());
}
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', $params));
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params));
}
throw new \OCP\Files\NotFoundException();
throw new NotFoundException();
}
}

View file

@ -31,18 +31,7 @@ use OCP\EventDispatcher\Event;
/**
* This event is triggered when the files app is rendered.
* It can be used to add additional scripts to the files app.
*
* @since 17.0.0
*/
class LoadAdditionalScriptsEvent extends Event {
private $hiddenFields = [];
public function addHiddenField(string $name, string $value): void {
$this->hiddenFields[$name] = $value;
}
public function getHiddenFields(): array {
return $this->hiddenFields;
}
}
class LoadAdditionalScriptsEvent extends Event {}

View file

@ -340,6 +340,7 @@
"api"
],
"security": [
{},
{
"bearer_auth": []
},

View file

@ -19,11 +19,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { action } from './openFolderAction'
import type { Navigation } from '../services/Navigation'
import { expect } from '@jest/globals'
import { File, Folder, Node, Permission } from '@nextcloud/files'
import { action } from './openFolderAction'
import { DefaultType, FileAction } from '../services/FileAction'
import type { Navigation } from '../services/Navigation'
const view = {
id: 'files',
@ -132,7 +134,7 @@ describe('Open folder action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
expect(goToRouteMock).toBeCalledWith(null, null, { dir: '/FooBar' })
expect(goToRouteMock).toBeCalledWith(null, { fileid: undefined, view: 'files' }, { dir: '/FooBar' })
})
test('Open folder fails without node', async () => {

View file

@ -59,8 +59,8 @@ export const action = new FileAction({
window.OCP.Files.Router.goToRoute(
null,
null,
{ dir: join(dir, node.basename) },
{ view: view.id, fileid: undefined },
{ dir: join(dir, node.basename), fileid: undefined },
)
return null
},

View file

@ -78,7 +78,7 @@ describe('Open in files action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { fileid: 1, dir: '/Foo', openfile: true })
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { fileid: 1, dir: '/Foo' })
})
test('Open in files with folder', async () => {
@ -98,6 +98,6 @@ describe('Open in files action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { fileid: 1, dir: '/Foo/Bar', openfile: true })
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { fileid: 1, dir: '/Foo/Bar' })
})
})

View file

@ -44,7 +44,7 @@ export const action = new FileAction({
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files', fileid: node.fileid },
{ dir, fileid: node.fileid, openfile: true },
{ dir, fileid: node.fileid },
)
return null
},

View file

@ -19,11 +19,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { action } from './sidebarAction'
import type { Navigation } from '../services/Navigation'
import { expect } from '@jest/globals'
import { File, Permission } from '@nextcloud/files'
import { action } from './sidebarAction'
import { FileAction } from '../services/FileAction'
import type { Navigation } from '../services/Navigation'
import logger from '../logger'
const view = {
@ -127,6 +129,8 @@ describe('Open sidebar action exec tests', () => {
test('Open sidebar', async () => {
const openMock = jest.fn()
window.OCA = { Files: { Sidebar: { open: openMock } } }
const goToRouteMock = jest.fn()
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new File({
id: 1,
@ -139,6 +143,12 @@ describe('Open sidebar action exec tests', () => {
// Silent action
expect(exec).toBe(null)
expect(openMock).toBeCalledWith('/foobar.txt')
expect(goToRouteMock).toBeCalledWith(
null,
{ view: view.id, fileid: 1 },
{ dir: '/' },
true,
)
})
test('Open sidebar fails', async () => {

View file

@ -19,9 +19,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { Navigation } from '../services/Navigation'
import { Permission, type Node } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import InformationSvg from '@mdi/svg/svg/information-variant.svg?raw'
import { Permission, type Node } from '@nextcloud/files'
import { registerFileAction, FileAction } from '../services/FileAction'
import logger from '../logger.js'
@ -40,6 +42,10 @@ export const action = new FileAction({
return false
}
if (!nodes[0]) {
return false
}
// Only work if the sidebar is available
if (!window?.OCA?.Files?.Sidebar) {
return false
@ -48,10 +54,18 @@ export const action = new FileAction({
return (nodes[0].root?.startsWith('/files/') && nodes[0].permissions !== Permission.NONE) ?? false
},
async exec(node: Node) {
async exec(node: Node, view: Navigation) {
try {
// TODO: migrate Sidebar to use a Node instead
window?.OCA?.Files?.Sidebar?.open?.(node.path)
await window.OCA.Files.Sidebar.open(node.path)
// Silently update current fileid
window.OCP.Files.Router.goToRoute(
null,
{ view: view.id, fileid: node.fileid },
{ dir: node.dirname },
true,
)
return null
} catch (error) {

View file

@ -128,7 +128,7 @@ describe('View in folder action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { fileid: 1, dir: '/' })
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/' })
})
test('View in (sub) folder', async () => {
@ -148,7 +148,7 @@ describe('View in folder action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { fileid: 1, dir: '/Foo/Bar' })
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/Foo/Bar' })
})
test('View in folder fails without node', async () => {

View file

@ -61,7 +61,7 @@ export const action = new FileAction({
window.OCP.Files.Router.goToRoute(
null,
{ view: 'files', fileid: node.fileid },
{ dir: node.dirname, fileid: node.fileid },
{ dir: node.dirname },
)
return null
},

View file

@ -21,9 +21,18 @@
-->
<template>
<Fragment>
<tr :class="{'files-list__row--visible': visible, 'files-list__row--active': isActive}"
data-cy-files-list-row
:data-cy-files-list-row-fileid="fileid"
:data-cy-files-list-row-name="source.basename"
class="list__row"
@contextmenu="onRightClick">
<!-- Failed indicator -->
<span v-if="source.attributes.failed" class="files-list__row--failed" />
<!-- Checkbox -->
<td class="files-list__row-checkbox">
<NcCheckboxRadioSwitch v-if="active"
<NcCheckboxRadioSwitch v-if="visible"
:aria-label="t('files', 'Select the row for {displayName}', { displayName })"
:checked="selectedFiles"
:value="fileid"
@ -32,7 +41,7 @@
</td>
<!-- Link to file -->
<td class="files-list__row-name">
<td class="files-list__row-name" data-cy-files-list-row-name>
<!-- Icon or preview -->
<span class="files-list__row-icon" @click="execDefaultAction">
<FolderIcon v-if="source.type === 'folder'" />
@ -43,10 +52,6 @@
class="files-list__row-icon-preview"
:style="{ backgroundImage }" />
<span v-else-if="mimeIconUrl"
class="files-list__row-icon-preview files-list__row-icon-preview--mime"
:style="{ backgroundImage: mimeIconUrl }" />
<FileIcon v-else />
<!-- Favorite icon -->
@ -79,6 +84,7 @@
ref="basename"
:aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
v-bind="linkTo"
@click="execDefaultAction">
<!-- File name -->
@ -91,7 +97,10 @@
</td>
<!-- Actions -->
<td v-show="!isRenamingSmallScreen" :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
<td v-show="!isRenamingSmallScreen"
:class="`files-list__row-actions-${uniqueId}`"
class="files-list__row-actions"
data-cy-files-list-row-actions>
<!-- Render actions -->
<CustomElementRender v-for="action in enabledRenderActions"
:key="action.id"
@ -100,10 +109,10 @@
:source="source" />
<!-- Menu actions -->
<NcActions v-if="active"
<NcActions v-if="visible"
ref="actionsMenu"
:boundaries-element="boundariesElement"
:container="boundariesElement"
:boundaries-element="getBoundariesElement()"
:container="getBoundariesElement()"
:disabled="source._loading"
:force-name="true"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
@ -113,6 +122,7 @@
:key="action.id"
:class="'files-list__row-action-' + action.id"
:close-after-click="true"
:data-cy-files-list-row-action="action.id"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
@ -127,6 +137,7 @@
<td v-if="isSizeAvailable"
:style="{ opacity: sizeOpacity }"
class="files-list__row-size"
data-cy-files-list-row-size
@click="openDetailsIfAvailable">
<span>{{ size }}</span>
</td>
@ -134,6 +145,7 @@
<!-- Mtime -->
<td v-if="isMtimeAvailable"
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
<span>{{ mtime }}</span>
</td>
@ -143,36 +155,36 @@
:key="column.id"
:class="`files-list__row-${currentView?.id}-${column.id}`"
class="files-list__row-column-custom"
:data-cy-files-list-row-column-custom="column.id"
@click="openDetailsIfAvailable">
<CustomElementRender v-if="active"
<CustomElementRender v-if="visible"
:current-view="currentView"
:render="column.render"
:source="source" />
</td>
</Fragment>
</tr>
</template>
<script lang='ts'>
import { CancelablePromise } from 'cancelable-promise'
import { debounce } from 'debounce'
import { emit } from '@nextcloud/event-bus'
import { formatFileSize, Permission } from '@nextcloud/files'
import { Fragment } from 'vue-frag'
import { extname } from 'path'
import { formatFileSize, Permission } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { vOnClickOutside } from '@vueuse/components'
import axios from '@nextcloud/axios'
import CancelablePromise from 'cancelable-promise'
import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import moment from '@nextcloud/moment'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import Vue from 'vue'
import type moment from 'moment'
import { ACTION_DETAILS } from '../actions/sidebarAction.ts'
import { getFileActions, DefaultType } from '../services/FileAction.ts'
@ -181,9 +193,9 @@ import { isCachedPreview } from '../services/PreviewService.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
import { useKeyboardStore } from '../store/keyboard.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import { useRenamingStore } from '../store/renaming.ts'
import CustomElementRender from './CustomElementRender.vue'
import CustomSvgIconRender from './CustomSvgIconRender.vue'
import FavoriteIcon from './FavoriteIcon.vue'
@ -203,7 +215,6 @@ export default Vue.extend({
FavoriteIcon,
FileIcon,
FolderIcon,
Fragment,
NcActionButton,
NcActions,
NcCheckboxRadioSwitch,
@ -212,7 +223,7 @@ export default Vue.extend({
},
props: {
active: {
visible: {
type: Boolean,
default: false,
},
@ -263,7 +274,6 @@ export default Vue.extend({
return {
backgroundFailed: false,
backgroundImage: '',
boundariesElement: document.querySelector('.app-content > .files-list'),
loading: '',
}
},
@ -284,10 +294,13 @@ export default Vue.extend({
return this.currentView?.columns || []
},
dir() {
currentDir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
currentFileId() {
return this.$route.params.fileid || this.$route.query.fileid || null
},
fileid() {
return this.source?.fileid?.toString?.()
},
@ -342,6 +355,13 @@ export default Vue.extend({
},
linkTo() {
if (this.source.attributes.failed) {
return {
title: this.t('files', 'This node is unavailable'),
is: 'span',
}
}
if (this.enabledDefaultActions.length > 0) {
const action = this.enabledDefaultActions[0]
const displayName = action.displayName([this.source], this.currentView)
@ -368,7 +388,7 @@ export default Vue.extend({
return this.selectionStore.selected
},
isSelected() {
return this.selectedFiles.includes(this.source?.fileid?.toString?.())
return this.selectedFiles.includes(this.fileid)
},
cropPreviews() {
@ -385,6 +405,7 @@ export default Vue.extend({
// Request tiny previews
url.searchParams.set('x', '32')
url.searchParams.set('y', '32')
url.searchParams.set('mimeFallback', 'true')
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
@ -393,17 +414,13 @@ export default Vue.extend({
return null
}
},
mimeIconUrl() {
const mimeType = this.source.mime || 'application/octet-stream'
const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType)
if (mimeIconUrl) {
return `url(${mimeIconUrl})`
}
return ''
},
// Sorted actions that are enabled for this node
enabledActions() {
if (this.source.attributes.failed) {
return []
}
return actions
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
@ -419,7 +436,7 @@ export default Vue.extend({
// Enabled action that are displayed inline with a custom render function
enabledRenderActions() {
if (!this.active) {
if (!this.visible) {
return []
}
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
@ -473,24 +490,13 @@ export default Vue.extend({
this.renamingStore.newName = newName
},
},
isActive() {
return this.fileid === this.currentFileId?.toString?.()
},
},
watch: {
active(active, before) {
if (active === false && before === true) {
this.resetState()
// When the row is not active anymore
// remove the display from the row to prevent
// keyboard interaction with it.
this.$el.parentNode.style.display = 'none'
return
}
// Restore default tabindex
this.$el.parentNode.style.display = ''
},
/**
* When the source changes, reset the preview
* and fetch the new one.
@ -522,9 +528,6 @@ export default Vue.extend({
// Fetch the preview on init
this.debounceIfNotCached()
// Right click watcher on tr
this.$el.parentNode?.addEventListener?.('contextmenu', this.onRightClick)
},
beforeDestroy() {
@ -563,8 +566,8 @@ export default Vue.extend({
// Store the promise to be able to cancel it
this.previewPromise = new CancelablePromise((resolve, reject, onCancel) => {
const img = new Image()
// If active, load the preview with higher priority
img.fetchpriority = this.active ? 'high' : 'auto'
// If visible, load the preview with higher priority
img.fetchpriority = this.visible ? 'high' : 'auto'
img.onload = () => {
this.backgroundImage = `url(${this.previewUrl})`
this.backgroundFailed = false
@ -613,7 +616,7 @@ export default Vue.extend({
this.loading = action.id
Vue.set(this.source, '_loading', true)
const success = await action.exec(this.source, this.currentView, this.dir)
const success = await action.exec(this.source, this.currentView, this.currentDir)
// If the action returns null, we stay silent
if (success === null) {
@ -639,7 +642,7 @@ export default Vue.extend({
event.preventDefault()
event.stopPropagation()
// Execute the first default action if any
this.enabledDefaultActions[0].exec(this.source, this.currentView, this.dir)
this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
}
},
@ -816,7 +819,7 @@ export default Vue.extend({
showError(this.t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
return
} else if (error?.response?.status === 412) {
showError(this.t('files', 'The name "{newName}"" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.dir }))
showError(this.t('files', 'The name "{newName}"" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
return
}
@ -828,6 +831,15 @@ export default Vue.extend({
}
},
/**
* Making this a function in case the files-list
* reference changes in the future. That way we're
* sure there is one at the time we call it.
*/
getBoundariesElement() {
return document.querySelector('.app-content > .files-list')
},
t: translate,
formatFileSize,
},
@ -839,7 +851,7 @@ export default Vue.extend({
tr {
&:hover,
&:focus,
&:active {
&:visible {
background-color: var(--color-background-dark);
}
}

View file

@ -20,194 +20,51 @@
-
-->
<template>
<tr>
<th class="files-list__column files-list__row-checkbox">
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
</th>
<!-- Actions multiple if some are selected -->
<FilesListHeaderActions v-if="!isNoneSelected"
:current-view="currentView"
:selected-nodes="selectedNodes" />
<!-- Columns display -->
<template v-else>
<!-- Link to file -->
<th class="files-list__column files-list__row-name files-list__column--sortable"
@click.stop.prevent="toggleSortBy('basename')">
<!-- Icon or preview -->
<span class="files-list__row-icon" />
<!-- Name -->
<FilesListHeaderButton :name="t('files', 'Name')" mode="basename" />
</th>
<!-- Actions -->
<th class="files-list__row-actions" />
<!-- Size -->
<th v-if="isSizeAvailable"
:class="{'files-list__column--sortable': isSizeAvailable}"
class="files-list__column files-list__row-size">
<FilesListHeaderButton :name="t('files', 'Size')" mode="size" />
</th>
<!-- Mtime -->
<th v-if="isMtimeAvailable"
:class="{'files-list__column--sortable': isMtimeAvailable}"
class="files-list__column files-list__row-mtime">
<FilesListHeaderButton :name="t('files', 'Modified')" mode="mtime" />
</th>
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
:class="classForColumn(column)">
<FilesListHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
<span v-else>
{{ column.title }}
</span>
</th>
</template>
</tr>
<div v-show="enabled" :class="`files-list__header-${header.id}`">
<span ref="mount" />
</div>
</template>
<script lang="ts">
import { translate } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import FilesListHeaderActions from './FilesListHeaderActions.vue'
import FilesListHeaderButton from './FilesListHeaderButton.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
export default Vue.extend({
/**
* This component is used to render custom
* elements provided by an API. Vue doesn't allow
* to directly render an HTMLElement, so we can do
* this magic here.
*/
export default {
name: 'FilesListHeader',
components: {
FilesListHeaderButton,
NcCheckboxRadioSwitch,
FilesListHeaderActions,
},
mixins: [
filesSortingMixin,
],
props: {
isMtimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
},
nodes: {
type: Array,
header: {
type: Object,
required: true,
},
filesListWidth: {
type: Number,
default: 0,
currentFolder: {
type: Object,
required: true,
},
currentView: {
type: Object,
required: true,
},
},
setup() {
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
return {
filesStore,
selectionStore,
}
},
computed: {
currentView() {
return this.$navigation.active
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
return []
}
return this.currentView?.columns || []
},
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
selectAllBind() {
const label = this.isNoneSelected || this.isSomeSelected
? this.t('files', 'Select all')
: this.t('files', 'Unselect all')
return {
'aria-label': label,
checked: this.isAllSelected,
indeterminate: this.isSomeSelected,
title: label,
}
},
selectedNodes() {
return this.selectionStore.selected
},
isAllSelected() {
return this.selectedNodes.length === this.nodes.length
},
isNoneSelected() {
return this.selectedNodes.length === 0
},
isSomeSelected() {
return !this.isAllSelected && !this.isNoneSelected
enabled() {
console.debug('Enabled', this.header.id)
return this.header.enabled(this.currentFolder, this.currentView)
},
},
methods: {
classForColumn(column) {
return {
'files-list__column': true,
'files-list__column--sortable': !!column.sort,
'files-list__row-column-custom': true,
[`files-list__row-${this.currentView.id}-${column.id}`]: true,
watch: {
enabled(enabled) {
if (!enabled) {
return
}
this.header.updated(this.currentFolder, this.currentView)
},
onToggleAll(selected) {
if (selected) {
const selection = this.nodes.map(node => node.fileid.toString())
logger.debug('Added all nodes to selection', { selection })
this.selectionStore.setLastIndex(null)
this.selectionStore.set(selection)
} else {
logger.debug('Cleared selection')
this.selectionStore.reset()
}
},
t: translate,
},
})
</script>
<style scoped lang="scss">
.files-list__column {
user-select: none;
// Make sure the cell colors don't apply to column headers
color: var(--color-text-maxcontrast) !important;
&--sortable {
cursor: pointer;
}
mounted() {
console.debug('Mounted', this.header.id)
this.header.render(this.$refs.mount, this.currentFolder, this.currentView)
},
}
</style>
</script>

View file

@ -0,0 +1,175 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<tr>
<th class="files-list__row-checkbox">
<span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
</th>
<!-- Link to file -->
<td class="files-list__row-name">
<!-- Icon or preview -->
<span class="files-list__row-icon" />
<!-- Summary -->
<span>{{ summary }}</span>
</td>
<!-- Actions -->
<td class="files-list__row-actions" />
<!-- Size -->
<td v-if="isSizeAvailable"
class="files-list__column files-list__row-size">
<span>{{ totalSize }}</span>
</td>
<!-- Mtime -->
<td v-if="isMtimeAvailable"
class="files-list__column files-list__row-mtime" />
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
:class="classForColumn(column)">
<span>{{ column.summary?.(nodes, currentView) }}</span>
</th>
</tr>
</template>
<script lang="ts">
import { formatFileSize } from '@nextcloud/files'
import { translate } from '@nextcloud/l10n'
import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
export default Vue.extend({
name: 'FilesListTableFooter',
components: {
},
props: {
isMtimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
},
nodes: {
type: Array,
required: true,
},
summary: {
type: String,
default: '',
},
filesListWidth: {
type: Number,
default: 0,
},
},
setup() {
const pathsStore = usePathsStore()
const filesStore = useFilesStore()
return {
filesStore,
pathsStore,
}
},
computed: {
currentView() {
return this.$navigation.active
},
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
currentFolder() {
if (!this.currentView?.id) {
return
}
if (this.dir === '/') {
return this.filesStore.getRoot(this.currentView.id)
}
const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
return this.filesStore.getNode(fileId)
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
return []
}
return this.currentView?.columns || []
},
totalSize() {
// If we have the size already, let's use it
if (this.currentFolder?.size) {
return formatFileSize(this.currentFolder.size, true)
}
// Otherwise let's compute it
return formatFileSize(this.nodes.reduce((total, node) => total + node.size || 0, 0), true)
},
},
methods: {
classForColumn(column) {
return {
'files-list__row-column-custom': true,
[`files-list__row-${this.currentView.id}-${column.id}`]: true,
}
},
t: translate,
},
})
</script>
<style scoped lang="scss">
// Scoped row
tr {
padding-bottom: 300px;
border-top: 1px solid var(--color-border);
// Prevent hover effect on the whole row
background-color: transparent !important;
border-bottom: none !important;
}
td {
user-select: none;
// Make sure the cell colors don't apply to column headers
color: var(--color-text-maxcontrast) !important;
}
</style>

View file

@ -0,0 +1,213 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<tr class="files-list__row-head">
<th class="files-list__column files-list__row-checkbox">
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
</th>
<!-- Actions multiple if some are selected -->
<FilesListTableHeaderActions v-if="!isNoneSelected"
:current-view="currentView"
:selected-nodes="selectedNodes" />
<!-- Columns display -->
<template v-else>
<!-- Link to file -->
<th class="files-list__column files-list__row-name files-list__column--sortable"
@click.stop.prevent="toggleSortBy('basename')">
<!-- Icon or preview -->
<span class="files-list__row-icon" />
<!-- Name -->
<FilesListTableHeaderButton :name="t('files', 'Name')" mode="basename" />
</th>
<!-- Actions -->
<th class="files-list__row-actions" />
<!-- Size -->
<th v-if="isSizeAvailable"
:class="{'files-list__column--sortable': isSizeAvailable}"
class="files-list__column files-list__row-size">
<FilesListTableHeaderButton :name="t('files', 'Size')" mode="size" />
</th>
<!-- Mtime -->
<th v-if="isMtimeAvailable"
:class="{'files-list__column--sortable': isMtimeAvailable}"
class="files-list__column files-list__row-mtime">
<FilesListTableHeaderButton :name="t('files', 'Modified')" mode="mtime" />
</th>
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
:class="classForColumn(column)">
<FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
<span v-else>
{{ column.title }}
</span>
</th>
</template>
</tr>
</template>
<script lang="ts">
import { translate } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
export default Vue.extend({
name: 'FilesListTableHeader',
components: {
FilesListTableHeaderButton,
NcCheckboxRadioSwitch,
FilesListTableHeaderActions,
},
mixins: [
filesSortingMixin,
],
props: {
isMtimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
},
nodes: {
type: Array,
required: true,
},
filesListWidth: {
type: Number,
default: 0,
},
},
setup() {
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
return {
filesStore,
selectionStore,
}
},
computed: {
currentView() {
return this.$navigation.active
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
return []
}
return this.currentView?.columns || []
},
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
selectAllBind() {
const label = this.isNoneSelected || this.isSomeSelected
? this.t('files', 'Select all')
: this.t('files', 'Unselect all')
return {
'aria-label': label,
checked: this.isAllSelected,
indeterminate: this.isSomeSelected,
title: label,
}
},
selectedNodes() {
return this.selectionStore.selected
},
isAllSelected() {
return this.selectedNodes.length === this.nodes.length
},
isNoneSelected() {
return this.selectedNodes.length === 0
},
isSomeSelected() {
return !this.isAllSelected && !this.isNoneSelected
},
},
methods: {
classForColumn(column) {
return {
'files-list__column': true,
'files-list__column--sortable': !!column.sort,
'files-list__row-column-custom': true,
[`files-list__row-${this.currentView.id}-${column.id}`]: true,
}
},
onToggleAll(selected) {
if (selected) {
const selection = this.nodes.map(node => node.fileid.toString())
logger.debug('Added all nodes to selection', { selection })
this.selectionStore.setLastIndex(null)
this.selectionStore.set(selection)
} else {
logger.debug('Cleared selection')
this.selectionStore.reset()
}
},
t: translate,
},
})
</script>
<style scoped lang="scss">
.files-list__column {
user-select: none;
// Make sure the cell colors don't apply to column headers
color: var(--color-text-maxcontrast) !important;
&--sortable {
cursor: pointer;
}
}
</style>

View file

@ -0,0 +1,226 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<th class="files-list__column files-list__row-actions-batch" colspan="2">
<NcActions ref="actionsMenu"
:disabled="!!loading || areSomeNodesLoading"
:force-name="true"
:inline="inlineActions"
:menu-name="inlineActions <= 1 ? t('files', 'Actions') : null"
:open.sync="openedMenu">
<NcActionButton v-for="action in enabledActions"
:key="action.id"
:class="'files-list__row-actions-batch-' + action.id"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" />
</template>
{{ action.displayName(nodes, currentView) }}
</NcActionButton>
</NcActions>
</th>
</template>
<script lang="ts">
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import Vue from 'vue'
import { getFileActions } from '../services/FileAction.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import CustomSvgIconRender from './CustomSvgIconRender.vue'
import logger from '../logger.js'
// The registered actions list
const actions = getFileActions()
export default Vue.extend({
name: 'FilesListTableHeaderActions',
components: {
CustomSvgIconRender,
NcActions,
NcActionButton,
NcLoadingIcon,
},
mixins: [
filesListWidthMixin,
],
props: {
currentView: {
type: Object,
required: true,
},
selectedNodes: {
type: Array,
default: () => ([]),
},
},
setup() {
const actionsMenuStore = useActionsMenuStore()
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
return {
actionsMenuStore,
filesStore,
selectionStore,
}
},
data() {
return {
loading: null,
}
},
computed: {
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
enabledActions() {
return actions
.filter(action => action.execBatch)
.filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
nodes() {
return this.selectedNodes
.map(fileid => this.getNode(fileid))
.filter(node => node)
},
areSomeNodesLoading() {
return this.nodes.some(node => node._loading)
},
openedMenu: {
get() {
return this.actionsMenuStore.opened === 'global'
},
set(opened) {
this.actionsMenuStore.opened = opened ? 'global' : null
},
},
inlineActions() {
if (this.filesListWidth < 512) {
return 0
}
if (this.filesListWidth < 768) {
return 1
}
if (this.filesListWidth < 1024) {
return 2
}
return 3
},
},
methods: {
/**
* Get a cached note from the store
*
* @param {number} fileId the file id to get
* @return {Folder|File}
*/
getNode(fileId) {
return this.filesStore.getNode(fileId)
},
async onActionClick(action) {
const displayName = action.displayName(this.nodes, this.currentView)
const selectionIds = this.selectedNodes
try {
// Set loading markers
this.loading = action.id
this.nodes.forEach(node => {
Vue.set(node, '_loading', true)
})
// Dispatch action execution
const results = await action.execBatch(this.nodes, this.currentView, this.dir)
// Check if all actions returned null
if (!results.some(result => result !== null)) {
// If the actions returned null, we stay silent
this.selectionStore.reset()
return
}
// Handle potential failures
if (results.some(result => result === false)) {
// Remove the failed ids from the selection
const failedIds = selectionIds
.filter((fileid, index) => results[index] === false)
this.selectionStore.set(failedIds)
showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))
return
}
// Show success message and clear selection
showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName }))
this.selectionStore.reset()
} catch (e) {
logger.error('Error while executing action', { action, e })
showError(this.t('files', '"{displayName}" action failed', { displayName }))
} finally {
// Remove loading markers
this.loading = null
this.nodes.forEach(node => {
Vue.set(node, '_loading', false)
})
}
},
t: translate,
},
})
</script>
<style scoped lang="scss">
.files-list__row-actions-batch {
flex: 1 1 100% !important;
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
::v-deep .button-vue__wrapper {
width: 100%;
span.button-vue__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View file

@ -0,0 +1,122 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<NcButton :aria-label="sortAriaLabel(name)"
:class="{'files-list__column-sort-button--active': sortingMode === mode}"
class="files-list__column-sort-button"
type="tertiary"
@click.stop.prevent="toggleSortBy(mode)">
<!-- Sort icon before text as size is align right -->
<MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" />
<MenuDown v-else slot="icon" />
{{ name }}
</NcButton>
</template>
<script lang="ts">
import { translate } from '@nextcloud/l10n'
import MenuDown from 'vue-material-design-icons/MenuDown.vue'
import MenuUp from 'vue-material-design-icons/MenuUp.vue'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import Vue from 'vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
export default Vue.extend({
name: 'FilesListTableHeaderButton',
components: {
MenuDown,
MenuUp,
NcButton,
},
mixins: [
filesSortingMixin,
],
props: {
name: {
type: String,
required: true,
},
mode: {
type: String,
required: true,
},
},
methods: {
sortAriaLabel(column) {
const direction = this.isAscSorting
? this.t('files', 'ascending')
: this.t('files', 'descending')
return this.t('files', 'Sort list by {column} ({direction})', {
column,
direction,
})
},
t: translate,
},
})
</script>
<style lang="scss">
.files-list__column-sort-button {
// Compensate for cells margin
margin: 0 calc(var(--cell-margin) * -1);
// Reverse padding
padding: 0 4px 0 16px !important;
// Icon after text
.button-vue__wrapper {
flex-direction: row-reverse;
// Take max inner width for text overflow ellipsis
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
width: 100%;
}
.button-vue__icon {
transition-timing-function: linear;
transition-duration: .1s;
transition-property: opacity;
opacity: 0;
}
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
.button-vue__text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&--active,
&:hover,
&:focus,
&:active {
.button-vue__icon {
opacity: 1 !important;
}
}
}
</style>

View file

@ -20,28 +20,18 @@
-
-->
<template>
<RecycleScroller ref="recycleScroller"
class="files-list"
key-field="source"
:items="nodes"
:item-size="55"
:table-mode="true"
item-class="files-list__row"
item-tag="tr"
list-class="files-list__body"
list-tag="tbody"
role="table">
<template #default="{ item, active, index }">
<!-- File row -->
<FileEntry :active="active"
:index="index"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:files-list-width="filesListWidth"
:nodes="nodes"
:source="item" />
</template>
<VirtualList :data-component="FileEntry"
:data-key="'source'"
:data-sources="nodes"
:item-height="56"
:extra-props="{
isMtimeAvailable,
isSizeAvailable,
nodes,
filesListWidth,
}"
:scroll-to-index="scrollToIndex">
<!-- Accessibility description and headers -->
<template #before>
<!-- Accessibility description -->
<caption class="hidden-visually">
@ -49,42 +39,56 @@
{{ t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') }}
</caption>
<!-- Thead-->
<FilesListHeader :files-list-width="filesListWidth"
<!-- Headers -->
<FilesListHeader v-for="header in sortedHeaders"
:key="header.id"
:current-folder="currentFolder"
:current-view="currentView"
:header="header" />
</template>
<!-- Thead-->
<template #header>
<FilesListTableHeader :files-list-width="filesListWidth"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes" />
</template>
<template #after>
<!-- Tfoot-->
<FilesListFooter :files-list-width="filesListWidth"
<!-- Tfoot-->
<template #footer>
<FilesListTableFooter :files-list-width="filesListWidth"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes"
:summary="summary" />
</template>
</RecycleScroller>
</VirtualList>
</template>
<script lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
import { translate, translatePlural } from '@nextcloud/l10n'
import { getFileListHeaders, type Node } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
import Vue from 'vue'
import VirtualList from './VirtualList.vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import FileEntry from './FileEntry.vue'
import FilesListFooter from './FilesListFooter.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import logger from '../logger.js'
export default Vue.extend({
name: 'FilesListVirtual',
components: {
RecycleScroller,
FileEntry,
FilesListHeader,
FilesListFooter,
FilesListTableHeader,
FilesListTableFooter,
VirtualList,
},
mixins: [
@ -96,6 +100,10 @@ export default Vue.extend({
type: Object,
required: true,
},
currentFolder: {
type: Object,
required: true,
},
nodes: {
type: Array,
required: true,
@ -105,6 +113,8 @@ export default Vue.extend({
data() {
return {
FileEntry,
headers: getFileListHeaders(),
scrollToIndex: 0,
}
},
@ -113,6 +123,10 @@ export default Vue.extend({
return this.nodes.filter(node => node.type === 'file')
},
fileId() {
return parseInt(this.$route.params.fileid || this.$route.query.fileid) || null
},
summaryFile() {
const count = this.files.length
return translatePlural('files', '{count} file', '{count} files', count, { count })
@ -138,13 +152,36 @@ export default Vue.extend({
}
return this.nodes.some(node => node.attributes.size !== undefined)
},
sortedHeaders() {
if (!this.currentFolder || !this.currentView) {
return []
}
return [...this.headers].sort((a, b) => a.order - b.order)
},
},
mounted() {
// Make the root recycle scroller a table for proper semantics
const slots = this.$el.querySelectorAll('.vue-recycle-scroller__slot')
slots[0].setAttribute('role', 'thead')
slots[1].setAttribute('role', 'tfoot')
// Scroll to the file if it's in the url
if (this.fileId) {
const index = this.nodes.findIndex(node => node.fileid === this.fileId)
if (index === -1) {
showError(this.t('files', 'File not found'))
}
this.scrollToIndex = Math.max(0, index)
}
// Open the file sidebar if we have the room for it
if (document.documentElement.clientWidth > 1024) {
// Open the sidebar on the file if it's in the url and
// we're just loaded the app for the first time.
const node = this.nodes.find(n => n.fileid === this.fileId) as Node
if (node && sidebarAction?.enabled?.([node], this.currentView)) {
logger.debug('Opening sidebar on file ' + node.path, { node })
sidebarAction.exec(node, this.currentView, this.currentFolder)
}
}
},
methods: {
@ -173,7 +210,7 @@ export default Vue.extend({
&::v-deep {
// Table head, body and footer
tbody, .vue-recycle-scroller__slot {
tbody {
display: flex;
flex-direction: column;
width: 100%;
@ -181,22 +218,36 @@ export default Vue.extend({
position: relative;
}
// Before table and thead
.files-list__before {
display: flex;
flex-direction: column;
}
// Table header
.vue-recycle-scroller__slot[role='thead'] {
.files-list__thead {
// Pinned on top when scrolling
position: sticky;
z-index: 10;
top: 0;
height: var(--row-height);
}
.files-list__thead,
.files-list__tfoot {
display: flex;
width: 100%;
background-color: var(--color-main-background);
}
tr {
position: absolute;
position: relative;
display: flex;
align-items: center;
width: 100%;
user-select: none;
border-bottom: 1px solid var(--color-border);
user-select: none;
}
td, th {
@ -221,8 +272,21 @@ export default Vue.extend({
}
}
.files-list__row--failed {
position: absolute;
display: block;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: .1;
z-index: -1;
background: var(--color-error);
}
.files-list__row-checkbox {
justify-content: center;
.checkbox-radio-switch {
display: flex;
justify-content: center;
@ -242,9 +306,14 @@ export default Vue.extend({
}
}
// Hover state of the row should also change the favorite markers background
.files-list__row:hover .favorite-marker-icon svg path {
stroke: var(--color-background-dark);
.files-list__row{
&:hover, &:focus, &:active, &--active {
background-color: var(--color-background-dark);
// Hover state of the row should also change the favorite markers background
.favorite-marker-icon svg path {
stroke: var(--color-background-dark);
}
}
}
// Entry preview or mime icon

View file

@ -134,13 +134,13 @@ export default {
// User storage stats display
.app-navigation-entry__settings-quota {
// Align title with progress and icon
&--not-unlimited::v-deep .app-navigation-entry__title {
margin-top: -4px;
&--not-unlimited::v-deep .app-navigation-entry__name {
margin-top: -6px;
}
progress {
position: absolute;
bottom: 10px;
bottom: 12px;
margin-left: 44px;
width: calc(100% - 44px - 22px);
}

View file

@ -0,0 +1,163 @@
<template>
<table class="files-list" data-cy-files-list>
<!-- Header -->
<div ref="before" class="files-list__before">
<slot name="before" />
</div>
<!-- Header -->
<thead ref="thead" class="files-list__thead" data-cy-files-list-thead>
<slot name="header" />
</thead>
<!-- Body -->
<tbody :style="tbodyStyle" class="files-list__tbody" data-cy-files-list-tbody>
<component :is="dataComponent"
v-for="(item, i) in renderedItems"
:key="i"
:visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)"
:source="item"
:index="i"
v-bind="extraProps" />
</tbody>
<!-- Footer -->
<tfoot v-show="isReady"
ref="tfoot"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
</tfoot>
</table>
</template>
<script lang="ts">
import { File, Folder } from '@nextcloud/files'
import { debounce } from 'debounce'
import Vue from 'vue'
import logger from '../logger.js'
// Items to render before and after the visible area
const bufferItems = 3
export default Vue.extend({
name: 'VirtualList',
props: {
dataComponent: {
type: [Object, Function],
required: true,
},
dataKey: {
type: String,
required: true,
},
dataSources: {
type: Array as () => (File | Folder)[],
required: true,
},
itemHeight: {
type: Number,
required: true,
},
extraProps: {
type: Object,
default: () => ({}),
},
scrollToIndex: {
type: Number,
default: 0,
},
},
data() {
return {
bufferItems,
index: this.scrollToIndex,
beforeHeight: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
}
},
computed: {
// Wait for measurements to be done before rendering
isReady() {
return this.tableHeight > 0
},
startIndex() {
return Math.max(0, this.index - bufferItems)
},
shownItems() {
return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
},
renderedItems(): (File | Folder)[] {
if (!this.isReady) {
return []
}
return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems)
},
tbodyStyle() {
const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
return {
paddingTop: `${this.startIndex * this.itemHeight}px`,
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
}
},
},
watch: {
scrollToIndex() {
this.index = this.scrollToIndex
this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
},
},
mounted() {
const before = this.$refs?.before as HTMLElement
const root = this.$el as HTMLElement
const tfoot = this.$refs?.tfoot as HTMLElement
const thead = this.$refs?.thead as HTMLElement
this.resizeObserver = new ResizeObserver(debounce(() => {
this.beforeHeight = before?.clientHeight ?? 0
this.headerHeight = thead?.clientHeight ?? 0
this.tableHeight = root?.clientHeight ?? 0
logger.debug('VirtualList resizeObserver updated')
this.onScroll()
}, 100, false))
this.resizeObserver.observe(before)
this.resizeObserver.observe(root)
this.resizeObserver.observe(tfoot)
this.resizeObserver.observe(thead)
this.$el.addEventListener('scroll', this.onScroll)
if (this.scrollToIndex) {
this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
}
},
beforeDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
methods: {
onScroll() {
// Max 0 to prevent negative index
this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight))
},
},
})
</script>
<style scoped>
</style>

View file

@ -1,55 +0,0 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { loadState } from '@nextcloud/initial-state'
import logger from '../logger.js'
/**
* Fetch and register the legacy files views
*/
export default function() {
const legacyViews = Object.values(loadState('files', 'navigation', {}))
if (legacyViews.length > 0) {
logger.debug('Legacy files views detected. Processing...', legacyViews)
legacyViews.forEach(view => {
registerLegacyView(view)
if (view.sublist) {
view.sublist.forEach(subview => registerLegacyView({ ...subview, parent: view.id }))
}
})
}
}
const registerLegacyView = function({ id, name, order, icon, parent, classes = '', expanded, params }) {
OCP.Files.Navigation.register({
id,
name,
order,
params,
parent,
expanded: expanded === true,
iconClass: icon ? `icon-${icon}` : 'nav-icon-' + id,
legacy: true,
sticky: classes.includes('pinned'),
})
}

View file

@ -15,13 +15,13 @@ import Vue from 'vue'
import { createPinia, PiniaVuePlugin } from 'pinia'
import FilesListView from './views/FilesList.vue'
import NavigationService from './services/Navigation'
import { NavigationService } from './services/Navigation'
import NavigationView from './views/Navigation.vue'
import processLegacyFilesViews from './legacy/navigationMapper.js'
import registerFavoritesView from './views/favorites'
import registerRecentView from './views/recent'
import registerFilesView from './views/files'
import registerPreviewServiceWorker from './services/ServiceWorker.js'
import router from './router/router.js'
import router from './router/router'
import RouterService from './services/RouterService'
import SettingsModel from './models/Setting.js'
import SettingsService from './services/Settings.js'
@ -78,8 +78,8 @@ const FilesList = new ListView({
FilesList.$mount('#app-content-vue')
// Init legacy and new files views
processLegacyFilesViews()
registerFavoritesView()
registerFilesView()
registerRecentView()
// Register preview service worker

View file

@ -23,14 +23,14 @@ import Vue from 'vue'
import { mapState } from 'pinia'
import { useViewConfigStore } from '../store/viewConfig'
import type { Navigation } from '../services/Navigation'
import type { NavigationService, Navigation } from '../services/Navigation'
export default Vue.extend({
computed: {
...mapState(useViewConfigStore, ['getConfig', 'setSortingBy', 'toggleSortingDirection']),
currentView(): Navigation {
return this.$navigation.active
return (this.$navigation as NavigationService).active as Navigation
},
/**

View file

@ -19,26 +19,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import Vue from 'vue'
import Router from 'vue-router'
import { generateUrl } from '@nextcloud/router'
import queryString from 'query-string'
import Router, { RawLocation, Route } from 'vue-router'
import Vue from 'vue'
import { ErrorHandler } from 'vue-router/types/router'
Vue.use(Router)
// Prevent router from throwing errors when we're already on the page we're trying to go to
const originalPush = Router.prototype.push as (to, onComplete?, onAbort?) => Promise<Route>
Router.prototype.push = function push(to: RawLocation, onComplete?: ((route: Route) => void) | undefined, onAbort?: ErrorHandler | undefined): Promise<Route> {
if (onComplete || onAbort) return originalPush.call(this, to, onComplete, onAbort)
return originalPush.call(this, to).catch(err => err)
}
const router = new Router({
mode: 'history',
// if index.php is in the url AND we got this far, then it's working:
// let's keep using index.php in the url
base: generateUrl('/apps/files', ''),
base: generateUrl('/apps/files'),
linkActiveClass: 'active',
routes: [
{
path: '/',
// Pretending we're using the default view
alias: '/files',
redirect: { name: 'filelist' },
},
{
path: '/:view/:fileid?',

View file

@ -19,7 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { File, Folder, parseWebdavPermissions } from '@nextcloud/files'
import { File, Folder, davParsePermissions } from '@nextcloud/files'
import { generateRemoteUrl } from '@nextcloud/router'
import { getClient, rootPath } from './WebdavClient'
import { getCurrentUser } from '@nextcloud/auth'
@ -47,7 +47,7 @@ interface ResponseProps extends DAVResultResponseProps {
const resultToNode = function(node: FileStat): File | Folder {
const props = node.props as ResponseProps
const permissions = parseWebdavPermissions(props?.permissions)
const permissions = davParsePermissions(props?.permissions)
const owner = getCurrentUser()?.uid as string
const nodeData = {

View file

@ -21,6 +21,7 @@
*/
import axios from '@nextcloud/axios'
import { davGetDefaultPropfind } from '@nextcloud/files'
/**
* @param {any} url -
@ -29,33 +30,7 @@ export default async function(url) {
const response = await axios({
method: 'PROPFIND',
url,
data: `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns"
xmlns:ocs="http://open-collaboration-services.org/ns">
<d:prop>
<d:getlastmodified />
<d:getetag />
<d:getcontenttype />
<d:resourcetype />
<oc:fileid />
<oc:permissions />
<oc:size />
<d:getcontentlength />
<nc:has-preview />
<nc:mount-type />
<nc:is-encrypted />
<ocs:share-permissions />
<nc:share-attributes />
<oc:tags />
<oc:favorite />
<oc:comments-unread />
<oc:owner-id />
<oc:owner-display-name />
<oc:share-types />
</d:prop>
</d:propfind>`,
data: davGetDefaultPropfind(),
})
// TODO: create new parser or use cdav-lib when available

View file

@ -0,0 +1,102 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { ContentsWithRoot } from './Navigation'
import type { FileStat, ResponseDataDetailed, DAVResultResponseProps } from 'webdav'
import { File, Folder, davParsePermissions } from '@nextcloud/files'
import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { getClient, rootPath } from './WebdavClient'
import { getDefaultPropfind } from './DavProperties'
import { hashCode } from '../utils/hashUtils'
import logger from '../logger'
const client = getClient()
interface ResponseProps extends DAVResultResponseProps {
permissions: string,
fileid: number,
size: number,
}
const resultToNode = function(node: FileStat): File | Folder {
const props = node.props as ResponseProps
const permissions = davParsePermissions(props?.permissions)
const owner = getCurrentUser()?.uid as string
const source = generateRemoteUrl('dav' + rootPath + node.filename)
const id = props?.fileid < 0
? hashCode(source)
: props?.fileid as number || 0
const nodeData = {
id,
source,
mtime: new Date(node.lastmod),
mime: node.mime as string,
size: props?.size as number || 0,
permissions,
owner,
root: rootPath,
attributes: {
...node,
...props,
hasPreview: props?.['has-preview'],
failed: props?.fileid < 0,
},
}
delete nodeData.attributes.props
return node.type === 'file'
? new File(nodeData)
: new Folder(nodeData)
}
export const getContents = async (path = '/'): Promise<ContentsWithRoot> => {
const propfindPayload = getDefaultPropfind()
const contentsResponse = await client.getDirectoryContents(path, {
details: true,
data: propfindPayload,
includeSelf: true,
}) as ResponseDataDetailed<FileStat[]>
const root = contentsResponse.data[0]
const contents = contentsResponse.data.slice(1)
if (root.filename !== path) {
throw new Error('Root node does not match requested path')
}
return {
folder: resultToNode(root) as Folder,
contents: contents.map(result => {
try {
return resultToNode(result)
} catch (error) {
logger.error(`Invalid node detected '${result.basename}'`, { error })
return null
}
}).filter(Boolean) as File[],
}
}

View file

@ -96,22 +96,9 @@ export interface Navigation {
* haven't customized their sorting column
*/
defaultSortKey?: string
/**
* This view is sticky a legacy view.
* Here until all the views are migrated to Vue.
* @deprecated It will be removed in a near future
*/
legacy?: boolean
/**
* An icon class.
* @deprecated It will be removed in a near future
*/
iconClass?: string
}
export default class {
export class NavigationService {
private _views: Navigation[] = []
private _currentView: Navigation | null = null
@ -131,14 +118,6 @@ export default class {
throw e
}
if (view.legacy) {
logger.warn('Legacy view detected, please migrate to Vue')
}
if (view.iconClass) {
view.legacy = true
}
this._views.push(view)
}
@ -192,18 +171,12 @@ const isValidNavigation = function(view: Navigation): boolean {
throw new Error('Navigation caption is required for top-level views and must be a string')
}
/**
* Legacy handle their content and icon differently
* TODO: remove when support for legacy views is removed
*/
if (!view.legacy) {
if (!view.getContents || typeof view.getContents !== 'function') {
throw new Error('Navigation getContents is required and must be a function')
}
if (!view.getContents || typeof view.getContents !== 'function') {
throw new Error('Navigation getContents is required and must be a function')
}
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
throw new Error('Navigation icon is required and must be a valid svg string')
}
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
throw new Error('Navigation icon is required and must be a valid svg string')
}
if (!('order' in view) || typeof view.order !== 'number') {

View file

@ -26,8 +26,12 @@ const SWCacheName = 'previews'
/**
* Check if the preview is already cached by the service worker
*/
export const isCachedPreview = function(previewUrl: string) {
return caches.open(SWCacheName)
export const isCachedPreview = function(previewUrl: string): Promise<boolean> {
if (!window?.caches?.open) {
return Promise.resolve(false)
}
return window?.caches?.open(SWCacheName)
.then(function(cache) {
return cache.match(previewUrl)
.then(function(response) {

View file

@ -19,7 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { File, Folder, Permission, parseWebdavPermissions } from '@nextcloud/files'
import { File, Folder, Permission, davParsePermissions } from '@nextcloud/files'
import { generateRemoteUrl } from '@nextcloud/router'
import { getClient, rootPath } from './WebdavClient'
import { getCurrentUser } from '@nextcloud/auth'
@ -94,7 +94,7 @@ interface ResponseProps extends DAVResultResponseProps {
const resultToNode = function(node: FileStat): File | Folder {
const props = node.props as ResponseProps
const permissions = parseWebdavPermissions(props?.permissions)
const permissions = davParsePermissions(props?.permissions)
const owner = getCurrentUser()?.uid as string
const nodeData = {

View file

@ -31,6 +31,18 @@ export default class RouterService {
this._router = router
}
get name(): string | null | undefined {
return this._router.currentRoute.name
}
get query(): Dictionary<string | (string | null)[] | null | undefined> {
return this._router.currentRoute.query || {}
}
get params(): Dictionary<string> {
return this._router.currentRoute.params || {}
}
/**
* Trigger a route change on the files app
*

View file

@ -20,9 +20,7 @@
-
-->
<template>
<NcAppContent v-show="!currentView?.legacy"
:class="{'app-content--hidden': currentView?.legacy}"
data-cy-files-content>
<NcAppContent data-cy-files-content>
<div class="files-list__header">
<!-- Current folder breadcrumbs -->
<BreadCrumbs :path="dir" @reload="fetchContent" />
@ -58,19 +56,25 @@
<!-- File list -->
<FilesListVirtual v-else
ref="filesListVirtual"
:current-folder="currentFolder"
:current-view="currentView"
:nodes="dirContents" />
:nodes="dirContentsSorted" />
</NcAppContent>
</template>
<script lang="ts">
import { Folder, File, Node } from '@nextcloud/files'
import type { Route } from 'vue-router'
import type { Navigation, ContentsWithRoot } from '../services/Navigation.ts'
import type { UserConfig } from '../types.ts'
import { Folder, Node } from '@nextcloud/files'
import { join } from 'path'
import { orderBy } from 'natural-orderby'
import { translate } from '@nextcloud/l10n'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import Vue from 'vue'
@ -83,8 +87,6 @@ import BreadCrumbs from '../components/BreadCrumbs.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
import Navigation, { ContentsWithRoot } from '../services/Navigation.ts'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
export default Vue.extend({
name: 'FilesList',
@ -126,32 +128,27 @@ export default Vue.extend({
},
computed: {
userConfig() {
userConfig(): UserConfig {
return this.userConfigStore.userConfig
},
/** @return {Navigation} */
currentView() {
return this.$navigation.active
|| this.$navigation.views.find(view => view.id === 'files')
currentView(): Navigation {
return (this.$navigation.active
|| this.$navigation.views.find(view => view.id === 'files')) as Navigation
},
/**
* The current directory query.
*
* @return {string}
*/
dir() {
dir(): string {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
/**
* The current folder.
*
* @return {Folder|undefined}
*/
currentFolder() {
currentFolder(): Folder|undefined {
if (!this.currentView?.id) {
return
}
@ -165,10 +162,8 @@ export default Vue.extend({
/**
* The current directory contents.
*
* @return {Node[]}
*/
dirContents() {
dirContentsSorted(): Node[] {
if (!this.currentView) {
return []
}
@ -178,8 +173,7 @@ export default Vue.extend({
// Custom column must provide their own sorting methods
if (customColumn?.sort && typeof customColumn.sort === 'function') {
const results = [...(this.currentFolder?._children || []).map(this.getNode).filter(file => file)]
.sort(customColumn.sort)
const results = [...this.dirContents].sort(customColumn.sort)
return this.isAscSorting ? results : results.reverse()
}
@ -198,16 +192,20 @@ export default Vue.extend({
const orders = new Array(identifiers.length).fill(this.isAscSorting ? 'asc' : 'desc')
return orderBy(
[...(this.currentFolder?._children || []).map(this.getNode).filter(file => file)],
[...this.dirContents],
identifiers,
orders,
)
},
dirContents(): Node[] {
return (this.currentFolder?._children || []).map(this.getNode).filter(file => file)
},
/**
* The current directory is empty.
*/
isEmptyDir() {
isEmptyDir(): boolean {
return this.dirContents.length === 0
},
@ -216,7 +214,7 @@ export default Vue.extend({
* But we already have a cached version of it
* that is not empty.
*/
isRefreshing() {
isRefreshing(): boolean {
return this.currentFolder !== undefined
&& !this.isEmptyDir
&& this.loading
@ -225,7 +223,7 @@ export default Vue.extend({
/**
* Route to the previous directory.
*/
toPreviousDir() {
toPreviousDir(): Route {
const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
return { ...this.$route, query: { dir } }
},
@ -257,10 +255,6 @@ export default Vue.extend({
methods: {
async fetchContent() {
if (this.currentView?.legacy) {
return
}
this.loading = true
const dir = this.dir
const currentView = this.currentView
@ -272,8 +266,7 @@ export default Vue.extend({
}
// Fetch the current dir contents
/** @type {Promise<ContentsWithRoot>} */
this.promise = currentView.getContents(dir)
this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot>
try {
const { folder, contents } = await this.promise
logger.debug('Fetched contents', { dir, folder, contents })
@ -333,12 +326,6 @@ export default Vue.extend({
overflow: hidden;
flex-direction: column;
max-height: 100%;
// TODO: remove after all legacy views are migrated
// Hides the legacy app-content if shown view is not legacy
&:not(&--hidden)::v-deep + #app-content {
display: none;
}
}
$margin: 4px;

View file

@ -2,9 +2,9 @@ import FolderSvg from '@mdi/svg/svg/folder.svg'
import ShareSvg from '@mdi/svg/svg/share-variant.svg'
import { createTestingPinia } from '@pinia/testing'
import NavigationService from '../services/Navigation'
import { NavigationService } from '../services/Navigation'
import NavigationView from './Navigation.vue'
import router from '../router/router.js'
import router from '../router/router'
import { useViewConfigStore } from '../store/viewConfig'
describe('Navigation renders', () => {

View file

@ -72,7 +72,7 @@
</NcAppNavigation>
</template>
<script>
<script lang="ts">
import { emit, subscribe } from '@nextcloud/event-bus'
import { translate } from '@nextcloud/l10n'
import Cog from 'vue-material-design-icons/Cog.vue'
@ -83,7 +83,7 @@ import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js
import { setPageHeading } from '../../../../core/src/OCP/accessibility.js'
import { useViewConfigStore } from '../store/viewConfig.ts'
import logger from '../logger.js'
import Navigation from '../services/Navigation.ts'
import type { NavigationService, Navigation } from '../services/Navigation.ts'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
@ -102,7 +102,7 @@ export default {
props: {
// eslint-disable-next-line vue/prop-name-casing
Navigation: {
type: Navigation,
type: Object as Navigation,
required: true,
},
},
@ -125,18 +125,15 @@ export default {
return this.$route?.params?.view || 'files'
},
/** @return {Navigation} */
currentView() {
currentView(): Navigation {
return this.views.find(view => view.id === this.currentViewId)
},
/** @return {Navigation[]} */
views() {
views(): Navigation[] {
return this.Navigation.views
},
/** @return {Navigation[]} */
parentViews() {
parentViews(): Navigation[] {
return this.views
// filter child views
.filter(view => !view.parent)
@ -146,8 +143,7 @@ export default {
})
},
/** @return {Navigation[]} */
childViews() {
childViews(): Navigation[] {
return this.views
// filter parent views
.filter(view => !!view.parent)
@ -165,17 +161,12 @@ export default {
watch: {
currentView(view, oldView) {
// If undefined, it means we're initializing the view
// This is handled by the legacy-view:initialized event
// TODO: remove when legacy views are dropped
if (view?.id === oldView?.id) {
return
if (view.id !== oldView?.id) {
this.Navigation.setActive(view)
logger.debug('Navigation changed', { id: view.id, view })
this.showView(view, oldView)
}
this.Navigation.setActive(view)
logger.debug('Navigation changed', { id: view.id, view })
this.showView(view, oldView)
},
},
@ -184,70 +175,22 @@ export default {
logger.debug('Navigation mounted. Showing requested view', { view: this.currentView })
this.showView(this.currentView)
}
subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged)
// TODO: remove this once the legacy navigation is gone
subscribe('files:legacy-view:initialized', () => {
logger.debug('Legacy view initialized', { ...this.currentView })
this.showView(this.currentView)
})
},
methods: {
/**
* @param {Navigation} view the new active view
* @param {Navigation} oldView the old active view
*/
showView(view, oldView) {
showView(view: Navigation) {
// Closing any opened sidebar
window?.OCA?.Files?.Sidebar?.close?.()
if (view?.legacy) {
const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer')
document.querySelectorAll('#app-content .viewcontainer').forEach(el => {
el.classList.add('hidden')
})
newAppContent.classList.remove('hidden')
// Triggering legacy navigation events
const { dir = '/' } = OC.Util.History.parseUrlQuery()
const params = { itemId: view.id, dir }
logger.debug('Triggering legacy navigation event', params)
window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params))
window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params))
}
this.Navigation.setActive(view)
setPageHeading(view.name)
emit('files:navigation:changed', view)
},
/**
* Coming from the legacy files app.
* TODO: remove when all views are migrated.
*
* @param {Navigation} view the new active view
*/
onLegacyNavigationChanged({ id } = { id: 'files' }) {
const view = this.Navigation.views.find(view => view.id === id)
if (view && view.legacy && view.id !== this.currentView.id) {
// Force update the current route as the request comes
// from the legacy files app router
this.$router.replace({ ...this.$route, params: { view: view.id } })
this.Navigation.setActive(view)
this.showView(view)
}
},
/**
* Expand/collapse a a view with children and permanently
* save this setting in the server.
*
* @param {Navigation} view the view to toggle
*/
onToggleExpand(view) {
onToggleExpand(view: Navigation) {
// Invert state
const isExpanded = this.isExpanded(view)
// Update the view expanded state, might not be necessary
@ -258,10 +201,8 @@ export default {
/**
* Check if a view is expanded by user config
* or fallback to the default value.
*
* @param {Navigation} view the view to check
*/
isExpanded(view) {
isExpanded(view: Navigation): boolean {
return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
? this.viewConfigStore.getConfig(view.id).expanded === true
: view.expanded === true
@ -269,10 +210,8 @@ export default {
/**
* Generate the route to a view
*
* @param {Navigation} view the view to toggle
*/
generateToNavigation(view) {
generateToNavigation(view: Navigation) {
if (view.params) {
const { dir, fileid } = view.params
return { name: 'filelist', params: view.params, query: { dir, fileid } }

View file

@ -88,20 +88,22 @@
</NcAppSidebar>
</template>
<script>
import { emit } from '@nextcloud/event-bus'
import { encodePath } from '@nextcloud/paths'
import { File, Folder } from '@nextcloud/files'
import { getCurrentUser } from '@nextcloud/auth'
import { Type as ShareTypes } from '@nextcloud/sharing'
import $ from 'jquery'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
import moment from '@nextcloud/moment'
import { Type as ShareTypes } from '@nextcloud/sharing'
import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import FileInfo from '../services/FileInfo.js'
import SidebarTab from '../components/SidebarTab.vue'
import LegacyView from '../components/LegacyView.vue'
import SidebarTab from '../components/SidebarTab.vue'
import SystemTags from '../../../systemtags/src/components/SystemTags.vue'
export default {
@ -253,7 +255,7 @@ export default {
return {
key: 'error', // force key to re-render
subname: '',
title: '',
name: '',
class: {
'app-sidebar--full': this.isFullScreen,
},
@ -263,7 +265,7 @@ export default {
return {
loading: this.loading,
subname: '',
title: '',
name: '',
class: {
'app-sidebar--full': this.isFullScreen,
},
@ -372,7 +374,13 @@ export default {
*/
setActiveTab(id) {
OCA.Files.Sidebar.setActiveTab(id)
this.tabs.forEach(tab => tab.setIsActive(id === tab.id))
this.tabs.forEach(tab => {
try {
tab.setIsActive(id === tab.id)
} catch (error) {
logger.error('Error while setting tab active state', { error, id: tab.id, tab })
}
})
},
/**
@ -397,12 +405,18 @@ export default {
</d:propertyupdate>`,
})
// TODO: Obliterate as soon as possible and use events with new files app
// Terrible fallback for legacy files: toggle filelist as well
if (OCA.Files && OCA.Files.App && OCA.Files.App.fileList && OCA.Files.App.fileList.fileActions) {
OCA.Files.App.fileList.fileActions.triggerAction('Favorite', OCA.Files.App.fileList.getModelForFile(this.fileInfo.name), OCA.Files.App.fileList)
}
/**
* TODO: adjust this when the Sidebar is finally using File/Folder classes
* @see https://github.com/nextcloud/server/blob/8a75cb6e72acd42712ab9fea22296aa1af863ef5/apps/files/src/views/favorites.ts#L83-L115
*/
const isDir = this.fileInfo.type === 'dir'
const Node = isDir ? Folder : File
emit(state ? 'files:favorites:added' : 'files:favorites:removed', new Node({
fileid: this.fileInfo.id,
source: this.davPath,
root: `/files/${getCurrentUser().uid}`,
mime: isDir ? undefined : this.fileInfo.mimetype,
}))
} catch (error) {
OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file'))
console.error('Unable to change favourite state', error)
@ -437,39 +451,41 @@ export default {
* @throws {Error} loading failure
*/
async open(path) {
if (!path || path.trim() === '') {
throw new Error(`Invalid path '${path}'`)
}
// update current opened file
this.Sidebar.file = path
if (path && path.trim() !== '') {
// reset data, keep old fileInfo to not reload all tabs and just hide them
this.error = null
this.loading = true
// reset data, keep old fileInfo to not reload all tabs and just hide them
this.error = null
this.loading = true
try {
this.fileInfo = await FileInfo(this.davPath)
// adding this as fallback because other apps expect it
this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/')
try {
this.fileInfo = await FileInfo(this.davPath)
// adding this as fallback because other apps expect it
this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/')
// DEPRECATED legacy views
// TODO: remove
this.views.forEach(view => {
view.setFileInfo(this.fileInfo)
})
// DEPRECATED legacy views
// TODO: remove
this.views.forEach(view => {
view.setFileInfo(this.fileInfo)
})
this.$nextTick(() => {
if (this.$refs.tabs) {
this.$refs.tabs.updateTabs()
}
this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id)
})
} catch (error) {
this.error = t('files', 'Error while loading the file data')
console.error('Error while loading the file data', error)
this.$nextTick(() => {
if (this.$refs.tabs) {
this.$refs.tabs.updateTabs()
}
this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id)
})
} catch (error) {
this.error = t('files', 'Error while loading the file data')
console.error('Error while loading the file data', error)
throw new Error(error)
} finally {
this.loading = false
}
throw new Error(error)
} finally {
this.loading = false
}
},

View file

@ -27,7 +27,7 @@ import * as eventBus from '@nextcloud/event-bus'
import { action } from '../actions/favoriteAction'
import * as favoritesService from '../services/Favorites'
import NavigationService from '../services/Navigation'
import { NavigationService } from '../services/Navigation'
import registerFavoritesView from './favorites'
jest.mock('webdav/dist/node/request.js', () => ({

View file

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { Navigation } from '../services/Navigation'
import type NavigationService from '../services/Navigation'
import type { Navigation, NavigationService } from '../services/Navigation'
import { getLanguage, translate as t } from '@nextcloud/l10n'
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
import StarSvg from '@mdi/svg/svg/star.svg?raw'

View file

@ -0,0 +1,41 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { NavigationService, Navigation } from '../services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
import { getContents } from '../services/Files'
export default () => {
const Navigation = window.OCP.Files.Navigation as NavigationService
Navigation.register({
id: 'files',
name: t('files', 'All files'),
caption: t('files', 'List of your files and folders.'),
icon: FolderSvg,
order: 0,
getContents,
} as Navigation)
}

View file

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../services/Navigation'
import type { Navigation } from '../services/Navigation'
import type { NavigationService, Navigation } from '../services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import HistorySvg from '@mdi/svg/svg/history.svg?raw'

View file

@ -1,41 +1,9 @@
<?php /** @var \OCP\IL10N $l */ ?>
<?php $_['appNavigation']->printPage(); ?>
<!-- File navigation -->
<div id="app-navigation-files" role="navigation"></div>
<!-- New files vue container -->
<!-- File list vue container -->
<div id="app-content-vue" class="hidden"></div>
<div id="app-content" tabindex="0">
<input type="checkbox" class="hidden-visually" id="showgridview"
aria-label="<?php p($l->t('Toggle grid view'))?>"
<?php if ($_['showgridview']) { ?>checked="checked" <?php } ?>/>
<label id="view-toggle" for="showgridview" tabindex="0" class="button <?php p($_['showgridview'] ? 'icon-toggle-filelist' : 'icon-toggle-pictures') ?>"
title="<?php p($_['showgridview'] ? $l->t('Show list view') : $l->t('Show grid view'))?>"></label>
<!-- Legacy views -->
<?php foreach ($_['appContents'] as $content) { ?>
<div id="app-content-<?php p($content['id']) ?>" class="hidden viewcontainer">
<?php print_unescaped($content['content']) ?>
</div>
<?php } ?>
<div id="searchresults" class="hidden"></div>
</div><!-- closing app-content -->
<!-- config hints for javascript -->
<input type="hidden" name="filesApp" id="filesApp" value="1" />
<input type="hidden" name="usedSpacePercent" id="usedSpacePercent" value="<?php p($_['usedSpacePercent']); ?>" />
<input type="hidden" name="owner" id="owner" value="<?php p($_['owner']); ?>" />
<input type="hidden" name="ownerDisplayName" id="ownerDisplayName" value="<?php p($_['ownerDisplayName']); ?>" />
<input type="hidden" name="fileNotFound" id="fileNotFound" value="<?php p($_['fileNotFound']); ?>" />
<?php if (!$_['isPublic']) :?>
<input type="hidden" name="allowShareWithLink" id="allowShareWithLink" value="<?php p($_['allowShareWithLink']) ?>" />
<input type="hidden" name="defaultFileSorting" id="defaultFileSorting" value="<?php p($_['defaultFileSorting']) ?>" />
<input type="hidden" name="defaultFileSortingDirection" id="defaultFileSortingDirection" value="<?php p($_['defaultFileSortingDirection']) ?>" />
<input type="hidden" name="showHiddenFiles" id="showHiddenFiles" value="<?php p($_['showHiddenFiles']); ?>" />
<input type="hidden" name="cropImagePreviews" id="cropImagePreviews" value="<?php p($_['cropImagePreviews']); ?>" />
<?php endif;
foreach ($_['hiddenFields'] as $name => $value) {?>
<input type="hidden" name="<?php p($name) ?>" id="<?php p($name) ?>" value="<?php p($value) ?>" />
<?php }

View file

@ -163,74 +163,28 @@ class ViewControllerTest extends TestCase {
[$this->user->getUID(), 'files', 'crop_image_previews', true, true],
[$this->user->getUID(), 'files', 'show_grid', true],
]);
$baseFolderFiles = $this->getMockBuilder(Folder::class)->getMock();
$this->rootFolder->expects($this->any())
->method('getUserFolder')
->with('testuser1')
->willReturn($baseFolderFiles);
$this->config
->expects($this->any())
->method('getAppValue')
->willReturnArgument(2);
$this->shareManager->method('shareApiAllowLinks')
->willReturn(true);
$nav = new Template('files', 'appnavigation');
$nav->assign('navigationItems', [
'files' => [
'id' => 'files',
'appname' => 'files',
'script' => 'list.php',
'order' => 0,
'name' => \OC::$server->getL10N('files')->t('All files'),
'active' => false,
'icon' => '',
'type' => 'link',
'classes' => '',
'expanded' => false,
'unread' => 0,
],
'systemtagsfilter' => [
'id' => 'systemtagsfilter',
'appname' => 'systemtags',
'script' => 'list.php',
'order' => 25,
'name' => \OC::$server->getL10N('systemtags')->t('Tags'),
'active' => false,
'icon' => '',
'type' => 'link',
'classes' => '',
'expanded' => false,
'unread' => 0,
],
]);
$expected = new Http\TemplateResponse(
'files',
'index',
[
'usedSpacePercent' => 123,
'owner' => 'MyName',
'ownerDisplayName' => 'MyDisplayName',
'isPublic' => false,
'defaultFileSorting' => 'basename',
'defaultFileSortingDirection' => 'asc',
'showHiddenFiles' => 0,
'cropImagePreviews' => 1,
'fileNotFound' => 0,
'allowShareWithLink' => 'yes',
'appNavigation' => $nav,
'appContents' => [
'files' => [
'id' => 'files',
'content' => null,
],
'systemtagsfilter' => [
'id' => 'systemtagsfilter',
'content' => null,
],
],
'hiddenFields' => [],
'showgridview' => null
]
);
$policy = new Http\ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain('\'self\'');
$policy->addAllowedFrameDomain('\'self\'');
$expected->setContentSecurityPolicy($policy);
@ -249,100 +203,6 @@ class ViewControllerTest extends TestCase {
$this->assertEquals($expected, $this->viewController->index('MyDir', 'MyView'));
}
public function testShowFileRouteWithFolder() {
$node = $this->getMockBuilder(Folder::class)->getMock();
$node->expects($this->once())
->method('getPath')
->willReturn('/testuser1/files/test/sub');
$baseFolder = $this->getMockBuilder(Folder::class)->getMock();
$this->rootFolder->expects($this->once())
->method('getUserFolder')
->with('testuser1')
->willReturn($baseFolder);
$baseFolder->expects($this->once())
->method('getById')
->with(123)
->willReturn([$node]);
$baseFolder->expects($this->once())
->method('getRelativePath')
->with('/testuser1/files/test/sub')
->willReturn('/test/sub');
$this->urlGenerator
->expects($this->once())
->method('linkToRoute')
->with('files.view.index', ['dir' => '/test/sub'])
->willReturn('/apps/files/?dir=/test/sub');
$expected = new Http\RedirectResponse('/apps/files/?dir=/test/sub');
$this->assertEquals($expected, $this->viewController->index('', '', '123'));
}
public function testShowFileRouteWithFile() {
$parentNode = $this->getMockBuilder(Folder::class)->getMock();
$parentNode->expects($this->once())
->method('getPath')
->willReturn('testuser1/files/test');
$baseFolder = $this->getMockBuilder(Folder::class)->getMock();
$this->rootFolder->expects($this->once())
->method('getUserFolder')
->with('testuser1')
->willReturn($baseFolder);
$node = $this->getMockBuilder(File::class)->getMock();
$node->expects($this->once())
->method('getParent')
->willReturn($parentNode);
$node->expects($this->once())
->method('getName')
->willReturn('somefile.txt');
$baseFolder->expects($this->once())
->method('getById')
->with(123)
->willReturn([$node]);
$baseFolder->expects($this->once())
->method('getRelativePath')
->with('testuser1/files/test')
->willReturn('/test');
$this->urlGenerator
->expects($this->once())
->method('linkToRoute')
->with('files.view.index', ['dir' => '/test', 'scrollto' => 'somefile.txt'])
->willReturn('/apps/files/?dir=/test/sub&scrollto=somefile.txt');
$expected = new Http\RedirectResponse('/apps/files/?dir=/test/sub&scrollto=somefile.txt');
$this->assertEquals($expected, $this->viewController->index('', '', '123'));
}
public function testShowFileRouteWithInvalidFileId() {
$baseFolder = $this->getMockBuilder(Folder::class)->getMock();
$this->rootFolder->expects($this->once())
->method('getUserFolder')
->with('testuser1')
->willReturn($baseFolder);
$baseFolder->expects($this->once())
->method('getById')
->with(123)
->willReturn([]);
$this->urlGenerator->expects($this->once())
->method('linkToRoute')
->with('files.view.index', ['fileNotFound' => true])
->willReturn('redirect.url');
$response = $this->viewController->index('', 'MyView', '123');
$this->assertInstanceOf('OCP\AppFramework\Http\RedirectResponse', $response);
$this->assertEquals('redirect.url', $response->getRedirectURL());
}
public function testShowFileRouteWithTrashedFile() {
$this->appManager->expects($this->once())
->method('isEnabledForUser')
@ -357,7 +217,7 @@ class ViewControllerTest extends TestCase {
$baseFolderFiles = $this->getMockBuilder(Folder::class)->getMock();
$baseFolderTrash = $this->getMockBuilder(Folder::class)->getMock();
$this->rootFolder->expects($this->once())
$this->rootFolder->expects($this->any())
->method('getUserFolder')
->with('testuser1')
->willReturn($baseFolderFiles);
@ -366,7 +226,7 @@ class ViewControllerTest extends TestCase {
->with('testuser1/files_trashbin/files/')
->willReturn($baseFolderTrash);
$baseFolderFiles->expects($this->once())
$baseFolderFiles->expects($this->any())
->method('getById')
->with(123)
->willReturn([]);
@ -375,9 +235,6 @@ class ViewControllerTest extends TestCase {
$node->expects($this->once())
->method('getParent')
->willReturn($parentNode);
$node->expects($this->once())
->method('getName')
->willReturn('somefile.txt');
$baseFolderTrash->expects($this->once())
->method('getById')
@ -391,10 +248,10 @@ class ViewControllerTest extends TestCase {
$this->urlGenerator
->expects($this->once())
->method('linkToRoute')
->with('files.view.index', ['view' => 'trashbin', 'dir' => '/test.d1462861890/sub', 'scrollto' => 'somefile.txt'])
->willReturn('/apps/files/?view=trashbin&dir=/test.d1462861890/sub&scrollto=somefile.txt');
->with('files.view.indexViewFileid', ['view' => 'trashbin', 'dir' => '/test.d1462861890/sub', 'fileid' => '123'])
->willReturn('/apps/files/trashbin/123?dir=/test.d1462861890/sub');
$expected = new Http\RedirectResponse('/apps/files/?view=trashbin&dir=/test.d1462861890/sub&scrollto=somefile.txt');
$expected = new Http\RedirectResponse('/apps/files/trashbin/123?dir=/test.d1462861890/sub');
$this->assertEquals($expected, $this->viewController->index('', '', '123'));
}
}

View file

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../../files/src/services/Navigation'
import type { Navigation } from '../../files/src/services/Navigation'
import type { NavigationService, Navigation } from '../../files/src/services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'

View file

@ -19,14 +19,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { action } from './openInFilesAction'
import type { Navigation } from '../../../files/src/services/Navigation'
import { expect } from '@jest/globals'
import { File, Permission } from '@nextcloud/files'
import { DefaultType, FileAction } from '../../../files/src/services/FileAction'
import * as eventBus from '@nextcloud/event-bus'
import axios from '@nextcloud/axios'
import type { Navigation } from '../../../files/src/services/Navigation'
import '../main'
import { action } from './openInFilesAction'
import { DefaultType, FileAction } from '../../../files/src/services/FileAction'
import { deletedSharesViewId, pendingSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../views/shares'
const view = {
@ -92,6 +92,6 @@ describe('Open in files action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { fileid: 1, dir: '/Foo' })
expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/Foo' })
})
})

View file

@ -43,7 +43,7 @@ export const action = new FileAction({
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files', fileid: node.fileid },
{ dir: node.dirname, fileid: node.fileid },
{ dir: node.dirname },
)
return null
},

View file

@ -25,7 +25,7 @@ import axios from '@nextcloud/axios'
import { type Navigation } from '../../../files/src/services/Navigation'
import { type OCSResponse } from '../services/SharingService'
import NavigationService from '../../../files/src/services/Navigation'
import { NavigationService } from '../../../files/src/services/Navigation'
import registerSharingViews from './shares'
import '../main'

View file

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../../../files/src/services/Navigation'
import type { Navigation } from '../../../files/src/services/Navigation'
import type { NavigationService, Navigation } from '../../../files/src/services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import AccountClockSvg from '@mdi/svg/svg/account-clock.svg?raw'

View file

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../../files/src/services/Navigation'
import type { Navigation } from '../../files/src/services/Navigation'
import type { NavigationService, Navigation } from '../../files/src/services/Navigation'
import { translate as t, translate } from '@nextcloud/l10n'
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'

View file

@ -21,7 +21,7 @@
*/
/* eslint-disable */
import { getCurrentUser } from '@nextcloud/auth'
import { File, Folder, parseWebdavPermissions } from '@nextcloud/files'
import { File, Folder, davParsePermissions } from '@nextcloud/files'
import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import type { FileStat, ResponseDataDetailed } from 'webdav'
@ -43,7 +43,7 @@ const data = `<?xml version="1.0"?>
const resultToNode = function(node: FileStat): File | Folder {
const permissions = parseWebdavPermissions(node.props?.permissions)
const permissions = davParsePermissions(node.props?.permissions)
const owner = getCurrentUser()?.uid as string
const previewUrl = generateUrl('/apps/files_trashbin/preview?fileId={fileid}&x=32&y=32', node.props)

View file

@ -60,6 +60,9 @@ window.addEventListener('DOMContentLoaded', function() {
TabInstance.update(fileInfo)
},
setIsActive(isActive) {
if (!TabInstance) {
return
}
TabInstance.setIsActive(isActive)
},
destroy() {

View file

@ -202,8 +202,8 @@ class UtilTest extends TestCase {
public function dataGetAppImage() {
return [
['core', 'logo/logo.svg', \OC::$SERVERROOT . '/core/img/logo/logo.svg'],
['files', 'external', \OC::$SERVERROOT . '/apps/files/img/external.svg'],
['files', 'external.svg', \OC::$SERVERROOT . '/apps/files/img/external.svg'],
['files', 'folder', \OC::$SERVERROOT . '/apps/files/img/folder.svg'],
['files', 'folder.svg', \OC::$SERVERROOT . '/apps/files/img/folder.svg'],
['noapplikethis', 'foobar.svg', false],
];
}

View file

@ -32,12 +32,14 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\IPreview;
use OCP\IRequest;
use OCP\Preview\IMimeIconProvider;
class PreviewController extends Controller {
public function __construct(
@ -46,6 +48,7 @@ class PreviewController extends Controller {
private IPreview $preview,
private IRootFolder $root,
private ?string $userId,
private IMimeIconProvider $mimeIconProvider,
) {
parent::__construct($appName, $request);
}
@ -62,9 +65,11 @@ class PreviewController extends Controller {
* @param bool $a Whether to not crop the preview
* @param bool $forceIcon Force returning an icon
* @param string $mode How to crop the image
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>
* @param bool $mimeFallback Whether to fallback to the mime icon if no preview is available
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
*
* 200: Preview returned
* 303: Redirect to the mime icon url if mimeFallback is true
* 400: Getting preview is not possible
* 403: Getting preview is not allowed
* 404: Preview not found
@ -75,7 +80,8 @@ class PreviewController extends Controller {
int $y = 32,
bool $a = false,
bool $forceIcon = true,
string $mode = 'fill'): Http\Response {
string $mode = 'fill',
bool $mimeFallback = false): Http\Response {
if ($file === '' || $x === 0 || $y === 0) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
@ -87,7 +93,7 @@ class PreviewController extends Controller {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode);
return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode, $mimeFallback);
}
/**
@ -102,9 +108,11 @@ class PreviewController extends Controller {
* @param bool $a Whether to not crop the preview
* @param bool $forceIcon Force returning an icon
* @param string $mode How to crop the image
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>
* @param bool $mimeFallback Whether to fallback to the mime icon if no preview is available
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
*
* 200: Preview returned
* 303: Redirect to the mime icon url if mimeFallback is true
* 400: Getting preview is not possible
* 403: Getting preview is not allowed
* 404: Preview not found
@ -115,7 +123,8 @@ class PreviewController extends Controller {
int $y = 32,
bool $a = false,
bool $forceIcon = true,
string $mode = 'fill') {
string $mode = 'fill',
bool $mimeFallback = false) {
if ($fileId === -1 || $x === 0 || $y === 0) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
@ -129,11 +138,11 @@ class PreviewController extends Controller {
$node = array_pop($nodes);
return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode);
return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode, $mimeFallback);
}
/**
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
*/
private function fetchPreview(
Node $node,
@ -141,7 +150,8 @@ class PreviewController extends Controller {
int $y,
bool $a,
bool $forceIcon,
string $mode) : Http\Response {
string $mode,
bool $mimeFallback = false) : Http\Response {
if (!($node instanceof File) || (!$forceIcon && !$this->preview->isAvailable($node))) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
@ -167,6 +177,13 @@ class PreviewController extends Controller {
$response->cacheFor(3600 * 24, false, true);
return $response;
} catch (NotFoundException $e) {
// If we have no preview enabled, we can redirect to the mime icon if any
if ($mimeFallback) {
if ($url = $this->mimeIconProvider->getMimeIconUrl($node->getMimeType())) {
return new RedirectResponse($url);
}
}
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);

View file

@ -939,6 +939,15 @@
"type": "string",
"default": "fill"
}
},
{
"name": "mimeFallback",
"in": "query",
"description": "Whether to fallback to the mime icon if no preview is available",
"schema": {
"type": "integer",
"default": 0
}
}
],
"responses": {
@ -976,6 +985,16 @@
"schema": {}
}
}
},
"303": {
"description": "Redirect to the mime icon url if mimeFallback is true",
"headers": {
"Location": {
"schema": {
"type": "string"
}
}
}
}
}
}
@ -1051,6 +1070,15 @@
"type": "string",
"default": "fill"
}
},
{
"name": "mimeFallback",
"in": "query",
"description": "Whether to fallback to the mime icon if no preview is available",
"schema": {
"type": "integer",
"default": 0
}
}
],
"responses": {
@ -1088,6 +1116,16 @@
"schema": {}
}
}
},
"303": {
"description": "Redirect to the mime icon url if mimeFallback is true",
"headers": {
"Location": {
"schema": {
"type": "string"
}
}
}
}
}
}

View file

@ -24,52 +24,53 @@
*/
(function(OC) {
_.extend(OC.Files.Client, {
PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign',
PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name',
PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible',
PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable',
})
/**
* @class OCA.SystemTags.SystemTagsCollection
* @classdesc
*
* System tag
*
*/
const SystemTagModel = OC.Backbone.Model.extend(
/** @lends OCA.SystemTags.SystemTagModel.prototype */ {
sync: OC.Backbone.davSync,
defaults: {
userVisible: true,
userAssignable: true,
canAssign: true,
},
davProperties: {
id: OC.Files.Client.PROPERTY_FILEID,
name: OC.Files.Client.PROPERTY_DISPLAYNAME,
userVisible: OC.Files.Client.PROPERTY_USERVISIBLE,
userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE,
// read-only, effective permissions computed by the server,
canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN,
},
parse(data) {
return {
id: data.id,
name: data.name,
userVisible: data.userVisible === true || data.userVisible === 'true',
userAssignable: data.userAssignable === true || data.userAssignable === 'true',
canAssign: data.canAssign === true || data.canAssign === 'true',
}
},
if (OC?.Files?.Client) {
_.extend(OC.Files.Client, {
PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign',
PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name',
PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible',
PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable',
})
OC.SystemTags = OC.SystemTags || {}
OC.SystemTags.SystemTagModel = SystemTagModel
/**
* @class OCA.SystemTags.SystemTagsCollection
* @classdesc
*
* System tag
*
*/
const SystemTagModel = OC.Backbone.Model.extend(
/** @lends OCA.SystemTags.SystemTagModel.prototype */ {
sync: OC.Backbone.davSync,
defaults: {
userVisible: true,
userAssignable: true,
canAssign: true,
},
davProperties: {
id: OC.Files.Client.PROPERTY_FILEID,
name: OC.Files.Client.PROPERTY_DISPLAYNAME,
userVisible: OC.Files.Client.PROPERTY_USERVISIBLE,
userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE,
// read-only, effective permissions computed by the server,
canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN,
},
parse(data) {
return {
id: data.id,
name: data.name,
userVisible: data.userVisible === true || data.userVisible === 'true',
userAssignable: data.userAssignable === true || data.userAssignable === 'true',
canAssign: data.canAssign === true || data.canAssign === 'true',
}
},
})
OC.SystemTags = OC.SystemTags || {}
OC.SystemTags.SystemTagModel = SystemTagModel
}
})(OC)

View file

@ -32,6 +32,6 @@ describe('Login with a new user and open the files app', function() {
it('See the default file welcome.txt in the files list', function() {
cy.visit('/apps/files')
cy.get('.files-fileList tr').should('contain', 'welcome.txt')
cy.get('[data-cy-files-list] [data-cy-files-list-row-name="welcome.txt"]').should('be.visible')
})
})

View file

@ -36,15 +36,15 @@ export function uploadThreeVersions(user: User, fileName: string) {
}
export function openVersionsPanel(fileName: string) {
cy.get(`[data-file="${fileName}"]`).within(() => {
cy.get('[data-action="menu"]')
.click()
cy.get('.fileActionsMenu')
.get('.action-details')
cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${fileName}"]`).within(() => {
cy.get('[data-cy-files-list-row-actions] .action-item__menutoggle')
.click()
})
cy.get('.action-item__popper')
.get('[data-cy-files-list-row-action="details"]')
.click()
cy.get('#app-sidebar-vue')
.get('[aria-controls="tab-version_vue"]')
.click()

4
dist/1929-1929.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-common.js vendored

File diff suppressed because one or more lines are too long

View file

@ -124,8 +124,12 @@
* Copyright (c) 2016 Jorik Tangelder;
* Licensed under the MIT license */
/*! https://mths.be/punycode v1.4.1 by @mathias */
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/**
* @copyright Copyright (c) 2019 Georg Ehrke
*

File diff suppressed because one or more lines are too long

4
dist/core-login.js vendored

File diff suppressed because one or more lines are too long

View file

@ -1,27 +1,5 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
@ -188,25 +166,3 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

View file

@ -327,28 +327,6 @@
*
*/
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
@ -587,28 +565,6 @@
*
*/
/**
* @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

View file

@ -38,57 +38,8 @@
/*! For license information please see NcInputField.js.LICENSE.txt */
/*! https://mths.be/punycode v1.4.1 by @mathias */
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,49 +1,5 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/**
* @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,50 +23,6 @@
*
*/
/**
* @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more